diff --git a/.changeset/optional-env-vars.md b/.changeset/optional-env-vars.md new file mode 100644 index 000000000000..3c5f625761ed --- /dev/null +++ b/.changeset/optional-env-vars.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `optional` option to `EnvVarConfig` for optional environment variables diff --git a/documentation/docs/20-core-concepts/70-environment-variables.md b/documentation/docs/20-core-concepts/70-environment-variables.md index d785468585c0..5740aefa5874 100644 --- a/documentation/docs/20-core-concepts/70-environment-variables.md +++ b/documentation/docs/20-core-concepts/70-environment-variables.md @@ -139,6 +139,24 @@ export const variables = defineEnvVars({ 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. +### Optional variables + +By default, every variable is required: if a value is missing when the app runs, validation will fail. To allow a variable to be missing, set `optional: true`: + +```ts +/// file: src/env.ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; + +export const variables = defineEnvVars({ + GOOGLE_ANALYTICS_ID: { + public: true, + +++optional: true+++ + } +}); +``` + +When a variable is optional, its type is `string | undefined` (or, if a `schema` is provided, the schema's output type `| undefined`), and validation is skipped when the value is missing. This is equivalent to using a schema that accepts an optional value, but without the boilerplate. + ### Static variables By default, variables are dynamic. If a variable is configured with `static: true`, it will be inlined into your application code, enabling optimisations like dead-code elimination: diff --git a/packages/kit/src/core/env.js b/packages/kit/src/core/env.js index 63c493e61875..ff73a392e86e 100644 --- a/packages/kit/src/core/env.js +++ b/packages/kit/src/core/env.js @@ -313,7 +313,7 @@ export function create_explicit_env_types(variables, relative, type) { const type = config.schema ? `import('@sveltejs/kit/internal/types').StandardSchemaV1.InferOutput` : 'string'; - return `${comment}export const ${name}: ${type};`; + return `${comment}export const ${name}: ${config.optional ? `${type} | undefined` : type};`; }); return dedent` diff --git a/packages/kit/src/exports/internal/env.js b/packages/kit/src/exports/internal/env.js index b34a8636ef74..c5173705375a 100644 --- a/packages/kit/src/exports/internal/env.js +++ b/packages/kit/src/exports/internal/env.js @@ -4,7 +4,7 @@ import { stackless } from '../../utils/error.js'; const MISSING = { - message: `Value is missing. If it is optional, add a Standard Schema validator declaring it as such.` + message: `Value is missing. If it is optional, set \`optional: true\` or add a Standard Schema validator declaring it as such.` }; const BAD_VALIDATOR = { @@ -26,6 +26,10 @@ export function validate(variables, value, name, issues) { const config = variables[name] ?? {}; const validator = config.schema; + if (config.optional && !value) { + return undefined; + } + if (!validator) { if (!value) issues[name] = [MISSING]; return value; diff --git a/packages/kit/src/exports/internal/env.spec.js b/packages/kit/src/exports/internal/env.spec.js new file mode 100644 index 000000000000..7ff95661a4a7 --- /dev/null +++ b/packages/kit/src/exports/internal/env.spec.js @@ -0,0 +1,91 @@ +import { assert, describe, test } from 'vitest'; +import { validate, handle_issues } from './env.js'; + +/** + * @param {Record} variables + * @param {string | undefined} value + * @param {string} name + */ +function run(variables, value, name) { + /** @type {Record} */ + const issues = {}; + const result = validate(variables, value, name, issues); + return { result, issues }; +} + +describe('validate', () => { + test('records a MISSING issue when a required variable without a schema is undefined', () => { + const { result, issues } = run({ FOO: {} }, undefined, 'FOO'); + assert.equal(result, undefined); + assert.deepEqual(issues, { FOO: [issues.FOO[0]] }); + assert.match(issues.FOO[0].message, /Value is missing/); + }); + + test('does not record an issue when an optional variable without a schema is undefined', () => { + const { result, issues } = run({ FOO: { optional: true } }, undefined, 'FOO'); + assert.equal(result, undefined); + assert.deepEqual(issues, {}); + }); + + test('does not record an issue when an optional variable without a schema is an empty string', () => { + const { result, issues } = run({ FOO: { optional: true } }, '', 'FOO'); + assert.equal(result, undefined); + assert.deepEqual(issues, {}); + }); + + test('returns the value unchanged when a required variable without a schema is present', () => { + const { result, issues } = run({ FOO: {} }, 'bar', 'FOO'); + assert.equal(result, 'bar'); + assert.deepEqual(issues, {}); + }); + + test('returns the value unchanged when an optional variable is present', () => { + const { result, issues } = run({ FOO: { optional: true } }, 'bar', 'FOO'); + assert.equal(result, 'bar'); + assert.deepEqual(issues, {}); + }); + + test('skips the schema validator when an optional variable is undefined', () => { + /** @type {any} */ + const validator = { + '~standard': { + validate(/** @type {any} */ value) { + if (value === undefined) return { issues: [{ message: 'nope' }] }; + return { value }; + } + } + }; + const { result, issues } = run( + { FOO: { optional: true, schema: validator } }, + undefined, + 'FOO' + ); + assert.equal(result, undefined); + assert.deepEqual(issues, {}); + }); + + test('runs the schema validator when an optional variable is present', () => { + /** @type {any} */ + const validator = { + '~standard': { + validate(/** @type {any} */ value) { + return { value: `validated:${value}` }; + } + } + }; + const { result, issues } = run({ FOO: { optional: true, schema: validator } }, 'bar', 'FOO'); + assert.equal(result, 'validated:bar'); + assert.deepEqual(issues, {}); + }); + + test('handle_issues throws when there are issues', () => { + assert.throws( + () => handle_issues({ FOO: [{ message: 'bad' }] }), + /Invalid environment variables/ + ); + }); + + test('handle_issues is a no-op when there are no issues', () => { + handle_issues({}); + }); +}); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index cab6332bac14..e074ba817036 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2472,6 +2472,13 @@ export interface EnvVarConfig { * A description of the variable that will be used for inline documentation on hover. */ description?: string; + /** + * Whether the variable is optional. + * - if `true`, validation will not fail when the value is `undefined`. Its type will be `T | undefined` + * - if `false`, the value must be present (and pass validation, if a `schema` is provided) + * @default false + */ + optional?: boolean; } export * from './index.js'; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6f5d75500c29..e720b5e56be5 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2443,6 +2443,13 @@ declare module '@sveltejs/kit' { * A description of the variable that will be used for inline documentation on hover. */ description?: string; + /** + * Whether the variable is optional. + * - if `true`, validation will not fail when the value is `undefined`. Its type will be `T | undefined` + * - if `false`, the value must be present (and pass validation, if a `schema` is provided) + * @default false + */ + optional?: boolean; } interface AdapterEntry { /**