diff --git a/.changeset/swift-badgers-taste.md b/.changeset/swift-badgers-taste.md new file mode 100644 index 000000000000..737c541a9c7a --- /dev/null +++ b/.changeset/swift-badgers-taste.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: ensure `building` resolves correctly to allow avoiding build-time explicit environment variable validation diff --git a/documentation/docs/20-core-concepts/70-environment-variables.md b/documentation/docs/20-core-concepts/70-environment-variables.md index f473fd35e9cf..d5262e9b008c 100644 --- a/documentation/docs/20-core-concepts/70-environment-variables.md +++ b/documentation/docs/20-core-concepts/70-environment-variables.md @@ -144,7 +144,21 @@ export const variables = defineEnvVars({ }); ``` -If a value is invalid, the app will fail to start (or build). +If a value is invalid, the app will fail to start (or build). To opt out of one or the other, use [`building`]($app-env#building) from `$app/env` along with a validator that accepts an optional value: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; ++++import { building } from '$app/env'+++ +import * as v from 'valibot'; + +export const variables = defineEnvVars({ + SECRET: { + // optional when building but required when starting the app + +++schema: building ? v.optional(v.string()) : v.string()+++ + } +}); +``` You can use validators to make values optional, or transform them (such as turning a string into a boolean, or parsing JSON) — see your validation library's documentation to learn how. diff --git a/packages/kit/src/core/env.js b/packages/kit/src/core/env.js index 883f34aace32..056f14635507 100644 --- a/packages/kit/src/core/env.js +++ b/packages/kit/src/core/env.js @@ -72,6 +72,10 @@ export async function load_explicit_env(kit, file, mode) { /** @type {Record>} */ let variables; + /** @type {import('../runtime/app/env/internal.js')} */ ( + await server.ssrLoadModule(`${runtime_directory}/app/env/internal.js`) + ).set_building(); + try { ({ variables } = await server.ssrLoadModule(file)); @@ -157,17 +161,14 @@ export function create_dynamic_module(type, dev_values, disabled) { /** * Creates the `__sveltekit/env` module - * @param {Record> | null} variables + * @param {Record | undefined> | null} variables * @param {Record} env * @param {string | null} entry */ export function create_sveltekit_env(variables, env, entry) { const imports = entry - ? [ - `import { variables } from ${JSON.stringify(entry)};`, - `import { validate, handle_issues } from '@sveltejs/kit/internal/env';` - ] - : [`const variables = {};`, `const handle_issues = () => {};`]; + ? [`import { validate, handle_issues } from '@sveltejs/kit/internal/env';`] + : [`const handle_issues = () => {};`]; const declarations = []; const setters = []; @@ -176,7 +177,7 @@ export function create_sveltekit_env(variables, env, entry) { const issues = {}; for (const [name, config] of Object.entries(variables ?? {})) { - if (config.static) { + if (config?.static) { if (config.public) { const value = validate(variables ?? {}, env[name], name, issues); declarations.push(`explicit_public_env.${name} = ${devalue.uneval(value)};`); @@ -186,7 +187,7 @@ export function create_sveltekit_env(variables, env, entry) { `const ${name} = validate(variables, env.${name}, ${JSON.stringify(name)}, issues);` ); - if (config.public) { + if (config?.public) { setters.push(`explicit_public_env.${name} = ${name};`); setters.push(`rendered_env.${name} = ${name};`); } else { @@ -201,14 +202,16 @@ export function create_sveltekit_env(variables, env, entry) { GENERATED_COMMENT, imports.join('\n'), `const issues = {};`, - 'export { variables }', 'export const dynamic_private_env = {};', 'export const explicit_public_env = {};', 'export const rendered_env = {};', ...declarations, `handle_issues(issues);`, + // avoid `variables` from being imported before `set_building` is called + // because that causes expressions with `building` to resolve incorrectly dedent` - export function set_env(env) { + export async function set_env(env) { + ${entry ? `const { variables } = (await import(${JSON.stringify(entry)}));` : ''} const issues = {}; ${setters.join('\n')} handle_issues(issues); diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index eda45829fb91..ac86994b4bd8 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -58,7 +58,7 @@ async function analyse({ const public_env = filter_env(env, public_prefix, private_prefix); internal.set_private_env(private_env); internal.set_public_env(public_env); - internal.set_env(env); + await internal.set_env(env); internal.set_manifest(manifest); internal.set_read_implementation((file) => createReadableStream(`${server_root}/server/${file}`)); diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index fcf07425a5b7..14ceb693112c 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -499,7 +499,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { const public_env = filter_env(env, public_prefix, private_prefix); internal.set_private_env(private_env); internal.set_public_env(public_env); - internal.set_env(env); + await internal.set_env(env); internal.set_manifest(manifest); internal.set_read_implementation((file) => createReadableStream(`${out}/server/${file}`)); diff --git a/packages/kit/src/exports/internal/env.js b/packages/kit/src/exports/internal/env.js index dcc89e02aa99..b34a8636ef74 100644 --- a/packages/kit/src/exports/internal/env.js +++ b/packages/kit/src/exports/internal/env.js @@ -16,7 +16,7 @@ const ASYNC_VALIDATOR = { }; /** - * @param {Record>} variables + * @param {Record | undefined>} variables * @param {string | undefined} value * @param {string} name * @param {Record} issues diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 3b900845f9af..02d70d5f3d54 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -51,10 +51,19 @@ export async function preview(vite, vite_config, svelte_config) { set_assets(assets); const server = new Server(manifest); - await server.init({ - env: loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''), - read: (file) => createReadableStream(`${dir}/${file}`) - }); + + try { + await server.init({ + env: loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''), + read: (file) => createReadableStream(`${dir}/${file}`) + }); + } catch (error) { + // Vite erases the error message when starting the preview server so we store + // it in the stack instead. This ensures errors thrown using `stackless` + // are still readable + if (error instanceof Error) error.stack = error.message; + throw error; + } const emulator = await svelte_config.kit.adapter?.emulate?.(); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 793cd5ccc69b..b37e89d5e9b2 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -63,7 +63,7 @@ export class Server { set_private_env(filter_env(env, env_private_prefix, env_public_prefix)); set_public_env(filter_env(env, env_public_prefix, env_private_prefix)); - set_env(env); + await set_env(env); if (read) { // Wrap the read function to handle MaybePromise diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 92f114253cbc..c11750a8cb3f 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -23,7 +23,7 @@ declare module '__sveltekit/env' { // exported environment variables are defined in env.d.ts /** Populate exported environment variables */ - export function set_env(environment: Record): void; + export function set_env(environment: Record): Promise; /** public env vars */ export const explicit_public_env: Record; diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index d57bfd57f838..8232989619ec 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -44,7 +44,7 @@ export interface ServerModule { export interface ServerInternalModule { set_assets(path: string): void; set_building(): void; - set_env(environment: Record): void; + set_env(environment: Record): Promise; set_manifest(manifest: SSRManifest): void; set_prerendering(): void; set_private_env(environment: Record): void; diff --git a/packages/kit/test/apps/options-2/package.json b/packages/kit/test/apps/options-2/package.json index 09ede907dc91..2d1a05104532 100644 --- a/packages/kit/test/apps/options-2/package.json +++ b/packages/kit/test/apps/options-2/package.json @@ -3,9 +3,9 @@ "private": true, "version": "0.0.1", "scripts": { - "dev": "vite dev", + "dev": "RUNTIME_ONLY=secret vite dev", "build": "vite build", - "preview": "vite preview", + "preview": "RUNTIME_ONLY=secret vite preview", "prepare": "svelte-kit sync", "check": "svelte-kit sync && tsc && svelte-check", "test": "pnpm test:dev && pnpm test:build", diff --git a/packages/kit/test/apps/options-2/src/env.ts b/packages/kit/test/apps/options-2/src/env.ts index 78cdd1569972..d61d03fddfec 100644 --- a/packages/kit/test/apps/options-2/src/env.ts +++ b/packages/kit/test/apps/options-2/src/env.ts @@ -1,4 +1,5 @@ import process from 'node:process'; +import { building } from '$app/env'; import * as v from 'valibot'; export const variables = { @@ -13,5 +14,8 @@ export const variables = { }, PRIVATE_VALIDATED_DEFAULT_ENV: { schema: v.optional(v.picklist(['foo', 'bar']), 'foo') + }, + RUNTIME_ONLY: { + schema: building ? v.optional(v.string()) : v.string() } }; diff --git a/packages/kit/test/apps/options-2/src/routes/env/runtime-only/+page.server.js b/packages/kit/test/apps/options-2/src/routes/env/runtime-only/+page.server.js new file mode 100644 index 000000000000..c7af6fedec93 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/routes/env/runtime-only/+page.server.js @@ -0,0 +1,7 @@ +import { RUNTIME_ONLY } from '$app/env/private'; + +export function load() { + return { + runtime_environment_variable: !!RUNTIME_ONLY + }; +} diff --git a/packages/kit/test/apps/options-2/src/routes/env/runtime-only/+page.svelte b/packages/kit/test/apps/options-2/src/routes/env/runtime-only/+page.svelte new file mode 100644 index 000000000000..54c0f93f5bd3 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/routes/env/runtime-only/+page.svelte @@ -0,0 +1,5 @@ + + +

runtime-only environment variable exists: {data.runtime_environment_variable}

diff --git a/packages/kit/test/apps/options-2/test/env.test.js b/packages/kit/test/apps/options-2/test/env.test.js index c82dd34a4d7c..cf1d7adc468b 100644 --- a/packages/kit/test/apps/options-2/test/env.test.js +++ b/packages/kit/test/apps/options-2/test/env.test.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { execFile } from 'node:child_process'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { expect } from '@playwright/test'; @@ -56,7 +57,8 @@ test.describe('$app/env', () => { JSON.stringify({ PRIVATE_EXPLICIT_ENV: 'secret resolved at runtime', PRIVATE_STATIC_EXPLICIT_ENV: 'secret resolved at build time', - PRIVATE_VALIDATED_DEFAULT_ENV: 'foo' + PRIVATE_VALIDATED_DEFAULT_ENV: 'foo', + RUNTIME_ONLY: 'secret' }) ); @@ -84,4 +86,23 @@ test.describe('$app/env', () => { expect(serviceWorker).not.toContain('importScripts("/basepath/_app/env.script.js");'); expect(fs.existsSync(`${output}/prerendered/dependencies/_app/env.script.js`)).toBeFalsy(); }); + + test('runtime-only validation', async ({ page, javaScriptEnabled }) => { + test.skip(javaScriptEnabled); + + await page.goto('/basepath/env/runtime-only'); + await expect(page.locator('p')).toHaveText('runtime-only environment variable exists: true'); + + // test validation runs at runtime + if (!process.env.DEV) { + const app_dir = fileURLToPath(new URL('..', import.meta.url)); + const output = await new Promise((resolve) => { + execFile('pnpm', ['vite', 'preview'], { cwd: app_dir }, (_, stdout, stderr) => + resolve(stdout + stderr) + ); + }); + expect(output).toContain('Invalid environment variables'); + expect(output).toContain('RUNTIME_ONLY'); + } + }); });