/** * @wrnexus/validation — one schema, validated on the server (API) and the browser * (forms). A schema is a fluent builder; `.parse()` runs server-side and returns * coerced values + field errors, while `.describe()` emits a JSON descriptor the * eval-free client validator interprets. Define schemas once in `app/schemas/`. */ // --- Descriptor (the JSON bridge between server and client) ---------------- export type RuleDescriptor = | { kind: "min"; n: number; message?: string } | { kind: "max"; n: number; message?: string } | { kind: "length"; n: number; message?: string } | { kind: "email"; message?: string } | { kind: "url"; message?: string } | { kind: "uuid"; message?: string } | { kind: "date"; message?: string } | { kind: "oneOf"; values: (string | number)[]; message?: string } | { kind: "pattern"; source: string; flags?: string; message?: string } | { kind: "integer"; message?: string }; export interface FieldDescriptor { type: "string" | "number" | "boolean" | "unknown"; optional?: boolean; /** Message used when a required field is empty. Defaults to "Required". */ requiredMessage?: string; label?: string; /** Trim string input before validating. */ trim?: boolean; rules: RuleDescriptor[]; } export interface SchemaDescriptor { type: "object"; fields: Record; } export interface ParseResult> { ok: boolean; /** Coerced values (present whether or not validation passed). */ value: T; /** Field name → message, only for fields that failed. */ errors: Record; } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** * Apply one rule to an already-coerced value. Shared by the server; the client * runtime (runtime.ts) mirrors this exactly. Returns an error message or null. */ export function applyRule(type: string, rule: RuleDescriptor, value: unknown): string | null { switch (rule.kind) { case "min": if (type === "string") return String(value).length < rule.n ? (rule.message ?? `Must be at least ${rule.n} characters`) : null; return (value as number) < rule.n ? (rule.message ?? `Must be at least ${rule.n}`) : null; case "max": if (type === "string") return String(value).length > rule.n ? (rule.message ?? `Must be at most ${rule.n} characters`) : null; return (value as number) > rule.n ? (rule.message ?? `Must be at most ${rule.n}`) : null; case "length": return String(value).length !== rule.n ? (rule.message ?? `Must be exactly ${rule.n} characters`) : null; case "email": return EMAIL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid email"); case "url": return URL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid URL"); case "uuid": return UUID_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid UUID"); case "date": return Number.isNaN(Date.parse(String(value))) ? (rule.message ?? "Must be a valid date") : null; case "oneOf": return rule.values.includes(value as string | number) ? null : (rule.message ?? `Must be one of: ${rule.values.join(", ")}`); case "pattern": try { return new RegExp(rule.source, rule.flags ?? "").test(String(value)) ? null : (rule.message ?? "Invalid format"); } catch { return null; } case "integer": return Number.isInteger(value) ? null : (rule.message ?? "Must be a whole number"); default: return null; } } /** Coerce + validate one field against its descriptor. */ export function checkField( desc: FieldDescriptor, raw: unknown, ): { value: unknown; error: string | null } { if (desc.type === "unknown") { const empty = raw === undefined || raw === null || raw === ""; return { value: raw, error: empty && !desc.optional ? desc.requiredMessage || "Required" : null, }; } if (desc.type === "boolean") { const value = raw === true || raw === "true" || raw === "on"; if (!desc.optional && !value) { return { value, error: desc.requiredMessage || "Required" }; } return { value, error: null }; } const pre = desc.trim && typeof raw === "string" ? raw.trim() : raw; const empty = pre === undefined || pre === null || pre === ""; if (empty) { return { value: undefined, error: desc.optional ? null : desc.requiredMessage || "Required", }; } let value: unknown; if (desc.type === "number") { value = Number(pre); if (Number.isNaN(value)) return { value, error: "Must be a number" }; } else { value = String(pre); } for (const rule of desc.rules) { const error = applyRule(desc.type, rule, value); if (error) return { value, error }; } return { value, error: null }; } // --- Fluent builder -------------------------------------------------------- /** A server-only refinement (a predicate that can't be serialized to the client). */ type Refinement = { fn: (value: unknown) => boolean | string; message?: string }; export abstract class FieldSchema { abstract readonly type: "string" | "number" | "boolean" | "unknown"; protected _optional = false; protected _requiredMessage?: string; protected _label?: string; protected _default?: unknown; protected rules: RuleDescriptor[] = []; protected refinements: Refinement[] = []; optional(): this { this._optional = true; return this; } /** Require a non-empty value and optionally replace the default message. */ required(message = "Required"): this { this._optional = false; this._requiredMessage = message; return this; } label(label: string): this { this._label = label; return this; } /** Value used when the field is absent (implies optional). */ default(value: unknown): this { this._default = value; this._optional = true; return this; } min(n: number, message?: string): this { this.rules.push({ kind: "min", n, message }); return this; } max(n: number, message?: string): this { this.rules.push({ kind: "max", n, message }); return this; } /** * Custom SERVER-side validation. `fn` returns true (ok), false (use `message`), * or a string (that error). Not mirrored to the client validator. */ refine(fn: (value: unknown) => boolean | string, message?: string): this { this.refinements.push({ fn, message }); return this; } getDefault(): unknown { return this._default; } runRefinements(value: unknown): string | null { for (const r of this.refinements) { const result = r.fn(value); if (result === false) return r.message ?? "Invalid value"; if (typeof result === "string") return result; } return null; } describe(): FieldDescriptor { return { type: this.type, optional: this._optional || undefined, requiredMessage: this._requiredMessage, label: this._label, rules: this.rules, }; } } export class StringSchema extends FieldSchema { readonly type = "string" as const; private _trim = false; email(message?: string): this { this.rules.push({ kind: "email", message }); return this; } url(message?: string): this { this.rules.push({ kind: "url", message }); return this; } uuid(message?: string): this { this.rules.push({ kind: "uuid", message }); return this; } date(message?: string): this { this.rules.push({ kind: "date", message }); return this; } length(n: number, message?: string): this { this.rules.push({ kind: "length", n, message }); return this; } oneOf(values: string[], message?: string): this { this.rules.push({ kind: "oneOf", values, message }); return this; } trim(): this { this._trim = true; return this; } pattern(re: RegExp, message?: string): this { this.rules.push({ kind: "pattern", source: re.source, flags: re.flags, message }); return this; } describe(): FieldDescriptor { return { ...super.describe(), trim: this._trim || undefined }; } } export class NumberSchema extends FieldSchema { readonly type = "number" as const; integer(message?: string): this { this.rules.push({ kind: "integer", message }); return this; } positive(message?: string): this { this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" }); return this; } oneOf(values: number[], message?: string): this { this.rules.push({ kind: "oneOf", values, message }); return this; } } export class BooleanSchema extends FieldSchema { readonly type = "boolean" as const; } export class UnknownSchema extends FieldSchema { readonly type = "unknown" as const; } export type AnyFieldSchema = StringSchema | NumberSchema | BooleanSchema | UnknownSchema; export class ObjectSchema { constructor(private readonly fields: Record) {} /** Return a defensive copy of the schema fields. */ getFields(): Readonly> { return { ...this.fields }; } /** Create a new schema with fields added or replaced. The original is unchanged. */ extend(fields: Record): ObjectSchema { return new ObjectSchema({ ...this.fields, ...fields }); } /** Create a new schema containing fields from both schemas. */ merge(schema: ObjectSchema): ObjectSchema { return new ObjectSchema({ ...this.fields, ...schema.getFields() }); } /** Validate an input object; returns coerced values + per-field errors. */ parse(input: unknown): ParseResult { const source = (input ?? {}) as Record; const value: Record = {}; const errors: Record = {}; for (const [name, field] of Object.entries(this.fields)) { const { value: coerced, error } = checkField(field.describe(), source[name]); if (error) { errors[name] = error; continue; } let finalValue = coerced; if (finalValue === undefined) { const fallback = field.getDefault(); if (fallback !== undefined) finalValue = fallback; } if (finalValue !== undefined) { const refineError = field.runRefinements(finalValue); if (refineError) { errors[name] = refineError; continue; } value[name] = finalValue; } } return { ok: Object.keys(errors).length === 0, value, errors }; } describe(): SchemaDescriptor { const fields: Record = {}; for (const [name, field] of Object.entries(this.fields)) fields[name] = field.describe(); return { type: "object", fields }; } } /** The fluent schema builder. */ export const v = { string: () => new StringSchema(), number: () => new NumberSchema(), boolean: () => new BooleanSchema(), unknown: () => new UnknownSchema(), object: (fields: Record) => new ObjectSchema(fields), }; // --- Environment configuration -------------------------------------------- /** Read the ambient environment (Bun.env, falling back to process.env). */ function readEnv(): Record { const bun = (globalThis as { Bun?: { env?: Record } }).Bun; if (bun?.env) return bun.env; const proc = (globalThis as { process?: { env?: Record } }).process; return proc?.env ?? {}; } /** * Validate environment variables against a schema at startup. Values are read * from `Bun.env` / `process.env` by default and coerced by the schema (so * `PORT` becomes a number, `DEBUG` a boolean). On any problem it throws ONE * readable error listing every offending variable, so misconfiguration fails * fast with an actionable message instead of surfacing deep inside the app. * * export const env = parseEnv(v.object({ * DATABASE_URL: v.string().min(1), * PORT: v.number(), * })); */ export function parseEnv>( schema: ObjectSchema, source: Record = readEnv(), ): T { const result = schema.parse(source); if (!result.ok) { const lines = Object.entries(result.errors).map(([name, message]) => ` • ${name}: ${message}`); throw new Error(`Invalid environment variables:\n${lines.join("\n")}`); } return result.value as T; } // --- API helpers ----------------------------------------------------------- /** A 400 response carrying field errors, for API routes. */ export function invalid(errors: Record): Response { return Response.json({ ok: false, errors }, { status: 400 }); } /** * Parse a request's JSON body against a schema. On failure returns * `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`. */ export async function parseBody>( schema: ObjectSchema, req: Request, ): Promise<{ ok: true; value: T } | { ok: false; response: Response }> { const body = await readBody(req); const result = schema.parse(body); if (!result.ok) return { ok: false, response: invalid(result.errors) }; return { ok: true, value: result.value as T }; } /** Read a request body as an object from JSON, form-urlencoded, or multipart. */ async function readBody(req: Request): Promise> { const contentType = req.headers.get("content-type") ?? ""; try { if (contentType.includes("application/json")) { return (await req.json()) as Record; } if ( contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data") ) { const out: Record = {}; for (const [key, value] of await req.formData()) out[key] = value; return out; } // Best effort: try JSON, else treat as empty. return (await req.json()) as Record; } catch { return {}; } } export { renderSchemasScript, VALIDATE_RUNTIME } from "./runtime.ts"; export { AsyncObjectSchema, asyncSchema, parseBodyAsync, schemaToOpenApi, mergeValidationResults, } from "./advanced.ts"; export type { AsyncValidationContext, AsyncRefinement, OpenApiSchema } from "./advanced.ts";