@beignet/core
Version:
Core framework primitives for Beignet
252 lines • 9.07 kB
JavaScript
/**
* @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