first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
/**
* @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";
optional?: boolean;
label?: string;
/** Trim string input before validating. */
trim?: boolean;
rules: RuleDescriptor[];
}
export interface SchemaDescriptor {
type: "object";
fields: Record<string, FieldDescriptor>;
}
export interface ParseResult<T = Record<string, unknown>> {
ok: boolean;
/** Coerced values (present whether or not validation passed). */
value: T;
/** Field name → message, only for fields that failed. */
errors: Record<string, string>;
}
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 === "boolean") {
const value = raw === true || raw === "true" || raw === "on";
if (!desc.optional && !value) return { value, error: "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 : "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 };
abstract class FieldSchema {
abstract readonly type: "string" | "number" | "boolean";
protected _optional = false;
protected _label?: string;
protected _default?: unknown;
protected rules: RuleDescriptor[] = [];
protected refinements: Refinement[] = [];
optional(): this {
this._optional = true;
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,
label: this._label,
rules: this.rules,
};
}
}
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 };
}
}
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;
}
}
class BooleanSchema extends FieldSchema {
readonly type = "boolean" as const;
}
export class ObjectSchema {
constructor(private readonly fields: Record<string, FieldSchema>) {}
/** Validate an input object; returns coerced values + per-field errors. */
parse(input: unknown): ParseResult {
const source = (input ?? {}) as Record<string, unknown>;
const value: Record<string, unknown> = {};
const errors: Record<string, string> = {};
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<string, FieldDescriptor> = {};
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(),
object: (fields: Record<string, FieldSchema>) => new ObjectSchema(fields),
};
// --- Environment configuration --------------------------------------------
/** Read the ambient environment (Bun.env, falling back to process.env). */
function readEnv(): Record<string, string | undefined> {
const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
if (bun?.env) return bun.env;
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).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<T = Record<string, unknown>>(
schema: ObjectSchema,
source: Record<string, string | undefined> = 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<string, string>): 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<T = Record<string, unknown>>(
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<Record<string, unknown>> {
const contentType = req.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
return (await req.json()) as Record<string, unknown>;
}
if (
contentType.includes("application/x-www-form-urlencoded") ||
contentType.includes("multipart/form-data")
) {
const out: Record<string, unknown> = {};
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<string, unknown>;
} catch {
return {};
}
}
export { renderSchemasScript, VALIDATE_RUNTIME } from "./runtime.ts";
+174
View File
@@ -0,0 +1,174 @@
/**
* Client-side validation. `renderSchemasScript` bakes the discovered schema
* descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
* eval-free validator that reads them and validates every `form[data-schema]`
* on submit and blur, writing messages into `[data-error="<field>"]` elements.
* The rule logic mirrors `checkField`/`applyRule` in index.ts.
*/
import type { SchemaDescriptor } from "./index.ts";
/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
}
export const VALIDATE_RUNTIME = String.raw`
(function () {
var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
var 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;
function applyRule(type, r, value) {
if (r.kind === "min") {
if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
}
if (r.kind === "max") {
if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
}
if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
return null;
}
function checkField(desc, raw) {
if (desc.type === "boolean") {
var b = raw === true || raw === "true" || raw === "on";
return (!desc.optional && !b) ? "Required" : null;
}
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
var empty = pre === undefined || pre === null || pre === "";
if (empty) return desc.optional ? null : "Required";
var value;
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
else value = String(pre);
for (var i = 0; i < desc.rules.length; i++) {
var err = applyRule(desc.type, desc.rules[i], value);
if (err) return err;
}
return null;
}
function rawValue(form, name) {
var el = form.elements[name];
if (!el) return undefined;
return el.type === "checkbox" ? el.checked : el.value;
}
function showError(form, name, err) {
var box = form.querySelector('[data-error="' + name + '"]');
if (box) box.textContent = err || "";
var el = form.elements[name];
if (el && el.setAttribute) {
if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
}
}
function validateForm(form, schema) {
var errors = 0;
Object.keys(schema.fields).forEach(function (name) {
var err = checkField(schema.fields[name], rawValue(form, name));
if (err) errors++;
showError(form, name, err);
});
return errors;
}
function collect(form) {
var out = {};
for (var i = 0; i < form.elements.length; i++) {
var el = form.elements[i];
if (!el.name) continue;
if (el.type === "checkbox") out[el.name] = el.checked;
else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
else out[el.name] = el.value;
}
return out;
}
function onSuccess(form, data) {
var redirect = form.getAttribute("data-redirect") || (data && data.redirect);
if (redirect) {
// Prefer client-side navigation (no full reload) when it is available.
if (window.__wrnexusNavigate) window.__wrnexusNavigate(redirect);
else location.assign(redirect);
return;
}
var box = form.querySelector("[data-success]");
if (box) { box.textContent = box.getAttribute("data-success") || "Success"; box.hidden = false; }
form.reset();
form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
}
function csrfHeader() {
var m = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
}
function submitForm(form, schema) {
var method = (form.getAttribute("method") || "post").toUpperCase();
var action = form.getAttribute("action") || location.pathname;
var btns = form.querySelectorAll("[type=submit]");
btns.forEach(function (b) { b.disabled = true; });
var headers = { "content-type": "application/json", accept: "application/json" };
var csrf = csrfHeader();
for (var k in csrf) headers[k] = csrf[k];
fetch(action, {
method: method,
headers: headers,
credentials: "same-origin",
body: JSON.stringify(collect(form)),
})
.then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) { return { res: res, data: data }; });
})
.then(function (r) {
if (r.res.ok) { onSuccess(form, r.data); return; }
// Surface server-side field errors (e.g. "email already taken").
var errors = r.data && r.data.errors;
if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
form.dispatchEvent(new CustomEvent("wire:error", { detail: r.data, bubbles: true }));
})
.catch(function () {
form.dispatchEvent(new CustomEvent("wire:error", { detail: { network: true }, bubbles: true }));
})
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
}
function bind(form) {
if (form.__wireValidateBound) return;
form.__wireValidateBound = 1;
var name = form.getAttribute("data-schema");
var schema = (window.__wireSchemas || {})[name];
if (!schema) return;
form.addEventListener("submit", function (e) {
e.preventDefault();
if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
submitForm(form, schema);
});
form.addEventListener("blur", function (e) {
var t = e.target;
if (t && t.name && schema.fields[t.name]) {
showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
}
}, true);
}
function init(root) {
(root || document).querySelectorAll("form[data-schema]").forEach(bind);
}
window.__wireValidate = { init: init };
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
else init(document);
})();
`.trim();