Files
WRNexusJS/.publish/validation/README.md
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

258 lines
11 KiB
Markdown

# @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.
## Boundary contracts
Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the
same schema descriptors for APIs, actions, webhooks, realtime, queues, cron,
pub/sub, plugins, configuration, and environment variables.
```ts
import { ContractRegistry, defineEvent, v } from "@wrnexus/validation";
export const contracts = new ContractRegistry().register(
defineEvent({
name: "user.created",
version: 1,
consumers: ["notification-worker", "audit-service"],
payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }),
}),
);
```
Export the registry from `app/contracts.ts`, then accept a baseline with
`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed
contracts/fields, required-field additions, type changes, narrowed enums, and
tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known
consumers. A generated `wrnexus.contracts.json` can be used instead of a module.
## 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.
- `required(message?)` — require a non-empty value and optionally replace the default `"Required"` message on both server and browser validation.
- `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().required("Enter your email address").trim().email(),
password: v.string().required("Enter your password").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`.
## Helper and component kit
The public helper API includes `parseOrThrow`, `ValidationError`, `validationResponse`, `firstValidationError`, `validationSummary`, and `schemaFieldNames`.
Schema output is inferred automatically by `ObjectSchema`, `parseOrThrow`, `parseBody`, `parseEnv`, and `asyncSchema`. Use `InferSchema<typeof schema>` when a named output type is useful:
```ts
const accountSchema = v.object({
email: v.string().email(),
attempts: v.number().integer(),
});
type AccountInput = InferSchema<typeof accountSchema>;
const account = parseOrThrow(accountSchema, input);
// account.email: string
// account.attempts: number
```
Enable `validationPlugin()` for:
- `<ValidationSummary />`
- `<FieldError />`
The summary block composes `Alert` from `@wrnexus/ui`, while `FieldError` remains a lightweight accessible field-level primitive.
Schemas can drive external contracts without maintaining a second definition:
```ts
import {
localizeDescriptor,
openApiRequestBody,
parseDescriptor,
toJsonSchema,
} from "@wrnexus/validation";
const jsonSchema = toJsonSchema(contactSchema, {
id: "urn:example:contact",
title: "Contact request",
});
const requestBody = openApiRequestBody(contactSchema);
const mr = localizeDescriptor(contactSchema, (key, params) =>
translations.t(`validation.${key}`, params),
);
const result = parseDescriptor(mr, input);
```
JSON Schema output targets draft 2020-12, closes unknown object properties, and
maps lengths/ranges/formats/enums/patterns/integer rules. OpenAPI request bodies
reuse the same properties. Localized descriptors preserve explicit custom
messages and fill default required, type-coercion, and rule messages; the same
descriptor is consumable by server parsing and the eval-free browser runtime.