Skip to content

Commit

Permalink
[node] Add isomorphic functions (#9947)
Browse files Browse the repository at this point in the history
This PR allow using Web APIs in Serverless functions

```js
// api/serverless.js
export const GET = () => {
  return new Response(`new Response('👋 Hello from Serverless Web!)`)
}
```

More about that
https://nextjs.org/docs/app/building-your-application/routing/router-handlers

---------

Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
  • Loading branch information
Kikobeats and EndangeredMassa committed Jun 6, 2023
1 parent 49c7178 commit 0039c8b
Show file tree
Hide file tree
Showing 12 changed files with 226 additions and 53 deletions.
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 });

export function createProxy(client: Client): Server {
return createServer(async (req, res) => {
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/test/dev/integration-1.test.ts
Expand Up @@ -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(
/<strong>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();
}
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 {
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));
}
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();
}
51 changes: 37 additions & 14 deletions packages/node/src/serverless-functions/serverless-handler.mts
Expand Up @@ -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<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
7 changes: 6 additions & 1 deletion packages/node/src/utils.ts
Expand Up @@ -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
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}`);
}

0 comments on commit 0039c8b

Please sign in to comment.