diff --git a/packages/dev/README.md b/packages/dev/README.md index 05448d2c9..f9c70940f 100644 --- a/packages/dev/README.md +++ b/packages/dev/README.md @@ -30,7 +30,12 @@ import { NetlifyDev } from '@netlify/dev' const devServer = new NetlifyDev({ blobs: { enabled: true }, edgeFunctions: { enabled: true }, - environmentVariables: { enabled: true }, + environmentVariables: { + enabled: true, + // OPTIONAL: control whether user-defined environment variables are injected + // When false, only platform env vars (NETLIFY_LOCAL, CONTEXT, SITE_ID, etc.) are injected + injectUserEnv: true, // default: true + }, functions: { enabled: true }, redirects: { enabled: true }, staticFiles: { diff --git a/packages/dev/src/lib/env.ts b/packages/dev/src/lib/env.ts index 344af494b..5198f2433 100644 --- a/packages/dev/src/lib/env.ts +++ b/packages/dev/src/lib/env.ts @@ -42,10 +42,26 @@ interface InjectEnvironmentVariablesOptions { accountSlug?: string baseVariables: Record envAPI: EnvironmentVariables + /** + * Whether to inject user-defined environment variables. + * When false, only platform environment variables (from 'general' and 'internal' sources) + * are injected. When true, all environment variables are injected. + * @default true + */ + injectUserEnv?: boolean netlifyAPI?: NetlifyAPI siteID?: string } +/** + * Determines if an environment variable is a platform variable. + * Platform variables are from 'general' or 'internal' sources and include + * documented runtime variables like NETLIFY_LOCAL, CONTEXT, SITE_ID, etc. + */ +const isPlatformEnvironmentVariable = (variable: EnvironmentVariable): boolean => { + return variable.sources.includes('general') || variable.sources.includes('internal') +} + /** * 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), @@ -61,6 +77,7 @@ export const injectEnvVariables = async ({ accountSlug, baseVariables = {}, envAPI, + injectUserEnv = true, netlifyAPI, siteID, }: InjectEnvironmentVariablesOptions) => { @@ -80,6 +97,11 @@ 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)) { + // If injectUserEnv is false, only inject platform env vars (from 'general' and 'internal' sources) + if (!injectUserEnv && !isPlatformEnvironmentVariable(variable)) { + continue + } + const existsInProcess = envAPI.has(key) const [usedSource, ...overriddenSources] = existsInProcess ? ['process', ...variable.sources] : variable.sources const isInternal = variable.sources.includes('internal') diff --git a/packages/dev/src/main.test.ts b/packages/dev/src/main.test.ts index 968a1dfaf..d9af7a027 100644 --- a/packages/dev/src/main.test.ts +++ b/packages/dev/src/main.test.ts @@ -1107,3 +1107,84 @@ describe('Handling requests', () => { }) }) }) + +describe('Environment variable injection', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + test('injectUserEnv option controls user-defined env var injection', async () => { + const fixture = new Fixture() + .withFile( + 'netlify.toml', + `[build] + publish = "public" + [context.dev.environment] + MY_USER_VAR = "user value" + `, + ) + .withFile( + 'netlify/functions/env-test.mjs', + `export default async (req, context) => Response.json({ + NETLIFY_LOCAL: Netlify.env.get("NETLIFY_LOCAL"), + CONTEXT: Netlify.env.get("CONTEXT"), + MY_USER_VAR: Netlify.env.get("MY_USER_VAR"), + }); + export const config = { path: "/env-test" };`, + ) + .withStateFile({ siteId: 'site_id' }) + const directory = await fixture.create() + + // Test with injectUserEnv: false - only platform vars should be injected + const devWithoutUserEnv = new NetlifyDev({ + projectRoot: directory, + environmentVariables: { + enabled: true, + injectUserEnv: false, + }, + edgeFunctions: { enabled: false }, + geolocation: { enabled: false }, + }) + + await devWithoutUserEnv.start() + + const req1 = new Request('https://site.netlify/env-test') + const res1 = await devWithoutUserEnv.handle(req1) + const envVarsWithoutUserEnv = await res1?.json() + + await devWithoutUserEnv.stop() + + // Platform env vars should be present + expect(envVarsWithoutUserEnv).toHaveProperty('NETLIFY_LOCAL', 'true') + expect(envVarsWithoutUserEnv).toHaveProperty('CONTEXT', 'dev') + + // User-defined env vars should NOT be present + expect(envVarsWithoutUserEnv.MY_USER_VAR).toBeUndefined() + + // Test with injectUserEnv: true (default) - all vars should be injected + const devWithUserEnv = new NetlifyDev({ + projectRoot: directory, + environmentVariables: { + enabled: true, + injectUserEnv: true, + }, + edgeFunctions: { enabled: false }, + geolocation: { enabled: false }, + }) + + await devWithUserEnv.start() + + const req2 = new Request('https://site.netlify/env-test') + const res2 = await devWithUserEnv.handle(req2) + const envVarsWithUserEnv = await res2?.json() + + await devWithUserEnv.stop() + + // Both platform and user-defined env vars should be present + expect(envVarsWithUserEnv).toHaveProperty('NETLIFY_LOCAL', 'true') + expect(envVarsWithUserEnv).toHaveProperty('CONTEXT', 'dev') + expect(envVarsWithUserEnv).toHaveProperty('MY_USER_VAR', 'user value') + + await fixture.destroy() + }) +}) diff --git a/packages/dev/src/main.ts b/packages/dev/src/main.ts index e5818ee81..601c6c8c9 100644 --- a/packages/dev/src/main.ts +++ b/packages/dev/src/main.ts @@ -53,6 +53,15 @@ export interface Features { */ environmentVariables?: { enabled?: boolean + /** + * Whether to inject user-defined environment variables from the linked site. + * When false, only platform environment variables (NETLIFY_LOCAL, CONTEXT, SITE_ID, etc.) + * are injected. When true, all environment variables including user-defined ones + * from Netlify UI, account, and config files are injected. + * + * {@default} true + */ + injectUserEnv?: boolean } /** @@ -181,6 +190,7 @@ export class NetlifyDev { blobs: boolean edgeFunctions: boolean environmentVariables: boolean + environmentVariablesInjectUserEnv: boolean functions: boolean geolocation: boolean headers: boolean @@ -216,6 +226,7 @@ export class NetlifyDev { blobs: options.blobs?.enabled !== false, edgeFunctions: options.edgeFunctions?.enabled !== false, environmentVariables: options.environmentVariables?.enabled !== false, + environmentVariablesInjectUserEnv: options.environmentVariables?.injectUserEnv !== false, functions: options.functions?.enabled !== false, geolocation: options.geolocation?.enabled !== false, headers: options.headers?.enabled !== false, @@ -517,6 +528,7 @@ export class NetlifyDev { accountSlug: config?.siteInfo?.account_slug, baseVariables: config?.env || {}, envAPI: runtime.env, + injectUserEnv: this.#features.environmentVariablesInjectUserEnv, netlifyAPI: config?.api, siteID, }) diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 9e309cc9e..1518632f0 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -31,6 +31,11 @@ The plugin accepts the following options: same way as the Netlify production environment - `blobs`: Configure blob storage functionality - `edgeFunctions`: Configure edge functions +- `environmentVariables`: Configure environment variable injection + - `enabled` (boolean, default: `true`): Enable environment variable injection + - `injectUserEnv` (boolean, default: `true`): Inject user-defined environment variables. When `false`, only platform + environment variables (like `NETLIFY_LOCAL`, `CONTEXT`, `SITE_ID`) are injected, excluding user-defined variables + from Netlify account settings, Netlify UI, config files, and addons - `functions`: Configure serverless functions - `headers`: Configure response headers - `images`: Configure Image CDN functionality @@ -49,3 +54,27 @@ export default defineConfig({ plugins: [netlify()], }) ``` + +### Environment Variables Configuration + +By default, the plugin injects all environment variables including user-defined ones from your Netlify site. If you want +to only inject platform environment variables (useful for frameworks that manage their own environment variables): + +```js +import { defineConfig } from 'vite' +import netlify from '@netlify/vite-plugin' + +export default defineConfig({ + plugins: [ + netlify({ + environmentVariables: { + enabled: true, + injectUserEnv: false, + }, + }), + ], +}) +``` + +With `injectUserEnv: false`, only platform variables like `NETLIFY_LOCAL`, `CONTEXT`, and `SITE_ID` are injected, +which are required for platform features like `purgeCache()` to work correctly. diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 346cc3b67..a2ecba8a4 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -158,6 +158,61 @@ describe.for([['5.0.0'], ['6.0.0'], ['7.0.0']])('Vite %s', ([viteVersion]) => { await fixture.destroy() }) + test('Populates platform env vars when injectUserEnv is false', async () => { + const fixture = new Fixture() + .withFile( + 'vite.config.js', + `import { defineConfig } from 'vite'; + import netlify from '@netlify/vite-plugin'; + + export default defineConfig({ + plugins: [ + netlify({ + middleware: false, + environmentVariables: { + enabled: true, + injectUserEnv: false + } + }) + ] + });`, + ) + .withFile( + 'netlify.toml', + `[context.dev.environment] + MY_USER_VAR = "user value"`, + ) + .withFile( + 'index.html', + ` + + Hello World +

Hello from the browser

+ `, + ) + const directory = await fixture.create() + await fixture + .withPackages({ + vite: viteVersion, + '@netlify/vite-plugin': pathToFileURL(path.resolve(directory, PLUGIN_PATH)).toString(), + }) + .create() + + const { server } = await startTestServer({ + root: directory, + }) + + // Platform env vars should be present + expect(process.env).toHaveProperty('NETLIFY_LOCAL', 'true') + expect(process.env).toHaveProperty('CONTEXT', 'dev') + + // User-defined env vars should NOT be present when injectUserEnv is false + expect(process.env).not.toHaveProperty('MY_USER_VAR') + + await server.close() + await fixture.destroy() + }) + test('Prints a basic message on server start', async () => { const fixture = new Fixture() .withFile(