Files
vercel/packages/node/src/edge-functions/edge-handler.ts
Kiko Beats 6dded87426 [node] Add streaming support for vc dev (#9745)
Until now, the user code response it's buffered and serialized. This is
mismatching how Vercel works these days.

This PR enables streaming response in `vc dev` for Edge/Serverless.

As part of the implementation, the `node-bridge` which spawns a process
to consume the user code is not necessary anymore.

Some necessary files (like HTTP server helpers) have been moved to live
in node builder package instead.

---------

Co-authored-by: Ethan Arrowood <ethan.arrowood@vercel.com>
Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
2023-04-19 23:56:41 +02:00

245 lines
7.5 KiB
TypeScript

import { createEdgeWasmPlugin, WasmAssets } from './edge-wasm-plugin';
import {
createNodeCompatPlugin,
NodeCompatBindings,
} from './edge-node-compat-plugin';
import { EdgeRuntime, runServer } from 'edge-runtime';
import fetch, { Headers } from 'node-fetch';
import { isError } from '@vercel/error-utils';
import { readFileSync } from 'fs';
import { serializeBody, entrypointToOutputPath, logError } from '../utils';
import esbuild from 'esbuild';
import exitHook from 'exit-hook';
import type { HeadersInit } from 'node-fetch';
import type { VercelProxyResponse } from '../types';
import type { IncomingMessage } from 'http';
import { pathToFileURL } from 'url';
const NODE_VERSION_MAJOR = process.version.match(/^v(\d+)\.\d+/)?.[1];
const NODE_VERSION_IDENTIFIER = `node${NODE_VERSION_MAJOR}`;
if (!NODE_VERSION_MAJOR) {
throw new Error(
`Unable to determine current node version: process.version=${process.version}`
);
}
const edgeHandlerTemplate = readFileSync(
`${__dirname}/edge-handler-template.js`
);
async function compileUserCode(
entrypointFullPath: string,
entrypointRelativePath: string,
isMiddleware: boolean
): Promise<
| undefined
| {
userCode: string;
wasmAssets: WasmAssets;
nodeCompatBindings: NodeCompatBindings;
}
> {
const { wasmAssets, plugin: edgeWasmPlugin } = createEdgeWasmPlugin();
const nodeCompatPlugin = createNodeCompatPlugin();
try {
const result = await esbuild.build({
// bundling behavior: use globals (like "browser") instead
// of "require" statements for core libraries (like "node")
platform: 'browser',
// target syntax: only use syntax available on the current
// version of node
target: NODE_VERSION_IDENTIFIER,
sourcemap: 'inline',
legalComments: 'none',
bundle: true,
plugins: [
edgeWasmPlugin,
nodeCompatPlugin.plugin,
{
name: 'import.meta.url',
setup({ onLoad }) {
onLoad({ filter: /\.[cm]?js$/, namespace: 'file' }, args => {
let code = readFileSync(args.path, 'utf8');
code = code.replace(
/\bimport\.meta\.url\b/g,
JSON.stringify(pathToFileURL(__filename))
);
return { contents: code };
});
},
},
],
entryPoints: [entrypointFullPath],
write: false, // operate in memory
format: 'cjs',
});
const compiledFile = result.outputFiles?.[0];
if (!compiledFile) {
throw new Error(
`Compilation of ${entrypointRelativePath} produced no output files.`
);
}
const userCode = `
// strict mode
"use strict";var regeneratorRuntime;
// 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'
);
}
// request metadata
const isMiddleware = ${isMiddleware};
const entrypointLabel = '${entrypointRelativePath}';
// edge handler
${edgeHandlerTemplate};
const dependencies = { Request, Response };
const options = { isMiddleware, entrypointLabel };
registerFetchListener(userEdgeHandler, options, dependencies);
`;
return {
userCode,
wasmAssets,
nodeCompatBindings: nodeCompatPlugin.bindings,
};
} catch (error: unknown) {
// We can't easily show a meaningful stack trace from ncc -> edge-runtime.
// So, stick with just the message for now.
console.error(`Failed to compile user code for edge runtime.`);
if (isError(error)) logError(error);
return undefined;
}
}
async function createEdgeRuntimeServer(params?: {
userCode: string;
wasmAssets: WasmAssets;
nodeCompatBindings: NodeCompatBindings;
}) {
try {
if (!params) {
return undefined;
}
const wasmBindings = await params.wasmAssets.getContext();
const nodeCompatBindings = params.nodeCompatBindings.getContext();
const runtime = new EdgeRuntime({
initialCode: params.userCode,
extend: context => {
Object.assign(context, {
// This is required for esbuild wrapping logic to resolve
module: {},
// This is required for environment variable access.
// In production, env var access is provided by static analysis
// so that only the used values are available.
process: {
env: process.env,
},
// These are the global bindings for Node.js compatibility
...nodeCompatBindings,
// These are the global bindings for WebAssembly module
...wasmBindings,
});
return context;
},
});
const server = await runServer({ runtime });
exitHook(() => server.close());
return server;
} catch (error: any) {
// We can't easily show a meaningful stack trace from ncc -> edge-runtime.
// So, stick with just the message for now.
console.error('Failed to instantiate edge runtime.');
logError(error);
return undefined;
}
}
export async function createEdgeEventHandler(
entrypointFullPath: string,
entrypointRelativePath: string,
isMiddleware: boolean,
isZeroConfig?: boolean
): Promise<(request: IncomingMessage) => Promise<VercelProxyResponse>> {
const userCode = await compileUserCode(
entrypointFullPath,
entrypointRelativePath,
isMiddleware
);
const server = await createEdgeRuntimeServer(userCode);
return async function (request: IncomingMessage) {
if (!server) {
// this error state is already logged, but we have to wait until here to exit the process
// this matches the serverless function bridge launcher's behavior when
// an error is thrown in the function
process.exit(1);
}
const headers = new Headers(request.headers as HeadersInit);
const body: Buffer | string | undefined = await serializeBody(request);
if (body !== undefined) headers.set('content-length', String(body.length));
const url = new URL(request.url ?? '/', server.url);
const response = await fetch(url, {
body,
headers,
method: request.method,
redirect: 'manual',
});
const isUserError =
response.headers.get('x-vercel-failed') === 'edge-wrapper';
if (isUserError && response.status >= 500) {
const body = await response.text();
// We can't currently get a real stack trace from the Edge Function error,
// but we can fake a basic one that is still usefult to the user.
const fakeStackTrace = ` at (${entrypointRelativePath})`;
const requestPath = entrypointToRequestPath(
entrypointRelativePath,
isZeroConfig
);
console.log(
`Error from API Route ${requestPath}: ${body}\n${fakeStackTrace}`
);
// this matches the serverless function bridge launcher's behavior when
// an error is thrown in the function
process.exit(1);
}
return {
status: response.status,
headers: response.headers,
body: response.body,
encoding: 'utf8',
};
};
}
function entrypointToRequestPath(
entrypointRelativePath: string,
isZeroConfig?: boolean
) {
// ensure the path starts with a slash to match conventions used elsewhere,
// notably when rendering serverless function paths in error messages
return '/' + entrypointToOutputPath(entrypointRelativePath, isZeroConfig);
}