first commit
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user