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
+179
View File
@@ -0,0 +1,179 @@
# @wrnexus/validation
> One fluent schema, validated on the server (API bodies, env vars) and mirrored to an eval-free browser validator for forms.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
## Overview
Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`.
## Installation
```bash
bun add @wrnexus/validation
```
> Private package — the machine must be authenticated to the `wrnexus` npm org
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
## API
### The `v` builder
```ts
import { v } from "@wrnexus/validation";
```
| Factory | Returns | Field methods |
| ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------- |
| `v.string()` | `StringSchema` | `email()`, `url()`, `uuid()`, `date()`, `length(n)`, `oneOf(string[])`, `pattern(re)`, `trim()`, `min(n)`, `max(n)` |
| `v.number()` | `NumberSchema` | `integer()`, `positive()`, `oneOf(number[])`, `min(n)`, `max(n)` |
| `v.boolean()` | `BooleanSchema` | (base methods only) |
| `v.object(fields)` | `ObjectSchema` | `parse(input)`, `describe()` |
Every field schema is chainable and shares these base methods:
- `min(n, message?)` / `max(n, message?)` — for strings, bounds the length; for numbers, bounds the value.
- `optional()` — an empty/missing value passes instead of erroring `"Required"`.
- `label(text)` — human label carried into the descriptor.
- `default(value)` — value substituted when the field is absent (implies `optional`).
- `refine(fn, message?)`**server-only** predicate. `fn` returns `true` (ok), `false` (use `message`), or a `string` (that error). Not serialized to the client.
Each string rule accepts an optional trailing `message` to override the default error text.
### `ObjectSchema`
```ts
schema.parse(input: unknown): ParseResult
schema.describe(): SchemaDescriptor
```
`parse` coerces each field (strings stay strings, `v.number()` runs `Number()`, `v.boolean()` treats `true` / `"true"` / `"on"` as true), applies its rules and refinements, fills in `default()` values, and returns:
```ts
interface ParseResult<T = Record<string, unknown>> {
ok: boolean; // true when errors is empty
value: T; // coerced values (present pass or fail)
errors: Record<string, string>; // field name → first failing message
}
```
`describe()` returns the JSON bridge for the client:
```ts
interface SchemaDescriptor {
type: "object";
fields: Record<string, FieldDescriptor>;
}
interface FieldDescriptor {
type: "string" | "number" | "boolean";
optional?: boolean;
label?: string;
trim?: boolean; // strings only
rules: RuleDescriptor[];
}
```
### Rules and coercion
`RuleDescriptor` is a discriminated union of the serializable rules — `min`, `max`, `length`, `email`, `url`, `uuid`, `date`, `oneOf`, `pattern`, `integer`. Two exported functions apply them and are shared by the server (the client runtime reimplements the same logic):
- `applyRule(type, rule, value): string | null` — validate one already-coerced value against one rule.
- `checkField(desc, raw): { value, error }` — coerce and validate one field. Empty input (`undefined`/`null`/`""`) is `"Required"` unless `optional`. Strings with `trim` are trimmed first. Numbers that fail `Number()` yield `"Must be a number"`.
Notes on specific rules: `email`/`url`/`uuid` test built-in regexes; `date` uses `Date.parse`; `pattern` reconstructs a `RegExp` from its `source`/`flags` and passes silently if the pattern is invalid; `integer` requires `Number.isInteger`; `positive()` is implemented as `min(Number.MIN_VALUE)`.
### API helpers
```ts
invalid(errors: Record<string, string>): Response // ready 400 { ok:false, errors }
parseBody<T>(schema, req):
Promise<{ ok: true; value: T } | { ok: false; response: Response }>
```
`parseBody` reads the request body from JSON, `application/x-www-form-urlencoded`, or `multipart/form-data`, validates it, and on failure hands back a ready 400 `Response`.
### Environment config
```ts
parseEnv<T>(schema: ObjectSchema, source?): T
```
Validates env vars (from `Bun.env`, falling back to `process.env`) against a schema and coerces them (`PORT` → number, `DEBUG` → boolean). On any problem it throws **one** error listing every offending variable, so misconfiguration fails fast at startup.
### Client runtime (from `runtime.ts`)
```ts
renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string
VALIDATE_RUNTIME: string
```
- `renderSchemasScript` produces `window.__wireSchemas = { name: descriptor, … };` to inline in the page.
- `VALIDATE_RUNTIME` is a self-contained, eval-free IIFE string. Injected as a `<script>`, it binds every `form[data-schema]` and validates on submit and blur, writing messages into `[data-error="<field>"]` elements and toggling `aria-invalid` / `.wire-invalid`. On a valid submit it `fetch`es the form `action` as JSON (attaching the `wire-csrf` cookie as an `x-csrf-token` header), then follows `data-redirect` / a `redirect` in the response, surfaces server-side field errors, and fires `wire:success` / `wire:error` events. It exposes `window.__wireValidate.init(root)` and self-initializes on `DOMContentLoaded`.
## Usage
Define a schema and validate an API body:
```ts
import { v, parseBody } from "@wrnexus/validation";
export const signupSchema = v.object({
email: v.string().trim().email(),
password: v.string().min(8).max(200),
age: v.number().integer().min(13).max(120).optional(),
role: v.string().oneOf(["user", "admin"]).default("user"),
agree: v.boolean(),
});
// inside a route handler
const result = await parseBody(signupSchema, req);
if (!result.ok) return result.response; // ready 400 with field errors
const { email, password, role } = result.value;
```
Server-only refinement:
```ts
const schema = v.object({
username: v
.string()
.min(3)
.refine((name) => !RESERVED.has(String(name)), "That name is taken"),
});
```
Validate environment at startup:
```ts
import { v, parseEnv } from "@wrnexus/validation";
export const env = parseEnv(
v.object({
DATABASE_URL: v.string().min(1),
PORT: v.number().integer().default(3000),
DEBUG: v.boolean().optional(),
}),
);
// throws one readable error listing every bad variable if misconfigured
```
Wire the same schema into the browser:
```ts
import { renderSchemasScript, VALIDATE_RUNTIME } from "@wrnexus/validation";
import { signupSchema } from "./app/schemas/signup.ts";
const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })}</script>
<script>${VALIDATE_RUNTIME}</script>`;
// render a <form data-schema="signup"> with [data-error="email"] etc.
```
## Requirements / Notes
- **Bun-only.** `parseEnv` reads `Bun.env` (falling back to `process.env`); `parseBody` and `invalid` use the Web `Request`/`Response` APIs that back `Bun.serve`.
- Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list.
- No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun.
- Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@wrnexus/validation",
"version": "0.2.12",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
+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();
+162
View File
@@ -0,0 +1,162 @@
import { test, expect } from "bun:test";
import { v, invalid, parseBody, checkField, parseEnv } from "../src/index.ts";
const login = v.object({
email: v.string().email(),
password: v.string().min(8, "too short"),
age: v.number().min(18).optional(),
});
test("parse coerces valid input and reports no errors", () => {
const r = login.parse({ email: "a@b.com", password: "secretpw", age: "25" });
expect(r.ok).toBe(true);
expect(r.value).toEqual({ email: "a@b.com", password: "secretpw", age: 25 });
expect(r.errors).toEqual({});
});
test("parse reports per-field errors with custom messages", () => {
const r = login.parse({ email: "nope", password: "x" });
expect(r.ok).toBe(false);
expect(r.errors.email).toBe("Must be a valid email");
expect(r.errors.password).toBe("too short");
});
test("optional fields may be omitted", () => {
const r = login.parse({ email: "a@b.com", password: "longenough" });
expect(r.ok).toBe(true);
expect(r.value.age).toBeUndefined();
});
test("required (non-optional) empty fields fail", () => {
const r = login.parse({});
expect(r.errors.email).toBe("Required");
expect(r.errors.password).toBe("Required");
});
test("describe emits a JSON descriptor", () => {
const d = login.describe();
expect(d.type).toBe("object");
expect(d.fields.email.rules).toContainEqual({ kind: "email" });
expect(d.fields.age.optional).toBe(true);
});
test("checkField coerces and applies rules", () => {
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "5").error).toBe(
"Must be at least 18",
);
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "20").value).toBe(20);
expect(checkField({ type: "string", optional: true, rules: [] }, "").error).toBeNull();
});
test("invalid() returns a 400 with errors", async () => {
const res = invalid({ email: "bad" });
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ ok: false, errors: { email: "bad" } });
});
test("parseBody reads JSON and form-encoded bodies", async () => {
const jsonReq = new Request("http://x", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "a@b.com", password: "longenough" }),
});
const j = await parseBody(login, jsonReq);
expect(j.ok).toBe(true);
const formReq = new Request("http://x", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "email=a@b.com&password=longenough",
});
const f = await parseBody(login, formReq);
expect(f.ok).toBe(true);
});
test("new string rules: url, uuid, date, length, oneOf, trim", () => {
const s = v.object({
site: v.string().url(),
id: v.string().uuid(),
when: v.string().date(),
code: v.string().length(4),
role: v.string().oneOf(["admin", "user"]),
name: v.string().trim().min(2),
});
const ok = s.parse({
site: "https://x.com/a",
id: "123e4567-e89b-12d3-a456-426614174000",
when: "2026-01-01",
code: "ABCD",
role: "admin",
name: " Ada ",
});
expect(ok.ok).toBe(true);
expect(ok.value.name).toBe("Ada"); // trimmed
const bad = s.parse({
site: "not-a-url",
id: "nope",
when: "not-a-date",
code: "AB",
role: "root",
name: " a ",
});
expect(bad.ok).toBe(false);
expect(Object.keys(bad.errors).sort()).toEqual(["code", "id", "name", "role", "site", "when"]);
});
test("number rules: positive, oneOf", () => {
const s = v.object({ qty: v.number().positive(), size: v.number().oneOf([1, 2, 3]) });
expect(s.parse({ qty: "5", size: "2" }).ok).toBe(true);
expect(s.parse({ qty: "-1", size: "9" }).ok).toBe(false);
});
test("default() fills absent fields; refine() runs server-side", () => {
const s = v.object({
role: v.string().default("user"),
even: v.number().refine((n) => (n as number) % 2 === 0, "Must be even"),
});
const r = s.parse({ even: 4 });
expect(r.ok).toBe(true);
expect(r.value.role).toBe("user"); // default applied
const bad = s.parse({ even: 3 });
expect(bad.ok).toBe(false);
expect(bad.errors.even).toBe("Must be even");
});
const envSchema = v.object({
DATABASE_URL: v.string().min(1),
PORT: v.number(),
DEBUG: v.boolean().optional(),
});
test("parseEnv coerces and returns typed values", () => {
const env = parseEnv<{ DATABASE_URL: string; PORT: number; DEBUG?: boolean }>(envSchema, {
DATABASE_URL: "sqlite://dev.db",
PORT: "3000",
DEBUG: "true",
});
expect(env.DATABASE_URL).toBe("sqlite://dev.db");
expect(env.PORT).toBe(3000);
expect(env.DEBUG).toBe(true);
});
test("parseEnv throws one readable error listing every missing/invalid var", () => {
let message = "";
try {
parseEnv(envSchema, { PORT: "not-a-number" });
} catch (err) {
message = (err as Error).message;
}
expect(message).toContain("Invalid environment variables");
expect(message).toContain("DATABASE_URL");
expect(message).toContain("PORT");
});
test("parseEnv defaults to reading the ambient environment", () => {
process.env.WIRE_TEST_ENV_VAR = "present";
const env = parseEnv<{ WIRE_TEST_ENV_VAR: string }>(
v.object({ WIRE_TEST_ENV_VAR: v.string().min(1) }),
);
expect(env.WIRE_TEST_ENV_VAR).toBe("present");
delete process.env.WIRE_TEST_ENV_VAR;
});