diff --git a/src/lib/functions/runtimes/js/index.ts b/src/lib/functions/runtimes/js/index.ts index 5b4c811909b..611b80215bf 100644 --- a/src/lib/functions/runtimes/js/index.ts +++ b/src/lib/functions/runtimes/js/index.ts @@ -84,7 +84,30 @@ export const invokeFunction = async ({ timeoutMs: timeout * SECONDS_TO_MILLISECONDS, } - const worker = new Worker(workerURL, { workerData }) + const worker = new Worker(workerURL, { + env: { + ...process.env, + // AWS Lambda disables these Node.js experimental features, even in Node.js versions where they are enabled by + // default: https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html#w292aac41c19. + // They also allow users to re-enable (i.e. not disable) these by co-opting the positive flag (which in reality + // may or may not exist depending on the exact node.js version). We replicate all this behavior here. + NODE_OPTIONS: [ + ...(process.env.NODE_OPTIONS?.split(' ') ?? []), + ...[ + ...(process.env.NODE_OPTIONS?.includes('--experimental-require-module') + ? [] + : ['--no-experimental-require-module']), + ...(process.env.NODE_OPTIONS?.includes('--experimental-detect-module') + ? [] + : ['--no-experimental-detect-module']), + ] + // Unfortunately Node.js throws if `NODE_OPTIONS` contains any unsupported flags and these flags have been + // added and removed in various specific versions in each major line. Luckily Node.js has an API just for this! + .filter((flag) => process.allowedNodeEnvironmentFlags.has(flag)), + ].join(' '), + }, + workerData, + }) return await new Promise((resolve, reject) => { worker.on('message', (result: WorkerMessage): void => { // TODO(serhalp): Improve `WorkerMessage` type. It sure would be nice to keep it simple as it diff --git a/tests/integration/commands/functions-serve/functions-serve.test.ts b/tests/integration/commands/functions-serve/functions-serve.test.ts index 0cf84a8d711..07cada07d02 100644 --- a/tests/integration/commands/functions-serve/functions-serve.test.ts +++ b/tests/integration/commands/functions-serve/functions-serve.test.ts @@ -3,6 +3,7 @@ import js from 'dedent' import execa from 'execa' import getPort from 'get-port' import fetch from 'node-fetch' +import semver from 'semver' import { describe, test } from 'vitest' import waitPort from 'wait-port' @@ -203,6 +204,155 @@ describe.concurrent('functions:serve command', () => { }) }) + test('should thread env vars from user env to function execution environment', async (t) => { + const port = await getPort() + await withSiteBuilder(t, async (builder) => { + await builder + .withContentFile({ + path: 'netlify/functions/get-env.js', + content: ` + export default async () => Response.json(process.env) + export const config = { path: "/get-env" } + `, + }) + .build() + + await withFunctionsServer({ builder, args: ['--port', port.toString()], port, env: { foo: 'bar' } }, async () => { + const response = await fetch(`http://localhost:${port.toString()}/get-env`) + t.expect(await response.json()).toMatchObject(t.expect.objectContaining({ foo: 'bar' })) + }) + }) + }) + + test('should thread `NODE_OPTIONS` if set in user env to function execution environment', async (t) => { + const port = await getPort() + await withSiteBuilder(t, async (builder) => { + await builder + .withContentFile({ + path: 'netlify/functions/get-env.js', + content: ` + export default async () => new Response(process.env.NODE_OPTIONS) + export const config = { path: "/get-env" } + `, + }) + .build() + + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { NODE_OPTIONS: '--abort-on-uncaught-exception --trace-exit' }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}/get-env`) + t.expect(await response.text()).toContain('--abort-on-uncaught-exception --trace-exit') + }, + ) + }) + }) + + // Testing just 22.12.0+ for simplicity. The real range is quite complex. + test.runIf(semver.gte(process.versions.node, '22.12.0'))( + 'should add AWS Lambda compat `NODE_OPTIONS` to function execution environment', + async (t) => { + const port = await getPort() + await withSiteBuilder(t, async (builder) => { + await builder + .withContentFile({ + path: 'netlify/functions/get-env.js', + content: ` + export default async () => new Response(process.env.NODE_OPTIONS) + export const config = { path: "/get-env" } + `, + }) + .build() + + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { NODE_OPTIONS: '--abort-on-uncaught-exception --trace-exit' }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}/get-env`) + const body = await response.text() + t.expect(body).toContain('--no-experimental-require-module') + t.expect(body).toContain('--no-experimental-detect-module') + t.expect(body).toContain('--abort-on-uncaught-exception --trace-exit') + }, + ) + }) + }, + ) + + test.runIf( + process.allowedNodeEnvironmentFlags.has('--no-experimental-require-module') || + process.allowedNodeEnvironmentFlags.has('--experimental-require-module'), + )('should allow user to re-enable experimental require module feature', async (t) => { + const port = await getPort() + await withSiteBuilder(t, async (builder) => { + await builder + .withContentFile({ + path: 'netlify/functions/get-env.js', + content: ` + export default async () => new Response(process.env.NODE_OPTIONS) + export const config = { path: "/get-env" } + `, + }) + .build() + + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { NODE_OPTIONS: '--experimental-require-module' }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}/get-env`) + const body = await response.text() + t.expect(body).toContain('--experimental-require-module') + t.expect(body).not.toContain('--no-experimental-require-module') + }, + ) + }) + }) + + test.runIf( + process.allowedNodeEnvironmentFlags.has('--no-experimental-detect-module') || + process.allowedNodeEnvironmentFlags.has('--experimental-detect-module'), + )('should allow user to re-enable experimental detect module feature', async (t) => { + const port = await getPort() + await withSiteBuilder(t, async (builder) => { + await builder + .withContentFile({ + path: 'netlify/functions/get-env.js', + content: ` + export default async () => new Response(process.env.NODE_OPTIONS) + export const config = { path: "/get-env" } + `, + }) + .build() + + await withFunctionsServer( + { + builder, + args: ['--port', port.toString()], + port, + env: { NODE_OPTIONS: '--experimental-detect-module' }, + }, + async () => { + const response = await fetch(`http://localhost:${port.toString()}/get-env`) + const body = await response.text() + t.expect(body).toContain('--experimental-detect-module') + t.expect(body).not.toContain('--no-experimental-detect-module') + }, + ) + }) + }) + test('should inject AI Gateway when linked site and online', async (t) => { await withSiteBuilder(t, async (builder) => { const { siteInfo, aiGatewayToken, routes } = createAIGatewayTestData()