Skip to content

Commit

Permalink
[node] support node:[lib] in vc dev (#9694)
Browse files Browse the repository at this point in the history
- [node] add edge-node-compat-plugin
- [node] implement edge-handler
  • Loading branch information
Schniz committed Mar 23, 2023
1 parent 4c9ca27 commit 40f73e7
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 3 deletions.
29 changes: 26 additions & 3 deletions packages/node/src/edge-functions/edge-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import fetch from 'node-fetch';
import { createEdgeWasmPlugin, WasmAssets } from './edge-wasm-plugin';
import { entrypointToOutputPath, logError } from '../utils';
import { readFileSync } from 'fs';
import {
createNodeCompatPlugin,
NodeCompatBindings,
} from './edge-node-compat-plugin';

const NODE_VERSION_MAJOR = process.version.match(/^v(\d+)\.\d+/)?.[1];
const NODE_VERSION_IDENTIFIER = `node${NODE_VERSION_MAJOR}`;
Expand Down Expand Up @@ -37,8 +41,17 @@ async function compileUserCode(
entrypointFullPath: string,
entrypointRelativePath: string,
isMiddleware: boolean
): Promise<undefined | { userCode: string; wasmAssets: WasmAssets }> {
): 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
Expand All @@ -50,7 +63,7 @@ async function compileUserCode(
sourcemap: 'inline',
legalComments: 'none',
bundle: true,
plugins: [edgeWasmPlugin],
plugins: [edgeWasmPlugin, nodeCompatPlugin.plugin],
entryPoints: [entrypointFullPath],
write: false, // operate in memory
format: 'cjs',
Expand Down Expand Up @@ -93,7 +106,11 @@ async function compileUserCode(
registerFetchListener(userEdgeHandler, options, dependencies);
`;

return { userCode, wasmAssets };
return {
userCode,
wasmAssets,
nodeCompatBindings: nodeCompatPlugin.bindings,
};
} catch (error) {
// We can't easily show a meaningful stack trace from ncc -> edge-runtime.
// So, stick with just the message for now.
Expand All @@ -106,13 +123,16 @@ async function compileUserCode(
async function createEdgeRuntime(params?: {
userCode: string;
wasmAssets: WasmAssets;
nodeCompatBindings: NodeCompatBindings;
}) {
try {
if (!params) {
return undefined;
}

const wasmBindings = await params.wasmAssets.getContext();
const nodeCompatBindings = params.nodeCompatBindings.getContext();

const edgeRuntime = new EdgeRuntime({
initialCode: params.userCode,
extend: (context: EdgeContext) => {
Expand All @@ -127,6 +147,9 @@ async function createEdgeRuntime(params?: {
env: process.env,
},

// These are the global bindings for Node.js compatibility
...nodeCompatBindings,

// These are the global bindings for WebAssembly module
...wasmBindings,
});
Expand Down
162 changes: 162 additions & 0 deletions packages/node/src/edge-functions/edge-node-compat-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import BufferImplementation from 'buffer';
import EventsImplementation from 'events';
import AsyncHooksImplementation from 'async_hooks';
import AssertImplementation from 'assert';
import UtilImplementation from 'util';
import type { Plugin } from 'esbuild';

const SUPPORTED_NODE_MODULES = [
'buffer',
'events',
'assert',
'util',
'async_hooks',
] as const;

const getSupportedNodeModuleRegex = () =>
new RegExp(`^(?:node:)?(?:${SUPPORTED_NODE_MODULES.join('|')})$`);

function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const res: Partial<Pick<T, K>> = {};
for (const key of keys) {
res[key] = obj[key];
}
return res as Pick<T, K>;
}

const NativeModuleMap = () => {
const mods: Record<typeof SUPPORTED_NODE_MODULES[number], unknown> = {
buffer: pick(BufferImplementation, [
'constants',
'kMaxLength',
'kStringMaxLength',
'Buffer',
'SlowBuffer',
]),
events: pick(EventsImplementation, [
'EventEmitter',
'captureRejectionSymbol',
'defaultMaxListeners',
'errorMonitor',
'listenerCount',
'on',
'once',
]),
async_hooks: pick(AsyncHooksImplementation, [
'AsyncLocalStorage',
'AsyncResource',
]),
assert: pick(AssertImplementation, [
'AssertionError',
'deepEqual',
'deepStrictEqual',
'doesNotMatch',
'doesNotReject',
'doesNotThrow',
'equal',
'fail',
'ifError',
'match',
'notDeepEqual',
'notDeepStrictEqual',
'notEqual',
'notStrictEqual',
'ok',
'rejects',
'strict',
'strictEqual',
'throws',
]),
util: pick(UtilImplementation, [
'_extend' as any,
'callbackify',
'format',
'inherits',
'promisify',
'types',
]),
};
return new Map(Object.entries(mods));
};

const NODE_COMPAT_NAMESPACE = 'vercel-node-compat';

export class NodeCompatBindings {
private bindings = new Map<
string,
{
name: string;
modulePath: string;
value: unknown;
}
>();

use(modulePath: `node:${string}`) {
const stripped = modulePath.replace(/^node:/, '');
const name = `__vc_node_${stripped}__`;
if (!this.bindings.has(modulePath)) {
const value = NativeModuleMap().get(stripped);
if (value === undefined) {
throw new Error(`Could not find module ${modulePath}`);
}
this.bindings.set(modulePath, {
modulePath: modulePath,
name,
value,
});
}
return name;
}

getContext(): Record<string, unknown> {
const context: Record<string, unknown> = {};
for (const binding of this.bindings.values()) {
context[binding.name] = binding.value;
}
return context;
}
}

/**
* Allows to enable Node.js compatibility by detecting namespaced `node:`
* imports and producing metadata to bind global variables for each.
* It requires from the consumer to add the imports.
*/
export function createNodeCompatPlugin() {
const bindings = new NodeCompatBindings();
const plugin: Plugin = {
name: 'vc-node-compat',
setup(b) {
b.onResolve({ filter: getSupportedNodeModuleRegex() }, async args => {
const importee = args.path.replace('node:', '');
if (!SUPPORTED_NODE_MODULES.includes(importee as any)) {
return;
}

return {
namespace: NODE_COMPAT_NAMESPACE,
path: args.path,
};
});

b.onLoad(
{ filter: /.+/, namespace: NODE_COMPAT_NAMESPACE },
async args => {
const fullName = args.path.startsWith('node:')
? (args.path as `node:${string}`)
: (`node:${args.path}` as const);
const globalName = bindings.use(fullName);

return {
contents: `module.exports = ${globalName};`,
loader: 'js',
};
}
);
},
};
return {
plugin,
bindings,
};
}
16 changes: 16 additions & 0 deletions packages/node/test/dev-fixtures/edge-buffer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* global Response */

import B from 'node:buffer';
import { Buffer } from 'buffer';

export const config = { runtime: 'edge' };

export default async () => {
const encoded = Buffer.from('Hello, world!').toString('base64');
return new Response(
JSON.stringify({
encoded,
'Buffer === B.Buffer': Buffer === B.Buffer,
})
);
};
27 changes: 27 additions & 0 deletions packages/node/test/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ function testForkDevServer(entrypoint: string) {
});
}

test('runs an edge function that uses `buffer`', async () => {
const child = testForkDevServer('./edge-buffer.js');

try {
const result = await readMessage(child);
if (result.state !== 'message') {
throw new Error('Exited. error: ' + JSON.stringify(result.value));
}

const response = await fetch(
`http://localhost:${result.value.port}/api/edge-buffer`
);
expect({
status: response.status,
json: await response.json(),
}).toEqual({
status: 200,
json: {
encoded: Buffer.from('Hello, world!').toString('base64'),
'Buffer === B.Buffer': true,
},
});
} finally {
child.kill(9);
}
});

test('runs a mjs endpoint', async () => {
const child = testForkDevServer('./esm-module.mjs');

Expand Down

0 comments on commit 40f73e7

Please sign in to comment.