diff --git a/.changeset/thick-dolls-run.md b/.changeset/thick-dolls-run.md new file mode 100644 index 000000000000..ed529a3f1984 --- /dev/null +++ b/.changeset/thick-dolls-run.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: explicit env vars diff --git a/documentation/docs/10-getting-started/30-project-structure.md b/documentation/docs/10-getting-started/30-project-structure.md index 719a0a88d5b4..948ed337a377 100644 --- a/documentation/docs/10-getting-started/30-project-structure.md +++ b/documentation/docs/10-getting-started/30-project-structure.md @@ -48,7 +48,7 @@ The `src` directory contains the meat of your project. Everything except `src/ro - `%sveltekit.body%` — the markup for a rendered page. This should live inside a `
` or other element, rather than directly inside ``, to prevent bugs caused by browser extensions injecting elements that are then destroyed by the hydration process. SvelteKit will warn you in development if this is not the case - `%sveltekit.assets%` — either [`paths.assets`](configuration#paths), if specified, or a relative path to [`paths.base`](configuration#paths) - `%sveltekit.nonce%` — a [CSP](configuration#csp) nonce for manually included links and scripts, if used - - `%sveltekit.env.[NAME]%` - this will be replaced at render time with the `[NAME]` environment variable, which must begin with the [`publicPrefix`](configuration#env) (usually `PUBLIC_`). It will fallback to `''` if not matched. + - `%sveltekit.env.[NAME]%` - this will be replaced at render time with the `[NAME]` environment variable, which must begin with the [`publicPrefix`](configuration#env) (usually `PUBLIC_`), or be defined as a public variable in `src/env` if using [`experimental.explicitEnvironmentVariables`](environment-variables). It will fallback to `''` if not matched. - `%sveltekit.version%` — the app version, which can be specified with the [`version`](configuration#version) configuration - `error.html` is the page that is rendered when everything else fails. It can contain the following placeholders: - `%sveltekit.status%` — the HTTP status diff --git a/documentation/docs/20-core-concepts/70-environment-variables.md b/documentation/docs/20-core-concepts/70-environment-variables.md new file mode 100644 index 000000000000..013389b05c20 --- /dev/null +++ b/documentation/docs/20-core-concepts/70-environment-variables.md @@ -0,0 +1,205 @@ +--- +title: Environment variables +--- + +Environment variables are values your app needs that exist separately from the app's source code. They allow you to use sensitive information like API keys and database credentials without storing them in version control. + +During development, and at build time, variables defined in a `.env` or `.env.local` file will be added to the environment: + +```env +API_KEY=19f401ba-e8b0-48c4-8c77-b0ebb26d97fe +``` + +By default, every environment variable is implicitly available inside your app via the following modules: + +- [`$env/static/private`]($env-static-private) +- [`$env/static/public`]($env-static-public) +- [`$env/dynamic/private`]($env-dynamic-private) +- [`$env/dynamic/public`]($env-dynamic-public) + +## Explicit environment variables + +As of SvelteKit 2.62, you can opt into _explicit_ environment variables, in which case you instead import environment variables from these modules: + +- [`$app/env/private`]($app-env-private) +- [`$app/env/public`]($app-env-public) + +Additionally, the [`$app/environment`]($app-environment) module is renamed to [`$app/env`]($app-env). + +> [!NOTE] Explicit environment variables will become the default in SvelteKit 3. The `$env/*` modules, along with `$app/environment`, will be removed. + +### Setup + +To opt in, update your configuration... + +```js +/// file: svelte.config.js +export default { + kit: { + experimental: { + +++explicitEnvironmentVariables: true+++ + } + } +}; +``` + +...and add a `src/env.ts` (or `src/env.js`) file that exports a `variables` object: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; + +export const variables = defineEnvVars({ + // ... +}); +``` + +Each value in the object passed to [`defineEnvVars`](@sveltejs-kit-hooks#defineEnvVars) is an [`EnvVarConfig`](@sveltejs-kit#EnvVarConfig) object that configures the environment variable. + +> [!NOTE] `defineEnvVars` returns its argument unaltered — it exists purely to help with type safety. + +### Private variables + +By default, all variables are considered private. For example, you don't want to reveal your `API_KEY`: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; + +export const variables = defineEnvVars({ + +++API_KEY: {}+++ +}); +``` + +> [!NOTE] Since no configuration is needed for this variable, we can use an empty object (`{}`). + +Now that `API_KEY` is defined, it can be imported into app code via `$app/env/private`: + +```js +import { API_KEY } from '$app/env/private'; +``` + +The `$app/env/private` module cannot be imported into code that runs in the browser, so that you can't accidentally reveal your secrets in a JavaScript bundle. + +### Public variables + +Some variables are perfectly safe — necessary, even — to expose to the browser. For these, we can specify `public: true`: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; + +export const variables = defineEnvVars({ + GOOGLE_ANALYTICS_ID: { + +++public: true+++ + } +}); +``` + +`GOOGLE_ANALYTICS_ID` can now be imported from `$app/env/public`, or used in your `app.html` template as `%sveltekit.env.GOOGLE_ANALYTICS_ID%`: + +```html + + + + + + + + %sveltekit.head% + ++++ + +++ + + +
%sveltekit.body%
+ + +``` + +### Validation + +You can specify a [Standard Schema](https://standardschema.dev/) validator such as [Zod](https://zod.dev/) or [Valibot](https://valibot.dev/) to check that an environment variable value is correct: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; ++++import * as v from 'valibot';+++ + +export const variables = defineEnvVars({ + GOOGLE_ANALYTICS_ID: { + public: true, + +++validate: v.pipe(v.string(), v.regex(/G-[A-Z0-9]+/))+++ + } +}); +``` + +If a value is invalid, the app will fail to start (or build). + +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. + +### Static variables + +If a variable is configured with `static: true`, it will be inlined into your application code, enabling optimisations like dead-code elimination: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; +import * as v from 'valibot'; + +export const variables = defineEnvVars({ + SHOW_DEBUG_OVERLAY: { + public: true, + +++static: true,+++ + + // coerce to true/false + validate: v.pipe( + v.optional(v.string(), ''), + v.transform((str) => str !== '') + ) + } +}); +``` + +Because this variable is `static`, the `` component shown here will be excluded from the JavaScript bundle unless `SHOW_DEBUG_OVERLAY` is truthy: + +```svelte + + +{#if SHOW_DEBUG_OVERLAY} + +{/if} +``` + +But if the variable is set before building the app... + +```bash +SHOW_DEBUG_OVERLAY=true npm run build +``` + +...then the component will be included and shown. + +### Documenting variables + +You can document the purpose of an environment variable by adding a `description`: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; + +export const variables = defineEnvVars({ + CACHE_TTL_SECONDS: { + description: 'How long to cache responses, in seconds' + } +}); +``` + +Hovering over `CACHE_TTL_SECONDS` in your app code will show the description. diff --git a/documentation/docs/98-reference/19-$app-env.md b/documentation/docs/98-reference/19-$app-env.md new file mode 100644 index 000000000000..88531a242957 --- /dev/null +++ b/documentation/docs/98-reference/19-$app-env.md @@ -0,0 +1,7 @@ +--- +title: $app/env +--- + +> [!NOTE] This is an alias of [`$app/environment`]($app-environment), used when [explicit environment variables](environment-variables#Explicit-environment-variables) are enabled. + +> MODULE: $app/env diff --git a/documentation/docs/98-reference/20-$app-env-private.md b/documentation/docs/98-reference/20-$app-env-private.md new file mode 100644 index 000000000000..51c2ea22124d --- /dev/null +++ b/documentation/docs/98-reference/20-$app-env-private.md @@ -0,0 +1,9 @@ +--- +title: $app/env/private +--- + +Private [environment variables](environment-variables) defined in `src/env.ts` (or `src/env.js`). + +To use this module, you must enable the `experimental.explicitEnvironmentVariables` flag in your project configuration. + + diff --git a/documentation/docs/98-reference/20-$app-env-public.md b/documentation/docs/98-reference/20-$app-env-public.md new file mode 100644 index 000000000000..6845c96858a9 --- /dev/null +++ b/documentation/docs/98-reference/20-$app-env-public.md @@ -0,0 +1,9 @@ +--- +title: $app/env/public +--- + +Public [environment variables](environment-variables) defined in `src/env.ts` (or `src/env.js`). + +To use this module, you must enable the `experimental.explicitEnvironmentVariables` flag in your project configuration. + + diff --git a/packages/kit/kit.vitest.config.js b/packages/kit/kit.vitest.config.js index 92b0acc01cbc..e006f20ee7f4 100644 --- a/packages/kit/kit.vitest.config.js +++ b/packages/kit/kit.vitest.config.js @@ -27,10 +27,11 @@ export default defineConfig({ alias: { // Order matters: vite prefix-matches with trailing-slash, so longer keys must // come first to avoid `$app/paths` matching `$app/paths/internal/client`. + '$app/env/internal': mock('app-env-internal'), + '$app/env': mock('app-env'), '$app/paths/internal/client': mock('app-paths-internal-client'), '$app/paths/internal/server': mock('app-paths-internal-server'), '$app/paths': mock('app-paths'), - '__sveltekit/environment': mock('sveltekit-environment'), '__sveltekit/paths': mock('sveltekit-paths') }, projects: [ diff --git a/packages/kit/package.json b/packages/kit/package.json index 84af819acd60..2b334ec8341a 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -94,6 +94,10 @@ "#app/paths": { "browser": "./src/runtime/app/paths/client.js", "default": "./src/runtime/app/paths/server.js" + }, + "#app/env/public": { + "browser": "./src/runtime/app/env/public/client.js", + "default": "./src/runtime/app/env/public/server.js" } }, "exports": { @@ -106,6 +110,13 @@ "types": "./types/index.d.ts", "import": "./src/exports/internal/index.js" }, + "./internal/env": { + "types": "./types/index.d.ts", + "import": "./src/exports/internal/env.js" + }, + "./internal/types": { + "import": "./src/exports/internal/types.js" + }, "./internal/server": { "types": "./types/index.d.ts", "import": "./src/exports/internal/server.js" diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js index b470d6d63494..75f000f90bbf 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -9,6 +9,7 @@ await createBundle({ '@sveltejs/kit/node': 'src/exports/node/index.js', '@sveltejs/kit/node/polyfills': 'src/exports/node/polyfills.js', '@sveltejs/kit/vite': 'src/exports/vite/index.js', + '$app/env': 'src/runtime/app/env/types.d.ts', '$app/environment': 'src/runtime/app/environment/types.d.ts', '$app/forms': 'src/runtime/app/forms.js', '$app/navigation': 'src/runtime/app/navigation.js', diff --git a/packages/kit/src/cli.js b/packages/kit/src/cli.js index 98f2cf03d2b2..12210697e5c0 100755 --- a/packages/kit/src/cli.js +++ b/packages/kit/src/cli.js @@ -4,6 +4,7 @@ import { parseArgs } from 'node:util'; import colors from 'kleur'; import { load_config } from './core/config/index.js'; import { coalesce_to_error } from './utils/error.js'; +import { resolve_explicit_env_entry } from './core/env.js'; /** @param {unknown} e */ function handle_error(e) { @@ -77,6 +78,9 @@ if (command === 'sync') { const config = await load_config(); const sync = await import('./core/sync/sync.js'); sync.all_types(config, values.mode); + + const explicit_env_entry = resolve_explicit_env_entry(config.kit); + await sync.env(config.kit, explicit_env_entry, values.mode); } catch (error) { handle_error(error); } diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index bb8f358d93a0..fb618f2f22be 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -1,8 +1,10 @@ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ /** @import { Builder } from '@sveltejs/kit' */ /** @import { ResolvedConfig } from 'vite' */ -/** @import { RouteDefinition } from '@sveltejs/kit' */ +/** @import { RouteDefinition, EnvVarConfig } from '@sveltejs/kit' */ /** @import { RouteData, ValidatedConfig, BuildData, ServerMetadata, ServerMetadataRoute, Prerendered, PrerenderMap, Logger, RemoteChunk } from 'types' */ import colors from 'kleur'; +import * as devalue from 'devalue'; import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'; import { extname, resolve, join, dirname, relative } from 'node:path'; import { pipeline } from 'node:stream'; @@ -17,6 +19,7 @@ import { write } from '../sync/utils.js'; import { list_files } from '../utils.js'; import { find_server_assets } from '../generate_manifest/find_server_assets.js'; import { reserved } from '../env.js'; +import { handle_issues, validate } from '../../exports/internal/env.js'; const pipe = promisify(pipeline); const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.wasm', '.txt']; @@ -32,7 +35,8 @@ const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.w * prerender_map: PrerenderMap; * log: Logger; * vite_config: ResolvedConfig; - * remotes: RemoteChunk[] + * remotes: RemoteChunk[]; + * explicit_env_config: Record> | null; * }} opts * @returns {Builder} */ @@ -45,7 +49,8 @@ export function create_builder({ prerender_map, log, vite_config, - remotes + remotes, + explicit_env_config }) { /** @type {Map} */ const lookup = new Map(); @@ -168,7 +173,7 @@ export function create_builder({ const fallback = await generate_fallback({ manifest_path, - env: { ...env.private, ...env.public }, + env: env.all, out_dir: config.kit.outDir, origin: config.kit.prerender.origin, assets: config.kit.files.assets @@ -193,7 +198,23 @@ export function create_builder({ const dest = `${config.kit.outDir}/output/prerendered/dependencies/${config.kit.appDir}/env.js`; const env = get_env(config.kit.env, vite_config.mode); - write(dest, `export const env=${JSON.stringify(env.public)}`); + const values = config.kit.experimental.explicitEnvironmentVariables ? {} : env.public; + + if (config.kit.experimental.explicitEnvironmentVariables) { + const variables = explicit_env_config ?? {}; + + /** @type {Record} */ + const issues = {}; + + for (const [name, config] of Object.entries(variables)) { + if (config.static || !config.public) continue; + values[name] = validate(variables, env.all[name], name, issues); + } + + handle_issues(issues); + } + + write(dest, `export const env=${devalue.uneval(values)}`); }, generateManifest({ relativePath, routes: subset }) { diff --git a/packages/kit/src/core/adapt/index.js b/packages/kit/src/core/adapt/index.js index 48d14116369e..c4a9ea2af613 100644 --- a/packages/kit/src/core/adapt/index.js +++ b/packages/kit/src/core/adapt/index.js @@ -10,6 +10,7 @@ import { create_builder } from './builder.js'; * @param {import('types').Logger} log * @param {import('types').RemoteChunk[]} remotes * @param {import('vite').ResolvedConfig} vite_config + * @param {Record> | null} explicit_env_config */ export async function adapt( config, @@ -19,7 +20,8 @@ export async function adapt( prerender_map, log, remotes, - vite_config + vite_config, + explicit_env_config ) { // This is only called when adapter is truthy, so the cast is safe const { name, adapt } = /** @type {import('@sveltejs/kit').Adapter} */ (config.kit.adapter); @@ -35,7 +37,8 @@ export async function adapt( prerender_map, log, remotes, - vite_config + vite_config, + explicit_env_config }); await adapt(builder); diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 1ef17025d164..e1bc73d0d394 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -33,11 +33,13 @@ export function load_template(cwd, { kit }) { } }); - for (const match of contents.matchAll(/%sveltekit\.env\.([^%]+)%/g)) { - if (!match[1].startsWith(env.publicPrefix)) { - throw new Error( - `Environment variables in ${relative} must start with ${env.publicPrefix} (saw %sveltekit.env.${match[1]}%)` - ); + if (!kit.experimental.explicitEnvironmentVariables) { + for (const match of contents.matchAll(/%sveltekit\.env\.([^%]+)%/g)) { + if (!match[1].startsWith(env.publicPrefix)) { + throw new Error( + `Environment variables in ${relative} must start with ${env.publicPrefix} (saw %sveltekit.env.${match[1]}%)` + ); + } } } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 3ce48925de48..914423bdf194 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -80,6 +80,7 @@ const get_defaults = (prefix = '') => ({ experimental: { tracing: { server: false }, instrumentation: { server: false }, + explicitEnvironmentVariables: false, remoteFunctions: false, forkPreloads: false, handleRenderingErrors: false @@ -108,6 +109,7 @@ const get_defaults = (prefix = '') => ({ resolution: 'client' }, serviceWorker: { + options: undefined, register: true }, typescript: {}, diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index ac30ce4fbd94..612fa471fb61 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -139,6 +139,7 @@ const options = object( instrumentation: object({ server: boolean(false) }), + explicitEnvironmentVariables: boolean(false), remoteFunctions: boolean(false), forkPreloads: boolean(false), handleRenderingErrors: boolean(false) diff --git a/packages/kit/src/core/env.js b/packages/kit/src/core/env.js index 455eb0f44b68..080e50787e8e 100644 --- a/packages/kit/src/core/env.js +++ b/packages/kit/src/core/env.js @@ -1,19 +1,126 @@ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ +/** @import { EnvVarConfig } from '@sveltejs/kit' */ +/** @import { ValidatedKitConfig } from 'types' */ +import path from 'node:path'; +import process from 'node:process'; +import * as vite from 'vite'; +import * as devalue from 'devalue'; import { GENERATED_COMMENT } from '../constants.js'; import { dedent } from './sync/utils.js'; -import { runtime_base } from './utils.js'; +import { runtime_base, runtime_directory } from './utils.js'; +import { resolve_entry } from '../utils/filesystem.js'; +import { handle_issues, validate } from '../exports/internal/env.js'; +import { get_config_aliases } from '../exports/vite/utils.js'; /** * @typedef {'public' | 'private'} EnvType */ +let warned = false; + +/** + * @param {import('types').ValidatedKitConfig} config + * @returns {string | null} + */ +export function resolve_explicit_env_entry(config) { + const resolved = resolve_entry(path.join(config.files.src, 'env')); + + if (resolved) { + if (config.experimental.explicitEnvironmentVariables) { + return resolved; + } + + if (!warned) { + console.warn( + `${path.relative(process.cwd(), resolved)} requires the \`experimental.explicitEnvironmentVariables\` flag to be set` + ); + warned = true; + } + } else if (config.experimental.explicitEnvironmentVariables) { + console.warn( + 'experimental.explicitEnvironmentVariables was set, but no src/env.ts or src/env.js file could be found' + ); + } + + return null; +} + +/** + * @param {ValidatedKitConfig} kit + * @param {string | null} file + * @param {string} mode + * @returns {Promise> | null>} + */ +export async function load_explicit_env(kit, file, mode) { + if (!file) return null; + + const server = await vite.createServer({ + configFile: false, + logLevel: 'silent', + mode, + define: { + __SVELTEKIT_APP_VERSION__: JSON.stringify(kit.version.name) // needed by $app/env + }, + resolve: { + alias: [ + { find: '$app/env', replacement: `${runtime_directory}/app/env` }, + ...get_config_aliases(kit) + ] + } + }); + + /** @type {Record>} */ + let variables; + + try { + ({ variables } = await server.ssrLoadModule(file)); + + if (!variables || typeof variables !== 'object') { + throw new Error(`${file} must export a variables object`); + } + + // validate + for (const name of Object.keys(variables)) { + if (!valid_identifier.test(name) || reserved.has(name)) { + throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`); + } + } + } catch (e) { + const error = /** @type {any} */ (e || {}); + + if ( + error.code === 'ERR_MODULE_NOT_FOUND' && + error.message?.includes(`Cannot find module '$app`) + ) { + throw new Error( + `Cannot import \`$app/*\` modules other than \`$app/env\` inside \`src/env\``, + { cause: e } + ); + } + + throw error; + } finally { + await server.close(); + } + + return variables; +} + /** * @param {string} id * @param {Record} env + * @param {boolean} disabled * @returns {string} */ -export function create_static_module(id, env) { +export function create_static_module(id, env, disabled) { /** @type {string[]} */ - const declarations = []; + const statements = []; + + if (disabled) { + statements.push( + `throw new Error('Cannot import \`${id}\` when \`experimental.explicitEnvironmentVariables\` is enabled. Use \`${id.replace('$env/static', '$app/env')}\` instead.');` + ); + } for (const key in env) { if (!valid_identifier.test(key) || reserved.has(key)) { @@ -23,24 +130,128 @@ export function create_static_module(id, env) { const comment = `/** @type {import('${id}').${key}} */`; const declaration = `export const ${key} = ${JSON.stringify(env[key])};`; - declarations.push(`${comment}\n${declaration}`); + statements.push(`${comment}\n${declaration}`); } - return GENERATED_COMMENT + declarations.join('\n\n'); + return GENERATED_COMMENT + statements.join('\n\n'); } /** * @param {EnvType} type * @param {Record | undefined} dev_values If in a development mode, values to pre-populate the module with. + * @param {boolean} disabled */ -export function create_dynamic_module(type, dev_values) { +export function create_dynamic_module(type, dev_values, disabled) { + const prelude = disabled + ? `throw new Error('Cannot import \`$env/dynamic/${type}\` when \`experimental.explicitEnvironmentVariables\` is enabled. Use \`$app/env/${type}\` instead.');\n\n` + : ''; + if (dev_values) { const keys = Object.entries(dev_values).map( ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}` ); - return `export const env = {\n${keys.join(',\n')}\n}`; + return `${prelude}export const env = {\n${keys.join(',\n')}\n}`; } - return `export { ${type}_env as env } from '${runtime_base}/shared-server.js';`; + return `${prelude}export { ${type}_env as env } from '${runtime_base}/shared-server.js';`; +} + +/** + * Creates the `__sveltekit/env` module + * @param {Record> | 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 = () => {};`]; + + const declarations = []; + const setters = []; + + /** @type {Record} */ + const issues = {}; + + for (const [name, config] of Object.entries(variables ?? {})) { + if (config.static) { + const value = validate(variables ?? {}, env[name], name, issues); + declarations.push(`export const ${name} = ${devalue.uneval(value)};`); + + if (config.public) { + declarations.push(`explicit_public_env.${name} = ${name};`); + } + } else { + declarations.push(`export var ${name};`); + setters.push(`${name} = validate(variables, env.${name}, ${JSON.stringify(name)}, issues);`); + + if (config.public) { + setters.push(`explicit_public_env.${name} = ${name};`); + setters.push(`rendered_env.${name} = ${name};`); + } + } + } + + handle_issues(issues); + + const blocks = [ + GENERATED_COMMENT, + imports.join('\n'), + `const issues = {};`, + 'export { variables }', + 'export const explicit_public_env = {};', + 'export const rendered_env = {};', + ...declarations, + `handle_issues(issues);`, + dedent` + export function set_env(env) { + const issues = {}; + ${setters.join('\n')} + handle_issues(issues); + }` + ]; + + const module = blocks.join('\n\n'); + + return module; +} + +/** + * Creates the `__sveltekit/env/browser` module + * @param {Record> | null} variables + * @param {Record} env + * @param {string} global + */ +export function create_sveltekit_env_browser(variables, env, global) { + if (!variables) { + return ''; + } + + /** @type {Record} */ + const issues = {}; + + const exports = Object.entries(variables).map(([name, config]) => { + if (config.static) { + const value = validate(variables, env[name], name, issues); + return `export const ${name} = ${devalue.uneval(value)};\n`; + } + + return `export const ${name} = env.${name};\n`; + }); + + handle_issues(issues); + + return `const env = ${global}.env;\n\n${exports.join('')}`; +} + +/** @param {string} description */ +function create_jsdoc(description) { + return `/**\n${description + .split('\n') + .map((line) => ` * ${line.replaceAll('*/', '*\\/')}`) + .join('\n')}\n */`; } /** @@ -98,6 +309,29 @@ export function create_dynamic_types(id, env, { public_prefix, private_prefix }) `; } +/** + * @param {Record>} variables + * @param {string} relative + * @param {EnvType} type + */ +export function create_explicit_env_types(variables, relative, type) { + const declarations = Object.entries(variables) + .filter(([_, config]) => !!config.public === (type === 'public')) + .map(([name, config]) => { + const comment = config.description ? `${create_jsdoc(config.description)}\n` : ''; + const type = config.schema + ? `import('@sveltejs/kit/internal/types').StandardSchemaV1.InferOutput` + : 'string'; + return `${comment}export const ${name}: ${type};`; + }); + + return dedent` + declare module '$app/env/${type}' { + ${declarations.join('\n') || `// no ${type} environment variables were defined`} + } + `; +} + export const reserved = new Set([ 'do', 'if', diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 9255dd40f5f3..81789c7c34d2 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -50,7 +50,7 @@ async function analyse({ installPolyfills(); - // configure `import { building } from '$app/environment'` — + // configure `import { building } from '$app/environment'` and `$app/env` — // essential we do this before analysing the code internal.set_building(); @@ -60,6 +60,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); 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 9251d6fac173..a811be3e2f3d 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -46,7 +46,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { /** @type {import('types').ServerModule} */ const { Server } = await import(pathToFileURL(`${out}/server/index.js`).href); - // configure `import { building } from '$app/environment'` — + // configure `import { building } from '$app/environment'` and `$app/env` — // essential we do this before analysing the code internal.set_building(); internal.set_prerendering(); @@ -497,6 +497,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); internal.set_manifest(manifest); internal.set_read_implementation((file) => createReadableStream(`${out}/server/${file}`)); diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index d9680e8ab65f..643f04341dad 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -11,6 +11,8 @@ import { create_node_analyser, get_page_options } from '../../exports/vite/static_analysis/index.js'; +import { load_explicit_env } from '../env.js'; +import { write_env } from './write_env.js'; /** * Initialize SvelteKit's generated files that only depend on the config and mode. @@ -87,6 +89,20 @@ export function all_types(config, mode) { write_non_ambient(config.kit, manifest_data); } +/** + * Generate modules and types for explicit env vars + * @param {import('types').ValidatedKitConfig} kit + * @param {string | null} entry + * @param {string} mode The Vite mode + */ +export async function env(kit, entry, mode) { + const env_config = await load_explicit_env(kit, entry, mode); + + write_env(kit, entry, env_config); + + return env_config; +} + /** * Regenerate __SERVER__/internal.js in response to src/{app.html,error.html,service-worker.js} changing * @param {import('types').ValidatedConfig} config diff --git a/packages/kit/src/core/sync/write_ambient.js b/packages/kit/src/core/sync/write_ambient.js index 1f2188097ade..dd798178c86f 100644 --- a/packages/kit/src/core/sync/write_ambient.js +++ b/packages/kit/src/core/sync/write_ambient.js @@ -53,11 +53,17 @@ ${create_dynamic_types('public', env, prefixes)} * @param {string} mode The Vite mode */ export function write_ambient(config, mode) { - const env = get_env(config.env, mode); - const { publicPrefix: public_prefix, privatePrefix: private_prefix } = config.env; + /** @type {string} */ + let content; - write_if_changed( - path.join(config.outDir, 'ambient.d.ts'), - template(env, { public_prefix, private_prefix }) - ); + if (config.experimental.explicitEnvironmentVariables) { + content = `${GENERATED_COMMENT}\n/// `; + } else { + const env = get_env(config.env, mode); + const { publicPrefix: public_prefix, privatePrefix: private_prefix } = config.env; + + content = template(env, { public_prefix, private_prefix }); + } + + write_if_changed(path.join(config.outDir, 'ambient.d.ts'), content); } diff --git a/packages/kit/src/core/sync/write_env.js b/packages/kit/src/core/sync/write_env.js new file mode 100644 index 000000000000..07be47bba8e9 --- /dev/null +++ b/packages/kit/src/core/sync/write_env.js @@ -0,0 +1,32 @@ +/** @import { EnvVarConfig } from '@sveltejs/kit' */ +import path from 'node:path'; +import { create_explicit_env_types } from '../env.js'; +import { write_if_changed } from './utils.js'; + +const DOCS = '// See https://svelte.dev/docs/kit/environment-variables for more information'; + +/** + * Writes ambient declarations including types reference to @sveltejs/kit, + * and the existing environment variables in process.env to + * $env/static/private and $env/static/public + * @param {import('types').ValidatedKitConfig} kit + * @param {string | null} entry + * @param {Record> | null} env_config + */ +export function write_env(kit, entry, env_config) { + const content = []; + const out = path.join(kit.outDir, 'env.d.ts'); + + if (entry && env_config) { + const relative = path.relative(kit.outDir, entry); + content.push( + `// This file is generated from ${relative}.\n${DOCS}`, + create_explicit_env_types(env_config, relative, 'private'), + create_explicit_env_types(env_config, relative, 'public') + ); + } else { + content.push(DOCS); + } + + write_if_changed(out, content.join('\n\n')); +} diff --git a/packages/kit/src/core/sync/write_root.js b/packages/kit/src/core/sync/write_root.js index 445c17e45dbc..213e5b88ddd5 100644 --- a/packages/kit/src/core/sync/write_root.js +++ b/packages/kit/src/core/sync/write_root.js @@ -100,7 +100,7 @@ export function write_root(manifest_data, config, output) { ${isSvelte5Plus() ? '' : ''} -

static: {PUBLIC_LOOK_IN_OPTIONS_2}

-

dynamic: {env.PUBLIC_LOOK_IN_OPTIONS_2}

+

public: {MESSAGE}

+

browser: {browser}

+

private dynamic: {data.private_dynamic}

+

private static: {data.private_static}

+

+ private validated default: {data.private_validated_default} +

diff --git a/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js b/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js index e237b43a6fb2..96a6d6521cfe 100644 --- a/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js +++ b/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js @@ -1,4 +1,4 @@ -import { building, dev } from '$app/environment'; +import { building, dev } from '$app/env'; import { command, form, prerender, query } from '$app/server'; import * as v from 'valibot'; diff --git a/packages/kit/test/apps/options-2/svelte.config.js b/packages/kit/test/apps/options-2/svelte.config.js index 498caf330656..c474601b8ada 100644 --- a/packages/kit/test/apps/options-2/svelte.config.js +++ b/packages/kit/test/apps/options-2/svelte.config.js @@ -28,6 +28,7 @@ const config = { bundleStrategy: 'single' }, experimental: { + explicitEnvironmentVariables: true, remoteFunctions: true } } diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js index 2c4541354be4..2aa6fdbe20a1 100644 --- a/packages/kit/test/apps/options-2/test/test.js +++ b/packages/kit/test/apps/options-2/test/test.js @@ -9,8 +9,21 @@ test.describe.configure({ mode: 'parallel' }); test.describe('env', () => { test('resolves upwards', async ({ page }) => { await page.goto('/basepath/env'); - expect(await page.textContent('[data-testid="static"]')).toBe('static: resolves upwards!'); - expect(await page.textContent('[data-testid="dynamic"]')).toBe('dynamic: resolves upwards!'); + expect(await page.textContent('[data-testid="public"]')).toBe('public: hello'); + expect(await page.textContent('[data-testid="private-dynamic"]')).toBe( + 'private dynamic: secret resolved at runtime' + ); + expect(await page.textContent('[data-testid="private-static"]')).toBe( + 'private static: secret resolved at build time' + ); + expect(await page.textContent('[data-testid="private-validated-default"]')).toBe( + 'private validated default: foo' + ); + }); + + test('applies explicit env vars to %sveltekit.env%', async ({ page }) => { + await page.goto('/basepath'); + await expect(page.locator('body')).toHaveAttribute('data-message', 'hello'); }); }); diff --git a/packages/kit/test/env/.env b/packages/kit/test/env/.env index 33732e8f8d1a..e1e9badcab07 100644 --- a/packages/kit/test/env/.env +++ b/packages/kit/test/env/.env @@ -1 +1,5 @@ -PUBLIC_LOOK_IN_OPTIONS_2=resolves upwards! \ No newline at end of file +# the options-2 test app checks that these vars are loaded +# from a different directory than the cwd +MESSAGE="hello" +PRIVATE_EXPLICIT_ENV="secret resolved at runtime" +PRIVATE_STATIC_EXPLICIT_ENV="secret resolved at build time" diff --git a/packages/kit/test/mocks/app-env-internal.js b/packages/kit/test/mocks/app-env-internal.js new file mode 100644 index 000000000000..afdbf1926bb7 --- /dev/null +++ b/packages/kit/test/mocks/app-env-internal.js @@ -0,0 +1,11 @@ +export const version = ''; +export let building = false; +export let prerendering = false; + +export function set_building() { + building = true; +} + +export function set_prerendering() { + prerendering = true; +} diff --git a/packages/kit/test/mocks/app-env.js b/packages/kit/test/mocks/app-env.js new file mode 100644 index 000000000000..931b94abac9b --- /dev/null +++ b/packages/kit/test/mocks/app-env.js @@ -0,0 +1,2 @@ +export { BROWSER as browser, DEV as dev } from 'esm-env'; +export { building, version } from './app-env-internal.js'; diff --git a/packages/kit/test/mocks/sveltekit-environment.js b/packages/kit/test/mocks/sveltekit-environment.js deleted file mode 100644 index 584280ea5eab..000000000000 --- a/packages/kit/test/mocks/sveltekit-environment.js +++ /dev/null @@ -1,9 +0,0 @@ -// Stub for `__sveltekit/environment` — the internal counterpart to `$app/environment`. -// Shape from `src/types/ambient-private.d.ts`. - -export const building = false; -export const prerendering = false; -export const version = 'test'; - -export function set_building() {} -export function set_prerendering() {} diff --git a/packages/kit/tsconfig.json b/packages/kit/tsconfig.json index 5a2855b2825b..130467bc224b 100644 --- a/packages/kit/tsconfig.json +++ b/packages/kit/tsconfig.json @@ -17,6 +17,8 @@ "@sveltejs/kit/node/polyfills": ["./src/exports/node/polyfills.js"], "@sveltejs/kit/internal": ["./src/exports/internal/index.js"], "@sveltejs/kit/internal/server": ["./src/exports/internal/server.js"], + "$app/env": ["./src/runtime/app/env/types.d.ts"], + "$app/env/internal": ["./src/runtime/app/env/internal.js"], "$app/paths": ["./src/runtime/app/paths/public.d.ts"], "$app/paths/internal/client": ["./src/runtime/app/paths/internal/client.js"], "$app/paths/internal/server": ["./src/runtime/app/paths/internal/server.js"], diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d954c584da12..fa15b700a746 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -450,6 +450,13 @@ declare module '@sveltejs/kit' { }; /** Experimental features. Here be dragons. These are not subject to semantic versioning, so breaking changes or removal can happen in any release. */ experimental?: { + /** + * Whether to enable explicit environment variables using `src/env.js` or `src/env.ts`. + * @since 2.62.0 + * @default false + */ + explicitEnvironmentVariables?: boolean; + /** * Options for enabling server-side [OpenTelemetry](https://opentelemetry.io/) tracing for SvelteKit operations including the [`handle` hook](https://svelte.dev/docs/kit/hooks#Server-hooks-handle), [`load` functions](https://svelte.dev/docs/kit/load), [form actions](https://svelte.dev/docs/kit/form-actions), and [remote functions](https://svelte.dev/docs/kit/remote-functions). * @default { server: false, serverFile: false } @@ -2287,6 +2294,39 @@ declare module '@sveltejs/kit' { export type RemoteLiveQueryFunction = ( arg: undefined extends Input ? Input | void : Input ) => RemoteLiveQuery; + + /** + * [Environment variables](https://svelte.dev/docs/kit/environment-variables) can be configured by exporting + * a `variables` object from `src/env.ts`, using [`defineEnvVars`](https://svelte.dev/docs/kit/@sveltejs-kit-hooks#defineEnvVars). + */ + export interface EnvVarConfig { + /** + * Whether the environment variable can be accessed by client-side code. + * - if `true`, it can be imported from `$app/env/public` + * - if `false`, it can be imported from `$app/env/private`, which is a [server-only module](https://svelte.dev/docs/kit/server-only-modules) + * @default false + */ + public?: boolean; + /** + * Whether the value is determined at build time or when the app runs. + * - if `true`, the build time value is inlined into the bundle. This enables optimisations like dead-code elimination + * - if `false`, the value is read from the environment when the app starts + * @default false + */ + static?: boolean; + /** + * A [Standard Schema](https://standardschema.dev/) validator that is applied to the value when the app starts. + * The validator can output any value — not necessarily a string — but public, non-static values must be + * serializable by [devalue](https://github.com/sveltejs/devalue) so that they can be sent to the browser. + * + * If omitted, the value must be a non-empty string. + */ + schema?: StandardSchemaV1; + /** + * A description of the variable that will be used for inline documentation on hover. + */ + description?: string; + } interface AdapterEntry { /** * A string that uniquely identifies an HTTP service (e.g. serverless function) and is used for deduplication. @@ -2931,7 +2971,12 @@ declare module '@sveltejs/kit' { } declare module '@sveltejs/kit/hooks' { - import type { Handle } from '@sveltejs/kit'; + import type { EnvVarConfig, Handle } from '@sveltejs/kit'; + /** + * Utility for defining [environment variables](https://svelte.dev/docs/kit/environment-variables), + * which are made available via `$app/env/public` and `$app/env/private`. + * */ + export function defineEnvVars>>(variables: T): T; /** * A helper function for sequencing multiple `handle` calls in a middleware-like manner. * The behavior for the `handle` options is as follows: @@ -3048,6 +3093,30 @@ declare module '@sveltejs/kit/vite' { export {}; } +declare module '$app/env' { + /** + * `true` if the app is running in the browser. + */ + export const browser: boolean; + + /** + * Whether the dev server is running. This is not guaranteed to correspond to `NODE_ENV` or `MODE`. + */ + export const dev: boolean; + + /** + * SvelteKit analyses your app during the `build` step by running it. During this process, `building` is `true`. This also applies during prerendering. + */ + export const building: boolean; + + /** + * The value of `config.kit.version.name`. + */ + export const version: string; + + export {}; +} + declare module '$app/environment' { /** * `true` if the app is running in the browser.