Skip to content

Commit 17cb26b

Browse files
author
Caleb Barnes
authored
feat: pass mutated config environment variables to functions during local development (#7325)
* feat: pass mutated config environment variables to functions during local development ## Summary Fixes environment variables from build event handlers not being passed to functions during local development. Static config environment variables from `netlify.toml` were already working via global process environment injection, but variables added by plugins via build event handlers were not. ## Problem Build event handlers (like `onPreDev`) could successfully mutate the config to add environment variables to `config.build.environment`, but these **mutated** environment variables were not available to functions in `process.env` during local development. **What was working:** - Static environment variables from `netlify.toml` → `cachedConfig.env` → global `process.env` → functions ✅ - Build event handler mutations → `config.build.environment` ✅ - Process env override behavior ✅ **What was broken:** - Mutated config environment variables → functions ❌ ## Root Cause Static config environment variables work because they're included in `cachedConfig.env` and injected into the global `process.env` via `injectEnvVariables()`. However, mutated config environment variables from build event handlers are only stored in `config.build.environment` and were never being passed to functions. The `NetlifyFunction.invoke()` method was passing an empty `environment` object to the runtime, so functions only had access to the global `process.env` (which contained static config vars) but not the mutated config environment variables. ## Solution Instead of injecting mutated config into the global `process.env` (which could affect other parts of the system), we: - Extract environment variables from `config.build.environment` in `NetlifyFunction.invoke()` - Pass them directly to the function runtime via the `environment` parameter - The runtime injects them into the function's specific `process.env` - Maintain existing precedence: process env variables override config env variables ## Key Changes ### Core Implementation - **`src/lib/functions/netlify-function.ts`**: Extract environment variables from mutated config and pass to runtime - **`src/lib/functions/runtimes/js/index.ts`**: Update V1 function path to accept and use environment variables ### Testing - **`tests/integration/commands/dev/functions.test.ts`**: Significantly expanded test coverage: - **NEW**: Static config environment variables for V1 functions - **NEW**: Build event handler environment variables for V1 functions - **NEW**: Static config environment variables for V2 functions *(first V2 function env var tests)* - **NEW**: Build event handler environment variables for V2 functions *(first V2 function env var tests)* - **NEW**: Environment variable precedence for both V1 and V2 functions *(first build event handler mutation tests)* **Note**: While static config environment variables were already tested in `dev.config.test.ts` for V1 functions, this PR adds the first comprehensive test coverage for V2 functions and build event handler mutations. ## Technical Details This approach is better than global environment injection because: - It doesn't pollute the global process environment - It only affects the specific function being invoked - It maintains proper precedence handling - It works for both V1 and V2 function runtimes ## Use Case This enables plugins to add environment variables via build event handlers: ```javascript // Plugin build event handler export const onPreDev = ({ netlifyConfig }) => { netlifyConfig.build.environment.MY_PLUGIN_VAR = 'value' } // Function can now access the mutated config env var during local dev export const handler = async (event, context) => { console.log(process.env.MY_PLUGIN_VAR) // 'value' ✅ (was undefined ❌) return { statusCode: 200, body: 'OK' } } ``` ## Test Coverage ✅ V1 Functions - Static config and build event handler environment variables ✅ V2 Functions - Static config and build event handler environment variables ✅ Environment variable precedence - Process env overrides config env for both function types ✅ Backwards compatibility - All existing functionality preserved (16 existing tests still pass)
1 parent 813a2e2 commit 17cb26b

File tree

3 files changed

+241
-2
lines changed

3 files changed

+241
-2
lines changed

src/lib/functions/netlify-function.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,23 @@ export default class NetlifyFunction<BuildResult extends BaseBuildResult> {
252252
throw new Error('Function timeout (`timeoutBackground` or `timeoutSynchronous`) not set')
253253
}
254254

255-
const environment = {}
255+
// Get function environment variables from config.build.environment
256+
// This allows build event handlers to add function-specific environment variables
257+
// Only include config environment variables that are not already set in process.env
258+
// to ensure process environment variables take precedence
259+
const configEnvVars: Record<string, string> = {}
260+
if (this.config.build?.environment) {
261+
Object.entries(this.config.build.environment).forEach(([key, value]) => {
262+
if (typeof value === 'string' && !(key in process.env)) {
263+
configEnvVars[key] = value
264+
}
265+
})
266+
}
267+
268+
const environment = {
269+
// Include function-specific environment variables from config
270+
...configEnvVars,
271+
}
256272

257273
if (this.blobsContext) {
258274
const payload = JSON.stringify(getBlobsEventProperty(this.blobsContext))

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ export const invokeFunction = async ({
6969
? buildData.runtimeAPIVersion
7070
: null
7171
if (runtimeAPIVersion == null || runtimeAPIVersion !== 2) {
72-
return await invokeFunctionDirectly({ context, event, func, timeout })
72+
return await invokeFunctionDirectly({
73+
context,
74+
environment: environment as Record<string, string>,
75+
event,
76+
func,
77+
timeout,
78+
})
7379
}
7480

7581
const workerData = {
@@ -114,11 +120,13 @@ export const invokeFunction = async ({
114120

115121
export const invokeFunctionDirectly = async <BuildResult extends JsBuildResult>({
116122
context,
123+
environment,
117124
event,
118125
func,
119126
timeout,
120127
}: {
121128
context: Record<string, unknown>
129+
environment: Record<string, string>
122130
event: Record<string, unknown>
123131
func: NetlifyFunction<BuildResult>
124132
timeout: number
@@ -134,6 +142,8 @@ export const invokeFunctionDirectly = async <BuildResult extends JsBuildResult>(
134142
const result = await lambdaLocal.execute({
135143
clientContext: JSON.stringify(context),
136144
environment: {
145+
// Include environment variables from config
146+
...environment,
137147
// We've set the Blobs context on the parent process, which means it will
138148
// be available to the Lambda. This would be inconsistent with production
139149
// where only V2 functions get the context injected. To fix it, unset the

tests/integration/commands/dev/functions.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,216 @@ test('nodeModuleFormat: esm v1 functions should work', async (t) => {
5050
})
5151
})
5252
})
53+
54+
test('should inject environment variables from config to functions', async (t) => {
55+
await withSiteBuilder(t, async (builder) => {
56+
await builder
57+
.withNetlifyToml({
58+
config: {
59+
build: { environment: { MY_CONFIG_ENV: 'FROM_CONFIG' } },
60+
functions: { directory: 'functions' },
61+
},
62+
})
63+
.withFunction({
64+
path: 'echo-config-env.js',
65+
handler: () => {
66+
return Promise.resolve({
67+
statusCode: 200,
68+
body: process.env.MY_CONFIG_ENV ?? 'NOT_FOUND',
69+
})
70+
},
71+
})
72+
.build()
73+
74+
await withDevServer({ cwd: builder.directory }, async (server) => {
75+
const response = await fetch(new URL('/.netlify/functions/echo-config-env', server.url))
76+
t.expect(await response.text()).toBe('FROM_CONFIG')
77+
t.expect(response.status).toBe(200)
78+
})
79+
})
80+
})
81+
82+
test('should inject environment variables from build event handlers to functions', async (t) => {
83+
await withSiteBuilder(t, async (builder) => {
84+
await builder
85+
.withNetlifyToml({
86+
config: {
87+
build: { environment: { EXISTING_VAR: 'existing_value' } },
88+
plugins: [{ package: './plugins/add-env-vars' }],
89+
functions: { directory: 'functions' },
90+
},
91+
})
92+
.withBuildPlugin({
93+
name: 'add-env-vars',
94+
plugin: {
95+
onPreDev({ netlifyConfig }) {
96+
// Simulate a build event handler adding environment variables to the config
97+
// This is how plugins can add environment variables that should be available to functions
98+
netlifyConfig.build.environment.MY_PLUGIN_ENV = 'FROM_BUILD_EVENT_HANDLER'
99+
},
100+
},
101+
})
102+
.withFunction({
103+
path: 'echo-plugin-env.js',
104+
handler: () => {
105+
return Promise.resolve({
106+
statusCode: 200,
107+
body: JSON.stringify({
108+
MY_PLUGIN_ENV: process.env.MY_PLUGIN_ENV ?? 'NOT_FOUND',
109+
EXISTING_VAR: process.env.EXISTING_VAR ?? 'NOT_FOUND',
110+
}),
111+
})
112+
},
113+
})
114+
.build()
115+
116+
await withDevServer({ cwd: builder.directory }, async (server) => {
117+
const response = await fetch(new URL('/.netlify/functions/echo-plugin-env', server.url))
118+
const result = (await response.json()) as { MY_PLUGIN_ENV: string; EXISTING_VAR: string }
119+
120+
// First verify that existing environment variables work
121+
t.expect(result.EXISTING_VAR).toBe('existing_value')
122+
123+
// Then verify that build event handler environment variables work
124+
t.expect(result.MY_PLUGIN_ENV).toBe('FROM_BUILD_EVENT_HANDLER')
125+
t.expect(response.status).toBe(200)
126+
})
127+
})
128+
})
129+
130+
test('should inject environment variables from config to V2 functions', async (t) => {
131+
await withSiteBuilder(t, async (builder) => {
132+
await builder
133+
.withNetlifyToml({
134+
config: {
135+
build: { environment: { MY_CONFIG_ENV: 'FROM_CONFIG_V2' } },
136+
functions: { directory: 'functions' },
137+
},
138+
})
139+
.withFunction({
140+
path: 'echo-config-env-v2.js',
141+
runtimeAPIVersion: 2, // This makes it a V2 function
142+
handler: () => {
143+
return new Response(process.env.MY_CONFIG_ENV ?? 'NOT_FOUND', {
144+
status: 200,
145+
})
146+
},
147+
})
148+
.build()
149+
150+
await withDevServer({ cwd: builder.directory }, async (server) => {
151+
const response = await fetch(new URL('/.netlify/functions/echo-config-env-v2', server.url))
152+
t.expect(await response.text()).toBe('FROM_CONFIG_V2')
153+
t.expect(response.status).toBe(200)
154+
})
155+
})
156+
})
157+
158+
test('should inject environment variables from build event handlers to V2 functions', async (t) => {
159+
await withSiteBuilder(t, async (builder) => {
160+
await builder
161+
.withNetlifyToml({
162+
config: {
163+
build: { environment: { EXISTING_VAR: 'existing_value_v2' } },
164+
plugins: [{ package: './plugins/add-env-vars-v2' }],
165+
functions: { directory: 'functions' },
166+
},
167+
})
168+
.withBuildPlugin({
169+
name: 'add-env-vars-v2',
170+
plugin: {
171+
onPreDev({ netlifyConfig }) {
172+
// Simulate a build event handler adding environment variables to the config
173+
netlifyConfig.build.environment.MY_PLUGIN_ENV = 'FROM_BUILD_EVENT_HANDLER_V2'
174+
},
175+
},
176+
})
177+
.withFunction({
178+
path: 'echo-plugin-env-v2.js',
179+
runtimeAPIVersion: 2, // This makes it a V2 function
180+
handler: () => {
181+
return new Response(
182+
JSON.stringify({
183+
MY_PLUGIN_ENV: process.env.MY_PLUGIN_ENV ?? 'NOT_FOUND',
184+
EXISTING_VAR: process.env.EXISTING_VAR ?? 'NOT_FOUND',
185+
}),
186+
{
187+
status: 200,
188+
headers: { 'Content-Type': 'application/json' },
189+
},
190+
)
191+
},
192+
})
193+
.build()
194+
195+
await withDevServer({ cwd: builder.directory }, async (server) => {
196+
const response = await fetch(new URL('/.netlify/functions/echo-plugin-env-v2', server.url))
197+
const result = (await response.json()) as { MY_PLUGIN_ENV: string; EXISTING_VAR: string }
198+
199+
// First verify that existing environment variables work
200+
t.expect(result.EXISTING_VAR).toBe('existing_value_v2')
201+
202+
// Then verify that build event handler environment variables work
203+
t.expect(result.MY_PLUGIN_ENV).toBe('FROM_BUILD_EVENT_HANDLER_V2')
204+
t.expect(response.status).toBe(200)
205+
})
206+
})
207+
})
208+
209+
test('should respect environment variable precedence for both V1 and V2 functions', async (t) => {
210+
await withSiteBuilder(t, async (builder) => {
211+
await builder
212+
.withNetlifyToml({
213+
config: {
214+
build: { environment: { TEST_PRECEDENCE: 'FROM_CONFIG' } },
215+
plugins: [{ package: './plugins/precedence-test' }],
216+
functions: { directory: 'functions' },
217+
},
218+
})
219+
.withBuildPlugin({
220+
name: 'precedence-test',
221+
plugin: {
222+
onPreDev({ netlifyConfig }) {
223+
netlifyConfig.build.environment.TEST_PRECEDENCE = 'FROM_BUILD_EVENT_HANDLER'
224+
},
225+
},
226+
})
227+
.withFunction({
228+
path: 'precedence-v1.js',
229+
handler: () => {
230+
return Promise.resolve({
231+
statusCode: 200,
232+
body: process.env.TEST_PRECEDENCE ?? 'NOT_FOUND',
233+
})
234+
},
235+
})
236+
.withFunction({
237+
path: 'precedence-v2.js',
238+
runtimeAPIVersion: 2,
239+
handler: () => {
240+
return new Response(process.env.TEST_PRECEDENCE ?? 'NOT_FOUND', {
241+
status: 200,
242+
})
243+
},
244+
})
245+
.build()
246+
247+
await withDevServer(
248+
{
249+
cwd: builder.directory,
250+
env: { TEST_PRECEDENCE: 'FROM_PROCESS_ENV' }, // Process env should override config
251+
},
252+
async (server) => {
253+
// Test V1 function
254+
const v1Response = await fetch(new URL('/.netlify/functions/precedence-v1', server.url))
255+
t.expect(await v1Response.text()).toBe('FROM_PROCESS_ENV')
256+
t.expect(v1Response.status).toBe(200)
257+
258+
// Test V2 function
259+
const v2Response = await fetch(new URL('/.netlify/functions/precedence-v2', server.url))
260+
t.expect(await v2Response.text()).toBe('FROM_PROCESS_ENV')
261+
t.expect(v2Response.status).toBe(200)
262+
},
263+
)
264+
})
265+
})

0 commit comments

Comments
 (0)