Skip to content

Commit 25fab29

Browse files
authored
fix: set same Node.js options locally as AWS Lambda does in production (#7735)
* fix: set same Node.js opts locally as AWS Lambda It turns out that, despite these Node.js features being enabled by default on some subset of these Node.js 20 and 22 versions, AWS Lambda disables them explicitly via flags: https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html#w292aac41c19.. This leads to a mismatch between the local dev Netlify Functions environment and the production (AWS Lambda) environment, leading to confusing bugs like this: netlify/react-router-template#10 (comment). * fix: respect user NODE_OPTIONS in local lambda * test: add coverage for threaded function invocation env vars * fix: allow user opt-back-in to disabled node flags, like Lambda
1 parent 6a38183 commit 25fab29

File tree

2 files changed

+174
-1
lines changed

2 files changed

+174
-1
lines changed

src/lib/functions/runtimes/js/index.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,30 @@ export const invokeFunction = async ({
8484
timeoutMs: timeout * SECONDS_TO_MILLISECONDS,
8585
}
8686

87-
const worker = new Worker(workerURL, { workerData })
87+
const worker = new Worker(workerURL, {
88+
env: {
89+
...process.env,
90+
// AWS Lambda disables these Node.js experimental features, even in Node.js versions where they are enabled by
91+
// default: https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html#w292aac41c19.
92+
// They also allow users to re-enable (i.e. not disable) these by co-opting the positive flag (which in reality
93+
// may or may not exist depending on the exact node.js version). We replicate all this behavior here.
94+
NODE_OPTIONS: [
95+
...(process.env.NODE_OPTIONS?.split(' ') ?? []),
96+
...[
97+
...(process.env.NODE_OPTIONS?.includes('--experimental-require-module')
98+
? []
99+
: ['--no-experimental-require-module']),
100+
...(process.env.NODE_OPTIONS?.includes('--experimental-detect-module')
101+
? []
102+
: ['--no-experimental-detect-module']),
103+
]
104+
// Unfortunately Node.js throws if `NODE_OPTIONS` contains any unsupported flags and these flags have been
105+
// added and removed in various specific versions in each major line. Luckily Node.js has an API just for this!
106+
.filter((flag) => process.allowedNodeEnvironmentFlags.has(flag)),
107+
].join(' '),
108+
},
109+
workerData,
110+
})
88111
return await new Promise((resolve, reject) => {
89112
worker.on('message', (result: WorkerMessage): void => {
90113
// TODO(serhalp): Improve `WorkerMessage` type. It sure would be nice to keep it simple as it

tests/integration/commands/functions-serve/functions-serve.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import js from 'dedent'
33
import execa from 'execa'
44
import getPort from 'get-port'
55
import fetch from 'node-fetch'
6+
import semver from 'semver'
67
import { describe, test } from 'vitest'
78
import waitPort from 'wait-port'
89

@@ -203,6 +204,155 @@ describe.concurrent('functions:serve command', () => {
203204
})
204205
})
205206

207+
test('should thread env vars from user env to function execution environment', async (t) => {
208+
const port = await getPort()
209+
await withSiteBuilder(t, async (builder) => {
210+
await builder
211+
.withContentFile({
212+
path: 'netlify/functions/get-env.js',
213+
content: `
214+
export default async () => Response.json(process.env)
215+
export const config = { path: "/get-env" }
216+
`,
217+
})
218+
.build()
219+
220+
await withFunctionsServer({ builder, args: ['--port', port.toString()], port, env: { foo: 'bar' } }, async () => {
221+
const response = await fetch(`http://localhost:${port.toString()}/get-env`)
222+
t.expect(await response.json()).toMatchObject(t.expect.objectContaining({ foo: 'bar' }))
223+
})
224+
})
225+
})
226+
227+
test('should thread `NODE_OPTIONS` if set in user env to function execution environment', async (t) => {
228+
const port = await getPort()
229+
await withSiteBuilder(t, async (builder) => {
230+
await builder
231+
.withContentFile({
232+
path: 'netlify/functions/get-env.js',
233+
content: `
234+
export default async () => new Response(process.env.NODE_OPTIONS)
235+
export const config = { path: "/get-env" }
236+
`,
237+
})
238+
.build()
239+
240+
await withFunctionsServer(
241+
{
242+
builder,
243+
args: ['--port', port.toString()],
244+
port,
245+
env: { NODE_OPTIONS: '--abort-on-uncaught-exception --trace-exit' },
246+
},
247+
async () => {
248+
const response = await fetch(`http://localhost:${port.toString()}/get-env`)
249+
t.expect(await response.text()).toContain('--abort-on-uncaught-exception --trace-exit')
250+
},
251+
)
252+
})
253+
})
254+
255+
// Testing just 22.12.0+ for simplicity. The real range is quite complex.
256+
test.runIf(semver.gte(process.versions.node, '22.12.0'))(
257+
'should add AWS Lambda compat `NODE_OPTIONS` to function execution environment',
258+
async (t) => {
259+
const port = await getPort()
260+
await withSiteBuilder(t, async (builder) => {
261+
await builder
262+
.withContentFile({
263+
path: 'netlify/functions/get-env.js',
264+
content: `
265+
export default async () => new Response(process.env.NODE_OPTIONS)
266+
export const config = { path: "/get-env" }
267+
`,
268+
})
269+
.build()
270+
271+
await withFunctionsServer(
272+
{
273+
builder,
274+
args: ['--port', port.toString()],
275+
port,
276+
env: { NODE_OPTIONS: '--abort-on-uncaught-exception --trace-exit' },
277+
},
278+
async () => {
279+
const response = await fetch(`http://localhost:${port.toString()}/get-env`)
280+
const body = await response.text()
281+
t.expect(body).toContain('--no-experimental-require-module')
282+
t.expect(body).toContain('--no-experimental-detect-module')
283+
t.expect(body).toContain('--abort-on-uncaught-exception --trace-exit')
284+
},
285+
)
286+
})
287+
},
288+
)
289+
290+
test.runIf(
291+
process.allowedNodeEnvironmentFlags.has('--no-experimental-require-module') ||
292+
process.allowedNodeEnvironmentFlags.has('--experimental-require-module'),
293+
)('should allow user to re-enable experimental require module feature', async (t) => {
294+
const port = await getPort()
295+
await withSiteBuilder(t, async (builder) => {
296+
await builder
297+
.withContentFile({
298+
path: 'netlify/functions/get-env.js',
299+
content: `
300+
export default async () => new Response(process.env.NODE_OPTIONS)
301+
export const config = { path: "/get-env" }
302+
`,
303+
})
304+
.build()
305+
306+
await withFunctionsServer(
307+
{
308+
builder,
309+
args: ['--port', port.toString()],
310+
port,
311+
env: { NODE_OPTIONS: '--experimental-require-module' },
312+
},
313+
async () => {
314+
const response = await fetch(`http://localhost:${port.toString()}/get-env`)
315+
const body = await response.text()
316+
t.expect(body).toContain('--experimental-require-module')
317+
t.expect(body).not.toContain('--no-experimental-require-module')
318+
},
319+
)
320+
})
321+
})
322+
323+
test.runIf(
324+
process.allowedNodeEnvironmentFlags.has('--no-experimental-detect-module') ||
325+
process.allowedNodeEnvironmentFlags.has('--experimental-detect-module'),
326+
)('should allow user to re-enable experimental detect module feature', async (t) => {
327+
const port = await getPort()
328+
await withSiteBuilder(t, async (builder) => {
329+
await builder
330+
.withContentFile({
331+
path: 'netlify/functions/get-env.js',
332+
content: `
333+
export default async () => new Response(process.env.NODE_OPTIONS)
334+
export const config = { path: "/get-env" }
335+
`,
336+
})
337+
.build()
338+
339+
await withFunctionsServer(
340+
{
341+
builder,
342+
args: ['--port', port.toString()],
343+
port,
344+
env: { NODE_OPTIONS: '--experimental-detect-module' },
345+
},
346+
async () => {
347+
const response = await fetch(`http://localhost:${port.toString()}/get-env`)
348+
const body = await response.text()
349+
t.expect(body).toContain('--experimental-detect-module')
350+
t.expect(body).not.toContain('--no-experimental-detect-module')
351+
},
352+
)
353+
})
354+
})
355+
206356
test('should inject AI Gateway when linked site and online', async (t) => {
207357
await withSiteBuilder(t, async (builder) => {
208358
const { siteInfo, aiGatewayToken, routes } = createAIGatewayTestData()

0 commit comments

Comments
 (0)