@beignet/core
Version:
Core framework primitives for Beignet
584 lines (498 loc) • 15.4 kB
text/typescript
/**
* @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>;
}