Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/dev/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ interface InjectEnvironmentVariablesOptions {
siteID?: string
}

/**
* Inject user-defined environment variables (from various sources, see `@netlify/config`)
* into the provided `envAPI` (which may be a proxy to `process.env`, affecting the current proc),
* if `siteID` and `accountSlug` are provided.
* @see {@link https://github.com/netlify/build/blob/8b7583e1890636bd64b54e20aee40ae5365edeaf/packages/config/src/env/main.ts#L92}
*
* This also injects and returns the documented runtime env vars:
* @see {@link https://docs.netlify.com/functions/environment-variables/#functions}
*
* @return Metadata about all injected environment variables
*/
export const injectEnvVariables = async ({
accountSlug,
baseVariables = {},
Expand All @@ -66,6 +77,8 @@ export const injectEnvVariables = async ({
})
}

// Inject env vars which come from multiple `source`s and have been collected from
// `@netlify/config` and/or Envelope. These have not been populated on the actual env yet.
for (const [key, variable] of Object.entries(variables)) {
const existsInProcess = envAPI.has(key)
const [usedSource, ...overriddenSources] = existsInProcess ? ['process', ...variable.sources] : variable.sources
Expand Down
1 change: 1 addition & 0 deletions packages/dev/src/lib/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const getRuntime = async ({ blobs, deployID, projectRoot, siteID }: GetRu

return {
env,
envSnapshot,
stop: async () => {
restoreEnvironment(envSnapshot)

Expand Down
195 changes: 182 additions & 13 deletions packages/dev/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

import { createImageServerHandler, Fixture, generateImage, getImageResponseSize, HTTPServer } from '@netlify/dev-utils'
import { describe, expect, test } from 'vitest'
import { afterEach, describe, expect, test, vi } from 'vitest'

import { isFile } from './lib/fs.js'
import { NetlifyDev } from './main.js'

import { withMockApi } from '../test/mock-api.js'

describe('Handling requests', () => {
afterEach(() => {
vi.unstubAllEnvs()
})

describe('No linked site', () => {
test('Same-site rewrite to a static file', async () => {
const fixture = new Fixture()
Expand Down Expand Up @@ -425,7 +429,7 @@ describe('Handling requests', () => {
'netlify/functions/hello.mjs',
`export default async () => {
const cache = await caches.open("my-cache");

await cache.put("https://example.com", new Response("Cached response"));

return new Response("Hello world");
Expand Down Expand Up @@ -533,6 +537,130 @@ describe('Handling requests', () => {
await dev.stop()
await fixture.destroy()
})

test('Invoking an edge function', async () => {
Copy link
Member Author

@serhalp serhalp Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was no coverage for the unlinked site case. This is copied and tweaked from the linked site test.

It's rather important for env vars because there's a whole separate code path with the envelope stuff when linked.

const fixture = new Fixture()
.withFile(
'netlify.toml',
`[build]
publish = "public"
[context.dev.environment]
MY_TOKEN = "value from dev context"
[context.deploy-preview.environment]
MY_OTHER_TOKEN = "value from deploy preview context"
`,
)
.withFile(
'netlify/functions/hello.mjs',
`export default async (req, context) => new Response("Hello from function");

export const config = { path: "/hello/:a/*" };`,
)
.withFile(
'netlify/edge-functions/passthrough.mjs',
`export default async (req, context) => {
const res = await context.next();
const text = await res.text();

return new Response(text.toUpperCase(), res);
};

export const config = { path: "/hello/passthrough/*" };`,
)
.withFile(
'netlify/edge-functions/terminate.mjs',
`export default async (req, context) => Response.json({
runtimeEnv: {
NETLIFY_BLOBS_CONTEXT: Netlify.env.get("NETLIFY_BLOBS_CONTEXT"),
},
platformEnv: {
DEPLOY_ID: Netlify.env.get("DEPLOY_ID"),
},
configEnv: {
MY_TOKEN: Netlify.env.get("MY_TOKEN"),
MY_OTHER_TOKEN: Netlify.env.get("MY_OTHER_TOKEN"),
},
parentProcessEnv: {
SOME_ZSH_THING_MAYBE: Netlify.env.get("SOME_ZSH_THING_MAYBE"),
},
geo: context.geo,
params: context.params,
path: context.path,
server: context.server,
site: context.site,
url: context.url,
});

export const config = { path: "/hello/terminate/*" };`,
)
const directory = await fixture.create()

vi.stubEnv('SOME_ZSH_THING_MAYBE', 'value on developer machine')

const dev = new NetlifyDev({
apiToken: 'token',
projectRoot: directory,
})

const { serverAddress } = await dev.start()

const req1 = new Request('https://site.netlify/hello/passthrough/two/three')
const res1 = await dev.handle(req1)

expect(await res1?.text()).toBe('HELLO FROM FUNCTION')

const req2 = new Request('https://site.netlify/hello/terminate/two/three')
const res2 = await dev.handle(req2)
const req2URL = new URL('/hello/terminate/two/three', serverAddress)

expect(await res2?.json()).toStrictEqual({
// Env vars emulating the EF runtime are present
runtimeEnv: {
NETLIFY_BLOBS_CONTEXT: expect.stringMatching(/\w+/) as unknown,
},
// Env vars emulating the EF runtime are present
// Note that these originate from `@netlify/config`
platformEnv: {
DEPLOY_ID: '0',
},
// Envs var set in `netlify.toml` for `dev` context only are passed to EFs
configEnv: {
MY_TOKEN: 'value from dev context',
// MY_OTHER_TOKEN is not present
},
parentProcessEnv: {
// SOME_ZSH_THING_MAYBE is not present
},

geo: {
city: 'San Francisco',
country: {
code: 'US',
name: 'United States',
},
latitude: 0,
longitude: 0,
subdivision: {
code: 'CA',
name: 'California',
},
timezone: 'UTC',
},
params: {
'0': 'two/three',
},
server: {
region: 'dev',
},
site: {
url: serverAddress,
},
url: req2URL.toString(),
})

await dev.stop()
await fixture.destroy()
})
})

describe('With linked site', () => {
Expand Down Expand Up @@ -587,7 +715,7 @@ describe('Handling requests', () => {
`export default async (req, context) => Response.json({
env: {
WITH_DEV_OVERRIDE: Netlify.env.get("WITH_DEV_OVERRIDE"),
WITHOUT_DEV_OVERRIDE: Netlify.env.get("WITHOUT_DEV_OVERRIDE")
WITHOUT_DEV_OVERRIDE: Netlify.env.get("WITHOUT_DEV_OVERRIDE")
},
geo: context.geo,
params: context.params,
Expand All @@ -596,7 +724,7 @@ describe('Handling requests', () => {
site: context.site,
url: context.url
});

export const config = { path: "/hello/:a/*" };`,
)
.withStateFile({ siteId: 'site_id' })
Expand Down Expand Up @@ -659,13 +787,17 @@ describe('Handling requests', () => {
.withFile(
'netlify.toml',
`[build]
publish = "public"
publish = "public"
[context.dev.environment]
MY_TOKEN = "value from dev context"
[context.deploy-preview.environment]
MY_OTHER_TOKEN = "value from deploy preview context"
`,
)
.withFile(
'netlify/functions/hello.mjs',
`export default async (req, context) => new Response("Hello from function");

export const config = { path: "/hello/:a/*" };`,
)
.withFile(
Expand All @@ -676,30 +808,45 @@ describe('Handling requests', () => {

return new Response(text.toUpperCase(), res);
};

export const config = { path: "/hello/passthrough/*" };`,
)
.withFile(
'netlify/edge-functions/terminate.mjs',
`export default async (req, context) => Response.json({
env: {
siteEnv: {
WITH_DEV_OVERRIDE: Netlify.env.get("WITH_DEV_OVERRIDE"),
WITHOUT_DEV_OVERRIDE: Netlify.env.get("WITHOUT_DEV_OVERRIDE")
WITHOUT_DEV_OVERRIDE: Netlify.env.get("WITHOUT_DEV_OVERRIDE"),
},
runtimeEnv: {
NETLIFY_BLOBS_CONTEXT: Netlify.env.get("NETLIFY_BLOBS_CONTEXT"),
},
platformEnv: {
DEPLOY_ID: Netlify.env.get("DEPLOY_ID"),
},
configEnv: {
MY_TOKEN: Netlify.env.get("MY_TOKEN"),
MY_OTHER_TOKEN: Netlify.env.get("MY_OTHER_TOKEN"),
},
parentProcessEnv: {
SOME_ZSH_THING_MAYBE: Netlify.env.get("SOME_ZSH_THING_MAYBE"),
},
geo: context.geo,
params: context.params,
path: context.path,
server: context.server,
site: context.site,
url: context.url
url: context.url,
});

export const config = { path: "/hello/terminate/*" };`,
)
.withStateFile({ siteId: 'site_id' })
const directory = await fixture.create()

await withMockApi(routes, async (context) => {
vi.stubEnv('SOME_ZSH_THING_MAYBE', 'value on developer machine')

const dev = new NetlifyDev({
apiURL: context.apiUrl,
apiToken: 'token',
Expand All @@ -718,10 +865,32 @@ describe('Handling requests', () => {
const req2URL = new URL('/hello/terminate/two/three', serverAddress)

expect(await res2?.json()).toStrictEqual({
env: {
// Env vars set on the site ("UI") are passed to EFs
siteEnv: {
WITH_DEV_OVERRIDE: 'value from dev context',
WITHOUT_DEV_OVERRIDE: 'value from all context',
},
// Env vars emulating the EF runtime are present
// TODO(serhalp): Test conditionally injected `NETLIFY_PURGE_API_TOKEN`
// TODO(serhalp): Finish implementing and test conditionally injected `BRANCH`
runtimeEnv: {
NETLIFY_BLOBS_CONTEXT: expect.stringMatching(/\w+/) as unknown,
},
// Env vars emulating the EF runtime are present
// Note that these originate from `@netlify/config`
platformEnv: {
DEPLOY_ID: '0',
},
// Envs var set in `netlify.toml` for `dev` context only are passed to EFs
configEnv: {
MY_TOKEN: 'value from dev context',
// MY_OTHER_TOKEN is not present
},
parentProcessEnv: {
// SOME_ZSH_THING_MAYBE is not present
},
// TODO(serhalp): Implement and test support for `.env.*` files (exists in CLI)

geo: {
city: 'San Francisco',
country: {
Expand Down Expand Up @@ -769,7 +938,7 @@ describe('Handling requests', () => {
.withFile(
'netlify/functions/greeting.mjs',
`export default async (req, context) => new Response(context.params.greeting + ", friend!");

export const config = { path: "/:greeting", preferStatic: true };`,
)
.withFile('public/hello.html', '<html>Hello</html>')
Expand Down
35 changes: 20 additions & 15 deletions packages/dev/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,27 +453,32 @@ export class NetlifyDev {
}

if (this.#features.edgeFunctions) {
const env = Object.entries(envVariables).reduce<Record<string, string>>((acc, [key, variable]) => {
if (
variable.usedSource === 'account' ||
variable.usedSource === 'addons' ||
variable.usedSource === 'internal' ||
variable.usedSource === 'ui' ||
variable.usedSource.startsWith('.env')
) {
return {
const edgeFunctionsEnv = {
// User-defined env vars + documented runtime env vars
...Object.entries(envVariables).reduce<Record<string, string>>(
(acc, [key, variable]) => ({
...acc,
[key]: variable.value,
}
}

return acc
}, {})
}),
{},
),
// Add runtime env vars that we've set ourselves so far. These are "internal" env vars,
// part of the runtime emulation. They've already been populated on this process's env, which
// is needed to make other dev features work. These are different than the "documented" runtime
// env vars, in that they are implementation details, needed to make our features work.
...Object.keys(runtime.envSnapshot).reduce<Record<string, string>>(
(acc, key) => ({
...acc,
[key]: runtime.env.get(key) ?? '',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this fallback ever kick in?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it should be possible, but the type returns | undefined.

}),
{},
),
}

const edgeFunctionsHandler = new EdgeFunctionsHandler({
configDeclarations: this.#config?.config.edge_functions ?? [],
directories: [this.#config?.config.build.edge_functions].filter(Boolean) as string[],
env,
env: edgeFunctionsEnv,
geolocation: mockLocation,
logger: this.#logger,
siteID,
Expand Down
Loading