diff --git a/.changeset/young-rats-smoke.md b/.changeset/young-rats-smoke.md new file mode 100644 index 00000000000..f73489e0346 --- /dev/null +++ b/.changeset/young-rats-smoke.md @@ -0,0 +1,6 @@ +--- +"vercel": minor +"@vercel/node": minor +--- + +[node] Add isomorphic functions diff --git a/packages/cli/src/util/extension/proxy.ts b/packages/cli/src/util/extension/proxy.ts index dc9c6fcec7d..fe71fc2f29c 100644 --- a/packages/cli/src/util/extension/proxy.ts +++ b/packages/cli/src/util/extension/proxy.ts @@ -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 }); export function createProxy(client: Client): Server { return createServer(async (req, res) => { diff --git a/packages/cli/test/dev/integration-1.test.ts b/packages/cli/test/dev/integration-1.test.ts index 4db49983596..319ad9850c5 100644 --- a/packages/cli/test/dev/integration-1.test.ts +++ b/packages/cli/test/dev/integration-1.test.ts @@ -322,14 +322,17 @@ 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( /500<\/strong>: INTERNAL_SERVER_ERROR/g ); - expect(stderr).toMatch( - /No default export was found. Add a default export to handle requests./g - ); + const url = `http://localhost:${port}/api/edge-error-no-handler`; + expect(stdout).toMatchInlineSnapshot(` + "Error from API Route /api/edge-error-no-handler: No default or HTTP-named export was found at ${url}. Add one to handle requests. Learn more: https://vercel.link/creating-edge-middleware + at (api/edge-error-no-handler.js) + " + `); } finally { await dev.kill(); } diff --git a/packages/node/package.json b/packages/node/package.json index 9f3afe93113..7bf399356c0 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -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", diff --git a/packages/node/src/edge-functions/edge-handler-template.js b/packages/node/src/edge-functions/edge-handler-template.js index 33348e0ecf1..5bf4da40144 100644 --- a/packages/node/src/edge-functions/edge-handler-template.js +++ b/packages/node/src/edge-functions/edge-handler-template.js @@ -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 @@ -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 { - 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 or HTTP-named export was found at ${url}. Add one to handle requests. Learn more: https://vercel.link/creating-edge-middleware` + ); + } + const response = await respond(handler, event, options, dependencies); + event.respondWith(response); } catch (error) { event.respondWith(toResponseError(error, dependencies.Response)); } diff --git a/packages/node/src/edge-functions/edge-handler.mts b/packages/node/src/edge-functions/edge-handler.mts index 2bcf431fead..af004aca668 100644 --- a/packages/node/src/edge-functions/edge-handler.mts +++ b/packages/node/src/edge-functions/edge-handler.mts @@ -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}; @@ -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 { diff --git a/packages/node/src/serverless-functions/helpers-web.ts b/packages/node/src/serverless-functions/helpers-web.ts new file mode 100644 index 00000000000..edf1ccbbddb --- /dev/null +++ b/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>; + 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(); +} diff --git a/packages/node/src/serverless-functions/serverless-handler.mts b/packages/node/src/serverless-functions/serverless-handler.mts index 3bb9edb984f..d48d3aa5e63 100644 --- a/packages/node/src/serverless-functions/serverless-handler.mts +++ b/packages/node/src/serverless-functions/serverless-handler.mts @@ -21,40 +21,63 @@ 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 */ +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 required to use HTTP method exports in your functions.' + ); + } + 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> { - 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); diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts index 7a7a3c91c0b..2fb3b5c026e 100644 --- a/packages/node/src/utils.ts +++ b/packages/node/src/utils.ts @@ -59,7 +59,12 @@ export function entrypointToOutputPath( } export function logError(error: Error) { - console.error(error.message); + let message = error.message; + if (!message.startsWith('Error:')) { + message = `Error: ${message}`; + } + console.error(message); + if (error.stack) { // only show the stack trace if debug is enabled // because it points to internals, not user code diff --git a/packages/node/test/dev-fixtures/serverless-web.js b/packages/node/test/dev-fixtures/serverless-web.js new file mode 100644 index 00000000000..b88c0a61c8a --- /dev/null +++ b/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}`); +} diff --git a/packages/node/test/unit/dev.test.ts b/packages/node/test/unit/dev.test.ts index b5de91cda1c..0f88af44461 100644 --- a/packages/node/test/unit/dev.test.ts +++ b/packages/node/test/unit/dev.test.ts @@ -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'; @@ -24,6 +26,41 @@ function testForkDevServer(entrypoint: string) { }); } +(NODE_MAJOR < 18 ? test.skip : test)( + '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 { @@ -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(), @@ -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), @@ -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), @@ -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(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e98b3868e6..e7978eca13e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1189,6 +1189,12 @@ importers: packages/node: dependencies: + '@edge-runtime/node-utils': + specifier: 2.0.3 + version: 2.0.3 + '@edge-runtime/primitives': + specifier: 2.1.2 + version: 2.1.2 '@edge-runtime/vm': specifier: 2.0.0 version: 2.0.0 @@ -3152,7 +3158,6 @@ packages: /@edge-runtime/node-utils@2.0.3: resolution: {integrity: sha512-JUSbi5xu/A8+D2t9B9wfirCI1J8n8q0660FfmqZgA+n3RqxD3y7SnamL1sKRE5/AbHsKs9zcqCbK2YDklbc9Bg==} engines: {node: '>=14'} - dev: true /@edge-runtime/primitives@2.0.0: resolution: {integrity: sha512-AXqUq1zruTJAICrllUvZcgciIcEGHdF6KJ3r6FM0n4k8LpFxZ62tPWVIJ9HKm+xt+ncTBUZxwgUaQ73QMUQEKw==}