diff --git a/.changeset/eager-env-init.md b/.changeset/eager-env-init.md new file mode 100644 index 000000000000..25a1876b9bb5 --- /dev/null +++ b/.changeset/eager-env-init.md @@ -0,0 +1,27 @@ +--- +'@sveltejs/kit': patch +'@sveltejs/adapter-node': patch +'@sveltejs/adapter-cloudflare': patch +'@sveltejs/adapter-vercel': patch +'@sveltejs/adapter-netlify': patch +--- + +fix: populate env vars before `instrumentation.server.js` is evaluated + +Previously, `$app/env/private` and `$app/env/public` dynamic variable values were only +populated when `Server.init()` called `set_env()`. If any module reading these values +was evaluated before `Server.init()` ran (e.g. via bundler chunk colocation with +`instrumentation.server.js`), the values would be silently `undefined`. + +`builder.instrument()` now accepts an `env` option. When provided, the generated +facade creates a separate init module that imports `set_env` and calls it with the +platform's env before instrumentation is imported. This ensures dynamic env vars are +populated (and validated) before any instrumentation or application code is evaluated. + +Adapters that have env available at module-load time pass the appropriate expression: +- adapter-node, adapter-vercel (serverless): `process.env` +- adapter-cloudflare: `env` from `cloudflare:workers` +- adapter-vercel (edge), adapter-netlify (serverless): env init via `generateText` + +If required env vars are missing, `set_env` will throw — this is intentional, as the +app cannot function without them. diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index c39f57e61521..f5c5e4226c83 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -127,7 +127,11 @@ export default function (options = {}) { if (builder.hasServerInstrumentationFile()) { builder.instrument({ entrypoint: worker_dest, - instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js` + instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js`, + env: { + imports: [`import { env } from 'cloudflare:workers';`], + expression: 'env' + } }); } diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 2c1355b42425..d13c90d265e4 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -322,7 +322,9 @@ export const config = { function generate_traced_module(config) { return ({ instrumentation, start }) => { return `\ -import '../server/${instrumentation}'; +const { set_env } = await import('../server/env.js'); +set_env(process.env); +await import('../server/${instrumentation}'); const { default: _0 } = await import('../server/${start}'); export { _0 as default }; diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index d359b63376d3..d3930038699f 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -148,6 +148,7 @@ export default function (opts = {}) { builder.instrument({ entrypoint: `${out}/index.js`, instrumentation: `${out}/instrumentation.server.js`, + env: 'process.env', module: { exports: ['path', 'host', 'port', 'server'] } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 763e6474945e..27e76c87bc6e 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -103,7 +103,8 @@ const plugin = function (defaults = {}) { if (builder.hasServerInstrumentationFile()) { builder.instrument({ entrypoint: `${tmp}/index.js`, - instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js` + instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js`, + env: 'process.env' }); } @@ -826,7 +827,9 @@ function is_prerendered(route) { */ function generate_traced_edge_module({ instrumentation, start }) { return `\ -import './${instrumentation}'; +const { set_env } = await import('./env.js'); +set_env(process.env); +await import('./${instrumentation}'); const promise = import('./${start}'); /** diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 57d10b1e15c2..9a04f64fdbbf 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -236,6 +236,7 @@ export function create_builder({ entrypoint, instrumentation, start = join(dirname(entrypoint), 'start.js'), + env, module = { exports: ['default'] } @@ -268,7 +269,9 @@ export function create_builder({ : create_instrumentation_facade({ instrumentation: relative_instrumentation, start: relative_start, - exports: module.exports + exports: module.exports, + env, + entrypoint }); rimraf(entrypoint); @@ -301,15 +304,35 @@ async function compress_file(file, format = 'gz') { /** * Given a list of exports, generate a facade that: + * - Imports and calls `set_env` with the platform's env (if provided), so that dynamic env + * vars are populated before instrumentation or any application code is evaluated * - Imports the instrumentation file - * - Imports `exports` from the entrypoint (dynamically, if `tla` is true) + * - Imports `exports` from the entrypoint (dynamically) * - Re-exports `exports` from the entrypoint * - * @param {{ instrumentation: string; start: string; exports: string[] }} opts + * @param {{ instrumentation: string; start: string; exports: string[]; env?: string | { imports: string[], expression: string }; entrypoint: string }} opts * @returns {string} */ -function create_instrumentation_facade({ instrumentation, start, exports }) { - const import_instrumentation = `import './${instrumentation}';`; +function create_instrumentation_facade({ instrumentation, start, exports, env, entrypoint }) { + const parts = []; + + if (env) { + // Generate a separate init module that imports `set_env` and calls it with the + // platform's env expression. Static imports are evaluated in order, so importing + // this module before instrumentation ensures env is populated before any + // instrumentation code (or transitively imported app modules) run. + const env_init_name = '__sveltekit_env_init.js'; + const env_init_path = join(dirname(entrypoint), env_init_name); + const imports = typeof env === 'string' ? [] : env.imports; + const expression = typeof env === 'string' ? env : env.expression; + write( + env_init_path, + `${imports.join('\n')}\nimport { set_env } from './env.js';\nset_env(${expression});\n` + ); + parts.push(`import './${env_init_name}';`); + } + + parts.push(`import './${instrumentation}';`); const { namespace, declarations, reexports } = create_exported_declarations( exports, @@ -317,13 +340,11 @@ function create_instrumentation_facade({ instrumentation, start, exports }) { '__mod' ); - const parts = [ - `const ${namespace} = await import('./${start}');`, - declarations.join('\n'), - reexports.length > 0 ? `export { ${reexports.join(', ')} };` : '' - ] - .filter(Boolean) - .join('\n'); + parts.push(`const ${namespace} = await import('./${start}');`); + parts.push(declarations.join('\n')); + if (reexports.length > 0) { + parts.push(`export { ${reexports.join(', ')} };`); + } - return `${import_instrumentation}\n${parts}`; + return parts.filter(Boolean).join('\n'); } diff --git a/packages/kit/src/core/env.js b/packages/kit/src/core/env.js index 63c493e61875..6446263c0432 100644 --- a/packages/kit/src/core/env.js +++ b/packages/kit/src/core/env.js @@ -94,9 +94,8 @@ export async function load_explicit_env(kit, file, root, mode) { * @param {Record | undefined> | null} variables * @param {Record} env * @param {string | null} entry - * @param {boolean} is_dev */ -export function create_sveltekit_env(variables, env, entry, is_dev) { +export function create_sveltekit_env(variables, env, entry) { const imports = entry ? [ `import { variables } from ${JSON.stringify(entry)};`, @@ -150,19 +149,6 @@ export function create_sveltekit_env(variables, env, entry, is_dev) { }` ]; - // In dev, initialise the env immediately. Tools like `vite-node` load modules - // through the Vite config but don't run the SvelteKit dev server, which is what - // normally calls `set_env`. Without this, dynamic env vars imported from - // `$app/env/public` and `$app/env/private` would be `undefined` in such contexts. - if (is_dev) { - /** @type {Record} */ - const dev_env = {}; - for (const name of Object.keys(variables ?? {})) { - if (name in env) dev_env[name] = env[name]; - } - blocks.push(`set_env(${devalue.uneval(dev_env)});`); - } - const module = blocks.join('\n\n'); return module; diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 450da13f7674..1b4014c2aacd 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -223,6 +223,12 @@ export interface Builder { * `entrypoint` which imports `instrumentation` and then dynamically imports `start`. This allows * the module hooks necessary for instrumentation libraries to be loaded prior to any application code. * + * If `env` is provided, a separate init module is generated that imports `set_env` from the env + * module and calls it with the provided expression. This module is imported before `instrumentation`, + * ensuring dynamic env vars are populated (and validated) before any instrumentation or application + * code is evaluated. The expression should evaluate to a `Record` — e.g. `'process.env'` + * for Node-like platforms, or an import from `'cloudflare:workers'` for Cloudflare Workers. + * * Caveats: * - "Live exports" will not work. If your adapter uses live exports, your users will need to manually import the server instrumentation on startup. * - If `tla` is `false`, OTEL auto-instrumentation may not work properly. Use it if your environment supports it. @@ -232,6 +238,7 @@ export interface Builder { * @param options.entrypoint the path to the entrypoint to trace. * @param options.instrumentation the path to the instrumentation file. * @param options.start the name of the start file. This is what `entrypoint` will be renamed to. + * @param options.env a JS expression that evaluates to the env object, or an object with `imports` (array of import statement strings) and `expression` (a JS expression), used to populate dynamic env vars before instrumentation runs. * @param options.module configuration for the resulting entrypoint module. * @param options.module.exports * @param options.module.generateText a function that receives the relative paths to the instrumentation and start files, and generates the text of the module to be traced. If not provided, the default implementation will be used, which uses top-level await. @@ -241,6 +248,7 @@ export interface Builder { entrypoint: string; instrumentation: string; start?: string; + env?: string | { imports: string[]; expression: string }; module?: | { exports: string[]; diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 14c88424ca3e..ef7f1d612560 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -628,7 +628,7 @@ function kit({ svelte_config }) { return create_service_worker_module(svelte_config); case sveltekit_env: - return create_sveltekit_env(explicit_env_config, env, explicit_env_entry, !is_build); + return create_sveltekit_env(explicit_env_config, env, explicit_env_entry); case sveltekit_env_public_client: return create_sveltekit_env_public( diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6892b0aab145..1ffb953a2859 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -196,6 +196,12 @@ declare module '@sveltejs/kit' { * `entrypoint` which imports `instrumentation` and then dynamically imports `start`. This allows * the module hooks necessary for instrumentation libraries to be loaded prior to any application code. * + * If `env` is provided, a separate init module is generated that imports `set_env` from the env + * module and calls it with the provided expression. This module is imported before `instrumentation`, + * ensuring dynamic env vars are populated (and validated) before any instrumentation or application + * code is evaluated. The expression should evaluate to a `Record` — e.g. `'process.env'` + * for Node-like platforms, or an import from `'cloudflare:workers'` for Cloudflare Workers. + * * Caveats: * - "Live exports" will not work. If your adapter uses live exports, your users will need to manually import the server instrumentation on startup. * - If `tla` is `false`, OTEL auto-instrumentation may not work properly. Use it if your environment supports it. @@ -205,6 +211,7 @@ declare module '@sveltejs/kit' { * @param options.entrypoint the path to the entrypoint to trace. * @param options.instrumentation the path to the instrumentation file. * @param options.start the name of the start file. This is what `entrypoint` will be renamed to. + * @param options.env a JS expression that evaluates to the env object, or an object with `imports` (array of import statement strings) and `expression` (a JS expression), used to populate dynamic env vars before instrumentation runs. * @param options.module configuration for the resulting entrypoint module. * @param options.module.generateText a function that receives the relative paths to the instrumentation and start files, and generates the text of the module to be traced. If not provided, the default implementation will be used, which uses top-level await. * @since 2.31.0 @@ -213,6 +220,7 @@ declare module '@sveltejs/kit' { entrypoint: string; instrumentation: string; start?: string; + env?: string | { imports: string[]; expression: string }; module?: | { exports: string[];