Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[node] Add isomorphic functions #9947

Merged
merged 16 commits into from Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/young-rats-smoke.md
@@ -0,0 +1,6 @@
---
"vercel": minor
"@vercel/node": minor
---

[node] Add isomorphic functions
5 changes: 1 addition & 4 deletions packages/cli/src/util/extension/proxy.ts
Expand Up @@ -8,10 +8,7 @@ import {
import type { Server } from 'http';
import type Client from '../client';

const toHeaders = buildToHeaders({
// @ts-expect-error - `node-fetch` Headers is missing `getAll()`
Headers,
});
const toHeaders = buildToHeaders({ Headers });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand how this TS error was fixed :thinkies:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upgrading @edge-runtime/node-utils fixed the issue I think because of this PR: vercel/edge-runtime#309


export function createProxy(client: Client): Server {
return createServer(async (req, res) => {
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/test/dev/integration-1.test.ts
Expand Up @@ -322,14 +322,16 @@ test('[vercel dev] should handle missing handler errors thrown in edge functions
);
validateResponseHeaders(res);

const { stderr } = await dev.kill();
const { stdout } = await dev.kill();

expect(await res.text()).toMatch(
/<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g
);
expect(stderr).toMatch(
/No default export was found. Add a default export to handle requests./g
);
expect(stdout).toMatchInlineSnapshot(`
"Error from API Route /api/edge-error-no-handler: No default export was found at http://localhost:${port}/api/edge-error-no-handler. Add a default export to handle requests. Learn more: https://vercel.link/creating-edge-middleware
at (api/edge-error-no-handler.js)
"
`);
} finally {
await dev.kill();
}
Expand Down
2 changes: 2 additions & 0 deletions packages/node/package.json
Expand Up @@ -19,6 +19,8 @@
"dist"
],
"dependencies": {
"@edge-runtime/node-utils": "2.0.3",
"@edge-runtime/primitives": "2.1.2",
"@edge-runtime/vm": "2.0.0",
"@types/node": "14.18.33",
"@types/node-fetch": "2.6.3",
Expand Down
38 changes: 28 additions & 10 deletions packages/node/src/edge-functions/edge-handler-template.js
Expand Up @@ -10,14 +10,14 @@ function getUrl(url, headers) {
return urlObj.toString();
}

async function respond(userEdgeHandler, event, options, dependencies) {
async function respond(handler, event, options, dependencies) {
const { Request, Response } = dependencies;
const { isMiddleware } = options;
event.request.headers.set(
'host',
event.request.headers.get('x-forwarded-host')
);
let response = await userEdgeHandler(
let response = await handler(
new Request(
getUrl(event.request.url, event.request.headers),
event.request
Expand Down Expand Up @@ -62,16 +62,34 @@ async function parseRequestEvent(event) {

// This will be invoked by logic using this template
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function registerFetchListener(userEdgeHandler, options, dependencies) {
function registerFetchListener(module, options, dependencies) {
let handler;

addEventListener('fetch', async event => {
try {
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
const response = await respond(
userEdgeHandler,
event,
options,
dependencies
);
return event.respondWith(response);
if (typeof module.default === 'function') {
handler = module.default;
} else {
if (
['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'PATCH'].some(
method => typeof module[method] === 'function'
)
) {
const method = event.request.method ?? 'GET';
handler =
typeof module[method] === 'function'
? module[method]
: () => new dependencies.Response(null, { status: 405 });
}
}
if (!handler) {
const url = getUrl(event.request.url, event.request.headers);
throw new Error(
`No default export was found at ${url}. Add a default export to handle requests. Learn more: https://vercel.link/creating-edge-middleware`
EndangeredMassa marked this conversation as resolved.
Show resolved Hide resolved
);
}
const response = await respond(handler, event, options, dependencies);
event.respondWith(response);
} catch (error) {
event.respondWith(toResponseError(error, dependencies.Response));
}
Expand Down
9 changes: 2 additions & 7 deletions packages/node/src/edge-functions/edge-handler.mts
Expand Up @@ -89,12 +89,7 @@ async function compileUserCode(

// user code
${compiledFile.text};
const userEdgeHandler = module.exports.default;
if (!userEdgeHandler) {
throw new Error(
'No default export was found. Add a default export to handle requests. Learn more: https://vercel.link/creating-edge-middleware'
);
}
const userModule = module.exports;

// request metadata
const isMiddleware = ${isMiddleware};
Expand All @@ -104,7 +99,7 @@ async function compileUserCode(
${edgeHandlerTemplate};
const dependencies = { Request, Response };
const options = { isMiddleware, entrypointLabel };
registerFetchListener(userEdgeHandler, options, dependencies);
registerFetchListener(userModule, options, dependencies);
`;

return {
Expand Down
76 changes: 76 additions & 0 deletions packages/node/src/serverless-functions/helpers-web.ts
@@ -0,0 +1,76 @@
import type { ServerResponse, IncomingMessage } from 'http';
import type { NodeHandler } from '@edge-runtime/node-utils';
import { buildToNodeHandler } from '@edge-runtime/node-utils';

class FetchEvent {
public request: Request;
public awaiting: Set<Promise<void>>;
public response: Response | null;

constructor(request: Request) {
this.request = request;
this.response = null;
this.awaiting = new Set();
}

respondWith(response: Response) {
this.response = response;
}

waitUntil() {
throw new Error('waitUntil is not implemented yet for Node.js');
}
}

const webHandlerToNodeHandler = buildToNodeHandler(
{
Headers,
ReadableStream,
Request: class extends Request {
constructor(input: RequestInfo | URL, init?: RequestInit | undefined) {
super(input, addDuplexToInit(init));
}
},
Uint8Array: Uint8Array,
FetchEvent: FetchEvent,
},
{ defaultOrigin: 'https://vercel.com' }
);

/**
* When users export at least one HTTP handler, we will generate
* a generic handler routing to the right method. If there is no
* handler function exported returns null.
*/
export function getWebExportsHandler(listener: any, methods: string[]) {
const handlerByMethod: { [key: string]: NodeHandler } = {};

for (const key of methods) {
handlerByMethod[key] =
typeof listener[key] !== 'undefined'
? webHandlerToNodeHandler(listener[key])
: defaultHttpHandler;
}

return (req: IncomingMessage, res: ServerResponse) => {
const method = req.method ?? 'GET';
handlerByMethod[method](req, res);
};
}

/**
* Add `duplex: 'half'` by default to all requests
* https://github.com/vercel/edge-runtime/blob/bf167c418247a79d3941bfce4a5d43c37f512502/packages/primitives/src/primitives/fetch.js#L22-L26
* https://developer.chrome.com/articles/fetch-streaming-requests/#streaming-request-bodies
*/
function addDuplexToInit(init: RequestInit | undefined) {
if (typeof init === 'undefined' || typeof init === 'object') {
return { duplex: 'half', ...init };
}
return init;
}

function defaultHttpHandler(_: IncomingMessage, res: ServerResponse) {
res.statusCode = 405;
res.end();
}
47 changes: 33 additions & 14 deletions packages/node/src/serverless-functions/serverless-handler.mts
Expand Up @@ -21,40 +21,59 @@ type ServerlessFunctionSignature = (
res: ServerResponse | VercelResponse
) => void;

async function createServerlessServer(
userCode: ServerlessFunctionSignature,
options: ServerlessServerOptions
) {
const server = createServer(async (req, res) => {
if (options.shouldAddHelpers) await addHelpers(req, res);
return userCode(req, res);
});
const [NODE_MAJOR] = process.versions.node.split('.').map(v => Number(v));

/* https://nextjs.org/docs/app/building-your-application/routing/router-handlers#supported-http-methods */
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
const HTTP_METHODS = [
'GET',
'HEAD',
'OPTIONS',
'POST',
'PUT',
'DELETE',
'PATCH',
];

async function createServerlessServer(userCode: ServerlessFunctionSignature) {
const server = createServer(userCode);
exitHook(() => server.close());
return { url: await listen(server) };
}

async function compileUserCode(entrypointPath: string) {
async function compileUserCode(
entrypointPath: string,
options: ServerlessServerOptions
) {
const id = isAbsolute(entrypointPath)
? pathToFileURL(entrypointPath).href
: entrypointPath;
let fn = await import(id);
let listener = await import(id);

/**
* In some cases we might have nested default props due to TS => JS
*/
for (let i = 0; i < 5; i++) {
if (fn.default) fn = fn.default;
if (listener.default) listener = listener.default;
}

if (HTTP_METHODS.some(method => typeof listener[method] === 'function')) {
if (NODE_MAJOR < 18) throw new Error('Node.js v18 or above is needed');
EndangeredMassa marked this conversation as resolved.
Show resolved Hide resolved
const { getWebExportsHandler } = await import('./helpers-web.js');
return getWebExportsHandler(listener, HTTP_METHODS);
}

return fn;
return async (req: IncomingMessage, res: ServerResponse) => {
if (options.shouldAddHelpers) await addHelpers(req, res);
return listener(req, res);
};
}

export async function createServerlessEventHandler(
entrypointPath: string,
options: ServerlessServerOptions
): Promise<(request: IncomingMessage) => Promise<VercelProxyResponse>> {
const userCode = await compileUserCode(entrypointPath);
const server = await createServerlessServer(userCode, options);
const userCode = await compileUserCode(entrypointPath, options);
const server = await createServerlessServer(userCode);

return async function (request: IncomingMessage) {
const url = new URL(request.url ?? '/', server.url);
Expand Down
10 changes: 10 additions & 0 deletions packages/node/test/dev-fixtures/serverless-web.js
@@ -0,0 +1,10 @@
/* global Response */

const baseUrl = ({ headers }) =>
`${headers.get('x-forwarded-proto')}://${headers.get('x-forwarded-host')}`;

export function GET(request) {
const { searchParams } = new URL(request.url, baseUrl(request));
const name = searchParams.get('name');
return new Response(`Greetings, ${name}`);
}
57 changes: 45 additions & 12 deletions packages/node/test/unit/dev.test.ts
Expand Up @@ -4,6 +4,8 @@ import fetch from 'node-fetch';

jest.setTimeout(20 * 1000);

const [NODE_MAJOR] = process.versions.node.split('.').map(v => Number(v));

function testForkDevServer(entrypoint: string) {
const ext = extname(entrypoint);
const isTypeScript = ext === '.ts';
Expand All @@ -24,6 +26,41 @@ function testForkDevServer(entrypoint: string) {
});
}

(NODE_MAJOR < 18 ? test.skip : test)(
EndangeredMassa marked this conversation as resolved.
Show resolved Hide resolved
'runs an serverless function that exports GET',
async () => {
const child = testForkDevServer('./serverless-web.js');
try {
const result = await readMessage(child);
if (result.state !== 'message') {
throw new Error('Exited. error: ' + JSON.stringify(result.value));
}

const { address, port } = result.value;

{
const response = await fetch(
`http://${address}:${port}/api/serverless-web?name=Vercel`
);
expect({
status: response.status,
body: await response.text(),
}).toEqual({ status: 200, body: 'Greetings, Vercel' });
}

{
const response = await fetch(
`http://${address}:${port}/api/serverless-web?name=Vercel`,
{ method: 'HEAD' }
);
expect({ status: response.status }).toEqual({ status: 405 });
}
} finally {
child.kill(9);
}
}
);

test('runs an edge function that uses `WebSocket`', async () => {
const child = testForkDevServer('./edge-websocket.js');
try {
Expand Down Expand Up @@ -57,9 +94,8 @@ test('runs an edge function that uses `buffer`', async () => {
throw new Error('Exited. error: ' + JSON.stringify(result.value));
}

const response = await fetch(
`http://localhost:${result.value.port}/api/edge-buffer`
);
const { address, port } = result.value;
const response = await fetch(`http://${address}:${port}/api/edge-buffer`);
expect({
status: response.status,
json: await response.json(),
Expand All @@ -84,9 +120,8 @@ test('runs a mjs endpoint', async () => {
throw new Error('Exited. error: ' + JSON.stringify(result.value));
}

const response = await fetch(
`http://localhost:${result.value.port}/api/hello`
);
const { address, port } = result.value;
const response = await fetch(`http://${address}:${port}/api/hello`);
expect({
status: response.status,
headers: Object.fromEntries(response.headers),
Expand Down Expand Up @@ -117,9 +152,8 @@ test('runs a esm typescript endpoint', async () => {
throw new Error('Exited. error: ' + JSON.stringify(result.value));
}

const response = await fetch(
`http://localhost:${result.value.port}/api/hello`
);
const { address, port } = result.value;
const response = await fetch(`http://${address}:${port}/api/hello`);
expect({
status: response.status,
headers: Object.fromEntries(response.headers),
Expand Down Expand Up @@ -150,9 +184,8 @@ test('allow setting multiple cookies with same name', async () => {
throw new Error(`Exited. error: ${JSON.stringify(result.value)}`);
}

const response = await fetch(
`http://localhost:${result.value.port}/api/hello`
);
const { address, port } = result.value;
const response = await fetch(`http://${address}:${port}/api/hello`);
expect({
status: response.status,
text: await response.text(),
Expand Down