UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

584 lines (498 loc) 15.4 kB
/** * @beignet/core/config * * Environment-first configuration layer using Standard Schema (Zod, Valibot, * ArkType, etc.) for Beignet applications and providers. */ import type { StandardSchemaV1 } from "@standard-schema/spec"; /** * Any Standard Schema compatible validator. */ export type StandardSchema = StandardSchemaV1<unknown, unknown>; /** * Runtime environment object shape. */ export type RuntimeEnv = Record<string, string | undefined>; /** * Map of environment variable names to Standard Schema validators. */ export type EnvSchemaShape = Record<string, StandardSchema>; type EmptyEnvSchemaShape = Record<keyof never, never>; type NoInferType<T> = [T][T extends unknown ? 0 : never]; /** * Infer the parsed output type from a Standard Schema. */ export type InferOutput<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>; /** * Infer the parsed output object for an env schema shape. */ export type InferEnvShape<Shape extends EnvSchemaShape> = { [Key in keyof Shape]: InferOutput<Shape[Key]>; }; /** * Env schema shape constrained to a client prefix. */ export type ClientEnvSchemaShape<ClientPrefix extends string> = ClientPrefix extends "" ? EnvSchemaShape : Record<`${ClientPrefix}${string}`, StandardSchema>; type ValidateClientEnvShape< ClientPrefix extends string, Client extends EnvSchemaShape, > = ClientPrefix extends "" ? Client : { [Key in keyof Client]: Key extends `${ClientPrefix}${string}` ? Client[Key] : never; }; /** * Raw Standard Schema validation issue produced while loading config. */ export type EnvValidationIssue = StandardSchemaV1.Issue; /** * Error thrown when Beignet config validation fails. */ export class ConfigValidationError extends Error { /** * Raw Standard Schema validation issues. */ readonly issues: readonly EnvValidationIssue[]; constructor(issues: readonly EnvValidationIssue[], message?: string) { super(message ?? formatStandardSchemaIssues(issues)); this.name = "ConfigValidationError"; this.issues = issues; } } /** * Options for reading raw environment variables. */ export interface ReadEnvOptions { /** * Runtime environment object. Defaults to `process.env`. */ env?: RuntimeEnv; /** * Optional prefix to filter and strip from matching keys. */ prefix?: string; /** * Treat empty strings as missing values. */ emptyStringAsUndefined?: boolean; } /** * Options for creating a single validated env loader. */ export interface CreateEnvLoaderOptions<Schema extends StandardSchemaV1> { /** * Standard Schema for validating the full environment object. */ schema: Schema; /** * Optional prefix to filter env vars. Matching keys are stripped before * validation, so `APP_DATABASE_URL` becomes `DATABASE_URL`. */ prefix?: string; /** * Runtime environment object. Defaults to `process.env` when available. */ runtimeEnv?: RuntimeEnv; /** * Treat empty strings as missing values before validation. */ emptyStringAsUndefined?: boolean; /** * Skip validation and return the raw env object. This is intended for build * phases where real secrets are unavailable. */ skipValidation?: boolean; /** * Called when validation fails. Throw from this hook to customize the error. */ onValidationError?: (issues: readonly EnvValidationIssue[]) => never; } /** * Validated env loader returned by `createEnvLoader(...)`. */ export interface EnvInstance<Out> { /** * Read and validate the environment. */ load(options?: { env?: RuntimeEnv }): Out; } /** * Options for `createEnv(...)`. */ export interface CreateEnvOptions< Server extends EnvSchemaShape, ClientPrefix extends string, Client extends EnvSchemaShape, > { /** * Server-only environment variables. These throw if accessed from a client * runtime through the returned env object. */ server?: Server; /** * Client-safe environment variables. Keys must use `clientPrefix` when a * prefix is provided. */ client?: Client & ValidateClientEnvShape<NoInferType<ClientPrefix>, Client>; /** * Prefix required for client variables, e.g. `NEXT_PUBLIC_`. */ clientPrefix?: ClientPrefix; /** * Runtime environment object. Defaults to `process.env` when available. */ runtimeEnv?: RuntimeEnv; /** * Strict runtime environment object. Every declared key must be present on the * object, even if the value is `undefined`. This catches framework bundling * mistakes where an env var was not explicitly accessed. */ runtimeEnvStrict?: RuntimeEnv; /** * Treat empty strings as missing values before validation. * * Defaults to `true` for `createEnv` because it keeps defaults ergonomic in * framework starters. */ emptyStringAsUndefined?: boolean; /** * Skip validation and return raw values. Use sparingly for build phases where * deployment secrets are unavailable. */ skipValidation?: boolean; /** * Override server detection. Defaults to checking for `window` on globalThis. */ isServer?: boolean; /** * Called when validation fails. Throw from this hook to customize the error. */ onValidationError?: (issues: readonly EnvValidationIssue[]) => never; /** * Called when a server-only variable is read from a client runtime. */ onInvalidAccess?: (key: string) => never; } /** * Parsed env object returned by `createEnv(...)`. */ export type CreateEnvResult< Server extends EnvSchemaShape, Client extends EnvSchemaShape, > = Readonly<InferEnvShape<Server> & InferEnvShape<Client>>; function defaultRuntimeEnv(): RuntimeEnv { return typeof process !== "undefined" && process.env ? process.env : {}; } function isPromiseLike(value: unknown): value is PromiseLike<unknown> { return ( value !== null && (typeof value === "object" || typeof value === "function") && "then" in value && typeof (value as { then?: unknown }).then === "function" ); } function normalizeEnvValue( value: string | undefined, emptyStringAsUndefined: boolean, ): string | undefined { if (emptyStringAsUndefined && value === "") return undefined; return value; } function issuePath(issue: StandardSchemaV1.Issue): string | undefined { if (!issue.path?.length) return undefined; return issue.path .map((segment) => { if (segment && typeof segment === "object" && "key" in segment) { return String(segment.key); } if ( typeof segment === "string" || typeof segment === "number" || typeof segment === "symbol" ) { return String(segment); } return undefined; }) .filter((segment): segment is string => Boolean(segment)) .join("."); } function prependIssuePath( key: string, issue: StandardSchemaV1.Issue, ): StandardSchemaV1.Issue { return { ...issue, path: [{ key }, ...(issue.path ?? [])], }; } /** * Format Standard Schema issues into a single readable message. */ export function formatStandardSchemaIssues( issues: readonly StandardSchemaV1.Issue[], ): string { return issues .map((issue) => { const path = issuePath(issue); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } /** * Read raw environment variables, optionally filtering by prefix. * * When a prefix is provided, matching keys are stripped before being returned. */ export function readEnv({ env = defaultRuntimeEnv(), prefix, emptyStringAsUndefined = false, }: ReadEnvOptions = {}): Record<string, string | undefined> { const raw: Record<string, string | undefined> = {}; for (const [key, value] of Object.entries(env)) { if (value == null) continue; if (prefix && prefix.length > 0) { if (!key.startsWith(prefix)) continue; raw[key.slice(prefix.length)] = normalizeEnvValue( value, emptyStringAsUndefined, ); continue; } raw[key] = normalizeEnvValue(value, emptyStringAsUndefined); } return raw; } function validationResultValue<Schema extends StandardSchemaV1>( result: StandardSchemaV1.Result<InferOutput<Schema>>, ): InferOutput<Schema> { if (result.issues?.length) { throw new ConfigValidationError(result.issues); } if ("value" in result) { return result.value as InferOutput<Schema>; } throw new Error("Invalid Standard Schema result: missing value"); } /** * Validate input with a synchronous Standard Schema. * * Throws when the schema returns a promise because env loading is synchronous. */ export function parseStandardSchemaSync<Schema extends StandardSchemaV1>( schema: Schema, input: unknown, ): InferOutput<Schema> { const validate = schema?.["~standard"]?.validate; if (typeof validate !== "function") { throw new Error("Invalid Standard Schema: missing ~standard.validate()"); } const result = validate(input); if (isPromiseLike(result)) { throw new Error( "[Beignet env] Schema uses async validation, which is not supported by load().", ); } return validationResultValue<Schema>(result); } /** * Validate input with a Standard Schema that may be synchronous or async. */ export async function parseStandardSchemaAsync<Schema extends StandardSchemaV1>( schema: Schema, input: unknown, ): Promise<InferOutput<Schema>> { const validate = schema?.["~standard"]?.validate; if (typeof validate !== "function") { throw new Error("Invalid Standard Schema: missing ~standard.validate()"); } return validationResultValue<Schema>(await validate(input)); } function wrapEnvError(err: unknown, prefix?: string): Error { if (err instanceof ConfigValidationError) { return new Error( `[Beignet env] Invalid environment${ prefix ? ` for prefix "${prefix}"` : "" }: ${err.message}`, ); } if (err instanceof Error) { return new Error( `[Beignet env] Invalid environment${ prefix ? ` for prefix "${prefix}"` : "" }: ${err.message}`, ); } return new Error( `[Beignet env] Invalid environment${ prefix ? ` for prefix "${prefix}"` : "" }: ${String(err)}`, ); } /** * Create a reusable validated env loader. * * This is useful for provider config and app-level config objects that are * loaded from a prefixed subset of the environment. */ export function createEnvLoader<Schema extends StandardSchemaV1>( options: CreateEnvLoaderOptions<Schema>, ): EnvInstance<InferOutput<Schema>> { const { schema, prefix, runtimeEnv, emptyStringAsUndefined = false, skipValidation = false, onValidationError, } = options; function load({ env = runtimeEnv ?? defaultRuntimeEnv(), } = {}): InferOutput<Schema> { const raw = readEnv({ env, prefix, emptyStringAsUndefined }); if (skipValidation) { return raw as InferOutput<Schema>; } try { return parseStandardSchemaSync(schema, raw); } catch (err) { if (err instanceof ConfigValidationError) { onValidationError?.(err.issues); } throw wrapEnvError(err, prefix); } } return { load }; } function validateEnvShape<Shape extends EnvSchemaShape>( shape: Shape, runtimeEnv: RuntimeEnv, emptyStringAsUndefined: boolean, ): InferEnvShape<Shape> { const output: Record<string, unknown> = {}; const issues: StandardSchemaV1.Issue[] = []; for (const [key, schema] of Object.entries(shape)) { const value = normalizeEnvValue(runtimeEnv[key], emptyStringAsUndefined); try { output[key] = parseStandardSchemaSync(schema, value); } catch (error) { if (error instanceof ConfigValidationError) { issues.push( ...error.issues.map((issue) => prependIssuePath(key, issue)), ); continue; } throw error; } } if (issues.length) { throw new ConfigValidationError(issues); } return output as InferEnvShape<Shape>; } function validateClientPrefix( client: EnvSchemaShape, clientPrefix: string | undefined, ) { if (!clientPrefix) return; for (const key of Object.keys(client)) { if (!key.startsWith(clientPrefix)) { throw new Error( `[Beignet env] Client environment variable "${key}" must start with "${clientPrefix}".`, ); } } } function validateRuntimeEnvStrict( runtimeEnv: RuntimeEnv, keys: readonly string[], ) { const missing = keys.filter((key) => !Object.hasOwn(runtimeEnv, key)); if (missing.length === 0) return; throw new Error( `[Beignet env] runtimeEnvStrict is missing declared keys: ${missing.join( ", ", )}`, ); } /** * Create a server/client split env object. * * Server variables are available only on the server. Client variables must use * `clientPrefix` when provided. Validation is synchronous, empty strings are * treated as undefined by default, and client access to server-only keys throws * through the returned proxy. */ export function createEnv< const ClientPrefix extends string = "", Server extends EnvSchemaShape = EmptyEnvSchemaShape, Client extends EnvSchemaShape = EmptyEnvSchemaShape, >( options: CreateEnvOptions<Server, ClientPrefix, Client>, ): CreateEnvResult<Server, Client> { const server = (options.server ?? {}) as Server; const client = (options.client ?? {}) as Client; const runtimeEnv = options.runtimeEnvStrict ?? options.runtimeEnv ?? defaultRuntimeEnv(); const emptyStringAsUndefined = options.emptyStringAsUndefined ?? true; const isServer = options.isServer ?? !("window" in globalThis); if (options.runtimeEnv && options.runtimeEnvStrict) { throw new Error( "[Beignet env] Specify runtimeEnv or runtimeEnvStrict, not both.", ); } validateClientPrefix(client, options.clientPrefix); const declaredKeys = [...Object.keys(server), ...Object.keys(client)]; const validationShape = isServer ? { ...server, ...client } : client; const validationKeys = isServer ? declaredKeys : Object.keys(client); if (options.runtimeEnvStrict) { validateRuntimeEnvStrict(runtimeEnv, validationKeys); } let parsed: Record<string, unknown>; if (options.skipValidation) { parsed = Object.fromEntries( validationKeys.map((key) => [ key, normalizeEnvValue(runtimeEnv[key], emptyStringAsUndefined), ]), ); } else { try { parsed = validateEnvShape( validationShape, runtimeEnv, emptyStringAsUndefined, ); } catch (error) { if (error instanceof ConfigValidationError) { options.onValidationError?.(error.issues); } throw wrapEnvError(error); } } const serverKeys = new Set(Object.keys(server)); const clientKeys = new Set(Object.keys(client)); return new Proxy(parsed, { get(target, property, receiver) { if ( typeof property === "string" && !isServer && serverKeys.has(property) && !clientKeys.has(property) ) { options.onInvalidAccess?.(property); throw new Error( `[Beignet env] Attempted to access server-only environment variable "${property}" from a client runtime.`, ); } return Reflect.get(target, property, receiver); }, }) as CreateEnvResult<Server, Client>; }