UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

252 lines 9.07 kB
/** * @beignet/core/config * * Environment-first configuration layer using Standard Schema (Zod, Valibot, * ArkType, etc.) for Beignet applications and providers. */ /** * Error thrown when Beignet config validation fails. */ export class ConfigValidationError extends Error { /** * Raw Standard Schema validation issues. */ issues; constructor(issues, message) { super(message ?? formatStandardSchemaIssues(issues)); this.name = "ConfigValidationError"; this.issues = issues; } } function defaultRuntimeEnv() { return typeof process !== "undefined" && process.env ? process.env : {}; } function isPromiseLike(value) { return (value !== null && (typeof value === "object" || typeof value === "function") && "then" in value && typeof value.then === "function"); } function normalizeEnvValue(value, emptyStringAsUndefined) { if (emptyStringAsUndefined && value === "") return undefined; return value; } function issuePath(issue) { 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) => Boolean(segment)) .join("."); } function prependIssuePath(key, issue) { return { ...issue, path: [{ key }, ...(issue.path ?? [])], }; } /** * Format Standard Schema issues into a single readable message. */ export function formatStandardSchemaIssues(issues) { 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, } = {}) { const raw = {}; 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(result) { if (result.issues?.length) { throw new ConfigValidationError(result.issues); } if ("value" in result) { return result.value; } 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, input) { 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(result); } /** * Validate input with a Standard Schema that may be synchronous or async. */ export async function parseStandardSchemaAsync(schema, input) { const validate = schema?.["~standard"]?.validate; if (typeof validate !== "function") { throw new Error("Invalid Standard Schema: missing ~standard.validate()"); } return validationResultValue(await validate(input)); } function wrapEnvError(err, prefix) { 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(options) { const { schema, prefix, runtimeEnv, emptyStringAsUndefined = false, skipValidation = false, onValidationError, } = options; function load({ env = runtimeEnv ?? defaultRuntimeEnv(), } = {}) { const raw = readEnv({ env, prefix, emptyStringAsUndefined }); if (skipValidation) { return raw; } try { return parseStandardSchemaSync(schema, raw); } catch (err) { if (err instanceof ConfigValidationError) { onValidationError?.(err.issues); } throw wrapEnvError(err, prefix); } } return { load }; } function validateEnvShape(shape, runtimeEnv, emptyStringAsUndefined) { const output = {}; const issues = []; 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; } function validateClientPrefix(client, clientPrefix) { 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, keys) { 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(options) { const server = (options.server ?? {}); const client = (options.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; 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); }, }); } //# sourceMappingURL=index.js.map