# Vielzeug — Full Documentation > Complete documentation for all 30 Vielzeug packages. Version: 1.0.2 --- ## @vielzeug/arsenal **Category:** utilities **Keywords:** utility, array, string, object, math, async, debounce, throttle, functional, helpers **Key exports:** chunk, debounce, throttle, allOf, clamp, isEqual, attempt, retry, sleep, hash, fuzzy, getPath (+4 more) **Related:** tempo, sourcerer, spell, coins ### Overview ## Why Arsenal? Arsenal favors a curated, typed utility surface over an everything-and-the-kitchen-sink API, with zero dependencies and modern tree-shakeable exports. | Feature | Arsenal | lodash-es | Remeda | | --------------------------- | --------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~72 kB | ~18 kB | | TypeScript-first ergonomics | | Partial | | | Deep utility coverage | | | Partial | | Async control-flow helpers | | Partial | | | Typed predicate functions | | | Partial | | Tree-shakeable modules | | | | | Zero dependencies | | | | **Use Arsenal when** you want one compact, typed utility layer that covers array/object/function/async/math use cases. For money handling, use [`@vielzeug/coins`](/coins/). **Consider narrower alternatives when** you only need a small functional subset and prefer ultra-focused APIs. ## Installation ```sh [pnpm] pnpm add @vielzeug/arsenal ``` ```sh [npm] npm install @vielzeug/arsenal ``` ```sh [yarn] yarn add @vielzeug/arsenal ``` ## Quick Start ```ts import { chunk, deepMerge, diff, fuzzy, hash, parseJSON, pick, queue, retry } from '@vielzeug/arsenal'; const pages = chunk([1, 2, 3, 4, 5], 2); const user = pick({ id: 1, name: 'Alice', role: 'admin' }, ['id', 'name']); const q = queue({ concurrency: 2 }); await q.add(() => fetch('/api/a')); const health = await retry(() => fetch('/api/health').then((r) => r.json()), { times: 3, delay: 250, timeout: 5000, }); const cfg = parseJSON('{"api":{"host":"localhost","port":3000}}', { fallback: { api: { host: 'localhost', port: 3000 } }, }); // Fuzzy search — filter mode returns T[], scored mode returns ScoredResult[] const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]; const hits = fuzzy(users, 'alice'); // User[] const ranked = fuzzy(users, 'alice', { scored: true }); // ScoredResult[] // [{ item: { name: 'Alice', ... }, score: 0.91 }, ...] // Deep merge with optional array concatenation deepMerge({ a: { x: 1 } }, { a: { y: 2 } }); // { a: { x: 1, y: 2 } } deepMerge({ tags: ['a'] }, { tags: ['b'] }, { arrayStrategy: 'concat' }); // { tags: ['a','b'] } // Structured diff diff({ port: 3000 }, { port: 4000 }); // { added: [], removed: [], changed: { port: { before: 3000, after: 4000 } } } // Deterministic cache keys from any value const key = hash({ sort: 'asc', filter: { role: 'admin' } }); ``` ## Features - **Array**: `chunk`, `compact`, `countBy`, `difference`, `filterMap`, `flatten`, `groupBy`, `indexBy`, `partition`, `fuzzy`, `fuzzyFilter`, `fuzzyScore`, `take/drop`, `union/intersection`, `zip/unzip`, and more - **Async**: `abortError`, `attempt`, `parallel`, `queue`, `retry`, `sleep`, `waitFor` - **Cache**: `memo` (sync LRU memoization), `stash` (TTL cache with stampede prevention) - **Object**: `pick`, `omit`, `mapValues`, `mapKeys`, `filterValues`, `defaults`, `deepMerge`, `shallowMerge`, `diff`, `diffArrays`, `invert`, `prune`, `getPath`, `flattenPaths`, `unflattenPaths`, `parseJSON`, `hash` (deterministic, handles `Date`/`Set`/`Map`/`bigint`) - **Function**: `pipe`, `assert`, `runAll`, `debounce`, `throttle`, `tap`, `identity`, `constant`, `once`, `memo` - **Guards**: `allOf`, `anyOf`, `noneOf`, `isArray`, `isBoolean`, `isDate`, `isDefined`, `isEmpty`, `isEqual`, `isError`, `isFunction`, `isMatch`, `isNil`, `isNumber`, `isPlainObject`, `isPrimitive`, `isPromise`, `isRegex`, `isString`, `isAbortError`, `shallowEqual` - **Math**: `lerp`, `normalize`, `mod`, `gcd/lcm`, `variance`, `standardDeviation`, `backoff`, plus numeric helpers - **Random**: `draw`, `random`, `shuffle`, `uuid` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Tempo](/tempo/) — date/time utilities including `expires`, `timeDiff`, and `dateRange` - [Coins](/coins/) — money formatting and currency conversion (`currency`, `exchange`) - [Sourcerer](/sourcerer/) — reactive paginated sources built on top of arsenal primitives - [Spell](/spell/) — schema validation that pairs naturally with `parseJSON` and `assert` ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --------------------------------------- | ----------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------- | | `chunk(input, size?)` | Split array or string into pages | Sync | Returns `string[]` for string input, `T[][]` for arrays | | `filterMap(array, fn)` | Map + filter in one pass, skipping `undefined` | Sync | Return `undefined` to drop an item; `null` is kept | | `groupBy(array, selector)` | Group items into a record by key | Sync | Key must be a `PropertyKey` | | `fuzzyFilter(array, query, options?)` | Filter array by fuzzy string similarity | Sync | Returns `T[]`; empty query returns all items unchanged | | `fuzzyScore(array, query, options?)` | Score and rank array items by similarity | Sync | Returns `ScoredResult[]` sorted by score descending; empty query returns all at score `1` | | `sort(array, selectors)` | Multi-key sort without mutation | Sync | Pass an object `{ key: 'asc' }` or a comparator | | `uniq(array, selector?)` | Deduplicate by value or key | Sync | Uses deep equality without a selector | | `parallel(array, fn, options?)` | Bounded async fan-out | Async | `limit` defaults to unbounded; `abortOnError` (default `false`) stops other workers on first failure | | `queue(options?)` | Serialise async jobs with concurrency cap | Async | `.onIdle()` resolves when queue drains; `.onSettled(cb)` subscribes to all task completions | | `attempt(fn)` | Run a sync or async fn and return `AttemptResult` — never throws | Both | Use `isOk(r)` / `isFail(r)` to narrow the result type | | `retry(fn, options?)` | Retry a throwing async function with timeout and signal | Async | Rethrows on exhaustion; `shouldRetry` receives `(error, failureIndex)` — not called on the final attempt | | `allOf(...predicates)` | AND combinator — all must pass | Sync | Zero predicates → vacuous truth (always `true`) | | `anyOf(...predicates)` | OR combinator — at least one must pass | Sync | Zero predicates → vacuous falsity (always `false`) | | `noneOf(...predicates)` | NOR combinator — none must pass | Sync | Single predicate is equivalent to logical NOT | | `debounce(fn, delay?, options?)` | Delay execution until input settles (trailing by default) | Sync | Returns `.cancel()`, `.flush()`, `.pending()`; reuse the returned function across renders | | `memo(fn, options?)` | Memoize a **sync** function with optional LRU size cap | Sync | Does **not** accept async functions; use `stash.getOrSet` for async caching | | `assert(condition, message?, options?)` | Throw if condition is falsy; narrows type via `asserts condition` | Sync | Accepts `{ type: ErrorConstructor }` for custom error class | | `diff(before?, after?)` | Structural diff between two objects | Sync | Returns `DiffResult` with `added`, `removed`, `changed` arrays — no sentinel symbols | | `parseJSON(json, options?)` | Safe JSON parse with fallback | Sync | Accepts `string \| null \| undefined`; returns `undefined` on failure | | `stash(options?)` | TTL-aware key-value cache with stampede prevention | Sync | `undefined` is a valid cached value — `getOrSet` will not re-invoke the factory | | `hash(value, options?)` | Deterministic JSON-like string for any value | Sync | Pass `{ onClassInstance: 'throw' }` to throw on class instances instead of coercing to `String()` | | `getPath(item, path, options?)` | Nested dot-notation access | Sync | Bracket notation auto-converted by default; pass `{ bracketNotation: false }` to throw instead | | `deepMerge(...items)` | Recursive object merge | Sync | Arrays are replaced by default; pass `{ arrayStrategy: 'concat' }` as last arg to concatenate | | `isMatch(object, source)` | Partial deep structural comparison | Sync | `Map` and `Set` sources are never matched — use `isEqual` for those | | `isEqual(a, b, options?)` | Deep or shallow equality | Sync | `depth: 'shallow'` compares one level by reference | | `backoff(attempt, maxMs?)` | Compute exponential backoff delay for retry loops | Async | Default cap `30 000 ms`; multiply by `Math.random()` for full-jitter | ## Package Entry Points | Import | Purpose | | ---------------------------- | ------------------------------------------------------------- | | `@vielzeug/arsenal` | All public exports | | `@vielzeug/arsenal/array` | Array utilities only | | `@vielzeug/arsenal/async` | Async utilities only | | `@vielzeug/arsenal/cache` | `memo` and `stash` | | `@vielzeug/arsenal/function` | `debounce`, `throttle`, `pipe`, `assert`, and more | | `@vielzeug/arsenal/guards` | Typed predicates and combinators (`allOf`, `anyOf`, …) | | `@vielzeug/arsenal/math` | Math utilities only | | `@vielzeug/arsenal/object` | `deepMerge`, `diff`, `getPath`, `parseJSON`, `hash`, and more | | `@vielzeug/arsenal/random` | `draw`, `random`, `shuffle`, `uuid` | | `@vielzeug/arsenal/string` | String utilities only | ## Array - `chunk(input, size?)` — splits array or string into chunks of `size` (default: `1`) - `compact(array)` — removes falsy values (`false`, `0`, `''`, `null`, `undefined`, `NaN`) - `compare(a, b)` — general-purpose comparator (numbers, strings, dates); string comparison uses `localeCompare` - `compareBy(selectors)` — multi-key comparator factory - `contains(array, value)` — `true` if array contains `value` using deep equality - `countBy(array, selector)` — group items and count occurrences per key - `difference(source, other, selector?)` — items in `source` not in `other` - `drop(array, n?)` — remove first `n` elements (default: 1) - `dropLast(array, n?)` — remove last `n` elements (default: 1) - `filterMap(array, callback)` — map + filter; return `undefined` to skip an item (`null` is kept) - `first(array, fallback?)` — first element or `fallback` - `flatten(array, depth?)` — flatten nested arrays; default depth `1` - `fuzzy(array, query, options?)` — see [fuzzy / fuzzyFilter / fuzzyScore](#fuzzy--fuzzyfilter--fuzzyscore) - `fuzzyFilter(array, query, options?)` — see [fuzzy / fuzzyFilter / fuzzyScore](#fuzzy--fuzzyfilter--fuzzyscore) - `fuzzyScore(array, query, options?)` — see [fuzzy / fuzzyFilter / fuzzyScore](#fuzzy--fuzzyfilter--fuzzyscore) - `groupBy(array, selector)` — group items into a record by key - `indexBy(array, selector)` — index items into a map by key (last value wins on collision) - `intersection(source, other, selector?)` — items in both arrays - `last(array, fallback?)` — last element or `fallback` - `partition(array, predicate)` — split into `[truthy, falsy]` tuples - `replace(array, predicate, value)` — replace first item matching predicate - `rotate(array, positions, options?)` — shift elements left or right - `sample(array, n)` — random `n` items without replacement - `sort(array, selectorOrSelectors, direction?)` — multi-key sort; does not mutate - `take(array, n?)` — first `n` elements (default: 1) - `takeLast(array, n?)` — last `n` elements (default: 1) - `toggle(array, item, selector?, options?)` — add if absent, remove if present - `union(source, other, selector?)` — unique items from both arrays - `uniq(array, selector?)` — deduplicate; uses deep equality without selector - `unzip(rows)` — transpose an array of tuples - `zip(...arrays)` — combine arrays into tuples ### fuzzy / fuzzyFilter / fuzzyScore ```ts // Unified entry point — prefer this over the lower-level functions fuzzy(array: T[], query: string, options: FuzzyOptions & { scored: true }): ScoredResult[] fuzzy(array: T[], query: string, options?: FuzzyOptions & { scored?: false }): T[] // Lower-level variants (also exported) fuzzyFilter(array: T[], query: string, options?: FuzzyOptions): T[] fuzzyScore(array: T[], query: string, options?: FuzzyOptions): ScoredResult[] type FuzzyOptions = { fields?: ReadonlyArray; // limit to specific object keys; searches all values when omitted maxDepth?: number; // max recursive depth for full-tree scanning; default: 10 normalize?: boolean; // NFKD Unicode normalization — 'café' matches 'cafe'; default: false threshold?: number; // 0–1 similarity cutoff; default: 0.25 // Note: scored is NOT a field on FuzzyOptions — it is only accepted by the fuzzy() overload call site }; type ScoredResult = { item: T; score: number }; ``` - `fuzzy(arr, q)` returns `T[]` (filter mode); `fuzzy(arr, q, { scored: true })` returns `ScoredResult[]`. - `fuzzyFilter` preserves array order; returns items whose best-field score meets `threshold`. - `fuzzyScore` returns items above `threshold`, sorted by score descending. Empty query returns all items at score `1`. - Nested object traversal is limited to **10 levels** by default (ignored when `fields` is set); override with `maxDepth`. `maxDepth` must be a non-negative integer (throws `ArsenalValidationError` otherwise) and is silently clamped to a hard ceiling of 1000 regardless of input, to bound worst-case recursion on untrusted data. --- ## Async - `abortError(signal?)` — constructs a `DOMException('AbortError')` or extracts the reason from `signal.reason` - `attempt(fn)` — wrap a **sync or async** function; returns `AttemptResult` — never throws; use `isOk(result)` / `isFail(result)` to narrow - `backoff(attempt, maxMs?)` — `min(1000 × 2ⁿ, maxMs)`; default cap `30_000 ms`; multiply by `Math.random()` for full jitter - `isOk(result)` — type guard: narrows `AttemptResult` to `{ ok: true; value: T }` - `isFail(result)` — type guard: narrows `AttemptResult` to `{ ok: false; error: unknown }` - `parallel(array, callback, options?)` — bounded concurrent fan-out; `options.limit` caps concurrency; `options.abortOnError` (default `false`) stops other in-flight workers as soon as one callback throws - `queue(options?)` — see [queue](#queue) - `retry(fn, options?)` — see [retry](#retry) - `sleep(ms, signal?)` — delay that resolves after `ms` or rejects when `signal` fires - `waitFor(condition, options?)` — poll until `condition()` returns `true` or timeout fires ### queue ```ts queue(options?: { concurrency?: number }): Queue interface Queue { add(fn: () => Promise, options?: { priority?: number }): Promise; clear(reason?: unknown): void; onIdle(): Promise; onSettled(cb: QueueSettledCallback): () => void; readonly active: number; // running tasks readonly pending: number; // queued tasks readonly size: number; // active + pending } // onSettled uses the shared AttemptResult type type QueueSettledCallback = (result: AttemptResult) => void; ``` - `concurrency` defaults to `1`. - `add()` accepts `{ priority?: number }` — higher numbers run first; equal-priority tasks run FIFO. - `clear(reason?)` rejects all pending tasks; running tasks are unaffected. - `onSettled(cb)` fires once per settled task with an `AttemptResult`; returns an unsubscribe function. - `onIdle()` resolves when both `active` and `pending` reach zero. ### retry ```ts retry( fn: (signal?: AbortSignal) => Promise, options?: { times?: number; // total attempts; default: 3 delay?: number | ((failureIndex: number) => number); // ms between retries; default: 250 timeout?: number; // per-attempt timeout in ms signal?: AbortSignal; // external cancellation shouldRetry?: (error: unknown, failureIndex: number) => boolean; // failureIndex is 0-based; NOT called on the final exhausting attempt onError?: (error: unknown) => void; // called on exhaustion and when shouldRetry returns false }, ): Promise ``` --- ## Cache ### memo ```ts memo(fn: SyncFn, options?: MemoOptions): Memoized type MemoOptions = { key?: (...args: Parameters) => PropertyKey; // custom cache key; defaults to JSON.stringify(args) maxSize?: number; // LRU eviction when exceeded; default: Infinity }; type Memoized = ((...args: Parameters) => ReturnType) & { clear(): void; invalidate(...args: Parameters): void; readonly size: number; }; ``` **`memo` only accepts sync functions.** Passing an async function is a compile-time error. Use `stash.getOrSet` for async caching with TTL and stampede prevention. ### stash ```ts stash(options?: CacheOptions): Stash type CacheOptions = { hash?: (key: K) => string; // defaults to String(key) — override for object keys maxSize?: number; // FIFO eviction when exceeded; default: Infinity onEvict?: (key: K, value: T) => void; persistence?: CachePersistence; // serialization pair for persistent caches ttlMs?: number; // global default TTL; overridable per set() call }; type CachePersistence = { serialize: (value: T) => string; // called in set() deserialize: (raw: string) => T; // called in get() / entries() }; type Stash = { get(key: K): T | undefined; set(key: K, value: T, options?: CacheSetOptions): void; delete(key: K): boolean; clear(): void; entries(): IterableIterator; // Sync factory — caches result (including undefined); factory called only once per key getOrSet(key: K, factory: () => T, options?: CacheSetOptions): T; // Async factory — concurrent callers share one in-flight Promise (stampede prevention) getOrSet(key: K, factory: () => Promise, options?: CacheSetOptions): Promise; readonly size: number; }; type CacheSetOptions = { forceRefresh?: boolean; // skip cache and in-flight; always calls factory ttlMs?: number; // override global ttlMs for this entry }; ``` - `undefined` is a valid cached value — `getOrSet` returns it without calling the factory again. - `delete()` during an in-flight `getOrSet` prevents the resolved value from writing to the cache; callers already awaiting still receive the value. - `clear()` increments an internal generation counter so in-flight results from the previous generation are discarded. - `persistence.serialize` and `persistence.deserialize` must both be present; providing a partial object is a TypeScript error. --- ## Function - `allOf(...predicates)` — AND combinator; zero predicates → always `true` - `anyOf(...predicates)` — OR combinator; zero predicates → always `false` - `assert(condition, message?, options?)` — throws if `condition` is falsy; narrows via `asserts condition`; `options.type` sets the error constructor (e.g. `RangeError`) - `constant(value)` — returns a function that always returns `value` - `debounce(fn, delay?, options?)` — **trailing-only by default** (`{ leading: false, trailing: true }`); returns `.cancel()`, `.flush()`, `.pending()`; calling the function returns `ReturnType | undefined` - `identity(value)` — returns its argument unchanged - `memo(fn, options?)` — see [Cache → memo](#memo) - `noneOf(...predicates)` — NOR combinator - `not(predicate)` — negates a single predicate; prefer over `noneOf` for single-predicate negation - `once(fn)` — run once; `.reset()` allows re-invocation - `pipe(...fns)` — left-to-right function composition; zero args returns identity `(x: T) => T` - `runAll(fns, options?)` — run all functions; errors are collected into `AggregateError`, not thrown individually; `{ reverse: true }` runs in reverse order - `tap(value, callback)` — call `callback(value)` for side effects and return `value` unchanged - `throttle(fn, delay?, options?)` — **leading-only by default** (`{ leading: true, trailing: false }`); returns `.cancel()`, `.flush()`, `.pending()` > **Note:** `allOf`, `anyOf`, and `noneOf` are also exported from `@vielzeug/arsenal/guards` and re-exported from the root package. ## Math - `abs(value)` - `allocate(amount, ratiosOrParts)` - `average(array, callback?)` - `clamp(n, min, max)` - `gcd(a, b)` - `lcm(a, b)` - `lerp(a, b, t)` - `linspace(start, end, steps?)` - `max(array, callback?)` - `median(array, callback?)` - `min(array, callback?)` - `mod(a, b)` — sign-correct modulo (result always has the sign of the divisor); throws `ArsenalValidationError` if `b` is `0` - `normalize(value, min, max)` — maps `value` to `0–1` relative to range; clamps - `percent(value, total)` - `range(stop)` / `range(start, stop)` / `range(start, stop, step)` - `round(value, precision?, parser?)` - `standardDeviation(array, callback?)` - `sum(array, callback?)` - `variance(array, callback?)` ## Object - `defaults(target, ...sources)` — fills `undefined` keys from sources; first source wins - `diff(before?, after?, compareFn?)` — see [diff](#diff) - `diffArrays(before, after, options?)` — see [diffArrays](#diffarrays) - `deepMerge(...items)` — recursive merge; arrays replaced by default; see [deepMerge](#deepmerge--shallowmerge) - `shallowMerge(...items)` — one-level `Object.assign`-style merge; variadic: `shallowMerge(a, b, c)` - `filterValues(obj, predicate)` — keep only entries where predicate returns `true` - `invert(obj)` — swap keys and values - `mapKeys(obj, mapper)` — transform all keys - `mapValues(obj, mapper)` — transform all values - `omit(obj, keys)` — return object without specified keys - `pick(obj, keys)` — return object with only specified keys - `prune(value)` — recursively remove null, undefined, empty strings, empty objects/arrays ### diff ```ts diff( before?: T, after?: T, compareFn?: (a: unknown, b: unknown) => boolean, ): DiffResult type DiffResult = { added: Array; // keys in after but not before removed: Array; // keys in before but not after changed: { [K in keyof T]?: { before: T[K]; after: T[K] } }; // keys present in both with differing values }; ``` Computes the structural difference between two plain objects. Both arguments default to `{}`. The custom `compareFn` defaults to deep `isEqual`. ```ts diff({ a: 1, b: 2, c: 3 }, { a: 1, b: 99 }); // { added: [], removed: ['c'], changed: { b: { before: 2, after: 99 } } } ``` ### deepMerge / shallowMerge ```ts deepMerge(...items: [...T] | [...T, DeepMergeOptions]): Merge shallowMerge(...items: [...T]): Merge type DeepMergeOptions = { arrayStrategy?: 'concat' | 'replace' }; // default: 'replace' ``` - Both functions are **variadic** — pass any number of objects as positional arguments. - `deepMerge` accepts an optional `DeepMergeOptions` as the **last** argument, detected only when it is a single-key object `{ arrayStrategy: 'concat' | 'replace' }`. - Prototype pollution is prevented: `__proto__`, `constructor`, and `prototype` keys are silently skipped. ```ts deepMerge({ a: { x: 1 } }, { a: { y: 2 } }); // { a: { x: 1, y: 2 } } deepMerge({ tags: ['a'] }, { tags: ['b'] }, { arrayStrategy: 'concat' }); // { tags: ['a', 'b'] } shallowMerge({ a: 1 }, { b: 2 }, { c: 3 }); // { a: 1, b: 2, c: 3 } ``` ### diffArrays ```ts diffArrays(before: T[], after: T[], options?: ArrayDiffOptions): ArrayDiff type ArrayDiffOptions = { compareFn?: (a: T, b: T) => boolean; // default: deep equality }; type ArrayDiff = { added: T[]; removed: T[] }; ``` Order-independent set-difference. Items in `after` not in `before` → `added`; items in `before` not in `after` → `removed`. ### getPath / flattenPaths / unflattenPaths ```ts getPath(item: T, path: P, options?: GetPathOptions): PathValue | undefined type GetPathOptions = { bracketNotation?: boolean; // auto-convert a[0].b → a.0.b; default: true fallback?: unknown; // returned when path is missing or resolves to undefined strict?: boolean; // throw ArsenalValidationError if any segment is missing; default: false }; ``` - Bracket notation is **auto-converted by default** (`a[0].b` → `a.0.b`). Pass `{ bracketNotation: false }` to throw an `ArsenalValidationError` on bracket syntax. - Unsafe path segments (`__proto__`, `constructor`, `prototype`) return `options.fallback` silently. - `strict` takes precedence over `fallback` when both are set. ```ts const obj = { a: { b: { c: 3 } }, d: [1, 2, 3] }; getPath(obj, 'a.b.c'); // 3 getPath(obj, 'a.b.x', { fallback: 'fallback' }); // 'fallback' getPath(obj, 'd[1]'); // 2 (bracket auto-converted) getPath(obj, 'e.f.g', { strict: true }); // throws ArsenalValidationError getPath(obj, 'a[0]', { bracketNotation: false }); // throws ArsenalValidationError ``` ```ts flattenPaths(obj: Record): Record unflattenPaths(flat: Record): Record ``` - `flattenPaths` flattens nested objects to `{ 'a.b': value }` maps. Nesting beyond 10 levels is treated as an opaque leaf. Unsafe path segments are silently skipped. - `unflattenPaths` reconstructs nested objects from dot-notation flat maps. Unsafe segments are silently skipped. ### parseJSON / hash ```ts parseJSON(json: string | null | undefined, options?: ParseJSONOptions): T | undefined type ParseJSONOptions = { fallback?: T; reviver?: (key: string, value: unknown) => unknown; validator?: (parsed: unknown) => boolean; }; ``` - `null`/`undefined` input → `fallback`; invalid JSON → `fallback`. - The JSON string `"null"` returns `null` (not `fallback`). - `validator` receives the parsed value; returning `false` falls back to `fallback`. ```ts hash(value: unknown, options?: HashOptions): string type HashOptions = { onClassInstance?: 'coerce' | 'throw'; // default: 'coerce' — calls String(value) }; ``` Produces a deterministic, order-independent JSON-like string. Object keys are sorted alphabetically. Handles `Date`, `RegExp`, `Set`, `Map`, and `bigint`. Circular references produce `'[Circular]'`. ```ts hash({ b: 2, a: 1 }); // '{"a":1,"b":2}' hash([3, 1, 2]); // '[3,1,2]' hash(new Date('2024-01-01T00:00:00Z')); // '[Date:2024-01-01T00:00:00.000Z]' hash(new Set([3, 1, 2])); // '[Set:1,2,3]' hash( new Map([ ['b', 2], ['a', 1], ]), ); // '[Map:"a"=>1,"b"=>2]' hash(42n); // '42n' hash(new MyClass()); // String(instance) by default hash(new MyClass(), { onClassInstance: 'throw' }); // throws ArsenalSerializationError const o: Record = { x: 1 }; o.self = o; hash(o); // '{"self":[Circular],"x":1}' ``` ## Random - `draw(array)` — pick one element at random; returns `undefined` for empty arrays - `drawMany(array, n)` — pick up to `n` unique elements at random (Fisher-Yates); `n >= array.length` returns all elements **unshuffled** - `random(min, max)` — random integer in `[min, max]` - `shuffle(array)` — Fisher-Yates shuffle; returns a new array - `uuid()` — `crypto.randomUUID()` wrapper ## String - `camelCase(str)` - `endsWith(value, suffix)` - `escape(value)` — HTML-escape `& " '` - `kebabCase(str)` - `pad(str, targetLength, fillString?)` - `pascalCase(str)` - `similarity(str1, str2)` — 0–1 Levenshtein similarity; throws `ArsenalValidationError` if either input exceeds 10 000 characters - `snakeCase(str)` - `startsWith(value, prefix)` - `titleCase(str)` - `truncate(str, limit?, options?)` - `unescape(value)` — reverse HTML escape - `words(str)` — split into word tokens ## Guards / Typed Predicates All predicates are standalone named exports. There is no `is` namespace. - `isAbortError(value)` — `Error` with `name === 'AbortError'` - `isArray(value, itemGuard?)` — optionally narrows item type when `itemGuard` is provided - `isBoolean(value)` - `isDate(value)` - `isDefined(value)` — not `undefined` - `isEmpty(value)` — empty string, array, object, Map, or Set - `isEqual(a, b, options?)` — deep equality by default; handles circular refs, `Date`, `Map`, `Set`; `Map`/`Set` are never equal to plain objects; `{ depth: 'shallow' }` for reference-level comparison - `isError(value)` - `isFunction(value)` - `isMatch(object, source)` — partial structural match; `Map`/`Set` sources always return `false` - `isNil(value)` — `null` or `undefined` - `isNumber(value)` - `isPlainObject(value)` — `Object.prototype` or `null` prototype only; excludes class instances and built-ins - `isPrimitive(value)` — `string`, `number`, or `boolean` - `isPromise(value)` - `isRegex(value)` - `isString(value)` - `shallowEqual(a, b)` — shallow (one-level reference) equality check --- ## Types ```ts export type Fn = (...args: Args) => Result; export type Obj = Record; export type Predicate = (value: T) => boolean; export type Primitive = string | number | boolean; export type Sorter = (a: T, b: T) => number; export type Unsubscribe = () => void; export type AttemptResult = { ok: true; value: T } | { error: unknown; ok: false }; export interface Queue { add(fn: () => Promise, options?: { priority?: number }): Promise; clear(reason?: unknown): void; onIdle(): Promise; onSettled(cb: QueueSettledCallback): () => void; readonly active: number; readonly pending: number; readonly size: number; } // QueueSettledResult was removed — onSettled callbacks now receive AttemptResult export type QueueSettledCallback = (result: AttemptResult) => void; export type RetryOptions = { times?: number; delay?: number | ((attempt: number) => number); timeout?: number; signal?: AbortSignal; shouldRetry?: (error: unknown, attempt: number) => boolean; onError?: (error: unknown) => void; }; export type WaitForOptions = { interval?: number; signal?: AbortSignal; timeout?: number }; export type MemoOptions = { key?: (...args: Parameters) => PropertyKey; maxSize?: number; }; export type Memoized = ((...args: Parameters) => ReturnType) & { clear(): void; invalidate(...args: Parameters): void; readonly size: number; }; export type CachePersistence = { deserialize: (raw: string) => T; serialize: (value: T) => string; }; export type CacheOptions = { hash?: (key: K) => string; maxSize?: number; onEvict?: (key: K, value: T) => void; persistence?: CachePersistence; ttlMs?: number; }; export type CacheSetOptions = { forceRefresh?: boolean; ttlMs?: number }; export type HashOptions = { onClassInstance?: 'coerce' | 'throw' }; export type GetPathOptions = { bracketNotation?: boolean; fallback?: unknown; strict?: boolean }; export type DeepMergeOptions = { arrayStrategy?: 'concat' | 'replace' }; export type DebounceOptions = { leading?: boolean; trailing?: boolean }; export type ThrottleOptions = { leading?: boolean; trailing?: boolean }; export type SortDirection = 'asc' | 'desc'; export type SortSelectors = Partial>; export type FuzzyOptions = { fields?: ReadonlyArray; maxDepth?: number; normalize?: boolean; threshold?: number; }; // Note: scored is accepted only at the fuzzy() overload call site, not a field on FuzzyOptions itself export type ScoredResult = { item: T; score: number }; export type ArrayDiff = { added: T[]; removed: T[] }; export type ArrayDiffOptions = { compareFn?: (a: T, b: T) => boolean }; export type DiffResult = { added: Array; changed: { [K in keyof T]?: { after: T[K]; before: T[K] } }; removed: Array; }; export type Once = T & { reset: () => void }; export type ParseJSONOptions = { fallback?: T; reviver?: (key: string, value: unknown) => unknown; validator?: (parsed: unknown) => boolean; }; export type TruncateOptions = { completeWords?: boolean; ellipsis?: string }; ``` ## Errors ### `ArsenalError` Base class for all arsenal errors. Use `instanceof ArsenalError` (or the static `ArsenalError.is()` helper) to catch any arsenal-originated error in one branch. ```ts class ArsenalError extends Error { static is(err: unknown): err is ArsenalError; } ``` Two named subclasses cover the package's error conditions — catch with `instanceof` for precise handling: ```ts import { ArsenalError, ArsenalValidationError } from '@vielzeug/arsenal'; try { range(0, 10, 0); // step cannot be 0 } catch (e) { if (e instanceof ArsenalValidationError) { // precise narrow — invalid argument or option } else if (ArsenalError.is(e)) { // catch any other arsenal error } } ``` **Named subclasses** | Class | Thrown when | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ArsenalValidationError` | A function argument or option is invalid — e.g. `range`'s non-finite bounds, `mod`'s zero divisor, `retry`'s non-positive `times`, `memo`/`stash`'s bad `maxSize`, `truncate`'s bad `limit`, `fuzzy`'s bad `maxDepth`, `getPath`'s strict-mode miss | | `ArsenalSerializationError` | Converting a value to/from its serialized form fails — `memo`'s default cache key (`JSON.stringify`) hits a circular reference, or `hash` encounters an unsupported class instance with `{ onClassInstance: 'throw' }` | Both subclasses extend `ArsenalError` with no additional members — they exist solely for `instanceof` narrowing. ## See Also - [`@vielzeug/coins`](/coins/) — money formatting and currency conversion (`currency`, `exchange`) - [`@vielzeug/tempo`](/tempo/) — date/time utilities (`expires`, `timeDiff`, `dateRange`) - [`@vielzeug/sourcerer`](/sourcerer/) — reactive paginated sources (`createLocalSource`, `createRemoteSource`) - [`@vielzeug/spell`](/spell/) — schema validation to pair with `parseJSON` and `assert` ### Usage Guide ## Basic Usage ### Named Imports (Recommended) ```ts import { chunk, groupBy, indexBy, pick, retry } from '@vielzeug/arsenal'; const users = [ { id: 1, name: 'Alice', role: 'admin' }, { id: 2, name: 'Bob', role: 'user' }, ]; const pages = chunk([1, 2, 3, 4, 5], 2); const byRole = groupBy(users, (u) => u.role); const byId = indexBy(users, (u) => u.id); const user = pick({ id: 1, name: 'Alice', role: 'admin' }, ['id', 'name']); const result = await retry(() => fetch('/api').then((r) => r.json()), { times: 2 }); ``` ### Namespace Import (Avoid) ```ts // Avoid: imports everything and weakens tree-shaking import * as arsenal from '@vielzeug/arsenal'; ``` ## Common Patterns ### Arrays ```ts import { filterMap, fuzzy, fuzzyFilter, fuzzyScore, groupBy, indexBy, partition, sort, toggle, uniq, zip, } from '@vielzeug/arsenal'; const values = [null, 1, 2, 3]; // filterMap maps values and skips undefined results const mapped = filterMap(values, (n) => (n == null ? 0 : n * 2)); // [0, 2, 4, 6] // return undefined to filter an item out const filtered = filterMap(values, (n) => (n == null ? undefined : n)); // [1, 2, 3] const sorted = sort( [ { age: 30, name: 'Bob' }, { age: 30, name: 'Alice' }, { age: 25, name: 'Chris' }, ], { age: 'desc', name: 'asc' }, ); const tags = toggle(['ts', 'node'], 'ts'); // ['node'] const deduped = uniq([1, 1, 2, 3]); // [1, 2, 3] const parts = partition([1, 2, 3, 4], (n) => n % 2 === 0); // [[2, 4], [1, 3]] const zipped = zip(['a', 'b'], [1, 2]); // [['a', 1], ['b', 2]] const byRole = groupBy([{ role: 'admin' }, { role: 'user' }], (item) => item.role); const byId = indexBy([{ id: 1 }, { id: 2 }], (item) => item.id); // fuzzy() is the unified entry point — filter mode or scored mode const roster = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]; const hits = fuzzy(roster, 'alice', { threshold: 0.4 }); // typeof roster[number][] const ranked = fuzzy(roster, 'alice', { scored: true }); // [{ item: { id: 1, name: 'Alice' }, score: 0.91 }, ...] // Lower-level variants are also exported const filtered = fuzzyFilter(roster, 'alice', { threshold: 0.4 }); const scored = fuzzyScore(roster, 'alice'); ``` ### Objects ```ts import { defaults, diff, getPath, hash, parseJSON, pick, omit, mapValues, prune } from '@vielzeug/arsenal'; const prev = { api: { host: 'localhost', port: 3000 }, secure: undefined as boolean | undefined }; const curr = structuredClone(prev); curr.api.port = 4000; const withDefaults = defaults(curr, { secure: true }); const changes = diff(prev, curr); // { added: [], removed: [], changed: { api: { before: { ... }, after: { ... } } } } // Bracket notation is auto-converted by default const port = getPath(curr, 'api.port'); // 4000 const arr = getPath({ items: [10, 20] }, 'items[1]'); // 20 const safe = getPath(curr, 'api.missing', { fallback: 0 }); // 0 const clean = prune({ a: 1, b: null, c: '' }); // { a: 1 } const parsed = parseJSON('{"ok":true}', { fallback: { ok: false } }); const publicUser = pick({ id: 1, name: 'Alice', password: 'secret' }, ['id', 'name']); const internalUser = omit({ id: 1, name: 'Alice', password: 'secret' }, ['password']); const renamed = mapValues({ a: 1, b: 2 }, (value) => value * 10); // Deterministic cache key from any value const key = hash({ sort: 'asc', filter: { role: 'admin' } }); console.log(withDefaults, changes, port, arr, safe, clean, parsed, publicUser, internalUser, renamed, key); ``` ### Functions ```ts import { assert, debounce, memo, once, pipe, runAll, throttle, allOf, noneOf, tap } from '@vielzeug/arsenal'; const toUpperTrimmed = pipe( (s: string) => s.trim(), (s) => s.toUpperCase(), ); const loadOnce = once(() => initApp()); const expensive = memo((a: number, b: number) => a * b); const onInput = debounce((q: string) => console.log(q), 300); const onScroll = throttle(() => console.log(window.scrollY), 100); const isWorkingAge = allOf( (age) => age >= 18, (age) => age n % 2 === 0)); const value = tap(42, (n) => console.log('debug', n)); // assert narrows the type and accepts a custom error class assert(n >= 1, 'n must be at least 1', { type: RangeError }); // runAll runs all teardowns, collecting errors rather than stopping on the first const cleanups = [() => a.dispose(), () => b.dispose()]; runAll(cleanups, { reverse: true }); ``` ### Async ```ts import { parallel, queue, retry, sleep, stash, waitFor, backoff } from '@vielzeug/arsenal'; const values = await parallel([1, 2, 3, 4], async (n) => n * 2, { limit: 3 }); const q = queue({ concurrency: 2 }); const a = q.add(() => fetch('/a').then((r) => r.text())); const b = q.add(() => fetch('/b').then((r) => r.text())); await q.onIdle(); // retry with per-attempt timeout and exponential backoff await retry((signal) => fetch('/health', { signal }).then((r) => r.json()), { times: 4, timeout: 5_000, delay: (failureIndex) => backoff(failureIndex), shouldRetry: (err, failureIndex) => { // failureIndex is 0-based: 0 = first failure, 1 = second, … // Not called on the final (exhausting) attempt. return failureIndex document.querySelector('#app') !== null, { timeout: 3_000 }); // memo only accepts sync functions — use stash.getOrSet for async caching const apiCache = stash({ ttlMs: 60_000 }); const payload = await apiCache.getOrSet('/api/profile', () => fetch('/api/profile').then((r) => r.json())); await Promise.all([a, b]); ``` ### Math ```ts import { backoff, clamp, lerp, normalize, percent, range, round } from '@vielzeug/arsenal'; // Clamp keeps a value within bounds clamp(150, 0, 100); // 100 clamp(-10, 0, 100); // 0 // Linear interpolation: t=0 → a, t=1 → b, t=0.5 → midpoint lerp(0, 100, 0.25); // 25 // Normalize maps a value into 0–1 relative to a range normalize(75, 0, 100); // 0.75 // range generates index arrays without mutation range(5); // [0, 1, 2, 3, 4] range(1, 6); // [1, 2, 3, 4, 5] range(0, 10, 2); // [0, 2, 4, 6, 8] // round to N decimal places round(3.14159, 2); // 3.14 // percent: what % is value of total? percent(1, 4); // 25 // backoff computes exponential delay for retry loops // min(1000 × 2ⁿ, maxMs) — multiply by Math.random() for full jitter backoff(0); // 1000 backoff(1); // 2000 backoff(2); // 4000 backoff(3, 5_000); // 5000 (capped — custom ceiling overrides the 30 000 default) ``` ## Typed Predicates All typed predicates are standalone named exports — there is no `is` namespace. ```ts import { isAbortError, isDefined, isEmpty, isEqual, isMatch, isNil, isNumber, isPlainObject, isString, } from '@vielzeug/arsenal'; function normalize(input: unknown) { if (isString(input)) return input.trim(); if (isNumber(input)) return String(input); if (Array.isArray(input)) return input.length; if (isNil(input)) return null; return input; } isEqual({ a: 1 }, { a: 1 }); // true — deep equality isEqual({ a: 1 }, { a: 1 }, { depth: 'shallow' }); // false — different references // isMatch: partial structural check — plain objects and arrays only isMatch({ a: 1, b: 2 }, { a: 1 }); // true isMatch({ a: 1 }, new Map([['a', 1]])); // false — Map sources are never matched // isPlainObject: true for {} and Object.create(null); false for class instances, Map, Set, Array isPlainObject({}); // true isPlainObject(new Map()); // false try { await fetch(url, { signal }); } catch (err) { if (isAbortError(err)) return; // request was cancelled — ignore throw err; } ``` ## Advanced Usage ### Cache via stash ```ts import { stash } from '@vielzeug/arsenal'; // Simple string cache — no options needed for string keys const sessionCache = stash(); sessionCache.set('user:1', { id: 1, name: 'Alice' }, { ttlMs: 30_000 }); const alice = sessionCache.get('user:1'); // Custom key type — provide a hash function for non-string keys const userCache = stash({ hash: (key) => JSON.stringify(key), maxSize: 500, // FIFO eviction when exceeded onEvict: (key, value) => console.log('evicted', key, value), ttlMs: 60_000, // global default TTL }); // getOrSet: caches the result including undefined — factory called only once per key const data = await userCache.getOrSet(['user', 2], () => fetchUser(2)); // Concurrent calls share one in-flight Promise (stampede prevention) ``` ### Stable cache keys ```ts import { hash } from '@vielzeug/arsenal'; // Consistent key regardless of object key insertion order const key1 = hash({ sort: 'asc', filter: { role: 'admin' } }); const key2 = hash({ filter: { role: 'admin' }, sort: 'asc' }); key1 === key2; // true // Handles Dates, Sets, Maps, bigints, null, undefined hash(new Set([3, 1, 2])); // '[Set:1,2,3]' hash( new Map([ ['b', 2], ['a', 1], ]), ); // '[Map:"a"=>1,"b"=>2]' // Class instances: String(instance) by default; throw with onClassInstance: 'throw' hash(new MyClass(), { onClassInstance: 'throw' }); // ArsenalSerializationError ``` ### Fuzzy search with scoring ```ts import { fuzzy } from '@vielzeug/arsenal'; const users = [ { id: 1, name: 'Alice Smith', role: 'admin' }, { id: 2, name: 'Alan Jones', role: 'user' }, { id: 3, name: 'Bob Brown', role: 'user' }, ]; // Filter mode — returns T[] in original order const filtered = fuzzy(users, 'alice', { threshold: 0.4 }); // Scored mode — returns ScoredResult[] sorted by score descending const ranked = fuzzy(users, 'ali', { scored: true, threshold: 0.3 }); // [{ item: { id: 1, name: 'Alice Smith', ... }, score: 0.91 }, // { item: { id: 2, name: 'Alan Jones', ... }, score: 0.52 }] // Restrict search to specific fields const byName = fuzzy(users, 'ali', { fields: ['name'], scored: true }); ``` ### Memoization with LRU eviction ```ts import { memo, stash } from '@vielzeug/arsenal'; // LRU cache with max 100 entries const computeScore = memo((id: number, weight: number) => id * weight, { maxSize: 100 }); // Custom key for object arguments const formatLabel = memo((params: { page: number; size: number }) => `Page ${params.page} of ${params.size}`, { key: ({ page, size }) => `${page}:${size}`, }); // memo only accepts sync functions — use stash.getOrSet for async caching with TTL const apiCache = stash({ ttlMs: 60_000 }); const data = await apiCache.getOrSet('user:1', () => fetch('/api/users/1').then((r) => r.json())); ``` ## Framework Integration ```tsx [React] import { debounce, filterMap } from '@vielzeug/arsenal'; import { useMemo } from 'react'; // Stable debounced handler — recreated only once const onSearch = useMemo(() => debounce((q: string) => console.log(q), 250), []); const visible = useMemo(() => filterMap(data, (item) => (item.hidden ? undefined : item)), [data]); ``` ```ts [Vue 3] import { computed, ref } from 'vue'; import { groupBy } from '@vielzeug/arsenal'; const users = ref([]); const byRole = computed(() => groupBy(users.value, (u) => u.role)); ``` ```svelte [Svelte] import { groupBy } from '@vielzeug/arsenal'; export let users: User[] = []; $: byRole = groupBy(users, (u) => u.role); ``` ## Working with Other Vielzeug Libraries ### With Spell Use `parseJSON` from arsenal together with `s` schemas from Spell to parse and validate in one step. ```ts import { parseJSON } from '@vielzeug/arsenal'; import { s } from '@vielzeug/spell'; const UserSchema = s.object({ id: s.number(), name: s.string() }); const raw = localStorage.getItem('user'); const user = parseJSON(raw, { validator: (v) => UserSchema.safeParse(v).ok, fallback: null, }); ``` ### With Sourcerer Use `fuzzy` and `sort` from arsenal as transform functions inside a `createLocalSource`. ```ts import { fuzzy, sort } from '@vielzeug/arsenal'; import { createLocalSource } from '@vielzeug/sourcerer'; const source = createLocalSource(users, { search: (items, query) => fuzzy(items, query), sort: (items, key, dir) => sort(items, { [key]: dir }), }); ``` ### With Tempo Date utilities (`expires`, `timeDiff`, `dateRange`) are in `@vielzeug/tempo`. ```ts import { expires, timeDiff } from '@vielzeug/tempo'; const status = expires(token.expiresAt, 3); // 'SOON' | 'EXPIRED' | 'LATER' | 'NEVER' const { value, unit } = timeDiff(token.issuedAt); // e.g. { value: 2, unit: 'day' } ``` ### With Coins Money formatting and currency conversion have moved to `@vielzeug/coins`. ```ts import { currency, exchange } from '@vielzeug/coins'; const price = currency({ amount: 123456n, currency: 'USD' }); // $1,234.56 ``` ## Best Practices - Prefer named imports from `@vielzeug/arsenal`. - Use `getPath(obj, 'a.b.c')` for nested access. Bracket notation (`a[0]`) is auto-converted to dot notation by default; pass `{ bracketNotation: false }` to throw on bracket syntax instead. - For cancellation-aware async work, pass `AbortSignal` through your callback stack; use `isAbortError(err)` to distinguish cancellation from other errors. - Use `queue` for explicit concurrency and `parallel` for bounded fan-out processing. - Prefer `memo` over ad-hoc caching; supply a `key` function when arguments are objects. Use `maxSize` to bound memory usage. - Use `stash` when you need TTL-based expiry, stampede prevention, or an eviction callback. It correctly caches `undefined` values. - Use `stringify` to generate deterministic cache keys from complex option objects. - `isMatch` supports plain objects and arrays only — do not pass `Map` or `Set` as the source argument. - Use `isPlainObject` for plain-object checks; it returns `false` for class instances, `Map`, `Set`, and arrays. - Reuse debounced/throttled functions instead of recreating them per render. ### Examples ## Examples - [Array utilities](./examples/array.md) - [Async utilities](./examples/async.md) - [Cache utilities](./examples/cache.md) - [Function utilities](./examples/function.md) - [Guards / Typed predicates](./examples/typed.md) - [Math utilities](./examples/math.md) - [Object utilities](./examples/object.md) — includes `getPath`, `parseJSON`, `stringify`, `diff`, `deepMerge` - [Random utilities](./examples/random.md) - [String utilities](./examples/string.md) ### REPL Examples - chunk - Split array into chunks (id: `array-chunk`) - filterMap - Filter and map array elements (id: `array-filter`) - groupBy - Group array by key (id: `array-group`) - filterMap - Transform array elements (id: `array-map`) - fuzzy - Fuzzy search in arrays (id: `array-search`) - fuzzy - Unicode normalization (normalize option) (id: `array-search-normalize`) - uniq - Remove duplicates (id: `array-uniq`) - attempt - Safe async execution with isFail/isOk helpers (id: `async-attempt`) - parallel - Controlled parallel execution (id: `async-parallel`) - queue - Parallel execution with concurrency limit (id: `async-pool`) - queue - Concurrent execution with active/pending tracking (id: `async-queue`) - retry - Retry failed operations (id: `async-retry`) - waitFor - Poll until condition is true or timeout/abort fires (id: `async-waitFor`) - debounce - Trailing (default) and leading-edge options (id: `function-debounce`) - memo - LRU cache with size tracking and invalidation (id: `function-memo`) - pipe - Left-to-right function composition (id: `function-pipe`) - runAll - Run all callbacks, collect errors (id: `function-runAll`) - stash - Async caching with stampede prevention (id: `function-stash-async`) - throttle - Throttle function calls (id: `function-throttle`) - average - Calculate average (id: `math-average`) - diff - Compare objects (id: `object-diff`) - diffArrays - Set and LCS strategies (id: `object-diffArrays`) - getPath - Dot-notation access with fallback and strict options (id: `object-getPath`) - hash - Deterministic cache key from any value (id: `object-hash`) - deepMerge - Merge objects (id: `object-merge`) - parseJSON - Safe JSON parsing with fallback and validator (id: `object-parseJSON`) - prune - Remove empty values (id: `object-prune`) - stash - Key-value cache with TTL and stampede prevention (id: `object-stash`) - camelCase - Convert to camelCase (id: `string-camelcase`) - Type checking utilities (id: `typed-is`) --- ## @vielzeug/clockwork **Category:** state **Keywords:** state-machine, finite-state, reactive, typed, async-tasks, persistence, debugging, hierarchical, interceptors **Key exports:** createMachine, ClockworkError, SendResult, InterceptorFn, InvokeArgs, AfterEvent, AfterActionFn, TransitionInput **Related:** ripple, herald, ward ### Overview ## Why Clockwork? Manual state management leads to invalid state combinations, unreachable code paths, and complex conditional logic. FSMs eliminate these bugs by making state transitions explicit and exhaustive. ```ts // Before — manual state management type LoaderState = { data?: string; error?: Error; isRetrying?: boolean; status: 'error' | 'idle' | 'loading' | 'success'; }; // Multiple invalid state combinations are possible. // After — FSM enforces valid state combinations import { createMachine } from '@vielzeug/clockwork'; type Event = { type: 'FETCH' } | { type: 'DONE'; data: string } | { type: 'FAIL'; error: Error }; const loader = createMachine({ context: { data: '' as string, error: undefined as Error | undefined }, initial: 'idle', states: { error: { on: { FETCH: { target: 'loading' } } }, idle: { on: { FETCH: { target: 'loading' } } }, loading: { on: { DONE: { target: 'success' }, FAIL: { target: 'error' } } }, success: { on: { FETCH: { target: 'loading' } } }, }, }).start(); // Now success && error is impossible. State is always valid. ``` | Feature | Clockwork | xstate | zustand | | -------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------- | | Bundle size | | ~15 KB | ~2 KB | | Zero dependencies | | 5+ deps | | | Typed discriminated events | | Partial | | | Reactive signals | Native | Observer pattern | Native | | Persistence adapter | Pluggable | | | | Hierarchical states | Compound + leaf resolution | | | | Interceptor pipeline | Pure functions | | | | Context isolation | Cloned on every transition | | | **Use Clockwork when** you need predictable state machines with strict type safety, reactive integrations, and a minimal footprint in applications where state is defined upfront. **Consider xstate when** you need visual state machine tooling or already have a large bundle budget. ## Installation ```sh [pnpm] pnpm add @vielzeug/clockwork ``` ```sh [npm] npm install @vielzeug/clockwork ``` ```sh [yarn] yarn add @vielzeug/clockwork ``` ## Quick Start ```ts import { createMachine } from '@vielzeug/clockwork'; type Event = { type: 'START' } | { type: 'COMPLETE'; result: string }; const m = createMachine({ context: { count: 0 }, initial: 'idle', states: { active: { on: { COMPLETE: { actions: [ ({ context, event }) => { context.count = event.result.length; }, ], target: 'idle', }, }, }, idle: { on: { START: { target: 'active' } }, }, }, }).start(); console.log(m.state.value); // 'idle' console.log(m.context.value.count); // 0 console.log(m.send({ type: 'START' }).status); // 'transitioned' console.log(m.send({ type: 'COMPLETE', result: 'hello' }).status); // 'transitioned' console.log(m.state.value); // 'idle' console.log(m.context.value.count); // 5 ``` ## Features - **`createMachine()`** — Validate config; returns a reusable `MachineDefinition` handle - **`.start(options?)`** — Spawn independent running instances from a definition - **`.resolve(input, options?)`** — Pure transition resolver for testing (no side effects) - **Shorthand transitions** — Single transition or array, your choice - **Typed events** — Discriminated unions with TypeScript inference - **`SendResult`** — `send()` returns `{ status }` where `status` is `'transitioned'` | `'queued'` | `'rejected'` - **Reactive state** — State and context are `@vielzeug/ripple` signals - **Async invokes** — Native Promise support; `onDone`/`onError` receive `(result, context)` - **Delayed transitions** — Timer-based `after` with guards and actions - **Hierarchical states** — Compound states with automatic leaf resolution - **Interceptors** — Pure event interceptors — return event or `null` to block - **Persistence** — Snapshot save/load adapter - **Tracing** — Ring buffer; auto-enabled (50 entries) when `onDebug` is set - **Debug events** — Unified discriminated union `onDebug` callback; use `debugMachine()` from `@vielzeug/clockwork/devtools` for pre-wired console logging - **Event queue** — FIFO processing with configurable infinite-loop guard - **Context isolation** — Cloned draft before every commit; machine is unchanged on validation failure - **Subscribe** — Change-detection subscription without direct ripple dependency Creating machines per-request in an SSR handler? See [Server-Side Rendering](./usage.md#server-side-rendering) for the one-time setup that keeps concurrent requests isolated. ## Sub-paths | Import | Purpose | | ------------------------------ | ------------------------------------------------------- | | `@vielzeug/clockwork` | All exports and types | | `@vielzeug/clockwork/devtools` | `debugMachine` — pre-wired console logging (dev only) | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — Reactive signals and effects; core reactivity layer for Clockwork - [Herald](/herald/) — Typed event bus; complementary for event-driven architectures - [Ward](/ward/) — RBAC engine; use alongside Clockwork for state-dependent permissions - [Forge](/forge/) — Form state management; integrates with Clockwork for multi-step workflows ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------------------ | ---------------------------------------------------- | -------------- | ---------------------------------------------------------------- | | `createMachine()` | Validate config; returns definition handle | Sync | Throws `ClockworkError` on invalid config — check at startup | | `.start()` | Start a running machine instance from a definition | Sync | Call `.start(options?)` on the returned `MachineDefinition` | | `.resolve()` | Pure transition resolver for testing (no side effects) | Sync | Does not fire debug hooks; pass `onGuard` via `options` object | | `ClockworkError` | Base class for all validation failures | — | Use `instanceof ` or `ClockworkError.is()` | ## Package Entry Points | Import | Purpose | | ------------------------------ | ---------------------------------------- | | `@vielzeug/clockwork` | All exports and types | | `@vielzeug/clockwork/devtools` | `debugMachine` — debug wrapper (dev only) | ## `createMachine()` Validates a machine configuration and returns a reusable `MachineDefinition` handle. Throws a `ClockworkError` subclass synchronously if validation fails. Use `createMachine(config).start()` for the common one-shot case, or hold the definition to start multiple instances or call `.resolve()` in tests. ```ts function createMachine( config: MachineConfig, ): MachineDefinition; ``` **Validates at call time:** - `initial` state exists in `states` - All transition `target` values exist in `states` — full nested paths (e.g. `loading.fetching`) are validated, not just the root segment - Transition arrays are non-empty - Compound states have `initial` pointing to a valid child - `after` delays are finite numbers (`>= 0`; `NaN` and `Infinity` are rejected) - `invoke` arrays are non-empty **Returns:** `MachineDefinition` — see [MachineDefinition](#machinedefinition). **Example:** ```ts // One-shot — validate + start immediately: const m = createMachine({ context: { count: 0 }, initial: 'idle', states: { active: { on: { STOP: { target: 'idle' } } }, idle: { on: { GO: { target: 'active' } } }, }, }).start({ onDebug: ({ type, ...rest }) => console.log(type, rest), }); // Reusable definition — start multiple instances: const counterDef = createMachine({ context: { count: 0 }, initial: 'idle', states: { idle: { on: { INC: { actions: [({ context }) => { context.count++ }], target: 'idle' } } } }, }); const m1 = counterDef.start(); const m2 = counterDef.start({ snapshot: { context: { count: 10 }, state: 'idle' } }); // Test transitions without a running machine: counterDef.resolve({ context: { count: 0 }, event: { type: 'INC' }, state: 'idle' }); ``` ## Types ### `MachineEvent` Base constraint for all event types. ```ts type MachineEvent = { type: string }; ``` Define events as discriminated unions for full TypeScript inference: ```ts type AppEvent = { type: 'FETCH' } | { type: 'DONE'; data: string } | { type: 'FAIL'; error: Error }; ``` --- ### `EventType` Extracts the union of all event type strings. ```ts type EventType = Ev['type'] & string; type MyEvent = { type: 'A' } | { type: 'B' }; type Types = EventType; // 'A' | 'B' ``` --- ### `EventByType` Narrows the event union to a specific type. Useful for accessing typed payloads inside generic helpers. ```ts type EventByType> = Extract; type SetEvent = EventByType; // { type: 'DONE'; data: string } ``` --- ### `LifecycleEvent` Internal synthetic events dispatched by the runtime. Received by `entry`, `exit`, and invoke `src` on state entry. ```ts type LifecycleEvent = | { readonly type: '$init' } | { readonly type: '$hydrate' } | { readonly delay: number; readonly type: '$after' }; ``` --- ### `ActionArgs` Arguments passed to action functions. ```ts type ActionArgs = { context: Ctx; // mutable context draft readonly event: Ev; // triggering event (readonly) }; ``` --- ### `ActionFn` A function that mutates context during a transition. ```ts type ActionFn = (args: ActionArgs) => void; ``` --- ### `GuardFn` A predicate that decides whether a transition is taken. Context is **readonly** — mutating it inside a guard corrupts live state. ```ts type GuardFn = (args: { readonly context: Readonly; readonly event: Readonly; }) => boolean; ``` --- ### `LifecycleFn` Function type for `entry` and `exit` hooks. Receives context and an event that may be a user event or a lifecycle event (`$init`, `$hydrate`, `$after`). ```ts type LifecycleFn = (args: { context: Ctx; readonly event: Ev | LifecycleEvent; }) => void; ``` --- ### `TransitionDef` A single transition configuration. ```ts type TransitionDef = EventType, > = { actions?: Array>>; guard?: GuardFn>; target: State; }; ``` --- ### `AfterEvent` The synthetic event dispatched to `after` action functions. Part of `LifecycleEvent`. ```ts type AfterEvent = { readonly delay: number; readonly type: '$after' }; ``` `delay` reflects the configured delay in milliseconds. --- ### `AfterActionFn` Convenience alias for action functions used in `after` delayed transitions. ```ts type AfterActionFn = ActionFn; ``` Use this when extracting after-actions into named functions: ```ts import type { AfterActionFn } from '@vielzeug/clockwork'; const logTimeout: AfterActionFn = ({ context, event }) => { console.log(`Timed out after ${event.delay}ms, attempts: ${context.attempts}`); }; ``` --- ### `AfterDef` Configuration for a delayed (timer-based) transition. ```ts type AfterDef = { actions?: Array>; delay: number; // milliseconds, must be a finite number >= 0 guard?: GuardFn; target: State; }; ``` The `guard` receives `{ readonly context: Readonly; readonly event: Readonly }` — same signature as all other guards. After-timers are automatically cleared when the owning state is exited. --- ### `StateNode` Configuration for a single state. ```ts type StateNode = { after?: Array>; entry?: LifecycleFn; exit?: LifecycleFn; initial?: string; // required for compound states invoke?: Array>; on?: Partial]: TransitionInput }>; states?: Record>; // nested substates }; ``` When `states` is provided, `initial` must point to one of its keys (validated at definition time). --- ### `MachineConfig` The configuration object passed to `machine()` or `define()`. ```ts type MachineConfig = ContextField & { initial: State; states: Record>; validateContext?: ContextValidator; }; ``` `context` is **required** when `Ctx` has properties. It is optional (and can be omitted) when `Ctx` is `Record`. --- ### `ContextValidator` A function used for `validateContext`. - Return `true` for valid context. - Return a non-empty string describing the failure (surfaced as `ClockworkInvalidValidateContextError.reason`). ```ts type ContextValidator = (context: Ctx) => string | true; ``` --- ### `TransitionInput` A single transition or an array of conditional alternatives, as used in the `on` map. ```ts type TransitionInput = EventType, > = TransitionDef | Array>; ``` Use `TransitionInput` to type the value of an `on` entry when extracting transitions into named variables: ```ts import type { TransitionInput } from '@vielzeug/clockwork'; const onFetch: TransitionInput = [ { guard: ({ context }) => context.authorized, target: 'loading' }, { target: 'denied' }, ]; ``` --- ### `InterpretOptions` Options passed to `machine()` or `define().start()`. ```ts type InterpretOptions = { clone?: (value: T) => T; interceptors?: Array>; maxTransitionsPerFlush?: number; onDebug?: (event: DebugEvent) => void; persistence?: PersistenceAdapter; snapshot?: MachineSnapshot; traceLimit?: number; }; ``` | Option | Default | Description | | ------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clone` | `structuredClone` | Context clone function. Must return a structurally equivalent object. The caller is responsible for ensuring a safe prototype when providing a custom function. | | `interceptors` | `[]` | Pure event interceptors, run left-to-right. Return `null` to block. | | `maxTransitionsPerFlush` | `1000` | Maximum transitions processed per flush before throwing loop-guard error | | `onDebug` | `undefined` | Callback for all debug events (guards, transitions, invokes, skips). Auto-enables a 50-entry trace buffer unless `traceLimit` is set. | | `persistence` | `undefined` | Save/load adapter for snapshot persistence | | `snapshot` | `undefined` | Snapshot to hydrate from on startup (takes priority over persistence) | | `traceLimit` | auto (`50`/`0`) | Ring buffer capacity for `getTrace()`. Defaults to `50` when `onDebug` is set; `0` (disabled) otherwise. Set explicitly to override. | --- ### `DebugEvent` Discriminated union of debug events passed to `onDebug`. ```ts type DebugEvent = | { type: 'guard'; context: Readonly; event: Ev; from: State; passed: boolean; target: State } | { type: 'transition'; event: Ev | LifecycleEvent; from: State; to: State } | { type: 'transition-skipped'; event: Ev; from: State } | { type: 'invoke-start'; context: Readonly; event: Ev | LifecycleEvent; invokeId: string; state: State } | { type: 'invoke-done'; context: Readonly; event: Ev | LifecycleEvent; invokeId: string; result: unknown; state: State; } | { type: 'invoke-error'; context: Readonly; error: unknown; event: Ev | LifecycleEvent; invokeId: string; state: State; } | { type: 'invoke-abort'; context: Readonly; event: Ev | LifecycleEvent; invokeId: string; state: State }; ``` | Type | Fires when | | -------------------- | -------------------------------------------------------------- | | `guard` | A guard is evaluated — fires for both passed and failed guards | | `transition` | A transition is committed (after entry/exit actions) | | `transition-skipped` | An event has no matching transition (or all guards failed) | | `invoke-start` | An async invoke task starts | | `invoke-done` | An invoke task resolves successfully | | `invoke-error` | An invoke task rejects | | `invoke-abort` | An invoke task is cancelled because the state was exited | --- ### `SendResult` Result object returned by `send()`. ```ts type SendResult = { readonly status: 'queued' | 'rejected' | 'transitioned'; }; ``` | `status` value | Meaning | | -------------- | ----------------------------------------------------------------------------------------------------------- | | `transitioned` | A transition occurred synchronously | | `queued` | Called re-entrantly (e.g. from inside an action); the event is queued for the next drain | | `rejected` | No matching transition, a guard failed, an interceptor blocked it, or the machine is disposed (dev warning) | --- ### `InterceptorFn` Pure event interceptor. Return the event (possibly transformed) to allow it, or `null` to block it. Runs left-to-right; first `null` stops the chain. ```ts type InterceptorFn = ( event: Ev, snapshot: { readonly context: Readonly; readonly state: State }, ) => Ev | null; ``` **Example — logging and blocking:** ```ts const rateLimiter: InterceptorFn = (event, snap) => { if (snap.context.rateLimited) return null; // block return event; // allow }; const m = createMachine(config).start({ interceptors: [rateLimiter] }); ``` --- ### `MachineInstance` The live machine object returned by `createMachine().start()`. ```ts interface MachineInstance { readonly context: Readable; readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly state: Readable; can(event: Ev): boolean; dispose(): void; getSnapshot(): MachineSnapshot; getTrace(): readonly TransitionTraceEntry[]; matches(...states: string[]): boolean; send(event: Ev): SendResult; subscribe(fn: (snapshot: MachineSnapshot) => void): () => void; [Symbol.dispose](): void; } ``` **Properties:** | Property | Type | Description | | ---------------- | ----------------------- | ---------------------------------------------------------------------------------- | | `state` | `Readable` | Current state — reactive; read inside `effect()` to subscribe | | `context` | `Readable` | Current context — reactive | | `disposed` | `boolean` | `true` after `dispose()` has been called | | `disposalSignal` | `AbortSignal` | Aborted when the machine is disposed. Use to tie external lifetimes to the machine | **Methods:** | Method | Returns | Description | | -------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `can(event)` | `boolean` | `true` if a valid transition exists for the event in the current state. Evaluates guards but fires no side effects or debug hooks. Returns `false` when disposed. | | `getSnapshot()` | `MachineSnapshot` | Deep-cloned, frozen snapshot of current state and context. The outer snapshot object is frozen — reassigning `snap.state` throws in strict mode. | | `getTrace()` | `TransitionTraceEntry[]` | Recent transitions in chronological order (oldest to newest). Returns cloned entries. Empty array when tracing is disabled. | | `matches(...states)` | `boolean` | `true` if the current state is one of the given values or a descendant of any (e.g. `matches('loading')` matches `'loading.pending'`). Returns `false` when disposed. | | `dispose()` | `void` | Aborts active invokes, clears after-timers, and disposes reactive signals. Idempotent. Does **not** clear persisted state. Equivalent to `using m = createMachine(config).start()`. | | `send(event)` | `SendResult` | Dispatches the event. Returns a `SendResult` with `.status`: `'transitioned'`, `'queued'`, or `'rejected'` (also when the machine is already disposed). | | `subscribe(fn)` | `() => void` | Subscribes to state/context changes. Returns an unsubscribe function. Fires only when state or context changes — **not** on the initial value. Use `getSnapshot()` to read the current state immediately. | | `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations. | --- ### `MachineDefinition` Handle returned by `createMachine()`. Holds the validated config and exposes two methods. ```ts interface MachineDefinition { resolve( input: { context: Readonly; event: Ev; state: State }, options?: { onGuard?: (info: { context: Readonly; event: Ev; from: State; passed: boolean; target: State }) => void; }, ): TransitionDef | undefined; start(options?: InterpretOptions): MachineInstance; } ``` | Method | Description | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `start()` | Creates a new running machine instance. Pass `options` to customise debug, snapshot, etc. | | `resolve()` | Pure — resolves which transition would be taken for a given state, context, and event. Runs guards but fires no side effects or debug hooks. Pass `options.onGuard` to observe each guard evaluation (pass or fail). | --- ### `MachineSnapshot` Frozen snapshot of state and context. ```ts type MachineSnapshot = { readonly context: Readonly; readonly state: State; }; ``` --- ### `PersistenceAdapter` Pluggable adapter for snapshot persistence. ```ts type PersistenceAdapter = { load: () => MachineSnapshot | undefined; save: (snapshot: MachineSnapshot) => void; }; ``` `save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided. --- ### `InvokeArgs` Arguments passed to the invoke `src` function. ```ts type InvokeArgs = { readonly context: Readonly; readonly entryEvent: Ev | LifecycleEvent; readonly signal: AbortSignal; }; ``` `signal` is aborted automatically when the state is exited. `context` is captured at invoke creation time. --- ### `InvokeDef` Configuration for an async task in a state. ```ts type InvokeDef = { id?: string; onDone?: (result: Result, context: Readonly) => Ev; onError?: (error: unknown, context: Readonly) => Ev; src: (args: InvokeArgs) => Promise; }; ``` | Field | Description | | --------- | ---------------------------------------------------------------------------------------------------- | | `id` | Optional label surfaced as `invokeId` in `DebugEvent`. Defaults to an auto-incremented string. | | `onDone` | Called with the resolved value and the **context at invoke start**. Returns the event to dispatch. | | `onError` | Called with the rejection reason and the **context at invoke start**. Returns the event to dispatch. | | `src` | The async task factory. Receives `InvokeArgs` — use `signal` for cooperative cancellation. | --- ### `TransitionTraceEntry` A single entry in the transition trace ring buffer. ```ts type TransitionTraceEntry = { readonly event: Ev | LifecycleEvent; readonly from: State; readonly timestamp: number; // Date.now() at commit time readonly to: State; }; ``` ## Schema Helpers These utilities bundle the three generics (`State`, `Ctx`, `Ev`) into a single opaque type so you can annotate actions, guards, and options without repeating the full signature everywhere. ### `MachineSchema` Opaque bundle that captures all three generics. Pass it to the `MachineType*` aliases below. ```ts type MachineSchema = { readonly _s: S; readonly _c: C; readonly _e: E; }; ``` ```ts type Auth = MachineSchema; ``` --- ### `MachineAction` Convenience alias for `ActionFn` parameterised by a schema. ```ts type MachineAction, E extends T['_e'] = T['_e']> = ActionFn; ``` ```ts type Auth = MachineSchema; const setUser: MachineAction = ({ context, event }) => { context.user = event.user; }; ``` --- ### `MachineGuard` Convenience alias for `GuardFn` parameterised by a schema. ```ts type MachineGuard, E extends T['_e'] = T['_e']> = GuardFn; ``` --- ### `MachineTypeConfig` Resolves to `MachineConfig` for a given schema. Use for typed config objects. ```ts type MachineTypeConfig> = MachineConfig; ``` --- ### `MachineTypeDefinition` Resolves to `MachineDefinition` for a given schema. ```ts type MachineTypeDefinition> = MachineDefinition; ``` --- ### `MachineTypeInstance` Resolves to `MachineInstance` for a given schema. ```ts type MachineTypeInstance> = MachineInstance; ``` --- ### `MachineTypeOptions` Resolves to `InterpretOptions` for a given schema. ```ts type MachineTypeOptions> = InterpretOptions; ``` --- ## Errors ### `ClockworkError` Base class for every error `clockwork` throws. Use `instanceof ClockworkError` (or the static `ClockworkError.is()`) to catch any clockwork-originated error, or `instanceof ` to handle one failure mode precisely. ```ts class ClockworkError extends Error { static is(err: unknown): err is ClockworkError; } ``` `ClockworkError.is()` is a type-safe static predicate — prefer it over `instanceof ClockworkError` in catch blocks that may receive unknown values: ```ts import { ClockworkError } from '@vielzeug/clockwork'; try { createMachine(config); } catch (err) { if (ClockworkError.is(err)) { console.error(err.name, err.message); } } ``` Each subclass carries typed fields describing the failure — narrow with `instanceof` to access them: | Subclass | Thrown when | Fields | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | `ClockworkInvalidAfterDelayError` | An `after` delay is negative, `NaN`, or non-finite (`Infinity`) | `path`, `delay` | | `ClockworkInvalidInitialStateError` | `initial` does not exist in `states` (top-level or compound) | `path`, `initial` | | `ClockworkInvalidMaxTransitionsError` | `maxTransitionsPerFlush` is less than 1 | `maxTransitionsPerFlush` | | `ClockworkInvalidSnapshotStateError` | Hydrated snapshot refers to an unknown state | `state` | | `ClockworkInvalidTransitionArrayError` | A transition array is empty, or an `invoke` array is empty | `path`, `eventType?` | | `ClockworkInvalidValidateContextError` | Context fails `validateContext` | `phase` (`'init' \| 'transition'`), `reason` | | `ClockworkMissingCompoundInitialError` | A compound state (has `states`) is missing `initial` | `path` | | `ClockworkTransitionLoopGuardError` | `maxTransitionsPerFlush` exceeded in one flush | `maxTransitionsPerFlush` | | `ClockworkUnknownTargetError` | A transition or `after` `target` does not exist in `states` — thrown for both unknown root states and unknown nested paths (e.g. `loading.nonexistent`) | `path`, `target`, `eventType?` | ```ts import { ClockworkError, ClockworkInvalidSnapshotStateError } from '@vielzeug/clockwork'; try { const m = createMachine(config).start({ snapshot: corruptedSnapshot }); } catch (err) { if (err instanceof ClockworkInvalidSnapshotStateError) { // discard snapshot and start fresh — err.state names the invalid state console.warn(`discarding snapshot: unknown state "${err.state}"`); } else if (ClockworkError.is(err)) { throw err; // some other clockwork validation failure } } ``` ## Signals and Reactivity `state` and `context` are `Reactive` values from `@vielzeug/ripple`. Use `effect()` to react to changes: ```ts import { effect } from '@vielzeug/ripple'; import { createMachine } from '@vielzeug/clockwork'; const m = createMachine(config).start(); effect(() => { document.title = `Status: ${m.state.value}`; }); m.send({ type: 'GO' }); // triggers the effect synchronously ``` Both signals update atomically inside a `batch()` — an effect reading both `state` and `context` will always see a consistent snapshot. For code that should not depend on `@vielzeug/ripple` directly, use `subscribe()`: ```ts const unsub = m.subscribe(({ state, context }) => { console.log(state, context); }); ``` ### Usage Guide ## Basic Usage ### Minimal Example ```ts import { createMachine } from '@vielzeug/clockwork'; type Event = { type: 'TOGGLE' }; const m = createMachine({ initial: 'on', states: { off: { on: { TOGGLE: { target: 'on' } } }, on: { on: { TOGGLE: { target: 'off' } } }, }, }).start(); console.log(m.send({ type: 'TOGGLE' }).status); // 'transitioned' — state changes to 'off' ``` ### With Context Context holds data that changes during transitions. Mutate it directly inside action functions: ```ts import { createMachine } from '@vielzeug/clockwork'; type Event = { type: 'DEC' } | { type: 'INC' }; const m = createMachine({ context: { count: 0 }, initial: 'idle', states: { idle: { on: { DEC: { actions: [ ({ context }) => { context.count -= 1; }, ], target: 'idle', }, INC: { actions: [ ({ context }) => { context.count += 1; }, ], target: 'idle', }, }, }, }, }).start(); ``` When your context type has properties (e.g. `{ count: number }`), `context` is a required field. For stateless machines, omit `context` entirely. ## Transition Syntax ### Shorthand — single transition When there is exactly one possible transition for an event, pass it directly as an object: ```ts states: { idle: { on: { GO: { target: 'active' }, }, }, } ``` ### Array — multiple guarded transitions When multiple transitions are possible (processed in order, first guard wins), use an array: ```ts states: { checkout: { on: { PAY: [ { guard: ({ context }) => context.balance >= context.total, actions: [({ context }) => { context.balance -= context.total; }], target: 'success', }, { // fallback when guard fails target: 'insufficient_funds', }, ], }, }, } ``` ## Guards Guards decide whether a transition occurs based on current context and the event payload. ```ts type Event = { type: 'SUBMIT'; value: number }; states: { form: { on: { SUBMIT: { guard: ({ event }) => event.value > 0, actions: [({ context, event }) => { context.input = event.value; }], target: 'processing', }, }, }, } ``` Guards must be pure functions. Side effects belong in `actions`, not `guard`. ## Actions Actions run during transitions to update context. Multiple actions execute in order. Actions receive a mutable context draft and the readonly event: ```ts import type { ActionFn } from '@vielzeug/clockwork'; const logEvent: ActionFn = ({ context, event }) => { context.lastEvent = event.type; context.updatedAt = Date.now(); }; // Use in transition: actions: [logEvent, ({ context }) => { context.processed = true; }], ``` For partial shallow-merge style updates, spread into the context: ```ts actions: [({ context, event }) => { Object.assign(context, { count: context.count + event.amount, updatedAt: Date.now() }); }], ``` ## Entry and Exit Actions `entry` and `exit` run when a state is entered or left. Both fire on self-transitions too. They receive `{ context, event }` where `event` may be a `LifecycleEvent` (`$init`, `$hydrate`, or `$after`): ```ts states: { active: { entry: ({ context }) => { context.startTime = Date.now(); }, exit: ({ context }) => { context.duration = Date.now() - context.startTime; }, on: { STOP: { target: 'idle' }, }, }, } ``` ## Async Invokes Invokes run a Promise when a state is entered and dispatch an event when it settles. ### Basic invoke `onDone` and `onError` receive `(result, context)` — context is a snapshot from when the invoke started, not the live value at resolution time. ```ts states: { loading: { invoke: [ { id: 'fetch-items', // optional; shown in debug events as invokeId src: async ({ signal }) => fetch('/api/data', { signal }).then(r => r.json()), onDone: (result, _ctx) => ({ type: 'DATA_READY', data: result }), onError: (error, _ctx) => ({ type: 'DATA_ERROR', message: String(error) }), }, ], on: { DATA_READY: { actions: [({ context, event }) => { context.data = event.data; }], target: 'idle' }, DATA_ERROR: { target: 'error' }, }, }, } ``` ### Cancellation with `AbortSignal` Invokes are automatically aborted when the state is exited. Pass `signal` to fetch or any `AbortSignal`-aware API: ```ts src: async ({ context, signal }) => { const response = await fetch(`/api/${context.userId}/items`, { signal }); return response.json(); }, ``` ### Multiple invokes A state can run multiple invokes in parallel. Each invoke can have an optional `id` string that appears as `invokeId` in debug events: ```ts invoke: [ { id: 'user', src: async () => fetchUser(), onDone: (user, _ctx) => ({ type: 'USER_LOADED', user }) }, { id: 'perms', src: async () => fetchPermissions(), onDone: (perms, _ctx) => ({ type: 'PERMS_LOADED', perms }) }, ], ``` ## Delayed Transitions (After) Schedule automatic transitions after a delay. After-timers are cancelled when the state is exited. ### Basic delay ```ts states: { notification: { after: [{ delay: 5000, target: 'dismissed' }], }, dismissed: {}, } ``` ### With guard and actions ```ts states: { idle: { after: [ { delay: 3000, guard: ({ context }) => context.autoClose, actions: [({ context }) => { context.closedAt = Date.now(); }], target: 'closed', }, ], }, } ``` ### After + Invoke interaction When a state has both `after` and `invoke`, the after-timer is cleared if the state is exited via an invoke result: ```ts states: { loading: { after: [{ delay: 10000, target: 'timeout' }], invoke: [{ src: async ({ signal }) => fetch('/api', { signal }).then(r => r.json()), onDone: (data) => ({ type: 'DONE', data }), }], on: { DONE: { target: 'success' } }, }, timeout: {}, success: {}, } ``` ## Hierarchical States Compound states contain nested substates. A compound state must have an `initial` property pointing to one of its children. ### Basic hierarchy ```ts import { createMachine } from '@vielzeug/clockwork'; const m = createMachine({ context: { mode: '' }, initial: 'active', states: { idle: {}, active: { initial: 'editing', states: { editing: { on: { SAVE: { target: 'active.saving' } } }, saving: { on: { DONE: { target: 'idle' } } }, }, }, }, }).start(); // Entering 'active' automatically resolves to 'active.editing' console.log(m.state.value); // 'active.editing' ``` ### Leaf resolution When targeting a compound state, the machine automatically descends to the deepest `initial` leaf: ```ts // target: 'active' resolves to 'active.editing' on: { RESTART: { target: 'active'; } } ``` ### `matches()` with hierarchy `matches()` returns `true` for ancestor states too: ```ts m.matches('active'); // true when in 'active.editing' or 'active.saving' m.matches('active.editing'); // true only when in 'active.editing' ``` ## Context Validation Validate context at initialization and on every transition: ```ts import { createMachine } from '@vielzeug/clockwork'; const m = createMachine({ context: { count: 0, name: 'app' }, initial: 'idle', validateContext: (ctx) => { if (typeof ctx.count !== 'number') return 'count must be a number'; if (typeof ctx.name !== 'string') return 'name must be a string'; return true; }, states: { idle: {} }, }).start(); ``` When validation fails, a `ClockworkInvalidValidateContextError` is thrown — its `.reason` field carries the string returned by `validateContext`. The machine state and context are **unchanged** — the transition is rolled back before any signals are updated. ## Persistence Save and restore machine state across sessions using a persistence adapter. ### Local Storage ```ts import { createMachine, type MachineSnapshot } from '@vielzeug/clockwork'; const m = createMachine(config).start({ persistence: { load: () => { const raw = localStorage.getItem('clockwork:state'); return raw ? (JSON.parse(raw) as MachineSnapshot) : undefined; }, save: (snapshot) => { localStorage.setItem('clockwork:state', JSON.stringify(snapshot)); }, }, }); ``` ### Hydration behavior On startup, `createMachine().start()` checks `options.snapshot` first, then `persistence.load()`. If a persisted snapshot exists, the machine hydrates from it — entry hooks run, invokes start, and after-timers schedule. `m[Symbol.dispose]()` does **not** clear persisted state. The machine may be recreated (e.g. after HMR or component remount) and should resume from the last saved state. To reset persistence, call your adapter's storage API directly. If context is loaded from untrusted sources (e.g. `localStorage`), run your `validateContext` guard before interpreting, or wrap `persistence.load()` with a try/catch and schema check. ## Interceptors Interceptors are pure functions that run before event processing. Return the event (optionally transformed) to allow it, or `null` to block it. They run left-to-right — the first `null` stops the chain: ```ts import { createMachine, type InterceptorFn } from '@vielzeug/clockwork'; const logger: InterceptorFn = (event, snapshot) => { console.log(`[${snapshot.state}] ${event.type}`); return event; // pass through }; const blocker: InterceptorFn = (event, _snapshot) => { if (event.type === 'BLOCKED') return null; // swallow event return event; }; const m = createMachine(config).start({ interceptors: [logger, blocker] }); ``` Interceptors can also transform events — return a modified event object to change its type or payload before it reaches the machine. ## send() and SendResult `send()` returns a `SendResult` object. Check `.status` for the outcome: ```ts const result = m.send({ type: 'GO' }); // result.status === 'transitioned' — a transition occurred // result.status === 'queued' — called re-entrantly from inside an action // result.status === 'rejected' — no match, guard failed, interceptor blocked, or disposed ``` Use this for conditional feedback or analytics: ```ts if (m.send({ type: 'SUBMIT' }).status === 'rejected') { showError('Action not allowed in current state'); } ``` ## Checking State ### `matches()` — check multiple states at once ```ts m.matches('idle'); // true if current state is 'idle' m.matches('loading', 'error'); // true if in either state (or a child of either) ``` ### `can()` — check if an event would be accepted ```ts m.can({ type: 'SUBMIT' }); // true if a valid transition exists for SUBMIT in the current state ``` `can()` evaluates guards against the current context but does **not** fire any debug hooks. It is a pure read — use it freely for UI conditional rendering. ## Subscribe Subscribe to state/context changes without using `@vielzeug/ripple` directly: ```ts const unsub = m.subscribe(({ state, context }) => { renderUI(state, context); }); m.send({ type: 'INC' }); // subscriber fires unsub(); // stop listening ``` The callback fires only when `state` or `context` reference changes — not on every signal read. ## Debugging and Tracing For quick console-based debugging, use `debugMachine` from the dedicated sub-path. It pre-wires `onDebug` to `console.debug`/`console.group` and is tree-shaken from production bundles. `debugMachine` writes event payloads — including full event objects and context — to the console. Do not use it in production if events or context contain PII. ```ts import { debugMachine } from '@vielzeug/clockwork/devtools'; const m = debugMachine(config); m.send({ type: 'START' }); // [clockwork:transition] START: idle → active // [clockwork:guard] START: idle → active — passed ``` For custom handling, pass `onDebug` directly to `createMachine().start()`. ### Debug events The `onDebug` callback receives a discriminated union of debug events: ```ts const m = createMachine(config).start({ onDebug: (event) => { switch (event.type) { case 'guard': console.log(`Guard: ${event.from} → ${event.target} = ${event.passed}`); break; case 'transition': console.log(`${event.event.type}: ${event.from} → ${event.to}`); break; case 'transition-skipped': console.log(`${event.event.type} in ${event.from}: no matching transition`); break; case 'invoke-start': console.log(`invoke #${event.invokeId} started in ${event.state}`); break; case 'invoke-done': console.log(`invoke #${event.invokeId} done:`, event.result); break; case 'invoke-error': console.error(`invoke #${event.invokeId} failed:`, event.error); break; case 'invoke-abort': console.log(`invoke #${event.invokeId} aborted`); break; } }, }); ``` ### Transition trace buffer When `onDebug` is set, a 50-entry trace buffer is enabled automatically. Set `traceLimit` to control size (`0` disables): ```ts const m = createMachine(config).start({ onDebug: () => {}, traceLimit: 200 }); m.send({ type: 'GO' }); m.send({ type: 'BACK' }); console.log(m.getTrace()); // [ // { from: 'idle', to: 'active', event: { type: 'GO' }, timestamp: 1234567890 }, // { from: 'active', to: 'idle', event: { type: 'BACK' }, timestamp: 1234567891 }, // ] ``` When the buffer is full, the oldest entry is overwritten. The array returned by `getTrace()` is always in chronological order. Each call returns fresh cloned entries — mutating them does not affect the internal buffer. ## Testing ### Pure transition resolution `.resolve()` is a pure method on `MachineDefinition` — it resolves which `TransitionDef` would apply without running any actions, entry/exit handlers, or invokes: ```ts import { createMachine } from '@vielzeug/clockwork'; const config = { /* machine config */ }; const def = createMachine(config); const transition = def.resolve({ context: { authorized: false }, event: { type: 'LOGIN' }, state: 'idle', }); expect(transition).toBeUndefined(); // guard failed const passing = def.resolve({ context: { authorized: true }, event: { type: 'LOGIN' }, state: 'idle', }); expect(passing?.target).toBe('dashboard'); ``` ### Snapshot testing ```ts const m = createMachine(config).start(); const before = m.getSnapshot(); m.send({ type: 'UPDATE', value: 10 }); const after = m.getSnapshot(); expect(before.context.value).toBe(0); expect(after.context.value).toBe(10); ``` ## Disposal Always dispose machines to clean up signals, abort in-flight invokes, and clear after-timers. ```ts const m = createMachine(config).start(); m.dispose(); // aborts invokes, clears timers, disposes reactive signals // With the explicit resource management proposal (ES2024+): { using m = createMachine(config).start(); m.send({ type: 'GO' }); } // dispose() called automatically via [Symbol.dispose] ``` Disposal does **not** clear persisted state. The machine may resume from the last snapshot on recreation. ## Common Patterns ### Traffic Light ```ts import { createMachine } from '@vielzeug/clockwork'; type Event = { type: 'EMERGENCY' } | { type: 'NEXT' }; const trafficLight = createMachine({ initial: 'red', states: { green: { on: { EMERGENCY: { target: 'red' }, NEXT: { target: 'yellow' } } }, red: { on: { EMERGENCY: { target: 'red' }, NEXT: { target: 'green' } } }, yellow: { on: { EMERGENCY: { target: 'red' }, NEXT: { target: 'red' } } }, }, }).start(); ``` ### Auto-dismiss notification ```ts import { createMachine } from '@vielzeug/clockwork'; type Event = { type: 'DISMISS' } | { type: 'SHOW'; message: string }; const notification = createMachine({ context: { message: '' }, initial: 'hidden', states: { hidden: { on: { SHOW: { actions: [ ({ context, event }) => { context.message = event.message; }, ], target: 'visible', }, }, }, visible: { after: [{ delay: 5000, target: 'hidden' }], on: { DISMISS: { target: 'hidden' } }, }, }, }).start(); ``` ### Auth flow with async login ```ts import { createMachine } from '@vielzeug/clockwork'; type Context = { attempts: number; user?: { id: string; token: string } }; type Event = | { email: string; password: string; type: 'SUBMIT' } | { type: 'LOGOUT' } | { type: 'AUTH_SUCCESS'; user: { id: string; token: string } } | { type: 'AUTH_FAILED' }; const auth = createMachine({ context: { attempts: 0 }, initial: 'unauthenticated', states: { authenticated: { on: { LOGOUT: { actions: [ ({ context }) => { context.user = undefined; }, ], target: 'unauthenticated', }, }, }, error: {}, loading: { invoke: [ { src: async ({ entryEvent, signal }) => { if (entryEvent.type !== 'SUBMIT') throw new Error('unexpected'); return fetch('/auth/login', { body: JSON.stringify({ email: entryEvent.email, password: entryEvent.password }), method: 'POST', signal, }).then((r) => r.json()); }, onDone: (user, _ctx) => ({ type: 'AUTH_SUCCESS', user: user as { id: string; token: string } }), onError: (_err, _ctx) => ({ type: 'AUTH_FAILED' }), }, ], on: { AUTH_FAILED: { target: 'unauthenticated' }, AUTH_SUCCESS: { actions: [ ({ context, event }) => { context.user = event.user; }, ], target: 'authenticated', }, }, }, unauthenticated: { on: { SUBMIT: { actions: [ ({ context }) => { context.attempts += 1; }, ], guard: ({ context }) => context.attempts | null>(null); const [state, setState] = useState('red'); useEffect(() => { const m = createMachine(trafficConfig).start(); machineRef.current = m; const unsub = m.subscribe(({ state }) => setState(state)); return () => { unsub(); m.dispose(); }; }, []); return ( Current: {state} machineRef.current?.send({ type: 'NEXT' })}>Next ); } ``` ```ts [Vue 3] import { onMounted, onUnmounted, ref } from 'vue'; import { createMachine, type MachineInstance } from '@vielzeug/clockwork'; import { trafficConfig } from './machine'; const state = ref('red'); let m: MachineInstance, { type: string }>; let unsub: (() => void) | undefined; onMounted(() => { m = createMachine(trafficConfig).start(); unsub = m.subscribe(({ state: s }) => { state.value = s; }); }); onUnmounted(() => { unsub?.(); m?.dispose(); }); Current: {{ state }} Next ``` ::: ## Server-Side Rendering Clockwork's `state` and `context` are `@vielzeug/ripple` signals, so every machine shares ripple's module-level flush queue by default. That's fine for a single long-running Node process with one machine tree, but if you create machines **per incoming request** (e.g. an SSR handler that runs `createMachine(config).start()` on every request), concurrent requests can share scheduling state and interleave `batch()` flushes. Install `@vielzeug/ripple`'s SSR tracking provider once at server bootstrap to give each request its own isolated scheduling context — clockwork needs no special import or configuration, it automatically picks up whatever provider ripple has installed: ```ts // server bootstrap (once, at startup) import { createAsyncProvider, setTrackingProvider } from '@vielzeug/ripple/ssr'; const provider = createAsyncProvider(); setTrackingProvider(provider); ``` ```ts // inside each request handler import { runWithProvider } from '@vielzeug/ripple/ssr'; import { createMachine } from '@vielzeug/clockwork'; import { trafficConfig } from './machine'; async function handleRequest(req: Request): Promise { return runWithProvider(provider, async () => { const m = createMachine(trafficConfig).start(); // ...drive the machine and render... m.dispose(); return new Response(/* ... */); }); } ``` Single-page apps, static builds, and Node scripts that never run concurrent request handlers don't need this — the default (no provider installed) is correct there. See the `@vielzeug/ripple/ssr` entry in `@vielzeug/ripple`'s [API reference](/ripple/api#package-entry-point) for the full provider API. ## Working with Other Vielzeug Libraries ### With `@vielzeug/ripple` `state` and `context` are `Reactive` values from `@vielzeug/ripple`. Use `effect()` to drive reactive UI from Clockwork state: ```ts import { effect } from '@vielzeug/ripple'; import { createMachine } from '@vielzeug/clockwork'; const m = createMachine(playerConfig).start(); effect(() => { document.getElementById('play-btn')!.textContent = m.state.value === 'playing' ? 'Pause' : 'Play'; }); m.send({ type: 'PLAY' }); // effect runs immediately ``` ### With `@vielzeug/herald` Bridge Clockwork transitions to a shared event bus for cross-machine coordination: ```ts import { createBus } from '@vielzeug/herald'; import { createMachine } from '@vielzeug/clockwork'; const bus = createBus(); const m = createMachine(authConfig).start({ onDebug: ({ type, ...ev }) => { if (type === 'transition' && ev.to === 'anonymous') bus.emit('auth:logout', undefined); }, }); ``` ## Best Practices - **Use discriminated event unions.** TypeScript infers payload types per transition from the event type string. - **Keep guards pure.** Guards must not produce side effects. All mutation belongs in `actions`. - **Mutate context directly in actions.** Actions receive a cloned draft — mutate it in place. - **Prefer shorthand transition syntax** (`on: { GO: { target: 'active' } }`) for single transitions. Use arrays only when you need multiple guarded alternatives. - **Dispose machines when done.** Always call `m.dispose()` (or `using m = createMachine(...).start()`) to prevent memory leaks and abort dangling invokes. - **Test with `.resolve()`.** Unit-test guard logic in isolation without spinning up a full machine instance. - **Keep machines focused.** A machine with more than 10–15 states is usually a sign it should be split. - **Use after for timeouts.** Prefer `after` over manual setTimeout in entry hooks — timers are automatically cleaned up on state exit. ### Examples ## Examples - [Counter with Reset](./examples/counter-with-reset.md) - [Data Fetching with Error Recovery](./examples/data-fetching.md) - [Fetch with Retry](./examples/fetch-retry.md) - [Auth Flow with Guards](./examples/auth-flow.md) - [Auto-Dismiss Notification](./examples/auto-dismiss-notification.md) - [Persisted Wizard](./examples/persisted-wizard.md) - [Multi-Step Wizard with Routing](./examples/wizard-with-routing.md) - [Checkout Flow](./examples/checkout.md) - [Form Validation](./examples/form-validation.md) - [Hierarchical States](./examples/hierarchical-states.md) - [Media Player](./examples/media-player.md) - [Interceptor Pipeline](./examples/middleware-pipeline.md) - [Debugging Transitions](./examples/debugging-transitions.md) - [Unit Testing with `.resolve()`](./examples/unit-testing.md) - [Permission-Based Access Control](./examples/permission-based-access.md) - [Paginated Data Loading with Search](./examples/paginated-data-loading.md) - [Multi-Machine Coordination](./examples/multi-machine-coordination.md) ### REPL Examples - Delayed Transitions (after) (id: `after-transitions`) - Async Invokes (id: `async-invokes`) - Basic State Machine (id: `basic-machine`) - Context Validation (id: `context-validation`) - Debug Hooks & Tracing (id: `debug-tracing`) - Entry & Exit Actions (id: `entry-exit-actions`) - Guards & Actions (id: `guards-and-actions`) - Hierarchical States (id: `hierarchical-states`) - Interceptors (id: `interceptors`) - Persistence & Snapshots (id: `persistence`) - resolve() + ClockworkError (id: `resolve-and-error`) --- ## @vielzeug/codex **Category:** ai-tooling **Keywords:** mcp, model-context-protocol, ai-agent, claude, copilot, stdio, http, docs **Key exports:** createServer, createServerFromDisk, createRequestHandler, startHttpServer, loadData, packageMeta, validateBundledData, CodexError, ToolError, ToolErrorCode, SCHEMA_VERSION, SearchHit (+14 more) **Related:** refine, spell ### Overview ## Why Codex? AI agents working with Vielzeug need reliable, structured access to package docs and metadata. Fetching the live site is fragile, network-dependent, and returns raw HTML that must be parsed and cleaned per-agent. ``` // Before — each agent fetches and parses docs independently fetch('https://vielzeug.dev/spell/api') → parse HTML → strip nav, ads, markup → hope it matches // After — one MCP server, structured tool calls, snapshot-backed { "name": "get-docs", "arguments": { "packageSlug": "spell", "page": "api" } } ``` | Feature | `@vielzeug/codex` | Live docs fetch | Generic web search | | ------------------------- | ------------------------------------------------ | ------------------------------------------ | ------------------------------------------ | | Structured metadata | | | | | Works offline | | | | | Refine component CEM | | | | | Ranked search | | | | | Stdio + HTTP transports | | | | | External runtime dep | (MCP SDK) | | | **Use `@vielzeug/codex` when** you are wiring an AI client (Claude Desktop, Copilot Chat, or a custom agent) to Vielzeug documentation and need reliable, offline-capable, structured access. **Consider live docs or a web search when** you need content added to the site after the current snapshot was published. ## Installation ```sh [pnpm] pnpm add @vielzeug/codex ``` ```sh [npm] npm install @vielzeug/codex ``` ```sh [yarn] yarn add @vielzeug/codex ``` ## Quick Start Run over stdio — the default transport, compatible with Claude Desktop and Copilot Chat: ```sh npx -y @vielzeug/codex ``` Run over HTTP for remote agents or multi-client setups: ```sh npx -y @vielzeug/codex --port 3100 ``` Wire it into your AI client — see the [Usage Guide](./usage.md) for client-specific config. ## Features - Thirteen MCP tools: `list-packages`, `get-package`, `get-docs`, `get-source`, `list-examples`, `get-example`, `search-packages`, `get-type-signature`, plus five `refine-*` tools for Refine component metadata - `search-packages` ranks hits by field weight: name (3.9) > category (3.5) > description (3.1) > keywords (2.5) > exports (2.2) > related (2.0) > docs (1.0) > examples (0.95) > source (0.9) - Multi-word AND search — all terms must match within the same field; hyphenated names normalised automatically - Bundled snapshot data — runs without a network connection or local Vielzeug checkout - Stdio transport (default) for local clients; Streamable HTTP with `--port ` for remote agents; legacy SSE at `GET /sse` for older clients - Health endpoint at `GET /health` in HTTP mode, reporting the bundled snapshot's `version` - `--data ` loads a custom snapshot instead of the built-in bundled one - Fail-fast startup: missing or malformed data bundle aborts with an actionable error and regen hint - Programmatic API: `createServer`, `createServerFromDisk`, `startHttpServer`, `loadData`, `validateBundledData` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Refine](/refine/) — source of the bundled Refine component CEM metadata exposed via `refine-list-components` and `refine-get-component` - [Spell](/spell/) — example of a well-documented package discoverable via `search-packages` and `get-docs` ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ---------------------------- | ----------------------------------------------- | -------------- | ----------------------------------------------------------------- | | `list-packages` | All packages (no filter) | Sync | Use `get-package` to fetch a single package by slug | | `get-package` | Single package metadata by slug | Sync | `isError: true` when slug is unknown | | `get-docs` | Package documentation page | Sync | `page` enum excludes `source` — use `get-source` | | `get-source` | `src/index.ts` text | Sync | `isError: true` when package has no bundled source | | `list-examples` | REPL example ids for a package | Sync | Returns `[]`, never an error, for packages with no examples | | `get-example` | Full runnable code for one REPL example | Sync | `isError: true` when `exampleId` is unknown for that package | | `search-packages` | Ranked search across metadata, docs, examples | Sync | Returns `[]`, never an error, when nothing matches | | `get-type-signature` | TypeScript export declaration from source | Sync | `isError: true` when symbol not found or no bundled source | | `refine-list-components` | Refine component tag list | Sync | `isError: true` if Refine CEM not in snapshot | | `refine-get-component` | Single Refine CEM declaration | Sync | `isError: true` lists available tags on miss | | `refine-generate-template` | Scaffolded HTML snippet for a Refine component | Sync | Required attrs filled; optional attrs in comment block | | `refine-get-tokens` | All Refine CSS custom properties | Sync | Optional `filter` prefix; returns `[]` when nothing matches | | `refine-validate-usage` | Validate AI-generated HTML against CEM spec | Sync | Returns `[]` when valid; `isError: true` on bad input | | `createServer()` | Programmatic server factory | Sync | Requires pre-loaded `BundledData` — call `loadData()` first | | `createServerFromDisk()` | One-call convenience factory | Sync | Calls `loadData()` internally — throws the same errors | | `startHttpServer()` | Start HTTP server (Streamable + SSE) | Async | Used by CLI; import for embedding in a larger process | | `createRequestHandler()` | Build the HTTP handler without binding a port | Sync | Exposed for integration testing; prefer `startHttpServer()` in prod | | `loadData()` | Load and validate bundled snapshot | Sync | Throws with an actionable message on missing or malformed data | | `packageMeta()` | Strip heavy fields from a `BundledPackage` | Sync | Returns `PackageMeta` — no `docs`, `apiSource`, `examples`, or `typeSignatures` | | `validateBundledData()` | Validate raw JSON against `BundledData` shape | Sync | Use when loading data from a custom path | | `SCHEMA_VERSION` | Current snapshot schema version constant | — | Used by `validateBundledData` to reject stale snapshots | ## Package Entry Point | Import | Purpose | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@vielzeug/codex` | `createServer`, `createServerFromDisk`, `createRequestHandler`, `startHttpServer`, `loadData`, `packageMeta`, `validateBundledData`, `CodexError`, `ToolError`, all CEM types | The CLI binary (`codex`) is the primary runtime interface; direct imports are for custom server wiring or embedding. ## Tools ### `list-packages` Returns an array of `PackageMeta` objects for all packages. Takes no input. **Input:** none **Result shape:** ```json [ { "name": "@vielzeug/ripple", "slug": "ripple", "version": "3.0.1", "description": "Reactive signals, computed, effects, stores", "category": "state", "keywords": ["signals", "reactive"], "exports": ["signal", "computed", "effect"], "related": ["ore", "forge"], "availableDocPages": ["index", "api", "usage", "examples"], "exampleIds": ["signal-basics", "computed-chain"], "hasSource": true } ] ``` --- ### `get-package` Returns a single `PackageMeta` object by slug. **Input:** | Field | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------- | | `packageSlug` | `string` | Yes | Package folder name, e.g. `"ripple"`. | **Result shape:** same as a single entry from `list-packages` — a `PackageMeta` object (not an array). **Error cases:** unknown slug or missing/empty `packageSlug` → `isError: true` with available slugs listed. --- ### `get-docs` Returns the Markdown content of a documentation page. **Input:** | Field | Type | Required | Description | | ------------- | ------------------------------------------- | -------- | ----------------------------------- | | `packageSlug` | `string` | Yes | Package folder name, e.g. `"spell"` | | `page` | `'index' \| 'api' \| 'usage' \| 'examples'` | No | Defaults to `'index'` | **Notes:** - Returns raw Markdown text. Use `get-source` for `src/index.ts`. - When a page is unavailable, the error message lists which pages the package has. **Error cases:** unknown slug or unavailable page → `isError: true`. --- ### `get-source` Returns the bundled `src/index.ts` source text. **Input:** | Field | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------ | | `packageSlug` | `string` | Yes | Package folder name, e.g. `"ripple"` | **Error cases:** unknown slug or no bundled source → `isError: true`. --- ### `list-examples` Returns the REPL example ids and display names for a package — the same runnable examples users can pick from interactively at [vielzeug.dev/repl](/repl). Does not include the runnable code; use `get-example` for that. **Input:** | Field | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------ | | `packageSlug` | `string` | Yes | Package folder name, e.g. `"ripple"` | **Result shape:** ```json [ { "id": "signal-basics", "name": "Signal basics" }, { "id": "computed-chain", "name": "Chaining computed values" } ] ``` Returns `[]` — not an error — for packages with no REPL examples (e.g. DOM-output packages like `refine`, `prism`, `ore`, which have no browser-executable preview container). **Error cases:** unknown `packageSlug` → `isError: true`. --- ### `get-example` Returns the full runnable source code for one REPL example. **Input:** | Field | Type | Required | Description | | ------------- | -------- | -------- | --------------------------------------------- | | `packageSlug` | `string` | Yes | Package folder name, e.g. `"ripple"` | | `exampleId` | `string` | Yes | Example id from `list-examples`, e.g. `"signal-basics"` | **Result:** the example's runnable code as plain text (JavaScript, copy-paste ready). **Error cases:** unknown `packageSlug`, or `exampleId` not found for that package (lists available ids) → `isError: true`. --- ### `search-packages` Searches metadata, keywords, documentation, REPL examples, and source. Returns ranked `SearchHit` objects. **Input:** | Field | Type | Required | Description | | ------- | -------- | -------- | ---------------------- | | `query` | `string` | Yes | Non-empty search term (max 500 chars) | **Ranking:** | Weight | Category | Field(s) searched | | ------ | -------------- | --------------------------------------------------------- | | 3.9 | `"metadata"` | `name` (exact package name) | | 3.5 | `"metadata"` | `category` | | 3.1 | `"metadata"` | `description` | | 2.5 | `"keywords"` | `keywords` array | | 2.2 | `"exports"` | `exports` array (exported symbol names) | | 2.0 | `"related"` | `related` array (sibling package slugs) | | 1.0 | `"docs"` | All doc pages (`matchedPages` lists which pages matched) | | 0.95 | `"examples"` | REPL example names and code (`matchedExamples` lists which ids matched) | | 0.9 | `"source"` | Bundled `src/index.ts` text | `score` is a floating-point number — the highest weight across all matched fields. Results sorted by `score` descending, then `slug` ascending as tiebreaker. Multiple categories can match simultaneously. **Multi-word queries:** all words must appear within the same field's normalised haystack for that category to score. For `keywords`, `exports`, and `related`, the individual array entries are joined into a single string before matching — so `"reactive signal"` matches a package with `keywords: ["reactive", "signal"]`. For `name`, `category`, and `description`, all terms must appear in the single field value. Search terms are normalised — lowercase, hyphens replaced with spaces — before matching. `"my-pkg"` matches a package named `"@vielzeug/my-pkg"`. **Result shape:** ```json [ { "name": "@vielzeug/spell", "slug": "spell", "score": 3.9, "matchedIn": ["metadata", "keywords"] }, { "name": "@vielzeug/forge", "slug": "forge", "score": 1.0, "matchedIn": ["docs"], "matchedPages": ["usage", "examples"] }, { "name": "@vielzeug/arsenal", "slug": "arsenal", "score": 0.95, "matchedIn": ["examples"], "matchedExamples": ["function-debounce"] } ] ``` Returns `[]` when nothing matches — not an error. --- ### `get-type-signature` Extracts the TypeScript export declaration(s) for a named symbol from a package's bundled `src/index.ts` (and everything it re-exports via `export * from './x'`). Works for functions, constants, type aliases, interfaces, and re-exports. **Input:** | Field | Type | Required | Description | | -------- | -------- | -------- | -------------------------------------------- | | `slug` | `string` | Yes | Package slug, e.g. `"arsenal"` | | `symbol` | `string` | Yes | Exported name to look up, e.g. `"debounce"` | **Output:** raw declaration text — the exact text as it appears in the source file. Multiple declarations for the same name (e.g. function overloads) are joined with a blank line. **Error cases:** unknown `slug`, package has no bundled source, or `symbol` not found (including inherited `Object.prototype` member names like `"constructor"` or `"toString"`, which are never real bundled symbols) → `isError: true`. --- ### `refine-list-components` Lists all `@vielzeug/refine` web component tags from bundled Custom Elements Manifest (CEM) metadata. **Input:** none **Result shape:** ```json [ { "tagName": "ore-button", "description": "A clickable button element.", "attrs": [ { "name": "variant", "type": "string", "default": "primary" }, { "name": "disabled", "type": "boolean" } ] } ] ``` Each entry includes: | Field | Type | Description | | ------------- | ---------------------------------- | ---------------------------------------------------- | | `tagName` | `string` | HTML custom element tag name, e.g. `"ore-button"` | | `description` | `string` | Component description from the CEM (may be empty) | | `attrs` | `Array` | Attribute list; `default` omitted when not defined | **Error cases:** Refine CEM not present in this snapshot → `isError: true`. --- ### `refine-get-component` Returns one full CEM declaration for a Refine component. **Input:** | Field | Type | Required | Description | | --------- | -------- | -------- | -------------------------------------------- | | `tagName` | `string` | Yes | HTML custom element tag, e.g. `"ore-button"` | **Result:** Full CEM declaration including attributes, members, events, slots, CSS parts, and CSS properties. **Error cases:** unknown tag (lists available tags) or missing Refine CEM → `isError: true`. --- ### `refine-generate-template` Generates a ready-to-use HTML snippet for a Refine component, derived entirely from bundled CEM metadata. Use this as the AI's starting point for declarative UI generation — it eliminates hallucinated attribute names before they reach the DOM. **Input:** | Field | Type | Required | Description | | ---------- | -------- | -------- | -------------------------------------------------------------------------------------- | | `tagName` | `string` | Yes | HTML custom element tag, e.g. `"ore-button"` | | `scenario` | `string` | No | Usage context prepended as an HTML comment, e.g. `"primary call-to-action button"` | **Result:** An HTML string with: - Required attributes filled with type-appropriate placeholders (first literal for union types, bare name for booleans, `""` for plain strings) - Optional attributes (those with defaults) in a `` comment block - Named slots scaffolded as `…` children - Event names listed in a `` comment **Example output:** ```html Content goes here ``` **Error cases:** unknown `tagName` (lists available tags) or missing Refine CEM → `isError: true`. --- ### `refine-get-tokens` Returns all CSS custom properties (design tokens) exposed by Refine components. Use when generating dynamic themes or inline styles in AI-driven UI — avoids guessing variable names. **Input:** | Field | Type | Required | Description | | -------- | -------- | -------- | ----------------------------------------------------------------- | | `filter` | `string` | No | Case-insensitive prefix to narrow results, e.g. `"--ore-color"` | **Result shape:** ```json [ { "name": "--ore-card-bg", "description": "Card background colour", "default": "#1e1e2e", "component": "ore-card" }, { "name": "--ore-card-radius", "component": "ore-card" } ] ``` Results are sorted by `name` ascending. `description` and `default` are omitted when absent. Tokens that appear on multiple components are deduplicated by name — components are iterated in stable bundled order, so the first occurrence is returned deterministically (shared design tokens are typically documented identically everywhere they appear). Returns `[]` (not an error) when `filter` matches nothing. **Error cases:** missing Refine CEM → `isError: true`. --- ### `refine-validate-usage` Validates AI-generated HTML against a Refine component's CEM spec. Use this to close the **generate → validate → fix** loop before passing HTML to the renderer. **Input:** | Field | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------------------- | | `tagName` | `string` | Yes | HTML custom element tag to validate against | | `html` | `string` | Yes | HTML fragment to validate (max 5000 characters) | **Result shape:** A JSON array of issue objects. An empty array means the usage is valid. ```json [ { "type": "error", "message": "Unknown attribute \"colour\" on . Known: variant, disabled." }, { "type": "error", "message": "Unknown slot \"icon\" on . Known slots: prefix." } ] ``` **Checks performed:** - Unknown attributes (after excluding `class`, `id`, `style`, `aria-*`, `data-*`, `on*`, `tabindex`, `part`, `slot`, and other safe globals) - Unknown slot names (only when the component defines at least one named slot) **Error cases:** unknown `tagName`, missing `` opening tag in HTML, `html` exceeding 5000 characters, or missing Refine CEM → `isError: true`. ## Programmatic API ### `createServerFromDisk()` ```ts createServerFromDisk(): Server; ``` Convenience factory that calls `loadData()` internally and passes the result to `createServer()`. Use this for the common single-expression wiring pattern. **Returns:** `Server` from `@modelcontextprotocol/sdk` **Throws:** same errors as `loadData()`. **Example:** ```ts import { createServerFromDisk } from '@vielzeug/codex'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; await createServerFromDisk().connect(new StdioServerTransport()); ``` --- ### `createServer()` ```ts createServer(data: BundledData): Server; ``` Creates and returns an MCP `Server` instance with all tools registered. **Parameters:** | Parameter | Type | Description | | --------- | ------------- | --------------------------------------------------------------- | | `data` | `BundledData` | Validated bundled snapshot — call `loadData()` to obtain it | **Returns:** `Server` from `@modelcontextprotocol/sdk` **Example:** ```ts import { createServer, loadData } from '@vielzeug/codex'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const server = createServer(loadData()); await server.connect(new StdioServerTransport()); ``` --- ### `loadData()` ```ts loadData(dataFile?: string): BundledData; ``` Reads and validates a bundled snapshot from disk. Throws synchronously with an actionable error message if the file is missing, malformed, or fails schema validation. **Parameters:** | Parameter | Type | Description | | ---------- | -------- | ------------------------------------------------------------------------------------ | | `dataFile` | `string` | Optional path to a custom snapshot file. Defaults to the bundled `data/vielzeug-data.json`. | **Returns:** `BundledData` **Throws:** `CodexError` — with a `pnpm run prepare:data` regen hint when the data file is absent or malformed. Delegates to `validateBundledData()` for schema validation. --- ### `packageMeta()` ```ts packageMeta(pkg: BundledPackage): PackageMeta; ``` Strips `apiSource`, `docs`, `examples`, and `typeSignatures` from a `BundledPackage`, adds `hasSource`, and reduces `examples` to `exampleIds`. Use this to produce lightweight metadata for tool responses. **Parameters:** | Parameter | Type | Description | | --------- | ---------------- | -------------------------------------------------- | | `pkg` | `BundledPackage` | Full package record from `BundledData.packages` | **Returns:** `PackageMeta` --- ### `startHttpServer()` ```ts startHttpServer( mcpServer: Server, port: number, createSseServer: () => Server, version?: string, ): Promise; ``` Starts an HTTP server that exposes both the Streamable HTTP transport (spec-compliant clients) and the legacy SSE transport (older clients). The CLI calls this when `--port` is provided; import it when embedding codex into a larger Node.js HTTP process. **Parameters:** | Parameter | Type | Description | | ----------------- | -------------- | ------------------------------------------------------------------------------ | | `mcpServer` | `Server` | The MCP server instance to connect to the Streamable HTTP transport | | `port` | `number` | Port to bind the HTTP server on | | `createSseServer` | `() => Server` | Factory called per legacy SSE connection; returns a fresh server instance | | `version` | `string` | Optional — included in the `/health` response when provided. The CLI passes the bundled data's own version. | **Returns:** `Promise` **Throws:** rejects if the port is already in use (`EADDRINUSE`). --- ### `HttpServerHandle` ```ts interface HttpServerHandle { dispose(): Promise; readonly disposed: boolean; [Symbol.asyncDispose](): Promise; } ``` Returned by `startHttpServer()`. Call `dispose()` (or use an `await using` declaration) to close the HTTP server and all open connections — `dispose()` is idempotent, and `disposed` reflects whether it has already run. The CLI registers `SIGTERM` and `SIGINT` handlers that call `dispose()` automatically. --- ### `createRequestHandler()` ```ts createRequestHandler( streamableTransport: StreamableHTTPServerTransport, sseSessions: Map, createSseServer: () => Server, version?: string, ): (req: IncomingMessage, res: ServerResponse) => void; ``` Builds the raw Node.js HTTP request handler without binding a port. Exposed primarily for integration testing — `startHttpServer()` calls this internally. Use `startHttpServer()` for production wiring. **Returns:** A `(req, res) => void` handler suitable for `node:http`'s `createServer()`. --- ### `validateBundledData()` ```ts validateBundledData(raw: unknown): BundledData; ``` Validates that `raw` conforms to the `BundledData` shape: checks `schemaVersion` (must match `SCHEMA_VERSION`), `version` (string), `packages` and `refineComponents` (arrays), and for each package entry — a non-empty `slug` string, a `name` string, that `availableDocPages`/`examples`/ `exports`/`keywords`/`related` are arrays, and that `docs`/`typeSignatures` are objects. Use when loading data from a custom path instead of `loadData()`. **Parameters:** | Parameter | Type | Description | | --------- | --------- | --------------------------- | | `raw` | `unknown` | Parsed JSON to validate | **Returns:** `BundledData` (cast after validation) **Throws:** `CodexError` with a descriptive message on schema failure — the message names the specific package slug and field that failed validation, not just "malformed data". **Example:** ```ts import { validateBundledData } from '@vielzeug/codex'; import { readFileSync } from 'node:fs'; const raw = JSON.parse(readFileSync('./my-snapshot.json', 'utf8')); const data = validateBundledData(raw); // throws if invalid ``` ## Types ### `BundledData` The validated snapshot loaded at startup. ```ts interface BundledData { packages: BundledPackage[]; refineComponents: CemDeclaration[]; schemaVersion: number; version: string; } ``` `refineComponents` is `[]` when `@vielzeug/refine` was not built before data generation (published releases always include it). ### `BundledPackage` Full package record in the snapshot. Includes all docs, source, and REPL examples inline. ```ts interface BundledPackage { apiSource: string | null; availableDocPages: DocPage[]; category: string; description: string; docs: Partial>; examples: BundledExample[]; exports: string[]; keywords: string[]; name: string; related: string[]; slug: string; typeSignatures: Record; version: string; // always a string; defaults to '0.0.0' when not found in package.json } ``` ### `BundledExample` A single runnable REPL code example, sourced from `docs/.vitepress/theme/components/repl/examples//`. ```ts interface BundledExample { code: string; id: string; name: string; } ``` ### `PackageMeta` Lightweight metadata returned by tools. Derived from `BundledPackage` — heavy content fields (`apiSource`, `docs`, `examples`, `typeSignatures`) are omitted, `hasSource` is computed, and `examples` is reduced to `exampleIds`. ```ts type PackageMeta = Omit & { exampleIds: string[]; hasSource: boolean; }; ``` ### `SearchHit` Result shape for `search-packages`. ```ts interface SearchHit { matchedExamples?: string[]; matchedIn: Array; matchedPages?: DocPage[]; name: string; score: number; slug: string; } ``` ### `DocPage` ```ts type DocPage = 'api' | 'examples' | 'index' | 'usage'; ``` ### CEM Types These types describe the Custom Elements Manifest (CEM) shape used by `refine-get-component` and `refine-list-components`. Import them from `@vielzeug/codex` when processing component declarations in TypeScript. ```ts interface CemTypeRef { text: string; } interface CemAttribute { default?: string; description?: string; fieldName?: string; name: string; type?: CemTypeRef; } interface CemCssPart { description?: string; name: string; } interface CemCssProperty { default?: string; description?: string; name: string; } interface CemEvent { description?: string; name: string; type?: CemTypeRef; } interface CemMember { description?: string; kind?: 'field' | 'method'; name: string; type?: CemTypeRef; } interface CemSlot { description?: string; name: string; } interface CemDeclaration { attributes?: CemAttribute[]; cssProperties?: CemCssProperty[]; cssParts?: CemCssPart[]; description?: string; events?: CemEvent[]; members?: CemMember[]; name?: string; slots?: CemSlot[]; superclass?: { name: string; package?: string }; tagName?: string; [key: string]: unknown; } ``` ### `SCHEMA_VERSION` ```ts const SCHEMA_VERSION: number; ``` The snapshot schema version. `validateBundledData` rejects data whose `schemaVersion` does not match this value. Useful as a guard when loading data from a custom snapshot file. ## Errors ### Tool errors (`isError: true`) Every failed tool call returns `isError: true` with a single text content block containing `{"code": "...", "message": "..."}` — `code` is one of `INVALID_ARG` (bad or missing argument), `NOT_FOUND` (unknown slug/tag/symbol/example), or `UNAVAILABLE` (data not bundled, e.g. Refine CEM metadata when `@vielzeug/refine` wasn't built). Branch on `code` instead of matching `message` text. ### Protocol errors (`McpError`) | Situation | Error code | | ------------------ | ----------------- | | Unknown tool name | `MethodNotFound` | ### `CodexError` ```ts class CodexError extends Error { static is(err: unknown): err is CodexError; } ``` Base class for every error codex throws itself (startup/data-loading failures, invalid tool arguments). Use `CodexError.is(err)` to distinguish codex-originated failures from errors thrown by dependencies (e.g. the MCP SDK) in a catch block. ### `ToolError` ```ts type ToolErrorCode = 'INVALID_ARG' | 'NOT_FOUND' | 'UNAVAILABLE'; class ToolError extends CodexError { readonly code: ToolErrorCode; } ``` Thrown internally by every tool's `run()` implementation for any expected failure (bad argument, unknown slug/tag, missing bundled data). Callers never see this directly — the MCP server's central handler catches it and converts it into a tool result with `isError: true` and the `{code, message}` JSON body described above. Exported so embedders using `createServer()`/`registerTools()` directly can recognise it with `instanceof ToolError` or `CodexError.is()`, and branch on `code`. ### Startup errors `loadData()` throws a `CodexError` synchronously with an actionable message when: - The bundled data file is missing (`ENOENT`): includes the regen command - The file cannot be read for any other reason (`EACCES`, etc.): includes the file path and system error message - The file is malformed JSON: includes the regen command - The parsed data fails schema validation (including per-package field shape checks): includes the regen command and, for a field-shape failure, the specific package slug and field name - A package entry is missing a non-empty `slug` or `name`: includes the regen command ## Runtime Behavior - Default transport: stdio - HTTP mode: `--port ` using Streamable HTTP (spec-compliant) + legacy SSE at `GET /sse` - Health endpoint: `GET /health` → `{ "status": "ok", "version": "" }` - `--data ` loads bundled data from a custom snapshot file instead of the built-in one - Bundled data validated at startup — missing or malformed data aborts with an actionable error - CLI flags: `--help` (stderr), `--version` (stdout — prints the npm package version from `package.json`; does not require bundled data) - Unknown flags print a usage hint and exit with code 1 - `EADDRINUSE` prints `error: port N is already in use.` and exits with code 1 - HTTP mode registers `SIGTERM` and `SIGINT` handlers; both call `handle.dispose()` and `process.exit(0)` - Argument validation failures (`parseArgs` against a tool's declared `ToolSchema`) throw `ToolError('INVALID_ARG', ...)`, which the server converts to `isError: true` — they never surface as an MCP protocol error ### Usage Guide Start with the [Overview](./index.md), then use this page for setup and client configuration. ## Basic Usage Run the server over stdio (the default transport): ```sh npx -y @vielzeug/codex ``` That is enough for Claude Desktop or Copilot Chat to connect and start calling tools. ## Transport Modes ### Stdio (default) The server communicates over stdin/stdout. Use this for local clients. ```sh npx -y @vielzeug/codex ``` ### HTTP / Streamable HTTP Run with `--port` to expose an HTTP endpoint for remote agents. ```sh npx -y @vielzeug/codex --port 3100 ``` HTTP endpoints: - MCP endpoint: `http://localhost:3100/` - Health check: `http://localhost:3100/health` → `{ "status": "ok", "version": "" }` ### Custom data snapshot Point the CLI at a snapshot file other than the built-in bundled one with `--data`: ```sh npx -y @vielzeug/codex --data ./my-snapshot.json ``` Combine with `--port` for HTTP mode. Useful for testing a locally regenerated snapshot without reinstalling the package. ## Connect Claude Desktop Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "vielzeug": { "command": "npx", "args": ["-y", "@vielzeug/codex"] } } } ``` ## Connect GitHub Copilot Chat Create or extend `.vscode/mcp.json` in your workspace root. ```json [Stdio] { "servers": { "vielzeug": { "type": "stdio", "command": "npx", "args": ["-y", "@vielzeug/codex"] } } } ``` ```json [HTTP] { "servers": { "vielzeug": { "type": "http", "url": "http://localhost:3100/" } } } ``` ## Recommended Tool Workflow Typical AI-agent pattern from discovery to code generation: ``` list-packages → scan catalog, note availableDocPages + exampleIds search-packages { query: "form validation" } → multi-word AND search across all fields get-package { packageSlug: "forge" } → structured metadata for one package get-docs { packageSlug: "forge", page: "usage" } → how-to guide get-docs { packageSlug: "forge", page: "api" } → full API reference get-source { packageSlug: "forge" } → exact exported signatures get-type-signature { slug: "forge", symbol: "createForm" } → one exported declaration, no full-source read ``` To find and run a REPL example: ``` list-examples { packageSlug: "arsenal" } → enumerate example ids for a package get-example { packageSlug: "arsenal", exampleId: "function-debounce" } → full runnable code ``` For Refine component queries: ``` refine-list-components → enumerate available tag names refine-get-component { tagName: "ore-input" } → full CEM declaration refine-generate-template { tagName: "ore-input" } → scaffolded HTML snippet refine-validate-usage { tagName: "ore-input", html: "" } → catch typos before rendering ``` ## Programmatic Usage For the common case, use `createServerFromDisk()` — it calls `loadData()` internally: ```ts import { createServerFromDisk } from '@vielzeug/codex'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; await createServerFromDisk().connect(new StdioServerTransport()); ``` When you need explicit control over when data is loaded, use `createServer` with `loadData` separately: ```ts import { createServer, loadData } from '@vielzeug/codex'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const data = loadData(); const server = createServer(data); await server.connect(new StdioServerTransport()); ``` To load data from a custom snapshot file, use `validateBundledData`: ```ts import { validateBundledData } from '@vielzeug/codex'; import { readFileSync } from 'node:fs'; const raw = JSON.parse(readFileSync('./my-snapshot.json', 'utf8')); const data = validateBundledData(raw); ``` ## Monorepo Development Use this mode only when developing `@vielzeug/codex` itself. ```sh cd packages/codex pnpm build # compile TypeScript pnpm test # run test suite (regenerates bundled data first) node dist/cli.js ``` Bundled data is regenerated automatically before `build` and `test`. Manual refresh: ```sh cd packages/codex pnpm run prepare:data ``` If `refine-list-components` returns an error about missing Refine metadata, build `@vielzeug/refine` first so `packages/refine/dist/custom-elements.json` is available during bundling. ## Security Notes - **HTTP mode binds with `Access-Control-Allow-Origin: *`** — any local web page can make cross-origin requests to the server. Do not expose the HTTP port on a publicly accessible interface. Use stdio mode for production/shared environments. - **Never expose the HTTP port through a firewall or proxy** — the MCP endpoint has no authentication. Treat it as a local-only service. ## Embedding in a Node.js Process When you need the MCP server as part of a larger Node.js process rather than a standalone binary, import and wire it directly: ```ts import { createServerFromDisk } from '@vielzeug/codex'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const server = createServerFromDisk(); await server.connect(new StdioServerTransport()); ``` For HTTP transport in CI or a container, use the binary directly: ```sh codex --port 3100 # or without installing: npx -y @vielzeug/codex --port 3100 ``` ## Working with Other Vielzeug Libraries **With Refine** — the codex MCP server exposes Refine component metadata via `refine-list-components` and `refine-get-component`. After building `@vielzeug/refine`, the `custom-elements.json` is bundled into the MCP data so AI agents can query component attributes, slots, and events: ```bash # Ensure Refine CEM is available before bundling codex pnpm --filter @vielzeug/refine build pnpm --filter @vielzeug/codex run prepare:data ``` An AI agent can then call: ```jsonc // list all ore- components { "tool": "refine-list-components" } // get full declaration for ore-button { "tool": "refine-get-component", "arguments": { "tagName": "ore-button" } } ``` **With Spell** — codex exposes `spell` documentation so AI agents can discover the schema validation API without leaving the MCP session: ```jsonc { "tool": "get-docs", "arguments": { "packageSlug": "spell", "page": "api" } } ``` This returns the complete `spell` API reference, letting an agent write correct validation schemas without browsing external docs. ## Best Practices - Call `list-packages` first to discover `availableDocPages` and `exampleIds`, then `get-package` with `packageSlug` for a focused view before calling `get-docs` — not every package has every page or example. - Prefer `search-packages` over iterating `list-packages` manually when looking for a capability. Multi-word queries are supported — all words must match. - Use `get-source` for the exact public API surface; prefer `get-type-signature` when you only need one symbol's declaration — it avoids loading the full file. - Use `list-examples` + `get-example` to show a real, runnable snippet instead of hand-writing one from docs alone. - In HTTP mode, check `/health` before routing traffic to verify the server is up and which snapshot `version` it's serving. - Pin a version in production (`npx @vielzeug/codex@3.0.1`) to avoid surprise data changes from snapshot updates. ### Examples ## Examples - [Listing Packages](./examples/listing-packages.md) - [Searching Packages](./examples/searching-packages.md) - [Package Metadata](./examples/package-metadata.md) - [Reading Docs](./examples/reading-docs.md) - [Running REPL Examples](./examples/running-repl-examples.md) - [Looking Up Components](./examples/looking-up-components.md) - [Inspector](./examples/inspector.md) --- ## @vielzeug/coins **Category:** utilities **Keywords:** money, currency, exchange rate, formatting, bigint, locale, arithmetic, allocation **Key exports:** money, add, subtract, multiply, divide, abs, negate, roundTo, allocate, splitEvenly, clamp, sum (+26 more) **Related:** arsenal, tempo ### Overview ## Why Coins? Monetary arithmetic with `number` accumulates IEEE-754 rounding errors. These errors are invisible in tests but show up in production totals, allocation remainders, and exchange results. ```ts // Before — float arithmetic const price = 10.1 + 10.2; // 20.299999999999997, not 20.3 const [a, b, c] = [price / 3, price / 3, price / 3]; a + b + c; // 20.299999999999997 — penny lost // After — bigint minor units import { add, allocate, money, toDecimal } from '@vielzeug/coins'; const price = add(money('10.10', 'USD'), money('10.20', 'USD')); const [a, b, c] = allocate(price, [1, 1, 1]); a.amount + b.amount + c.amount === price.amount; // true — always ``` | Feature | Coins | Dinero.js v2 | currency.js | | ---------------------------- | ------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------- | | Bundle size | | ~14 kB | ~2.5 kB | | Zero dependencies | | | | | `bigint` minor units | | (number) | (number) | | TypeScript-native | | | third-party types | | Validated currency codes | | | | | Locale-aware formatting | | | manual | | Largest Remainder allocation | | | | **Use Coins when** you need exact bigint arithmetic with validated currencies, typed allocation, and `Intl`-powered formatting in a single zero-dependency package. **Consider Dinero.js when** your team already uses it and float precision is acceptable for your use case. ## Installation ```sh [pnpm] pnpm add @vielzeug/coins ``` ```sh [npm] npm install @vielzeug/coins ``` ```sh [yarn] yarn add @vielzeug/coins ``` ## Quick Start ```ts import { add, allocate, exchange, format, money, multiply } from '@vielzeug/coins'; import type { ExchangeRate, Money } from '@vielzeug/coins'; // Create money from decimal strings (lossless) or bigint minor units const price: Money = money('19.99', 'USD'); // { amount: 1999n, currency: 'USD' } const tax: Money = money('1.60', 'USD'); const total: Money = add(price, tax); // { amount: 3559n, currency: 'USD' } // Arithmetic multiply(total, '1.1'); // $39.15 (half-away-from-zero, default) multiply(total, '1.1', 'floor'); // explicit rounding mode // Lossless allocation — no minor unit is ever lost or gained allocate(money('10.00', 'USD'), [1, 1, 1]); // [$3.34, $3.33, $3.33] // Locale-aware formatting format(total); // '$35.59' format(total, { locale: 'de-DE' }); // '35,59 $' format(total, { style: 'code' }); // 'USD 35.59' // Currency exchange — ExchangeRate.from/to are plain strings; rate is a decimal string const rate: ExchangeRate = { from: 'USD', rate: '0.92', to: 'EUR' }; exchange(total, rate); // { amount: 3274n, currency: 'EUR' } ``` ## Features - `money()` — create from decimal string, number, or bigint minor units; currency validated at creation time via `Intl`; dev warning when float has more decimals than currency supports - Arithmetic — `add`, `subtract`, `multiply`, `divide`, `abs`, `negate`; all throw `CurrencyMismatchError` on currency mismatch - `roundTo()` — round to fewer decimal places (e.g. whole dollars); configurable rounding mode - Allocation — `allocate` (weighted) and `splitEvenly` (equal); Largest Remainder Method guarantees exact totals - Aggregates — `sum`, `min`, `max`, `clamp`; `min`/`max` accept a non-empty array - Comparison — `compare`, `isEqual` (returns `false` on currency mismatch), `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `isZero`, `isPositive`, `isNegative`, `isNonNegative`, `isNonPositive` - `format()` — `Intl.NumberFormat`-powered string output with symbol / code / name / narrowSymbol styles - `formatParts()` — typed part array for custom UI rendering (superscript cents, coloured symbols, etc.) - `exchange()` — currency conversion using string rates; `ExchangeRate.from`/`to` are plain strings; throws `CurrencyMismatchError` on mismatch - Serialization — `toDecimal`, `toNumber`, `toJSON`, `fromJSON`; safe `bigint` round-trip through JSON - `withAmount()` — clone a `Money` with a new bigint amount, preserving the currency - `isMoney()` — type guard for narrowing unknown payloads; own-property check guards against prototype pollution - `CurrencyMismatchError` / `InvalidCurrencyError` — typed error subclasses for structured `catch` blocks ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Arsenal](/arsenal/) — general-purpose utility functions; pairs with Coins for formatting pipelines that combine numbers, strings, and currency in one pass - [Tempo](/tempo/) — date and time utilities; combine with Coins when displaying transaction histories or time-windowed financial summaries ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | -------------------------------------------- | ------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `money()` | Create a `Money` value from decimal string / number / bigint | Sync | Number input is converted via `String()` — IEEE-754 limits apply; dev warning fires when float has more decimals than currency supports | | `add()` / `subtract()` | Add or subtract same-currency values | Sync | Throws `CurrencyMismatchError` on currency mismatch | | `multiply()` | Scale a money value by a factor | Sync | Default rounding is `'half-away-from-zero'`; use string factors for lossless fractions | | `divide()` | Divide a money value by a divisor | Sync | Throws `RangeError` on division by zero | | `abs()` / `negate()` | Absolute value / sign flip | Sync | | | `roundTo()` | Round to fewer decimal places than currency default | Sync | `places` must be `0..currencyDecimals`; throws `RangeError` if out of range | | `allocate()` | Distribute across weighted shares (LRM) | Sync | All ratios zero → `RangeError`; any negative ratio → `RangeError` | | `splitEvenly()` | Distribute into equal shares | Sync | Sugar over `allocate`; non-positive `parts` → `RangeError` | | `sum()` | Sum an array of money values | Sync | Empty array → `RangeError`; mixed currencies → `CurrencyMismatchError` | | `min()` / `max()` | Smallest / largest value | Sync | Accepts a non-empty array; throws `CurrencyMismatchError` on mismatch, `RangeError` for empty array | | `clamp()` | Clamp to `[lower, upper]` range | Sync | Throws `CurrencyMismatchError` on mismatch; `RangeError` if `lower > upper` | | `compare()` | Three-way comparison | Sync | Throws `CurrencyMismatchError` on currency mismatch | | `isEqual()` | Equality check | Sync | Returns `false` on currency mismatch (does not throw) | | `greaterThan()` / `lessThan()` etc. | Boolean comparisons | Sync | Throw `CurrencyMismatchError` on currency mismatch | | `CurrencyMismatchError` | Typed error — currency mismatch | — | Extends `TypeError`; has `expected` and `received` properties | | `InvalidCurrencyError` | Typed error — unknown currency code | — | Extends `RangeError`; has `code` property | | `isZero()` / `isPositive()` / `isNegative()` | Sign predicates | Sync | | | `isNonNegative()` / `isNonPositive()` | Non-strict sign predicates (>= 0 / **Dev warning:** In development builds, passing a `number` with more decimal places than the currency supports (e.g. `money(0.123, 'USD')`) emits a `console.warn` via `[@vielzeug/coins]`. This is a sign of potential IEEE-754 precision loss. Use a decimal string instead. ```ts money('1234.56', 'USD'); // { amount: 123456n, currency: 'USD' } money(1234.56, 'USD'); // { amount: 123456n, currency: 'USD' } money(123456n, 'USD'); // { amount: 123456n, currency: 'USD' } money('1234', 'JPY'); // { amount: 1234n, currency: 'JPY' } money('1.234', 'KWD'); // { amount: 1234n, currency: 'KWD' } ``` ## Arithmetic All binary arithmetic functions throw `CurrencyMismatchError` (extends `TypeError`) when the two `Money` values have different currencies. ### `add(a, b)` ```ts function add(a: Money, b: Money): Money; ``` ### `subtract(a, b)` ```ts function subtract(a: Money, b: Money): Money; ``` ### `multiply(money, factor, mode?)` ```ts function multiply(m: Money, factor: number | string, mode?: RoundingMode): Money; ``` Multiplies `m.amount` by `factor`. Use a decimal string for lossless fractional factors. Defaults to `'half-away-from-zero'` rounding. ```ts multiply(money('100.00', 'USD'), '1.5'); // $150.00 multiply(money('1.00', 'USD'), '0.339', 'floor'); // $0.33 multiply(money('1.00', 'USD'), '0.339', 'ceiling'); // $0.34 ``` ### `divide(money, divisor, mode?)` ```ts function divide(m: Money, divisor: number | string, mode?: RoundingMode): Money; ``` Divides `m.amount` by `divisor`. Throws `RangeError` on division by zero. ```ts divide(money('100.00', 'USD'), 3); // $33.33 divide(money('100.00', 'USD'), 3, 'ceiling'); // $33.34 divide(money('100.00', 'USD'), 0); // throws RangeError ``` ### `abs(money)` ```ts function abs(m: Money): Money; ``` Returns the absolute value. Negative amounts become positive. ### `negate(money)` ```ts function negate(m: Money): Money; ``` Returns the money with its sign flipped. ## Allocation ### `allocate(money, ratios)` ```ts function allocate(m: Money, ratios: readonly (number | string)[]): [Money, ...Money[]]; ``` Distributes `m` across `ratios` using the **Largest Remainder Method**. The sum of all returned values is always exactly equal to `m` — no minor unit is ever lost or gained. Ratios are proportional — they do not need to sum to 1. Accepts number or string ratios; use strings for lossless decimal weights. Throws `RangeError` if: - `ratios` is empty - any ratio is negative (including negative string ratios like `'-0.5'`) - all ratios are zero ```ts allocate(money('10.00', 'USD'), [1, 1, 1]); // [{ amount: 334n, ... }, { amount: 333n, ... }, { amount: 333n, ... }] allocate(money('10.00', 'USD'), ['0.3', '0.7']); // [{ amount: 300n, ... }, { amount: 700n, ... }] ``` ### `splitEvenly(money, parts)` ```ts function splitEvenly(m: Money, parts: number): [Money, ...Money[]]; ``` Splits `m` into `parts` equal shares. Equivalent to `allocate(m, Array(parts).fill(1))`. Throws `RangeError` if `parts` is not a positive integer. ## Aggregates ### `sum(moneys)` ```ts function sum(moneys: readonly Money[]): Money; ``` Sums an array of `Money` values. Throws `RangeError` if empty. Throws `CurrencyMismatchError` on currency mismatch. ### `min(moneys)` · `max(moneys)` ```ts function min(moneys: readonly Money[]): Money; function max(moneys: readonly Money[]): Money; ``` Returns the smallest / largest value from a non-empty array. Throws `CurrencyMismatchError` on currency mismatch. Throws `RangeError` for an empty array. ```ts min([money('3.00', 'USD'), money('1.00', 'USD'), money('2.00', 'USD')]); // $1.00 max([money('1.00', 'USD'), money('3.00', 'USD')]); // $3.00 ``` ### `clamp(m, lower, upper)` ```ts function clamp(m: Money, lower: Money, upper: Money): Money; ``` Clamps `m` to the inclusive range `[lower, upper]`. Returns `lower` if `m upper`, or `m` unchanged if within bounds. Throws `CurrencyMismatchError` on currency mismatch. Throws `RangeError` if `lower > upper`. ```ts const lo = money('1.00', 'USD'); const hi = money('10.00', 'USD'); clamp(money('5.00', 'USD'), lo, hi); // $5.00 (within range) clamp(money('0.50', 'USD'), lo, hi); // $1.00 (below lower) clamp(money('15.00', 'USD'), lo, hi); // $10.00 (above upper) ``` ## Comparison Most comparison functions throw `CurrencyMismatchError` when currencies differ. `isEqual` is the exception — it returns `false` on currency mismatch. ### `compare(a, b)` ```ts function compare(a: Money, b: Money): -1 | 0 | 1; ``` Returns `-1` if `a b`. ### `isEqual(a, b)` ```ts function isEqual(a: Money, b: Money): boolean; ``` Returns `true` if both amount and currency are identical. Returns `false` if currencies differ (does not throw). Safe to use in `.filter()` and conditional chains across mixed-currency arrays. ### `greaterThan(a, b)` · `greaterThanOrEqual(a, b)` · `lessThan(a, b)` · `lessThanOrEqual(a, b)` ```ts function greaterThan(a: Money, b: Money): boolean; function greaterThanOrEqual(a: Money, b: Money): boolean; function lessThan(a: Money, b: Money): boolean; function lessThanOrEqual(a: Money, b: Money): boolean; ``` ### `isZero(money)` · `isPositive(money)` · `isNegative(money)` · `isNonNegative(money)` · `isNonPositive(money)` ```ts function isZero(m: Money): boolean; // amount === 0n function isPositive(m: Money): boolean; // amount > 0n function isNegative(m: Money): boolean; // amount = 0n function isNonPositive(m: Money): boolean; // amount maximumFractionDigits` or if either is negative or non-integer. ```ts format(money('1234.56', 'USD')); // '$1,234.56' format(money('1234.56', 'USD'), { locale: 'de-DE' }); // '1.234,56 $' format(money('1234.56', 'USD'), { style: 'code' }); // 'USD 1,234.56' format(money('1234.56', 'USD'), { style: 'name' }); // '1,234.56 US dollars' format(money('1234.56', 'USD'), { style: 'narrowSymbol' }); // '$1,234.56' (compact) format(money('1234', 'JPY')); // '¥1,234' // Only maximumFractionDigits — no need to also set minimumFractionDigits format(money('100.99', 'USD'), { maximumFractionDigits: 0 }); // '$101' ``` --- ### `formatParts(money, options?)` ```ts function formatParts(m: Money, options?: FormatOptions): MoneyFormatPart[]; ``` Same options as `format()`. Returns an array of typed part objects instead of a joined string. Useful for applying different styles to each semantic segment. Joining all `value` fields always produces the same output as `format(m, options)`. ```ts formatParts(money('1234.56', 'USD')); // [ // { type: 'currency', value: '$' }, // { type: 'integer', value: '1,234' }, // { type: 'decimal', value: '.' }, // { type: 'fraction', value: '56' }, // ] formatParts(money('-99.99', 'USD')); // [ // { type: 'minusSign', value: '-' }, // { type: 'currency', value: '$' }, // { type: 'integer', value: '99' }, // { type: 'decimal', value: '.' }, // { type: 'fraction', value: '99' }, // ] ``` ## Currency Exchange ### `exchange(money, rate, mode?)` ```ts function exchange(m: Money, rate: ExchangeRate, mode?: RoundingMode): Money; ``` Converts `m` to the currency specified in `rate.to` using lossless bigint arithmetic. The `rate.rate` field must be a decimal string. Throws `CurrencyMismatchError` if `m.currency !== rate.from`. Throws `InvalidCurrencyError` if `rate.to` is not a recognised ISO 4217 code. Throws `RangeError` if `rate.rate` is negative or an empty string. `ExchangeRate.from` and `.to` are plain strings. Accepts an optional `RoundingMode` (default `'half-away-from-zero'`). ```ts exchange(money('100.00', 'USD'), { from: 'USD', rate: '0.92', to: 'EUR' }); // { amount: 9200n, currency: 'EUR' } exchange(money('100.00', 'USD'), { from: 'USD', rate: '0.92', to: 'EUR' }, 'floor'); // { amount: 9200n, currency: 'EUR' } (same here — exact result) ``` ## Utilities ### `withAmount(m, amount)` ```ts function withAmount(m: Money, amount: bigint): Money; ``` Creates a new `Money` with the given `amount` and the same currency as `m`. Useful when you compute a raw `bigint` externally and need to wrap it back without re-validating the currency. ```ts const price = money('9.99', 'USD'); withAmount(price, 1999n); // { amount: 1999n, currency: 'USD' } withAmount(price, -500n); // { amount: -500n, currency: 'USD' } ``` --- ### `isMoney(value)` ```ts function isMoney(value: unknown): value is Money; ``` Type guard that returns `true` if `value` is a `Money`-shaped object — has an own `bigint` `amount` and an own `string` `currency`. Uses `hasOwnProperty` to guard against prototype-chain properties. Does **not** validate the currency code — shape check only. ```ts isMoney({ amount: 100n, currency: 'USD' }); // true isMoney({ amount: 1.5, currency: 'USD' }); // false isMoney(null); // false isMoney(Object.create({ amount: 100n, currency: 'USD' })); // false (prototype only) ``` --- ### `validateCurrencyCode(code)` ```ts function validateCurrencyCode(code: string): string; ``` Validates a currency code string against `Intl.NumberFormat`. Returns the code unchanged on success. Throws `InvalidCurrencyError` if the code is not a recognised ISO 4217 currency. Uses the same underlying check as `money()` — results are cached, so repeated calls for the same code are cheap. Useful when you need to validate a currency code before constructing a `Money` value, or when building validated lookup structures. ```ts validateCurrencyCode('USD'); // 'USD' validateCurrencyCode('FAKE'); // throws InvalidCurrencyError: Invalid ISO 4217 currency code: "FAKE" // Pre-validate before constructing const code = validateCurrencyCode(userInput); const m = money(0n, code); // no re-validation cost — cached ``` --- ### `getCurrencyDecimals(currencyCode)` ```ts function getCurrencyDecimals(currencyCode: string): number; ``` Returns the number of minor-unit decimal places for a given ISO 4217 currency code (e.g. `USD→2`, `JPY→0`, `KWD→3`). Uses `Intl.NumberFormat` internally; results are cached for performance. Throws `InvalidCurrencyError` for unrecognised codes. Useful when building custom formatters or when you need to know the precision for a currency before constructing a `Money` value. ```ts getCurrencyDecimals('USD'); // 2 getCurrencyDecimals('JPY'); // 0 getCurrencyDecimals('KWD'); // 3 getCurrencyDecimals('FAKE'); // throws InvalidCurrencyError ``` ## Rounding ### `roundTo(money, places, mode?)` ```ts function roundTo(m: Money, places: number, mode?: RoundingMode): Money; ``` Rounds a `Money` value to fewer decimal places than the currency's default. Useful for display purposes (e.g. rounding USD cents to whole dollars). `places` must be a non-negative integer in the range `0..currencyDecimals`. Returns `m` unchanged when `places === currencyDecimals`. Throws `RangeError` if out of range. ```ts roundTo(money('1234.56', 'USD'), 0); // { amount: 1235n, currency: 'USD' } ($1,235) roundTo(money('1234.56', 'USD'), 1); // { amount: 12346n, currency: 'USD' } ($1,234.6) roundTo(money('1234.56', 'USD'), 1, 'floor'); // { amount: 12345n, currency: 'USD' } ($1,234.5) roundTo(money(1234n, 'JPY'), 0); // no-op — JPY has 0 decimal places ``` ## Rounding Modes Used by `multiply`, `divide`, `exchange`, and `roundTo` when the result contains a fractional minor unit. | Mode | Description | | ----------------------- | -------------------------------------------------- | | `'half-away-from-zero'` | Round half away from zero **(default)** | | `'half-even'` | Banker's rounding — nearest even integer at halves | | `'down'` | Truncate toward zero | | `'up'` | Away from zero | | `'floor'` | Toward −∞ | | `'ceiling'` | Toward +∞ | ## Error Types ### `CurrencyMismatchError` ```ts class CurrencyMismatchError extends TypeError { readonly expected: string; // currency of the first operand readonly received: string; // currency of the mismatching operand } ``` Thrown by all functions that require same-currency operands (`add`, `subtract`, `compare`, `sum`, `min`, `max`, `clamp`, `exchange`, etc.). Extends `TypeError` — existing `instanceof TypeError` catch blocks continue to work. ```ts try { add(money('1.00', 'USD'), money('1.00', 'EUR')); } catch (e) { if (e instanceof CurrencyMismatchError) { console.log(e.expected, e.received); // 'USD' 'EUR' } } ``` --- ### `InvalidCurrencyError` ```ts class InvalidCurrencyError extends RangeError { readonly code: string; // the unrecognised currency code } ``` Thrown by `money`, `exchange` (for invalid `rate.to`), `validateCurrencyCode`, and any other function that validates a currency string. Extends `RangeError` — existing `instanceof RangeError` catch blocks continue to work. ```ts try { money('1.00', 'FAKE'); } catch (e) { if (e instanceof InvalidCurrencyError) { console.log('Bad code:', e.code); // 'FAKE' } } ``` --- ## Types ### `CurrencyCode` ```ts type CurrencyCode = string; ``` A type alias for `string`. Currency codes are validated at runtime (via `Intl.NumberFormat`) when passed to `money()` or `exchange()`. --- ### `Money` ```ts type Money = { readonly amount: bigint; // minor units (cents for USD, whole units for JPY) readonly currency: string; // validated ISO 4217 code }; ``` --- ### `ExchangeRate` ```ts type ExchangeRate = { readonly from: string; // source currency code readonly rate: string; // decimal multiplier string, e.g. '0.92' readonly to: string; // target currency code }; ``` `rate` is a **string**, not a number. Using a number would introduce IEEE-754 errors into the bigint conversion arithmetic. --- ### `FormatOptions` ```ts type FormatOptions = { locale?: string; maximumFractionDigits?: number; minimumFractionDigits?: number; style?: 'code' | 'name' | 'narrowSymbol' | 'symbol'; }; ``` --- ### `MoneyFormatPart` ```ts type MoneyFormatPart = { type: 'currency' | 'decimal' | 'fraction' | 'integer' | 'literal' | 'minusSign'; value: string; }; ``` --- ### `MoneyJSON` ```ts type MoneyJSON = { amount: string; // bigint serialized as decimal integer string currency: string; }; ``` --- ### `RoundingMode` ```ts type RoundingMode = 'ceiling' | 'down' | 'floor' | 'half-away-from-zero' | 'half-even' | 'up'; ``` ## See Also - [Usage Guide](./usage.md) - [Examples](./examples.md) ### Usage Guide ## Basic Usage Use `money()` to construct a `Money` value from a human-readable decimal, number, or raw bigint minor units. The currency code is validated against `Intl.NumberFormat` at creation time — unrecognised codes throw `InvalidCurrencyError`. ```ts import { money } from '@vielzeug/coins'; // From decimal string (preferred — lossless) money('1234.56', 'USD'); // { amount: 123456n, currency: 'USD' } money('-10.50', 'USD'); // { amount: -1050n, currency: 'USD' } money('1234', 'JPY'); // { amount: 1234n, currency: 'JPY' } (zero-decimal) money('1.234', 'KWD'); // { amount: 1234n, currency: 'KWD' } (three-decimal) // From number (converted via String() — IEEE-754 applies; prefer strings) money(1234.56, 'USD'); // { amount: 123456n, currency: 'USD' } // From bigint — raw minor units, passed through as-is money(123456n, 'USD'); // { amount: 123456n, currency: 'USD' } // Zero accumulator — use money(0n, currency) money(0n, 'USD'); // { amount: 0n, currency: 'USD' } money(0n, 'JPY'); // { amount: 0n, currency: 'JPY' } // Invalid currency — throws InvalidCurrencyError (extends RangeError) money('1.00', 'NOTREAL'); // InvalidCurrencyError: Invalid ISO 4217 currency code: "NOTREAL" ``` `Money` is a plain readonly object — no class, no methods: ```ts type Money = { readonly amount: bigint; // minor units readonly currency: string; // validated ISO 4217 code }; ``` ## Arithmetic All binary functions (`add`, `subtract`) throw `CurrencyMismatchError` when currencies differ: ```ts import { add, subtract, multiply, divide, abs, negate } from '@vielzeug/coins'; const a = money('100.00', 'USD'); const b = money('30.00', 'USD'); add(a, b); // { amount: 13000n, currency: 'USD' } ($130.00) subtract(a, b); // { amount: 7000n, currency: 'USD' } ($70.00) abs(money('-50.00', 'USD')); // { amount: 5000n, currency: 'USD' } negate(money('10.00', 'USD')); // { amount: -1000n, currency: 'USD' } // throws CurrencyMismatchError: Currency mismatch: USD and EUR add(money('10.00', 'USD'), money('10.00', 'EUR')); ``` ### `multiply` and `divide` Both accept a `number | string` scalar and an optional `RoundingMode` (default `'half-away-from-zero'`). Use strings for lossless fractional factors. ```ts multiply(money('100.00', 'USD'), '1.5'); // $150.00 multiply(money('1.00', 'USD'), '0.339', 'floor'); // $0.33 multiply(money('1.00', 'USD'), '0.339', 'ceiling'); // $0.34 divide(money('100.00', 'USD'), 3); // $33.33 divide(money('100.00', 'USD'), 3, 'ceiling'); // $33.34 divide(money('100.00', 'USD'), 0); // throws RangeError: Division by zero ``` ### Rounding Modes | Mode | Description | | ----------------------- | ------------------------------------------------------------------- | | `'half-away-from-zero'` | Round half away from zero **(default)** | | `'half-even'` | Banker's rounding — minimises cumulative error over many operations | | `'down'` | Truncate toward zero | | `'up'` | Away from zero | | `'floor'` | Toward −∞ (down for positives, extra step for negatives) | | `'ceiling'` | Toward +∞ (extra step for positives, truncate for negatives) | ## Allocation Allocation distributes a `Money` value across weighted shares with a guarantee: the sum of all shares is always exactly equal to the input. No minor unit is ever lost or gained. ### `allocate(money, ratios)` Ratios can be numbers or strings. Use strings for lossless decimal weights (e.g. `'0.333'`). ```ts import { allocate } from '@vielzeug/coins'; // Equal split — extra penny to the first share allocate(money('10.00', 'USD'), [1, 1, 1]); // → [$3.34, $3.33, $3.33] (sum = $10.00 exactly) // Weighted split allocate(money('10.00', 'USD'), [3, 7]); // → [$3.00, $7.00] // Decimal string ratios allocate(money('10.00', 'USD'), ['0.3', '0.7']); // → [$3.00, $7.00] // Decimal string ratios that don't sum to 1 — proportions are normalised allocate(money('7.00', 'USD'), ['0.333', '0.333', '0.334']); // → [$2.33, $2.33, $2.34] (sum = $7.00 exactly) ``` Uses the **Largest Remainder Method**: each share gets its floor allocation first, then any remainder units are assigned one-by-one to the shares with the largest fractional parts. ### `splitEvenly(money, parts)` Sugar over `allocate` with all-equal weights. ```ts import { splitEvenly } from '@vielzeug/coins'; splitEvenly(money('10.00', 'USD'), 3); // → [$3.34, $3.33, $3.33] ``` ## Aggregates ```ts import { clamp, max, min, sum } from '@vielzeug/coins'; const items = [money('1.00', 'USD'), money('2.50', 'USD'), money('0.99', 'USD')]; sum(items); // $4.49 min(items); // $0.99 max(items); // $2.50 sum([]); // throws RangeError: sum requires at least one Money value // Clamp to an allowed price range const lo = money('1.00', 'USD'); const hi = money('99.99', 'USD'); clamp(money('0.50', 'USD'), lo, hi); // $1.00 (below minimum) clamp(money('42.00', 'USD'), lo, hi); // $42.00 (in range) clamp(money('150.00', 'USD'), lo, hi); // $99.99 (above maximum) ``` ## Comparison Most comparison functions throw `CurrencyMismatchError` on currency mismatch. `isEqual` is the exception — it returns `false` when currencies differ, making it safe for `.filter()` and conditional chains. ```ts import { compare, isEqual, greaterThan, lessThan, isZero, isPositive, isNegative, isNonNegative, isNonPositive, } from '@vielzeug/coins'; const five = money('5.00', 'USD'); const ten = money('10.00', 'USD'); compare(five, ten); // -1 compare(ten, five); // 1 compare(five, five); // 0 isEqual(five, five); // true isEqual(five, ten); // false isEqual(five, money('5.00', 'EUR')); // false — different currency, no throw greaterThan(ten, five); // true lessThan(five, ten); // true isZero(money('0.00', 'USD')); // true isPositive(five); // true isNegative(money('-1.00', 'USD')); // true // Non-strict predicates (inclusive of zero) isNonNegative(money('0.00', 'USD')); // true (zero or positive) isNonNegative(money('-1.00', 'USD')); // false isNonPositive(money('0.00', 'USD')); // true (zero or negative) isNonPositive(five); // false // throws CurrencyMismatchError: Currency mismatch: USD and EUR compare(money('5.00', 'USD'), money('5.00', 'EUR')); ``` ## Serialization `bigint` cannot be serialized by `JSON.stringify`. Use `toJSON` / `fromJSON` to round-trip through JSON safely: ```ts import { toJSON, fromJSON, toDecimal, toNumber } from '@vielzeug/coins'; const price = money('1234.56', 'USD'); // JSON serialization const serialized = toJSON(price); // → { amount: '123456', currency: 'USD' } (amount is a string) JSON.stringify(serialized); // → '{"amount":"123456","currency":"USD"}' fromJSON(serialized); // → { amount: 123456n, currency: 'USD' } // fromJSON rejects non-string amount fields fromJSON({ amount: 123456 as any, currency: 'USD' }); // TypeError: expected an integer string fromJSON({ amount: '1.5', currency: 'USD' }); // TypeError: expected an integer string // Round-trips fromJSON(toJSON(price)); // equals price money(toDecimal(price), 'USD'); // equals price // Decimal string — useful for display or passing to other systems toDecimal(money(5n, 'USD')); // '0.05' toDecimal(money(1234n, 'JPY')); // '1234' // Lossy float — for charting libraries, not arithmetic toNumber(price); // 1234.56 ``` ## Formatting ### `format(money, options?)` Produces a locale-aware currency string. Uses bigint arithmetic throughout — exact regardless of amount size. ```ts import { format } from '@vielzeug/coins'; const price = money('1234.56', 'USD'); format(price); // '$1,234.56' format(price, { locale: 'de-DE' }); // '1.234,56 $' format(price, { locale: 'fr-FR' }); // '1 234,56 $' format(price, { style: 'code' }); // 'USD 1,234.56' format(price, { style: 'name' }); // '1,234.56 US dollars' format(price, { style: 'narrowSymbol' }); // '$1,234.56' (compact) // Zero-decimal currencies format(money('1234', 'JPY')); // '¥1,234' // Custom fraction digits — set only maximumFractionDigits when you want to truncate format(price, { maximumFractionDigits: 0 }); // '$1,235' format(price, { minimumFractionDigits: 3, maximumFractionDigits: 3 }); // '$1,234.560' ``` ### `formatParts(money, options?)` Returns a `MoneyFormatPart[]` array instead of a joined string. Useful for applying different CSS to each semantic part (symbol, integer, fraction, sign). ```ts import { formatParts } from '@vielzeug/coins'; formatParts(money('1234.56', 'USD')); // [ // { type: 'currency', value: '$' }, // { type: 'integer', value: '1,234' }, // { type: 'decimal', value: '.' }, // { type: 'fraction', value: '56' }, // ] formatParts(money('-99.99', 'USD')); // [ // { type: 'minusSign', value: '-' }, // { type: 'currency', value: '$' }, // { type: 'integer', value: '99' }, // { type: 'decimal', value: '.' }, // { type: 'fraction', value: '99' }, // ] // Joining all values always equals format(): formatParts(m) .map((p) => p.value) .join('') === format(m); // true ``` ## Currency Exchange `exchange()` converts a `Money` value using a provided `ExchangeRate`. The `rate` field must be a **decimal string** — not a number — to avoid IEEE-754 errors in the bigint multiplication. ```ts import { exchange } from '@vielzeug/coins'; import type { ExchangeRate } from '@vielzeug/coins'; // ExchangeRate.from and .to are plain strings — no pre-validation ceremony const rate: ExchangeRate = { from: 'USD', rate: '0.92', to: 'EUR' }; exchange(money('100.00', 'USD'), rate); // { amount: 9200n, currency: 'EUR' } exchange(money('100.00', 'USD'), rate, 'floor'); // explicit rounding mode // Throws CurrencyMismatchError if money.currency !== rate.from exchange(money('100.00', 'EUR'), rate); // CurrencyMismatchError: Currency mismatch: EUR and USD // Throws InvalidCurrencyError if rate.to is not a recognised ISO 4217 code exchange(money('100.00', 'USD'), { from: 'USD', rate: '1.0', to: 'FAKE' }); // InvalidCurrencyError // Throws RangeError for negative or empty rates exchange(money('100.00', 'USD'), { from: 'USD', rate: '-0.92', to: 'EUR' }); // RangeError: Exchange rate must be non-negative exchange(money('100.00', 'USD'), { from: 'USD', rate: '', to: 'EUR' }); // RangeError: Exchange rate must be a non-empty decimal string // High-precision rates — string parsing avoids float error const highPrecRate: ExchangeRate = { from: 'USD', rate: '0.847532', to: 'EUR' }; exchange(money('1000.00', 'USD'), highPrecRate); // { amount: 84753n, currency: 'EUR' } ``` ## Practical Patterns ### Cart Total ```ts import { add, format, money, sum } from '@vielzeug/coins'; import type { Money } from '@vielzeug/coins'; const items: Money[] = [money('9.99', 'USD'), money('14.99', 'USD'), money('2.50', 'USD')]; const subtotal = sum(items); const tax = multiply(subtotal, '0.08'); const total = add(subtotal, tax); format(total); // '$29.68' ``` ### Invoice Line Allocation ```ts import { allocate, format, money } from '@vielzeug/coins'; const invoice = money('100.00', 'USD'); const [alice, bob, carol] = allocate(invoice, [50, 30, 20]); format(alice); // '$50.00' format(bob); // '$30.00' format(carol); // '$20.00' // alice.amount + bob.amount + carol.amount === 10000n (exactly) ``` ### Multi-Currency Price Display ```ts import { exchange, format, money } from '@vielzeug/coins'; import type { ExchangeRate } from '@vielzeug/coins'; const price = money('50.00', 'USD'); const rates: ExchangeRate[] = [ { from: 'USD', rate: '0.92', to: 'EUR' }, { from: 'USD', rate: '0.79', to: 'GBP' }, { from: 'USD', rate: '149.5', to: 'JPY' }, ]; for (const rate of rates) { console.log(format(exchange(price, rate))); } // €46.00 // £39.50 // ¥7,475 ``` ### React Custom Rendering ```ts import { formatParts, money } from '@vielzeug/coins'; function Price({ amount, currency }: { amount: bigint; currency: string }) { const parts = formatParts(money(amount, currency)); return ( {parts.map((part, i) => part.type === 'fraction' ? ( {part.value} ) : ( {part.value} ) )} ); } ``` ## Utilities ### `withAmount(m, amount)` Creates a new `Money` with a different raw bigint amount while preserving the source currency. Use this when you compute a `bigint` externally and need to re-wrap it without re-validating the currency. ```ts import { money, withAmount, toDecimal } from '@vielzeug/coins'; const price = money('9.99', 'USD'); // Compute externally, then wrap back const rawDoubled = price.amount * 2n; const doubled = withAmount(price, rawDoubled); toDecimal(doubled); // '19.98' // Useful in reduce / fold patterns on raw bigint values const amounts = [100n, 250n, 75n]; const total = withAmount( price, amounts.reduce((a, b) => a + b, 0n), ); toDecimal(total); // '4.25' ``` ### `isMoney(value)` Type guard that narrows `unknown` to `Money`. Checks own-property `bigint` `amount` and `string` `currency` — prototype-chain properties are rejected. ```ts import { isMoney, toDecimal } from '@vielzeug/coins'; // Narrow untrusted API payloads function displayPrice(raw: unknown): string { if (!isMoney(raw)) throw new TypeError('Expected a Money value'); return toDecimal(raw); } displayPrice({ amount: 1999n, currency: 'USD' }); // '19.99' displayPrice({ amount: 9.99, currency: 'USD' }); // throws — amount is float, not bigint displayPrice(null); // throws // isMoney does NOT validate the currency code — it only checks shape isMoney({ amount: 100n, currency: 'FAKE' }); // true — shape matches but code is unvalidated ``` ### `validateCurrencyCode(code)` Pre-validates a currency code against `Intl.NumberFormat` without constructing a `Money` value. Returns the code unchanged on success or throws `InvalidCurrencyError`. Use this when you need to surface a validation error before any arithmetic. ```ts import { InvalidCurrencyError, validateCurrencyCode, money } from '@vielzeug/coins'; // Validate once, then reuse — the result is cached const code = validateCurrencyCode(userInput); const price = money('0', code); // no re-validation cost try { validateCurrencyCode('FAKE'); } catch (e) { if (e instanceof InvalidCurrencyError) { console.log('Unknown code:', e.code); // 'FAKE' } } ``` ### `getCurrencyDecimals(currencyCode)` Returns the number of minor-unit decimal places for a currency. Use this when building custom formatters or lookup tables that need to know a currency's precision independently of constructing a `Money` value. ```ts import { getCurrencyDecimals } from '@vielzeug/coins'; getCurrencyDecimals('USD'); // 2 getCurrencyDecimals('JPY'); // 0 getCurrencyDecimals('KWD'); // 3 // Build a precision-aware formatter function formatAmount(amount: bigint, currency: string): string { const decimals = getCurrencyDecimals(currency); const divisor = 10 ** decimals; return (Number(amount) / divisor).toFixed(decimals); } ``` ## Typed Error Handling All currency mismatch errors are `CurrencyMismatchError` (extends `TypeError`) and all invalid currency code errors are `InvalidCurrencyError` (extends `RangeError`). Use `instanceof` for structured error handling: ```ts import { CurrencyMismatchError, InvalidCurrencyError, add, money } from '@vielzeug/coins'; try { add(money('1.00', 'USD'), money('1.00', 'EUR')); } catch (e) { if (e instanceof CurrencyMismatchError) { // e.expected === 'USD', e.received === 'EUR' console.log(`Expected ${e.expected}, got ${e.received}`); } } try { money('1.00', 'FAKE'); } catch (e) { if (e instanceof InvalidCurrencyError) { console.log('Unknown currency code:', e.code); // 'FAKE' } } ``` Both error classes extend built-in error types, so existing `instanceof TypeError` / `instanceof RangeError` catch blocks continue to work without any changes. ## Rounding to Fewer Decimal Places Use `roundTo()` when you need to display a `Money` value at coarser precision than the currency default (e.g. whole dollars for a summary widget, or 1 decimal place for a chart axis). `places` must be in the range `0..currencyDecimals`. The function is a pure rounding operation — no currency conversion, no allocation. ```ts import { money, roundTo } from '@vielzeug/coins'; const price = money('1234.56', 'USD'); roundTo(price, 0); // { amount: 1235n, currency: 'USD' } — whole dollars, rounds up roundTo(price, 1); // { amount: 12346n, currency: 'USD' } — 1 decimal place roundTo(price, 2); // price unchanged (2 === USD decimal places) // Explicit rounding mode roundTo(price, 0, 'floor'); // { amount: 1234n, currency: 'USD' } — truncate roundTo(price, 0, 'ceiling'); // { amount: 1235n, currency: 'USD' } — always up // JPY has 0 decimal places — roundTo(m, 0) is always a no-op const yen = money(1234n, 'JPY'); roundTo(yen, 0) === yen; // true — same reference returned ``` > `roundTo` is for **display** purposes. Do not feed its output back into allocation or arithmetic — the reduced precision may cause downstream rounding errors. ## Working with Other Vielzeug Libraries **With Tempo** — format monetary amounts alongside dates in the same pipeline: ```ts import { money, format } from '@vielzeug/coins'; import { formatDate } from '@vielzeug/tempo'; const amount = money('1234.56', 'USD'); const date = new Date(); console.log(`As of ${formatDate(date, 'MMM d, yyyy')}: ${format(amount)}`); // e.g. "As of Jun 9, 2026: $1,234.56" ``` **With Arsenal** — combine array utilities with monetary aggregation: ```ts import { sum, money } from '@vielzeug/coins'; import { groupBy } from '@vielzeug/arsenal'; const transactions = [ { category: 'food', amount: money('12.50', 'USD') }, { category: 'travel', amount: money('80.00', 'USD') }, { category: 'food', amount: money('9.75', 'USD') }, ]; const byCategory = groupBy(transactions, (t) => t.category); const foodTotal = sum(byCategory.food.map((t) => t.amount)); // foodTotal = money('22.25', 'USD') ``` **With Spell** — validate and parse currency input from user forms: ```ts import { money, InvalidCurrencyError } from '@vielzeug/coins'; import { object, string } from '@vielzeug/spell'; const MoneyInput = object({ amount: string().regex(/^\d+(\.\d{1,3})?$/), currency: string(), }); const parsed = MoneyInput.parse(formData); // money() validates the currency code — throws InvalidCurrencyError for unknown codes const value = money(parsed.amount, parsed.currency); ``` ## Best Practices - Prefer decimal strings over numbers when constructing `money()` — `money('1234.56', 'USD')` avoids IEEE-754 rounding before the value ever reaches bigint storage. In development, `money()` warns via `[@vielzeug/coins]` when a float has more decimal places than the currency supports. - Use `money(0n, currency)` for zero accumulators — it bypasses decimal parsing and is explicit about minor units. - Use `validateCurrencyCode(code)` when you want to pre-check an ISO 4217 code without immediately creating a `Money` value — it returns the code unchanged or throws `InvalidCurrencyError`. This is the same check `money()` performs internally and results are cached. - Pass `ExchangeRate.from`/`to` as plain strings — `money()` validates currencies at creation time, and `exchange()` validates `rate.to` before returning. - Use `allocate()` instead of manual `divide` + rounding whenever distributing a total across multiple parties — it guarantees the shares sum exactly to the original amount. - Use `'half-even'` (banker's rounding) in bulk-processing scenarios (batch invoices, statement generation) to minimise cumulative rounding drift. - Never store `toNumber()` output and feed it back into arithmetic. `toNumber()` is lossy — use it only for display and charting libraries. - Pass `ExchangeRate.rate` as a string, not a number. The string is parsed into an exact rational fraction; a `number` would introduce float error before the bigint conversion. - Use `sum()` instead of a manual reduce over `add()` — it validates currency consistency across the entire array upfront, so any mismatch is caught immediately with a clear error rather than failing at a mid-array `add()` call. - Use `instanceof CurrencyMismatchError` / `instanceof InvalidCurrencyError` in `catch` blocks rather than string-matching error messages — they are stable, typed, and extend built-in error types. - Use `getCurrencyDecimals(code)` when building custom formatters or lookup tables that need to know the minor-unit precision for a currency — it is the same call `money()` makes internally and results are cached, so it is cheap to call repeatedly. ### Examples ## Examples - [Formatting](./examples/formatting.md) - [Exchange Rate Conversion](./examples/exchange.md) - [Allocation](./examples/allocation.md) ### REPL Examples - Allocation without losing minor units (id: `allocation-basic`) - Arithmetic and rounding modes (id: `arithmetic-basic`) - Typed error classes (id: `errors-basic`) - Currency exchange (id: `exchange-basic`) - Locale-aware formatting (id: `format-basic`) - Create and inspect Money values (id: `money-basic`) - Rounding Money values (id: `rounding-basic`) - Serialization (toJSON, fromJSON, toDecimal, toNumber) (id: `serialization-basic`) - Utilities (withAmount, isMoney, validateCurrencyCode, getCurrencyDecimals) (id: `utilities-basic`) --- ## @vielzeug/conduit **Category:** di **Keywords:** dependency-injection, ioc, container, singleton, transient, factory, named-scope **Key exports:** createContainer, token, scope, loadModules, resolveOptional, resolveOrDefault, tryResolve, trySyncResolve, resolveSyncOptional, resolveSyncOrDefault, ConduitError, ConduitCircularDependencyError (+8 more) **Related:** rune, herald, ward ### Overview ## Why Conduit? Manual dependency wiring often spreads across modules, making lifetimes and teardown behavior difficult to reason about in larger systems. ```ts // Before — manual wiring, no lifecycle, no type safety const logger = new ConsoleLogger(); const db = await connectDb(process.env.DATABASE_URL); const repo = new UserRepo(db, logger); const service = new UserService(repo, logger); // cleanup is your problem // After — explicit tokens, typed resolution, disposal hooks const container = createContainer(); container.value(Logger, new ConsoleLogger()); container.factory(Db, () => connectDb(process.env.DATABASE_URL), { dispose: (db) => db.close() }); container.factory(UserRepo, async (r) => { const [db, logger] = await Promise.all([r.resolve(Db), r.resolve(Logger)]); return new UserRepo(db, logger); }); container.factory(UserService, async (r) => { const [repo, logger] = await Promise.all([r.resolve(UserRepo), r.resolve(Logger)]); return new UserService(repo, logger); }); const service = await container.resolve(UserService); await container.dispose(); // all hooks run automatically ``` | Feature | Conduit | tsyringe | InversifyJS | | --------------------------- | --------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Bundle size | | ~6 kB | ~45 kB | | Typed token ergonomics | | Partial | Partial | | Async-first resolution | | Partial | Partial | | Child container scopes | | | | | Explicit disposal lifecycle | | | Partial | | Decorator-free usage | | (decorator-oriented) | (commonly decorator-oriented) | | Zero dependencies | | | | **Use Conduit when** you need a compact typed container with explicit scopes and lifecycle control. **Consider decorator-heavy DI frameworks when** your project is already standardized around metadata/decorator injection patterns. ## Installation ```sh [pnpm] pnpm add @vielzeug/conduit ``` ```sh [npm] npm install @vielzeug/conduit ``` ```sh [yarn] yarn add @vielzeug/conduit ``` ## Quick Start ```ts import { createContainer, token } from '@vielzeug/conduit'; const Logger = token('Logger'); const Service = token }>('Service'); const container = createContainer(); container.value(Logger, console); container.factory(Service, async (r) => { const logger = await r.resolve(Logger); return { run: async () => logger.log('Running service') }; }); const service = await container.resolve(Service); await service.run(); await container.dispose(); ``` ## Features - Small core API — `token`, `scope`, `createContainer`, and a focused set of container methods - Typed dependency contracts via Symbol tokens with phantom types - Factory functions receive a `FactoryResolver` — dependencies resolved lazily via `r.resolve(Token)` - Named scope tokens via `scope()` and `createScope()` for explicit lifecycle isolation - Async-first resolution with singleton deduplication for concurrent callers - Sync resolution path (`resolveSync`) for hot paths after warm-up — rethrows cached rejections for failed singletons - Free-function helpers: `resolveSyncOptional`, `resolveSyncOrDefault`, `resolveOptional`, `resolveOrDefault`, `tryResolve`, `trySyncResolve` - `resolveMany()` to resolve multiple tokens in parallel with typed tuples - `resolveAll()` to eagerly warm all singletons; pass `{ includeScoped: true }` to also pre-warm named-scope factories - `InferTokenTypes` utility type to infer a typed tuple from a token array - Registration existence check (`has`) without triggering factory execution - `ContainerModule` + `loadModules()` for grouping and async provider setup - `freeze()` to lock the container after startup; idempotent — safe to call multiple times; declare `deps:` on factories for static cycle detection at freeze time - `inspect()` to get a serializable dependency graph - `on()` to subscribe to container lifecycle events (each event carries a `source` field) - `onResolve()` interceptor called after every successful resolution — for telemetry and hot-path observability - Dispose hooks on both factory and value registrations; failures warn in dev instead of throwing - Named scope containers for request/component/test scope boundaries - Explicit disposal lifecycle with `Symbol.asyncDispose` support ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Familiar](../familiar/index.md) for dependency-managed worker orchestration. - [Herald](../herald/index.md) for pub/sub coordination in container-managed modules. - [Ward](../ward/index.md) for injecting authorization services. ### API Reference ## API Overview | Symbol | Purpose | Mode | Common gotcha | | -------------------------- | ------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | | `token()` | Create a typed DI token (Symbol-backed) | Sync | Each call produces a distinct symbol | | `scope()` | Create a named scope token for lifecycle-scoped instances | Sync | Register factories with a `ScopeToken` as their lifetime | | `createContainer()` | Create a new root DI container | Sync | Register all providers before resolving | | `loadModules()` | Apply `ContainerModule` functions to a container | **Async** | Free function; modules run sequentially; returns `Promise` | | `resolveOptional()` | Resolve, returning `undefined` if not registered | **Async** | Free function; re-throws all other errors | | `resolveOrDefault()` | Resolve, returning a fallback if not registered | **Async** | Free function; same re-throw semantics as `resolveOptional` | | `tryResolve()` | Resolve, returning `{ ok, value/error }` instead of throwing | **Async** | Free function; only swallows `ConduitProviderNotFoundError`; re-throws all others | | `resolveSyncOptional()` | Resolve synchronously, returning `undefined` if not found | Sync | Free function; re-throws `ConduitSyncResolutionError`, `ConduitDisposedError` | | `resolveSyncOrDefault()` | Resolve synchronously, returning a fallback if not found | Sync | Free function; `null` from factory is preserved, not replaced by default | | `container.value()` | Register a static value | Sync | Throws `ConduitDuplicateRegistrationError` if token already used | | `container.factory()` | Register a lazy factory (sync or async) | Sync | Receives a `FactoryResolver`; factory does not run until first `resolve()` | | `container.has()` | Check if a token is registered (walks parent chain) | Sync | Does not execute the factory | | `container.resolve()` | Resolve a single provider | **Async** | Throws `ConduitProviderNotFoundError` if token not registered | | `container.resolveSync()` | Resolve synchronously from cache | Sync | Throws for transient and not-yet-resolved; rethrows cached rejections | | `container.resolveMany()` | Resolve multiple tokens in parallel, returning a typed tuple | **Async** | Rejects if any token fails | | `container.resolveAll()` | Eagerly resolve all singleton factories (walks parent chain) | **Async** | Pass `{ includeScoped: true }` to also pre-warm named-scope factories | | `container.inspect()` | Return a serializable graph of registered tokens | Sync | Defaults to deep traversal of the full parent chain | | `container.validate()` | Validate the graph without freezing | Sync | Same checks as `freeze()` but does not lock the container | | `container.freeze()` | Lock registrations; validate completeness | Sync | Detects declared-dep cycles; lazy cycles caught at resolve time | | `container.createScope()` | Create a child container, optionally tagged with a scope | Sync | Pass a `ScopeToken` to activate named-scope lifecycle | | `container.on()` | Subscribe to container events (register / resolve / dispose) | Sync | Events carry a `source` field; propagate up to parent listeners | | `container.onResolve()` | Register an interceptor called after every successful resolve | Sync | Returns unsubscribe fn; errors swallowed; propagates to parent | | `container.dispose()` | Dispose container and run cleanup hooks | **Async** | Hook failures warn in dev — never rethrow | | `container.disposalSignal` | `AbortSignal` aborted when the container is disposed | Sync getter | Tie external resource lifetimes to this container | | `container.disposed` | Whether the container has been disposed | Sync getter | — | | `container.name` | Human-readable container identifier | Sync getter | Set via `createContainer({ name })` or `createScope(token, { name })` | ## Package Entry Point | Import | Purpose | | ------------------- | ------------------------------------------ | | `@vielzeug/conduit` | All exports — functions, types, and errors | ```ts import { token, scope, createContainer, loadModules } from '@vielzeug/conduit'; import { resolveOptional, resolveOrDefault, tryResolve, trySyncResolve, resolveSyncOptional, resolveSyncOrDefault, } from '@vielzeug/conduit'; ``` ## Core Functions ### `token()` ```ts function token(description: string): Token; ``` Creates a unique typed symbol used to identify a dependency. Two calls with the same `description` produce distinct symbols. **Parameters:** | Parameter | Type | Description | | ------------- | -------- | ------------------------------------------- | | `description` | `string` | Human-readable label used in error messages | **Returns:** `Token` — a symbol carrying `T` as a phantom type parameter. **Example:** ```ts import { token } from '@vielzeug/conduit'; const Logger = token('Logger'); const Config = token('Config'); ``` --- ### `scope()` ```ts function scope(name: string): ScopeToken; ``` Creates a named scope token. Use as a `lifetime` in `factory()` to bind instances to a specific scope container created via `container.createScope(scopeToken)`. **Returns:** `ScopeToken` — a unique symbol used as a scope identifier. **Example:** ```ts import { scope, token, createContainer } from '@vielzeug/conduit'; const RequestScope = scope('request'); const Session = token('Session'); const root = createContainer(); root.factory(Session, () => ({ id: crypto.randomUUID() }), { lifetime: RequestScope }); // Per-request scope container const requestContainer = root.createScope(RequestScope); const session = await requestContainer.resolve(Session); ``` --- ### `createContainer()` ```ts function createContainer(opts?: { name?: string }): Container; ``` Creates a new root container with an empty registry. **Parameters:** | Option | Type | Default | Description | | ------ | -------- | -------- | ------------------------------------------------------------- | | `name` | `string` | `'root'` | Human-readable identifier for the container (shown in errors) | **Returns:** `Container` **Example:** ```ts import { createContainer } from '@vielzeug/conduit'; const container = createContainer({ name: 'app' }); ``` ## Container ### `container.value()` ```ts value(tok: Token, value: T, opts?: ValueOptions): this; ``` Registers a constant value. The value is returned as-is on every resolution. An optional `dispose` hook is always called on container disposal (regardless of whether the value was ever resolved). **Throws:** `ConduitDuplicateRegistrationError` if the token is already registered. **Parameters — `ValueOptions`:** | Option | Type | Default | Description | | --------- | ---------------------------------------- | ----------- | ---------------------- | | `dispose` | `(instance: T) => void \| Promise` | `undefined` | Called during disposal | **Returns:** `this` (chainable) **Example:** ```ts const Db = token('Db'); const db = await connectDb(); container.value(Db, db, { dispose: (db) => db.close() }); ``` --- ### `container.factory()` ```ts factory(tok: Token, fn: (resolver: FactoryResolver) => Promise | T, opts?: FactoryOptions): this; ``` Registers a lazy factory. The factory receives a `FactoryResolver` and runs on first resolution; its result is cached according to `lifetime`. **Throws:** `ConduitDuplicateRegistrationError` if the token is already registered. **Parameters — `FactoryOptions`:** | Option | Type | Default | Description | | ---------- | ------------------------------------------ | ------------- | ----------------------------------------------------------------------------------------------------- | | `deps` | `readonly Token[]` | `undefined` | Statically declared dependencies. Used by `freeze()` for early validation and static cycle detection. | | `lifetime` | `'singleton' \| 'transient' \| ScopeToken` | `'singleton'` | Caching strategy | | `dispose` | `(instance: T) => void \| Promise` | `undefined` | Called during disposal if the instance was resolved | **Returns:** `this` (chainable) **Example:** ```ts const Logger = token('Logger'); const Service = token }>('Service'); container.value(Logger, console); container.factory(Service, async (r) => { const logger = await r.resolve(Logger); return { run: async () => logger.log('ok') }; }); ``` #### Lifetimes | Value | Behavior | | ------------- | ---------------------------------------------------------------------------------------------------------- | | `'singleton'` | Factory runs once; the same instance is returned on every subsequent call. Shared across scope containers. | | `'transient'` | Factory runs on every resolution; result is never cached. | | `ScopeToken` | One instance per matching scope container (created via `createScope(scopeToken)`). | #### Singleton failure caching If a singleton factory rejects, the rejection is cached and rethrown on every subsequent `resolve()` call. The factory is **not** retried. To retry, create a new container. --- ### `container.has()` ```ts has(tok: Token): boolean; ``` Returns `true` if the token has a registered provider (walks the parent chain). Does not execute the factory. **Returns:** `boolean` **Throws:** `ConduitDisposedError` --- ### `loadModules()` (free function) ```ts function loadModules(container: Container, ...modules: ContainerModule[]): Promise; ``` Applies one or more `ContainerModule` functions to a container **sequentially**. Each module may be async; `loadModules()` awaits each in order. Returns `Promise` (the same container) for chaining. **Example:** ```ts import { createContainer, loadModules, token, type ContainerModule } from '@vielzeug/conduit'; const Logger = token('Logger'); const loggingModule: ContainerModule = (c) => { c.value(Logger, console); }; const container = await loadModules(createContainer(), loggingModule); ``` --- ### `container.resolve()` ```ts resolve(tok: Token): Promise; ``` Resolves the provider registered for `tok`. Concurrent calls for a singleton or scoped token share the same in-flight promise. **Returns:** `Promise` **Throws:** `ConduitProviderNotFoundError`, `ConduitCircularDependencyError`, `ConduitScopedResolutionError`, `ConduitDisposedError` --- ### `container.resolveSync()` ```ts resolveSync(tok: Token): T; ``` Resolves synchronously. Works for value providers (always) and singleton/scoped instances that have already been resolved at least once. **Returns:** `T` **Throws:** - `ConduitSyncResolutionError` — transient factory, or unresolved singleton/scoped instance - The **cached rejection** (original error) — if a singleton factory previously failed - `ConduitScopedResolutionError` — scoped token called on the root container - `ConduitProviderNotFoundError`, `ConduitDisposedError` **Recommended pattern:** ```ts // Warm all singletons once at startup await container.resolveAll(); // Then resolve synchronously in hot paths const config = container.resolveSync(Config); ``` --- ### `resolveSyncOptional()` (free function) ```ts function resolveSyncOptional(container: Container, tok: Token): T | undefined; ``` Resolves a token synchronously. Returns `undefined` when no provider is registered. Re-throws all other errors — including `ConduitSyncResolutionError` (unresolved singleton) and `ConduitDisposedError`. **Returns:** `T | undefined` **Example:** ```ts import { resolveSyncOptional } from '@vielzeug/conduit'; await container.resolveAll(); const plugin = resolveSyncOptional(container, OptionalPlugin); if (plugin) plugin.init(); ``` --- ### `resolveSyncOrDefault()` (free function) ```ts function resolveSyncOrDefault(container: Container, tok: Token, defaultValue: T): T; ``` Resolves a token synchronously. Returns `defaultValue` when no provider is registered. Equivalent to `resolveSyncOptional(container, tok) ?? defaultValue`. **Returns:** `T` **Example:** ```ts import { resolveSyncOrDefault } from '@vielzeug/conduit'; await container.resolveAll(); const timeout = resolveSyncOrDefault(container, RequestTimeout, 5000); ``` --- ### `resolveOptional()` (free function) ```ts function resolveOptional(container: Container, tok: Token): Promise; ``` Resolves the token when available. Returns `undefined` when no provider is registered. Re-throws all other errors including `ConduitDisposedError`. **Returns:** `Promise` --- ### `resolveOrDefault()` (free function) ```ts function resolveOrDefault(container: Container, tok: Token, defaultValue: T): Promise; ``` Resolves the token when available. Returns `defaultValue` when no provider is registered. Re-throws all other errors including `ConduitDisposedError`. **Returns:** `Promise` **Example:** ```ts import { resolveOrDefault } from '@vielzeug/conduit'; const Telemetry = token('Telemetry'); const noop: Telemetry = { track: () => {} }; const telemetry = await resolveOrDefault(container, Telemetry, noop); telemetry.track('app-start'); ``` --- ### `container.resolveAll()` ```ts resolveAll(opts?: { includeScoped?: boolean }): Promise; ``` Eagerly resolves factory registrations in parallel — including those inherited from parent containers. - By default, only **singleton** factories are resolved. Value registrations and transient factories are always skipped. - Pass `{ includeScoped: true }` to also pre-warm **named-scope** factories registered on the current scope container (i.e., the container must be a scope container tagged with the matching `ScopeToken`). Useful for: - **Startup validation** — fail fast if any factory throws - **Pre-warming** — populate the cache so `resolveSync()` is available immediately **Returns:** `Promise` **Throws:** `ConduitDisposedError` **Example:** ```ts const container = createContainer(); // ... register providers ... await container.resolveAll(); // warms all singletons const config = container.resolveSync(Config); // Pre-warm a named-scope container: const sc = root.createScope(RequestScope); await sc.resolveAll({ includeScoped: true }); // also warms RequestScope factories ``` --- ### `tryResolve()` (free function) ```ts function tryResolve(container: Container, tok: Token): Promise>; ``` Resolves a token, returning a discriminated union result object. Returns `{ ok: false, error }` **only** when the token is not registered (`ConduitProviderNotFoundError`). All other errors — including `ConduitDisposedError`, `ConduitCircularDependencyError`, and factory errors — are **re-thrown**. ```ts type ResolveResult = { ok: true; value: T } | { ok: false; error: unknown }; ``` **Returns:** `Promise>` **Example:** ```ts import { tryResolve } from '@vielzeug/conduit'; const result = await tryResolve(container, OptionalPlugin); if (result.ok) { result.value.init(); } ``` --- ### `trySyncResolve()` (free function) ```ts function trySyncResolve(container: Container, tok: Token): ResolveResult; ``` Synchronous equivalent of `tryResolve()`. Returns `{ ok: false, error }` **only** when the token is not registered. Re-throws `ConduitSyncResolutionError`, `ConduitDisposedError`, `ConduitScopedResolutionError`, and all other errors. **Returns:** `ResolveResult` **Example:** ```ts import { trySyncResolve } from '@vielzeug/conduit'; await container.resolveAll(); const result = trySyncResolve(container, OptionalPlugin); if (result.ok) { result.value.init(); } ``` --- ### `container.resolveMany()` ```ts resolveMany[]>(toks: D): Promise>; ``` Resolves multiple tokens in parallel and returns a typed tuple. Equivalent to `Promise.all(toks.map(t => container.resolve(t)))` but with full type inference. **Returns:** `Promise>` — a tuple typed to each token's `T`. **Throws:** `ConduitProviderNotFoundError`, `ConduitCircularDependencyError`, `ConduitDisposedError` (first rejection wins). **Example:** ```ts const [db, cache, logger] = await container.resolveMany([Db, Cache, Logger] as const); ``` --- ### `container.inspect()` ```ts inspect(opts?: { deep?: boolean }): ContainerGraph; ``` Returns a serializable description of every registered token. By default traverses the full parent chain (`deep: true`). Pass `{ deep: false }` to limit to this container's own registry. **Throws:** `ConduitDisposedError` **Parameters:** | Option | Type | Default | Description | | ------ | --------- | ------- | --------------------------------------------- | | `deep` | `boolean` | `true` | Whether to include parent chain registrations | **Returns:** `ContainerGraph` ```ts type ContainerNode = { /** Statically-declared dependency descriptions (from `deps:` option). */ deps?: string[]; description: string; kind: 'value' | 'factory'; /** 'singleton', 'transient', or 'scope:' for named-scope factories. */ lifetime?: 'singleton' | 'transient' | `scope:${string}`; }; type ContainerGraph = { nodes: ContainerNode[]; }; ``` **Example:** ```ts const graph = container.inspect(); for (const node of graph.nodes) { const depList = node.deps ? ` deps: [${node.deps.join(', ')}]` : ''; console.log(`${node.description} (${node.kind}, ${node.lifetime ?? 'singleton'})${depList}`); } ``` --- ### `container.validate()` ```ts validate(): this; ``` Runs the same static-dep validation as `freeze()` — checks that every token listed in `deps:` arrays is registered and that no `deps` cycle exists — **without** locking the container against further registrations. Useful in test setups or debug tooling where you want to catch wiring errors early but still need to add tokens afterward. **Returns:** `this` (chainable) **Throws:** - `ConduitDisposedError` — if the container has been disposed - `ConduitProviderNotFoundError` — if a declared `deps` dep is missing - `ConduitCircularDependencyError` — if declared `deps` form a cycle **Example:** ```ts const container = createContainer(); container.factory(Service, (r) => new Service(r.resolve(Logger)), { deps: [Logger] }); container.value(Logger, new ConsoleLogger()); container.validate(); // verifies deps are registered — does not freeze container.factory(OptionalFeature, (_r) => new Feature()); // still allowed ``` --- ### `container.freeze()` ```ts freeze(): this; ``` Locks the container against further `value()` or `factory()` calls. Before locking, runs a validation pass over statically-declared `deps`: 1. **Missing dep check** — every token listed in any `deps:` array must be registered. Throws `ConduitProviderNotFoundError` if a declared dep is missing. 2. **Static cycle detection** — checks `deps` graphs for cycles. Throws `ConduitCircularDependencyError` if a cycle is found. > **Note:** Lazy dependencies (accessed inside the factory via `resolver.resolve()`) that are _not_ listed in `deps:` are **not** checked by `freeze()`. Those cycles are caught at resolve time. Use `deps:` to opt in to early static validation. `freeze()` is **idempotent** — calling it more than once is a no-op after the first freeze. It is also **local** — scope containers created after `freeze()` are not frozen. **Returns:** `this` (chainable) **Throws:** - `ConduitDisposedError` — if the container has been disposed - `ConduitProviderNotFoundError` — if a declared `deps` dep is missing - `ConduitCircularDependencyError` — if declared `deps` form a cycle - `ConduitFrozenError` — on any subsequent `value()` or `factory()` call **Example:** ```ts const container = createContainer({ name: 'app' }); // Declare deps for early static validation: container.factory(Service, (r) => new Service(r.resolve(Logger)), { deps: [Logger] }); await loadModules(container, coreModule, dbModule); container.freeze(); // validates static deps + seals await container.resolveAll(); // pre-warm after freeze ``` --- ### `container.createScope()` ```ts createScope(scopeToken?: ScopeToken, opts?: { name?: string }): Container; ``` Creates a child container. When `scopeToken` is provided, the child is tagged with that scope — factories registered with this `ScopeToken` lifetime will resolve and cache within this container. Omitting `scopeToken` creates a plain child that inherits parent registrations. The auto-generated container name includes the scope token's description when no explicit `name` is given (e.g. `'root:request'` for a scope named `'request'`). Provide `opts.name` to override. **Returns:** `Container` **Throws:** `ConduitDisposedError` **Example:** ```ts const RequestScope = scope('request'); const Session = token('Session'); const root = createContainer({ name: 'root' }); root.factory(Session, () => ({ id: crypto.randomUUID() }), { lifetime: RequestScope }); // Each request gets its own scope container function handleRequest(id: string) { const requestContainer = root.createScope(RequestScope, { name: `req-${id}` }); return requestContainer.resolve(Session); } ``` --- ### `container.onResolve()` ```ts onResolve(interceptor: ResolveInterceptor): () => void; ``` Registers an interceptor called after every successful resolution — from both `resolve()` and `resolveSync()`. The interceptor receives the resolved `Token` and the resolved value. Errors thrown by the interceptor are silently swallowed. Interceptors **propagate up** to parent containers (same semantics as `on()`). **Returns:** An unsubscribe function. **Throws:** `ConduitDisposedError` — if the container has been disposed. **Example:** ```ts import { createContainer, token, type ResolveInterceptor } from '@vielzeug/conduit'; const Logger = token('Logger'); const container = createContainer(); const unsub = container.onResolve((tok, value) => { console.log(`Resolved: ${tok.description}`, value); }); await container.resolve(Logger); unsub(); ``` --- ### `container.on()` ```ts on(listener: ContainerEventListener): () => void; ``` Subscribes to container lifecycle events. The listener receives events synchronously when they occur. Errors thrown by listeners are silently swallowed to protect container operation. Events **propagate up** to parent container listeners — a listener on the root container observes all events from children and scopes. **Returns:** An unsubscribe function — call it to stop receiving events. **Throws:** `ConduitDisposedError` — if the container has been disposed. **Event types:** | Event type | Shape | When | | ------------ | --------------------------------------------------------------------------------------- | ---------------------------------------- | | `'register'` | `{ type: 'register', source: string, description: string, kind: 'value' \| 'factory' }` | `value()` or `factory()` is called | | `'resolve'` | `{ type: 'resolve', source: string, description: string }` | `resolve()` or `resolveSync()` completes | | `'dispose'` | `{ type: 'dispose', source: string }` | `dispose()` is called | The `source` field is the `name` of the container that emitted the event. **Example:** ```ts const unsubscribe = container.on((event) => { if (event.type === 'register') { console.log(`Registered ${event.description} (${event.kind})`); } if (event.type === 'resolve') { console.log(`Resolved ${event.description}`); } }); // Later: unsubscribe(); ``` --- ### `container.dispose()` ```ts dispose(): Promise; [Symbol.asyncDispose](): Promise; ``` Disposes the container. Runs all registered cleanup hooks in parallel. Factory hooks fire only for resolved instances. Value hooks always fire. Hook failures are **warned in dev** (via the internal warn channel) — they do not throw. Idempotent — multiple calls are safe. **Returns:** `Promise` **Example:** ```ts await container.dispose(); // With explicit resource management: await using container = createContainer(); // dispose() called automatically at block exit ``` --- ### `container.disposalSignal` ```ts get disposalSignal(): AbortSignal; ``` `AbortSignal` that is aborted when the container is disposed. Use it to tie the lifetime of external resources (e.g., SSE connections, polling loops) to the container. ```ts const container = createContainer(); const resource = startPolling({ signal: container.disposalSignal }); // When container.dispose() is called, resource automatically stops. ``` --- ### `container.disposed` ```ts get disposed(): boolean; ``` Returns `true` after `dispose()` has been called. All container operations throw `ConduitDisposedError` when `disposed` is `true`. ## Types ```ts /** Symbol carrying T as a phantom type. Created via token(). */ type Token = symbol & { __type?: T }; /** A named scope identifier. Created via scope(). */ type ScopeToken = symbol & { __scopeToken?: never }; /** Caching strategy for factory registrations. */ type Lifetime = 'singleton' | 'transient' | ScopeToken; /** Options for container.value(). */ type ValueOptions = { dispose?: (instance: T) => void | Promise; }; /** Options for container.factory(). */ type FactoryOptions = { /** Statically-declared dependencies for freeze() validation. */ deps?: readonly Token[]; dispose?: (instance: T) => void | Promise; lifetime?: Lifetime; }; /** * Infer the resolved-value tuple from a readonly token array. * Mirrors the return type of resolveMany(). */ type InferTokenTypes[]> = { [K in keyof T]: T[K] extends Token ? U : never; }; /** Interceptor called after every successful resolution. */ type ResolveInterceptor = (tok: Token, value: T) => void; /** Passed to each factory function — resolves other tokens lazily. */ interface FactoryResolver { resolve(tok: Token): Promise; /** Resolve synchronously. Works for value providers and already-resolved instances. */ resolveSync(tok: Token): T; } /** A function that registers providers on a container. May be async. */ type ContainerModule = (container: Container) => Promise | void; /** A lifecycle event emitted by the container. */ type ContainerEvent = | { description: string; kind: 'factory' | 'value'; source: string; type: 'register' } | { description: string; source: string; type: 'resolve' } | { source: string; type: 'dispose' }; /** Listener function for container events. */ type ContainerEventListener = (event: ContainerEvent) => void; /** Result type returned by tryResolve(). */ type ResolveResult = { ok: true; value: T } | { ok: false; error: unknown }; /** Serializable node in the dependency graph returned by inspect(). */ type ContainerNode = { /** Statically-declared dependency descriptions (from `deps:` option), if declared. */ deps?: string[]; description: string; kind: 'value' | 'factory'; /** 'singleton', 'transient', or 'scope:' for named-scope factories. */ lifetime?: 'singleton' | 'transient' | `scope:${string}`; }; /** Serializable graph returned by inspect(). */ type ContainerGraph = { nodes: ContainerNode[]; }; ``` ## Errors All conduit errors extend `ConduitError`. Use `instanceof ConduitError` to catch any conduit-originated error in one branch, or narrow further with specific subclass checks. | Error | When thrown | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `ConduitError` | **Base class** for all conduit errors — catch with `instanceof ConduitError` | | `ConduitCircularDependencyError` | `freeze()` detected a declared-dep cycle; lazy cycles detected at resolve time; message includes the full cycle path | | `ConduitProviderNotFoundError` | `resolve()` / `resolveSync()` / `freeze()` — token not registered; message includes container name | | `ConduitDuplicateRegistrationError` | `value()` or `factory()` called for a token that is already registered | | `ConduitSyncResolutionError` | `resolveSync()` called for a transient factory or an unresolved singleton factory | | `ConduitScopedResolutionError` | `resolve()` / `resolveSync()` called outside a matching named-scope container | | `ConduitDisposedError` | Any operation called after `dispose()` — message includes the container name | | `ConduitFrozenError` | `value()` or `factory()` called after `freeze()` — message includes the container name | ### Usage Guide ## Basic Usage Create a container, register providers with typed tokens, resolve dependencies, then dispose when done. ```ts import { createContainer, token } from '@vielzeug/conduit'; const Logger = token('Logger'); const Service = token }>('Service'); const container = createContainer(); container.value(Logger, console); container.factory(Service, async (r) => { const logger = await r.resolve(Logger); return { run: async () => logger.log('running') }; }); const service = await container.resolve(Service); await service.run(); await container.dispose(); ``` ## Tokens Create one token per dependency contract. Tokens are unique symbols — two tokens with the same description are still distinct. ```ts import { token } from '@vielzeug/conduit'; const Logger = token('Logger'); const Config = token('Config'); ``` ## Registration Use `value()` for constants and pre-constructed instances. Use `factory()` for anything built lazily. A token can only be registered once — `ConduitDuplicateRegistrationError` is thrown on a second registration for the same token. ```ts import { createContainer, token } from '@vielzeug/conduit'; const Logger = token('Logger'); const Service = token('Service'); const container = createContainer(); container.value(Logger, console); container.factory(Service, async (r) => { const logger = await r.resolve(Logger); return { run: () => logger.log('ok') }; }); ``` ## Container Modules Group related registrations into reusable modules. A `ContainerModule` is any function that registers providers on a container. ```ts import { createContainer, token, type ContainerModule } from '@vielzeug/conduit'; const Logger = token('Logger'); const Config = token('Config'); const loggingModule: ContainerModule = (c) => { c.value(Logger, console); }; const configModule: ContainerModule = (c) => { c.factory(Config, async () => { const res = await fetch('/config.json'); return res.json(); }); }; const container = await loadModules(createContainer(), loggingModule, configModule); ``` Import `loadModules` from `'@vielzeug/conduit'`. ## Resolution Call `resolve()` for a single provider. Use the free-function helpers when a missing token should not throw. ```ts import { createContainer, token, resolveOptional, resolveOrDefault } from '@vielzeug/conduit'; const Service = token('Service'); const Plugin = token('Plugin'); const container = createContainer(); const service = await container.resolve(Service); const maybePlugin = await resolveOptional(container, Plugin); // undefined if not registered const plugin = await resolveOrDefault(container, Plugin, defaultPlugin); // fallback if not registered ``` ## Checking Registration Use `has()` to test whether a token is registered without triggering the factory. ```ts if (container.has(FeatureFlag)) { const flags = await container.resolve(FeatureFlag); } ``` `has()` walks the parent chain, so child containers see parent registrations. ## Synchronous Resolution After a container has been warmed up asynchronously, registered values and cached singleton/scoped instances can be retrieved synchronously with `resolveSync()`. The recommended pattern is to call `resolveAll()` once at startup to pre-warm all singleton factories, then use `resolveSync()` freely in hot paths. ```ts // Warm all singletons once during async startup await container.resolveAll(); // Then resolve synchronously anywhere — no Promise overhead const config = container.resolveSync(Config); const logger = container.resolveSync(Logger); ``` `resolveSync()` throws `ConduitSyncResolutionError` for transient factories (never cached) and for unresolved singleton instances. It throws `ConduitScopedResolutionError` when called outside a matching named-scope container. If a singleton factory previously **failed**, `resolveSync()` rethrows the original cached rejection. > **Note:** `resolveAll()` only pre-warms `'singleton'` factories by default. Pass `{ includeScoped: true }` on a scope container to also pre-warm named-scope factories tagged to that scope. Transient factories are never pre-warmed. Use the free-function variants when a token may not be registered: ```ts import { resolveSyncOptional, resolveSyncOrDefault } from '@vielzeug/conduit'; await container.resolveAll(); const plugin = resolveSyncOptional(container, OptionalPlugin); // undefined if not registered const timeout = resolveSyncOrDefault(container, RequestTimeout, 5000); // 5000 if not registered ``` Both re-throw `ConduitSyncResolutionError`, `ConduitDisposedError`, and all other errors — only `ConduitProviderNotFoundError` is silenced. ## Lifetimes - `'singleton'` — factory runs once; the same instance is returned on every subsequent call (default). Shared across child containers. - `'transient'` — factory runs on every resolution; result is never cached. - `ScopeToken` — one instance per matching named-scope container; see [Named Scopes](#named-scopes) below. ### Singleton failure behavior If a singleton factory rejects, the rejection is cached and rethrown on every subsequent `resolve()` call. The factory is **not** silently retried. To retry, create a new container. ## Child Containers Use `createScope()` (without a scope token) to create a plain child container that inherits parent registrations. Use it for test isolation or per-request overrides. ```ts const child = container.createScope(); const session = await child.resolve(Session); await child.dispose(); ``` ## Named Scopes Named scopes give you explicit control over which child container owns a lifecycle. Create a `ScopeToken` with `scope()`, register factories with that token as the `lifetime`, then create scope containers with `createScope()`. ```ts import { scope, token, createContainer } from '@vielzeug/conduit'; const RequestScope = scope('request'); const Session = token('Session'); const root = createContainer(); root.factory(Session, () => ({ id: crypto.randomUUID() }), { lifetime: RequestScope }); // Each request gets its own scope container with isolated instances async function handleRequest() { const requestContainer = root.createScope(RequestScope); const session = await requestContainer.resolve(Session); // session is unique to this request scope await requestContainer.dispose(); // runs Session dispose hook } ``` Resolving a named-scope factory from a container that is not a matching scope (or one of its descendants) throws `ConduitScopedResolutionError` with the required scope name. ## Async Providers Factories may return promises. Concurrent callers share the same in-flight singleton or scoped resolution — the factory runs exactly once even if `resolve()` is called concurrently. ```ts container.factory(Config, async () => { const response = await fetch('/config.json'); return response.json(); }); ``` ## Resolving Without Throwing Use `tryResolve()` (free function) to resolve a token as a discriminated union instead of throwing. It returns `{ ok: false, error }` **only** when the token is not registered — all other errors (factory failures, `ConduitDisposedError`, circular deps) are re-thrown: ```ts import { tryResolve, trySyncResolve } from '@vielzeug/conduit'; const result = await tryResolve(container, OptionalPlugin); if (result.ok) { result.value.init(); } // After resolveAll(), use the synchronous variant: await container.resolveAll(); const syncResult = trySyncResolve(container, OptionalPlugin); if (syncResult.ok) { syncResult.value.init(); } ``` > **Note:** `trySyncResolve()` re-throws `ConduitSyncResolutionError` and `ConduitDisposedError` — it only swallows `ConduitProviderNotFoundError`. Use `resolveMany()` to resolve multiple tokens in parallel with a typed tuple result: ```ts const [db, cache, logger] = await container.resolveMany([Db, Cache, Logger] as const); ``` ## Freezing Containers Call `freeze()` after all registrations are complete. It validates the graph, then locks the container: ```ts const container = createContainer({ name: 'app' }); await loadModules(container, dbModule, authModule, serviceModule); container.freeze(); // validates declared deps + seals // Later in application code: container.value(SomeToken, x); // throws ConduitFrozenError: Container 'app' is frozen ... ``` `freeze()` is **idempotent** — calling it multiple times is safe and a no-op after the first call. Declare `deps` on a factory to enable static validation at freeze time: ```ts const Logger = token('Logger'); const Service = token('Service'); container.value(Logger, new ConsoleLogger()); container.factory(Service, (r) => new Service(r.resolve(Logger)), { deps: [Logger] }); container.freeze(); // → throws ConduitCircularDependencyError if deps form a cycle // → throws ConduitProviderNotFoundError if a declared dep is missing ``` > Lazy dependencies not listed in `deps:` are still caught at resolve time — declaring deps is opt-in. ## Named Containers Assign names to containers for clearer error messages: ```ts const root = createContainer({ name: 'root' }); const child = root.createScope(undefined, { name: 'child-42' }); const scope = root.createScope(RequestScope, { name: 'scope-42' }); // Error messages will include the container name: // ConduitProviderNotFoundError: No provider registered for token: MyToken (in container 'child-42') ``` ## Cycle Detection By default, cycle detection runs lazily at resolve time. To catch cycles earlier, declare `deps:` on factories and call `freeze()` — this enables static cycle detection before any resolution occurs. ## Inspecting the Container `container.inspect()` returns a serializable graph of registered tokens. By default it traverses the full parent chain. Pass `{ deep: false }` to limit to the local registry. Declared `deps` are included in each `ContainerNode.deps` (as token description strings) when present. ```ts const graph = container.inspect(); // deep traversal (default) const local = container.inspect({ deep: false }); // local only for (const node of graph.nodes) { const depList = node.deps ? ` → [${node.deps.join(', ')}]` : ''; console.log(`${node.description} (${node.kind}, ${node.lifetime ?? 'singleton'})${depList}`); } ``` ## Disposal Dispose the container when its scope ends. ```ts await container.dispose(); ``` Disposal hooks are supported on both `factory()` and `value()` registrations. Factory hooks fire only when the instance was resolved at least once; value hooks always fire. ```ts // Factory with cleanup container.factory(DbPool, () => createPool(), { dispose: (pool) => pool.end(), }); // External resource registered as a value const db = await connectDb(); container.value(Db, db, { dispose: (db) => db.close() }); await container.dispose(); // calls both hooks ``` If any hook throws, the container still disposes fully. Failures are **warned** (dev-only) — they do not throw or reject `dispose()`. Use the `await using` pattern (explicit resource management) to ensure disposal even on early return or thrown error: ```ts await using container = createContainer(); // container.dispose() is called automatically at block exit ``` ## Resolution Interceptors Use `onResolve()` to register a callback fired after every successful resolution (both async and sync). Useful for telemetry, logging, and debugging. ```ts const unsub = container.onResolve((tok, value) => { metrics.increment('di.resolve', { token: tok.description }); }); // Stop intercepting: unsub(); ``` Interceptor errors are swallowed — a misbehaving interceptor cannot break resolution. Interceptors propagate to parent containers. ## Observing Container Events Subscribe to container lifecycle events with `on()` for logging, metrics, or debugging. Each event carries a `source` field containing the container name. ```ts const unsubscribe = container.on((event) => { if (event.type === 'register') { console.log(`[${event.source}] registered ${event.description} (${event.kind})`); } if (event.type === 'resolve') { console.log(`[${event.source}] resolved ${event.description}`); } if (event.type === 'dispose') { console.log(`[${event.source}] container disposed`); } }); // Stop listening: unsubscribe(); ``` Events propagate up to parent listeners. Listener errors are silently swallowed. ## Framework Integration Conduit's container is a plain object — use it as a singleton, inject it via framework context, or scope it to a component tree. ```tsx [React] import { createContext, useContext, useEffect, useState, type FC } from 'react'; import { createContainer, type Container } from '@vielzeug/conduit'; const ContainerCtx = createContext(null); export const ContainerProvider: FC = ({ children }) => { const [container] = useState(() => createContainer()); useEffect( () => () => { container.dispose(); }, [container], ); return {children}; }; export function useContainer(): Container { const c = useContext(ContainerCtx); if (!c) throw new Error('useContainer must be used within ContainerProvider'); return c; } ``` ```vue [Vue 3] import { createContainer, type Container } from '@vielzeug/conduit'; import { provide, inject, onScopeDispose } from 'vue'; // In root component (App.vue): const container = createContainer(); provide('container', container); onScopeDispose(() => container.dispose()); // In child components: const container = inject('container')!; const repo = await container.resolve(UserRepo); ``` ```svelte [Svelte] import { createContainer, type Container } from '@vielzeug/conduit'; import { setContext, getContext, onDestroy } from 'svelte'; // Root component: const container = createContainer(); setContext('container', container); onDestroy(() => container.dispose()); ``` ## Working with Other Vielzeug Libraries ### With Rune Inject a shared logger into all services. ```ts import { createContainer, token } from '@vielzeug/conduit'; import { createLogger } from '@vielzeug/rune'; const Logger = token>('Logger'); const container = createContainer(); container.factory(Logger, () => createLogger({ level: 'debug', prefix: 'app' })); ``` ### With Herald Register an event bus and inject it into services that emit or subscribe. ```ts import { createContainer, token } from '@vielzeug/conduit'; import { type Bus, createBus } from '@vielzeug/herald'; const EventBus = token('EventBus'); const container = createContainer(); container.factory(EventBus, () => createBus(), { dispose: (bus) => bus.clear() }); const NotificationService = token('NotificationService'); container.factory(NotificationService, async (r) => { const bus = await r.resolve(EventBus); return { notify: (msg) => bus.emit('notification', msg) }; }); ``` ## Best Practices - Register all providers at startup before any resolution begins. - Group registrations into `ContainerModule` functions. Use `loadModules()` for sequential async setup. - Call `freeze()` after all modules are loaded — it validates the graph and locks the container in one step. - Declare `deps:` on factories to enable static cycle detection at `freeze()` time. - Use `resolveAll()` once at startup, then `resolveSync()` in hot paths. - Use `tryResolve()` or `trySyncResolve()` when optional providers are expected to be absent — both only swallow `ConduitProviderNotFoundError`; use `resolveOptional()` for one-off nullable checks; use `resolveOrDefault()` when a concrete fallback value is available. - In hot paths after `resolveAll()`, prefer `resolveSyncOptional()` or `resolveSyncOrDefault()` over wrapping `resolveSync()` in try/catch. - Use `resolveMany()` to resolve multiple well-known providers at startup in parallel. - Use `onResolve()` for observability (telemetry, logging) rather than coupling resolution paths to event parsing. - Scope named-scope containers to request or component lifetimes — dispose them with the scope. - Attach `dispose` hooks to both factory and value registrations for external resources that need cleanup. - Call `container.dispose()` during app teardown to invoke all registered cleanup hooks. ### Examples ## Examples - [Basic Setup](./examples/basic-setup.md) - [Async Providers](./examples/async-providers.md) - [Lifetimes](./examples/lifetimes.md) - [Named Scopes](./examples/named-scopes.md) - [Batch Resolution](./examples/batch-resolution.md) - [Child Containers](./examples/child-containers.md) - [Sync Resolution](./examples/sync-resolution.md) - [Dispose Lifecycle](./examples/dispose-lifecycle.md) - [Startup Hardening](./examples/startup-hardening.md) - [Observing Events](./examples/observing-events.md) ### REPL Examples - Basic Container (id: `basic-container`) - Child Containers (id: `child-containers`) - Factory Providers (id: `class-provider`) - Dispose Lifecycle (id: `dispose-lifecycle`) - freeze() and ConduitFrozenError (id: `freeze`) - has() and resolveSync() (id: `has-and-sync`) - Provider Lifetimes (id: `lifetimes`) - resolveOptional / resolveOrDefault / tryResolve (id: `resolve-optional`) - Named Scope Isolation (id: `scoped-execution`) - resolveSyncOptional / resolveSyncOrDefault hot path (id: `sync-hot-path`) - Testing with Child Containers (id: `testing`) - trySyncResolve + FactoryResolver.resolveSync (id: `try-sync-resolve`) - Cycle Detection (freeze()) (id: `validate`) --- ## @vielzeug/courier **Category:** http **Keywords:** http-client, fetch, caching, deduplication, mutations, query-cache, rest, sse, streaming, interceptors, persist, batcher **Key exports:** createApi, createCourier, createMutation, createQuery, createStream, CourierError, CourierHttpError, CourierNetworkError, CourierTimeoutError, CourierAbortError, CourierSchemaValidationError, bindRefetch (+7 more) **Related:** spell, ripple, vault ### Overview ## Why Courier? Native `fetch` is excellent but low-level. Courier adds typed path params, a query cache, tracked mutations, SSE, readable streaming, and a shared interceptor pipeline without external dependencies. ```ts // Before — raw fetch const res = await fetch(`https://api.example.com/users/${userId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const user: User = await res.json(); // After — Courier const client = createCourier({ baseUrl: 'https://api.example.com' }); const user = await client.api.get('/users/{id}', { params: { id: userId } }); ``` | Feature | Courier | TanStack Query v5 | axios | ky | | --------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------- | -------------------------------------- | | Bundle size | | ~14 kB (core) + framework adapter | ~26 kB | ~5 kB | | Built on | fetch | Bring your own fetch | XMLHttpRequest | fetch | | Type-safe path params | | | Manual | Manual | | Query cache | | | | | | Built-in HTTP client | | | | | | SSE + streaming | | | | | | Standalone mutations | | | | | | Zero dependencies | | Framework adapter peer dep required | | | **Use Courier when** your app needs typed HTTP, a query cache, tracked mutations, or SSE — especially when you want all of these sharing one interceptor pipeline and zero extra dependencies. **Consider TanStack Query when** you already have a separate HTTP client you are happy with, need deep React/Vue/Svelte DevTools integration, or need advanced features like infinite scroll queries out of the box. **Consider axios when** you need to support IE11 or other XMLHttpRequest-based environments, or you already have a large axios-specific codebase. ## Installation ```sh [pnpm] pnpm add @vielzeug/courier ``` ```sh [npm] npm install @vielzeug/courier ``` ```sh [yarn] yarn add @vielzeug/courier ``` ## Quick Start ```ts import { createCourier } from '@vielzeug/courier'; type NewUser = { name: string }; type User = { id: number; name: string }; const client = createCourier({ baseUrl: 'https://api.example.com', query: { staleTime: 30_000 }, }); const user = await client.query.fetch({ key: ['users', 42], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id: 42 }, signal }), }); const createUser = client.mutation((input: NewUser, signal) => client.api.post('/users', { body: input, signal }), ); const nextUser = await createUser.mutate({ name: 'Alice' }); client.query.set(['users', nextUser.id], nextUser); client.query.invalidate(['users']); ``` ## Features - **Unified client** — `createCourier()` combines `api`, `stream`, `query`, and `mutation()` behind one shared transport - **HTTP client** — `createApi()` with base URL, global headers, interceptors, timeout, deduplication, and `cancelAll()` - **SSE** — `createStream().sse()` with typed events, `Last-Event-ID` reconnects, and shared interceptors - **Readable HTTP streams** — `stream.readable()` for raw text or NDJSON chunk parsing - **Query cache** — `createQuery()` with `fetch()`, prefix invalidation, background revalidation, and stable query keys - **SyncStore integration** — `query.observe()` (watch + fetch in one call), `query.observeMany()`, and `mutation.store` work with React, Vue, and Svelte adapters; `observe()` accepts `placeholderData`, `select`, and `fetch: false` via `ObserveOptions`; `toSyncStore()` adapts any `peek()`/`subscribe()` source for framework bindings - **Standalone mutations** — `createMutation()` with retry, lifecycle callbacks, cancellation, and observable state - **Request deduplication** — idempotent requests dedupe by method + URL + response type, with `dedupe: false` to opt out - **DataLoader-style batcher** — coalesce N individual `load()` calls into one batch via the internal batcher API - **Interceptor presets** — `withBearerAuth()`, `withRequestId()`, and `withLogging()` ready to plug in via `use()` - **Focus/reconnect binding** — `bindRefetch(qc)` wires up tab visibility and network events; fully opt-in - **Cache persistence** — `persistQueryCache()` and `hydrateQueryCache()` for cross-reload cache survival - **Structured errors** — distinct `CourierHttpError`, `CourierNetworkError`, `CourierTimeoutError`, and `CourierAbortError` classes for precise handling - **Disposable** — clients implement `[Symbol.dispose]` for deterministic cleanup - **Debug logging** via `debugCourier()` (`@vielzeug/courier/devtools`) — pre-wires `withLogging()`; tree-shaken from production bundles ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Spell](/spell/) — validate HTTP response payloads against typed schemas before they enter your cache - [Forge](/forge/) — pair with Courier mutations to manage typed form state and submission - [Ripple](/ripple/) — use signal stores as a reactive layer on top of Courier's `SyncStore` API ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ----------------------- | ------------------------------------------------------ | -------------- | -------------------------------------------------------------------- | | `createApi()` | Create an HTTP client with defaults and interceptors | Sync | Uses `TransportOptions`, not `ApiClientOptions` | | `createQuery()` | Create cache/query orchestration utilities | Sync | Use `fetch()`, not `query()` | | `createMutation()` | Create tracked write handles with cancellation | Sync | `times: 1` means no retries; lifecycle callbacks receive `variables` | | `createCourier()` | Create a unified client with shared transport | Sync | REST timeout defaults to 30s; streams default to `Infinity` | | `createStream()` | Open SSE or readable HTTP streams | Sync | `reconnect: true` means up to 5 reconnects after a failure | | `bindRefetch()` | Opt-in focus/reconnect revalidation binding | Sync | Returns unbind fn; call it on cleanup | | `withBearerAuth()` | Interceptor preset for Bearer token injection | Sync | Accepts static string or async token factory | | `withRequestId()` | Interceptor preset adding a unique request ID header | Sync | Defaults to `x-request-id` with `crypto.randomUUID()` | | `withLogging()` | Interceptor preset logging method/URL/status/ms | Sync | Defaults to `console.debug`; override with `logger` option | | `toSyncStore()` | Convert any `peek()`/`subscribe()` source to `SyncStore` | Sync | Useful for framework adapters that accept a `SyncStore` directly | | `createBatcher()` | DataLoader-style batcher coalescing `load()` calls | Sync | Exactly one of `resolve`/`resolveSettled` must be provided | | `persistQueryCache()` | Subscribe to cache and write successful entries | Sync | Eagerly persists existing successful entries on setup | | `hydrateQueryCache()` | Read persisted entries and seed the cache | Async | Runs all keys in parallel; restores original `updatedAt` | | `debugCourier()` | Create a `Courier` with logging pre-wired (dev only) | Sync | Import from `@vielzeug/courier/devtools`, not the main entry point | | `CourierError` | Base class for all courier errors | — | `CourierError.is(e)` catches any courier error; narrow further after | | `CourierHttpError` | Structured non-2xx HTTP error with status + body | — | Use `CourierHttpError.is(err, status?)` for narrowing | | `CourierNetworkError` | Connection-level failure (no response received) | — | Use `instanceof CourierNetworkError` for narrowing | | `CourierTimeoutError` | Request timed out via transport or `AbortSignal` | — | Use `instanceof CourierTimeoutError` for narrowing | | `CourierAbortError` | Request was cancelled via `cancel()` or signal | — | Use `instanceof CourierAbortError` for narrowing | | `CourierSchemaValidationError` | Thrown when `schema.parse()` rejects the response body | — | Wraps the original parse error; `data` holds the raw pre-parse body | | `CourierDisposedError` | Thrown by any primitive's primary method after `dispose()` | — | Use `instanceof CourierDisposedError` for narrowing | | `CourierBatcherError` | Thrown when a batcher's `resolve`/`resolveSettled` misbehaves | — | Covers wrong result-array length and synchronous throws | | `CourierParseError` | Thrown when a response body or path param cannot be parsed | — | Use `instanceof CourierParseError` for narrowing | ## Package Entry Points | Import | Purpose | | ----------------------------- | -------------------------------------------- | | `@vielzeug/courier` | Main API and types | | `@vielzeug/courier/devtools` | `debugCourier` — request logger (dev only) | ## Core Functions ### `createApi()` ```ts createApi(opts?: TransportOptions): ApiClient; ``` Creates an HTTP client. Use `createCourier()` to share one transport across REST, streams, and the query cache. **Returns:** `ApiClient` **Parameters — `TransportOptions`:** | Option | Type | Default | Description | | --------- | ------------------------- | ------------------ | -------------------------------------------------- | | `baseUrl` | `string` | `''` | Base URL prepended to every request | | `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Optional custom fetch implementation | | `headers` | `Record` | `{}` | Default headers sent with every request | | `timeout` | `number` | `30000` | Request timeout in ms; must be `> 0` or `Infinity` | **Methods:** | Method | Signature | Description | | ------------------ | -------------------------------------------------------- | --------------------------------------------------------------------- | | `get` | `(url: P, cfg?) => Promise` | GET request | | `post` | `(url: P, cfg?) => Promise` | POST request | | `put` | `(url: P, cfg?) => Promise` | PUT request | | `patch` | `(url: P, cfg?) => Promise` | PATCH request | | `delete` | `(url: P, cfg?) => Promise` | DELETE request | | `request` | `(method, url: P, cfg?) => Promise` | Custom HTTP method | | `cancelAll` | `() => void` | Abort every active request without disposing the client | | `getHeaders` | `() => Readonly>` | Returns a **snapshot copy** — mutating it has no effect on the client | | `headers` | `(updates: Record) => void` | Update global headers; `undefined` removes a header | | `use` | `(interceptor: Interceptor) => () => void` | Add an interceptor; returns a dispose function | | `disposalSignal` | `AbortSignal` (getter) | Aborted when `dispose()` is called; use to tie external lifetimes | | `dispose` | `() => void` | Dispose the underlying transport when owned by this client | | `disposed` | `boolean` (getter) | Whether `dispose()` has been called | | `[Symbol.dispose]` | — | Delegates to `dispose()`; enables `using` declarations | **Example:** ```ts import { createApi } from '@vielzeug/courier'; const api = createApi({ baseUrl: 'https://api.example.com', timeout: 30_000 }); const user = await api.get('/users/{id}', { params: { id: 1 } }); ``` --- ### `createQuery()` ```ts createQuery(options?: QueryClientOptions): QueryClient; ``` Creates a query client with caching, deduplication, prefix invalidation, and reactive subscriptions. `fetch()` always throws on error. Use `observe()` for reactive subscriptions that surface errors as store state. **Returns:** `QueryClient` **Parameters — `QueryClientOptions`:** | Option | Type | Default | Description | | ------------- | ------------------------------- | ----------- | ------------------------------------------------------------------------------ | | `staleTime` | `number` | `0` | ms a successful entry is served from cache before the next `fetch()` refetches | | `gcTime` | `number` | `300000` | ms before an unobserved cache entry is collected; `Infinity` disables GC | | `times` | `number` | `1` | Total attempts per fetch; `1` means a single try with no retries | | `delay` | `number \| (attempt) => number` | full jitter | Delay between retries; `attempt` is **zero-based** (0 = before the 2nd try) | | `shouldRetry` | `(error, attempt) => boolean` | — | Return `false` to stop retrying; `attempt` is **zero-based** | **Methods:** | Method | Signature | Description | | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `fetch` | `(options: QueryOptions) => Promise` | Fetch with caching, deduplication, and retry; always throws on error | | `fetchMany` | `(queries: QueryOptions[]) => Promise` | Parallel fetch for multiple keys; throws if any query fails | | `observe` | `(options: ObserveOptions) => SyncStore>` | Return a store and trigger a background fetch; pass `fetch: false` to skip the fetch | | `get` | `(key) => T \| undefined` | Read cached data | | `set` | `(key, data \| updater, opts?) => void` | Set or update cached data; `opts.updatedAt` restores a historical timestamp | | `getState` | `(key) => QueryState \| null` | Full state snapshot | | `observeMany` | `(keys: QueryKey[]) => SyncStore[]>` | Observe multiple keys as one combined store; updates on any key change | | `invalidate` | `(key) => void` | Evict or background-revalidate a key/prefix | | `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to idle if active | | `cancel` | `(key) => void` | Cancel an in-flight fetch; entry returns to `'loading'` or retains prior success data | | `clear` | `() => void` | Clear all entries; active subscribers see `'loading'` | | `refetchStale` | `() => void` | Manually revalidate all stale observed entries | | `keys` | `() => QueryKey[]` | Returns all currently cached keys — useful for SSR serialization | | `size` | `number` (getter) | Number of entries currently held in the cache | | `cancelAll` | `() => void` | Abort all in-flight cache fetches without disposing the client | | `disposalSignal` | `AbortSignal` (getter) | Aborted when `dispose()` is called; use to tie external lifecycles | | `dispose` | `() => void` | Cancel all in-flight requests and clear all timers | | `disposed` | `boolean` (getter) | Whether `dispose()` has been called | | `[Symbol.dispose]` | — | Delegates to `dispose()` | **Example:** ```ts import { createQuery } from '@vielzeug/courier'; const qc = createQuery({ staleTime: 30_000 }); const user = await qc.fetch({ key: ['users', 1], fn: ({ signal }) => api.get('/users/{id}', { params: { id: 1 }, signal }), }); ``` --- ### `createMutation()` ```ts createMutation( fn: (input: TVariables, signal: AbortSignal) => Promise, options?: MutationOptions, ): Mutation; ``` Creates a standalone, observable mutation handle. **Returns:** `Mutation` **`MutationOptions`:** | Option | Type | Default | Description | | ----------------- | --------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | | `times` | `number` | `1` | Total attempts; `1` means a single try with no retries | | `delay` | `number \| (attempt) => number` | full jitter | Delay between retries; `attempt` is **zero-based** (0 = before the 2nd try) | | `shouldRetry` | `(error, attempt) => boolean` | — | Return `false` to skip retrying; `attempt` is **zero-based** | | `onSuccess` | `(data: TData, variables: TVariables) => void \| Promise` | — | Called after a successful run | | `onError` | `(error: Error, variables: TVariables) => void \| Promise` | — | Called after a failed run; **not** called on abort | | `onFinally` | `(variables: TVariables) => void \| Promise` | — | Called after every run (success, error, abort) before `onSettled`; use for cleanup that does not need the result | | `onSettled` | `(result: SettledResult) => void \| Promise` | — | Called after every run; switch on `result.status` (`'success'`, `'error'`, `'aborted'`) for exhaustive handling | | `onCallbackError` | `(error: Error) => void` | — | Called when any lifecycle callback throws; does not affect `mutate()` result | **Mutation methods:** | Method | Signature | Description | | ------------------ | -------------------------------------------- | ------------------------------------------------------- | | `mutate` | `(variables, opts?) => Promise` | Execute a run | | `cancel` | `() => Promise` | Abort the active run and wait for it to settle | | `peek` | `() => MutationState` | Read current state snapshot | | `subscribe` | `(cb: () => void) => () => void` | Subscribe to state changes; returns unsubscribe fn | | `store` | `SyncStore>` (property) | Framework-friendly external store; stable reference | | `reset` | `() => void` | Reset back to the idle state | | `dispose` | `() => void` | Abort active run, clear observers, and mark as disposed | | `disposed` | `boolean` (getter) | Whether `dispose()` has been called | | `[Symbol.dispose]` | — | Delegates to `dispose()`; enables `using` declarations | **Example:** ```ts import { createMutation } from '@vielzeug/courier'; const addUser = createMutation( (input: NewUser, signal: AbortSignal) => api.post('/users', { body: input, signal }), { onSuccess: (user, variables) => { qc.set(['users', user.id], user); console.log('Created user with input:', variables); }, onError: (err, variables) => console.error('Failed for input:', variables, err), onSettled: (result) => { if (result.status === 'success') console.log('Done:', result.data); else if (result.status === 'error') console.error('Error:', result.error); // result.status === 'aborted' — user cancelled }, }, ); await addUser.mutate({ name: 'Alice' }); ``` When multiple `mutate()` calls run simultaneously, state updates reflect the **latest** call. Lifecycle callbacks (`onSuccess`, `onError`, `onSettled`) fire independently for **every** call — not just the last one. Use `mutation.cancel()` before calling `mutate()` again if you need last-call-wins semantics. `mutate()` throws `CourierDisposedError` if called after `dispose()` — the mutation function never runs and no lifecycle callbacks fire, matching `createApi`/`createQuery`/`createStream`. --- ### `createCourier()` ```ts createCourier(opts?: CourierOptions): Courier; ``` Creates a unified Courier client backed by one shared transport. **Returns:** `Courier` **`CourierOptions`:** | Option | Type | Default | Description | | ------------------ | --------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `baseUrl` | `string` | `''` | Shared base URL for `api` and `stream` | | `fetch` | `typeof fetch` | global | Shared fetch implementation | | `headers` | `Record` | `{}` | Shared global headers | | `timeout` | `number` | `30000` | REST timeout; streaming connections still default to `Infinity` | | `query` | `QueryClientOptions` | — | Defaults for the embedded query client | | `mutationDefaults` | `Pick` | — | Retry/error-handling defaults merged into every `mutation()` call; lifecycle callbacks are per-mutation since they receive typed variables | **Courier interface:** | Property / Method | Type / Signature | Description | | ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------- | | `api` | `ApiClient` | Shared REST client | | `stream` | `StreamClient` | Shared SSE/readable stream client | | `query` | `QueryClient` | Embedded query client | | `mutation` | `(fn, opts?) => Mutation` | Create a mutation; accepts `invalidates` and `sets` cache shorthands in addition to `MutationOptions` | | `use` | `(interceptor) => () => void` | Register an interceptor shared by `api` and `stream` | | `headers` | `(updates) => void` | Update shared global headers | | `cancelAll` | `() => void` | Abort all active transport-backed requests and streams | | `disposalSignal` | `AbortSignal` | Aborted when the client is disposed. Use to tie external lifetimes to this client. | | `dispose` | `() => void` | Dispose the transport and embedded query client. Idempotent. | | `disposed` | `boolean` | `true` after `dispose()` is called | | `[Symbol.dispose]` | — | Delegates to `dispose()` | **Example:** ```ts import { createCourier } from '@vielzeug/courier'; const client = createCourier({ baseUrl: 'https://api.example.com', query: { staleTime: 30_000 }, }); const user = await client.query.fetch({ key: ['users', 1], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id: 1 }, signal }), }); ``` --- ### `createStream()` ```ts createStream(opts?: TransportOptions): StreamClient; ``` Creates a streaming client for SSE and readable HTTP responses. Use `createCourier()` to share one transport across REST, streams, and the query cache. **Returns:** `StreamClient` **Stream methods:** | Method | Signature | Description | | ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------- | | `sse` | `(url, opts?) => SseSource` | Open a Server-Sent Events connection | | `readable` | `(url, opts?) => AsyncGenerator` | Stream text or NDJSON chunks | | `cancelAll` | `() => void` | Abort every active SSE or readable stream | | `getHeaders` | `() => Readonly>` | Read current global headers | | `headers` | `(updates: Record) => void` | Update shared global headers | | `use` | `(interceptor: Interceptor) => () => void` | Add an interceptor shared by all stream requests | | `disposalSignal` | `AbortSignal` (getter) | Aborted when `dispose()` is called; use to tie external lifetimes | | `dispose` | `() => void` | Dispose the underlying transport when owned by this client | | `disposed` | `boolean` (getter) | Whether `dispose()` has been called | | `[Symbol.dispose]` | — | Delegates to `dispose()` | **Example:** ```ts import { createStream } from '@vielzeug/courier'; const stream = createStream({ baseUrl: 'https://api.example.com' }); const source = stream.sse('/events', { reconnect: true }); source.on('message', (data) => console.log(data.text)); source.dispose(); ``` ## Errors ### `CourierError` Base class for all courier errors. Catch with `CourierError.is(e)` to handle any courier error in one branch, then narrow further. - `name`: `'CourierError'` (overridden by subclasses to their own class name) - `message`: not auto-prefixed by the base class — classified request/response errors (`CourierHttpError`, `CourierNetworkError`, `CourierTimeoutError`, `CourierAbortError`, `CourierSchemaValidationError`) carry the underlying platform/response message as-is; internally-thrown validation and disposal errors (e.g. `CourierDisposedError`) include a `[courier]` prefix - Static helper: `CourierError.is(err)` — inherited by every subclass; only checks `instanceof CourierError`, so it does **not** narrow to a specific subclass (use `instanceof ` for that, or `CourierHttpError.is(err, status?)` for status-code narrowing) Hierarchy: ``` CourierError ├── CourierHttpError — non-2xx response (has status + body) ├── CourierNetworkError — connection failed, no response received ├── CourierTimeoutError — request aborted by timeout ├── CourierAbortError — request cancelled via signal or cancel() ├── CourierSchemaValidationError — schema.parse() rejected the response body ├── CourierDisposedError — a primitive's primary method was called after dispose() ├── CourierBatcherError — a batcher's resolve()/resolveSettled() misbehaved └── CourierParseError — a response body or path param could not be parsed ``` ### `CourierHttpError` Thrown for non-2xx HTTP responses. Carries the full response metadata. - `status`, `method`, `url`, `data`, `headers` - Static helpers: `fromResponse()`, `is(err, status?)` ```ts import { CourierHttpError } from '@vielzeug/courier'; try { await api.get('/users/1'); } catch (err) { if (CourierHttpError.is(err, 404)) console.log('Not found'); else if (CourierHttpError.is(err)) { console.log(err.status, err.method, err.url); console.log(err.headers?.get('x-request-id')); } } ``` ### `CourierNetworkError` Thrown when the connection fails before any response is received (e.g. DNS failure, refused connection). - `method`, `url`, `cause` - Use `instanceof CourierNetworkError` for narrowing. ### `CourierTimeoutError` Thrown when the request is aborted by the timeout (transport-level or via a timeout `AbortSignal`). - `method`, `url`, `cause` - Use `instanceof CourierTimeoutError` for narrowing. ### `CourierAbortError` Thrown when the request is cancelled explicitly via `cancel()`, `cancelAll()`, or an external `AbortSignal`. - `method`, `url` - Use `instanceof CourierAbortError` for narrowing. ```ts import { CourierAbortError, CourierHttpError, CourierNetworkError, CourierTimeoutError } from '@vielzeug/courier'; try { await api.get('/data'); } catch (err) { if (CourierHttpError.is(err, 404)) console.log('Not found'); else if (err instanceof CourierTimeoutError) console.log('Timed out'); else if (err instanceof CourierAbortError) console.log('Cancelled'); else if (err instanceof CourierNetworkError) console.log('Network failure:', (err as CourierNetworkError).cause); } ``` ### `CourierSchemaValidationError` Thrown when `schema.parse()` rejects the parsed response body. - `name`: `'CourierSchemaValidationError'` - `data`: the raw (pre-validation) response body - `cause`: the original error thrown by `schema.parse()` - Use `instanceof CourierSchemaValidationError` for narrowing — `static is()` exists only on the base `CourierError` (any-courier-error check) and on `CourierHttpError` (status-code narrowing); it is inherited, not overridden, by every other subclass, so `CourierSchemaValidationError.is(err)` would match **any** courier error, not just this one. ```ts import { CourierSchemaValidationError } from '@vielzeug/courier'; try { const user = await api.get('/users/1', { schema: UserSchema }); } catch (err) { if (err instanceof CourierSchemaValidationError) { console.error('Validation failed for body:', err.data); console.error('Cause:', err.cause); } } ``` ### `CourierDisposedError` Thrown when a primitive's primary method is called after its `dispose()` has been called — `api.request()` (and the `get`/`post`/etc. shorthands), `qc.fetch()`/`qc.observe()`, `stream.sse()`/`stream.readable()`, `mutation.mutate()`, and `batcher.load()` all guard against this. - `message`: `` `[courier] ${clientName} disposed` `` — e.g. `'[courier] Mutation disposed'` - Use `instanceof CourierDisposedError` for narrowing. ```ts import { CourierDisposedError, createMutation } from '@vielzeug/courier'; const mutation = createMutation(saveUser); mutation.dispose(); try { await mutation.mutate({ name: 'Alice' }); } catch (err) { if (err instanceof CourierDisposedError) console.log('Mutation was already disposed'); } ``` ### `CourierBatcherError` Thrown when a `createBatcher()` batch's `resolve`/`resolveSettled` returns a result array of the wrong length, or throws (synchronously or via a rejected promise) — in either case every pending `load()` call in that batch rejects with this error. - Use `instanceof CourierBatcherError` for narrowing. ### `CourierParseError` Thrown when a response body cannot be read or parsed, or when a `{param}` path placeholder cannot be resolved from `params`. - Use `instanceof CourierParseError` for narrowing. ## Request and Stream Config ### `TransportOptions` ```ts type TransportOptions = { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: Record; timeout?: number; }; ``` ### `HttpRequestConfig` ```ts type HttpRequestConfig = CourierRequestConfig & { fetchInit?: Omit; headers?: Record; signal?: AbortSignal; }; ``` `CourierRequestConfig` adds: | Field | Type | Description | | -------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | | `body` | `unknown` | Plain objects are serialized as JSON; `BodyInit` values pass through | | `dedupe` | `boolean` | Set to `false` to opt out of in-flight deduplication | | `dedupeKey` | `StableValue` | Explicit stable key for deduplicating non-idempotent writes | | `query` | `Params` | Query string parameters | | `responseType` | `ResponseType` | Response parsing strategy | | `schema` | `{ parse(data: unknown): T }` | Response validation schema; `T` matches the request return type. Throws `CourierSchemaValidationError` on failure | | `timeout` | `number` | Per-request timeout override | Idempotent requests (`GET`, `HEAD`, `OPTIONS`) dedupe by **method + URL + responseType** automatically. `DELETE` does not auto-dedupe (it has side effects); provide an explicit `dedupeKey` to opt in. Request headers are not part of the automatic dedupe key. --- ### `StreamRequestConfig` ```ts type StreamRequestConfig = { body?: unknown; /** Raw fetch options for advanced use (credentials, cache, mode, referrer, etc.). */ fetchInit?: Omit; headers?: Record; method?: string; params?: P extends string ? Record : never; query?: Params; signal?: AbortSignal; timeout?: number; }; ``` Streaming requests default to `Infinity` timeout per connection when `timeout` is omitted. ### `ReadableConfig` ```ts type ReadableConfig = StreamRequestConfig & { onError?: (error: Error) => void; parse?: 'ndjson' | 'text'; reconnect?: boolean | ReconnectOptions; }; ``` - `parse: 'ndjson'` — splits by newline and JSON-parses each complete line, including any partial line at EOF. - `reconnect` — auto-reconnect on connection loss using the same full-jitter backoff as `sse()`. `true` uses defaults (5 attempts). When the budget is exhausted: throws if `onError` is omitted, calls `onError` if provided. - `onError` — called when the reconnect budget is exhausted or a non-retriable error occurs. Not called when aborted via signal or `cancelAll()`. Extends `StreamRequestConfig` with a `parse` option for `stream.readable()`. `'text'` (default) yields raw decoded string chunks; `'ndjson'` splits by newline and JSON-parses each complete line — use the type parameter `T` to type the parsed values. ### `SseOptions` ```ts type SseOptions = StreamRequestConfig & { onError?: (error: Error) => void; reconnect?: boolean | ReconnectOptions; }; ``` | Field | Type | Description | | ----------- | ----------------------------- | --------------------------------------------------------- | | `reconnect` | `boolean \| ReconnectOptions` | `true` uses 5 reconnect attempts with full-jitter backoff | | `onError` | `(error: Error) => void` | Called when reconnect budget is exhausted | ### `ReconnectOptions` ```ts type ReconnectOptions = { times?: number; delay?: number | ((attempt: number) => number); }; ``` - `times` counts reconnects **after** the first failure - default `times` is `5` - clean server closes do **not** reset the reconnect budget ## Types ### `QueryOptions` ```ts type QueryOptions = { enabled?: boolean; fn: (ctx: QueryFnContext) => Promise; gcTime?: number; initialData?: T | (() => T | undefined); key: QueryKey; staleTime?: number; } & RetryOptions; ``` `fetch()` always throws on error. Errors surface as rejected promises — no swallow option. For reactive subscriptions that surface errors as state, use `observe()`. ### `ObserveOptions` ```ts type ObserveOptions = | ({ fetch?: true } & QueryOptions & ObserveExtras) | ({ fetch: false; key: QueryKey } & Partial> & ObserveExtras); type ObserveExtras = { placeholderData?: S | (() => S | undefined); select?: (data: T | undefined) => S | undefined; }; ``` Two forms: `fetch?: true` (default) triggers a background fetch and requires `fn`; `fetch: false` is a read-only store — `fn` is not required or called. `placeholderData` and `select` do not affect the underlying cache or `fetch()` behaviour. `S` defaults to `T`; provide a second generic when using `select` (e.g. `ObserveOptions` with `select: (u) => u?.name`). ### `QueryClientOptions` ```ts type QueryClientOptions = { gcTime?: number; staleTime?: number; } & RetryOptions; ``` ### `MutationFn` ```ts type MutationFn = (input: TVariables, signal: AbortSignal) => Promise; ``` ### `MutationOptions` ```ts type MutationOptions = RetryOptions & { onCallbackError?: (error: Error) => void; onError?: (error: Error, variables: TVariables) => void | Promise; onFinally?: (variables: TVariables) => void | Promise; onSettled?: (result: SettledResult) => void | Promise; onSuccess?: (data: TData, variables: TVariables) => void | Promise; }; ``` `onError` is not called when the mutation is aborted. `onFinally` runs after `onSuccess`/`onError` and before `onSettled`. `onSettled` is always called — switch on `result.status` for exhaustive handling of `'success'`, `'error'`, and `'aborted'`. ### `CourierMutationOptions` ```ts type CourierMutationOptions = MutationOptions & { invalidates?: QueryKey[]; sets?: (data: TData, variables: TVariables) => Array; }; ``` Extra options accepted by `client.mutation()` (not `createMutation()`). Applied automatically on success before `onSuccess` fires. | Option | Type | Description | | ------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | | `invalidates` | `QueryKey[]` | Keys to invalidate in the embedded query cache after a successful run | | `sets` | `(data, variables) => Array` | Seed one or more cache entries; always return an array of `[key, data]` pairs | **Example:** ```ts const createUser = client.mutation( (input: NewUser, signal) => client.api.post('/users', { body: input, signal }), { sets: (user) => [ [['users', user.id], user], [['users', 'latest'], user], ], invalidates: [['users']], }, ); // On success: seeds ['users', user.id] and ['users', 'latest'], then invalidates ['users'] ``` --- ### `SettledResult` ```ts type SettledResult = | { readonly data: TData; readonly status: 'success'; readonly variables: TVariables } | { readonly error: Error; readonly status: 'error'; readonly variables: TVariables } | { readonly status: 'aborted'; readonly variables: TVariables }; ``` Discriminated union passed to `onSettled`. Switch on `status` for exhaustive handling: ```ts onSettled: (result) => { if (result.status === 'success') console.log(result.data, result.variables); else if (result.status === 'error') console.error(result.error, result.variables); // 'aborted' — user cancelled; only result.variables is available }, ``` ### `RetryOptions` ```ts type RetryOptions = { delay?: number | ((attempt: number) => number); shouldRetry?: (error: unknown, attempt: number) => boolean; times?: number; }; ``` `times: 1` (the default) means one try with no retries. `delay` defaults to full-jitter exponential backoff capped at 30 s. ### `SyncStore` ```ts interface SyncStore { peek(): T; subscribe(onStoreChange: () => void): () => void; } ``` Returned by `mutation.store` (property), `query.observe()`, and `query.observeMany()`. ### `QueryFn` ```ts type QueryFn = (ctx: QueryFnContext) => Promise; ``` ### `QueryKey` ```ts type QueryKeyAtom = string | number | boolean | null | { readonly [k: string]: string | number | boolean | null }; type QueryKey = readonly [QueryKeyAtom, ...QueryKeyAtom[]]; ``` `QueryKeyAtom` is exported separately for use in factory helpers and key-building utilities. A non-empty tuple of JSON-safe atoms. Object atoms are allowed and serialized stably — no `Date`, `Map`, `Set`, or `bigint`. This prevents silent serialization bugs when keys are used in persistence. ### `QueryFnContext` ```ts type QueryFnContext = { key: QueryKey; signal: AbortSignal; }; ``` ### `AsyncStatus` ```ts type AsyncStatus = 'loading' | 'success' | 'error'; ``` - `'loading'` — no data yet; a fetch may or may not be in-flight - `'success'` — data available; `isFetching` may be `true` during background revalidation - `'error'` — last operation failed; stale `data` from a prior success may still be present ### `AsyncState` ```ts type AsyncState = { readonly isFetching: boolean; readonly isLoading: boolean; // shorthand for status === 'loading' } & ( | { readonly data: undefined; readonly error: null; readonly status: 'loading'; readonly updatedAt: undefined } | { readonly data: T; readonly error: null; readonly status: 'success'; readonly updatedAt: number } | { readonly data: T | undefined; readonly error: Error; readonly status: 'error'; readonly updatedAt: number } ); ``` `isLoading` is a convenience shorthand for `status === 'loading'`. Use it as a loading-spinner predicate. ### `QueryState` ```ts type QueryState = AsyncState; ``` ### `MutationState` ```ts type MutationState = AsyncState; ``` ### `SseSource` ```ts type SseStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'; type SseSource = Record> = { readonly closed: boolean; readonly status: SseStatus; [Symbol.dispose](): void; dispose(): void; on(event: K, handler: (data: TEvents[K]) => void): () => void; }; ``` - `status` transitions: `connecting` → `open` → (`reconnecting` → `connecting`)\* → `closed` - `closed` is a shorthand for `status === 'closed'` ```ts type FetchContext = { headers: Record; init: Omit; url: string; withHeaders(updates: Record): FetchContext; }; type Interceptor = (ctx: FetchContext, next: (ctx: FetchContext) => Promise) => Promise; ``` Interceptors must use `ctx.withHeaders(updates)` to add or override headers — this returns a new immutable `FetchContext` and prevents interceptors from stomping each other. ### Return Types ```ts type ApiClient = ReturnType; type QueryClient = ReturnType; type Mutation = ReturnType>; type StreamClient = ReturnType; type Courier = ReturnType; ``` ### `PersistOptions` ```ts interface PersistOptions { /** * Keys to persist/hydrate. Either: * - `QueryKey[]` — explicit list of keys. * - `(key: QueryKey) => boolean` — predicate applied to all cached keys. */ keys: QueryKey[] | ((key: QueryKey) => boolean); maxAge?: number; onError?: (err: unknown, key: QueryKey) => void; prefix?: string; // default: 'courier:' storage: PersistStorage; } ``` - **`keys`** — required. Pass an array of explicit keys or a predicate function filtered against all cached keys. ### `PersistStorage` ```ts interface PersistStorage { getItem(key: string): Promise | string | null; setItem(key: string, value: string): Promise | void; } ``` ## Utility Functions ### `bindRefetch()` ```ts bindRefetch(qc: { refetchStale(): void }, opts?: { signal?: AbortSignal }): () => void; ``` Wires up `qc.refetchStale()` to browser lifecycle events — `document visibilitychange` (when tab becomes visible) and `window online`. Returns an unbind function. Fully opt-in. Pass `opts.signal` (e.g. `qc.disposalSignal`) to automatically remove listeners when the signal aborts — eliminating the need to call the returned unbind function manually on teardown. **Returns:** `() => void` (unbind function) **Example:** ```ts import { bindRefetch, createQuery } from '@vielzeug/courier'; const qc = createQuery({ staleTime: 30_000 }); const unbind = bindRefetch(qc); // On cleanup: unbind(); ``` --- ### `toSyncStore()` ```ts toSyncStore(source: { peek(): T; subscribe(cb: () => void): Unsubscribe }): SyncStore; ``` Converts any object with `peek()` and `subscribe()` to a plain `SyncStore`. Use when a framework adapter accepts a `SyncStore` directly rather than consuming `peek`/`subscribe` separately. **Example:** ```ts import { createMutation, toSyncStore } from '@vielzeug/courier'; const mutation = createMutation(async (input: NewUser) => api.post('/users', { body: input })); // React const state = useSyncExternalStore(mutation.store.subscribe, mutation.store.peek); // Or use toSyncStore to wrap a custom peek/subscribe object const store = toSyncStore(mutation); const state2 = useSyncExternalStore(store.subscribe, store.peek); ``` --- ### `createBatcher()` ```ts createBatcher(opts: BatcherOptions): Batcher; ``` `BatcherOptions` requires exactly one of `resolve` or `resolveSettled` (mutually exclusive). `resolve()` must return results in the **same order** as `keys`. A length mismatch, or a throw — synchronous or via a rejected promise — rejects every pending `load()` call in that batch with `CourierBatcherError` (or the thrown value) rather than leaving any of them hanging. `resolveSettled` returns `PromiseSettledResult[]` for per-key error isolation — each `load()` fulfills or rejects independently. After `dispose()`, any subsequent `load()` call rejects immediately. **Returns:** `Batcher` | Member | Type | Description | | ------------------- | ----------------------- | ------------------------------------------------------------------------ | | `load(key)` | `Promise` | Enqueues `key`; returns a promise fulfilled with its result | | `dispose()` | `void` | Cancels pending promises, stops scheduled flushes. Idempotent. | | `[Symbol.dispose]` | `void` | Alias for `dispose()` — supports `using` declarations | | `disposalSignal` | `AbortSignal` (getter) | Aborted when `dispose()` is called; use to tie external lifetimes | | `disposed` | `boolean` (getter) | `true` after `dispose()` | **Example:** ```ts import { createBatcher } from '@vielzeug/courier'; const userLoader = createBatcher({ resolve: async (ids) => api.post('/users/batch', { body: { ids } }), }); const [alice, bob] = await Promise.all([userLoader.load(1), userLoader.load(2)]); ``` --- ### Built-in Interceptor Presets ```ts withBearerAuth(token: string | (() => string | Promise)): Interceptor; withRequestId(opts?: { header?: string; generate?: () => string }): Interceptor; withLogging(opts?: { logger?: (msg: string, meta: { duration: number; method: string; status: number; url: string }) => void; }): Interceptor; ``` `withBearerAuth` accepts a static token or an async factory (for token refresh flows). It correctly handles all `HeadersInit` forms — plain object, `Headers` instance, or array of tuples. `withRequestId` defaults to `x-request-id` populated with `crypto.randomUUID()`. `withLogging` defaults to `console.debug`. **Example:** ```ts import { withBearerAuth, withLogging, withRequestId } from '@vielzeug/courier'; api.use(withBearerAuth(async () => tokenStore.getAccessToken())); api.use(withRequestId()); api.use(withLogging()); ``` --- ### `persistQueryCache()` and `hydrateQueryCache()` ```ts persistQueryCache( qc: QueryClient, opts: PersistOptions, ): () => void; hydrateQueryCache( qc: QueryClient, opts: PersistOptions, ): Promise; interface PersistOptions { /** Keys to persist/hydrate: explicit `QueryKey[]` list or predicate function. */ keys: QueryKey[] | ((key: QueryKey) => boolean); maxAge?: number; onError?: (err: unknown, key: QueryKey) => void; prefix?: string; // default: 'courier:' storage: PersistStorage; } interface PersistStorage { getItem(key: string): Promise | string | null; setItem(key: string, value: string): Promise | void; } ``` - `persistQueryCache` returns a stop function. It eagerly persists any already-successful entries on setup. - `hydrateQueryCache` restores the original `updatedAt` timestamp so staleTime checks are accurate after hydration. - `maxAge` (ms) — entries older than `Date.now() - maxAge` are skipped during hydration. - `onError` is called for each failing storage operation; errors are silently swallowed when omitted. **Returns:** `persistQueryCache` returns `() => void` (stop function); `hydrateQueryCache` returns `Promise` **Example:** ```ts import { createQuery, hydrateQueryCache, persistQueryCache } from '@vielzeug/courier'; const qc = createQuery({ staleTime: 60_000 }); await hydrateQueryCache(qc, { keys: [['users', userId]], maxAge: 24 * 60 * 60_000, storage: localStorage, }); const stop = persistQueryCache(qc, { keys: [['users', userId]], storage: localStorage, }); ``` --- ### `debugCourier()` ```ts debugCourier(options?: CourierOptions): Courier; ``` Equivalent to `createCourier(options)` with `client.use(withLogging())` already registered. Returns the same `Courier` instance — every method is identical to `createCourier()`. Import from the dedicated sub-path so the `console.debug` reference is tree-shaken from production bundles when not imported. `withLogging()` logs the full request URL, including any query parameters — if those may contain tokens or PII, use `createCourier()` with a custom `withLogging({ logger })` that sanitizes the URL instead, or none at all in production. **Example:** ```ts import { debugCourier } from '@vielzeug/courier/devtools'; const client = debugCourier({ baseUrl: 'https://api.example.com' }); await client.api.get('/users'); // GET https://api.example.com/users 200 (42ms) ``` ### Usage Guide Start with the [Overview](./index.md) for a quick introduction and installation, then come back here for in-depth usage patterns. ## Basic Usage `createCourier()` combines `api`, `stream`, `query`, and `mutation()` behind one shared transport. ```ts import { createCourier } from '@vielzeug/courier'; type NewUser = { name: string }; type User = { id: number; name: string }; const client = createCourier({ baseUrl: 'https://api.example.com', headers: { authorization: `Bearer ${token}` }, timeout: 30_000, query: { staleTime: 30_000 }, }); client.use(async (ctx, next) => { return next(ctx.withHeaders({ 'x-request-id': crypto.randomUUID() })); }); const user = await client.query.fetch({ key: ['users', 1], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id: 1 }, signal }), }); const createUser = client.mutation((input: NewUser, signal) => client.api.post('/users', { body: input, signal }), ); await createUser.mutate({ name: 'Alice' }); client.stream.sse('/events', { reconnect: true }); ``` `timeout` applies to REST requests. SSE and readable streams default to `Infinity` per connection unless you override `timeout` explicitly. ## HTTP Client `createApi()` returns a thin HTTP client built on the native `fetch` API. ### Creating a Client ```ts import { createApi } from '@vielzeug/courier'; const api = createApi({ baseUrl: 'https://api.example.com', timeout: 30_000, // default: 30 000 ms headers: { Authorization: 'Bearer token' }, fetch: globalThis.fetch, // optional }); // Disable timeouts explicitly when needed const noTimeoutApi = createApi({ timeout: Infinity }); ``` ### HTTP Methods All methods return `Promise` with the deserialized response body. ```ts // GET const users = await api.get('/users'); // POST — plain object body is serialized to JSON automatically const created = await api.post('/users', { body: { name: 'Alice' } }); // PUT / PATCH / DELETE const updated = await api.put('/users/1', { body: { name: 'Alice Smith' } }); const patched = await api.patch('/users/1', { body: { email: 'new@example.com' } }); await api.delete('/users/1'); // Custom method const info = await api.request('OPTIONS', '/users'); ``` ### Type-Safe Path Parameters `{param}` placeholders are extracted at compile time — TypeScript errors if a required param is missing or misspelled. ```ts // Single param const user = await api.get('/users/{id}', { params: { id: 42 } }); // → GET /users/42 // Multiple params — TypeScript enforces all are provided const comment = await api.get('/posts/{postId}/comments/{commentId}', { params: { postId: 1, commentId: 99 }, }); // → GET /posts/1/comments/99 ``` ### Query String Parameters ```ts const page = await api.get('/users', { query: { role: 'admin', page: 1, limit: 20 }, }); // → GET /users?role=admin&page=1&limit=20 // Combining path params and query string const posts = await api.get('/users/{id}/posts', { params: { id: 42 }, query: { status: 'published', limit: 10 }, }); // → GET /users/42/posts?status=published&limit=10 ``` ### Managing Headers ```ts // Update at runtime — settles on all subsequent requests api.headers({ Authorization: `Bearer ${newToken}` }); // Remove a header by setting it to undefined api.headers({ Authorization: undefined }); ``` ### Cancelling In-Flight Requests `cancelAll()` aborts every active request without disposing the client. The client stays usable for new requests immediately after. ```ts // Route change — drop all pending background fetches api.cancelAll(); // Client is still alive and ready const fresh = await api.get('/config'); ``` ### Request Deduplication GET, HEAD, and OPTIONS requests are deduplicated automatically by **method + URL + responseType**. DELETE is idempotent but has side effects, so it does not auto-dedupe — pass an explicit `dedupeKey` to opt in. Request headers are not part of the automatic dedupe key. Writes (POST, PUT, PATCH) are never deduplicated unless you pass an explicit `dedupeKey`. ```ts // Only ONE network call is made const [a, b, c] = await Promise.all([api.get('/users/1'), api.get('/users/1'), api.get('/users/1')]); console.log(a === b); // true — same promise // Opt out when you explicitly want separate requests await api.get('/users/1', { dedupe: false }); // Writes only dedupe when you give them a stable key const result = await api.post('/data', { body: payload, dedupeKey: ['save-draft', draftId] }); ``` ### Per-Request Options All standard `RequestInit` options are supported alongside Courier extensions: ```ts const data = await api.get('/protected', { headers: { 'X-Custom': 'value' }, // per-request headers merged over globals signal: controller.signal, // AbortSignal for cancellation timeout: 5_000, // override client-level timeout }); ``` ### Query Param Arrays `query` supports scalars, arrays, and `null`. ```ts await api.get('/users', { query: { page: [1, 2], search: null, role: 'admin' }, }); // → /users?page=1&page=2&search=&role=admin ``` ## Interceptors `use(interceptor)` adds middleware that wraps every request. Returns a dispose function. Interceptors are called in registration order. ```ts // Auth — inject a fresh token before each request const removeAuth = api.use(async (ctx, next) => { const token = await getAccessToken(); return next(ctx.withHeaders({ Authorization: `Bearer ${token}` })); }); // Logging const removeLog = api.use(async (ctx, next) => { const start = Date.now(); const res = await next(ctx); console.log(`${ctx.init.method} ${ctx.url} → ${res.status} (${Date.now() - start}ms)`); return res; }); // Remove interceptors when no longer needed removeAuth(); removeLog(); ``` An interceptor can short-circuit the chain by returning a `Response` without calling `next(ctx)`. ### Built-in Interceptor Presets Three ready-to-use presets are exported: ```ts import { withBearerAuth, withLogging, withRequestId } from '@vielzeug/courier'; // Inject a Bearer token — static or async (useful for refresh flows) client.use(withBearerAuth('my-token')); client.use(withBearerAuth(async () => tokenStore.getAccessToken())); // Add a unique request ID header (default: x-request-id populated with crypto.randomUUID()) client.use(withRequestId()); client.use(withRequestId({ header: 'x-trace-id', generate: () => ulid() })); // Log method, URL, status, and duration to console.debug client.use(withLogging()); client.use(withLogging({ logger: (msg, meta) => structuredLogger.info(msg, meta) })); ``` `withBearerAuth` handles `undefined`, plain object, array-of-tuples, and `Headers` instance inputs in `ctx.init.headers` — no manual spread required. ### Debug Logging Import `debugCourier` from the dedicated `/devtools` sub-path to create a `Courier` instance with `withLogging()` already registered. The sub-path is tree-shaken from production bundles when not imported. `withLogging()` logs the full request URL, including any query parameters — if those may contain tokens or PII, use `createCourier()` with a custom `withLogging({ logger })` that sanitizes the URL instead, or none at all in production. ```ts import { debugCourier } from '@vielzeug/courier/devtools'; const client = debugCourier({ baseUrl: 'https://api.example.com' }); await client.api.get('/users'); // GET https://api.example.com/users 200 (42ms) ``` ## Query Client `createQuery()` provides cache-backed reads with request deduplication, prefix invalidation, and reactive subscriptions for any async data source. ### Creating a Query Client ```ts import { createQuery } from '@vielzeug/courier'; const qc = createQuery({ staleTime: 0, // ms to serve from cache before refetching (default: 0 = always stale) gcTime: 300_000, // ms before an unobserved entry is GC'd (default: 5 min) times: 1, // 1 = one try with no retries }); ``` ### `fetch(options)` Fetches data with automatic caching, deduplication, and retry. The `fn` receives a `QueryFnContext` with both the cache `key` and an `AbortSignal`. ```ts const user = await qc.fetch({ key: ['users', userId], fn: ({ key, signal }) => api.get('/users/{id}', { params: { id: key[1] as number }, signal }), staleTime: 5_000, times: 3, shouldRetry: (err) => !CourierHttpError.is(err) || (err.status ?? 500) >= 500, }); ``` | Option | Type | Default | Description | | ----------------- | ------------------------------------------ | -------------------- | ------------------------------------------------------------------------- | | `key` | `QueryKey` | required | Cache identifier; serialized with stable key ordering | | `fn` | `(ctx: QueryFnContext) => Promise` | required | Data-fetching function; receives `{ key, signal }` | | `staleTime` | `number` | `0` | ms served from cache before the next `fetch()` call refetches | | `gcTime` | `number` | `300000` | ms before an unobserved entry is GC'd while unobserved | | `times` | `number` | query-client default | Total attempts for this specific fetch; `1` means one try with no retries | | `delay` | `number \| (attempt) => number` | query-client default | Delay strategy for this specific fetch | | `shouldRetry` | `(error, attempt) => boolean` | query-client default | Retry predicate for this specific fetch | | `enabled` | `boolean` | `true` | Skip the fetch when `false`; existing cached data is returned | | `initialData` | `T \| () => T \| undefined` | — | Pre-seed the cache as a successful entry when no data exists | | `placeholderData` | `S \| () => S \| undefined` | — | **Observe only.** Pass as part of `ObserveOptions` — ignored by `fetch()` | | `select` | `(data: T \| undefined) => S \| undefined` | — | **Observe only.** Pass as part of `ObserveOptions` — ignored by `fetch()` | Per-fetch retry options override `createQuery()` defaults when provided. `times: 3` means **3 total attempts**. `times: 1` means one try with no retries. ### Conditional Fetching Set `enabled: false` to skip the fetch. Useful for dependent queries or fields that should not load until the user interacts. ```ts const posts = await qc.fetch({ key: ['users', userId, 'posts'], fn: ({ signal }) => api.get('/posts', { query: { userId }, signal }), enabled: userId != null, }); ``` ### Seeding Cache Data `initialData` pre-populates the cache as a successful entry before the first fetch. If data already exists the value is ignored. ```ts const user = await qc.fetch({ key: ['users', id], fn: ({ signal }) => api.get('/users/{id}', { params: { id }, signal }), staleTime: 30_000, initialData: () => qc.get(['users'])?.find((u) => u.id === id), }); ``` ### Warming the Cache Use `fetch()` to warm the cache before a page renders. `fetch()` always throws on error — wrap with `try/catch` or use `Promise.allSettled` when warming multiple keys in parallel. ```ts // Warm the cache try { await qc.fetch({ key: ['users', 1], fn: ({ signal }) => api.get('/users/{id}', { params: { id: 1 }, signal }), staleTime: 10_000, }); } catch { // Error stored in cache state; qc.getState(['users', 1]).status === 'error' } // Later reads hit the cache if still within staleTime const user = await qc.fetch({ key: ['users', 1], fn: ({ signal }) => api.get('/users/{id}', { params: { id: 1 }, signal }), staleTime: 10_000, }); ``` ### Cache Access ```ts const cached = qc.get(['users', 1]); // Set a value (updatedAt defaults to Date.now()) qc.set(['users', 1], { id: 1, name: 'Alice' }); qc.set(['users'], (old = []) => [...old, newUser]); // Restore a persisted entry with its original timestamp so staleTime checks are accurate qc.set(['users', 1], persistedData, { updatedAt: storedTimestamp }); const state = qc.getState(['users', 1]); ``` ### `watchKey(key)` `watchKey()` returns a `SyncStore>` for a single key without triggering any fetch. Use it when another code path is responsible for populating the cache entry. ```ts const store = qc.watchKey(['users', 1]); const initial = store.peek(); // idle state if not yet fetched const stop = store.subscribe(() => { console.log(store.peek()); }); stop(); ``` The store does **not** fire immediately. Read the initial snapshot with `peek()`. For `select` or `placeholderData` support, use `observe({ fetch: false, ... })` instead. ### `observeMany(keys)` `observeMany()` returns a combined `SyncStore[]>` that updates whenever any of the specified keys change. Useful for parallel query status aggregation. ```ts const store = qc.observeMany([ ['users', 1], ['users', 2], ]); const states = store.peek(); // QueryState[] const stop = store.subscribe(() => { const [user1, user2] = store.peek(); if (user1.status === 'success' && user2.status === 'success') render(user1.data, user2.data); }); ``` ### `observe(options)` `observe()` is the preferred API for components: it returns a `SyncStore>` **and** triggers a background fetch if the cache entry is stale or missing. Pass `placeholderData` and `select` directly on the options object — no second argument needed. ```ts const store = qc.observe({ key: ['users', id], fn: ({ signal }) => api.get('/users/{id}', { params: { id }, signal }), staleTime: 30_000, placeholderData: { id: 0, name: 'Loading…' }, }); // Synchronously read the current state console.log(store.peek().status); // 'idle' or 'pending' console.log(store.peek().data); // placeholderData while fetching // Subscribe to future changes const stop = store.subscribe(() => { const state = store.peek(); if (state.status === 'success') render(state.data); }); ``` Use `select` to project to a derived type — TypeScript infers `S` from the return type: ```ts // store is SyncStore> const store = qc.observe({ key: ['users', id], fn: ({ signal }) => api.get('/users/{id}', { params: { id }, signal }), select: (user) => user?.name, placeholderData: 'Loading…', }); ``` In React, pass `observe()` directly to `useSyncExternalStore`: ```tsx function useUser(id: number) { const store = useMemo( () => client.query.observe({ key: ['users', id], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id }, signal }), staleTime: 30_000, }), [id], ); return useSyncExternalStore(store.subscribe, store.peek); } ``` ### `invalidate(key)` For entries **without active subscribers**, invalidation evicts the cache entry immediately. For entries **with active subscribers**: - If the entry has a stored query function (registered via `fetch()`), it is background-revalidated. - If the entry was only populated via `set()`, it resets to `idle`. Supports **prefix matching**: invalidating `['users']` affects `['users', 1]`, `['users', 2]`, and so on. ```ts qc.invalidate(['users', 1]); qc.invalidate(['users']); ``` ### `cancel(key)` Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'idle'`. ```ts qc.cancel(['users', 1]); ``` ### `clear()` Clears every cache entry. Active subscribers are notified with an `'idle'` state. ```ts qc.clear(); ``` ### Background Revalidation `refetchStale()` revalidates all stale observed entries. Use `bindRefetch()` to wire it up to browser lifecycle events — tab visibility and network reconnection: ```ts import { bindRefetch, createQuery } from '@vielzeug/courier'; const qc = createQuery({ staleTime: 30_000 }); // Returns an unbind function — call it on cleanup to remove the listeners const unbind = bindRefetch(qc); // later, e.g. on logout or component teardown: unbind(); ``` Alternatively, pass `qc.disposalSignal` so listeners are removed automatically when the query client is disposed — no manual unbind needed: ```ts bindRefetch(qc, { signal: qc.disposalSignal }); ``` `bindRefetch` attaches a `visibilitychange` listener (fires when `document.visibilityState` becomes `'visible'`) and an `online` listener on `window`. Only entries with active subscribers that are past their `staleTime` are revalidated. Error entries with stale data are also eligible once their `updatedAt` age exceeds `staleTime`. ### Stable Key Serialization Object property order doesn't matter in query keys — Courier sorts keys before serialization. ```ts await qc.fetch({ key: ['users', { page: 1, role: 'admin' }], fn: ({ signal }) => api.get('/users', { query: { page: 1, role: 'admin' }, signal }), }); await qc.fetch({ key: ['users', { role: 'admin', page: 1 }], fn: ({ signal }) => api.get('/users', { query: { page: 1, role: 'admin' }, signal }), }); ``` ### Dispose Both `createApi()` and `createQuery()` implement `[Symbol.dispose]` for deterministic cleanup. ```ts { using api = createApi({ baseUrl: 'https://api.example.com' }); using qc = createQuery(); } qc.dispose(); api.dispose(); ``` ## Query Cache Persistence Persist successful cache entries across page reloads with `persistQueryCache()` and `hydrateQueryCache()`. ```ts import { createQuery, hydrateQueryCache, persistQueryCache } from '@vielzeug/courier'; const qc = createQuery({ staleTime: 60_000 }); // Hydrate on page load (before any fetch calls) await hydrateQueryCache(qc, { keys: [['users', userId], ['settings']], storage: localStorage, }); // Wire up persistence for future writes (also eagerly persists any already-successful entries) const stopPersisting = persistQueryCache(qc, { keys: [['users', userId], ['settings']], storage: localStorage, }); // Stop on logout stopPersisting(); qc.clear(); ``` **`PersistOptions`:** | Option | Type | Default | Description | | --------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------- | | `storage` | `PersistStorage` | required | Any sync or async `getItem` / `setItem` backend | | `keys` | `QueryKey[] \| (key: QueryKey) => boolean` | required | Explicit list of keys **or** predicate applied to all cached keys | | `prefix` | `string` | `'courier:'` | Storage key namespace to avoid collisions | | `maxAge` | `number` | — | Max entry age in ms during hydration; entries older than this are skipped | | `onError` | `(err, key) => void` | silent | Called when a storage read or write fails | `hydrateQueryCache` restores the original `updatedAt` timestamp so staleTime checks after hydration are accurate — 55-second-old hydrated data with `staleTime: 60_000` will be refetched after 5 more seconds, not after a full 60 seconds. ```ts // Custom async storage (IndexedDB adapter) const idbStorage: PersistStorage = { getItem: (key) => idb.get(key), setItem: (key, value) => idb.put(key, value), }; await hydrateQueryCache(qc, { keys: [['products']], maxAge: 24 * 60 * 60_000, // skip entries older than 1 day onError: (err, key) => console.warn('Hydration failed for', key, err), storage: idbStorage, }); ``` ## DataLoader-Style Batcher `createBatcher()` coalesces individual `load()` calls made within the same scheduling window into a single `resolve()` call, eliminating N+1 request patterns. ```ts import { createBatcher } from '@vielzeug/courier'; const userLoader = createBatcher({ resolve: async (ids: number[]) => api.post('/users/batch', { body: { ids } }), }); // These three calls collapse into one POST /users/batch { ids: [1, 2, 3] } const [alice, bob, carol] = await Promise.all([userLoader.load(1), userLoader.load(2), userLoader.load(3)]); ``` **Options:** | Option | Type | Default | Description | | --------- | ----------------------------- | -------- | ------------------------------------------------------------------------------------------ | | `resolve` | `(keys: K[]) => Promise` | required | Execute a batch and return results **in the same order as `keys`** | | `maxSize` | `number` | `25` | Force-flush when the queue reaches this size | | `window` | `number` | `0` | Scheduling window in ms. `0` = next microtask; positive value coalesces across async ticks | ```ts // Custom window and batch size const loader = createBatcher({ maxSize: 100, resolve: async (keys) => fetchBatch(keys), window: 16, // collect for one animation frame }); // Dispose — rejects all queued promises and prevents further use loader.dispose(); // or use the explicit resource management syntax: // using loader = createBatcher(...); ``` `resolve()` **must** return an array in the same order as `keys`. A length mismatch rejects all pending promises. ## Mutations When using `createCourier()`, create mutations directly from the client — no extra import needed: ```ts const createUser = client.mutation( (input: NewUser, signal) => client.api.post('/users', { body: input, signal }), { times: 2, // Cache shorthands: applied automatically on success before onSuccess fires sets: (user) => [['users', user.id], user], invalidates: [['users']], onSuccess: (user, variables) => console.log('Created:', variables.name), onError: (err, variables) => toast.error(`Failed to create ${variables.name}: ${err.message}`), onSettled: (result) => { if (result.status !== 'aborted') hideSpinner(result.variables); }, }, ); const user = await createUser.mutate({ name: 'Alice', email: 'alice@example.com' }); ``` Any options set via `mutationDefaults` in `createCourier()` are automatically merged into every `client.mutation()` call. When not using `createCourier()`, use `createMutation()` standalone: ```ts import { createMutation } from '@vielzeug/courier'; const createUser = createMutation( (input: NewUser, signal: AbortSignal) => api.post('/users', { body: input, signal }), { times: 2 }, ); ``` ### Lifecycle Callbacks Callbacks are defined on the mutation, not the call site. They fire after each `mutate()` run. Every callback receives the original `variables` passed to `mutate()`. | Callback | Signature | Called when | | ----------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `onSuccess` | `(data: TData, variables: TVariables) => void \| Promise` | The run succeeds | | `onError` | `(error: Error, variables: TVariables) => void \| Promise` | The run fails; **not** called on abort | | `onSettled` | `(result: SettledResult) => void \| Promise` | After every run including abort; switch on `result.status` for exhaustive handling | When multiple `mutate()` calls run simultaneously, state reflects the **latest** call. Each callback fires for its own call independently. Use `mutation.cancel()` before a new `mutate()` for last-call-wins semantics. ### Cancellation ```ts createUser.mutate({ name: 'Alice', email: 'alice@example.com' }); await createUser.cancel(); ``` ```ts const controller = new AbortController(); createUser.mutate({ name: 'Alice', email: 'alice@example.com' }, { signal: controller.signal }); controller.abort(); ``` ## Server-Sent Events Use `createStream()` directly or reuse `client.stream` from `createCourier()`. ```ts import { createStream } from '@vielzeug/courier'; const stream = createStream({ baseUrl: 'https://api.example.com' }); const source = stream.sse('/events', { reconnect: true, onError: (error) => console.error('SSE closed permanently:', error.message), }); source.on('message', (data) => console.log(data.text)); source.on('ping', () => {}); source.dispose(); ``` `reconnect: true` uses full-jitter exponential backoff with a default budget of 5 reconnects after the first failure. ```ts const source = stream.sse('/events', { reconnect: { times: 2, delay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000), }, }); ``` SSE connections: - parse standard `data`, `event`, and `id` fields - JSON-parse non-string payloads automatically - send `Last-Event-ID` on reconnect - use the shared interceptor pipeline - keep the reconnect budget across clean server closes and failures ## HTTP Streaming Use `stream.readable()` for raw text chunks or NDJSON streams. ```ts for await (const chunk of stream.readable('/completions', { body: { prompt }, })) { process.stdout.write(chunk); } ``` ```ts type ChatMessage = { content: string }; for await (const msg of stream.readable('/chat', { body: { prompt }, parse: 'ndjson', })) { console.log(msg.content); } ``` `parse: 'text'` is the default. Streaming connections default to `Infinity` timeout per connection unless you pass `timeout` explicitly. ## Error Handling Courier throws distinct error classes for different failure modes: | Class | When thrown | | ----------------------- | ---------------------------------------------------------------------- | | `CourierHttpError` | Non-2xx HTTP response — has `status`, `data`, `headers` | | `CourierNetworkError` | Connection failed before any response was received | | `CourierTimeoutError` | Request aborted by the configured timeout | | `CourierAbortError` | Request cancelled via `cancel()`, `cancelAll()`, or an external signal | | `CourierSchemaValidationError` | `schema.parse()` rejected the response body | All extend `CourierError`. Use `CourierError.is(err)` to catch any Courier error, then narrow: ```ts import { CourierAbortError, CourierHttpError, CourierNetworkError, CourierTimeoutError } from '@vielzeug/courier'; try { await api.get('/users/99'); } catch (err) { if (CourierHttpError.is(err, 404)) { console.log('Not found'); } else if (CourierHttpError.is(err)) { console.log(err.status, err.method, err.url); console.log(err.data); console.log(err.headers.get('x-request-id')); } else if (err instanceof CourierTimeoutError) { console.log('Timed out after', err.url); } else if (err instanceof CourierAbortError) { // User navigated away — ignore } else if (err instanceof CourierNetworkError) { console.log('Connection failed:', err.cause); } } ``` ## Common Patterns ### Optimistic Updates ```ts client.query.set(['users', 1], (old) => ({ ...old!, name: 'New Name' })); const updateUser = client.mutation((input: Partial, signal) => client.api.put('/users/{id}', { params: { id: 1 }, body: input, signal }), ); try { await updateUser.mutate({ name: 'New Name' }); } catch { client.query.invalidate(['users', 1]); } ``` ### Type-Safe Query Keys ```ts const keys = { users: { all: () => ['users'] as const, detail: (id: number) => ['users', id] as const, list: (filters: { role?: string }) => ['users', 'list', filters] as const, }, } as const; await qc.fetch({ key: keys.users.detail(42), fn: ({ signal }) => api.get('/users/{id}', { params: { id: 42 }, signal }), }); qc.invalidate(keys.users.all()); ``` ### Dependent Queries ```ts const user = await qc.fetch({ key: ['users', userId], fn: ({ signal }) => api.get('/users/{id}', { params: { id: userId }, signal }), }); if (user) { await qc.fetch({ key: ['users', userId, 'posts'], fn: ({ signal }) => api.get('/users/{id}/posts', { params: { id: userId }, signal }), }); } ``` ### Custom Retry Delay The built-in default uses full-jitter exponential backoff. Override it per query client or per individual fetch. ```ts const retryingQc = createQuery({ times: 4, delay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), shouldRetry: (err) => !CourierHttpError.is(err) || (err.status ?? 500) >= 500, }); await retryingQc.fetch({ key: ['data'], fn: ({ signal }) => api.get('/data', { signal }), }); ``` ### Pitfalls - `times: 1` means one try and no retries. - Use `dedupe: false` when method + URL + response type are the same but you explicitly want separate requests. - `observe()` does **not** emit immediately; call `peek()` for the initial snapshot. - `observe()` returns a **new object on every call** — memoize it in framework hooks (e.g. React `useMemo`) to avoid re-subscribing on every render. - Long-lived streams default to `Infinity` timeout per connection. ## Framework Integration Courier exposes a minimal external-store contract compatible with any framework. ```tsx [React] import { useMemo, useSyncExternalStore } from 'react'; import { createCourier } from '@vielzeug/courier'; const client = createCourier({ baseUrl: 'https://api.example.com', query: { staleTime: 30_000 }, }); type User = { id: number; name: string }; function useUserName(id: number) { // observe() returns a store AND triggers a background fetch if stale const store = useMemo( () => client.query.observe({ key: ['users', id], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id }, signal }), staleTime: 30_000, select: (user) => user?.name ?? '', }), [id], ); const state = useSyncExternalStore(store.subscribe, store.peek); return state.status === 'success' ? state.data : null; } ``` ```ts [Vue 3] import { onScopeDispose, shallowRef, watchEffect } from 'vue'; import { createCourier } from '@vielzeug/courier'; const client = createCourier({ baseUrl: 'https://api.example.com' }); type User = { id: number; name: string }; function useUserName(id: number) { // observe() triggers a background fetch and returns a store with select support const store = client.query.observe({ key: ['users', id], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id }, signal }), staleTime: 30_000, select: (user) => user?.name, placeholderData: 'Loading…', }); const name = shallowRef(store.peek().data); const stop = store.subscribe(() => { name.value = store.peek().data; }); onScopeDispose(stop); return { name }; } ``` ```ts [Svelte] import { readable } from 'svelte/store'; import { createCourier } from '@vielzeug/courier'; const client = createCourier({ baseUrl: 'https://api.example.com' }); type User = { id: number; name: string }; export function userNameStore(id: number) { // observe() triggers a background fetch and exposes select + placeholderData const store = client.query.observe({ key: ['users', id], fn: ({ signal }) => client.api.get('/users/{id}', { params: { id }, signal }), staleTime: 30_000, select: (user) => user?.name, placeholderData: 'Loading…', }); return readable(store.peek(), (set) => { set(store.peek()); return store.subscribe(() => set(store.peek())); }); } ``` ## Working with Other Vielzeug Libraries ### With Spell Validate response payloads at the API boundary before using them. ```ts import { createApi } from '@vielzeug/courier'; import { s } from '@vielzeug/spell'; const api = createApi({ baseUrl: 'https://api.example.com' }); const UserSchema = s.object({ id: s.number(), name: s.string().min(1) }); async function getUser(id: number) { const raw = await api.get('/users/{id}', { params: { id } }); return UserSchema.parse(raw); // throws ValidationError on unexpected shape } ``` ### With Ripple Use a Ripple store to hold the query result and drive reactive UI without framework-specific hooks. ```ts import { createApi, createQuery } from '@vielzeug/courier'; import { store, effect } from '@vielzeug/ripple'; type User = { id: number; name: string }; const api = createApi({ baseUrl: 'https://api.example.com' }); const qc = createQuery({ staleTime: 30_000 }); const userStore = store({ user: null, loading: false }); async function loadUser(id: number) { userStore.patch({ loading: true }); const user = await qc.fetch({ key: ['users', id], fn: ({ signal }) => api.get('/users/{id}', { params: { id }, signal }), }); userStore.patch({ user, loading: false }); } effect(() => console.log('user:', userStore.value.user?.name)); ``` ## Best Practices - Prefer `createCourier()` when your app needs REST, streams, shared interceptors, and one place to manage headers. Use `client.mutation()` for mutations — no separate import needed. - Use `times` consistently: `1` means one try and no retries. - Set `staleTime` on `createQuery` to match your data's freshness requirements; default is `0`. - Use the `invalidates` and `sets` shorthands on `client.mutation()` to keep the cache in sync without boilerplate in `onSuccess`. - Always pass the `signal` from query and mutation functions to the underlying request. - Use `dedupe: false` when you intentionally want separate in-flight requests for the same method + URL + response type. - Use `observe()` for components — it triggers a background fetch and supports `select` and `placeholderData`. Use `observe({ fetch: false })` when the cache is populated by another path and you only need the store. - Use `mutation.store`, `mutation.peek()`, or `mutation.subscribe()` for framework bindings; `mutation.store` is a stable `SyncStore` reference suitable for `useSyncExternalStore`. - Use `bindRefetch(qc)` instead of `refetchOnFocus`/`refetchOnReconnect` options — it is fully opt-in and the returned unbind function gives you explicit control. - Remember the timeout split: REST requests default to 30s, SSE and readable streams default to `Infinity` per connection. - When using `persistQueryCache`, call it **after** `hydrateQueryCache` resolves. Already-successful entries are eagerly persisted on setup. - Dispose long-lived clients in server-side code and tests to avoid leaking listeners or in-flight work. ### Examples ## Examples - [Crud Operations](./examples/crud-operations.md) - [Error Handling Patterns](./examples/error-handling-patterns.md) - [Authentication](./examples/authentication.md) - [Polling](./examples/polling.md) - [Disposal](./examples/disposal.md) - [Query Callbacks](./examples/query-callbacks.md) - [File Uploads](./examples/file-uploads.md) - [Mutation Cancel](./examples/mutation-cancel.md) - [Mutation Variables in Callbacks](./examples/mutation-variables.md) - [Optimistic Updates](./examples/optimistic-updates.md) - [Real-time Events](./examples/sse-events.md) - [AI Token Stream](./examples/ai-token-stream.md) ### REPL Examples - createBatcher - DataLoader Pattern (id: `batcher`) - Cache Persistence - persistQueryCache / hydrateQueryCache (id: `cache-persist`) - createCourier - Unified Client (id: `create-courier`) - debugCourier - Request Logging (devtools) (id: `debug-courier`) - HTTP Client - Basic Requests (id: `http-client-basic`) - HTTP Client - Custom Headers (id: `http-client-headers`) - HTTP Client - All Methods (id: `http-client-methods`) - HTTP Client - Path & Query Parameters (id: `http-client-params`) - HTTP Client - Interceptor Presets (id: `http-interceptors`) - Mutations - onSettled SettledResult (id: `mutation-settled`) - Query Client - Basic Caching (id: `query-client-basic`) - Query Client - Cache Invalidation (id: `query-client-invalidate`) - Mutations - Lifecycle & Store (id: `query-client-mutations`) - Query Client - observe() with select (id: `query-client-observe`) - Query Client - observe() with and without fetch (id: `query-client-watchkey`) - Stream - ReadableStream (text + NDJSON) (id: `stream-readable`) - Stream - Server-Sent Events (SSE) (id: `stream-sse`) - toSyncStore - Framework Adapter Bridge (id: `sync-store`) --- ## @vielzeug/dnd **Category:** ui-interaction **Keywords:** drag-drop, sortable, file-upload, drop-zone, dnd, reorder **Key exports:** createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept **Related:** ore, scroll, refine ### Overview ## Why Dnd? The HTML5 Drag & Drop API requires careful counter tracking to avoid hover state flicker, has no MIME type pre-filtering, and provides no sortable list abstraction. ```ts // Before — raw HTML5 Drag & Drop let enterCount = 0; dropzone.addEventListener('dragenter', () => { enterCount++; dropzone.classList.add('over'); }); dropzone.addEventListener('dragleave', () => { if (--enterCount === 0) dropzone.classList.remove('over'); }); dropzone.addEventListener('dragover', (e) => e.preventDefault()); dropzone.addEventListener('drop', (e) => { e.preventDefault(); enterCount = 0; const files = [...e.dataTransfer!.files]; if (!files.every((f) => f.type.startsWith('image/'))) return showError('Images only'); uploadFiles(files); }); // After — Dnd import { createDropZone } from '@vielzeug/dnd'; const zone = createDropZone({ element: dropzone, accept: ['image/*'], onDrop: (files) => uploadFiles(files), onDropRejected: (files) => showError(`${files.length} file(s) not accepted`), onHoverChange: (hovered) => dropzone.classList.toggle('over', hovered), }); ``` | Feature | DND | SortableJS | dnd-kit | | ------------------- | -------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~15 kB | ~30 kB | | Framework agnostic | | | | | MIME type filtering | Pre-validated | | | | Counter-based hover | | | N/A | | Sortable lists | | | | | Drag handles | | | | | `using` support | | | | | Zero dependencies | | | | **Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment. **Consider dnd-kit** if you are building a React app and need complex multi-container drag interactions or accessibility-first sortable trees. ## Installation ```sh [pnpm] pnpm add @vielzeug/dnd ``` ```sh [npm] npm install @vielzeug/dnd ``` ```sh [yarn] yarn add @vielzeug/dnd ``` ## Quick Start ```ts import { createDropZone, createSortable } from '@vielzeug/dnd'; // File drop zone — with async validation and paste support using zone = createDropZone({ element: document.getElementById('dropzone')!, accept: ['image/*', '.pdf'], paste: true, onValidate: async (files) => checkServerQuota(files), onDrop: (files) => uploadFiles(files), onDropRejected: (files) => { showError(`${files.length} file(s) not accepted`); }, onHoverChange: (hovered) => { document.getElementById('dropzone')!.classList.toggle('drag-over', hovered); }, }); // Sortable list — with revert support for optimistic updates using sortable = createSortable({ element: document.getElementById('list')!, keyboard: true, onBeforeReorder: (from, to) => { // record positions here before the DOM commits (for FLIP animations) }, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids, setRevert }) => { const prev = currentOrder; setOrder(ids); setRevert(() => setOrder(prev)); // enable sortable.revert() on failure }, }); ``` ## Features - **Counter-based hover state** — `onHoverChange` stays accurate when dragging over child elements; hover only activates when the drag payload passes the `accept` filter, with symmetric enter/leave pairing to prevent flicker - **MIME type pre-validation** — queries `dataTransfer.items` during drag to set `dropEffect='none'` before the drop; confirmed against `File.type` on drop - **Flexible accept patterns** — MIME types (`image/png`), wildcards (`image/*`), and file extensions (`.pdf`) - **`maxFiles` limit** — cap the number of accepted files per drop; excess files are forwarded to `onDropRejected` - **`onValidate` async gating** — optional async step after type filtering; `zone.validating` is `true` while a promise is pending; only receives type-accepted files - **Clipboard paste support** — `paste: true` routes pasted files through the same `accept`, `maxFiles`, and `onValidate` pipeline; `onPaste` provides a separate callback; paste rejections are forwarded to `onDropRejected` with the same `(files: File[]) => void` signature as drop rejections - **`onDropRejected`** — separate callback for files that didn't match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`; event type reflects whether the rejection came from a drop or a paste - **Sortable lists** — reorders DOM children with a placeholder indicator; fires `onReorder` only when the order actually changes - **Drag handles** — scope dragging to a child selector via `handle`; whole item is draggable when omitted - **Custom drag preview** — pass an element or a `(id, item, event) => element | null` factory; control hotspot with `dragImageOffset` - **`onBeforeReorder` FLIP hook** — fires before commit for both drag and keyboard moves; items are still in pre-commit positions, making it ideal for FLIP animation setup - **`sortable.revert()`** — register a revert function via `event.setRevert(fn)` inside `onReorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure - **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally - **Explicit connected scopes** — lists only exchange items when they share a `createSortableScope()` instance - **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers - **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup - **Reactive-friendly options** — `disabled` is re-read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone - **Zero dependencies** — gzipped, dependencies ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Orbit](/orbit/) — floating element positioning; use alongside Dnd to anchor drag previews and drop-zone indicators to precise positions - [Ore](/ore/) — web-component authoring framework; build draggable custom elements with Dnd's pointer event primitives - [Refine](/refine/) — accessible web components; Dnd powers the drag-and-drop inside Refine's sortable list and kanban components ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- | | `createDropZone()` | Create a typed drop-zone controller | Sync | Remember to destroy the controller during teardown | | `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations | | `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance | | `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended | | `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly | | `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called | | `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all | ## Package Entry Point | Import | Purpose | | --------------- | ---------------------- | | `@vielzeug/dnd` | Main exports and types | ## Types ### `Disposable` ```ts interface Disposable { readonly disposed: boolean; readonly disposalSignal: AbortSignal; dispose(): void; [Symbol.dispose](): void; } ``` ### `DropZoneOptions` ```ts interface DropZoneOptions { element: HTMLElement; accept?: string[]; maxFiles?: number; onValidate?: (files: File[]) => boolean | Promise; disabled?: boolean; dropEffect?: DataTransfer['dropEffect']; onDrop?: (files: File[]) => void; onDropRejected?: (files: File[]) => void; onHoverChange?: (hovered: boolean) => void; onValidatingChange?: (validating: boolean) => void; paste?: boolean; onPaste?: (files: File[]) => void; } ``` ### `DropZone` ```ts interface DropZone extends Disposable { readonly hovered: boolean; readonly validating: boolean; } ``` ### `SortableOptions` ```ts interface SortableOptions { element: HTMLElement; getKey: (element: HTMLElement) => string; scope?: SortableScope; handle?: string; keyboard?: boolean; axis?: 'vertical' | 'horizontal'; autoScroll?: boolean | AutoScrollOptions; dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined); dragImageOffset?: [number, number]; placeholderClass?: string; disabled?: boolean; onDragStart?: (id: string, event: DragEvent) => void; onDragEnd?: (id: string, event: DragEvent) => void; onBeforeReorder?: (from: string[], to: string[]) => void; onReorder?: (event: ReorderEvent) => void; } ``` ### `AutoScrollOptions` ```ts interface AutoScrollOptions { edgeThreshold?: number; speed?: number; container?: boolean; viewport?: boolean; } ``` ### `Sortable` ```ts interface Sortable extends Disposable { readonly isDragging: boolean; revert(): void; sync(): void; } ``` ### `SortableScope` ```ts declare function createSortableScope(): SortableScope; ``` Creates an explicit connection scope for multi-container sorting. Containers only exchange items when they share the same scope instance. ## `createDropZone()` ```ts declare function createDropZone(options: DropZoneOptions): DropZone; ``` Attaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle. | Option | Type | Default | Description | | ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. | | `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). | | `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. | | `onValidate` | `(files: File[]) => boolean \| Promise` | — | Optional async gating step. Called after type/`accept`/`maxFiles` filtering, before `onDrop`. Return or resolve `false` to reject all accepted files. `zone.validating` is `true` while a promise is pending. Only receives type-accepted files. | | `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. | | `dropEffect` | `'copy' \| 'move' \| 'link' \| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. | | `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. | | `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. | | `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. | | `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. | | `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. | **Returns:** `DropZone` Notes: - Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time. - Hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. Drags carrying rejected file types enter and leave the zone without triggering `onHoverChange`. - Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport. ```ts const zone = createDropZone({ element: dropEl, accept: ['image/*', '.pdf'], onDrop: (files) => { upload(files); }, onDropRejected: (files) => { showError(`${files.length} rejected`); }, onHoverChange: (hovered) => { dropEl.classList.toggle('drag-over', hovered); }, }); ``` ## `DropZone` Interface ### `zone.hovered` `readonly hovered: boolean` `true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time. ### `zone.validating` `readonly validating: boolean` `true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing. ```ts console.log(zone.validating); // true between drop and onValidate resolution ``` ### `zone.disposed` `readonly disposed: boolean` `true` once `dispose()` has been called. Safe to read at any time. ### `zone.disposalSignal` `readonly disposalSignal: AbortSignal` An `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime. ### `zone.dispose()` `dispose(): void` Removes all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times. ```ts zone.dispose(); ``` ### `zone[Symbol.dispose]()` `[Symbol.dispose](): void` Alias for `dispose()`. Called automatically when used with the `using` keyword. ```ts { using zone = createDropZone({ element: dropEl, onDrop: handleFiles }); } // zone.dispose() runs here ``` ## `createSortable()` ```ts declare function createSortable(options: SortableOptions): Sortable; ``` Makes the direct children of a container element reorderable via drag. Returns a `Sortable` handle. `createSortable` sets `draggable="true"` and `role="listitem"` on qualifying children and sets `role="list"` on the container at initialization. After DOM mutations, call `sortable.sync()` to re-apply sortable attributes explicitly. - `element`: `HTMLElement`, required. The container whose children become sortable. - `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped. - `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance. - `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable. - `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`. - `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion. - `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`. - `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely. - `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor. - `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element. - `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order. - `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts. - `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled. - `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for FLIP animation setup. - `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke. **Returns:** `Sortable` ```ts const boardScope = createSortableScope(); const sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.id!, handle: '.drag-handle', onDragStart: (id) => { listEl.classList.add('sorting'); }, onDragEnd: (id) => { listEl.classList.remove('sorting'); }, onReorder: ({ ids, setRevert }) => { const prev = currentOrder; saveOrder(ids); setRevert(() => saveOrder(prev)); }, scope: boardScope, }); ``` ### `createSortableScope()` ```ts declare function createSortableScope(): SortableScope; ``` Use one scope per connected set of containers. Sortables without an explicit scope use a private scope and remain isolated. ## `Sortable` Interface ### `sortable.isDragging` `readonly isDragging: boolean` `true` while an item drag is in progress. ### `sortable.revert()` `revert(): void` Calls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders. Only the most recent reorder can be reverted — a new reorder overwrites the stored function. ```ts const sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids, setRevert }) => { const prev = currentOrder; setOrder(ids); setRevert(() => setOrder(prev)); // ← enable revert }, }); // On server error: try { await api.saveOrder(ids); } catch { sortable.revert(); } ``` ### `sortable.sync()` `sync(): void` Re-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children. ### `sortable.disposed` `readonly disposed: boolean` `true` once `dispose()` has been called. ### `sortable.disposalSignal` `readonly disposalSignal: AbortSignal` An `AbortSignal` that fires when `dispose()` is called. ### `sortable.dispose()` `dispose(): void` Removes all event listeners from the container, strips sortable attributes from items and handles, and cancels any in-progress drag by restoring the original order. Idempotent — safe to call multiple times. ### `sortable[Symbol.dispose]()` `[Symbol.dispose](): void` Alias for `dispose()`. ## DOM Attributes Dnd reads and writes the following DOM attributes: - `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Removed by `dispose()`. - `draggable`: set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Enables native drag on each item or handle. - `role="list"`: set by `createSortable`, removed by `dispose()`. Accessibility role on the container. - `role="listitem"`: set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Accessibility role on each item. - `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state. - `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied. - `aria-hidden="true"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element. ## CSS Classes | Class | Applied to | When | | ----------------- | ---------------------------- | ------------------------------------------------------------- | | `dnd-placeholder` | `` inserted by sortable | While an item is being dragged, in the placeholder's position | ## `matchesAccept()` ```ts declare function matchesAccept(file: File, accept: string[]): boolean; ``` Tests whether a `File` matches an accept pattern list. Each pattern can be: - A MIME type: `'image/png'` - A MIME wildcard: `'image/*'` - A file extension: `'.pdf'` An empty list accepts everything. Extension matching is case-insensitive. ```ts import { matchesAccept } from '@vielzeug/dnd'; matchesAccept(file, ['image/*', '.pdf']); // true or false ``` ## `applyReorder()` ```ts declare function applyReorder(items: T[], ids: string[], getKey: (item: T) => string): T[]; ``` Applies a DOM reorder result (`orderedIds`) to your backing array. - IDs missing from `items` are ignored. - Items not listed in `ids` are appended in original order. - Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored. ```ts const next = applyReorder(items, orderedIds, (item) => item.id); ``` ### Usage Guide ## Basic Usage `createDropZone` attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter. ```ts import { createDropZone } from '@vielzeug/dnd'; const zone = createDropZone({ element: document.getElementById('dropzone')!, onDrop: (files) => { uploadFiles(files); }, }); ``` ### Accept filtering ```ts const zone = createDropZone({ element: dropEl, accept: ['image/*', '.pdf', 'application/json'], onDrop: (files) => { // accepted files only }, onDropRejected: (files) => { showToast(`${files.length} file(s) not accepted`); }, }); ``` The `accept` list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop. ### Hover state ```ts const zone = createDropZone({ element: dropEl, onHoverChange: (hovered) => { dropEl.classList.toggle('drag-over', hovered); }, }); ``` Read zone state imperatively: ```ts console.log(zone.hovered); console.log(zone.validating); ``` ### Drop effect ```ts createDropZone({ element: dropEl, dropEffect: 'move', onDrop: (files) => { // ... }, }); ``` ### Disabled state ```ts const options = { disabled: false, element: dropEl, onDrop: handleFiles }; const zone = createDropZone(options); // options.disabled is read live on each event — mutate to toggle: options.disabled = isReadOnly; ``` ### File limit ```ts const zone = createDropZone({ element: dropEl, accept: ['image/*'], maxFiles: 5, onDrop: (files) => { // 1-5 accepted files }, onDropRejected: (files) => { showToast(`Only 5 files at a time. ${files.length} were ignored.`); }, }); ``` ### Cleanup ```ts zone.dispose(); // or: using zone = createDropZone({ element: dropEl, onDrop: handleFiles }); ``` ### Async validation Gate drops behind an async check with `onValidate`. The zone sets `validating: true` while the promise is pending; on resolution, accepted files go to `onDrop` and rejected files go to `onDropRejected`. ```ts const zone = createDropZone({ element: dropEl, accept: ['image/*'], onValidate: async (files) => { const ok = await checkServerQuota(files); return ok; // false → all files forwarded to onDropRejected }, onDrop: (files) => uploadFiles(files), onDropRejected: (files) => showError('Quota exceeded'), }); // show a spinner while checking console.log(zone.validating); // true during pending check ``` A synchronous boolean return skips the microtask queue entirely: ```ts const zone = createDropZone({ element: dropEl, onValidate: (files) => files.every((f) => f.size { uploadFiles(files); }, onDropRejected: (files) => { showError(`${files.length} file(s) not accepted`); }, }); ``` When `onPaste` is omitted, accepted pasted files fall through to `onDrop`. ## Sortable `createSortable` makes direct children of a container reorderable via drag. ### Setup ```html Design Develop Review ``` ```ts const sortable = createSortable({ element: document.getElementById('task-list')!, getKey: (el) => el.dataset.sortId!, axis: 'vertical', onReorder: ({ ids }) => { saveTaskOrder(ids); }, }); ``` Dnd automatically sets: - `draggable="true"` on sortable nodes (or handles) - `role="listitem"` on each item - `role="list"` on the container - `tabindex="0"` on each item for keyboard reordering ### Drag handles ```ts createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, handle: '.drag-handle', onReorder: ({ ids }) => saveOrder(ids), }); ``` ### Keyboard reordering Focus an item and use arrow keys to move it. `Home` and `End` move to the boundary positions. When an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). Only keys that actually move an item call `preventDefault`. ### Connected lists Create a shared scope when items should move between containers: ```ts const boardScope = createSortableScope(); createSortable({ element: todoEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => saveTodoOrder(ids), scope: boardScope, }); createSortable({ element: doneEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => saveDoneOrder(ids), scope: boardScope, }); ``` ### Auto-scroll and drag preview ```ts createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, autoScroll: { edgeThreshold: 40, speed: 24, viewport: true }, dragImage: (id, item) => item, dragImageOffset: [8, 8], }); ``` Viewport scrolling is opt-in. Container scrolling stays enabled by default. ### Lifecycle hooks ```ts createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onDragStart: (id) => { listEl.classList.add('sorting'); }, onDragEnd: (id) => { listEl.classList.remove('sorting'); }, onReorder: ({ ids }) => saveOrder(ids), }); ``` ### Custom identity function ```ts createSortable({ element: listEl, getKey: (el) => el.getAttribute('data-id')!, onReorder: ({ ids }) => saveOrder(ids), }); ``` ### Dynamic lists Call `sortable.sync()` after adding, removing, or replacing sortable items. ```ts const item = document.createElement('li'); item.dataset.sortId = 'task-4'; item.textContent = 'Deploy'; listEl.appendChild(item); sortable.sync(); ``` ### Disabled state ```ts import { createSortable, type SortableOptions } from '@vielzeug/dnd'; const options: SortableOptions = { disabled: false, element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => saveOrder(ids), }; const sortable = createSortable(options); // options.disabled is read live on each event — mutate to toggle: options.disabled = isLocked; ``` ### Placeholder styling ```css .dnd-placeholder { background: var(--color-primary-50); border: 2px dashed var(--color-primary-300); border-radius: 4px; box-sizing: border-box; } [data-dragging] { opacity: 0.35; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } ``` ### Mapping DOM order back to data ```ts import { applyReorder, createSortable } from '@vielzeug/dnd'; let items = [ { id: 'task-1', title: 'Design' }, { id: 'task-2', title: 'Develop' }, { id: 'task-3', title: 'Review' }, ]; createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => { items = applyReorder(items, ids, (item) => item.id); }, }); ``` ### Cleanup ```ts sortable.dispose(); // or: using sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => saveOrder(ids), }); ``` ### FLIP animation hook `onBeforeReorder` fires just before the DOM reorder commits, for both drag and keyboard moves. At the time of the call items are still in their pre-commit positions, making it the right place to record element bounds for FLIP animations. ```ts const sortable = createSortable({ element: listEl, onBeforeReorder: (from, to) => { // snapshot bounds before the DOM moves const snapshots = new Map(getItems().map((el) => [el.dataset.sortId!, el.getBoundingClientRect()])); requestAnimationFrame(() => { // animate from snapshot to new position for (const [id, before] of snapshots) { const el = listEl.querySelector(`[data-sort-id="${id}"]`) as HTMLElement; const after = el.getBoundingClientRect(); const dy = before.top - after.top; if (dy === 0) continue; el.style.transform = `translateY(${dy}px)`; el.style.transition = 'none'; requestAnimationFrame(() => { el.style.transition = 'transform 200ms ease'; el.style.transform = ''; }); } }); }, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => saveOrder(ids), }); ``` ### Optimistic updates and revert Call `sortable.revert()` to roll back the most recent reorder. Register a revert function via `setRevert` inside `onReorder`. ```ts const sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids, setRevert }) => { const prev = currentOrder; setOrder(ids); // optimistic update setRevert(() => setOrder(prev)); // registered for sortable.revert() }, }); // On server error: try { await api.saveOrder(currentOrder); } catch { sortable.revert(); } ``` ## Framework Integration ```tsx [React] import { useEffect, useRef } from 'react'; import { createSortable, applyReorder } from '@vielzeug/dnd'; function SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) { const listRef = useRef(null); const items = useRef(initialItems); useEffect(() => { const sortable = createSortable({ element: listRef.current!, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => { items.current = applyReorder(items.current, ids, (i) => i.id); }, }); return () => sortable.dispose(); }, []); return ( {initialItems.map((item) => ( {item.text} ))} ); } ``` ```ts [Vue 3] import { ref, onMounted, onUnmounted } from 'vue'; import { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd'; function useSortable(items: { id: string; text: string }[]) { const listRef = ref(null); const orderedItems = ref(items); let sortable: Sortable | null = null; onMounted(() => { sortable = createSortable({ element: listRef.value!, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => { orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id); }, }); }); onUnmounted(() => sortable?.dispose()); return { listRef, orderedItems }; } ``` ```svelte [Svelte] import { onMount } from 'svelte'; import { createSortable, applyReorder } from '@vielzeug/dnd'; export let initialItems: { id: string; text: string }[] = []; let items = initialItems; let listEl: HTMLUListElement; onMount(() => { const sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); }, }); return () => sortable.dispose(); }); {#each items as item (item.id)} {item.text} {/each} ``` ## Working with Other Vielzeug Libraries ### With Ore Use Dnd in custom web components by attaching behavior in component lifecycle hooks. ```ts import { createSortable } from '@vielzeug/dnd'; import { define, onMounted, html } from '@vielzeug/ore'; define('task-list', { setup(_props, { host }) { onMounted(() => { const sortable = createSortable({ element: host.el, getKey: (el) => el.dataset.sortId!, onReorder: ({ ids }) => save(ids), }); return () => sortable.dispose(); }); return () => html``; }, }); ``` ## Best Practices - Attach `createDropZone` and `createSortable` after the container element is in the DOM — use `onMounted` in component frameworks. - Call `.dispose()` in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks. - Use `data-sort-id` attributes that match your data's identity field — do not use DOM index as an identifier. - Prefer `applyReorder()` over manual array splicing to keep your data array in sync with DOM order. - Use `createSortableScope()` only when items should genuinely move between containers. - Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection. - Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default. ### Examples ## Examples - [Sortable List](./examples/sortable-list.md) - [File Upload Drop Zone](./examples/file-upload-drop-zone.md) - [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md) - [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md) - [Connected Kanban Keyboard Sorting](./examples/connected-kanban-keyboard-sorting.md) - [Web Component With Ore](./examples/web-component-with-ore.md) - [Using `using` for scoped cleanup](./examples/using-using-for-scoped-cleanup.md) ### REPL Examples - createDropZone - Accept Filter (id: `drop-zone-accept`) - createDropZone - Basic (id: `drop-zone-basic`) - DropZone — disposed & disposalSignal (id: `drop-zone-disposal`) - matchesAccept - Accept Pattern Testing (id: `drop-zone-matches-accept`) - createDropZone - Async Validate (id: `drop-zone-validate`) - createSortableScope - Connected Lists (id: `sortable-connected`) - createSortable - Drag to Reorder (id: `sortable-list`) - createSortable - Optimistic Revert (id: `sortable-revert`) - createSortable - Drag Handle (id: `sortable-with-handle`) --- ## @vielzeug/familiar **Category:** workers **Keywords:** web-workers, pool, concurrency, offload, background, threading, timeout, streaming, priority **Key exports:** createWorker, createModuleWorker, task, createTestWorker, FamiliarError, FamiliarTimeoutError, FamiliarTaskError, FamiliarQueueFullError, FamiliarTerminatedError, FamiliarRuntimeError, FamiliarInvalidOptionsError **Related:** arsenal, ripple, herald ### Overview ## Why Familiar? Raw Web Workers require blob-URL boilerplate, untyped `postMessage`/`onmessage` pairs, and no built-in pooling, timeouts, or cancellation. ```ts // Before — raw Web Worker const blob = new Blob([`onmessage = (e) => postMessage(e.data * 2);`]); const rawWorker = new Worker(URL.createObjectURL(blob)); rawWorker.postMessage(21); rawWorker.onmessage = (e) => console.log(e.data); // 42 — untyped, no await, no error handling // After — familiar import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); const typedWorker = createWorker(double); console.log(await typedWorker.run(21)); // 42 — typed, awaitable, error-safe typedWorker.dispose(); ``` | Feature | Worker | Comlink | workerpool | | ----------------- | ------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~2 kB | ~10 kB | | Worker pools | | | | | Typed payloads | | Partial | | | Timeout support | | | | | Priority queue | | | | | AbortSignal | Queued tasks | | | | Streaming | `runStream()` | | | | Heartbeat | Auto for inline workers | | | | Typed errors | `instanceof FamiliarTimeoutError` etc. | | | | Testing utilities | | | | | Module workers | `createModuleWorker` | | | | Zero dependencies | | | | **Use Worker when** you need typed, awaitable Web Workers with pooling, priorities, timeouts, streaming, and cancellation. **Consider Comlink** if you only need a simple typed RPC proxy over a single Worker without pooling, priority, or timeout requirements. ## Installation ```sh [pnpm] pnpm add @vielzeug/familiar ``` ```sh [npm] npm install @vielzeug/familiar ``` ```sh [yarn] yarn add @vielzeug/familiar ``` ## Quick Start ```ts import { createWorker, task, FamiliarTimeoutError, FamiliarQueueFullError } from '@vielzeug/familiar'; // Wrap the function with task() to mark it as self-contained (safe to serialize) const sum = task((nums) => nums.reduce((a, b) => a + b, 0)); // Single worker — processes one task at a time const worker = createWorker(sum); console.log(await worker.run([1, 2, 3, 4, 5])); // 15 worker.dispose(); // Worker pool — 4 concurrent slots with a timeout const upper = task((text) => text.toUpperCase()); const pool = createWorker(upper, { concurrency: 4, timeout: 5000 }); const items = ['alpha', 'beta', 'gamma', 'delta']; const results = await Promise.all(items.map((item) => pool.run(item))); pool.dispose(); // Priority — higher values run first when tasks queue up await pool.run(urgentTask, { priority: 10 }); // Typed errors — precise instanceof checks try { await pool.run(input, { timeout: 100 }); } catch (err) { if (err instanceof FamiliarTimeoutError) console.error(`Timed out after ${err.timeoutMs}ms`); if (err instanceof FamiliarQueueFullError) console.error(`Queue full (max ${err.maxQueue})`); } ``` ## Features - **Type-safe** — payload types flow from `TaskFn` declaration to every `run()` call - **Web Worker backed** — CPU-bound work runs off the main thread, no jank - **Pool support** — create N workers via the `concurrency` option with built-in queuing - **Priority queue** — pass `priority` per-run; higher values run first with FIFO tiebreaking - **Timeout support** — pool-level or per-run `timeout` rejects with `FamiliarTimeoutError` - **Heartbeat monitoring** — `heartbeatWindow` kills tasks that stop responding, with auto-heartbeats for inline workers - **AbortSignal** — cancel queued tasks with the standard `AbortController` API - **Streaming** — `runStream()` for tasks that yield multiple partial results - **Batch** — `batch()` runs inputs through the pool and yields results ordered or as-completed - **Task groups** — `group()` ties related tasks to a shared abort and drain lifecycle - **Transferables** — move large buffers to the Worker without a structured-clone copy - **Prime** — pre-initialize worker slots to eliminate first-task latency - **Metrics** — `active`, `queued`, `completed`, `failed`, `groupCount` counters for observability - **Typed error hierarchy** — `FamiliarTimeoutError`, `FamiliarTaskError`, `FamiliarQueueFullError`, and more - **`[Symbol.dispose]`** — `using` keyword support (ES2025 explicit resource management) - **Module workers** — `createModuleWorker` loads a real `.js/.ts` module file as the Worker - **Testing utilities** — `createTestWorker` runs tasks in-process with call recording - **Zero dependencies** — no supply chain risk, minimal bundle size ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Arsenal](/arsenal/) — utility functions useful inside self-contained task functions - [Ripple](/ripple/) — pair with reactive signals to drive UI from worker pool metrics - [Herald](/herald/) — emit typed events from worker task functions back to the main thread ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ---------------------- | ------------------------------------------------ | -------------- | ----------------------------------------------- | | `createWorker()` | Create an inline worker or pool | Sync | Task functions must be entirely self-contained | | `createModuleWorker()` | Create a pool from a real module-worker file | Sync | Worker file must implement the message protocol | | `worker.run()` | Execute a task in a Worker | Async | Pass transferables for large buffers | | `worker.runStream()` | Execute a streaming task, yield partial results | Async iterator | Requires a free slot — cannot be queued | | `worker.batch()` | Run multiple inputs, yield results | Async iterator | Cancels remaining tasks on first failure | | `worker.group()` | Submit related tasks that share an abort + drain | Sync | `drain()` only waits for tasks added so far | | `createTestWorker()` | Run tasks in-process for tests | Async | Does not enforce serialization constraints | ## Package Entry Point | Import | Purpose | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `@vielzeug/familiar` | All public exports and types | | `@vielzeug/familiar/testing` | Test utilities (not in main bundle) | | `@vielzeug/familiar/protocol` | `handleMessages()`, `handleStreamMessages()` helpers + `PROTOCOL_VERSION` constant for module worker implementations | ## Package Exports ```ts // Main entry export { createWorker, createModuleWorker, task, FamiliarError, FamiliarInvalidOptionsError, FamiliarQueueFullError, FamiliarRuntimeError, FamiliarTaskError, FamiliarTerminatedError, FamiliarTimeoutError, } from '@vielzeug/familiar'; export type { BatchOptions, GroupOptions, RunOptions, TaskFn, TaskGroup, WorkerHandle, WorkerOptions, WorkerStatus, } from '@vielzeug/familiar'; // Test utilities export { createTestWorker } from '@vielzeug/familiar/testing'; export type { TestWorkerHandle, TestWorkerOptions } from '@vielzeug/familiar/testing'; // Protocol helpers (module worker implementations only) export { handleMessages, handleStreamMessages, PROTOCOL_VERSION } from '@vielzeug/familiar/protocol'; ``` ## Types ### `TaskFn` ```ts type TaskFn = (input: TInput) => TOutput | Promise; ``` The signature for the task function passed to `createWorker`. Accepts a single typed input and returns a value or a Promise. The function is serialized via `.toString()` and runs in an isolated Worker scope. It cannot reference variables, imports, or helpers from the outer module. --- ### `WorkerStatus` ```ts type WorkerStatus = 'idle' | 'running' | 'terminated'; ``` | Value | Meaning | | -------------- | -------------------------------------- | | `'idle'` | All worker slots are free | | `'running'` | One or more slots are executing a task | | `'terminated'` | `dispose()` was called | --- ### `WorkerOptions` ```ts type WorkerOptions = { concurrency?: number | 'auto'; heartbeatWindow?: number; maxQueue?: number; onFull?: 'reject' | 'wait'; onSlotError?: (error: FamiliarRuntimeError, restart: () => void) => void; timeout?: number; }; ``` | Field | Type | Default | Description | | ----------------- | -------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `concurrency` | `number \| 'auto'` | `1` | Worker slot count (1–512). `'auto'` uses `navigator.hardwareConcurrency`. Throws `FamiliarInvalidOptionsError` if out of range. | | `heartbeatWindow` | `number` | — | Watchdog window in ms applied to every task in the pool. If no heartbeat arrives within this window, the task is killed with `FamiliarTimeoutError`. Inline workers send heartbeats automatically at `heartbeatWindow / 2` intervals. | | `maxQueue` | `number` | unlimited | Maximum queued tasks. Exceeding this rejects with `FamiliarQueueFullError` (or suspends when `onFull='wait'`). | | `onFull` | `'reject' \| 'wait'` | `'reject'` | Behavior when queue is full. `'wait'` suspends the caller until a slot opens (natural backpressure). | | `onSlotError` | `(error, restart) => void` | — | Called when a Worker slot encounters an unhandled runtime error. `restart()` pre-warms a replacement Worker. | | `timeout` | `number` | — | Pool-level task timeout in milliseconds. Can be overridden per-run via `RunOptions.timeout`. | --- ### `RunOptions` ```ts type RunOptions = { priority?: number; signal?: AbortSignal; timeout?: number; transferables?: Transferable[]; }; ``` | Field | Type | Default | Description | | --------------- | ---------------- | ------- | -------------------------------------------------------------------------------------------- | | `priority` | `number` | `0` | Scheduling priority. Higher values run first when tasks queue up. Equal priorities are FIFO. | | `signal` | `AbortSignal` | — | Cancel a queued task before it starts. In-flight tasks cannot be interrupted. | | `timeout` | `number` | — | Per-run timeout in ms. Overrides `WorkerOptions.timeout` for this task. | | `transferables` | `Transferable[]` | `[]` | Objects to move (not copy) to the Worker thread. | `heartbeatWindow` is set on `WorkerOptions`, not per-run. All tasks in the pool share the same heartbeat watchdog window. --- ### `BatchOptions` ```ts type BatchOptions = Omit & { ordered?: boolean; }; ``` Extends `RunOptions` (minus `signal`) with: - `ordered`: `boolean`, default `true`. When `false`, results are yielded as each task completes (unordered, maximum throughput). --- ### `WorkerHandle` `WorkerHandle` is a flat interface — all capabilities on one type, no need to reference sub-types: ```ts interface WorkerHandle { // Lifecycle drain(timeoutMs?: number): Promise; dispose(): void; prime(): Promise; readonly disposed: boolean; readonly disposalSignal: AbortSignal; readonly status: WorkerStatus; [Symbol.dispose](): void; [Symbol.asyncDispose](): Promise; // Metrics readonly active: number; readonly completed: number; readonly concurrency: number; readonly failed: number; readonly groupCount: number; readonly queued: number; // Execution run(input: TInput, options?: RunOptions): Promise; runStream(input: TInput, options?: Omit): AsyncIterable; batch(inputs: TInput[], options?: BatchOptions): AsyncIterable; group(name?: string, options?: GroupOptions): TaskGroup; } ``` --- ### `GroupOptions` ```ts type GroupOptions = { signal?: AbortSignal; }; ``` | Field | Type | Description | | -------- | ------------- | ------------------------------------------------------------------------------------------------ | | `signal` | `AbortSignal` | When aborted, the group is aborted automatically. Composable with `WorkerHandle.disposalSignal`. | --- ### `TaskGroup` ```ts type TaskGroup = { abort(reason?: unknown): void; drain(): Promise[]>; run(input: TInput, options?: Omit): Promise; readonly name: string | undefined; readonly pending: number; readonly size: number; }; ``` Returned by `worker.group()`. See [`group()`](#group) below. | Member | Description | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `abort` | Cancels all pending tasks. In-flight tasks run to completion. | | `drain` | Resolves with `PromiseSettledResult[]` for every task submitted so far. Also closes the group (decrements `groupCount`). | | `name` | Optional name passed to `group(name)`, useful for logging and debugging. | | `pending` | Tasks not yet settled — decrements as tasks complete. | | `run` | Submits a task associated with this group. Throws `FamiliarTerminatedError` if the pool has been disposed or is draining (same as `worker.run()`). | | `size` | Total tasks ever submitted to this group (never decrements). | --- ### `task(fn)` — optional validator ```ts function task(fn: TaskFn): TaskFn; ``` Optional helper that validates `fn` is safe to serialize before passing to `createWorker`. `createWorker` accepts any `TaskFn` directly — `task()` exists only to catch the common mistake of passing a bound or native function. Throws `FamiliarInvalidOptionsError` if `fn` is a bound or native function. ```ts import { createWorker, task } from '@vielzeug/familiar'; // task() is optional — both forms are equivalent: const worker1 = createWorker((n: number) => n * 2); const worker2 = createWorker(task((n: number) => n * 2)); // validates fn is not native/bound // Catches mistakes at construction time: createWorker(task(Math.sqrt)); // throws FamiliarInvalidOptionsError ``` ## createWorker ```ts function createWorker( fn: TaskFn, options?: WorkerOptions, ): WorkerHandle; ``` Creates a typed worker or pool that executes `fn` in a Web Worker. `fn` is serialized via `.toString()` and runs in an isolated scope — it cannot close over module-level variables. Safe to call in any runtime — errors from Worker unavailability surface on the first `run()` call. ### Parameters | Parameter | Type | Description | | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `fn` | `TaskFn` | Task function. Serialized via `.toString()`, runs in an isolated scope. Use `task()` to validate it is not native/bound. | | `options` | `WorkerOptions` | Optional pool configuration. | Returns `WorkerHandle`. ```ts import { createWorker, task } from '@vielzeug/familiar'; // Single worker (concurrency=1) — pass fn directly: const worker = createWorker((text: string) => text.toUpperCase()); // Pool of 4 with a 3 s timeout: const pool = createWorker((n: number) => n ** 2, { concurrency: 4, timeout: 3000 }); // CPU-count concurrency: const autoPool = createWorker((n: number) => n * 2, { concurrency: 'auto' }); ``` ## createModuleWorker ```ts function createModuleWorker(url: URL | string, options?: WorkerOptions): WorkerHandle; ``` Creates a pool where each slot is a `{ type: 'module' }` Web Worker loaded from `url`. The Worker file is a normal ES module — it can use imports, top-level await, and module-scope helpers. ### Parameters | Parameter | Type | Description | | --------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | `URL \| string` | URL of the worker module. Use `new URL('./my-worker.ts', import.meta.url)` in bundlers. | | `options` | `WorkerOptions` | Optional pool configuration (same as `createWorker`). Note: `heartbeatWindow` is validated but has no effect on module workers (they must implement heartbeat manually). A dev-mode warning is emitted if it is set. | ### Worker File Protocol The module must handle `postMessage` with this schema: ```ts // Incoming from host: { id: number; input: TInput; stream?: boolean } // Reply with success: self.postMessage({ id, result: TOutput }); // Reply with error (any Error is structured-cloned natively — no manual serialization needed): self.postMessage({ id, error }); // Streaming — send chunks then a final result: self.postMessage({ id, chunk: TOutput }); // one or more chunks self.postMessage({ id, result: undefined }); // signals end of stream // Heartbeat (sent automatically by inline workers; module workers must send manually): self.postMessage({ id, heartbeat: true }); // sent at heartbeatWindow / 2 ms ``` ### `@vielzeug/familiar/protocol` Import from the `/protocol` sub-path in module worker files to implement the message protocol without boilerplate. #### `handleMessages(fn)` ```ts function handleMessages(fn: (input: TInput) => TOutput | Promise): void; ``` Sets up `self.onmessage` to handle the familiar host↔worker protocol. Errors from `fn` are caught and forwarded as structured `{ id, error }` messages — no manual try/catch needed. ```ts // my-worker.ts — zero boilerplate: import { handleMessages } from '@vielzeug/familiar/protocol'; handleMessages(async (input: { a: number; b: number }) => input.a + input.b); // main.ts import { createModuleWorker } from '@vielzeug/familiar'; const pool = createModuleWorker(new URL('./my-worker.ts', import.meta.url), { concurrency: 4, }); ``` #### `handleStreamMessages(fn)` ```ts function handleStreamMessages( fn: (input: TInput) => AsyncIterable | Promise>, ): void; ``` Sets up `self.onmessage` for a module worker that **yields streaming results**. The function must return an `AsyncIterable` (e.g. an `async function*`). Each yielded value is forwarded as a `{ id, chunk }` message, followed by `{ id, result: undefined }` to signal completion — the same protocol used by inline blob workers. ```ts // my-streaming-worker.ts import { handleStreamMessages } from '@vielzeug/familiar/protocol'; handleStreamMessages(async function* (n: number) { for (let i = 0; i (new URL('./my-streaming-worker.ts', import.meta.url)); for await (const chunk of pool.runStream(5)) { console.log(chunk); // 0, 1, 2, 3, 4 } ``` #### `PROTOCOL_VERSION` ```ts export const PROTOCOL_VERSION: 2; ``` Numeric constant for the current host↔worker message protocol. Include in a startup message (`self.postMessage({ protocol: PROTOCOL_VERSION })`) as a debugging convention to detect version skew from cached module workers. The host does **not** validate this value at runtime. ## WorkerHandle Members ### `run(input, options?)` `run(input: TInput, options?: RunOptions): Promise` Dispatches a task to the next available slot. If all slots are busy, the task enters the queue. **Rejects with:** | Error | Condition | | ---------------------------- | ------------------------------------------------------------------- | | `FamiliarQueueFullError` | `maxQueue` is set and the queue is at capacity (`onFull='reject'`) | | `FamiliarTimeoutError` | Task exceeded its timeout or heartbeat window | | `FamiliarTerminatedError` | `dispose()` was called before or during the task | | `FamiliarTaskError` | Task function threw an error | | `FamiliarRuntimeError` | Worker runtime or setup failure | | `DOMException (AbortError)` | Provided `signal` was aborted before the task started | --- ### `runStream(input, options?)` `runStream(input: TInput, options?: Omit): AsyncIterable` Runs a streaming task and yields partial results as they arrive. The task function must return an async iterable; each yielded value is forwarded as a chunk. `runStream()` cannot be queued. If all slots are busy it **throws `FamiliarRuntimeError` synchronously** at the call site. Use `run()` for queueable work. ```ts import { createWorker, task } from '@vielzeug/familiar'; const counter = task( (n) => (async function* () { for (let i = 0; i ` Runs all inputs through the pool and yields results. By default yields in submission order. Pass `ordered: false` to yield as each task completes (maximum throughput). ```ts const pool = createWorker((n) => n * 2, { concurrency: 4 }); // Submission order (default) for await (const result of pool.batch([1, 2, 3, 4, 5])) { console.log(result); // 2, 4, 6, 8, 10 } // Completion order — maximum throughput for await (const result of pool.batch([1, 2, 3], { ordered: false })) { console.log(result); // arrives as soon as each task finishes } pool.dispose(); ``` If any task throws, `batch()` aborts remaining queued tasks and re-throws the error. `batch()` submits tasks in a window bounded by `concurrency`: at most `concurrency` results can be settled-but-unread ahead of the consumer at any time, regardless of the total batch size. A slow consumer applies natural backpressure instead of buffering the entire batch in memory. --- ### `group(name?, options?)` `group(name?: string, options?: GroupOptions): TaskGroup` Creates a task group. All tasks submitted via `group.run()` share an `AbortController` and can be cancelled or awaited as a unit. An optional `name` is stored on the group for logging and debugging. Pass `options.signal` to tie the group's lifetime to an external `AbortSignal` — when the signal aborts, the group aborts automatically: ```ts import { createWorker, task } from '@vielzeug/familiar'; const square = task((n) => n * 2); const pool = createWorker(square, { concurrency: 4 }); // Tied to pool lifetime — group aborts automatically when pool disposes: const g = pool.group('batch-1', { signal: pool.disposalSignal }); const p1 = g.run(1); const p2 = g.run(2); const p3 = g.run(3); const results = await g.drain(); // resolves with PromiseSettledResult[] console.log(results[0]); // { status: 'fulfilled', value: 2 } // Cancel all pending tasks in the group g.abort(); pool.dispose(); ``` #### `TaskGroup.drain()` `drain(): Promise[]>` Waits for all tasks submitted to the group _so far_ to settle. Returns an array of `PromiseSettledResult` — one per task, in submission order. Unlike `Promise.allSettled`, the group's individual promises still reject normally; `drain()` collects all outcomes without throwing. Calling `abort()` concurrently while `drain()` is pending is safe: aborted tasks appear as `{ status: 'rejected' }` entries in the result. `drain()` clears its internal task list after snapshotting, so settled Promise references become eligible for GC. For groups that cycle through many drain() calls, this prevents accumulation of settled Promise objects. #### `TaskGroup.abort(reason?)` `abort(reason?: unknown): void` Cancels all pending group tasks. In-flight tasks run to natural completion. #### `TaskGroup.name` `readonly name: string | undefined` Optional name provided when the group was created via `group(name)`. #### `TaskGroup.pending` `readonly pending: number` Number of tasks not yet settled (active + queued). Decrements as tasks complete or fail. #### `TaskGroup.size` `readonly size: number` Total tasks ever submitted to this group (never decrements). --- ### `drain(timeoutMs?)` `drain(timeoutMs?: number): Promise` Graceful shutdown. Waits until all queued and in-flight tasks settle, then terminates all workers. Calling `run()` after `drain()` has started rejects with `FamiliarTerminatedError`. If `timeoutMs` is given and the pool has not gone idle within that window, rejects with `FamiliarTimeoutError` and force-terminates. ```ts await pool.drain(); // drain then terminate await pool.drain(5000); // must drain within 5 s ``` --- ### `dispose()` `dispose(): void` Immediate forceful termination. Rejects all in-flight and queued tasks with `FamiliarTerminatedError`. After `dispose()`, `status` is `'terminated'` and further `run()` calls reject immediately. --- ### `disposed` `readonly disposed: boolean` `true` after `dispose()` has been called or `drain()` has settled. Use to guard against post-termination calls. --- ### `disposalSignal` `readonly disposalSignal: AbortSignal` `AbortSignal` aborted when the pool is terminated (via `dispose()` or `drain()` settling). Use to tie external lifetimes (polling loops, SSE connections, etc.) to the pool's lifecycle. ```ts const pool = createWorker((n) => n * 2); startPolling({ signal: pool.disposalSignal }); // polling stops automatically when the pool is disposed ``` --- ### `prime()` `prime(): Promise` Pre-initializes all worker slots by spawning their `Worker` instances now. Resolves when all slots are ready. Call during application startup to eliminate first-task cold-start latency. If the Worker API is unavailable (e.g. SSR), `prime()` silently resolves. Errors surface on the first `run()` call. ```ts const pool = createWorker((n) => n * 2, { concurrency: 4 }); await pool.prime(); // pre-spawn all 4 threads const result = await pool.run(21); // no cold-start ``` --- ### Metrics | Member | Type | Description | | ------------- | -------------- | -------------------------------------------------------------------------------------- | | `active` | `number` | Slots currently executing a task | | `completed` | `number` | Successful tasks since creation | | `concurrency` | `number` | Configured slot count | | `failed` | `number` | Tasks rejected with task/timeout/worker error (excludes aborts and terminations) | | `groupCount` | `number` | Active groups — decrements when all tasks settle naturally or when `drain()` is called | | `queued` | `number` | Tasks waiting in queue (accurate — excludes cancelled items) | | `status` | `WorkerStatus` | Current lifecycle state | --- ### `[Symbol.dispose]()` / `[Symbol.asyncDispose]()` `[Symbol.dispose](): void` — alias for `dispose()`, enables the `using` keyword. `[Symbol.asyncDispose](): Promise` — alias for `drain()`, enables `await using`. ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); // Synchronous dispose — terminates immediately { using worker = createWorker(double); await worker.run(21); // 42 } // dispose() called automatically // Async dispose — drains then terminates { await using pool = createWorker(double, { concurrency: 4 }); const results = await Promise.all([1, 2, 3].map((n) => pool.run(n))); } // drain() called automatically ``` ## Error Model All worker errors extend `FamiliarError`. Use `instanceof FamiliarError` to catch any familiar error, or the specific subclass for precise handling. ```ts class FamiliarError extends Error {} ``` ### Error Hierarchy | Class | Extra fields | When thrown | | ------------------------------ | -------------------- | ----------------------------------------------------- | | `FamiliarTimeoutError` | `.timeoutMs: number` | Task exceeded timeout or heartbeat window | | `FamiliarTaskError` | `.cause: unknown` | Task function threw | | `FamiliarQueueFullError` | `.maxQueue: number` | Queue at capacity (`onFull='reject'`) | | `FamiliarTerminatedError` | — | `dispose()` called; task was in-flight or queued | | `FamiliarRuntimeError` | `.cause?: unknown` | Worker API unavailable or unhandled thread error | | `FamiliarInvalidOptionsError` | — | Invalid `createWorker` / `createModuleWorker` options | ```ts import { FamiliarQueueFullError, FamiliarTaskError, FamiliarTerminatedError, FamiliarTimeoutError } from '@vielzeug/familiar'; try { await worker.run(input, { timeout: 500 }); } catch (err) { if (err instanceof FamiliarTimeoutError) { console.error(`Timed out after ${err.timeoutMs}ms`); } else if (err instanceof FamiliarTaskError) { console.error('Task threw:', err.cause); } else if (err instanceof FamiliarQueueFullError) { console.error(`Queue full (maxQueue=${err.maxQueue})`); } else if (err instanceof FamiliarTerminatedError) { console.error('Worker was disposed'); } } ``` ## Testing Utilities Import from the `/testing` subpath — not included in the main bundle: ```ts import { createTestWorker } from '@vielzeug/familiar/testing'; import type { TestWorkerHandle, TestWorkerOptions } from '@vielzeug/familiar/testing'; ``` Error classes are also re-exported from the `/testing` subpath so test files need only one import. --- ### `createTestWorker` ```ts function createTestWorker( fn: (input: TInput) => TOutput | Promise, options?: TestWorkerOptions, ): TestWorkerHandle; ``` Creates an in-process test double. `fn` runs on the same thread — no Worker is spawned. Successful calls are recorded in `handle.calls`. Errors propagate unwrapped (not wrapped in `FamiliarError`), so vitest assertion errors surface directly in test output. --- ### `TestWorkerOptions` ```ts type TestWorkerOptions = { concurrency?: number; errorWrapping?: boolean; maxQueue?: number; onFull?: 'reject' | 'wait'; }; ``` | Field | Type | Default | Description | | --------------- | -------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `concurrency` | `number` | `1` | In-process slot count. Default `1` for deterministic ordering. Increase only when testing concurrency-specific behavior. | | `errorWrapping` | `boolean` | `false` | When `true`, errors from `fn` are wrapped in `FamiliarTaskError` (with `.cause` set to the original error), mirroring real worker behavior. Useful when testing code that checks `instanceof FamiliarError`. | | `maxQueue` | `number` | unlimited | Queue capacity before rejecting with `FamiliarQueueFullError`. | | `onFull` | `'reject' \| 'wait'` | `'reject'` | Queue-full behavior. | --- ### `TestWorkerHandle` ```ts type TestWorkerHandle = WorkerHandle & { readonly calls: ReadonlyArray; }; ``` Extends `WorkerHandle` with `.calls` — all successful `run()` invocations in call order. **Differences from the real worker:** - Tasks run in-process — serialization constraints are not enforced. - `prime()` is a no-op (tasks run in-process). - `runStream()` is not supported (rejects with `FamiliarRuntimeError` on first `next()`). - Error wrapping is skipped by default — task errors propagate as-is for better test DX. Set `errorWrapping: true` to mirror real worker behavior. ```ts import { createTestWorker } from '@vielzeug/familiar/testing'; import { describe, expect, it } from 'vitest'; describe('add worker', () => { it('records calls', async () => { const worker = createTestWorker(({ a, b }) => a + b); expect(await worker.run({ a: 2, b: 3 })).toBe(5); expect(await worker.run({ a: 10, b: 20 })).toBe(30); expect(worker.calls).toHaveLength(2); expect(worker.calls[0]!.input).toEqual({ a: 2, b: 3 }); expect(worker.calls[1]!.output).toBe(30); worker.dispose(); }); }); ``` ### Usage Guide New to Worker? Start with the [Overview](./index.md) for a quick introduction. ## Basic Usage Wrap your task function with `task()` before passing it to `createWorker`. This marks the function as self-contained and safe to serialize into a Web Worker. Because the function is serialized via `.toString()` and executed in a separate global scope, it **must be entirely self-contained** — it cannot close over variables from the surrounding module. ```ts import { createWorker, task } from '@vielzeug/familiar'; import type { TaskFn } from '@vielzeug/familiar'; // Inline function wrapped with task() const worker = createWorker(task((n) => n * 2)); // Named function reference — also fine function double(n: number): number { return n * 2; } const worker2 = createWorker(task(double)); // Define once, reuse across pools type Fn = TaskFn; const add = task(({ a, b }) => a + b); const addWorker = createWorker(add); ``` The task function runs inside a Web Worker with a separate global scope. Any outer-scope variable you reference will be `undefined` at runtime. Put helpers inside the task function or encode them into the input payload. `task()` throws `FamiliarInvalidOptionsError` if you pass a bound or native function (e.g. `Math.sqrt.bind(null)`). ## Single Worker Calling `createWorker` without a `concurrency` option creates a single worker that processes one task at a time. Additional calls to `run()` are queued and dispatched in order. ```ts import { createWorker, task } from '@vielzeug/familiar'; const upper = task((text) => text.toUpperCase()); const worker = createWorker(upper); console.log(await worker.run('hello')); // 'HELLO' console.log(await worker.run('world')); // 'WORLD' worker.dispose(); ``` ## Worker Pool Pass `concurrency` to spin up multiple worker slots. Tasks are dispatched to the first idle slot; if all slots are busy the task is queued. ```ts import { createWorker, task } from '@vielzeug/familiar'; // Fixed pool of 4 const fib = task((n) => { function fib(x: number): number { return x pool.run(n))); pool.dispose(); // 'auto' — uses navigator.hardwareConcurrency when available const square = task((n) => n ** 2); const autoPool = createWorker(square, { concurrency: 'auto' }); ``` ## Queue Back-Pressure (`maxQueue`) Set `maxQueue` to cap how many tasks can wait in the queue. When the queue is full and `onFull` is `'reject'` (the default), `run()` rejects immediately with `FamiliarQueueFullError`: ```ts import { createWorker, task, FamiliarQueueFullError } from '@vielzeug/familiar'; const double = task((n) => n * 2); const worker = createWorker(double, { concurrency: 1, maxQueue: 100, }); try { await worker.run(1); } catch (error) { if (error instanceof FamiliarQueueFullError) { console.error(`Back-pressure triggered: queue is full (max ${error.maxQueue})`); } } ``` For producer→consumer pipelines, use `onFull: 'wait'` to suspend the caller instead. See [Queue Back-Pressure (`onFull`)](#queue-back-pressure-onfull) below. ## Timeouts Set `timeout` (in milliseconds) to automatically reject tasks that run too long. A `FamiliarTimeoutError` is thrown. ```ts import { createWorker, task, FamiliarTimeoutError } from '@vielzeug/familiar'; const delay = task((ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))); const worker = createWorker(delay, { timeout: 1000, }); try { await worker.run(5000); // will reject after 1 s } catch (err) { if (err instanceof FamiliarTimeoutError) { console.error(`Task timed out after ${err.timeoutMs}ms`); } } worker.dispose(); ``` ## AbortSignal Pass an `AbortSignal` via `RunOptions` to cancel a **queued** task before it starts. Tasks already in flight cannot be interrupted. ```ts import { createWorker, task } from '@vielzeug/familiar'; const upper = task((text) => text.toUpperCase()); const worker = createWorker(upper, { concurrency: 1 }); const ac = new AbortController(); // Queue multiple tasks const p1 = worker.run('first'); const p2 = worker.run('second', { signal: ac.signal }); const p3 = worker.run('third', { signal: ac.signal }); // Cancel the queued tasks ac.abort(); // p2 and p3 reject with DOMException (AbortError) await p1; // still resolves — it was already in flight worker.dispose(); ``` ## Transferables Large `ArrayBuffer`, `MessagePort`, or `OffscreenCanvas` values can be moved to the Worker thread instead of copied. This avoids the structured-clone overhead on large payloads. Pass the transferable list via `RunOptions.transferables`: ```ts import { createWorker, task } from '@vielzeug/familiar'; type ImageTask = { pixels: Uint8ClampedArray; width: number; height: number }; type ImageResult = { pixels: Uint8ClampedArray }; const grayscale = task(({ pixels }) => { const out = new Uint8ClampedArray(pixels.length); for (let i = 0; i ((n) => n * 2)); console.log(worker.status); // 'idle' const p = worker.run(21); console.log(worker.status); // 'running' await p; console.log(worker.status); // 'idle' worker.dispose(); console.log(worker.status); // 'terminated' ``` ## Stats Use lightweight counters for visibility and load monitoring: - `completed`: successful tasks since creation - `failed`: tasks rejected with a task / timeout / worker error (aborts and terminations excluded) - `active`: number of slots currently executing a task - `queued`: tasks currently waiting in the queue - `groupCount`: active task groups (decrements when `drain()` is called or all tasks settle) ```ts import { createWorker, task } from '@vielzeug/familiar'; const pool = createWorker( task((n) => n + 1), { concurrency: 4 }, ); console.log(pool.completed); // 0 console.log(pool.failed); // 0 console.log(pool.active); // 0 console.log(pool.queued); // 0 console.log(pool.groupCount); // 0 await pool.run(1); await pool.run(-1).catch(() => {}); // throws inside worker console.log(pool.completed); // 1 console.log(pool.failed); // 1 ``` ## Batch Processing (`batch`) Use `batch()` to run a list of inputs through the pool and consume results as they arrive, in submission order: ```ts import { createWorker, task } from '@vielzeug/familiar'; const fib = task((n) => { function fib(x: number): number { return x ( ({ start, end }) => (async function* () { for (let i = start; i setTimeout(r, 10)); // simulate work per chunk yield i; } })() as unknown as number[], ); const worker = createWorker(rangeStream); for await (const chunk of worker.runStream({ start: 1, end: 5 })) { console.log('chunk:', chunk); // 1, 2, 3, 4, 5 } worker.dispose(); ``` Breaking out of the loop early (or throwing from the body) releases the slot cleanly — no leak, no stale timers. ## Task Groups (`group`) Use `group()` when you want to cancel and drain a set of related tasks together. Pass an optional name for logging and debugging. ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); const pool = createWorker(double, { concurrency: 4 }); const g = pool.group('my-batch'); // Submit tasks into the group const p1 = g.run(1); const p2 = g.run(2); const p3 = g.run(3); // Wait for all group tasks to settle — returns PromiseSettledResult[] const results = await g.drain(); console.log(results[0]); // { status: 'fulfilled', value: 2 } console.log(g.name); // 'my-batch' console.log(g.size); // 3 pool.dispose(); ``` `drain()` resolves with a `PromiseSettledResult[]` array — every task outcome in submission order. Individual promises still reject normally; `drain()` just collects all outcomes without throwing: ```ts const g = pool.group(); g.run(1).catch(() => {}); g.run(-1).catch(() => {}); // will fail inside the worker g.run(3).catch(() => {}); const settled = await g.drain(); const failures = settled.filter((r) => r.status === 'rejected'); console.log(failures.length); // 1 ``` Cancel all pending tasks in the group with `g.abort()`: ```ts const g = pool.group(); const p1 = g.run(slowTask1); const p2 = g.run(slowTask2); const p3 = g.run(slowTask3); g.abort(); // p2, p3 (queued) reject; p1 (in-flight) completes normally await p1; ``` ## Priority Queue Pass `priority` per `run()` call. Higher values run before lower values when tasks queue up. Equal priorities are FIFO. ```ts import { createWorker, task } from '@vielzeug/familiar'; const upper = task((s) => s.toUpperCase()); const pool = createWorker(upper, { concurrency: 1 }); // Queue multiple tasks — the slot is busy with a blocker const blocker = pool.run('low-priority-blocker'); pool.run('low', { priority: 1 }); pool.run('critical', { priority: 100 }); // runs first once blocker finishes pool.run('normal', { priority: 10 }); // Execution order after blocker: critical → normal → low ``` Default priority is `0`. ## Per-Run Timeout The `timeout` option can also be passed per `run()` call, overriding the pool-level timeout for that specific task: ```ts import { createWorker, task } from '@vielzeug/familiar'; const upper = task((s) => s.toUpperCase()); const pool = createWorker(upper, { timeout: 5000, // default: 5 s }); // This specific task must complete in 100ms await pool.run('hello', { timeout: 100 }); ``` ## Graceful Shutdown (`drain`) Use `drain()` to finish queued/in-flight work before terminating workers: ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); const worker = createWorker(double, { concurrency: 1 }); const p1 = worker.run(1); const p2 = worker.run(2); await worker.drain(); await p1; await p2; console.log(worker.status); // 'terminated' ``` Pass a timeout to prevent indefinite hangs — if the pool hasn't drained within that window, `drain()` rejects with `FamiliarTimeoutError` and force-terminates: ```ts try { await worker.drain(5000); // must drain within 5 s } catch (err) { // timed out — worker is now force-terminated } ``` Use `dispose()` for immediate forceful termination. ## Heartbeat Monitoring Set `heartbeatWindow` on `WorkerOptions` to kill tasks that stop responding (e.g., blocked CPU work). If the worker does not send a heartbeat within `heartbeatWindow` ms, the task is rejected with `FamiliarTimeoutError`. **Inline workers** (`createWorker`) send heartbeats automatically at `heartbeatWindow / 2` intervals — no worker-side code needed: ```ts import { createWorker, task } from '@vielzeug/familiar'; const heavy = task(() => new Promise((r) => setTimeout(r, 5000))); // 60s watchdog — auto-heartbeats keep it alive throughout const worker = createWorker(heavy, { heartbeatWindow: 60_000 }); await worker.run(undefined); worker.dispose(); ``` **Module workers** (`createModuleWorker`) must send heartbeats manually at `heartbeatWindow / 2` intervals. Note: passing `heartbeatWindow` to `createModuleWorker` emits a dev warning — it has no automatic effect on module workers: ```ts // my-worker.ts self.onmessage = async (event) => { const { id, input } = event.data; // Send heartbeat at heartbeatWindow / 2 intervals (e.g. every 30s for a 60s window) const hb = setInterval(() => self.postMessage({ id, heartbeat: true }), 30_000); try { self.postMessage({ id, result: await heavyWork(input) }); } finally { clearInterval(hb); } }; // main.ts — heartbeatWindow is informational; the worker must implement heartbeats itself const pool = createModuleWorker(new URL('./my-worker.ts', import.meta.url)); ``` ## Slot Error Handling (`onSlotError`) Use `onSlotError` to be notified when a Worker slot crashes with an unhandled runtime error. The slot is stopped automatically; call `restart()` to pre-warm a replacement Worker. ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); const pool = createWorker(double, { concurrency: 4, onSlotError: (error, restart) => { console.error('Worker slot crashed:', error.message); // Optionally pre-warm the replacement Worker immediately restart(); }, }); ``` If `onSlotError` is omitted, errors are handled silently and the slot restarts lazily on the next `run()` call. ## Queue Back-Pressure (`onFull`) By default, `run()` rejects with `FamiliarQueueFullError` when `maxQueue` is reached (`onFull: 'reject'`). Set `onFull: 'wait'` to suspend the caller instead — natural backpressure for producer→consumer pipelines: ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); const pool = createWorker(double, { concurrency: 2, maxQueue: 10, onFull: 'wait', // callers suspend until a queue slot opens }); // Producer — never rejects with FamiliarQueueFullError for (let i = 0; i { const { id, input } = event.data; try { self.postMessage({ id, result: await heavyLib.process(input) }); } catch (error) { // Error objects are structured-cloned natively — no manual serialization needed self.postMessage({ id, error }); } }; // main.ts import { createModuleWorker } from '@vielzeug/familiar'; const pool = createModuleWorker(new URL('./my-worker.ts', import.meta.url), { concurrency: 4 }); const result = await pool.run('hello'); pool.dispose(); ``` See the [API Reference](./api.md#createmoduleworker) for the full message protocol schema. ## Typed Errors Each failure reason has its own class with extra fields for context. Use `instanceof` checks for precise handling: ```ts import { createWorker, task, FamiliarQueueFullError, FamiliarTaskError, FamiliarTerminatedError, FamiliarTimeoutError, } from '@vielzeug/familiar'; const validate = task((n) => { if (n ((n) => n * 2)); try { console.log(await worker.run(21)); } catch (error) { if (error instanceof FamiliarRuntimeError) { console.error('Worker execution is unavailable in this runtime'); } } ``` This keeps construction cheap and predictable in shared modules, while still failing clearly when the runtime cannot execute Workers. ## `Symbol.dispose` / `using` Declarations `WorkerHandle` implements `[Symbol.dispose]` as an alias for `dispose()`, enabling the TC39 [explicit resource management](https://github.com/tc39/proposal-explicit-resource-management) `using` keyword (TypeScript ≥ 5.2 with `"lib": ["es2025"]`): ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); { using worker = createWorker(double); const result = await worker.run(21); // 42 } // worker.dispose() is called automatically here ``` This also works with worker pools: ```ts const upper = task((text) => text.toUpperCase()); { using pool = createWorker(upper, { concurrency: 4 }); const results = await Promise.all(['hello', 'world'].map((s) => pool.run(s))); // ['HELLO', 'WORLD'] } // all 4 slots terminated automatically ``` `createTestWorker` supports `[Symbol.dispose]` as well: ```ts import { createTestWorker } from '@vielzeug/familiar/testing'; { using worker = createTestWorker((n) => n * 3); const result = await worker.run(7); // 21 } // disposed automatically ``` ## Testing Use `createTestWorker` from the `/test` subpath to run tasks in-process with call recording. Workers never spawn, so tests run in any environment (Node, jsdom, etc.) without additional setup. ```ts import { createTestWorker } from '@vielzeug/familiar/testing'; import { describe, expect, it } from 'vitest'; type Input = { a: number; b: number }; type Output = number; describe('add worker', () => { it('returns the sum', async () => { const worker = createTestWorker(({ a, b }) => a + b); expect(await worker.run({ a: 2, b: 3 })).toBe(5); expect(await worker.run({ a: 10, b: 20 })).toBe(30); // Inspect recorded calls expect(worker.calls).toHaveLength(2); expect(worker.calls[0]!.input).toEqual({ a: 2, b: 3 }); expect(worker.calls[1]!.output).toBe(30); worker.dispose(); }); }); ``` `TestWorkerHandle` also supports `[Symbol.dispose]`: ```ts { using worker = createTestWorker((n) => n * 2); const result = await worker.run(21); // 42 } ``` ### `TestWorkerOptions` ```ts type TestWorkerOptions = { concurrency?: number; // default: 1 errorWrapping?: boolean; // default: false maxQueue?: number; onFull?: 'reject' | 'wait'; }; ``` The default `concurrency: 1` gives deterministic serial execution. Increase it only when testing concurrency-specific behavior: ```ts // Test that 3 tasks run truly in parallel const worker = createTestWorker((n) => new Promise((r) => setTimeout(() => r(n), 20)), { concurrency: 3, }); const start = Date.now(); const results = await Promise.all([worker.run(1), worker.run(2), worker.run(3)]); console.log(Date.now() - start); // ~20ms — ran in parallel ``` ## Prime (Pre-initialize) Call `prime()` after creating a pool to pre-spawn all Worker threads and eliminate cold-start latency on the first task. ```ts import { createWorker, task } from '@vielzeug/familiar'; const double = task((n) => n * 2); const pool = createWorker(double, { concurrency: 4 }); // Pre-spawn during app init and await readiness await pool.prime(); // First run() has no cold-start overhead const result = await pool.run(21); // 42 pool.dispose(); ``` Prime is best-effort. If the Worker API is unavailable (SSR, Node.js without Worker support), it silently does nothing and the error surfaces on the first `run()` call instead. ## Framework Integration Worker handles are plain objects — wrap them in a hook or composable to integrate with your framework's lifecycle. ```tsx [React] import { useEffect, useRef } from 'react'; import { createWorker, task, type WorkerHandle, type TaskFn } from '@vielzeug/familiar'; function useWorker(fn: TaskFn, concurrency = 2) { const ref = useRef | null>(null); useEffect(() => { const worker = createWorker(fn, { concurrency }); void worker.prime(); ref.current = worker; return () => { worker.drain(); }; }, []); return ref; } const getByteLength = task((buf) => buf.byteLength); function ImageProcessor() { const workerRef = useWorker(getByteLength, 4); async function handleUpload(file: File) { const buf = await file.arrayBuffer(); const size = await workerRef.current!.run(buf); console.log('Processed', size, 'bytes'); } return handleUpload(selectedFile)}>Process; } ``` ```vue [Vue 3] import { createWorker, task } from '@vielzeug/familiar'; import { onScopeDispose, ref } from 'vue'; const pool = createWorker( task((n: number) => n * n), { concurrency: 2 }, ); void pool.prime(); onScopeDispose(() => pool.drain()); const result = ref(null); async function runTask(n: number) { result.value = await pool.run(n); } Square {{ result }} ``` ```svelte [Svelte] import { createWorker, task } from '@vielzeug/familiar'; import { onDestroy } from 'svelte'; const pool = createWorker(task((n: number) => n * n), { concurrency: 2 }); void pool.prime(); onDestroy(() => pool.drain()); let result: number | null = null; async function runTask() { result = await pool.run(9); } Square {result} ``` ### Pitfalls - **React:** Initializing the pool with `createWorker(fn, ...)` directly in the component body (not inside `useEffect` or `useRef`) creates a new pool on every render. Always use `useRef` for stable initialization. - **Vue 3:** Creating the pool inside a `watch` or `computed` callback instead of at the top level of `setup()` can result in multiple pools being created. Always create at the top level and register `onScopeDispose` immediately. - **Svelte:** The pool created at the top of `` starts immediately — if the component is conditionally rendered with `{#if}`, the pool is created when the component mounts. This is correct. Ensure `onDestroy` is called to close it when the component is removed. ## Working with Other Vielzeug Libraries ### With Herald Emit progress events from a worker task and consume them on the main thread. ```ts import { createWorker, task } from '@vielzeug/familiar'; import { createBus } from '@vielzeug/herald'; const bus = createBus(); bus.on('progress', (pct) => console.log(`${pct}%`)); const processItems = task(async (items) => { for (let i = 0; i ((n) => n * 2); const pool = createWorker(double, { concurrency: 4 }); const active = signal(pool.active); const queued = signal(pool.queued); const isBusy = computed(() => active() > 0); async function runTask(input: number) { active.set(pool.active); const result = await pool.run(input); active.set(pool.active); queued.set(pool.queued); return result; } ``` ## Best Practices - Always wrap task functions with `task()` — this is the compile-time signal that a function is safe to serialize. - Use `concurrency` > 1 for CPU-bound tasks — multiple slots prevent head-of-line blocking. - Set `maxQueue` to bound memory usage when consumers are slower than producers. - Pass large binary data (images, audio, WASM buffers) via `transfer()` or `RunOptions.transferables` to avoid copying. - Use `AbortSignal` to cancel queued tasks when the user navigates away. - Call `await pool.prime()` at startup when you know tasks will arrive soon, to eliminate first-task cold-start latency. - Always call `drain()` in framework cleanup callbacks to terminate worker threads and free resources. - Keep worker task functions pure and self-contained — avoid closures over mutable main-thread state. - Use `createTestWorker()` in unit tests to run tasks in-process without spinning up real Worker threads. ### Examples ## Examples - [Fibonacci With Pool And Timeout](./examples/fibonacci-with-pool-and-timeout.md) - [Data Transformation Pipeline](./examples/data-transformation-pipeline.md) - [Image Processing](./examples/image-processing.md) - [Using Transferables](./examples/using-transferables.md) - [Cancellable Batch](./examples/cancellable-batch.md) - [Priority Queue](./examples/priority-queue.md) - [Streaming With runStream](./examples/streaming-with-runstream.md) - [Module Worker](./examples/module-worker.md) - [Typed Error Handling](./examples/typed-error-handling.md) - [React Integration](./examples/react-integration.md) - [Testing With createTestWorker](./examples/testing-with-createtestworker.md) --- ## @vielzeug/flux **Category:** reactive **Keywords:** streams, observables, reactive, operators, cold, hot, subjects, adapters **Key exports:** flux, createSubject, createBehaviorSubject, createReplaySubject, of, from, fromEvent, interval, timer, empty, never, throwError (+49 more) **Related:** ripple, herald, pulse, courier ### Overview ## Why Flux? Callback chains and `Promise`-only pipelines break down when you need multi-value, cancellable, composable data flows. Flux gives you a small, typesafe stream primitive with a complete operator library — no heavyweight runtime, no magic. ```ts // Before — nested callbacks, no cancellation function search(query: string, cb: (results: string[]) => void) { const id = setTimeout(() => fetchResults(query).then(cb), 300); return () => clearTimeout(id); // manual cleanup } // After — composable, self-cleaning pipeline import { flux, fromEvent, debounce, switchMap, from } from '@vielzeug/flux'; const results$ = fromEvent(input, 'input').pipe( debounce(300), switchMap((e) => from(fetchResults((e.target as HTMLInputElement).value))), ); const unsub = results$.subscribe(renderResults); // unsub() cancels everything, including in-flight fetches ``` | Feature | Flux | RxJS | Observable (TC39) | | ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | | Bundle size | | ~50 KB | Native (no bundle) | | Zero dependencies | | | | | Cold by default | | | | | Disposable (not just teardown)| | Partial | | | Ripple signal adapters | Native | | | | Operator library | 40+ operators | 100+ | WIP| **Use Flux when** you need multi-value composable pipelines with cancellation, especially in a Vielzeug project that already uses Ripple, Herald, or Pulse. **Consider RxJS when** you need the full RxJS operator catalogue, rely on RxJS-aware third-party libraries, or are migrating an existing codebase. ## Installation ```sh [pnpm] pnpm add @vielzeug/flux ``` ```sh [npm] npm install @vielzeug/flux ``` ```sh [yarn] yarn add @vielzeug/flux ``` ## Quick Start ```ts import { flux, map, take, toArray } from '@vielzeug/flux'; // Create a cold stream — producer runs per subscriber const integers$ = flux((observer) => { let i = 0; const id = setInterval(() => observer.next(i++), 100); return () => clearInterval(id); // cleanup on unsubscribe }); // Compose operators via .pipe() const first5$ = integers$.pipe( map((n) => n * 2), take(5), ); // Collect to an array (returns a Promise) const result = await toArray(first5$); console.log(result); // [0, 2, 4, 6, 8] // Or subscribe directly const unsub = integers$.pipe(take(3)).subscribe({ next(v) { console.log(v); }, complete() { console.log('done'); }, error(err) { console.error(err); }, }); // unsub() to cancel early ``` ## Features - `flux()` — Cold stream factory; producer runs once per subscriber - `createSubject()` — Hot multicasting subject with `emit()` / `complete()` / `fail()` - `createBehaviorSubject()` — Subject that replays the latest value to new subscribers - `createReplaySubject()` — Subject that replays the last N values to new subscribers - **40+ operators** — Creation, transformation, filtering, combination, utility - `pipe()` — Chainable, type-safe operator composition - `dispose()` — First-class lifecycle; shuts down the stream and all subscriptions - `AbortSignal` support — `takeUntil(signal)` integrates with standard cancellation - **Ripple adapters** — `fromSignal()` / `toSignal()` bridge signals and streams - **Herald adapters** — `fromBus()` / `toBus()` bridge typed event buses - **Pulse adapters** — `fromPulse()` / `fromPresence()` for real-time channels - **Courier adapters** — `fromSse()` / `fromQuery()` for HTTP sources; use `from()` for `AsyncIterable` - `toPromise()` / `toArray()` — Collect stream output into standard async primitives ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — Reactive signals and effects; `fromSignal()`/`toSignal()` connect Flux streams to Ripple's signal graph - [Herald](/herald/) — Typed event bus; `fromBus()`/`toBus()` wrap bus events as Flux streams - [Pulse](/pulse/) — Real-time WebSocket channels; `fromPulse()`/`fromPresence()` expose channel data as streams - [Courier](/courier/) — HTTP client; `fromSse()`/`fromQuery()` wrap Courier sources as streams ### API Reference ## API Overview | Symbol | Purpose | Mode | Gotcha | | -------------------------- | ------------------------------------------ | ----- | ----------------------------------------- | | `flux()` | Cold stream factory | Sync | Producer runs once per subscriber | | `createSubject()` | Hot multicasting subject | Sync | No replay; late subscribers miss values | | `createBehaviorSubject()` | Subject replaying latest value | Sync | Initial value required | | `createReplaySubject()` | Subject replaying last N values | Sync | `bufferSize (producer: Producer): Flux; ``` Creates a cold `Flux`. The `producer` runs once per subscriber and returns a cleanup function. **Parameters:** | Parameter | Type | Description | | ---------- | ------------- | ----------------------------------------------------- | | `producer` | `Producer` | Called on each `subscribe()`; returns cleanup or void | **Returns:** `Flux` **Example:** ```ts import { flux } from '@vielzeug/flux'; const source$ = flux((observer) => { const id = setInterval(() => observer.next(Date.now()), 1000); return () => clearInterval(id); }); const unsub = source$.subscribe(console.log); setTimeout(unsub, 5000); // stop after 5 seconds ``` --- ## Subjects ### `createSubject()` ```ts createSubject(): Subject; ``` Returns a hot multicasting `Subject`. Emits to all current subscribers; late subscribers miss past values. **Returns:** `Subject` **Methods:** | Method | Signature | Description | | ------------ | ---------------------------- | ------------------------------------ | | `emit()` | `(value: T) => void` | Push a value to all subscribers | | `complete()` | `() => void` | Complete all subscriptions and dispose | | `fail()` | `(err: unknown) => void` | Error all subscriptions and dispose | | `dispose()` | `() => void` | Complete all subscriptions and permanently shut down | **Example:** ```ts import { createSubject } from '@vielzeug/flux'; const click$ = createSubject(); button.addEventListener('click', (e) => click$.emit(e)); ``` --- ### `createBehaviorSubject()` ```ts createBehaviorSubject(initial: T): BehaviorSubject; ``` Like `createSubject`, but replays the most recent value to every new subscriber. **Parameters:** | Parameter | Type | Description | | --------- | ---- | ------------------------------------- | | `initial` | `T` | Value emitted immediately on subscribe | **Returns:** `BehaviorSubject` **Additional members:** | Member | Type | Description | | -------- | ---- | ---------------------------------- | | `value` | `T` | Read the current value (getter) | **Example:** ```ts import { createBehaviorSubject } from '@vielzeug/flux'; const theme$ = createBehaviorSubject('light'); theme$.subscribe((t) => document.body.dataset.theme = t); theme$.emit('dark'); ``` --- ### `createReplaySubject()` ```ts createReplaySubject(bufferSize: number): ReplaySubject; ``` Creates a `ReplaySubject` that buffers up to `bufferSize` values and replays them to every new subscriber immediately on subscription. **Parameters:** | Parameter | Type | Description | | ------------ | -------- | ---------------------------------------- | | `bufferSize` | `number` | Maximum values to keep in the buffer (min 1; non-finite or `` **Additional members:** | Member | Type | Description | | --------- | ------------ | ----------------------------------------- | | `buffer` | `readonly T[]` | Snapshot of the current replay buffer | **Example:** ```ts import { createReplaySubject } from '@vielzeug/flux'; const log$ = createReplaySubject(3); log$.emit('a'); log$.emit('b'); log$.subscribe(console.log); // logs 'a', 'b' immediately ``` --- ## Creation Operators ### `of()` ```ts of(...values: T[]): Flux; ``` Emits all `values` synchronously then completes. --- ### `from()` ```ts from(source: Iterable | AsyncIterable | Promise): Flux; ``` Converts an iterable, async iterable, or promise to a `Flux`. Errors from the source are forwarded to `observer.error`. --- ### `fromEvent()` ```ts fromEvent(target: EventTarget, eventName: string): Flux; ``` Emits every time `target` fires `eventName`. Removes the listener when unsubscribed. --- ### `interval()` ```ts interval(ms: number, scheduler?: Scheduler): Flux; ``` Emits an incrementing integer (starting at `0`) every `ms` milliseconds. Does not complete; must be unsubscribed or combined with `take()`. Pass a custom `scheduler` to control timer execution (useful in tests). --- ### `timer()` ```ts timer(delay: number, intervalMs?: number, scheduler?: Scheduler): Flux; ``` Emits `0` after `delay` ms, then (if `intervalMs` given) emits incrementing values every `intervalMs` ms. Pass a custom `scheduler` to control timer execution. --- ### `empty()` ```ts empty(): Flux; ``` Completes immediately without emitting any values. --- ### `never()` ```ts never(): Flux; ``` Never emits, never completes, never errors. --- ### `throwError()` ```ts throwError(error: unknown | (() => unknown)): Flux; ``` Errors immediately. Accepts a value or a factory function — the factory is called on each subscribe. --- ## Transformation Operators ### `map()` ```ts map(fn: (value: T) => U): Operator; ``` Applies `fn` to each value. --- ### `filter()` ```ts filter(predicate: (value: T) => boolean): Operator; ``` Emits only values for which `predicate` returns `true`. --- ### `scan()` ```ts scan(accumulator: (acc: U, value: T) => U, seed: U): Operator; ``` Applies `accumulator` on each value; emits the running result. --- ### `switchMap()` ```ts switchMap(fn: (value: T) => Flux): Operator; ``` For each emission maps to an inner `Flux`; cancels the previous inner subscription when a new outer value arrives. --- ### `flatMap()` ```ts flatMap(fn: (value: T) => Flux): Operator; ``` Maps to inner fluxes and merges them concurrently. --- ### `concatMap()` ```ts concatMap(fn: (value: T) => Flux, maxBuffer?: number): Operator; ``` Maps to inner fluxes and subscribes to them sequentially, waiting for each to complete. When `maxBuffer` is set, queue items beyond that limit are dropped silently. --- ### `distinctUntilChanged()` ```ts distinctUntilChanged(comparator?: (a: T, b: T) => boolean): Operator; ``` Suppresses consecutive duplicate values. Uses `Object.is` by default. --- ### `startWith()` ```ts startWith(...values: T[]): Operator; ``` Prepends `values` before the first source emission. --- ### `bufferCount()` ```ts bufferCount(size: number, every?: number): Operator; ``` Collects emissions into arrays of length `size`. Starts a new buffer every `every` emissions (default: `size`, non-overlapping). A partial buffer is flushed when the source completes. `size`/`every` values that are non-finite or `(): Operator; ``` Emits `[previous, current]` tuples. No emission until the second source value arrives. --- ## Filtering Operators ### `take()` ```ts take(count: number): Operator; ``` Emits the first `count` values, then completes. --- ### `skip()` ```ts skip(count: number): Operator; ``` Skips the first `count` values. --- ### `first()` ```ts first(): Operator; ``` Emits only the first value, then completes. --- ### `last()` ```ts last(): Operator; ``` Emits only the last value (on source completion). --- ### `takeWhile()` ```ts takeWhile(predicate: (value: T) => boolean): Operator; ``` Emits values while `predicate` returns `true`; completes when it first returns `false`. --- ### `takeUntil()` ```ts takeUntil(notifier: AbortSignal | Flux): Operator; ``` Completes when `notifier` aborts (if `AbortSignal`) or emits (if `Flux`). --- ### `debounce()` ```ts debounce(ms: number, scheduler?: Scheduler): Operator; ``` Emits the last value after `ms` ms of silence. The timer resets on every emission. **Note:** a pending value is dropped if the source completes before the timer fires. Pass a custom `scheduler` to control the timer. --- ### `throttle()` ```ts throttle(ms: number, clock?: () => number): Operator; ``` Emits the first value in each `ms`-millisecond window; subsequent values in the window are dropped. Pass a custom `clock` function (returns a timestamp in ms) to control time in tests. --- ### `sample()` ```ts sample(notifier: Flux): Operator; ``` Emits the latest source value each time `notifier` emits. Does not emit if no source value has arrived since the last sample. --- ## Combination Operators ### `merge()` ```ts merge(...sources: Flux[]): Flux; ``` Subscribes to all `sources` simultaneously and emits their values as they arrive. --- ### `concat()` ```ts concat(...sources: Flux[]): Flux; ``` Subscribes to `sources` sequentially — each source must complete before the next starts. --- ### `combineLatest()` ```ts combineLatest(...sources: { [K in keyof T]: Flux }): Flux; ``` Emits a tuple of the latest value from each source whenever any source emits. Does not emit until all sources have emitted at least once. --- ### `withLatestFrom()` ```ts withLatestFrom(other: Flux): Operator; ``` Combines each source emission with the latest value from `other`. Does not emit if `other` has not yet emitted. --- ### `race()` ```ts race(...sources: Flux[]): Flux; ``` Subscribes to all sources; once any emits its first value, unsubscribes from all others and continues with the winner. --- ### `zip()` ```ts zip(...sources: { [K in keyof T]: Flux }): Flux; ``` Pairs emissions by index — emits `[a1, b1]`, then `[a2, b2]`, etc. --- ### `forkJoin()` ```ts forkJoin(...sources: { [K in keyof T]: Flux }): Flux; ``` Waits for all sources to complete, then emits a single tuple of the last value from each. --- ## Utility Operators ### `tap()` ```ts tap(fn: (value: T) => void): Operator; ``` Runs a side effect on each value without modifying it. --- ### `delay()` ```ts delay(ms: number, scheduler?: Scheduler): Operator; ``` Delays each emission by `ms` milliseconds. Pass a custom `scheduler` to control the timer. --- ### `timeout()` ```ts timeout(ms: number, scheduler?: Scheduler): Operator; ``` Errors with `FluxTimeoutError` if no value arrives within `ms` ms since the last emission (or subscription). The timer resets on each emission — this is an inactivity timeout, not a total-duration timeout. Pass a custom `scheduler` to control the timer. --- ### `catchError()` ```ts catchError(fn: (error: unknown) => Flux): Operator; ``` Intercepts errors and replaces the failed stream with the `Flux` returned by `fn`. --- ### `retry()` ```ts retry( count: number, delayMs?: number | ((attempt: number) => number), scheduler?: Scheduler, ): Operator; ``` On error, re-subscribes to the source up to `count` times. Propagates the error after all retries are exhausted. `delayMs` can be a fixed number or a backoff function `(attempt) => ms`. Pass a custom `scheduler` to control the delay timer. --- ### `finalize()` ```ts finalize(fn: () => void): Operator; ``` Calls `fn` when the stream completes, errors, or is unsubscribed — whichever comes first. --- ### `flow()` ```ts flow(op1: Operator): Operator; flow(op1: Operator, op2: Operator): Operator; // ...up to 8 operators flow(...operators: Operator[]): Operator; ``` Composes multiple operators into a single reusable operator. Equivalent to chaining `.pipe()` calls but extractable as a named pipeline. **Example:** ```ts import { flow, filter, map, take } from '@vielzeug/flux'; const evenTenx = flow( filter((n: number) => n % 2 === 0), map((n: number) => n * 10), take(5), ); source$.pipe(evenTenx).subscribe(console.log); ``` --- ### `share()` ```ts share(): Operator; ``` Multicasts one source execution to all subscribers. Re-subscribes to the source when the first new subscriber arrives after all previous subscribers have left. --- ### `shareReplay()` ```ts shareReplay(bufferSize?: number): Operator; ``` Like `share`, but replays the last `bufferSize` emissions to late subscribers (default `1`). Non-finite or `(source: Flux, signal?: AbortSignal): Promise; ``` Returns a `Promise` that resolves with the last value when the source completes (`undefined` if it completed without emitting), or rejects if it errors. Pass `signal` to unsubscribe and reject early — with the signal's abort reason, or a `DOMException('Aborted', 'AbortError')` if none was given — if the caller loses interest before the source completes (e.g. a component unmounting). **Note:** `toPromise` is a direct function, not an operator — pass the `Flux` as the first argument. --- ### `toArray()` ```ts toArray(source: Flux, signal?: AbortSignal): Promise; ``` Returns a `Promise` that resolves with all emitted values when the source completes. Pass `signal` to stop early and resolve with the values collected so far (rather than reject) if the caller loses interest before completion. **Note:** direct function, not an operator. --- ## Adapters ### `fromSignal()` (Ripple) ```ts fromSignal(source: Readable): Flux; ``` Emits the signal's current value immediately on subscribe, then emits on every change. Requires `@vielzeug/ripple`. --- ### `toSignal()` (Ripple) ```ts toSignal(source: Flux, opts: ToSignalOptions): SignalBinding; ``` Creates a `SignalBinding` whose `value` tracks each emission from `source`. Call `binding.dispose()` to stop tracking; the last value remains readable after disposal. **Parameters — `ToSignalOptions`:** | Option | Type | Default | Description | | --------- | ------------- | ----------- | ---------------------------------------------- | | `initial` | `T` | (required) | Value before the first emission | | `signal` | `AbortSignal` | `undefined` | Stops the subscription when aborted | --- ### `fromBus()` / `toBus()` (Herald) ```ts fromBus(bus: Bus, event: K): Flux; toBus(bus: Bus, event: K): Operator; ``` Bridge between a `@vielzeug/herald` `Bus` and a `Flux`. Requires `@vielzeug/herald`. --- ### `fromPulse()` / `fromPresence()` (Pulse) ```ts fromPulse(pulse: Pulse, event: K): Flux; fromPresence(channel: PresenceChannel): Flux>; ``` Wrap Pulse channel events as streams. Requires `@vielzeug/pulse`. --- ### `fromSse()` / `fromQuery()` (Courier) ```ts fromSse(source: SseSource, event: K): Flux; fromQuery(store: { peek(): T; subscribe(fn: () => void): () => void }): Flux; ``` Wrap Courier sources as streams. Requires `@vielzeug/courier`. To consume an `AsyncIterable` or `ReadableStream`, use the generic `from()` operator. --- ## Types ```ts // Core stream type interface Flux { readonly disposed: boolean; readonly disposalSignal: AbortSignal; subscribe( observerOrNext: Observer | ((value: T) => void), signal?: AbortSignal, ): Unsubscribe; pipe(op1: Operator): Flux; pipe(op1: Operator, op2: Operator): Flux; // ...up to 9 operators dispose(): void; [Symbol.dispose](): void; [Symbol.asyncIterator](): AsyncIterableIterator; } // Observer passed to subscribe() interface Observer { next: (value: T) => void; complete?: () => void; error?: (error: unknown) => void; } // Operator — pure function from Flux to Flux type Operator = (source: Flux) => Flux; // Producer — passed to flux() type Producer = (observer: Observer) => (() => void) | void; // Cleanup handle type Unsubscribe = () => void; // Timer scheduling abstraction (inject in tests to control time) type Scheduler = { delay(fn: () => void, ms: number): () => void; repeat(fn: () => void, ms: number): () => void; }; // Built-in scheduler backed by real timers const DEFAULT_SCHEDULER: Scheduler; // Hot subject types interface Subject extends Flux { emit(value: T): void; complete(): void; fail(err: unknown): void; } interface BehaviorSubject extends Subject { readonly value: T; } interface ReplaySubject extends Subject { readonly buffer: readonly T[]; } // toSignal() options interface ToSignalOptions { initial: T; signal?: AbortSignal; } // Handle returned by toSignal() interface SignalBinding { readonly value: T; // reactive — reads track in ripple effects readonly signal: Readable; // underlying ripple Readable readonly disposed: boolean; readonly disposalSignal: AbortSignal; dispose(): void; [Symbol.dispose](): void; } ``` --- ## Errors ### `FluxError` Base class for all Flux errors. `instanceof FluxError` matches any Flux-specific error. Static helper: `FluxError.is(err)` — equivalent to `err instanceof FluxError`, inherited by every subclass (including `FluxTimeoutError`), so it does not narrow to a specific subclass. ### `FluxTimeoutError` Thrown by `timeout(ms)` when no emission arrives within `ms` milliseconds of the last emission. **Properties:** `ms: number` — the configured timeout duration. ### Usage Guide ## Basic Usage ```ts import { flux, map, take } from '@vielzeug/flux'; const double$ = flux((observer) => { observer.next(1); observer.next(2); observer.next(3); observer.complete?.(); }).pipe( map((n) => n * 2), take(2), ); double$.subscribe({ next(v) { console.log(v); }, // 2, 4 complete() { console.log('done'); }, }); ``` ## Creating Streams ### From static values ```ts import { of, from, empty, never, throwError } from '@vielzeug/flux'; of(1, 2, 3).subscribe(console.log); // 1 2 3 from([10, 20]).subscribe(console.log); // 10 20 from(Promise.resolve('hello')).subscribe(console.log); // hello empty().subscribe({ complete: () => console.log('done') }); ``` ### From time ```ts import { interval, timer } from '@vielzeug/flux'; // Emits 0, 1, 2, ... every 500ms const tick$ = interval(500); // Emits 0 after 1 second, then completes const oneShot$ = timer(1000); ``` ### From DOM events ```ts import { fromEvent, debounce } from '@vielzeug/flux'; const clicks$ = fromEvent(document, 'click'); const keyups$ = fromEvent(input, 'keyup').pipe(debounce(300)); ``` ## Subjects A `Subject` is a hot stream — it multicasts to all current subscribers. Use it as the imperative entry point into a pipeline. ```ts import { createSubject, createBehaviorSubject } from '@vielzeug/flux'; const events$ = createSubject(); events$.subscribe((v) => console.log('A:', v)); events$.subscribe((v) => console.log('B:', v)); events$.emit('hello'); // A: hello B: hello events$.fail(new Error('oops')); // errors all subscribers and disposes // events$.complete() ends all subscriptions cleanly and disposes ``` `createBehaviorSubject` replays the latest value to every new subscriber: ```ts const count$ = createBehaviorSubject(0); count$.emit(1); count$.emit(2); // Late subscriber immediately receives 2 count$.subscribe((v) => console.log(v)); // 2 ``` ## Subscribing `subscribe` accepts either a function or an observer object: ```ts // Function shorthand — only receives values const unsub = source$.subscribe((v) => console.log(v)); // Full observer const unsub = source$.subscribe({ next(v) { /* ... */ }, complete() { /* ... */ }, error(err) { /* ... */ }, }); // Tie the subscription to an AbortSignal — aborted = auto-unsubscribe const ac = new AbortController(); const unsub = source$.subscribe((v) => console.log(v), ac.signal); ac.abort(); // unsubscribes immediately // Cancel at any time unsub(); ``` ## Composing Operators All operators are used via `.pipe()`: ```ts import { filter, map, take, debounce } from '@vielzeug/flux'; const result$ = source$.pipe( filter((v) => v > 0), map((v) => v * 10), take(5), ); ``` ### Transformation ```ts import { map, scan, switchMap, flatMap, concatMap, startWith, bufferCount, pairwise } from '@vielzeug/flux'; // Accumulate running total of(1, 2, 3).pipe(scan((acc, n) => acc + n, 0)); // 1, 3, 6 // Prepend static values of(3, 4).pipe(startWith(1, 2)); // 1, 2, 3, 4 // Emit pairs of consecutive values of(1, 2, 3).pipe(pairwise()); // [1,2], [2,3] // Collect into fixed-size arrays of(1, 2, 3, 4).pipe(bufferCount(2)); // [1,2], [3,4] // Cancel previous inner stream on each new outer emission search$.pipe(switchMap((q) => from(fetch(`/api?q=${q}`)))); ``` ### Filtering ```ts import { take, skip, first, last, takeWhile, takeUntil, debounce, throttle, sample } from '@vielzeug/flux'; source$.pipe(take(3)); // first 3 values source$.pipe(skip(2)); // skip first 2 source$.pipe(takeWhile((n) => n (); source$.pipe(takeUntil(stop$)).subscribe(console.log); stop$.emit(); // stops the subscription ``` ### Combination ```ts import { merge, concat, combineLatest, withLatestFrom, zip, forkJoin } from '@vielzeug/flux'; // Merge two streams — emit as values arrive merge(stream1$, stream2$); // Sequential — exhaust stream1$ before subscribing to stream2$ concat(stream1$, stream2$); // Emit tuple when all sources have at least one value combineLatest(a$, b$); // Each a$ emission paired with the latest b$ value a$.pipe(withLatestFrom(b$)); // Pair by index — [a1, b1], [a2, b2] zip(a$, b$); // Wait for all to complete; emit tuple of last values forkJoin(a$, b$); ``` ## Error Handling ```ts import { catchError, retry, of } from '@vielzeug/flux'; // Recover with a fallback stream source$.pipe(catchError(() => of('fallback'))); // Retry up to 3 times before propagating source$.pipe(retry(3)); // Retry with 500ms delay between attempts source$.pipe(retry(3, 500)); ``` ## Multicasting ```ts import { share, shareReplay } from '@vielzeug/flux'; // Share one execution among all subscribers (unsubscribes when last subscriber leaves) const shared$ = expensiveSource$.pipe(share()); // Replay last N values to late subscribers const replayed$ = source$.pipe(shareReplay(2)); ``` ## Converting to Promises / Arrays ```ts import { toPromise, toArray } from '@vielzeug/flux'; const last = await toPromise(of(1, 2, 3)); // 3 const all = await toArray(of(1, 2, 3)); // [1, 2, 3] ``` Both accept an optional `AbortSignal` to stop waiting on a source that never completes (e.g. cancel when a component unmounts). `toPromise` rejects on abort; `toArray` resolves with whatever it collected so far: ```ts const ac = new AbortController(); const pending = toArray(longLivedStream$, ac.signal); ac.abort(); // resolves `pending` with the values collected up to this point ``` ## Disposal `dispose()` terminates a stream permanently. All active subscribers receive a `complete` notification, then no further values are accepted: ```ts const subject = createSubject(); subject.subscribe({ next: console.log, complete: () => console.log('done') }); subject.dispose(); // logs 'done'; no further values accepted ``` For the `flux()` factory, each subscription is cancelled by the `Unsubscribe` function returned by `subscribe()`. The stream itself does not need to be explicitly disposed. ## Framework Integration ```ts [React] import { useEffect, useState } from 'react'; import type { Flux } from '@vielzeug/flux'; function useFlux(source: Flux, initial: T): T { const [value, setValue] = useState(initial); useEffect(() => { const unsub = source.subscribe((v) => setValue(v)); return unsub; // React calls this on cleanup }, [source]); return value; } ``` ```ts [Vue 3] import { onUnmounted, ref } from 'vue'; import type { Flux } from '@vielzeug/flux'; function useFlux(source: Flux, initial: T) { const value = ref(initial); const unsub = source.subscribe((v) => { value.value = v as T; }); onUnmounted(unsub); return value; } ``` ```ts [Svelte] import type { Flux } from '@vielzeug/flux'; import type { Readable } from 'svelte/store'; function fromFlux(source: Flux, initial: T): Readable { return { subscribe(run) { run(initial); return source.subscribe(run); }, }; } // Use in template: {$myStore} ``` ## Working with Other Vielzeug Libraries ### Ripple signals ```ts import { fromSignal, toSignal } from '@vielzeug/flux'; import { signal } from '@vielzeug/ripple'; const count = signal(0); // Ripple signal → Flux stream (emits current value immediately) const count$ = fromSignal(count); count$.subscribe(console.log); // 0, then on every change // Flux stream → Ripple signal binding const latest = toSignal(count$, { initial: 0 }); // latest.value is reactive (reads track in ripple effects) // latest.dispose() ends tracking; value freezes at last received ``` ### Herald event bus ```ts import { fromBus, toBus } from '@vielzeug/flux'; import { createBus } from '@vielzeug/herald'; type Events = { 'user:login': { id: string } }; const bus = createBus(); // Bus event → Flux stream fromBus(bus, 'user:login').subscribe(({ id }) => console.log(id)); // Flux stream → bus emissions (values also pass through) source$.pipe(toBus(bus, 'user:login')).subscribe(); ``` ## Best Practices - Unsubscribe or dispose when a stream is no longer needed to avoid memory leaks - Pass an `AbortSignal` as the second arg to `subscribe()` for automatic cleanup tied to component lifecycles - Prefer `switchMap` over `flatMap` for request–response patterns where only the latest matters - Use `shareReplay(1)` when multiple components need the same latest value - Use `createBehaviorSubject` rather than `createSubject` when late subscribers need the current state - Use `fail()` instead of `complete()` on a `Subject` when the stream terminates due to an error - Use `toArray()` or `toPromise()` in tests — they wrap the stream in a `Promise` that resolves when the stream completes ### Examples ## Examples - [Debounced Search Input](./examples/debounce-search.md) - [Combining Streams with combineLatest](./examples/combine-streams.md) - [Ripple Signal Integration](./examples/signal-integration.md) ### REPL Examples - Async Conversion (id: `async-conversion`) - Creating a Cold Stream (id: `basic-flux`) - Combining Streams (id: `combination`) - Error Handling (id: `error-handling`) - Operators (id: `operators`) - Subjects (id: `subjects`) --- ## @vielzeug/forge **Category:** forms **Keywords:** form-state, validation, input, submission, dirty-tracking, controlled, field **Key exports:** createForm, toFormData, ValidationModes, FORM_ERROR **Related:** spell, ripple, courier ### Overview ## Why Forge? Native form handling quickly grows repetitive when you need typed values, deterministic submit behavior, and granular subscriptions. ```ts // Before: manual state and ad-hoc validation sequencing const errors: Record = {}; if (!email.includes('@')) errors.email = 'Invalid email'; if (password.length | ~9 kB | ~16 kB | | Framework-agnostic | | React only | Vue only | | Typed dot-path APIs | | Partial | Partial | | Result-based submit flow | | | | | Live field observation | | | | | Full array helpers | | | | | Scoped sub-forms | | | | | Form + field subscriptions | | | | | Zero dependencies | | | | **Use Forge when** you want one typed form controller that works across frameworks or in vanilla apps with explicit, predictable state transitions. **Consider framework-specific alternatives when** you need deeply integrated framework bindings and are not sharing form logic across runtimes. ## Installation ```sh [pnpm] pnpm add @vielzeug/forge ``` ```sh [npm] npm install @vielzeug/forge ``` ```sh [yarn] yarn add @vielzeug/forge ``` ## Quick Start ```ts import { createForm } from '@vielzeug/forge'; const form = createForm({ defaultValues: { email: '', password: '' }, validators: { email: (v) => (!String(v).includes('@') ? 'Invalid email' : undefined), password: (v) => (String(v).length { await fetch('/api/login', { body: JSON.stringify(values), headers: { 'Content-Type': 'application/json' }, method: 'POST', }); }); if (!submission.ok && submission.type === 'validation') { console.log(submission.errors); } ``` ## Features - Typed field paths with compile-time value inference - Explicit validation API: `validate()`, `validate(name)`, and `validate(fields[])` - Streaming validation with `validateStream()` — yields each field result as it resolves, read-only - Per-connection validation triggers via `connect()` with `ValidationModes` presets - `connect()` bindings own independent debounce timers; call `binding.dispose()` on unmount - `submit(handler)` — returns `{ ok: true, value }` or `{ ok: false, errors }` - Schema integration: pass any `safeParse`-compatible schema directly to `validator` - `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths - `subscribeScoped()` — filtered subscription that only fires on changes within the scope's prefix - `snapshot()` / `restore()` — capture and replay complete form state - `form.fields.remove(name)` — clean conditional field lifecycle - Full array helpers: `append`, `prepend`, `insert`, `remove`, `move`, `swap`, `replace` - Explicit synchronous subscriptions: `subscribe`, `subscribeField`, and `subscribeScoped` - Stable frozen snapshots for `form.state` and `form.field(name)` (external-store friendly) - Explicit touched and error controls: `touch`, `untouch`, `touchAll`, `untouchAll`, `setError`, `resetErrors` - Mutation batching with `batch(fn)` and dynamic field validators via `fields.setValidator` - Baseline-safe `reset`/`replace`/`patch` model - Browser-first utility: `toFormData` - Framework-agnostic core — wire into React, Vue, Svelte, or vanilla JS with `subscribe()`/`connect()`, no dedicated adapter package - `@vielzeug/forge/validators` adapter: `fieldValidator` and `composeValidators` - `@vielzeug/forge/devtools`: opt-in `attachForgeDevtools()` for `console.debug` state-transition logging, tree-shaken from production ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Spell](/spell/) — schema validation; plug a Spell schema into Forge to validate fields and submission payloads with full type inference - [Courier](/courier/) — HTTP client; submit Forge's validated payload directly through a Courier mutation with loading and error state wired automatically - [Ripple](/ripple/) — reactive signals; Forge exposes field values and submission state as signals for fine-grained reactive UI updates ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `createForm()` | Create a typed form controller | Sync (async when `defaultValues` is a factory) | Infer `TValues` from `defaultValues`; explicit type param needed for dynamic shapes | | `form.get()` / `form.set()` | Read/write field values by dot-path | Sync | `set()` after `dispose()` throws | | `form.field()` / `form.state` | Read field and form snapshots | Sync | Returns a stable frozen snapshot; re-read on each subscriber call | | `form.validate()` | Run validation — all fields, a subset, or a single field | Async | Each call re-runs validators from scratch | | `form.validateStream()` | Streaming validation — yields each field result as it resolves | Async (iterator) | Read-only — does not write errors to form state | | `form.submit()` | Deterministic submit flow returning a `SubmitResult` | Async | Rejects if called while already submitting — guard with `form.isSubmitting` | | `form.connect()` | Live field binding with DOM event handlers and live getters | Sync | Do not destructure — live getters lose context; call `dispose()` on unmount | | `form.scope()` | Memoized scoped sub-form with relative field paths | Sync | Returns the same object for repeated calls with the same prefix; `state` is scoped — flags reflect only prefix fields | | `form.array()` | Array mutation helpers | Sync | Returns a cached helper — call once and reuse | | `form.subscribe()` / `form.subscribeField()` / `form.subscribeScoped()` | Synchronous form and field subscriptions | Sync | Callbacks receive frozen snapshots | | `form.snapshot()` / `form.restore()` | Capture and replay complete form state | Sync | Useful for undo/redo and draft saving | | `form.batch()` | Group mutations into one notification | Sync | Nested `batch()` calls are safe — only the outermost flush notifies | | `form.touch()` / `form.touchAll()` | Mark fields touched | Sync | `touchAll()` marks every key currently in the store | | `form.setError()` / `form.clearError()` / `form.resetErrors()` | Manual error management | Sync | `setError()` bypasses validators; cleared on next `validate()` run for that field | | `form.reset()` / `form.replace()` / `form.patch()` / `form.fields.remove()` | Baseline and value management | Sync | `replace()` updates the baseline; `reset()` restores to it | | `form.fields.list()` | Enumerate currently-known field paths | Sync | Scoped forms return prefix-relative paths, not absolute paths like `state.touchedFields` | | `toFormData()` | Serialize nested values to `FormData` | Sync | Nested objects are dot-path serialized; `File` values are passed through | | `ValidationModes` | Named presets for `connect()` validation triggers | — | Pass as `connect` option in `createForm()` for a global default | | `FORM_ERROR` | Reserved key `'_form'` for root-level errors | — | Use with `setError(FORM_ERROR, msg)` or `form.validator` return value | | `form[Symbol.asyncIterator]()` | Iterate form state changes with `for await...of` | Async (iterator) | Iterator completes when `dispose()` is called | ## Package Entry Points | Entry | Purpose | | ---------------------------- | ---------------------------------------------------------------------------------- | | `@vielzeug/forge` | `createForm`, `toFormData`, `ValidationModes`, `FORM_ERROR`, and all types | | `@vielzeug/forge/validators` | `fieldValidator`, `composeValidators` — schema and validator composition helpers | | `@vielzeug/forge/devtools` | `attachForgeDevtools` — opt-in `console.debug` logging for form state transitions | ## createForm() ```ts function createForm>(init?: FormOptions): Form; ``` Creates a typed form controller. `TValues` is inferred from `defaultValues` when provided. ### FormOptions ```ts type FormOptions = { /** Initial values. May be a static object or an async factory. */ defaultValues?: TValues | (() => Promise); /** Field-level validators keyed by dot-notation path. */ validators?: Partial, FieldValidator>>; /** * Form-level validator. Accepts a FormValidator function or any safeParse- * compatible schema (auto-detected — works with @vielzeug/spell, Zod, Valibot). */ validator?: FormValidator | SafeParseSchema; /** * Default connect() behavior. Use ValidationModes presets: * ValidationModes.onSubmit (default) — validates only on submit * ValidationModes.onBlur — validates on blur * ValidationModes.onChange — validates on every change * ValidationModes.onTouched — blur first, then change once touched */ connect?: ConnectOptions; /** Called when the async defaultValues factory rejects. Useful for surfacing load errors. */ onLoadError?: (error: unknown) => void; }; ``` Async `defaultValues` factory — `state.isLoading` / `form.isLoading` is `true` while the factory is pending. ## Values ### form.get(name) ```ts get>(name: K): TypeAtPath ``` Returns the stored value for a field path. Returns `undefined` for unknown paths. ### form.set(name, value, options?) ```ts set>(name: K, value: TypeAtPath, options?: SetOptions): void type SetOptions = { touched?: boolean; // default: false }; ``` ### form.values() ```ts values(): TValues ``` Returns the full nested values object (reconstructed from the flat store). ### form.patch(partial) ```ts patch(partial: DeepPartial): void ``` Merges a partial object into both the store and the baseline, marking the affected fields clean. Useful for applying server-returned data without dirtying the form. ## State Access ### form.field(name) ```ts field>(name: K): FieldState> type FieldState = { value: V; error: string | undefined; /** Convenience alias — `true` when `error` is not `undefined`. */ hasError: boolean; touched: boolean; dirty: boolean; }; ``` Returns a frozen, cached snapshot. The reference stays stable until that field changes. ### form.state ```ts readonly state: FormState type FormState = { errors: Readonly>; isDirty: boolean; isLoading: boolean; isSubmitting: boolean; isTouched: boolean; isValid: boolean; isValidating: boolean; submitCount: number; /** * Full dot-notation paths of all currently touched fields. * On a scoped form these are still full paths (e.g. "address.city", not "city"). * Prefer scope.validate() rather than scope.validate([...state.touchedFields]). */ touchedFields: readonly string[]; /** Paths of fields with an active async validation run. */ validatingFields: readonly string[]; }; ``` ### form.isLoading ```ts readonly isLoading: boolean ``` `true` while an async `defaultValues` factory is resolving. Mirrors `state.isLoading`. ### form.isSubmitting ```ts readonly isSubmitting: boolean ``` `true` while a `submit()` call is in progress. Mirrors `state.isSubmitting`. Useful for guarding against concurrent submissions without subscribing to form state. ## Error and Touched Management ```ts setError(name: ErrorKeyOf, message: string): void clearError(name: ErrorKeyOf): void resetErrors(errors?: Partial, string | undefined>>): void touch(name: FlatKeyOf): void untouch(name: FlatKeyOf): void touchAll(): void untouchAll(): void ``` - `setError` — set one field error (does not clear the value). - `clearError` — remove one field error. - `resetErrors` — replace the full error map; omit entries with `undefined` values. - `touch` / `untouch` / `touchAll` / `untouchAll` — manage touched state. To manage validators dynamically see [`form.fields`](#formfields). ## Validation All three variants share a single unified method: ```ts // All fields + form-level validator validate(signal?: AbortSignal): Promise // Single named field (no form-level validator) validate(name: FlatKeyOf, signal?: AbortSignal): Promise // Specific subset of fields (no form-level validator) validate(fields: FlatKeyOf[], signal?: AbortSignal): Promise type ValidateResult = { valid: boolean; errors: Readonly>; }; ``` - `validate()` — runs all registered field validators plus the form-level validator. - `validate(name)` — runs one field's validator; `errors` contains at most one entry. - `validate(fields[])` — runs only the specified fields (no form-level validator). `valid` is `true` only when `errors` is empty after the run. ## submit() / submitOrThrow() ```ts submit( handler: (values: TValues) => MaybePromise, ): Promise> submitOrThrow( handler: (values: TValues) => MaybePromise, ): Promise type SubmitResult = | { ok: true; value: T } | { ok: false; type: 'validation'; errors: Record }; ``` Submit behavior: 1. Marks all known fields touched 2. Runs full validation 3. If invalid: returns `{ ok: false, type: 'validation', errors }` / throws `ForgeValidationError` 4. If valid: calls `handler(values())` and returns `{ ok: true, value }` / resolves with the handler return value `submit()` always resolves — it never throws for validation failures. Exceptions thrown inside `handler` propagate normally. `submitOrThrow()` throws a `ForgeValidationError` when validation fails. Exceptions thrown inside `handler` propagate as-is (not wrapped). Both methods are `async function`s. Calling either while `state.isSubmitting` is `true` rejects the returned promise with a `ForgeSubmitError` — since they're async, `await` (or `.catch()`) the call to observe the error; it is not thrown synchronously. ## connect() ```ts connect>( name: K, config?: ConnectOptions, ): ConnectionResult> type ConnectOptions = { debounce?: number; // debounce auto-validation by this many ms (default: 0) touchOnBlur?: boolean; // mark field touched on blur (default: false) validateOnBlur?: boolean; // validate on blur (default: false) validateOnChange?: boolean; // validate on every change (default: false) validateOnTouch?: boolean; // validate on change only after first touch (default: false) }; type ConnectionResult = { readonly value: V; readonly error: string | undefined; readonly touched: boolean; readonly dirty: boolean; /** true after dispose() has been called on this binding. */ readonly disposed: boolean; onBlur(): void; onChange(value: V): void; /** Cancel any pending debounce timer owned by this binding. Call on field unmount. */ dispose(): void; [Symbol.dispose](): void; }; ``` Call once per field and store the result. Do not destructure — getters re-evaluate on every property access. Each `connect()` call creates its own independent debounce timer; cancelling one binding does not affect others. Call `dispose()` when the field unmounts to avoid stale timer fires. Supports `using` declarations. ### ValidationModes Named presets for `ConnectOptions`. Pass to `createForm({ connect: ... })` or to individual `connect()` calls. ```ts import { ValidationModes } from '@vielzeug/forge'; ValidationModes.onSubmit; // {} — no automatic touch or validation ValidationModes.onBlur; // { touchOnBlur: true, validateOnBlur: true } ValidationModes.onChange; // { touchOnBlur: true, validateOnChange: true } ValidationModes.onTouched; // { touchOnBlur: true, validateOnBlur: true, validateOnTouch: true } ``` ## scope() ```ts scope>(prefix: P): Form> ``` Returns a memoized scoped sub-form whose field paths are relative to `prefix`. Repeated calls with the same prefix return the **same cached object** — no need to store the result yourself, though it is still good practice. ```ts const address = form.scope('address'); address.get('city'); // → form.get('address.city') address.set('city', 'Portland'); // → form.set('address.city', 'Portland') address.validate(); // validates only address.* fields; errors have relative keys address.submit((vals) => save(vals)); // validates and submits only address.* scope ``` **Characteristics:** - `dispose()` on a scoped form is a no-op. Call `parentForm.dispose()` to tear down. - `scope.state` returns a **scoped projection**: `errors`, `touchedFields`, `validatingFields`, `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect only fields within the scope's prefix. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. - `validate()`, `validate(name)`, `validate(fields[])`, and `submit()` / `submitOrThrow()` return errors with relative keys (no prefix) and a `valid` / throw that reflects only the scoped fields. - Memoized — `scope(prefix)` always returns the same object; safe to call on every render. ## Subscriptions ```ts subscribe( listener: (state: FormState) => void, options?: SubscribeOptions, ): Unsubscribe subscribeField>( name: K, listener: (state: FieldState>) => void, options?: SubscribeOptions, ): Unsubscribe subscribeScoped( listener: (state: FormState) => void, options?: SubscribeOptions, ): Unsubscribe type SubscribeOptions = { sync?: boolean }; type Unsubscribe = () => void; ``` Pass `{ sync: true }` to also receive the current snapshot immediately upon subscription. Subscriptions fire synchronously whenever the form mutates. Because state snapshots are stable (frozen, reference-equal between mutations), these integrate directly with React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol. ### subscribeScoped `subscribeScoped` is available on both root forms and scoped forms: - **On a scoped form** — filters `errors`, `touchedFields`, and `validatingFields` to paths within the scope's prefix (remapped to relative paths). The listener is **only called when the scoped projection changes** — mutations outside the scope are suppressed. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. - **On a root form** — behaves identically to `subscribe`; no filtering is applied. ```ts const address = form.scope('address'); address.subscribeScoped((state) => { // state.errors uses relative keys: { city: '...' } not { 'address.city': '...' } // only fires when an address.* field changes console.log(state.errors, state.touchedFields); }); ``` ## Arrays ```ts array(name: FlatKeyOf): ArrayField type ArrayField = { append(value: unknown): void; prepend(value: unknown): void; insert(index: number, value: unknown): void; remove(index: number): void; move(from: number, to: number): void; swap(a: number, b: number): void; replace(index: number, value: unknown): void; }; ``` `form.array(name)` returns a cached helper — the same object is returned on repeated calls with the same name. `append()` and `prepend()` initialize the field as a one-item array when its current value is `undefined` or `null`. If the field already holds a non-array, non-nullish value (e.g. written by `set()`), both are a no-op — they never overwrite an existing scalar with an array. `insert()`, `remove()`, `move()`, `swap()`, and `replace()` are all no-ops when the field's current value is not an array. ## Snapshot / Restore ```ts snapshot(): FormSnapshot restore(snap: FormSnapshot): void type FormSnapshot = { readonly baseline: Partial, unknown>>; readonly dirty: readonly string[]; readonly errors: Readonly>; readonly store: Partial, unknown>>; readonly submitCount: number; readonly touched: readonly string[]; }; ``` - `snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object. - `restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation. Useful for undo/redo, draft saving, and "discard changes" flows: ```ts const draft = form.snapshot(); form.set('email', 'changed@example.com'); form.restore(draft); // reverts all changes ``` ## validateStream() ```ts validateStream(signal?: AbortSignal): AsyncIterableIterator ``` Runs all field validators in parallel and yields each result as soon as its validator resolves. If a form-level validator is configured, all keys it returns are yielded last — including `field: '_form'` and any field-specific keys returned by the form validator. **Read-only**: `validateStream()` does not write to `fieldErrors` or trigger subscriber notifications. Use `validate()` when you want errors applied to form state. ```ts for await (const { field, error } of form.validateStream()) { if (error) showInlineError(field, error); } // After the loop: form.state.errors is unchanged ``` Pass an `AbortSignal` to cancel the stream: ```ts const ctrl = new AbortController(); for await (const result of form.validateStream(ctrl.signal)) { processResult(result); } ctrl.abort(); // cancels any remaining in-flight validators ``` ## Baseline and Value Management ```ts reset(): void replace(newValues: TValues): void patch(partial: DeepPartial): void resetField(name: FlatKeyOf): void ``` - `reset()` — restore all values to the current baseline; clear errors, touched, dirty, and `submitCount`. Aborts in-flight validation. - `replace(newValues)` — replace values **and** the baseline in one operation; resets `submitCount` to `0`. Aborts in-flight validation. - `patch(partial)` — merge specific fields into the store and baseline; those fields become clean. - `resetField(name)` — restore one field to its baseline value; clear its error, touched, and dirty state. ## form.fields Namespace for dynamic field lifecycle management. ```ts form.fields: { list(): readonly string[]; register>( name: K, options?: RegisterFieldOptions>, ): Unsubscribe; remove(name: FlatKeyOf): void; setValidator(name: FlatKeyOf, validator?: FieldValidator): void; } type RegisterFieldOptions = { defaultValue?: V; validator?: FieldValidator; }; ``` - `fields.list()` — enumerate all currently-known field paths: the deduplicated union of populated store keys and keys with a registered validator (the same definition `touchAll()` uses). - `fields.register(name, opts?)` — declare a dynamic field with an optional default value and validator. Returns an unregister callback that calls `fields.remove()` on the same field. If the field already exists, `defaultValue` is ignored. - `fields.remove(name)` — drop a field entirely: value, dirty, touched, error, and validator. - `fields.setValidator(name, validator?)` — add, replace, or remove a field validator. Removing (`undefined`) immediately clears that field's error. On a scoped form, all paths are relative: ```ts const address = form.scope('address'); const unsub = address.fields.register('zip', { defaultValue: '' }); // Equivalent to: form.fields.register('address.zip', { defaultValue: '' }) address.fields.list(); // ['zip'] — relative to the scope's prefix, not ['address.zip'] ``` ## Lifecycle ```ts dispose(): void readonly disposed: boolean readonly disposalSignal: AbortSignal ``` After `dispose()`, all mutating APIs throw. In-flight validation is aborted. Subscriptions are cleared. `disposalSignal` is aborted when `dispose()` is called. Pass it to validators or other async work that should be cancelled when the form tears down. ## Async Iteration ```ts [Symbol.asyncIterator](): AsyncIterableIterator ``` Makes the form directly iterable with `for await...of`. Each iteration yields the current `FormState` snapshot whenever the form changes. The iterator completes when `dispose()` is called. ```ts for await (const state of form) { renderUI(state); if (state.submitCount > 0 && state.isValid) break; } // Iterator completes automatically when form.dispose() is called ``` > Each `for await...of` loop creates an independent iterator — multiple concurrent loops are supported. ## batch() ```ts batch(fn: () => void): void ``` Batches all mutations inside `fn` into a single subscriber notification. Notifications always flush at the end — even if `fn` throws. ## Validators (`@vielzeug/forge/validators`) > Forge has no framework-specific sub-paths — `form.subscribe()`, `form.subscribeField()`, and `form.connect()` are the framework-agnostic primitives every integration is built on. See [Usage → Framework Integration](./usage.md#framework-integration) for copy-pasteable React/Vue/Svelte recipes. ```ts import { composeValidators, fieldValidator } from '@vielzeug/forge/validators'; import { s } from '@vielzeug/spell'; // Wrap a safeParse-compatible field schema into a FieldValidator const emailValidator = fieldValidator(s.string().email('Invalid email')); // Chain multiple validators — short-circuits on the first error const passwordValidator = composeValidators( fieldValidator(s.string().min(8, 'At least 8 characters')), async (value, signal) => { const breached = await checkBreachedPasswords(value, signal); return breached ? 'Password found in breach database' : undefined; }, ); const form = createForm({ defaultValues: { email: '', password: '' }, validators: { email: emailValidator, password: passwordValidator }, }); ``` - `fieldValidator(schema)` — wraps any `safeParse`-compatible schema as a `FieldValidator`. The first issue message becomes the field error. - `composeValidators(...validators)` — chains validators in order, stopping at the first error. Abort signals are respected between steps. ## Devtools (`@vielzeug/forge/devtools`) Opt-in `console.debug` logging for form state transitions. Not exported from the main `@vielzeug/forge` entry point — import from this sub-path so the logging code is tree-shaken from production bundles. ```ts function attachForgeDevtools>( form: Form, options?: ForgeDevtoolsOptions, ): Unsubscribe; type ForgeDevtoolsOptions = { label?: string; // included in every log line; default: 'form' }; ``` Logs one line per observable state transition: per-field `value`/`error`/`touched`/`dirty` changes, `isSubmitting` edges (submit start/end), and `isLoading` edges (async `defaultValues` resolving). Works identically on scoped sub-forms — field paths logged are relative to whatever `form` object is passed in, matching that form's own `state` convention. **Development only:** a no-op when `__FORGE_PROD__` is set on `globalThis` — the same convention forge's internal `_dev.ts` warnings use. ```ts import { createForm } from '@vielzeug/forge'; import { attachForgeDevtools } from '@vielzeug/forge/devtools'; const form = createForm({ defaultValues: { email: '' } }); const detach = attachForgeDevtools(form, { label: 'signup' }); // [forge:devtools:signup] field "email" value: "" → "a@b.com" detach(); // stop logging ``` ## Standalone Utilities ### toFormData() ```ts function toFormData(values: Record): FormData; ``` Serializes a nested values object to `FormData`. Nested keys are flattened with dot notation. `File`, `Blob`, and `FileList` values are appended as-is; all others are coerced to strings. `null` and `undefined` are skipped. ## Types ```ts // Core type Form> type FormOptions type FormState type FieldState type FormSnapshot type RegisterFieldOptions type ValidateResult type SubmitResult type SetOptions type SubscribeOptions type Unsubscribe type MaybePromise class ForgeError // base class — instanceof / ForgeError.is() catches any forge error class ForgeConfigError // unsafe __proto__/constructor/prototype key class ForgeDisposedError // mutating call after dispose() class ForgeSubmitError // submit()/submitOrThrow() called while already submitting class ForgeValidationError // thrown by submitOrThrow() on validation failure // Validation type FieldValidator type FormValidator type SafeParseSchema type ConnectOptions type ConnectionResult const ValidationModes // named presets object — not a type const FORM_ERROR // string constant '_form' — not a type // Devtools (@vielzeug/forge/devtools) type ForgeDevtoolsOptions // Utility types type DeepPartial type FlatKeyOf type TypeAtPath type ErrorKeyOf type ScopedValues ``` ## Errors Forge exports a small typed error hierarchy, all extending a common `ForgeError` base: ```ts class ForgeError extends Error { static is(err: unknown): err is ForgeError; } class ForgeConfigError extends ForgeError {} class ForgeDisposedError extends ForgeError {} class ForgeSubmitError extends ForgeError {} class ForgeValidationError extends ForgeError { readonly errors: Record; } ``` - `ForgeError` — base class. Use `instanceof ForgeError` or the static `ForgeError.is(err)` to catch any forge-originated error. - `ForgeConfigError` — thrown when a form key contains a reserved prototype-polluting segment (`__proto__`, `constructor`, or `prototype`), e.g. from `form.set('__proto__', ...)`. - `ForgeDisposedError` — thrown when a mutating method (e.g. `set()`, `submit()`, `connect()`) is called after `form.dispose()`. - `ForgeSubmitError` — thrown when `submit()` or `submitOrThrow()` is called while a submission is already in progress. - `ForgeValidationError` — thrown by `submitOrThrow()` when validation fails; carries a `readonly errors: Record` map. Usage notes: - `form.submit()` returns `{ ok: false, type: 'validation', errors }` — it **never throws** for validation failures. - `form.submitOrThrow()` throws a `ForgeValidationError` when validation fails — use when you prefer exception-based control flow. - `form.validate()` (all overloads) returns `{ valid: boolean, errors }` — never throws for validation failures. - Other thrown exceptions are programming errors: calling mutating APIs after `dispose()` (`ForgeDisposedError`), using a reserved key (`ForgeConfigError`), or calling `submit()` / `submitOrThrow()` while already submitting (`ForgeSubmitError`). ### Usage Guide ## Basic Usage ```ts import { createForm } from '@vielzeug/forge'; const form = createForm({ defaultValues: { email: '', password: '', profile: { age: 0, name: '' }, }, validators: { email: (v) => (!String(v).includes('@') ? 'Invalid email' : undefined), password: (v) => (String(v).length ({ defaultValues: { email: '', profile: { age: 0, name: '' } }, }); form.set('profile.age', 42); // TypeScript error if type is wrong ``` Dynamic shape escape hatch: ```ts const dynamicForm = createForm>({}); dynamicForm.set('custom.field', 'value'); ``` ## Validation ```ts await form.validate('password'); await form.validate(); await form.validate(['email', 'password']); const controller = new AbortController(); await form.validate(controller.signal); controller.abort(); ``` Validation result: ```ts const result = await form.validate(['email']); console.log(result.valid); // true only if no errors exist after this run console.log(result.errors); // full current error map after the run ``` Schema integration — pass a `safeParse`-compatible schema directly to `validator`: ```ts import { createForm } from '@vielzeug/forge'; import { s } from '@vielzeug/spell'; const schema = s.object({ age: s.number().min(18, 'Must be 18+'), email: s.string().email('Invalid email'), }); const form = createForm({ defaultValues: { age: 0, email: '' }, validator: schema, // auto-detected as a safeParse schema }); ``` For per-field validation with a schema, use `fieldValidator` from `@vielzeug/forge/validators`: ```ts import { fieldValidator } from '@vielzeug/forge/validators'; import { s } from '@vielzeug/spell'; const form = createForm({ defaultValues: { age: 0, email: '' }, validators: { age: fieldValidator(s.number().min(18, 'Must be 18+')), email: fieldValidator(s.string().email('Invalid email')), }, }); ``` ## Submission ```ts const result = await form.submit(async (values) => { const res = await fetch('/api/submit', { body: JSON.stringify(values), headers: { 'Content-Type': 'application/json' }, method: 'POST', }); return res.json(); }); if (!result.ok && result.type === 'validation') { console.log(result.errors); } if (result.ok) { console.log(result.value); // typed return value from the handler } ``` `submit()` always: 1. Marks all known fields touched 2. Runs full validation 3. If invalid: returns `{ ok: false, type: 'validation', errors }` 4. If valid: calls the handler and returns `{ ok: true, value }` Calling `submit()` while a submission is already in progress rejects the returned promise with a `ForgeSubmitError` (it's an `async function`, so this is never a synchronous throw) — guard with `state.isSubmitting` if needed. ## Async Default Values Pass an async factory to `defaultValues` to load initial state remotely. The form's `state.isLoading` / `form.isLoading` is `true` while the factory is pending. ```ts const form = createForm({ defaultValues: async () => { const res = await fetch('/api/user/me'); return res.json(); }, }); // isLoading is true until the factory resolves form.subscribe((state) => { if (!state.isLoading) renderForm(form.values()); }); ``` ## Connect (Field Binding) `connect()` returns a live binding object with DOM event handlers and live getters. Call once per field and store the result — do not destructure. Each binding owns its own independent debounce timer; call `dispose()` when the field unmounts to cancel it. ```ts const emailConn = form.connect('email'); input.addEventListener('change', (e) => emailConn.onChange(e.target.value)); input.addEventListener('blur', () => emailConn.onBlur()); // Live getters re-evaluate on every access console.log(emailConn.value, emailConn.error, emailConn.touched, emailConn.dirty); // On unmount: cancel any pending debounce timer emailConn.dispose(); ``` ### Validation Modes Pass a `ConnectOptions` object (or a `ValidationModes` preset) to control when validation triggers: ```ts import { createForm, ValidationModes } from '@vielzeug/forge'; // Global default for all connect() calls const form = createForm({ defaultValues: { email: '', password: '' }, connect: ValidationModes.onBlur, // validate each field on blur }); // Per-field override const emailConn = form.connect('email', ValidationModes.onChange); const passwordConn = form.connect('password', { validateOnBlur: true, debounce: 300 }); ``` | Preset | `touchOnBlur` | `validateOnBlur` | `validateOnChange` | `validateOnTouch` | | ------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | | `ValidationModes.onSubmit` (default) | — | — | — | — | | `ValidationModes.onBlur` | | | — | — | | `ValidationModes.onChange` | | — | | — | | `ValidationModes.onTouched` | | | — | | `debounce` delays auto-triggered validation by a given number of milliseconds — useful for async validators on `onChange` to avoid one request per keystroke. ## Scoped Sub-Forms `scope(prefix)` returns a sub-form whose field paths are relative to the prefix. All state and lifecycle is shared with the parent form. ```ts const form = createForm({ defaultValues: { name: 'Alice', address: { city: 'New York', street: '123 Main St', zip: '10001' }, }, validators: { 'address.city': (v) => (!v ? 'City is required' : undefined), }, }); // scope() is memoized — repeated calls with the same prefix return the same object const address = form.scope('address'); address.get('city'); // same as form.get('address.city') address.set('city', 'Portland'); // same as form.set('address.city', 'Portland') address.connect('city'); // same as form.connect('address.city') await address.validate(); // validates only address.* fields; returns scoped errors (no prefix) await address.submit((vals) => vals); // validates and submits only address.* fields ``` **Key characteristics:** - `dispose()` on a scoped form is a no-op — call `parentForm.dispose()` to tear down. - `scope.state` returns a **scoped projection**: `errors`, `touchedFields`, `validatingFields`, `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect only fields within the scope's prefix. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. Use `scope.validate()` or `scope.submit()` for scoped validity checks; their results contain relative keys and a scoped `valid` flag. - `touchedFields` in `state` contains full-prefixed paths. Prefer `scope.validate()` over `scope.validate([...state.touchedFields])` to avoid double-prefixing. ### Scoped Subscriptions `subscribeScoped` delivers form state filtered to the scope's prefix. `errors`, `touchedFields`, and `validatingFields` use relative keys. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. The listener **only fires when the scoped projection changes** — mutations outside the prefix are suppressed. ```ts const address = form.scope('address'); address.subscribeScoped((state) => { // state.errors → { city: 'Required' } (not 'address.city') // state.isDirty → true only when an address.* field is dirty // does not fire when form.set('name', 'Alice') is called renderAddressErrors(state.errors); }); ``` ## Subscriptions ```ts const stopForm = form.subscribe((state) => { console.log(state.isValid, state.isDirty, state.errors); }); const stopEmail = form.subscribeField('email', (field) => { console.log(field.value, field.error, field.touched, field.dirty); }); // Pass sync: true to receive the current snapshot immediately on subscription form.subscribeField('email', (field) => updatePreview(String(field.value)), { sync: true }); stopEmail(); stopForm(); ``` Snapshot semantics: - `form.state` and `form.field(name)` return stable, frozen snapshots. - Reference identity is preserved until a relevant mutation occurs. - These are directly compatible with external-store patterns such as React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol. ## Streaming Validation `validateStream()` runs all field validators in parallel and yields each result as it resolves. It is **read-only** — it does not write errors to form state. The form-level validator, if set, is yielded last with `field: '_form'`. ```ts for await (const { field, error } of form.validateStream()) { if (error) showInlineError(field, error); } // form.state.errors is unchanged after the loop ``` ## Snapshots and Restore Capture and replay complete form state for undo/redo or "discard changes" flows: ```ts const draft = form.snapshot(); // ... user edits ... form.set('email', 'different@example.com'); // Revert all changes, including errors, touched, dirty, and submitCount form.restore(draft); ``` ## Arrays ```ts const items = form.array('items'); items.append({ name: '' }); items.prepend({ name: 'first' }); items.insert(1, { name: 'middle' }); items.remove(0); items.move(1, 0); items.swap(0, 1); items.replace(0, { name: 'updated' }); ``` `form.array()` returns a cached helper — call it once and reuse. ## Batching Wrap multiple mutations in `batch()` to emit only one notification: ```ts form.batch(() => { form.set('firstName', 'Alice'); form.set('lastName', 'Smith'); form.touch('firstName'); }); // subscribers notified once ``` ## Reset, Replace, and Patch ```ts form.reset(); // restore all values to baseline; clear errors/touched/dirty/submitCount form.replace({ email: '', name: '' }); // replace values and baseline; also resets submitCount form.patch({ name: 'Alice' }); // merge specific fields into store and baseline (marks them clean) form.resetField('email'); // restore single field to baseline form.fields.remove('coupon'); // drop field entirely (value, touched, error, validator) ``` `patch()` accepts a `DeepPartial` object — nested paths are flattened automatically. Useful for applying a server response without dirtying the form. ## Lifecycle ```ts form.dispose(); // tear down: abort all pending validation, clear listeners console.log(form.disposed); // true after dispose() ``` After `dispose()`, all mutating APIs throw. ## Debugging Import `attachForgeDevtools()` from the dedicated `/devtools` sub-path to log per-field value/error/touched/dirty changes and submit/loading transitions via `console.debug`: ```ts import { attachForgeDevtools } from '@vielzeug/forge/devtools'; const detach = attachForgeDevtools(form, { label: 'signup' }); // later, e.g. on unmount: detach(); ``` It is a no-op in production (`__FORGE_PROD__` set) and never imported from the main entry point, so it is tree-shaken out of production bundles entirely. See [API Reference → Devtools](./api.md#devtools-vielzeug-forge-devtools) for the full contract. ## Framework Integration Forge has no framework-specific packages — `form.subscribe()`, `form.subscribeField()`, and `form.connect()` are the framework-agnostic primitives every integration is built on. Each snippet below is ~10 lines of glue; keep it in a shared `lib/form-hooks.ts`-style module and reuse it across forms. ```tsx [React] import { useEffect, useRef, useSyncExternalStore } from 'react'; import { createForm } from '@vielzeug/forge'; import type { ConnectOptions, FlatKeyOf, Form, TypeAtPath } from '@vielzeug/forge'; function useField, K extends FlatKeyOf>(form: Form, name: K) { return useSyncExternalStore( (onChange) => form.subscribeField(name, onChange), () => form.field(name), ); } // Live connect() binding, recreated when `name` or an option value changes, disposed on unmount function useConnect, K extends FlatKeyOf>( form: Form, name: K, options?: ConnectOptions, ) { const bindingRef = useRef } | null>(null); if (!bindingRef.current || bindingRef.current.disposed) bindingRef.current = form.connect(name, options); useEffect(() => { const binding = form.connect(name, options); bindingRef.current = binding; return () => binding.dispose(); }, [form, name, options?.debounce, options?.touchOnBlur, options?.validateOnBlur, options?.validateOnChange]); return bindingRef.current; } const form = createForm({ defaultValues: { email: '', password: '' } }); function LoginForm() { const email = useField(form, 'email'); const conn = useConnect(form, 'email', { touchOnBlur: true }); return ( { e.preventDefault(); form.submit(handleLogin); }}> conn.onChange(e.target.value)} onBlur={() => conn.onBlur()} /> {email.error && {email.error}} Submit ); } ``` ```ts [Vue 3] import { onScopeDispose, shallowRef } from 'vue'; import { createForm } from '@vielzeug/forge'; import type { FlatKeyOf, Form } from '@vielzeug/forge'; // Wraps a field in a reactive shallowRef; auto-unsubscribes when the scope tears down function useField, K extends FlatKeyOf>(form: Form, name: K) { const ref = shallowRef(form.field(name)); onScopeDispose(form.subscribeField(name, (state) => (ref.value = state))); return ref; } const form = createForm({ defaultValues: { email: '' } }); export default { setup() { const email = useField(form, 'email'); const conn = form.connect('email'); return { email, conn }; }, }; ``` ```svelte [Svelte] import { createForm } from '@vielzeug/forge'; const form = createForm({ defaultValues: { email: '' } }); // Any object exposing subscribe() is a valid Svelte store — form.subscribeField() already fits const email = { subscribe(run) { run(form.field('email')); return form.subscribeField('email', run); }, }; const conn = form.connect('email'); form.submit(handleSubmit)}> conn.onChange(e.target.value)} on:blur={() => conn.onBlur()} /> {#if $email.error}{$email.error}{/if} Submit ``` Reuse the same `subscribe`/`subscribeField` pattern for `useFormState`/`useFormValues`-style helpers — swap `form.subscribeField(name, ...)` / `form.field(name)` for `form.subscribe(...)` / `form.state` (or `form.values()`). ## Working with Other Vielzeug Libraries ### With Spell Combine Spell schemas with Forge to get typed validation rules without writing validator functions by hand. ```ts import { createForm } from '@vielzeug/forge'; import { fieldValidator } from '@vielzeug/forge/validators'; import { s } from '@vielzeug/spell'; // Per-field validation with a Spell schema const form = createForm({ defaultValues: { email: '', password: '', age: 0 }, validators: { age: fieldValidator(s.number().min(18, 'Must be 18+')), email: fieldValidator(s.string().email('Invalid email')), password: fieldValidator(s.string().min(8, 'Min 8 characters')), }, }); // Full-form schema validation (auto-detects safeParse) const schema = s.object({ age: s.number().min(18, 'Must be 18+'), email: s.string().email('Invalid email'), password: s.string().min(8, 'Min 8 characters'), }); const formWithSchema = createForm({ defaultValues: { email: '', password: '', age: 0 }, validator: schema, }); ``` ## Best Practices - Call `connect()` once per field and store the result — never call it inside a render or update loop. Call `binding.dispose()` when the field unmounts to cancel any pending debounce timer. - `scope()` is memoized — repeated calls with the same prefix return the same object. Store the result for clarity, but it is safe to call multiple times. - Prefer `scope.validate()` over `scope.validate([...state.touchedFields])` on scoped forms to avoid double-prefixed paths. - Wrap multi-field mutations in `batch()` to emit a single subscriber notification. - Pass a `signal` to long-running validators where applicable — Forge passes its own abort signal to validators on `dispose()`. - Set a `connect` default in `createForm()` using `ValidationModes` presets rather than repeating per-field options. - Use `replace()` after a successful async load instead of `reset()` — `replace()` updates the baseline so `isDirty` reflects changes against the new data. - Guard concurrent submissions with `form.isSubmitting` or `state.isSubmitting` — calling `submit()` while a submission is in progress rejects the returned promise with a `ForgeSubmitError` (not a synchronous throw, since `submit()` is async). ### Examples ## Examples - [Login Form](./examples/login-form.md) - [Registration Form](./examples/registration-form.md) - [Search Form With Debounce](./examples/search-form-with-debounce.md) - [Form With Conditional Fields](./examples/form-with-conditional-fields.md) - [Contact Form With File Upload](./examples/contact-form-with-file-upload.md) - [Dynamic Form Fields](./examples/dynamic-form-fields.md) - [Multi-Step Wizard](./examples/multi-step-wizard.md) ### REPL Examples - Array Fields - Dynamic Lists (id: `array-fields`) - Create Form - Basic Setup (id: `create-form`) - Dynamic Fields (fields.register/list) (id: `dynamic-fields`) - Field Connection (connect) (id: `field-binding`) - Field Operations - Get, Set, Batch, Reset (id: `field-operations`) - Form Submission with Validation (id: `form-submission`) - Form Subscriptions - Reactive Updates (id: `form-subscriptions`) - Field & Form Validation (id: `form-validation`) - Schema Integration - safeParse Auto-detection (id: `schema-integration`) - Scoped Sub-Forms (scope) (id: `scoped-sub-forms`) - Streaming Validation (validateStream) (id: `validate-stream`) --- ## @vielzeug/herald **Category:** events **Keywords:** event-bus, typed-events, pub-sub, reactive, decoupled, async-streams **Key exports:** createBus, createBehaviorBus, pipeEvents, combineSignals, BusDisposedError, HeraldConfigError, debugBus, debugBehaviorBus, createTestBus **Related:** ripple, wayfinder, familiar ### Overview ## Why Herald? Manual event emitters lack TypeScript inference across event names and payloads, and offer no async patterns — `await`ing an event or streaming all future emits requires bespoke wiring. ```ts // Before — manual typed event bus type Handlers = { 'user:login': (p: { userId: string }) => void }; const listeners = new Map>(); function on(event: K, fn: Handlers[K]) { /* ... */ } function emit(event: K, payload: Parameters[0]) { /* ... */ } // No await, no stream, no AbortSignal, no error isolation // After — Herald import { createBus } from '@vielzeug/herald'; const bus = createBus(); bus.on('user:login', ({ userId }) => loadProfile(userId)); bus.emit('user:login', { userId: '42', email: 'alice@example.com' }); const session = await bus.wait('user:login'); // async one-shot for await (const event of bus.events('cart:updated')) { } // async stream ``` | Feature | Herald | mitt | EventEmitter3 | | -------------------- | ----------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- | | Bundle size | | ~200 B | ~1.5 kB | | TypeScript inference | Full | Basic | Basic | | Async/await (`wait`) | | | | | Async streaming | | | | | AbortSignal | | | | | Event piping | | | | | Wildcard (`onAny`) | | | | | Disposal signal | | | | | Error isolation | | | | | Zero dependencies | | | | **Use Herald when** you need a fully-typed event bus with async patterns (`wait`, `events` generator) and AbortSignal-based lifecycle management. **Consider mitt when** you only need a bare-minimum synchronous pub/sub with the smallest possible footprint. ## Installation ```sh [pnpm] pnpm add @vielzeug/herald ``` ```sh [npm] npm install @vielzeug/herald ``` ```sh [yarn] yarn add @vielzeug/herald ``` ## Quick Start ```ts import { BusDisposedError, createBus, pipeEvents } from '@vielzeug/herald'; type AppEvents = { 'user:login': { userId: string; email: string }; 'user:logout': void; }; const bus = createBus(); bus.on('user:login', ({ userId }) => { console.log('Logged in:', userId); }); bus.emit('user:login', { email: 'alice@example.com', userId: '42' }); bus.emit('user:logout'); const nextLogin = await bus.wait('user:login'); const nextSessionChange = await bus.waitAny(['user:login', 'user:logout']); if (nextSessionChange.event === 'user:login') { console.log(nextSessionChange.payload.userId); } for await (const payload of bus.events('user:login', { signal: AbortSignal.timeout(5_000) })) { console.log(payload.email); } // Forward selected events to another bus const auditBus = createBus(); const unpipe = pipeEvents(bus, auditBus, ['user:login', 'user:logout']); // Disposal signal — use as an AbortSignal for external cleanup otherBus.on('count', handler, { signal: bus.disposalSignal }); try { await bus.wait('user:login', { signal: AbortSignal.timeout(500) }); } catch (err) { if (err instanceof BusDisposedError) { console.log('Bus was disposed'); } } ``` ## Features - **Typed event maps** for strict event/payload correctness - **Persistent + one-shot listeners** with `on` and `once` — each registration is independent, including duplicate handlers - **Wildcard listeners** with `onAny` — subscribe to all events for cross-cutting concerns like logging and analytics - **Listener management APIs** with unsubscribe handles, `wildcardCount()`, and `eventNames()` - **Async event coordination** with `wait` - **First-event racing** with `waitAny` - **Async streaming** with `events` — eager subscription buffers events from the moment `events()` is called; `await using` ensures cleanup on early `break` - **Event piping** with `pipeEvents` — forward events across buses with optional renaming and automatic teardown - **Middleware pipeline** via `createBus({ middleware: [...] })` — intercept or block dispatches before listeners run - **Payload validation** via `createBus({ validatePayload: ... })` — schema-level guards applied before middleware - **Disposal signal** via `bus.disposalSignal` — use as an `AbortSignal` to tie external lifecycles to the bus - **Leak detection** via `maxListeners` — warn when a single event accumulates too many listeners - **Named buses** via `createBus({ name: 'myBus' })` — name appears in debug log prefixes and `BusDisposedError` messages for easier debugging across multiple bus instances - **Debug logging** via `logger.debug` or `debugBus()` / `debugBehaviorBus()` (`@vielzeug/herald/devtools`) — logs subscribe/emit/dispose activity with `[herald:*]` prefixes; tree-shaken from production bundles - **Abort-aware APIs** for lifecycle-safe teardown - **`onAny()` wildcard listener** for bus-wide observability; `wildcardCount()` to inspect active wildcards - **Custom logger** via `createBus({ logger: { debug, warn } })` — route or suppress debug and warn output - **`onError` hook** for listener-error isolation and resilience - **`dispose` and `[Symbol.dispose]`** for deterministic cleanup - **Testing helper** via `@vielzeug/herald/testing` - **Zero dependencies** — gzipped, dependencies ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — reactive signals and computed state that pair naturally with event-driven update patterns - [Wayfinder](/wayfinder/) — client-side router whose navigation lifecycle hooks integrate with bus-dispatched events - [Familiar](/familiar/) — Web Worker pool that can use a bus to stream task progress and completion events ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --------------------- | ---------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------- | | `createBus()` | Create a typed event bus instance | Sync | Use a strict event map to avoid payload drift | | `createBehaviorBus()` | Create a bus that replays the last value to new subs | Sync | `events()` and `wait()` do not replay; `once()` fires immediately if buffer exists | | `pipeEvents()` | Forward events from one bus to another | Sync | Supports cross-type buses and event renaming | | `bus.on()` | Persistent subscription with optional `once` option | Sync | Pass `{ signal }` to auto-unsubscribe | | `bus.emit()` | Emit an event; returns listener invocation count | Sync | Every listener runs even if one throws; first error rethrows after | | `bus.events()` | Stream future emits as an async generator | Async | Subscribes eagerly; use `maxBuffer` to cap the buffer | | `combineSignals()` | Merge N AbortSignals into one | Sync | Returns the first already-aborted signal early | | `bus.wait()` | Await a one-time event occurrence | Async | Pass `{ signal }` for timeout / cancellation | | `bus.waitAny()` | Await the first event from many | Async | Result is a discriminated union by event key | | `bus.onAny()` | Subscribe to all events | Sync | Fires after event-specific listeners | | `bus.eventNames()` | Inspect events with active listeners | Sync | Snapshot reflects current subscriptions | | `createTestBus()` | Create deterministic test bus utilities | Sync | Reset emitted events between test cases | ## Package Entry Points | Import | Purpose | | --------------------------- | ---------------------------------------------------------- | | `@vielzeug/herald` | Main runtime API and types | | `@vielzeug/herald/devtools` | `debugBus`, `debugBehaviorBus` — debug wrappers (dev only) | | `@vielzeug/herald/testing` | Test helpers (`createTestBus`, `TestBus` type) | ## Types `EventMap`, `EventKey`, `Listener`, `Unsubscribe` — simple type aliases: ```ts type EventMap = Record; type EventKey = keyof T & string; type Listener = (payload: T) => void; type Unsubscribe = () => void; ``` `BusOptions` — options object passed to `createBus()` and `createBehaviorBus()`: ```ts type SubscribeOptions = { once?: boolean; // auto-remove after first invocation signal?: AbortSignal; // auto-remove when signal aborts }; type EmissionErrorContext = { err: unknown; // the thrown error event: EventKey; // event key that triggered the failing listener payload: unknown; // payload passed to the listener timestamp: number; // ms since epoch at emit() call time }; type BusLogger = { debug?: (msg: string) => void; // omit to silence debug output warn?: (msg: string) => void; // omit to silence warn output }; type BusOptions = { logger?: BusLogger; maxListeners?: number; middleware?: readonly Middleware[]; name?: string; // optional display name — appears in log prefixes and BusDisposedError messages; avoid sensitive/user-derived values onError?: (context: EmissionErrorContext) => void; validatePayload?: >(event: K, payload: T[K]) => void; }; ``` `Middleware` — a function called in sequence on every `emit()`, before listeners run. Call `next()` to continue; omit it to block dispatch: ```ts type Middleware = (event: EventKey, payload: unknown, next: () => void) => void; ``` `PipeableKey` — keys shared between two event maps with compatible payload types: ```ts type PipeableKey = { [K in EventKey & EventKey]: S[K] extends T[K] ? K : never; }[EventKey & EventKey]; ``` `PipeEntry` — a single entry for `pipeEvents`, either a key string or a `{ from, to }` rename: ```ts type PipeEntry = PipeableKey | { from: EventKey; to: EventKey }; ``` `EventStream` — returned by `bus.events()`. Extends `AsyncGenerator` with `AsyncDisposable`: ```ts type EventStream = AsyncGenerator & AsyncDisposable; ``` `WaitAnyResult` — discriminated-union result returned by `waitAny()`: ```ts type WaitAnyResult[]> = { [I in keyof K]: K[I] extends EventKey ? { event: K[I]; payload: T[K[I]] } : never; }[number]; ``` `Bus` — the runtime bus interface. Individual method docs are in the [Bus Interface](#bus-interface) section. ```ts type Bus = { readonly disposed: boolean; readonly disposalSignal: AbortSignal; [Symbol.dispose](): void; dispose(): void; emit>(event: K, ...args: T[K] extends void ? [] : [payload: T[K]]): number; eventNames(): EventKey[]; events>(event: K, options?: { maxBuffer?: number; signal?: AbortSignal }): EventStream; listenerCount(event?: EventKey): number; on>(event: K, listener: Listener, opts?: SubscribeOptions): Unsubscribe; onAny(listener: (event: EventKey, payload: unknown) => void, opts?: SubscribeOptions): Unsubscribe; once>(event: K, listener: Listener, opts?: { signal?: AbortSignal }): Unsubscribe; wait>(event: K, opts?: { signal?: AbortSignal }): Promise; waitAny, EventKey, ...EventKey[]]>( events: K, opts?: { signal?: AbortSignal }, ): Promise>; wildcardCount(): number; }; ``` `BehaviorBus` — extends `Bus` with last-value replay. Full docs in the [`createBehaviorBus()`](#createbehaviorbus) section. ```ts type BehaviorInitial = { [K in EventKey]?: T[K] }; type BehaviorBus = Bus & { current>(event: K): T[K] | undefined; reset(event?: EventKey): void; snapshot(): Partial; }; ``` `TestBus` — extends `Bus` with emission recording. Full docs in the [Testing Utilities](#testing-utilities) section. ```ts type TestBus = Bus & { allEmitted(): { [K in EventKey]?: T[K][] }; emitted>(event: K): T[K][]; emittedCount>(event: K): number; removeAllListeners>(event: K): void; reset(): void; }; ``` ## `createBus()` Signature: `createBus(options?: BusOptions): Bus` Creates and returns a new `Bus` instance. | Parameter | Type | Description | | --------- | --------------- | --------------------------- | | `options` | `BusOptions` | Optional hook configuration | **Returns:** `Bus` ```ts import { createBus } from '@vielzeug/herald'; type AppEvents = { 'user:login': { userId: string }; 'user:logout': void; }; const bus = createBus({ logger: { debug: myLogger.debug, warn: myLogger.warn }, // provide debug to enable logging; pass {} to silence all onError: ({ err, event, payload }) => console.error('[bus] error in', event, err, payload), middleware: [ (event, _payload, next) => { // run before listeners; omit next() to block dispatch console.debug('[mw]', event); next(); }, ], validatePayload: (event, payload) => { // throw to reject the emit before any middleware or listener runs if (event === 'count' && typeof payload !== 'number') throw new TypeError('must be number'); }, }); ``` ## Bus Interface ### `bus.disposed` Type: `readonly boolean` `true` after `dispose()` has been called. Use this to guard against using a torn-down bus. ```ts if (!bus.disposed) { bus.emit('user:login', payload); } ``` --- ### `bus.disposalSignal` Type: `readonly AbortSignal` An `AbortSignal` that fires when the bus is disposed. Use it to tie external lifecycles to the bus lifetime without polling `bus.disposed`. ```ts // Automatically unsubscribe from another bus when this bus is torn down otherBus.on('count', syncState, { signal: bus.disposalSignal }); // Cancel a fetch when the bus is disposed fetch('/api/stream', { signal: bus.disposalSignal }); // Combine with a timeout const signal = AbortSignal.any([bus.disposalSignal, AbortSignal.timeout(10_000)]); ``` The signal is already aborted when `bus.disposed` is `true`. --- ### `bus.on()` Signature: `on(event, listener, opts?) => Unsubscribe` Subscribe to an event. The listener runs synchronously on every emit. | Parameter | Type | Description | | ---------- | ------------------ | --------------------------------------------------- | | `event` | `K` | Event key to subscribe to | | `listener` | `Listener` | Callback for each emit | | `opts` | `SubscribeOptions` | Optional `{ signal?, once? }` for lifecycle control | **Returns:** `Unsubscribe` — call to remove the listener manually. If `opts.signal` is already aborted, `on()` returns a no-op unsubscribe immediately without adding the listener. Registering the same listener function twice creates **two independent subscriptions**. The listener will fire twice per emit and each registration has its own unsubscribe handle. There is no deduplication. ```ts const unsub = bus.on('user:login', ({ userId }) => { console.log('login:', userId); }); unsub(); // Auto-unsubscribe via AbortSignal const controller = new AbortController(); bus.on('theme:change', applyTheme, { signal: controller.signal }); controller.abort(); // listener removed // One-shot subscription inline bus.on('session:expired', redirectToLogin, { once: true }); // Both options combined bus.on('cart:updated', syncState, { once: true, signal: controller.signal }); ``` --- ### `bus.once()` Signature: `once(event, listener, opts?) => Unsubscribe` Convenience wrapper around `bus.on(event, listener, { once: true, signal: opts?.signal })`. The listener fires exactly once and is automatically removed. | Parameter | Type | Description | | ---------- | -------------------------- | ---------------------------------- | | `event` | `K` | Event key | | `listener` | `Listener` | One-shot callback | | `opts` | `{ signal?: AbortSignal }` | Optional `signal` for early cancel | **Returns:** `Unsubscribe` ```ts bus.once('session:expired', () => redirectToLogin()); // Cancel before it fires const controller = new AbortController(); bus.once('session:expired', redirectToLogin, { signal: controller.signal }); controller.abort(); ``` --- ### `bus.onAny()` Signature: `onAny(listener, opts?) => Unsubscribe` Subscribe to **all** events. The listener is called after event-specific listeners on every emit. | Parameter | Type | Description | | ---------- | ------------------------------------------------ | --------------------------------------------------- | | `listener` | `(event: EventKey, payload: unknown) => void` | Wildcard callback | | `opts` | `SubscribeOptions` | Optional `{ signal?, once? }` for lifecycle control | **Returns:** `Unsubscribe` `onAny` listeners count is tracked separately via `wildcardCount()` and are not included in per-event `listenerCount()` results. ```ts const unsub = bus.onAny((event, payload) => { analytics.track(event, payload); }); unsub(); // remove when done // With AbortSignal const controller = new AbortController(); bus.onAny(logger, { signal: controller.signal }); controller.abort(); // Fire exactly once bus.onAny(logFirstEvent, { once: true }); ``` --- ### `bus.wait()` Signature: `wait(event, opts?) => Promise` Returns a `Promise` that resolves with the payload of the next emit of `event`. | Parameter | Type | Description | | --------- | -------------------------- | -------------------------- | | `event` | `K` | Event key to await | | `opts` | `{ signal?: AbortSignal }` | Optional `signal` to abort | **Returns:** `Promise` **Rejects when:** - The bus is disposed before the event fires — rejects with `BusDisposedError` - The provided `signal` aborts — rejects with `signal.reason` ```ts const login = await bus.wait('user:login'); // With timeout const timedLogin = await bus.wait('user:login', { signal: AbortSignal.timeout(5_000) }); ``` --- ### `bus.waitAny()` Signature: `waitAny(events, opts?) => Promise` Waits for the first emitted event among a list of event keys. | Parameter | Type | Description | | --------- | -------------------------- | -------------------------- | | `events` | `readonly K[]` | Event keys to race | | `opts` | `{ signal?: AbortSignal }` | Optional `signal` to abort | **Returns:** `Promise>` **Throws synchronously:** `HeraldConfigError` if fewer than 2 event keys are provided. **Rejects when:** - The bus is disposed before any listed event fires — rejects with `BusDisposedError` - The provided `signal` aborts — rejects with `signal.reason` ```ts const winner = await bus.waitAny(['user:login', 'user:logout']); if (winner.event === 'user:login') { console.log(winner.payload.userId); } ``` --- ### `bus.events()` Signature: `events(event, options?) => EventStream` Returns an `EventStream` — an `AsyncGenerator` extended with `AsyncDisposable` and chainable `.filter()`, `.map()`, and `.take()` operators — that yields payloads for every future emit of `event`. - `event: K` — event key to stream - `options?: { signal?: AbortSignal; maxBuffer?: number }` — optional early termination and buffering `events()` validates `maxBuffer` **synchronously** at call time. If `maxBuffer` is `0` or negative, a `HeraldConfigError` is thrown immediately — not on the first `await`. `events()` subscribes **when called**, not when the first `for await` iteration begins. Events emitted before iteration starts are buffered and yielded immediately on the first iteration. **Terminates when:** - The bus is disposed — generator returns cleanly (no exception thrown) - The provided `signal` aborts — generator returns cleanly (no exception thrown) **`AsyncDisposable` support:** Use the `await using` keyword for guaranteed cleanup: ```ts // Standard iteration for await (const payload of bus.events('cart:updated')) { renderCart(payload.items); // loop exits cleanly when bus is disposed or signal aborts } // Stop early with a signal const ctl = new AbortController(); for await (const payload of bus.events('data:loaded', { signal: ctl.signal, maxBuffer: 50 })) { if (payload.count === 0) ctl.abort(); } // await using — cleanup guaranteed even on break or throw await using stream = bus.events('user:login'); for await (const { userId } of stream) { if (userId === targetId) break; // subscription torn down automatically } // Stop early with a break — subscription torn down automatically via AsyncDisposable await using stream2 = bus.events('count'); for await (const n of stream2) { if (n > 100) break; } ``` --- ### `bus.emit()` Signature: `emit(event, payload?) => number` Emit an event, calling all registered listeners synchronously in subscription order. Returns the number of listeners that were invoked. - For `void` events, no second argument is accepted. - For payload events, the second argument is required and type-checked. - Returns `0` when: the bus is disposed, a `middleware` function blocked dispatch, or `validatePayload` threw an error (with `onError` configured). - **Listener throws:** every registered listener (specific and wildcard) for the emission still runs, regardless of whether an earlier one threw. Without `onError` configured, the first thrown error is rethrown once every listener has been called — it never short-circuits the rest of the broadcast. With `onError` configured, every error is forwarded per-listener and `emit()` never throws for a listener failure. ```ts const count = bus.emit('user:login', { userId: '42', email: 'alice@example.com' }); bus.emit('user:logout'); // void — no argument console.log(count); // number of listeners invoked ``` --- ### `bus.dispose()` Signature: `dispose() => void` Permanently tears down the bus: - All listeners are removed. - All pending `wait()` promises are rejected with `BusDisposedError`. - Subsequent `emit()` and `on()` calls become no-ops. Idempotent — safe to call multiple times. ```ts bus.dispose(); bus.disposed; // true ``` --- ### `bus[Symbol.dispose]()` Signature: `[Symbol.dispose]() => void` Alias for `dispose()`. Enables the `using` keyword (TypeScript 5.2+): ```ts { using bus = createBus(); // ... } // dispose() called automatically ``` --- ### `bus.listenerCount()` Signature: `listenerCount(event?) => number` Returns the number of active listeners. | Parameter | Type | Description | | --------- | ------------------------ | ---------------------------------------- | | `event` | `EventKey` (optional) | Specific event key; omit for total count | **Returns:** `number` When called with an event key, the count includes **both** event-specific listeners **and** any `onAny` wildcard listeners — since wildcards fire on every emission of every event. When called without an argument, wildcards are counted once in the total. ```ts bus.on('user:login', handler1); bus.on('user:login', handler2); bus.on('user:logout', handler3); bus.onAny(wildcardHandler); bus.listenerCount('user:login'); // 3 — 2 specific + 1 wildcard bus.listenerCount('user:logout'); // 2 — 1 specific + 1 wildcard bus.listenerCount(); // 4 — 3 specific + 1 wildcard (wildcard counted once) bus.dispose(); bus.listenerCount(); // 0 ``` --- ### `bus.eventNames()` Signature: `eventNames() => EventKey[]` Returns a snapshot of event keys that currently have at least one active listener. ```ts bus.on('user:login', handler1); bus.on('user:logout', handler2); bus.eventNames(); // ['user:login', 'user:logout'] ``` --- ### `bus.wildcardCount()` Signature: `wildcardCount() => number` Returns the number of active `onAny` wildcard listeners. Wildcards are counted separately from event-specific listeners — this is the count that `listenerCount(event)` adds on top of the specific count for each event. ```ts bus.onAny(logAll); bus.onAny(trackAll); bus.wildcardCount(); // 2 bus.listenerCount('user:login'); // 0 specific + 2 wildcard = 2 ``` ## `BusOptions` — middleware `BusOptions.middleware` accepts an array of `Middleware` functions that run on every `emit()`, after `validatePayload` and before listeners. Each middleware receives `(event, payload, next)` — call `next()` to continue the chain; omit it to block dispatch entirely. ```ts import { createBus } from '@vielzeug/herald'; const bus = createBus({ middleware: [ // Logging middleware — logs every event then continues (event, payload, next) => { console.debug(`[mw] ${event}`, payload); next(); }, // Rate-limiting middleware — blocks dispatch under certain conditions (event, _payload, next) => { if (!rateLimiter.allow(event)) return; // omit next() to block listeners next(); }, ], }); bus.on('user:login', handler); bus.emit('user:login', { userId: '1', email: 'a@b.com' }); // → [mw] user:login { userId: '1', ... } // → handler fires (if rate limiter allows) ``` `emit()` returns `0` when any middleware omits `next()`. ## `BusOptions` — validatePayload `BusOptions.validatePayload` is called on every `emit()` **before** middleware and listeners. Throw to reject the payload entirely — no middleware or listener will run. | Throw with `onError` | Error forwarded to `onError`; `emit()` returns `0`. | | Throw without `onError` | Error propagates to the `emit()` caller. | ```ts const bus = createBus({ validatePayload: (event, payload) => { if (event === 'count' && typeof payload !== 'number') { throw new TypeError(`"count" payload must be a number, got ${typeof payload}`); } }, onError: ({ err, event }) => logger.warn('rejected emit', event, err), }); bus.on('count', vi.fn()); bus.emit('count', 'oops'); // → onError called; listener never fires; returns 0 bus.emit('count', 42); // → listener fires; returns 1 ``` ## `createBehaviorBus()` Signature: `createBehaviorBus(initial?, options?) => BehaviorBus` Creates a bus that replays the last known value to new subscribers. Useful for state-like events where late subscribers should receive the current value immediately. | Parameter | Type | Description | | --------- | -------------------- | ---------------------------------------------- | | `initial` | `BehaviorInitial` | Optional map of event names to starting values | | `options` | `BusOptions` | Standard bus options (hooks, logger, etc.) | **Returns:** `BehaviorBus` **Replay rules:** - `on()` and `once()` — replay the current value synchronously to new subscribers. - `events()`, `wait()`, `waitAny()` — no replay; behave like a regular bus. - The returned `BehaviorBus` adds `current(event)`, `reset()`, and `snapshot()` methods. - The replay buffer is only updated when dispatch actually runs — payloads rejected by `validatePayload` or blocked by middleware that omits `next()` are never buffered. - **`once()` with a buffered value:** if a current value exists, the listener fires immediately (synchronously) and is never registered for future emits. Use `on()` if you need to receive the _next_ emit rather than the current state. ```ts import { createBehaviorBus } from '@vielzeug/herald'; type UIEvents = { theme: 'light' | 'dark'; zoom: number }; const bus = createBehaviorBus({ theme: 'light', zoom: 1 }); // New subscribers receive the current value immediately bus.on('theme', applyTheme); // called with 'light' right now bus.emit('theme', 'dark'); bus.on('theme', applyTheme); // called with 'dark' right now // Read current value without subscribing bus.current('theme'); // 'dark' bus.current('zoom'); // 1 (from initial) ``` ### `behaviorBus.current()` Signature: `current(event) => T[K] | undefined` Returns the last emitted value for the given event, or `undefined` if no value has been emitted and no initial value was provided. ```ts bus.current('theme'); // 'dark' bus.current('unknown' as never); // undefined ``` --- ### `behaviorBus.reset()` Signature: `reset(event?) => void` Clears the replay buffer for a specific event, or for all events when called without arguments. After reset, new subscribers will not receive a replayed value until the next `emit()`. Does not affect active subscriptions or the disposed state of the bus. ```ts bus.emit('theme', 'dark'); bus.current('theme'); // 'dark' bus.reset('theme'); // clear only 'theme' bus.current('theme'); // undefined bus.reset(); // clear all buffers ``` --- ### `behaviorBus.snapshot()` Signature: `snapshot() => Partial` Returns a plain object containing the most recently emitted value for every currently buffered event. Events with no value in the buffer are omitted from the result. Useful for serializing the current state of all channels at once, for debugging, or for hydrating a new bus from a snapshot. An event named `__proto__`, `constructor`, or `prototype` is silently excluded from the returned object — bracket-assigning one of these keys onto a plain object literal would hijack its prototype rather than set an own property. The value itself is still tracked internally and reachable via `current(event)`; only the object-literal snapshot omits it. ```ts type UIEvents = { theme: 'light' | 'dark'; zoom: number; sidebar: boolean }; const bus = createBehaviorBus({ theme: 'light', zoom: 1 }); bus.snapshot(); // → { theme: 'light', zoom: 1 } (sidebar not buffered — omitted) bus.emit('theme', 'dark'); bus.snapshot(); // → { theme: 'dark', zoom: 1 } bus.reset('theme'); bus.snapshot(); // → { zoom: 1 } ``` ## `pipeEvents()` Signature: `pipeEvents(source, target, entries, opts?) => Unsubscribe` Forwards a selected subset of events from a source bus to a target bus. Source and target may have different event map types — only the listed keys must be compatible. | Parameter | Type | Description | | --------- | -------------------------------------------------- | -------------------------------------------------------------------------------- | | `source` | `Bus` | The bus to listen on | | `target` | `Bus` | The bus to forward events to | | `entries` | `readonly [PipeEntry, ...PipeEntry[]]` | One or more string keys or `{ from, to }` renames — throws `HeraldConfigError` if empty | | `opts` | `{ signal?: AbortSignal }` (optional) | Optional signal to stop forwarding early | **Returns:** `Unsubscribe` — call to stop forwarding manually. Forwarding stops automatically when the **target bus is disposed**. Source disposal is handled via the source bus's own subscription lifecycle. ```ts import { createBus, pipeEvents } from '@vielzeug/herald'; const appBus = createBus(); const auditBus = createBus(); // Forward only auth events — tears down automatically when auditBus disposes const unpipe = pipeEvents(appBus, auditBus, ['user:login', 'user:logout']); // Stop forwarding manually unpipe(); // Scope to a signal const controller = new AbortController(); pipeEvents(appBus, auditBus, ['user:login'], { signal: controller.signal }); controller.abort(); // forwarding stops // Rename events during forwarding type AuthEvents = { 'auth:login': { userId: string }; 'auth:logout': void }; type AppEventsTarget = { 'user:authenticated': { userId: string }; 'user:signed-out': void }; const authBus = createBus(); const targetBus = createBus(); pipeEvents(authBus, targetBus, [ { from: 'auth:login', to: 'user:authenticated' }, { from: 'auth:logout', to: 'user:signed-out' }, ]); ``` ## Testing Utilities Import from `@vielzeug/herald/testing`. ### `createTestBus()` Signature: `createTestBus(options?: BusOptions): TestBus` Creates a `TestBus` (a full `Bus` plus recording helpers). Behavior: - Every `emit()` is recorded per event key - `emitted(event)` returns a snapshot array - `reset()` clears records without removing listeners - `dispose()` clears listeners and records - Accepts full `BusOptions` including `onError` | Parameter | Type | Description | | --------- | --------------- | ----------------------------------------------- | | `options` | `BusOptions` | Optional hooks; composed with internal recorder | **Returns:** `TestBus` ```ts import { createTestBus } from '@vielzeug/herald/testing'; type AppEvents = { 'user:login': { userId: string }; }; const bus = createTestBus(); bus.emit('user:login', { userId: '1' }); console.log(bus.emitted('user:login')); // [{ userId: '1' }] ``` --- ### `testBus.emitted()` Signature: `emitted(event) => payload[]` Returns a **snapshot** of all payloads emitted for the given event key, in emission order. Each call returns a new array — mutations do not affect the internal records. ```ts bus.emit('user:login', { userId: '1', email: 'a@example.com' }); bus.emit('user:login', { userId: '2', email: 'b@example.com' }); bus.emitted('user:login'); // => [{ userId: '1', email: 'a@example.com' }, { userId: '2', email: 'b@example.com' }] ``` --- ### `testBus.emittedCount()` Signature: `emittedCount(event) => number` Returns the number of times the given event has been emitted. Shorthand for `emitted(event).length`. ```ts bus.emit('user:login', { userId: '1' }); bus.emit('user:login', { userId: '2' }); bus.emittedCount('user:login'); // 2 bus.emittedCount('user:logout'); // 0 ``` --- ### `testBus.reset()` Signature: `reset() => void` Clears all recorded payloads without disposing the bus or removing any listeners. ```ts bus.reset(); bus.emitted('user:login'); // => [] ``` --- ### `testBus.allEmitted()` Signature: `allEmitted() => { [K in EventKey]?: T[K][] }` Returns a snapshot object containing all recorded payloads for every event that has been emitted at least once. Keys absent from the result have never been emitted. Each call returns a new object — mutations do not affect internal records. Useful for asserting that **no other events** were emitted beyond the ones being tested. An event named `__proto__`, `constructor`, or `prototype` is silently excluded from the returned object — bracket-assigning one of these keys onto a plain object literal would hijack its prototype rather than set an own property. The recorded payloads are still tracked internally and reachable via `emitted(event)`; only the object-literal snapshot omits them. ```ts bus.emit('user:login', { userId: '1' }); bus.emit('theme:change', 'dark'); bus.allEmitted(); // => { 'user:login': [{ userId: '1' }], 'theme:change': ['dark'] } ``` --- ### `testBus.dispose()` Signature: `dispose() => void` Clears recorded payloads and then calls the underlying `bus.dispose()`, removing all listeners and rejecting pending waits. Idempotent. --- ### `testBus.removeAllListeners()` Signature: `removeAllListeners(event) => void` Unsubscribes all listeners registered via `on()` for the given event key. Emission records are preserved — call `reset()` separately to clear them. ```ts bus.on('user:login', handlerA); bus.on('user:login', handlerB); bus.emit('user:login', { userId: '1' }); bus.removeAllListeners('user:login'); bus.emit('user:login', { userId: '2' }); // neither handler fires bus.emitted('user:login'); // => [{ userId: '1' }, { userId: '2' }] — records intact ``` `removeAllListeners` is available on `TestBus` only — it is not part of the standard `Bus` interface. Use unsubscribe handles or `{ signal }` options for lifecycle-managed cleanup in production code. ## `combineSignals()` Signature: `combineSignals(first: AbortSignal, ...rest: AbortSignal[]) => AbortSignal` Returns a signal that aborts as soon as any of the provided signals abort. With a single argument, returns it directly (no allocation). Registers and cleans up its own event listeners — no leaks when no signal fires. | Parameter | Type | Description | | --------- | --------------- | ----------------------------------------------------- | | `first` | `AbortSignal` | First signal; returned directly if no others provided | | `...rest` | `AbortSignal[]` | Additional signals to race | **Returns:** `AbortSignal` — aborts when any input aborts. ```ts import { combineSignals, createBus } from '@vielzeug/herald'; const bus = createBus(); const timeoutSignal = AbortSignal.timeout(5_000); // Unsubscribe when the bus disposes OR after 5 seconds const signal = combineSignals(bus.disposalSignal, timeoutSignal); bus.on('user:login', handler, { signal }); // Three signals — no nesting required const signal3 = combineSignals(userSignal, timeoutSignal, bus.disposalSignal); ``` `AbortSignal.any([a, b])` is a platform equivalent, but it retains a strong reference to both signals until one fires. If neither signal ever fires, the internal `'abort'` listeners are never removed — a potential memory leak in long-lived buses. `combineSignals(a, b)` uses `once: true` listeners that clean up immediately as soon as either signal fires, making it the safer choice for `disposalSignal`-scoped subscriptions. ## Errors ### `BusDisposedError` ```ts class BusDisposedError extends Error { override name = 'BusDisposedError'; // message: 'Bus is disposed' or 'Bus "" is disposed' when name option is set } ``` Thrown as the rejection reason when a pending `wait()` or `waitAny()` call is interrupted by `bus.dispose()`. Also used as the abort reason on `bus.disposalSignal`. When the bus was created with a `name` option, the message includes the name: `Bus "myBus" is disposed`. Use `instanceof` to distinguish from signal aborts and other rejections: ```ts import { BusDisposedError } from '@vielzeug/herald'; try { await bus.wait('user:login'); } catch (err) { if (err instanceof BusDisposedError) { // bus was torn down before the event fired } else { throw err; // signal abort or unexpected error } } ``` --- ### `HeraldConfigError` ```ts class HeraldConfigError extends HeraldError {} ``` Thrown synchronously for invalid arguments or configuration, before any async work begins: - `waitAny(events)` — fewer than 2 event keys provided - `events(event, { maxBuffer })` — `maxBuffer` is `0` or negative - `pipeEvents(source, target, entries)` — `entries` is empty ```ts import { HeraldConfigError } from '@vielzeug/herald'; try { bus.waitAny(['user:login']); // only one key — needs at least 2 } catch (err) { if (err instanceof HeraldConfigError) { console.error('Invalid herald call:', err.message); } } ``` --- ### `HeraldError` ```ts class HeraldError extends Error { static is(err: unknown): err is HeraldError; } ``` Base class for every error herald throws (`BusDisposedError`, `HeraldConfigError`). Use `HeraldError.is()` to catch any herald-originated error without enumerating each subclass: ```ts import { HeraldError } from '@vielzeug/herald'; try { bus.waitAny(['user:login']); } catch (err) { if (HeraldError.is(err)) { // BusDisposedError or HeraldConfigError console.error('Herald error:', err.message); } else { throw err; // not from herald } } ``` ## Devtools Import from `@vielzeug/herald/devtools` — a dedicated sub-path so `console.debug` references are tree-shaken from production bundles when this sub-path is not imported. ### `debugBus()` Signature: `debugBus(options?) => Bus` Equivalent to `createBus({ logger: { debug: console.debug } })`. Pass `logger.warn` to also redirect or silence warnings; every other `BusOptions` field (`maxListeners`, `middleware`, `name`, `onError`, `validatePayload`) passes through unchanged. ```ts import { debugBus } from '@vielzeug/herald/devtools'; const bus = debugBus(); // or redirect warnings: const auditedBus = debugBus({ logger: { warn: myLogger.warn } }); ``` ### `debugBehaviorBus()` Signature: `debugBehaviorBus(initial?, options?) => BehaviorBus` Equivalent to `createBehaviorBus(initial, { logger: { debug: console.debug } })`. Same `logger.warn` override and `BehaviorBusOptions` passthrough as `debugBus()`. ```ts import { debugBehaviorBus } from '@vielzeug/herald/devtools'; const bus = debugBehaviorBus({ theme: 'light' }); // or redirect warnings: const auditedBus = debugBehaviorBus({ theme: 'light' }, { logger: { warn: myLogger.warn } }); ``` ### Usage Guide Start with the [Overview](./index.md) for a quick introduction and installation, then come back here for in-depth usage patterns. ## Basic Usage An event map is a plain TypeScript type where each key is an event name and each value is the payload type. Use `void` for signal events that carry no data. ```ts type AppEvents = { // events with payloads 'user:login': { userId: string; email: string }; 'user:logout': void; // signal — no payload 'cart:updated': { items: CartItem[]; total: number }; 'theme:change': 'light' | 'dark'; 'data:loaded': { count: number; items: unknown[] }; }; const bus = createBus(); ``` ## Subscribing ### `on()` — Persistent subscription `on()` registers a listener for every future emit of an event. It returns an `Unsubscribe` function. ```ts const unsub = bus.on('user:login', ({ userId, email }) => { console.log('logged in:', userId, email); // fully typed }); // Remove the listener unsub(); ``` Registering the **same listener function** twice creates two independent subscriptions — the listener fires twice per emit and each registration has its own independent unsubscribe handle. There is no deduplication. ### `once()` — One-shot listener `once()` registers a listener that fires exactly once, then removes itself automatically. ```ts bus.once('user:logout', () => { redirectToLogin(); }); ``` ### `eventNames()` — Introspect active subscriptions Get a snapshot of event keys that currently have listeners. ```ts bus.on('user:login', handler); bus.on('user:logout', handler); console.log(bus.eventNames()); // ['user:login', 'user:logout'] ``` ### AbortSignal and `SubscribeOptions` Pass a `SubscribeOptions` object as the third argument to `on()`. Use `signal` to auto-unsubscribe when an `AbortSignal` fires, and `once` to auto-remove after the first invocation. ```ts const controller = new AbortController(); // Auto-remove when signal aborts bus.on('user:login', handler, { signal: controller.signal }); // Later — removes the listener automatically controller.abort(); // One-shot subscription inline (equivalent to bus.once()) bus.on('user:login', handler, { once: true }); // Both combined — fires at most once, and only before signal aborts bus.on('cart:updated', handler, { once: true, signal: controller.signal }); ``` ## Emitting Events `emit()` calls all registered listeners synchronously and returns the number of listeners that were invoked. ```ts const count = bus.emit('user:login', { userId: '42', email: 'alice@example.com' }); bus.emit('user:logout'); // void event — no second argument console.log(count); // number of listeners fired ``` `emit()` returns `0` when the bus is disposed, when middleware blocks dispatch, or when `validatePayload` rejects the payload with `onError` configured. Every registered listener still runs even if an earlier one throws. Without `onError` configured, the first thrown error is rethrown once every listener has been called — it never short-circuits the rest of the broadcast. With `onError`, every error is captured per-listener and `emit()` never throws for a listener failure. ### Middleware Pass `middleware` to `createBus()` to intercept every `emit()` before listeners run. Middleware functions receive `(event, payload, next)` — call `next()` to continue, or omit it to block dispatch: ```ts const bus = createBus({ middleware: [ (event, payload, next) => { console.debug('[dispatch]', event, payload); next(); }, // Rate limit: block dispatch if quota is exceeded (event, _payload, next) => { if (rateLimiter.allow(event)) next(); }, ], }); ``` Multiple middleware run in array order. If any omits `next()`, subsequent middleware and all listeners are skipped and `emit()` returns `0`. ### `validatePayload` Use `validatePayload` for schema-level guards that run **before** middleware. Throw to reject the emit: ```ts const bus = createBus({ validatePayload: (event, payload) => { if (event === 'count' && typeof payload !== 'number') { throw new TypeError('count must be a number'); } }, onError: ({ err, event }) => logger.warn('rejected', event, err), }); bus.emit('count', 'oops'); // → onError called, listeners skipped, returns 0 bus.emit('count', 42); // → listeners run, returns listener count ``` Without `onError`, a `validatePayload` throw propagates directly to the `emit()` caller. ## Awaiting Events `wait()` returns a `Promise` that resolves with the payload of the next emit. This is useful for one-off async coordination patterns. ```ts // Waits for the next 'user:login' emit and resolves const { userId } = await bus.wait('user:login'); ``` `wait()` rejects if: - The bus is disposed before the event fires - A provided `AbortSignal` aborts ```ts // Reject if login hasn't happened within 5 seconds const { userId } = await bus.wait('user:login', { signal: AbortSignal.timeout(5_000) }); ``` ### `waitAny()` `waitAny()` resolves with the first event that fires from a list and returns both the winning event key and payload. ```ts const result = await bus.waitAny(['user:login', 'user:logout']); if (result.event === 'user:login') { console.log(result.payload.userId); } ``` Like `wait()`, it rejects when the bus is disposed or when the provided signal aborts: ```ts const result = await bus.waitAny(['user:login', 'user:logout'], { signal: AbortSignal.timeout(10_000) }); ``` ## Async Iteration `events()` returns an `AsyncGenerator` that yields every future emit of an event. It terminates when the bus is disposed or the provided signal aborts. `events()` subscribes **eagerly** — the listener is registered when `events()` is called, so events emitted before the first `await` are buffered and will be yielded on the next iteration. ```ts for await (const { items, total } of bus.events('cart:updated')) { renderCart(items, total); } ``` Use the `options` object to stop iterating early or cap the internal buffer: ```ts const controller = new AbortController(); for await (const payload of bus.events('data:loaded', { signal: controller.signal, maxBuffer: 100 })) { process(payload); if (isDone(payload)) controller.abort(); // exits the loop cleanly } // loop ends here — no exception thrown on abort or dispose ``` The generator is `AsyncDisposable` — use `await using` for guaranteed cleanup even on early `break`: ```ts await using stream = bus.events('user:login'); for await (const { userId } of stream) { if (userId === targetId) break; // stream subscription is torn down automatically } ``` ## Error Handling Every registered listener for an emission runs regardless of whether an earlier one throws. By default (no `onError` configured), the first thrown error propagates to the `emit()` caller once every listener has been called. Configure `onError` to capture errors instead of rethrowing. `emit()` never throws for a listener failure when `onError` is set. ```ts const bus = createBus({ onError: ({ err, event, payload, timestamp }) => { // Structured context — event, payload, and timestamp at the time emit() was called logger.error(`[herald] Error in "${event}" listener`, err, { payload, timestamp }); }, }); ``` `onError` receives an `EmissionErrorContext` object: - `err` — the thrown value - `event` — the event key that was being emitted - `payload` — the payload passed to the failing listener (typed as `unknown`) - `timestamp` — `Date.now()` captured at the moment `emit()` was called ## Dispose & Cleanup `dispose()` permanently tears down the bus: all listeners are removed and all pending `wait()` promises are rejected with `BusDisposedError`. ```ts bus.dispose(); bus.disposed; // true // Calling dispose() again is safe — idempotent bus.dispose(); // no-op ``` Use `instanceof BusDisposedError` to distinguish bus teardown from other rejections: ```ts import { BusDisposedError } from '@vielzeug/herald'; try { const payload = await bus.wait('user:login'); } catch (err) { if (err instanceof BusDisposedError) { // bus was torn down before the event fired } else { throw err; // signal abort reason or unexpected error } } ``` ### `disposalSignal` Every bus exposes a `disposalSignal: AbortSignal` property. The signal fires when `dispose()` is called, giving you a handle to tie external lifecycles to the bus lifetime without polling `bus.disposed`. ```ts const bus = createBus(); // Pass disposalSignal to another bus subscription — auto-unsubscribes on teardown otherBus.on('count', syncState, { signal: bus.disposalSignal }); // Use with any AbortSignal-aware API fetch('/api/stream', { signal: bus.disposalSignal }); // Combine with other signals const combined = AbortSignal.any([bus.disposalSignal, AbortSignal.timeout(10_000)]); bus.events('data:loaded', { signal: combined }); ``` The signal is already aborted when `bus.disposed` is `true`. ## Wildcard Listeners `onAny()` subscribes to **all events** on the bus. The listener receives the event name and payload on every emit, after event-specific listeners have run. Useful for cross-cutting concerns like logging, analytics, or dev-mode tracing. ```ts const unsub = bus.onAny((event, payload) => { console.debug(`[bus] ${event}`, payload); }); unsub(); // remove the wildcard listener when done ``` Like `on()`, `onAny()` accepts an optional `SubscribeOptions` object: ```ts const controller = new AbortController(); bus.onAny(logger, { signal: controller.signal }); controller.abort(); // removes the wildcard listener // Once-only wildcard bus.onAny(logFirstEvent, { once: true }); ``` Use `wildcardCount()` to inspect the current number of active wildcard listeners. `onAny` is a runtime listener — it can be added and removed dynamically, and accepts `{ signal, once }` options just like `on()`. Prefer it over global tracing hooks for cross-cutting concerns. ## Event Piping Use `pipeEvents()` to forward events from one bus to another. It supports same-name forwarding and event renaming. ```ts import { createBus, pipeEvents } from '@vielzeug/herald'; type AppEvents = { 'user:login': { userId: string; email: string }; 'user:logout': void; 'cart:updated': { items: CartItem[]; total: number }; }; const appBus = createBus(); const auditBus = createBus(); // Forward only auth events to the audit bus const unpipe = pipeEvents(appBus, auditBus, ['user:login', 'user:logout']); ``` `pipeEvents` returns an `Unsubscribe` function to stop forwarding manually: ```ts unpipe(); // stop forwarding ``` Forwarding stops automatically when the **target** bus is disposed — no manual cleanup needed. Source disposal is handled by the source bus's own `on()` lifecycle. You can scope piping to a signal: ```ts const controller = new AbortController(); pipeEvents(appBus, auditBus, ['user:login'], { signal: controller.signal }); // Stop forwarding after 30 seconds setTimeout(() => controller.abort(), 30_000); ``` ### Event renaming Pass a `{ from, to }` object to forward an event under a different name on the target bus. This enables cross-domain event translation without manually wiring `on()` + `emit()`. ```ts type AuthEvents = { 'auth:login': { userId: string }; 'auth:logout': void }; type AppEvents = { 'user:authenticated': { userId: string }; 'user:signed-out': void }; const authBus = createBus(); const appBus = createBus(); pipeEvents(authBus, appBus, [ { from: 'auth:login', to: 'user:authenticated' }, { from: 'auth:logout', to: 'user:signed-out' }, ]); ``` Mix string keys and `{ from, to }` objects freely in the same array: ```ts pipeEvents(sourceBus, targetBus, [ 'config:updated', // same name { from: 'auth:login', to: 'user:authenticated' }, // renamed ]); ``` ## Behavior Bus `createBehaviorBus()` creates a bus that remembers and replays the last emitted value to new subscribers. This is useful for state-like events where late subscribers should receive the current value immediately — similar to a BehaviorSubject in RxJS. ```ts import { createBehaviorBus } from '@vielzeug/herald'; type UIState = { theme: 'light' | 'dark'; zoom: number }; // Provide initial values — these are replayed to first subscribers const bus = createBehaviorBus({ theme: 'light', zoom: 1 }); bus.on('theme', applyTheme); // called with 'light' immediately bus.on('zoom', setZoom); // called with 1 immediately bus.emit('theme', 'dark'); bus.on('theme', applyTheme); // called with 'dark' immediately — gets current value ``` ### `current()` Read the current value for any event without subscribing: ```ts bus.current('theme'); // 'dark' bus.current('zoom'); // 1 ``` ### `snapshot()` Read all currently buffered values at once as a plain object: ```ts bus.snapshot(); // → { theme: 'dark', zoom: 1 } (only buffered events are included) ``` This is useful for serializing state, hydrating a new bus, or debugging all channels simultaneously. ### Replay rules | Method | Replays current value? | | -------------- | ---------------------------------------------------------- | | `on()` | Yes | | `once()` | Yes (then done) | | `on({ once })` | Yes (then done) | | `events()` | No | | `wait()` | No | If the bus has a buffered value for the event, `once()` (and `on(event, fn, { once: true })`) fires the listener **synchronously** with the current value and is immediately done — the listener is never registered for future emits. If you need to react to the _next_ new emit rather than the current state, use `on()` and unsubscribe manually after the first call. ## Debug Mode Import `debugBus` from the dedicated sub-path to create a bus with debug logging pre-enabled. The sub-path is tree-shaken from production bundles when not imported. ```ts import { debugBus } from '@vielzeug/herald/devtools'; const bus = debugBus(); bus.on('user:login', handler); // → [herald:on] on("user:login") bus.emit('user:login', { email: 'alice@example.com', userId: '42' }); // → [herald:emit] emit("user:login") — 1 listener(s) bus.dispose(); // → [herald:lifecycle] dispose() ``` Alternatively, wire logging manually by passing `logger.debug` directly to `createBus()`: ```ts const bus = createBus({ logger: { debug: console.debug } }); // equivalent ``` `debugBehaviorBus` is the same wrapper for `createBehaviorBus()`: ```ts import { debugBehaviorBus } from '@vielzeug/herald/devtools'; const bus = debugBehaviorBus({ theme: 'light' }); bus.on('theme', applyTheme); // replays 'light' immediately, logs the subscription ``` Debug logging has no effect on behavior and should not be enabled in production. ### Custom logger Provide a `logger` object to route or silence debug and warn output: ```ts const bus = createBus({ logger: { debug: (msg) => myLogger.trace(msg), // enable + redirect debug output warn: (msg) => myLogger.warn(msg), // redirect warn output }, }); // Omit logger.debug to disable debug logging, omit logger.warn to silence warnings const warnOnlyBus = createBus({ logger: { warn: console.warn } }); // Pass {} to suppress all bus logging entirely const silentBus = createBus({ logger: {} }); ``` ### Naming a bus with `name` Pass `name` to identify a bus in log messages and error output. Useful when multiple buses run concurrently and you need to distinguish their activity: ```ts const authBus = createBus({ name: 'auth', logger: { debug: console.debug } }); const cartBus = createBus({ name: 'cart', logger: { debug: console.debug } }); authBus.emit('user:login', { userId: '1' }); // → [herald:emit] emit("user:login") — 1 listener(s) (auth) cartBus.dispose(); // → [herald:lifecycle] dispose() (cart) ``` When a named bus is disposed, `BusDisposedError` includes the name in its message: ```ts // Bus "auth" is disposed ``` `name` has no effect on behavior and does not need to be unique. ### Detecting listener leaks with `maxListeners` Pass `maxListeners` to `createBus()` to receive a `console.warn` whenever a single event's listener count exceeds the threshold. This helps catch accidental listener accumulation during development. ```ts const bus = createBus({ maxListeners: 10 }); // Registering an 11th listener for 'cart:updated' prints: // [herald:warn] "cart:updated" has 11 listeners, exceeding maxListeners (10). Possible memory leak. ``` The warning fires for both event-specific listeners (`on`, `once`) and wildcard listeners (`onAny`). There is no effect on bus behavior — all listeners are still registered and invoked normally. ### Counting listeners `listenerCount()` lets you inspect active subscriptions without needing to track them manually: ```ts bus.on('user:login', handler1); bus.on('user:login', handler2); bus.on('user:logout', handler3); bus.onAny(wildcardHandler); bus.listenerCount('user:login'); // 3 — 2 specific + 1 wildcard bus.listenerCount(); // 4 — 3 specific + 1 wildcard (wildcards counted once) ``` This is useful for debugging, assertions in tests, or conditional emit optimizations. You can combine this with `eventNames()` when you need a quick snapshot of which channels are active. ### `using` keyword `Bus` implements `[Symbol.dispose]`, so it works with the `using` keyword (TypeScript 5.2+, `"lib": ["esnext"]`): ```ts { using bus = createBus(); bus.on('user:login', handler); bus.emit('user:login', { userId: '1', email: 'a@b.com' }); } // bus.dispose() is called automatically here ``` This is especially useful in test cases, request handlers, or any scope where you want guaranteed cleanup. ## Testing Import `createTestBus` from `@vielzeug/herald/testing`. It wraps `createBus` and records every emitted payload by event key. ```ts import { createTestBus } from '@vielzeug/herald/testing'; const bus = createTestBus(); bus.emit('user:login', { userId: '1', email: 'a@example.com' }); bus.emit('user:login', { userId: '2', email: 'b@example.com' }); // emitted() returns a typed snapshot — not a live reference expect(bus.emitted('user:login')).toEqual([ { userId: '1', email: 'a@example.com' }, { userId: '2', email: 'b@example.com' }, ]); bus.reset(); // clear recorded payloads, keep listeners active bus.dispose(); // clear listeners and recorded payloads ``` Use `emittedCount(event)` when you only need the count, not the full payload list: ```ts bus.emittedCount('user:login'); // number of times the event was emitted ``` `createTestBus` accepts the full `BusOptions` including `onError`. Use `reset()` to clear recorded payloads between assertions without affecting active listeners: ```ts bus.emit('user:login', { email: 'a@example.com', userId: '1' }); bus.reset(); // clears emission records — listeners remain active bus.emitted('user:login'); // => [] ``` Use `using` for automatic cleanup in test cases: ```ts it('records emitted events', () => { using bus = createTestBus(); bus.emit('user:logout'); expect(bus.emitted('user:logout')).toHaveLength(1); }); // bus disposed automatically ``` ## Framework Integration ```tsx [React] import { useEffect } from 'react'; import { createBus } from '@vielzeug/herald'; type AppEvents = { 'user:login': { userId: string; email: string }; 'user:logout': void; }; // Module-level bus shared across components const bus = createBus(); function useEvent(event: K, handler: (payload: AppEvents[K]) => void) { useEffect(() => { const controller = new AbortController(); bus.on(event as any, handler as any, { signal: controller.signal }); return () => controller.abort(); }, [event, handler]); } function LoginButton() { useEvent('user:login', ({ userId }) => console.log('logged in:', userId)); return bus.emit('user:login', { userId: '1', email: 'a@x.com' })}>Login; } ``` ```ts [Vue 3] import { onScopeDispose } from 'vue'; import { createBus } from '@vielzeug/herald'; type AppEvents = { 'user:login': { userId: string; email: string }; 'user:logout': void; }; const bus = createBus(); function useEvent(event: K, handler: (payload: AppEvents[K]) => void) { const controller = new AbortController(); bus.on(event as any, handler as any, { signal: controller.signal }); onScopeDispose(() => controller.abort()); } ``` ```svelte [Svelte] import { onDestroy } from 'svelte'; import { createBus } from '@vielzeug/herald'; type AppEvents = { 'user:login': { userId: string; email: string }; 'user:logout': void; }; const bus = createBus(); // Listen to events with automatic cleanup on component destroy const controller = new AbortController(); bus.on('user:login', ({ userId }) => console.log('logged in:', userId), { signal: controller.signal }); onDestroy(() => controller.abort()); function login() { bus.emit('user:login', { userId: '1', email: 'alice@example.com' }); } Login ``` ## Working with Other Vielzeug Libraries ### With Rune Use Rune to trace all event dispatches in development. ```ts import { createBus } from '@vielzeug/herald'; import { createLogger } from '@vielzeug/rune'; const logger = createLogger({ scope: 'herald' }); const bus = createBus({ logger: { debug: logger.debug, warn: logger.warn }, onError: ({ err, event }) => logger.error('handler failed', { err, event }), }); ``` ### With Ripple Use Ripple signals to reflect the latest event payload as reactive state. ```ts import { createBus } from '@vielzeug/herald'; import { signal } from '@vielzeug/ripple'; type AppEvents = { 'user:login': { userId: string; email: string }; 'user:logout': void }; const bus = createBus(); const currentUser = signal(null); bus.on('user:login', (payload) => { currentUser.value = payload; }); bus.on('user:logout', () => { currentUser.value = null; }); ``` ## Best Practices - Create one bus per logical domain (e.g., one bus per micro-frontend module) rather than a single global bus. - Pass `AbortSignal` to `on()` and `once()` for lifecycle-bound listeners — avoids manual `unsub()` tracking. - Use `wait()` for one-off async coordination; use `events()` for continuous processing pipelines. - Configure `onError` on the bus rather than wrapping each listener in try/catch. - Call `dispose()` when a bus is no longer needed — it rejects all pending `wait()` promises. - Use `pipeEvents()` to forward events between buses rather than re-emitting manually inside listeners. - Pass `bus.disposalSignal` to tie external subscriptions and fetch calls to the bus lifetime. - Prefer typed `EventMap` interfaces over generic string keys for full payload inference. - Use `createTestBus` from `@vielzeug/herald/testing` in unit tests rather than mocking the bus. ### Examples ## Examples - [Standalone Entry](./examples/standalone-entry.md) - [Module Level Bus](./examples/module-level-bus.md) - [Awaiting A One Time Event](./examples/awaiting-a-one-time-event.md) - [Inspecting Listener Counts](./examples/inspecting-listener-counts.md) - [Custom Error Boundary](./examples/custom-error-boundary.md) - [Handling Disposal In Async Code](./examples/handling-disposal-in-async-code.md) - [Request Scoping](./examples/request-scoping.md) - [Streaming With Events](./examples/streaming-with-events.md) - [Bus Bridging With pipeEvents](./examples/bus-bridging-with-pipeevents.md) - [Testing With Createtestbus](./examples/testing-with-createtestbus.md) ### REPL Examples - AbortSignal & BusDisposedError (id: `abort-signal`) - events() - Async Generator (id: `async-generator`) - Async wait() (id: `async-wait`) - Basic Bus (id: `basic-bus`) - Behavior Bus (id: `behavior-bus`) - BehaviorBus snapshot() (id: `behavior-snapshot`) - createBus - Basics (id: `bus-basics`) - disposalSignal (id: `disposal-signal`) - Error Handling (id: `error-handling`) - events() with break (id: `event-stream-take`) - Custom Logger (id: `logger-option`) - Named Bus (id: `named-bus`) - once() and wait() (id: `once-and-wait`) - pipeEvents() (id: `pipe-events`) - createTestBus() (id: `test-bus`) - waitAny() (id: `wait-any`) - onAny + wildcardCount() (id: `wildcard-listeners`) --- ## @vielzeug/keymap **Category:** app-infrastructure **Keywords:** keyboard, shortcuts, hotkeys, chord, keybinding, headless, accessibility **Key exports:** canonicalizeShortcut, createKeymap, createKeymapLayer, detectModKey, findShortcutConflicts, formatShortcut, KeymapError, KeymapParseError, matchStep, parseShortcut, parseStep **Related:** herald, refine, ore ### Overview ## Why Keymap? Browser keyboard handling is error-prone: modifier key normalisation, platform differences (`ctrl` vs `meta`), chord sequences, and cleanup all require boilerplate. Keymap handles all of it in a headless, zero-dependency package. | Feature | Raw `addEventListener` | Keymap | | -------------------- | -------------------------------------------- | --------------------------------------------------------- | | Bundle size | 0 B (built-in) | | | Zero dependencies | | | | Chord sequences | | | | Modifier aliases | | `cmd`, `win`, `option` → canonical | | Context guards | Manual `if` in handler | `when()` predicate per keymap | | Headless / SSR-safe | DOM required | | | Disposable | Manual `removeEventListener` | `dispose()` + `using` | **Use Keymap when** you need chord sequences (`g g`, `ctrl+k ctrl+s`), modifier aliases, or context-scoped hotkeys that can be cleanly mounted and unmounted. **Stick with raw `addEventListener` when** you have a single, static, never-removed hotkey and don't need chords. ## Installation ```sh [pnpm] pnpm add @vielzeug/keymap ``` ```sh [npm] npm install @vielzeug/keymap ``` ```sh [yarn] yarn add @vielzeug/keymap ``` ## Quick Start ```ts import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap({ 'ctrl+k ctrl+s': () => save(), 'meta+shift+p': () => openPalette(), 'g g': () => goToTop(), 'escape': () => closePanel(), }); const unmount = map.mount(document); // Later: unmount(); // remove from this target only map.dispose(); // or: using map = createKeymap(…) ``` ## Features - `createKeymap()` — Create a keymap from a bindings record; mount to any `EventTarget` - Chord sequences — `"g g"`, `"ctrl+k ctrl+s"` with configurable timeout (default 1 s) - Modifier aliases — `cmd`/`command`/`win` → `meta`; `opt`/`option` → `alt`; `mod` → platform-aware - `BindingOptions` — per-binding `{ handler, when?, trigger?, priority? }` object syntax - `modKey` option — explicit platform override for SSR and cross-platform tests - `formatShortcut()` — platform-aware display (`⇧⌘P` on Mac, `Ctrl+Shift+P` elsewhere) - `createKeymapLayer()` — scoped keymap stack with `activate()` / `deactivate()` - `parseShortcut()` / `parseStep()` / `matchStep()` — exposed for building custom matchers or testing - `canonicalizeShortcut()` — convert any shortcut alias to a stable key for conflict detection - `detectModKey()` — platform modifier detection (`'meta'` on Mac, `'ctrl'` elsewhere) - `listBindings()` — snapshot all active bindings (shortcut, trigger, priority) for palette UIs - `findShortcutConflicts()` — detect prefix/duplicate conflicts before binding a user-customized shortcut - Disposable — `dispose()` + `[Symbol.dispose]` for `using` declarations ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Herald](/herald/) — Typed event bus; pair with Keymap by publishing shortcut events to a bus instead of calling handlers directly - [Refine](/refine/) — `ore-command-palette` uses Keymap internally; register your own shortcuts alongside it - [Ore](/ore/) — Attach a keymap inside a `define()` setup function for component-scoped shortcuts ### API Reference ## API Overview | Export | Kind | Description | | ------ | ---- | ----------- | | `createKeymap` | function | Creates a headless keyboard shortcut manager | | `createKeymapLayer` | function | Creates a scoped keymap layer that stacks on a parent | | `formatShortcut` | function | Formats a shortcut string for display (Mac symbols or word labels) | | `findShortcutConflicts` | function | Finds registered bindings that would conflict with a proposed shortcut | | `KeymapError` | class | Base class for all keymap errors | | `KeymapParseError` | class | Thrown when a shortcut string cannot be parsed | | `Keymap` | interface | Object returned by `createKeymap` | | `KeymapLayer` | interface | Extends `Keymap` with `activate()`, `deactivate()`, `active` | | `KeymapOptions` | interface | Options for `createKeymap` and `createKeymapLayer` | | `BindingOptions` | type | Per-binding object: `{ handler, when?, trigger?, priority? }` | | `BindingValue` | type | `Handler \| BindingOptions` — accepted wherever a handler is bound | | `Handler` | type | `(event: KeyboardEvent) => void` | | `parseShortcut` | function | Parses a shortcut string into `ShortcutStep[]` | | `parseStep` | function | Parses a single chord step into `ShortcutStep \| null` | | `matchStep` | function | Tests whether a `KeyboardEvent` matches a `ShortcutStep` | | `canonicalizeShortcut` | function | Converts `ShortcutStep[]` into a stable canonical string | | `detectModKey` | function | Detects the platform modifier key (`'ctrl'` or `'meta'`) | | `ShortcutStep` | type | `{ key: string; modifiers: Set }` — one parsed step | | `Shortcut` | type | `ShortcutStep[]` — the result of `parseShortcut` | | `ModifierKey` | type | `'alt' \| 'ctrl' \| 'meta' \| 'shift'` | | `BindingEntry` | type | Snapshot of a registered binding: `{ shortcut, trigger, priority }` | | `ConflictOptions` | type | Options for `findShortcutConflicts`: `{ modKey?, trigger? }` | ## Package Entry Points ```ts import { canonicalizeShortcut, createKeymap, createKeymapLayer, detectModKey, findShortcutConflicts, formatShortcut, KeymapError, KeymapParseError, matchStep, parseShortcut, parseStep, } from '@vielzeug/keymap'; import type { BindingEntry, BindingOptions, BindingValue, ConflictOptions, Handler, Keymap, KeymapLayer, KeymapOptions, ModifierKey, Shortcut, ShortcutStep, } from '@vielzeug/keymap'; ``` ## `createKeymap(bindings?, options?)` Creates a headless keyboard shortcut manager. ```ts function createKeymap( bindings?: Record, options?: KeymapOptions, ): Keymap ``` **Parameters** - `bindings` — Optional record mapping shortcut strings to `BindingValue`. Shortcut strings support chord sequences (space-separated steps), modifier aliases (`mod`, `cmd`, `ctrl`, `alt`, `shift`), special key aliases (`esc`, `space`, `up`, etc.), and are case-insensitive. - `options` — Optional configuration (see `KeymapOptions`). **Returns** a `Keymap` object. **Example** ```ts const map = createKeymap({ 'mod+k mod+s': () => save(), 'mod+shift+p': () => openPalette(), 'g g': () => goToTop(), esc: { handler: closePanel, when: () => isPanelOpen() }, space: { handler: togglePlay, trigger: 'keyup' }, }, { modKey: 'ctrl' }); const unmount = map.mount(document); ``` ## `Keymap` ```ts interface Keymap { bind(shortcut: string, value: BindingValue): () => void; dispose(): void; readonly disposalSignal: AbortSignal; readonly disposed: boolean; listBindings(): readonly BindingEntry[]; mount(target: EventTarget): () => void; unbind(shortcut: string): void; [Symbol.dispose](): void; } ``` ### `mount(target)` Attaches `keydown` and `keyup` listeners to `target`. Returns an unmount function that removes only the listeners added by this call. ```ts const unmount = map.mount(document); unmount(); // detach ``` One keymap can be mounted to multiple targets simultaneously. Each call returns an independent unmount function. Mounting the **same** target a second time without unmounting first still attaches a second listener (handlers fire twice) — this emits a dev warning rather than throwing, since remounting the same target is occasionally intentional. ### `bind(shortcut, value)` Adds or replaces a binding at runtime. Returns an unbind function. ```ts const unbind = map.bind('ctrl+shift+f', () => openSearch()); unbind(); // remove just this binding ``` Throws if `shortcut` is invalid (modifier-only, empty, or ambiguous). ### `unbind(shortcut)` Removes the binding for the given shortcut string. Emits a dev warning if the shortcut is not registered; never throws. ```ts map.unbind('ctrl+k'); ``` ### `dispose()` Removes all mounted listeners and resets chord state. Idempotent. ```ts map.dispose(); // or: using map = createKeymap({ ... }); ``` ### `listBindings()` Returns a snapshot of all currently registered bindings. Does not include `handler` or `when` — only the shortcut shape, trigger, and priority. ```ts const entries = map.listBindings(); // [ // { shortcut: [{ key: 'k', modifiers: Set { 'ctrl' } }], trigger: 'keydown', priority: 0 }, // ] ``` Useful for building shortcut palette UIs, conflict detection, and accessibility overlays. ## `KeymapOptions` ```ts interface KeymapOptions { chordTimeout?: number; // default: 1000ms modKey?: 'ctrl' | 'meta'; // default: platform-detected preventDefault?: boolean; // default: true stopPropagation?: boolean; // default: false when?: () => boolean; } ``` | Option | Default | Description | | ------ | ------- | ----------- | | `chordTimeout` | `1000` | Milliseconds before a partial chord sequence resets. A non-finite or non-positive value falls back to `1000` with a dev warning. | | `modKey` | platform | Override `mod` alias resolution: `'meta'` (Mac ⌘) or `'ctrl'` (Windows/Linux). Auto-detected from `navigator` when omitted. | | `preventDefault` | `true` | Call `event.preventDefault()` on a matched binding | | `stopPropagation` | `false` | Call `event.stopPropagation()` on a matched binding | | `when` | — | Global guard predicate; all bindings are suppressed when `when()` returns `false` | ## `createKeymapLayer(parent, bindings?, options?)` Creates a scoped keymap layer that stacks on top of a parent keymap. The caller is responsible for mounting both the parent and the layer independently — each manages its own event listeners. ```ts function createKeymapLayer( parent: Keymap, bindings?: Record, options?: KeymapOptions, ): KeymapLayer ``` **Example** ```ts const base = createKeymap({ 'ctrl+z': undo }); const modal = createKeymapLayer(base, { esc: { handler: closeModal, when: () => isModalOpen() }, }); // Mount parent and layer independently — each manages its own listeners. const unmountBase = base.mount(document); const unmountModal = modal.mount(document); modal.deactivate(); // base handles everything; layer is suspended modal.activate(); // layer resumes unmountModal(); unmountBase(); ``` Disposing the layer does **not** dispose the parent — the caller owns the parent lifecycle. ## `KeymapLayer` ```ts interface KeymapLayer extends Keymap { activate(): void; deactivate(): void; readonly active: boolean; readonly parent: Keymap; } ``` | Member | Description | | ------ | ----------- | | `activate()` | Re-enables the layer (default: active) | | `deactivate()` | Suspends the layer; the parent keymap continues to fire normally | | `active` | `true` when the layer is currently active | | `parent` | Returns the parent `Keymap` passed to `createKeymapLayer` | | `listBindings()` | Returns the layer's own bindings (not the parent's) | ## `formatShortcut(shortcut, modKey?)` Formats a shortcut string into a human-readable display string. Resolves `mod` using `modKey`. ```ts function formatShortcut( shortcut: string, modKey?: 'ctrl' | 'meta', ): string ``` On Mac (`modKey: 'meta'`), uses standard Mac symbols. On other platforms, uses word labels. ```ts formatShortcut('mod+shift+p', 'meta') // '⇧⌘P' formatShortcut('mod+shift+p', 'ctrl') // 'Ctrl+Shift+P' formatShortcut('ctrl+k ctrl+s', 'meta') // '⌃K ⌃S' formatShortcut('escape', 'meta') // 'Esc' ``` ## `findShortcutConflicts(shortcut, entries, options?)` Finds registered bindings that would conflict with a proposed shortcut — an exact duplicate, a shorter binding that would be shadowed as a chord prefix, or a longer binding the proposed shortcut would itself shadow. Only compares against entries sharing the same `trigger`. ```ts function findShortcutConflicts( shortcut: string, entries: readonly BindingEntry[], options?: ConflictOptions, ): BindingEntry[] ``` **Parameters** - `shortcut` — The shortcut string being considered for a new binding. - `entries` — Existing bindings to check against — typically `map.listBindings()`. - `options` — See `ConflictOptions`. `trigger` defaults to `'keydown'`. **Returns** the subset of `entries` that conflict; `[]` if there's no relationship (or `shortcut` is empty/whitespace-only). **Example** ```ts const map = createKeymap({ g: () => scrollToTop() }); findShortcutConflicts('g g', map.listBindings()); // → [{ shortcut: [{ key: 'g', modifiers: Set {} }], trigger: 'keydown', priority: 0 }] // binding 'g g' would be shadowed: 'g' fires immediately before the second step is ever read ``` Useful when building a shortcut-customization UI — check `findShortcutConflicts()` before calling `bind()` to warn the user instead of silently creating an unreachable binding. ### `ConflictOptions` ```ts interface ConflictOptions { modKey?: 'ctrl' | 'meta'; trigger?: 'keydown' | 'keyup'; } ``` | Field | Default | Description | | ----- | ------- | ----------- | | `modKey` | platform | Resolves `mod` in the proposed `shortcut` string | | `trigger` | `'keydown'` | Which entries to compare against — `'keydown'` and `'keyup'` never conflict with each other | ## Errors ### `KeymapError` Base class for all keymap errors. Use `instanceof KeymapError` (or `KeymapError.is()`) to catch any keymap-originated error. ```ts class KeymapError extends Error { static is(err: unknown): err is KeymapError; } ``` ### `KeymapParseError` Thrown when a shortcut string cannot be parsed — an ambiguous multi-key step (e.g. `'ctrl+k+j'`) or an invalid step (modifier-only, with no key). Extends `KeymapError`. ```ts class KeymapParseError extends KeymapError {} ``` ```ts import { KeymapError, KeymapParseError } from '@vielzeug/keymap'; try { map.bind('ctrl+k+j', handler); } catch (err) { if (KeymapError.is(err)) { console.error(err.message); // 'Ambiguous shortcut step: "ctrl+k+j" — multiple non-modifier keys found' } } ``` ## Types ### `BindingOptions` Per-binding configuration object. ```ts type BindingOptions = { handler: Handler; priority?: number; // default: 0 trigger?: 'keydown' | 'keyup'; // default: 'keydown' when?: () => boolean; }; ``` | Field | Default | Description | | ----- | ------- | ----------- | | `handler` | — | The function to call when the shortcut fires | | `priority` | `0` | Reserved for future conflict resolution — see note below. A non-finite value falls back to `0` with a dev warning. | | `trigger` | `'keydown'` | Which keyboard event phase fires the handler | | `when` | — | Per-binding guard; handler suppressed when `when()` returns `false` at event time | > **Note on `priority`:** because bindings are keyed by their canonical shortcut string, two *live* bindings can never share an identical step sequence — the moment they would, the second `bind()` call simply replaces the first (see `bind(shortcut, value)` above). There is currently no scenario where two distinct bindings compete to fire the same event, so `priority` has no observable effect on which handler runs. It's kept as a documented, validated field for forward compatibility rather than removed outright. ### `BindingValue` ```ts type BindingValue = Handler | BindingOptions; ``` A plain function is treated as `{ handler: fn, priority: 0, trigger: 'keydown' }`. Use `BindingOptions` for any per-binding customisation. ```ts const map = createKeymap({ 'ctrl+k': () => quickAction(), esc: { handler: closePanel, when: () => isPanelOpen() }, space: { handler: togglePlay, trigger: 'keyup' }, }); ``` ### `Handler` ```ts type Handler = (event: KeyboardEvent) => void; ``` ### `ShortcutStep` One parsed step within a shortcut sequence. ```ts type ShortcutStep = { key: string; // lowercase, alias-resolved key name modifiers: Set; // required modifier keys }; ``` ### `Shortcut` An alias for `ShortcutStep[]` — the direct return type of `parseShortcut`. ```ts type Shortcut = ShortcutStep[]; ``` ### `ModifierKey` ```ts type ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift'; ``` ### `BindingEntry` A read-only snapshot of a registered binding, returned by `listBindings()`. The `handler` and `when` guard are intentionally omitted. ```ts type BindingEntry = { readonly priority: number; readonly shortcut: readonly ShortcutStep[]; readonly trigger: 'keydown' | 'keyup'; }; ``` | Field | Description | | ----- | ----------- | | `priority` | The binding's priority value | | `shortcut` | The parsed shortcut steps | | `trigger` | Which event phase fires the handler | --- ## Parser Utilities ### `parseShortcut(raw, modKey?)` Parses a shortcut string into an array of `ShortcutStep` objects. Useful for building custom matchers, testing, or integrating with other libraries. ```ts function parseShortcut( raw: string, modKey?: 'ctrl' | 'meta', // default: auto-detected ): Shortcut ``` Throws if any non-empty step is invalid (modifier-only with no key, or ambiguous multi-key step like `ctrl+k+j`). Extra whitespace between steps is silently ignored. ```ts parseShortcut('ctrl+k ctrl+s', 'ctrl') // [ // { key: 'k', modifiers: Set { 'ctrl' } }, // { key: 's', modifiers: Set { 'ctrl' } }, // ] ``` ### `parseStep(raw, modKey?)` Parses a **single** chord step (one keypress) into a `ShortcutStep`, or returns `null` if the step is empty or invalid. Does not throw. ```ts function parseStep( raw: string, modKey?: 'ctrl' | 'meta', ): ShortcutStep | null ``` Unlike `parseShortcut`, `parseStep` returns `null` instead of throwing on invalid input — useful for "try" patterns when parsing user-typed shortcut strings one step at a time. ```ts parseStep('ctrl+k', 'ctrl') // { key: 'k', modifiers: Set { 'ctrl' } } parseStep('', 'ctrl') // null ``` ### `matchStep(event, step)` Tests whether a `KeyboardEvent` matches a `ShortcutStep`. Zero allocations — pure boolean comparisons. ```ts function matchStep(event: KeyboardEvent, step: ShortcutStep): boolean ``` Returns `false` (never throws) for a malformed event missing a string `.key` — safe to call with hand-built event objects in headless/non-DOM usage. ### `canonicalizeShortcut(steps)` Converts a `ShortcutStep[]` (i.e. the result of `parseShortcut`) into a stable canonical string. Modifiers are sorted alphabetically, steps are space-separated. Useful for conflict detection: two shortcuts resolve to the same canonical string if and only if they match the same key events. ```ts function canonicalizeShortcut(steps: readonly ShortcutStep[]): string ``` ```ts canonicalizeShortcut(parseShortcut('cmd+k', 'ctrl')) // 'meta+k' canonicalizeShortcut(parseShortcut('meta+k', 'ctrl')) // 'meta+k' canonicalizeShortcut(parseShortcut('ctrl+k ctrl+s', 'ctrl')) // 'ctrl+k ctrl+s' ``` ### `detectModKey()` Detects the platform modifier key. Returns `'meta'` on macOS, `'ctrl'` elsewhere. ```ts function detectModKey(): 'ctrl' | 'meta' ``` Useful when you need a consistent `modKey` across multiple calls to `createKeymap`, `formatShortcut`, and `parseShortcut` without threading it manually. ```ts const modKey = detectModKey(); const map = createKeymap(bindings, { modKey }); const label = formatShortcut('mod+k', modKey); ``` --- ## Shortcut String Syntax Shortcut strings are space-separated steps. Each step is `+`-joined modifier names and a single non-modifier key. ### Modifier aliases | You write | Resolves to | | --------- | ----------- | | `mod` | `meta` on Mac, `ctrl` elsewhere (per `modKey`) | | `cmd`, `command`, `win` | `meta` | | `opt`, `option` | `alt` | | `control` | `ctrl` | ### Special key aliases | You write | `KeyboardEvent.key` | | --------- | ------------------- | | `esc` | `Escape` | | `space`, `spacebar` | ` ` (space character) | | `del` | `Delete` | | `up` | `ArrowUp` | | `down` | `ArrowDown` | | `left` | `ArrowLeft` | | `right` | `ArrowRight` | ### Examples ``` 'ctrl+k' → single step, Ctrl modifier 'ctrl+k ctrl+s' → two-step chord (VS Code–style) 'g g' → two-step key-key chord (Vim-style) 'mod+shift+p' → ⌘⇧P on Mac, Ctrl+Shift+P elsewhere 'escape' → Escape key, no modifiers 'space' → Space key (alias for ' ') ``` ### Usage Guide ## Basic Shortcuts Pass a record of shortcut strings to handlers: ```ts import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap({ 'ctrl+s': () => save(), 'ctrl+z': () => undo(), 'ctrl+shift+z': () => redo(), 'escape': () => closeModal(), }); const unmount = map.mount(document); ``` The returned `unmount` function detaches listeners from that target only. Call `map.dispose()` to remove from all mounted targets at once. ## Modifier Aliases You can write shortcuts in the style that feels natural — Keymap normalises everything: | You write | Canonical form | | --------- | -------------- | | `cmd`, `command`, `win` | `meta` | | `opt`, `option` | `alt` | | `ctrl`, `control` | `ctrl` | | `shift` | `shift` | ```ts // All three are equivalent: createKeymap({ 'cmd+k': handler }); createKeymap({ 'command+k': handler }); createKeymap({ 'meta+k': handler }); ``` ## Chord Sequences Separate chord steps with a space. The default timeout between steps is 1 s: ```ts const map = createKeymap({ 'ctrl+k ctrl+s': () => save(), // VS Code–style 'g g': () => goToTop(), // Vim-style 'g G': () => goToBottom(), }, { chordTimeout: 750, // ms — reset partial chord if exceeded }); ``` > **Tip:** A shorter binding always fires immediately when typed, even if a longer chord shares its prefix — binding order doesn't matter. `'g'` and `'g g'` together means `'g g'` can never be reached, because `'g'` fires the instant it's pressed. Use `findShortcutConflicts()` (see below) to detect this before it surprises a user. ## `BindingOptions` — Per-binding Configuration Pass a `BindingOptions` object instead of a plain handler to add guards, trigger control, or priority: ```ts const map = createKeymap({ 'ctrl+s': () => save(), // plain handler escape: { handler: closePanel, when: () => isOpen() }, // per-binding guard space: { handler: togglePlay, trigger: 'keyup' }, // fires on keyup 'ctrl+z': { handler: undo, priority: 10 }, // wins over lower-priority bindings }); ``` ## Context Guards Use a global `when()` in `KeymapOptions` to disable an entire keymap conditionally: ```ts const map = createKeymap( { escape: () => closePanel() }, { when: () => panelIsOpen() }, ); ``` For per-binding guards, use `BindingOptions.when`: ```ts const map = createKeymap({ escape: { handler: closePanel, when: () => isPanelOpen() }, backspace: { handler: deleteLine, when: () => isEditorFocused() }, }); ``` ## Trigger Control Bindings default to `keydown`. Use `trigger: 'keyup'` for actions that should fire on release: ```ts const map = createKeymap({ space: { handler: confirmAction, trigger: 'keyup' }, }); ``` Keydown and keyup chord trackers are independent — a `'g g'` chord on `keyup` does not interfere with a `'g g'` chord on `keydown`. ## Replacing a Binding at Runtime `bind()` always replaces any existing binding for the same shortcut — the most recent call wins, regardless of `priority`: ```ts const map = createKeymap({ 'ctrl+k': defaultAction, }); // A plugin registers an override at runtime — this replaces the default binding outright: map.bind('ctrl+k', pluginOverride); ``` > `BindingOptions.priority` doesn't affect this — because bindings are keyed by their canonical shortcut, two *live* bindings can never actually compete for the same event, so there's no tie for `priority` to break. It's a validated, reserved field kept for forward compatibility — see the note in [`api.md`](./api.md#bindingoptions). ## Display with `formatShortcut` Format shortcut strings for display in tooltips, menus, or documentation: ```ts import { formatShortcut } from '@vielzeug/keymap'; formatShortcut('mod+shift+p', 'meta'); // '⇧⌘P' formatShortcut('mod+shift+p', 'ctrl'); // 'Ctrl+Shift+P' formatShortcut('ctrl+k ctrl+s'); // platform-detected ``` Returns `''` and emits a dev warning for empty or invalid shortcuts. ## Detecting Conflicts Before binding a user-customized shortcut, check whether it would shadow (or be shadowed by) an existing binding — most useful for a shortcut-customization UI where the shortcut string comes from user input: ```ts import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'; const map = createKeymap({ g: () => scrollToTop() }); const conflicts = findShortcutConflicts('g g', map.listBindings()); if (conflicts.length > 0) { warnUser('This shortcut would never fire — "g" already handles the first key.'); } else { map.bind('g g', () => scrollToBottom()); } ``` `findShortcutConflicts()` catches both directions: a shorter existing binding shadowing your proposal, and your proposal shadowing an existing longer chord. ## Keymap Layers Stack a scoped keymap on top of a base keymap for modal UIs. Mount each independently: ```ts import { createKeymap, createKeymapLayer } from '@vielzeug/keymap'; const base = createKeymap({ 'ctrl+z': undo, 'ctrl+s': save }); const modal = createKeymapLayer(base, { escape: { handler: closeModal, when: () => isModalOpen() }, 'ctrl+enter': () => confirm(), }); const unmountBase = base.mount(document); const unmountModal = modal.mount(document); modal.deactivate(); // base handles everything; layer is suspended modal.activate(); // layer resumes modal.parent === base; // true unmountModal(); unmountBase(); ``` ## Mounting to a Specific Element Pass any `EventTarget` — not just `document`: ```ts const editorEl = document.getElementById('editor')!; const unmount = map.mount(editorEl); // only fires when focus is inside editor ``` One keymap can be mounted to multiple targets simultaneously: ```ts const u1 = map.mount(editorA); const u2 = map.mount(editorB); // u1() removes from editorA only // map.dispose() removes from both ``` Mounting the *same* target twice without unmounting first (e.g. a forgotten cleanup in an effect) still works — handlers just fire twice — and emits a dev warning to flag the likely mistake. ## `preventDefault` and `stopPropagation` ```ts const map = createKeymap( { 'ctrl+s': () => save() }, { preventDefault: true, // default: true — prevents browser save dialog stopPropagation: false, // default: false }, ); ``` ## Framework Integration ```tsx [React] import { useEffect, useRef } from 'react'; import { createKeymap } from '@vielzeug/keymap'; function App() { useEffect(() => { const map = createKeymap({ 'ctrl+k': () => setOpen(true), 'escape': () => setOpen(false), }); const unmount = map.mount(document); return () => unmount(); }, []); } ``` ```vue [Vue 3] import { onMounted, onUnmounted } from 'vue'; import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap({ 'ctrl+k': () => openPalette(), 'escape': () => closePalette(), }); let unmount: (() => void) | undefined; onMounted(() => { unmount = map.mount(document); }); onUnmounted(() => unmount?.()); ``` ```ts [Svelte] import { onMount } from 'svelte'; import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap({ 'ctrl+k': () => openPalette(), 'escape': () => closePalette(), }); onMount(() => { const unmount = map.mount(document); return () => unmount(); }); ``` ## Working with Other Vielzeug Libraries ### Keymap + Ledger Wire undo/redo shortcuts to a `Ledger` instance: ```ts import { createKeymap } from '@vielzeug/keymap'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const map = createKeymap({ 'ctrl+z': () => ledger.undo(), 'ctrl+shift+z': () => ledger.redo(), 'ctrl+y': () => ledger.redo(), // Windows alias }); map.mount(document); ``` ### Keymap + Herald Publish shortcut events to a bus instead of calling handlers directly: ```ts import { createKeymap } from '@vielzeug/keymap'; import { createBus } from '@vielzeug/herald'; const bus = createBus(); const map = createKeymap({ 'ctrl+s': () => bus.emit('shortcut:save'), 'meta+shift+p': () => bus.emit('shortcut:palette'), }); map.mount(document); ``` ## Best Practices - **One keymap per scope**: create separate keymaps for global shortcuts, panel shortcuts, and editor shortcuts — mount/unmount them as the relevant UI state changes. - **Dispose on teardown**: always call `unmount()` or `map.dispose()` when the component unmounts or the scope is destroyed. - **Avoid modifier-only shortcuts**: shortcuts like `shift` alone (no key) can't be reliably parsed — always include a non-modifier key. - **Use `when()` for toggleable scopes**: simpler than manually mounting and unmounting on every state change. ### Examples ## Examples - [Global Shortcuts](./examples/global-shortcuts.md) — Register document-level hotkeys with a context guard - [Vim-style Navigation](./examples/vim-navigation.md) — Chord sequences for keyboard-driven navigation ### REPL Examples - Basic Shortcuts (id: `basic-shortcuts`) - Chord Sequences (id: `chord-sequences`) - Conflict Detection (id: `conflict-detection`) - Keymap Layers (id: `keymap-layers`) - Parse & Match (id: `parse-and-match`) - Shortcut Utilities (id: `shortcut-utilities`) --- ## @vielzeug/ledger **Category:** utilities **Keywords:** undo, redo, history, command-pattern, async, reactive, ripple **Key exports:** createLedger, compose **Related:** ripple, keymap, forge, vault ### Overview ## Why Ledger? Undo/redo is deceptively complex: you need to handle async side-effects, prevent concurrent mutations from racing, cap history size, and keep UI buttons reactive. Ledger solves all of this with a clean command-pattern API and Ripple signals. | Feature | Roll your own | Ledger | | ---------------------- | ------------------------------------- | --------------------------------------------------------- | | Bundle size | 0 B | | | Async commands | Manual promise chaining | serialised queue | | Race prevention | Manual locks | built-in queue | | Reactive `canUndo` | Poll or manual events | `Computed` from Ripple | | Composable commands | Custom wrapper | `compose()` | | History cap | Array slice | `maxHistory` option | | Disposable | Manual | `dispose()` + `using` | **Use Ledger when** you need undo/redo for editors, design tools, form state, or any app with reversible mutations — especially with async side-effects like server persistence. **Consider a simpler approach when** you only need client-side state undo with a single synchronous mutation and no UI reactivity — `@vielzeug/ripple`'s `storeWithHistory` may be enough. ## Installation ```sh [pnpm] pnpm add @vielzeug/ledger ``` ```sh [npm] npm install @vielzeug/ledger ``` ```sh [yarn] yarn add @vielzeug/ledger ``` ## Quick Start ```ts import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger({ maxHistory: 50 }); // Execute a reversible command await ledger.do({ execute: async () => { item.name = newName; }, rollback: async () => { item.name = oldName; }, label: 'Rename item', }); await ledger.undo(); // runs rollback await ledger.redo(); // runs execute again ledger.dispose(); // or: using ledger = createLedger() ``` ## Features - `createLedger()` — Creates an async command stack; operations are serialised to prevent races - Reactive state — `canUndo`, `canRedo`, `historySize`, `isProcessing`, `pendingCount`, `historySnapshot` are Ripple `Computed` values - `compose()` — Group multiple commands into one atomic undo step; partial failure rolls back already-executed sub-commands; sub-rollback errors reach `onRollbackError` - `maxHistory` — Cap the undo stack; oldest entries evicted automatically - Async-safe — `execute()`, `rollback()`, and `clear()` are fully serialised through the queue - Typed history — `Command.data` stores custom metadata; `historySnapshot.value[n].data` is typed to `TData` - Error-safe rollback — failed `rollback()` warns via dev console; optional `onRollbackError` callback for UI integration - Cancellable — `execute`/`rollback` receive an `AbortSignal`, merged from a caller-supplied signal and the ledger's own `disposalSignal` - Disposable — `dispose()` + `[Symbol.dispose]` for `using` declarations ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — `canUndo`, `canRedo`, `isProcessing` are Ripple `Computed` values; use `effect()` or bind directly to templates - [Keymap](/keymap/) — Wire `ctrl+z` / `ctrl+shift+z` to `ledger.undo()` / `ledger.redo()` with zero boilerplate - [Forge](/forge/) — Combine Ledger with Forge for reversible form mutations - [Vault](/vault/) — Persist undo history across sessions by storing commands in IndexedDB ### API Reference ## API Overview | Export | Kind | Execution mode | Description | | ------ | ---- | -------------- | ----------- | | `createLedger` | function | async | Creates an undo/redo command stack | | `compose` | function | — | Combines multiple commands into one reversible command | | `Ledger` | interface | — | Object returned by `createLedger` | | `Command` | interface | — | A command: `{ execute, rollback?, label? }` | | `LedgerOptions` | interface | — | Options for `createLedger` | | `LedgerCallOptions` | interface | — | Options for `do()`/`undo()`/`redo()` — cancellation | | `CommandMeta` | interface | — | Metadata entry in `historySnapshot` | ## Package Entry Points ```ts import { compose, createLedger } from '@vielzeug/ledger'; import type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from '@vielzeug/ledger'; ``` ## `createLedger(options?)` Creates an async undo/redo command history. ```ts function createLedger(options?: LedgerOptions): Ledger ``` **Parameters** - `options.maxHistory` — Maximum number of entries in the undo stack (default: `100`). Oldest entries are evicted when exceeded. - `options.onRollbackError` — Optional callback invoked when `rollback()` throws. Receives the error and the `CommandMeta` of the failing command. The stack position is left unchanged regardless. **Returns** a `Ledger` object. ```ts const ledger = createLedger({ maxHistory: 50 }); ``` ## `Ledger` ```ts interface Ledger { readonly canRedo: Computed; readonly canUndo: Computed; readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly historySize: Computed; readonly historySnapshot: Computed[]>; readonly isProcessing: Computed; readonly pendingCount: Computed; clear(): Promise; dispose(): void; do(command: Command, options?: LedgerCallOptions): Promise; redo(options?: LedgerCallOptions): Promise; undo(options?: LedgerCallOptions): Promise; [Symbol.dispose](): void; } ``` ### Reactive signals All signals are Ripple `Computed` — read `.value` or call `.subscribe()`. | Signal | Type | Description | | ------ | ---- | ----------- | | `canUndo` | `Computed` | `true` when the undo stack is non-empty | | `canRedo` | `Computed` | `true` when the redo stack is non-empty | | `historySize` | `Computed` | Number of undo steps available | | `historySnapshot` | `Computed[]>` | Metadata for each undo entry, newest first | | `isProcessing` | `Computed` | `true` while a command's `execute` or `rollback` is running; `false` during a queued `clear()` | | `pendingCount` | `Computed` | Number of operations currently in the queue (executing + waiting) | ### `disposalSignal` / `disposed` ```ts readonly disposalSignal: AbortSignal readonly disposed: boolean ``` `disposalSignal` aborts when `dispose()` runs — it's the same signal merged into the one passed to `execute`/`rollback` (see [`LedgerCallOptions`](#ledgercalloptions)). `disposed` flips to `true` at the same point. ### `do(command, options?)` Executes a command and pushes it onto the undo stack. Clears the redo stack. ```ts ledger.do(command: Command, options?: LedgerCallOptions): Promise ``` If `execute()` rejects, the command is not added to the stack. Rejects with `LedgerDisposedError` if the ledger is already disposed — `execute()` is never called. ### `undo(options?)` Pops the top entry from the undo stack and pushes it onto the redo stack. If the entry has a `rollback`, it is called first. ```ts ledger.undo(options?: LedgerCallOptions): Promise ``` No-op when `canUndo.value === false`. If `rollback()` throws, a dev warning is issued, `onRollbackError` is called with a `LedgerRollbackError` (if configured), and the stack position is left unchanged. Commands without a `rollback` are popped and moved to the redo stack without any reversal. Rejects with `LedgerDisposedError` if the ledger is already disposed. ### `redo(options?)` Pops the top entry from the redo stack, calls `execute()`, and pushes it back onto the undo stack. ```ts ledger.redo(options?: LedgerCallOptions): Promise ``` No-op when `canRedo.value === false`. Rejects with `LedgerExecutionError` if `execute()` throws, and with `LedgerDisposedError` if the ledger is already disposed. ### `clear()` Enqueues a reset of both the undo and redo stacks. Returns a `Promise` that resolves once the reset has run (after any already-queued operations complete). ```ts await ledger.clear() ``` Safe to call while operations are in flight — the clear is serialised in the queue and runs after the current operation finishes. Rejects with `LedgerDisposedError` if the ledger is already disposed. ### `dispose()` Clears both stacks, aborts `disposalSignal`, and disposes all Ripple signals. After `dispose()`, reading `.value` on any signal returns `undefined`, and `do()`/`undo()`/`redo()`/`clear()` reject with `LedgerDisposedError`. ```ts ledger.dispose(); // or: using ledger = createLedger(); ``` ## `Command` ```ts interface Command { data?: TData; execute: (signal?: AbortSignal) => Promise | void; rollback?: (signal?: AbortSignal) => Promise | void; label?: string; } ``` Both `execute` and `rollback` accept sync and async functions. Both receive an `AbortSignal` — see [`LedgerCallOptions`](#ledgercalloptions) — but the parameter is optional, so existing commands that ignore it (`execute: () => {...}`) still type-check. `rollback` is optional. Commands without one are still tracked in history; `undo()` moves them on the stack but performs no reversal. `label` is optional — it surfaces in `historySnapshot.value` for building undo history UI. `data` is an optional custom metadata payload, typed to the `TData` type parameter of `createLedger`. It is stored as-is in `historySnapshot.value[n].data`. Use it to attach context needed by undo-history UIs (e.g. before/after snapshots, affected IDs). ## `LedgerOptions` ```ts interface LedgerOptions { maxHistory?: number; // default: 100 onRollbackError?: (err: unknown, meta: CommandMeta) => void; } ``` | Option | Default | Description | | ------ | ------- | ----------- | | `maxHistory` | `100` | Maximum undo stack depth. Oldest entries evicted on overflow. | | `onRollbackError` | — | Called with a `LedgerRollbackError` when `rollback()` throws. Useful for surfacing undo failures to the UI without parsing console warnings. | ## `LedgerCallOptions` Options accepted by `do()`/`undo()`/`redo()`. ```ts interface LedgerCallOptions { signal?: AbortSignal; } ``` | Option | Default | Description | | ------ | ------- | ----------- | | `signal` | — | Merged with the ledger's own `disposalSignal` via `AbortSignal.any()` and passed to `execute`/`rollback`. Lets a long-running command observe caller-initiated cancellation, ledger disposal, or both. | `execute`/`rollback` always receive a live `AbortSignal`, even when `options.signal` is omitted — it's the ledger's own `disposalSignal` in that case, so every command can at least observe disposal. ```ts const controller = new AbortController(); await ledger.do( { execute: async (signal) => { await fetch('/api/save', { signal }); }, }, { signal: controller.signal }, ); controller.abort(); // aborts the fetch above, if still in flight ``` ## `compose(commands, label?)` Combines multiple commands into a single reversible command that counts as one undo step. ```ts function compose(commands: Command[], label?: string): Command ``` `execute` runs all sub-commands in order and forwards its own `signal` argument to every sub-command's `execute`. **If any sub-command fails, already-executed sub-commands are rolled back automatically (best-effort) before the error is re-thrown** — making `compose()` atomic. `rollback` runs sub-commands in reverse (also forwarding `signal`), skipping any without a defined `rollback`. If a sub-command's `rollback` throws during `undo()`, the error is propagated to the ledger's `onRollbackError` callback (if configured). `rollback` is `undefined` when no sub-command defines one. Pass the result directly to `ledger.do()`: ```ts await ledger.do(compose([ { execute: () => { node.x = newX; }, rollback: () => { node.x = oldX; } }, { execute: () => { node.y = newY; }, rollback: () => { node.y = oldY; } }, ], 'Move node')); ``` ## `CommandMeta` Shape of entries in `historySnapshot.value`: ```ts interface CommandMeta { data: TData | undefined; label: string | undefined; } ``` `data` holds the value from `Command.data`. The type parameter is inferred from `createLedger()`; it defaults to `unknown` when no type argument is supplied. --- ## Errors ### `LedgerError` Base class for all ledger errors. Use `instanceof LedgerError` or `LedgerError.is()` to catch any ledger-originated error. ```ts class LedgerError extends Error { static is(err: unknown): err is LedgerError; } ``` **Named subclasses** | Class | Thrown when | | ---------------------- | ------------------------------------------------------------------------------ | | `LedgerDisposedError` | `do()`/`undo()`/`redo()`/`clear()` is called on a disposed ledger instance | | `LedgerExecutionError` | A command's `execute()` function throws; original error available via `.cause` | | `LedgerRollbackError` | Passed to `onRollbackError` when a command's `rollback()` function throws during undo; original error via `.cause` | ### Usage Guide ## Basic Usage Define commands as `{ execute, rollback }` pairs and push them through `ledger.do()`: ```ts import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const prev = item.name; const next = 'New name'; await ledger.do({ execute: async () => { item.name = next; }, rollback: async () => { item.name = prev; }, label: 'Rename item', }); await ledger.undo(); // item.name === prev await ledger.redo(); // item.name === next ``` Commands can be sync or async — both `() => void` and `() => Promise` are accepted. ## Reactive State `canUndo`, `canRedo`, `historySize`, `isProcessing`, and `historySnapshot` are Ripple `Computed` values. Read them directly in effects or templates: ```ts import { effect } from '@vielzeug/ripple'; effect(() => { undoButton.disabled = !ledger.canUndo.value; redoButton.disabled = !ledger.canRedo.value; spinner.hidden = !ledger.isProcessing.value; }); ``` Or read `.value` imperatively: ```ts console.log(ledger.historySize.value); // number of undo steps console.log(ledger.historySnapshot.value); // readonly CommandMeta[] ``` ## Composing Commands Group multiple commands into a single undo step with `compose()`. Rollback runs all sub-commands in reverse: ```ts import { compose, createLedger } from '@vielzeug/ledger'; await ledger.do(compose( [ { execute: () => { node.x = newX; }, rollback: () => { node.x = oldX; } }, { execute: () => { node.y = newY; }, rollback: () => { node.y = oldY; } }, { execute: () => { node.width = newW; }, rollback: () => { node.width = oldW; } }, ], 'Move and resize', )); // One undo step undoes all three: await ledger.undo(); ``` ## Concurrent Safety All operations — `do()`, `undo()`, and `redo()` — are serialised through an internal queue. Concurrent calls are queued, not rejected: ```ts // Safe to call without awaiting each: ledger.do(cmd1); ledger.do(cmd2); ledger.do(cmd3); // cmd1 → cmd2 → cmd3 execute in order ``` `isProcessing.value` is `true` while a command's `execute` or `rollback` is actively running. Use `pendingCount.value > 0` to check whether there are any operations in the queue (including those waiting to start). ## History Cap Limit the undo stack size with `maxHistory` (default: `100`): ```ts const ledger = createLedger({ maxHistory: 30 }); ``` When the limit is reached, the oldest undo entry is silently evicted. The redo stack is always cleared when a new `do()` is performed. ## Custom Command Data Attach arbitrary metadata to a command with the `data` field. Use `createLedger()` to type it: ```ts type EditData = { before: string; after: string }; const ledger = createLedger(); await ledger.do({ data: { before: item.name, after: newName }, execute: () => { item.name = newName; }, rollback: () => { item.name = item.name; }, // captured in closure label: 'Rename item', }); const [latest] = ledger.historySnapshot.value; console.log(latest.data?.before); // string | undefined — fully typed ``` `data` is stored as-is and does not affect `execute` or `rollback` behaviour. ## Error Handling If `execute()` rejects, the command is **not** added to the undo stack: ```ts await ledger.do({ execute: async () => { await api.save(item); // throws if server error }, rollback: async () => { /* not reached */ }, }); // ledger.historySize.value unchanged ``` If `rollback()` throws during `undo()`, a dev warning is issued and the stack position is left unchanged — the entry stays on the undo stack so the operation can be retried. To receive rollback errors in your application code (for example, to show a notification), pass `onRollbackError` to `createLedger`: ```ts const ledger = createLedger({ onRollbackError: (err, meta) => { notify(`Could not undo "${meta.label ?? 'action'}": ${String(err)}`); }, }); ``` ## Cancellation `execute`/`rollback` receive an `AbortSignal` as their argument — pass your own via `{ signal }` on `do()`/`undo()`/`redo()` to cancel a specific in-flight command, or ignore it if the command has nothing to abort: ```ts const controller = new AbortController(); const save = ledger.do( { execute: async (signal) => { await fetch('/api/save', { body: JSON.stringify(item), method: 'POST', signal }); }, label: 'Save item', }, { signal: controller.signal }, ); cancelButton.addEventListener('click', () => controller.abort()); ``` The signal you pass is merged with the ledger's own `disposalSignal`, so a command can bail out early on `dispose()` too — without you having to wire that up yourself: ```ts const ledger = createLedger(); const polling = ledger.do({ execute: async (signal) => { while (!signal?.aborted) { await pollServer(); } }, }); // later, e.g. when the owning component unmounts: ledger.dispose(); // the loop above sees signal.aborted === true and exits await polling; ``` ## Framework Integration ```tsx [React] import { useEffect, useState } from 'react'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); function UndoRedoButtons() { const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); useEffect(() => { const unsub = ledger.canUndo.subscribe(({ newValue }) => setCanUndo(newValue)); const unsub2 = ledger.canRedo.subscribe(({ newValue }) => setCanRedo(newValue)); return () => { unsub(); unsub2(); }; }, []); return ( <> ledger.undo()}>Undo ledger.redo()}>Redo ); } ``` ```vue [Vue 3] import { onUnmounted, ref } from 'vue'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const canUndo = ref(false); const canRedo = ref(false); const u1 = ledger.canUndo.subscribe(({ newValue }) => { canUndo.value = newValue; }); const u2 = ledger.canRedo.subscribe(({ newValue }) => { canRedo.value = newValue; }); onUnmounted(() => { u1(); u2(); }); Undo Redo ``` ```ts [Svelte] import { onMount } from 'svelte'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); let canUndo = false; let canRedo = false; onMount(() => { const u1 = ledger.canUndo.subscribe(({ newValue }) => { canUndo = newValue; }); const u2 = ledger.canRedo.subscribe(({ newValue }) => { canRedo = newValue; }); return () => { u1(); u2(); }; }); ``` ## Working with Other Vielzeug Libraries ### Ledger + Keymap ```ts import { createKeymap } from '@vielzeug/keymap'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const map = createKeymap({ 'ctrl+z': () => ledger.undo(), 'ctrl+shift+z': () => ledger.redo(), 'ctrl+y': () => ledger.redo(), // Windows alias }); map.mount(document); ``` ### Ledger + Ripple effect ```ts import { effect } from '@vielzeug/ripple'; effect(() => { document.title = ledger.canUndo.value ? `● ${documentTitle}` // unsaved indicator : documentTitle; }); ``` ## Best Practices - **Capture state before mutation**: close over `prev` / `next` values at `do()` call time, not inside `execute`/`rollback`. - **Label meaningful operations**: `historySnapshot.value` exposes labels for undo history lists. - **Use `data` for rich history UIs**: store before/after snapshots or affected IDs in `Command.data`; retrieve them via `historySnapshot.value[n].data`. - **Await `clear()` when order matters**: `ledger.clear()` is serialised — it returns a `Promise` that resolves after any in-flight operation finishes. - **Dispose when done**: call `ledger.dispose()` when the owner component unmounts — it clears both stacks and disposes all signals. - **Avoid reading `.value` after `dispose()`**: the computed nodes are disposed; `.value` returns `undefined`. ### Examples # Examples - [Text Editor History](./examples/text-editor.md) — Per-keystroke undo with debouncing and Keymap integration - [Form History](./examples/form-history.md) — Reversible form field mutations with reactive undo/redo buttons ### REPL Examples - Cancellation (AbortSignal) (id: `cancellation`) - command data & pendingCount (id: `command-data`) - compose() — Atomic Multi-step (id: `compose-commands`) - do / undo / redo (id: `do-undo-redo`) - Reactive Signals & historySnapshot (id: `reactive-signals`) - onRollbackError Hook (id: `rollback-error`) --- ## @vielzeug/lingua **Category:** i18n **Keywords:** internationalization, translations, pluralization, locale, i18n, l10n, async-loading **Key exports:** createI18n, createFormatter, hydrateI18n, serializeI18n, validateCatalog, LinguaError, LinguaDisposedError, LinguaInvalidCountError, LinguaCountInVarsError, LinguaMissingLocaleError, LinguaInvalidLocaleError, LinguaNamespaceMissingError (+1 more) **Related:** ripple, wayfinder, courier ### Overview ## Why Lingua? Most i18n libraries either couple runtime and framework, or require a global plugin system. Lingua is a plain object with a subscription model that any framework can consume directly. ```ts // Before — manual key lookup with no type safety or fallback const messages = { en: { greeting: 'Hello, {name}!' }, de: { greeting: 'Hallo, {name}!' } }; const locale = 'de'; const raw = (messages[locale] ?? messages['en'])['greeting'].replace('{name}', 'Alice'); // After — typed keys, fallback chain, plural resolution, reactive subscriptions const i18n = createI18n({ locale: 'de', fallback: 'en', catalogs: messages }); const greeting = i18n.t('greeting', { name: 'Alice' }); ``` - Minimal API: `t`, `tp`, `extend`, `registerNamespace`, `loadNamespace`, `preload`, `setLocale`, `register`, `scope`, `fork`, `getSnapshot`, `getState`, `restoreState`, `subscribe`, `has`, `isLoaded`, `isRegistered`, `isNamespaceLoaded`, `isNamespaceRegistered`, `dispose`, `getSupportedLocales` - Deterministic locale fallback chain resolution - Typed leaf and plural branch keys with explicit APIs (`t` and `tp`) - Explicit locale source model (static messages or async loaders) - Typed error class hierarchy rooted at `LinguaError` — `instanceof` narrows to the specific failure (`LinguaDisposedError`, `LinguaMissingLocaleError`, `LinguaNamespaceMissingError`, …) - Framework-agnostic store primitives that compose with any UI framework - Zero dependencies | Feature | Lingua | i18next | FormatJS | | --------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~24 kB | ~16 kB | | Typed key ergonomics | | Partial | Partial | | Deterministic fallback chain | | | | | Async locale preload | | | | | Namespace lazy loading | (`extend()`) | Partial | | | Runtime snapshots + subscriptions | | | | | External formatter bridge | (`createFormatter` in main entry) | Partial | | | Framework agnostic | | | | | Zero dependencies | | | | **Use Lingua when** you want a compact, typed runtime with deterministic fallback behavior and framework-agnostic reactive state. **Consider i18next or FormatJS when** you need larger ecosystem plugins, message extraction pipelines, or mature framework-specific integrations. ## Installation ```sh [pnpm] pnpm add @vielzeug/lingua ``` ```sh [npm] npm install @vielzeug/lingua ``` ```sh [yarn] yarn add @vielzeug/lingua ``` ## Quick Start ```ts import { createFormatter, createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ locale: 'en', fallback: 'en', catalogs: { en: { greeting: 'Hello, {name}!', inbox: { zero: 'No messages', one: 'One message', other: '{count} messages', }, }, fr: () => import('./locales/fr.json').then((m) => m.default), }, }); await i18n.setLocale('fr'); const greeting = i18n.t('greeting', { name: 'Alice' }); const messages = i18n.tp('inbox', 3); // Scope reduces key repetition inside a namespace const nav = i18n.scope('nav'); nav.t('home'); // resolves 'nav.home' // Namespace-based lazy loading for route-specific keys await i18n.extend('settings', (locale) => import(`./routes/${locale}/settings.i18n.json`).then((m) => m.default)); // Formatter bound to the current locale — follows locale changes automatically const fmt = createFormatter(() => i18n.locale); const price = fmt.currency(12.5, 'EUR'); const unsubscribe = i18n.subscribe( (next) => { console.log(next.locale); }, { immediate: true }, ); unsubscribe(); i18n.getSupportedLocales(); ``` ## Features - One runtime primitive: `createI18n(options)` - Explicit translation methods: `t(leafKey, vars?)` and `tp(branchKey, count, options?)` - Explicit locale lifecycle: `register`, `preload`, `setLocale` - Namespace lazy loading: `registerNamespace(ns, factory)` + `loadNamespace(ns, locale?)` — or use `extend(ns, factory, locale?)` as a convenience that does both; deduplicates per `ns + locale`; use for per-route or per-feature keys - Scoped translation helpers: `scope(prefix)` returns a `{ fmt, t, tp, has }` helper bound to a key prefix - Unified key existence check: `has(key)` returns `true` for leaf keys, branch keys, and pipe-plural base keys in the active fallback chain - Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `serializeI18n()` guards - Registered-locale predicate: `isRegistered(locale)` distinguishes "never configured" from "async loader not yet called" - Instance disposal: `dispose()` clears all subscribers and catalog state — prevents memory leaks in route-scoped SPA instances - Typed error handling: every thrown/rejected error is `instanceof LinguaError`, with named subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, `LinguaNamespaceMissingError`, …) for specific `instanceof` branching - Instance forking: `fork(overrides?)` creates an isolated child for SSR or test isolation - Reactive model through snapshots: `getSnapshot`, `subscribe` - Deterministic fallback chain using active locale plus configured fallback locales - Separate missing handlers: `onMissingKey(key, locale)` and `onMissingVar(varName, key, locale)` - Formatting via `createFormatter(source)` — exported from the main entry alongside `createI18n` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Wayfinder](../wayfinder/index.md) for locale-aware routes and URL state. - [Ripple](../ripple/index.md) for reactive locale and translation state. - [Courier](../courier/index.md) for lazy loading translation catalogs. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------------------------ | ---------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `createI18n()` | Create an i18n instance with locale catalogs | Sync | Catalogs are lazy; call `preload()` before SSR render | | `i18n.t()` | Translate a leaf key with optional vars | Sync | Missing keys use `onMissingKey` or return the key itself | | `i18n.tp()` | Translate a plural branch key | Sync | `count` is injected automatically — do not pass it in `vars` | | `i18n.extend()` | Register and immediately load a namespace | Async | Deduplicates per `ns + locale`; new factory updates registry but does not reload; throws synchronously if disposed | | `i18n.setLocale()` | Switch the active locale | Async | Await before rendering; throws if locale is not registered | | `i18n.preload()` | Pre-load a locale catalog without switching | Async | Locale must be registered first | | `i18n.register()` | Register or replace a locale source; loads it immediately | Async | Returns `Promise`; awaiting ensures the catalog is ready before rendering | | `i18n.scope()` | Return a prefix-bound `{ fmt, t, tp, has }` helper | Sync | Memoized per prefix — same object returned for same prefix string | | `i18n.fork()` | Create an isolated child instance from current state | Sync | Catalog snapshot is copied; post-fork extend() calls are independent | | `i18n.has()` | Check if a leaf or branch key exists in the active chain | Sync | Returns `true` for branch keys and pipe-plural base keys | | `i18n.isLoaded()` | Check if a locale catalog is fully resolved | Sync | Returns `false` for async loaders not yet preloaded; safe predicate | | `i18n.isRegistered()` | Check if a locale is in the known registry | Sync | `true` for both resolved catalogs and pending loaders; never throws | | `i18n.disposalSignal` | `AbortSignal` aborted on disposal | Sync getter | Tie external lifetimes (SSE, polling) to this i18n instance | | `i18n.dispose()` | Release all subscribers and catalog state | Sync | After disposal, `t()` falls back to `onMissingKey` for every key | | `i18n.disposed` | `true` after `dispose()` is called | Sync getter | — | | `i18n[Symbol.dispose]()` | Delegates to `dispose()` | Sync | Enables `using` declarations | | `i18n.registerNamespace()` | Register a namespace factory without loading | Sync | Call `loadNamespace()` when ready to load, or use `extend()` for register+load in one call | | `i18n.loadNamespace()` | Load a registered namespace for a locale | Async | Deduplicates concurrent and repeated calls; throws `LinguaNamespaceMissingError` if namespace not registered | | `i18n.isNamespaceLoaded()` | Check if a namespace is loaded for the active (or given) locale | Sync | Returns `false` if not registered or not yet loaded for this locale | | `i18n.isNamespaceRegistered()` | Check if a namespace factory has been registered | Sync | `true` after `registerNamespace()` or `extend()`; `false` before | | `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Equivalent to `serializeI18n(i18n)` — preferred for public API access | | `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Equivalent to `hydrateI18n(state, i18n)` — preferred for public API access; throws `LinguaRestoreError` if locale missing | | `serializeI18n()` | Serialise loaded catalogs for SSR hydration | Sync | Loader-only locales are omitted — check `isLoaded()` before calling | | `hydrateI18n()` | Hydrate a client instance from server-serialised state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog | | Error classes | Named error subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, …) | — | All runtime errors are `instanceof LinguaError`; use `instanceof` for specific handling | | `createFormatter()` | Create a standalone Intl formatter | Sync | Available from the main entry or `@vielzeug/lingua/format` — pass a getter `() => i18n.locale` to follow locale changes | | `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` — not for production | ## Package Entry Points | Import | Purpose | | --------------------------- | ---------------------------------------------------------- | | `@vielzeug/lingua` | Main exports and types, includes `createFormatter` | | `@vielzeug/lingua/format` | Standalone `createFormatter` — no `createI18n` dependency | | `@vielzeug/lingua/validate` | `validateCatalog` — dev/CI only, exclude from prod | ## createI18n ```ts createI18n(options: I18nOptions): I18n createI18n(options?: I18nOptions): I18n ``` Creates an i18n instance. All locale strings must be valid BCP 47 tags. Invalid tags throw `LinguaInvalidLocaleError`. **Parameters — `I18nOptions`:** | Option | Type | Default | Description | | ------------------- | ---------------------------------------------------------- | --------------- | ----------------------------------------------------------------------- | | `locale` | `Locale` | `'en'` | Active locale at startup. Must be a valid BCP 47 tag. | | `fallback` | `Locale \| Locale[]` | `undefined` | Fallback locale chain searched when the active locale is missing a key. | | `catalogs` | `Record>` | `{}` | Locale source registry. Values are static objects or async loaders. | | `onMissingKey` | `(key: string, locale: string) => string` | returns `key` | Called when a translation key is missing. | | `onMissingVar` | `(varName: string, key: string, locale: string) => string` | returns `{var}` | Called when an interpolation variable is absent. | | `onSubscriberError` | `(error: unknown) => void` | `console.error` | Called when a `subscribe` callback throws. | **Returns:** `I18n` **Example:** ```ts import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ locale: 'en', fallback: 'en', catalogs: { en: { greeting: 'Hello, {name}!' }, de: () => import('./locales/de.json').then((m) => m.default), }, onMissingKey: (key) => `[missing:${key}]`, onMissingVar: (varName) => `{${varName}}`, }); ``` ## I18n Interface Every `createI18n` call returns an `I18n` instance. **Methods:** | Member | Signature | Description | | ------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `t(key, vars?)` | `(key: MessageLeafKeys \| string, vars?: TranslateVars) => string` | Translate a leaf key with optional variable interpolation. | | `tp(key, count, options?)` | `(key: MessageBranchKeys \| string, count: number, options?: TpOptions) => string` | Translate a plural branch key. | | `extend(ns, factory, locale?)` | `(ns: string, factory: NamespaceFactory, locale?: Locale) => Promise` | Register a namespace factory and immediately load it for `locale` (defaults to active locale). Deduplicates per `ns + locale`. | | `preload(locale)` | `(locale: Locale) => Promise` | Load a catalog without switching the active locale. | | `setLocale(locale)` | `(locale: Locale) => Promise` | Load if needed, then switch and notify subscribers. On load failure, locale is unchanged. | | `register(locale, source)` | `(locale: Locale, source: LocaleSource) => Promise` | Register or replace a locale source. Returns a Promise that resolves when loading is complete. Async loaders start immediately. | | `registerNamespace(ns, factory)` | `(ns: string, factory: NamespaceFactory) => void` | Register a namespace factory without loading. Use `loadNamespace()` to trigger, or `extend()` to do both. | | `loadNamespace(ns, locale?)` | `(ns: string, locale?: Locale) => Promise` | Load a registered namespace for `locale` (defaults to active locale). Deduplicates concurrent and repeated calls. | | `scope(prefix)` | `(prefix: MessageBranchKeys \| string) => ScopedI18n` | Return a prefix-bound `{ fmt, t, tp, has }` helper. Memoized per prefix — same object reference for the same prefix string. | | `fork(overrides?)` | `(overrides?: Omit, 'catalogs'>) => I18n` | Create an isolated child instance from the current catalog snapshot. | | `getState()` | `() => I18nState` | Extract a serializable snapshot of loaded catalogs and the active locale. | | `restoreState(state)` | `(state: I18nState) => void` | Hydrate this instance from serialized state. Clears namespace markers. Notifies subscribers. | | `has(key)` | `(key: MessageLeafKeys \| MessageBranchKeys \| string) => boolean` | Check if a leaf or branch key exists in the active fallback chain. | | `isLoaded(locale)` | `(locale: Locale) => boolean` | Return `true` if the catalog for `locale` is fully resolved. Never throws. | | `isRegistered(locale)` | `(locale: Locale) => boolean` | Return `true` if `locale` is in the known registry (resolved **or** pending loader). Never throws. | | `isNamespaceLoaded(ns, locale?)` | `(ns: string, locale?: Locale) => boolean` | Return `true` if the namespace is loaded for `locale` (defaults to active locale). Never throws. | | `isNamespaceRegistered(ns)` | `(ns: string) => boolean` | Return `true` if a namespace factory is registered under `ns`. Never throws. | | `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. | | `dispose()` | `() => void` | Release all subscribers, catalogs, loaders, and namespace state. Idempotent. | | `disposed` | `boolean` | `true` after `dispose()` has been called. | | `[Symbol.dispose]()` | `() => void` | Delegates to `dispose()`. Enables `using` declarations. | | `getSupportedLocales(sorted?)` | `(sorted?: boolean) => Locale[]` | Return all registered locales. | | `getSnapshot()` | `() => I18nSnapshot` | Return the current `{ locale, t, tp }` snapshot. Object identity changes on each observable change. | | `subscribe(callback, options?)` | `(callback: (snapshot: I18nSnapshot) => void, options?: SubscribeOptions) => Unsubscribe` | Subscribe to changes. Supports `{ immediate, signal }`. Already-aborted signal skips registration. | **Properties:** | Member | Type | Description | | -------- | ----------- | ----------------------------------------------------------------------------------------------------- | | `locale` | `Locale` | Readonly. Current active locale string. | | `fmt` | `Formatter` | Lazy-initialised formatter tied to this instance. Invalidates cached `Intl` objects on locale change. | ### `t()` Resolves a leaf key against the active fallback chain and interpolates variables. ```ts i18n.t('greeting', { name: 'Alice' }); // => 'Hello, Alice!' ``` Missing keys call `onMissingKey(key, locale)`. Without `onMissingKey`, returns the key string. Unresolved variables call `onMissingVar(varName, key, locale)`. Without `onMissingVar`, keeps the `{varName}` placeholder. ### `tp()` Resolves a plural branch key using CLDR rules. For cardinal plurals, `count=0` checks `${key}.zero` before falling back to the CLDR-selected form. Ordinal plurals follow CLDR exclusively. ```ts i18n.tp('inbox', 0); // => 'No messages' (from inbox.zero) i18n.tp('inbox', 1); // => 'One message' i18n.tp('inbox', 5); // => '5 messages' i18n.tp('position', 2, { ordinal: true }); // => '2nd' (ordinal) i18n.tp('pos', 1, { ordinal: true, vars: { name: 'Alice' } }); // ordinal + extra vars ``` `count` is injected automatically. Do not include `count` in `vars`. **Pipe-delimited shorthand** — a leaf string containing `|` is expanded into a plural branch at registration time: ```ts // Equivalent to { inbox: { one: 'One message', other: '{count} messages' } } const i18n = createI18n({ catalogs: { en: { inbox: 'One message|{count} messages' } } }); ``` Supported part counts: `2` (one | other), `3` (zero | one | other), `6` (zero | one | two | few | many | other). Any other count, or any part that is empty, is treated as a plain string and not expanded. ### `extend()` ```ts extend(ns: string, factory: NamespaceFactory, locale?: Locale): Promise ``` Registers a namespace factory and immediately loads it for `locale` (defaults to the active locale). The factory receives the target locale string and must return `Promise` — namespace content is independent of the instance's catalog type `M`. Concurrent and repeated calls for the same `ns + locale` pair are deduplicated — the factory runs at most once per locale. > **Note:** Calling `extend()` with a **new factory** after the namespace is already loaded updates the registry for future reloads (e.g. after `register()` replaces the catalog) but does **not** reload the namespace immediately. The new factory takes effect the next time the namespace marker is cleared. ```ts // Load settings keys when entering the settings route await i18n.extend('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default)); // Pre-load for a specific locale await i18n.extend('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default), 'de'); ``` Throws `LinguaDisposedError` synchronously if called on a disposed instance. ### `registerNamespace()` ```ts registerNamespace(ns: string, factory: NamespaceFactory): void ``` Registers a namespace factory without loading it. Use `loadNamespace()` to trigger loading when needed, or use `extend()` to register and load in one call. Re-registering a namespace updates the factory for future loads but does **not** reload if the namespace is already loaded. The new factory takes effect the next time the namespace marker is cleared (by a `register()` or `restoreState()` call). Throws `LinguaDisposedError` if called on a disposed instance. ### `loadNamespace()` ```ts loadNamespace(ns: string, locale?: Locale): Promise ``` Loads a registered namespace for `locale` (defaults to the active locale). Concurrent and repeated calls for the same `ns + locale` pair are deduplicated — the factory runs at most once per locale. Throws if the namespace has not been registered with `registerNamespace()` first. Throws `LinguaDisposedError` if called on a disposed instance. ```ts i18n.registerNamespace('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default), ); // Load on demand (e.g. when the settings route is activated) await i18n.loadNamespace('settings'); ``` ### `isNamespaceLoaded()` ```ts isNamespaceLoaded(ns: string, locale?: Locale): boolean ``` Returns `true` if the namespace `ns` has been fully loaded for `locale` (defaults to active locale). Returns `false` if not registered, not yet loaded for this locale, or if the instance is disposed. Never throws. ### `isNamespaceRegistered()` ```ts isNamespaceRegistered(ns: string): boolean ``` Returns `true` if a namespace factory has been registered under `ns` via `registerNamespace()` or `extend()`. Returns `false` otherwise. Never throws. ### `getState()` ```ts getState(): I18nState ``` Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale. Equivalent to `serializeI18n(i18n)` but preferred because it is called directly on the instance without requiring an import. **Warning:** Only fully resolved catalogs are included. Loader-only locales not yet preloaded are omitted. Use `i18n.isLoaded(locale)` to verify before calling. ```ts const state = i18n.getState(); // JSON.stringify(state) and send to client ``` ### `restoreState()` ```ts restoreState(state: I18nState): void ``` Hydrates this instance from an `I18nState` produced by `getState()` or `serializeI18n()`. Equivalent to `hydrateI18n(state, i18n)` but preferred because it is called directly on the instance. - Replaces all catalogs with those from `state`. - Sets the active locale to `state.locale`. - Clears all namespace loaded-markers so that `extend()` / `loadNamespace()` can re-apply namespaces. - Notifies subscribers. Throws `LinguaRestoreError` if `state.locale` has no catalog in `state.catalogs`. Throws `LinguaDisposedError` if called on a disposed instance. ```ts // Client — restore server-rendered state const i18n = createI18n(); i18n.restoreState(window.__I18N_STATE__); ``` ### `scope()` ```ts scope(prefix: MessageBranchKeys | string): ScopedI18n ``` Returns a `{ fmt, t, tp, has }` helper where every key is automatically prefixed with `prefix + '.'`. ```ts const nav = i18n.scope('nav'); nav.t('home'); // equivalent to i18n.t('nav.home') nav.has('logout'); // equivalent to i18n.has('nav.logout') nav.tp('items', 3); // equivalent to i18n.tp('nav.items', 3) ``` `scope()` is memoized per prefix — repeated calls with the same prefix string return the same object reference. The cached object is invalidated when `dispose()` is called. ### `fork()` ```ts fork(overrides?: Omit, 'catalogs'>): I18n ``` Creates an isolated child instance from the current catalog snapshot and loader registry. The fork: - Inherits all resolved catalogs (as static snapshots) and all pending loaders. - Inherits the namespace registry and loaded-namespace markers as they exist at fork time. - Has its own locale, fallback chain, and subscribers. - Catalog and namespace mutations on the fork do not affect the parent, and vice versa. - Namespace registrations made **after** the fork are not propagated in either direction. - **Loaded-namespace markers are copied.** If the parent has already loaded a namespace, calling `extend()` on the fork for the same `ns + locale` pair is a no-op. This avoids redundant refetches in SSR fork-per-request patterns. This is the preferred pattern for SSR: fork the shared instance once per request rather than re-creating the full instance and re-registering all catalogs. ```ts // SSR: one fork per request — clean locale isolation without re-registering catalogs const reqI18n = i18n.fork({ locale: req.locale }); await reqI18n.setLocale(req.locale); const html = `${reqI18n.t('title')}`; // Tests: custom missing-key handler without polluting the shared instance const testI18n = i18n.fork({ onMissingKey: (k) => `MISSING:${k}` }); ``` ### `subscribe()` ```ts subscribe(callback: (snapshot: I18nSnapshot) => void, options?: SubscribeOptions): Unsubscribe ``` Registers a callback that runs on locale or catalog changes. Returns an `Unsubscribe` function. Pass `{ immediate: true }` to call the callback immediately with the current snapshot. Pass `{ signal }` to unsubscribe automatically when an `AbortSignal` fires. If the signal is already aborted when `subscribe()` is called, no subscription is created and no callback is invoked. > **Note:** When `{ immediate: true }` is used and the callback throws synchronously on the first invocation, `onSubscriberError` is called and **the subscription is not registered** — the callback will not fire on future changes. This prevents a broken callback from being repeatedly invoked. ```ts // Manual unsubscribe const stop = i18n.subscribe( ({ locale }) => { document.documentElement.lang = locale; }, { immediate: true }, ); stop(); // unsubscribe // AbortSignal-based lifecycle management const controller = new AbortController(); i18n.subscribe(({ locale }) => render(locale), { signal: controller.signal }); // controller.abort() unsubscribes ``` ### `getSupportedLocales()` ```ts getSupportedLocales(sorted?: boolean): Locale[] ``` Returns all registered locales. Without arguments, returns locales in registration order. Pass `true` for Unicode code-point sort order. ```ts i18n.getSupportedLocales(); // => ['en', 'de', 'fr'] (insertion order) i18n.getSupportedLocales(true); // => ['de', 'en', 'fr'] (sorted) ``` ### `has()` ```ts has(key: MessageLeafKeys | MessageBranchKeys | string): boolean ``` Returns `true` if a leaf or branch key exists in the active fallback chain. Checks all locales in the chain in order. - **Leaf keys**: returns `true` if the key maps to a string value. - **Branch keys**: returns `true` if the key maps to a nested object (e.g. a plural branch). - **Pipe-plural base keys**: the base key is expanded at registration time into sub-keys (`inbox.one`, `inbox.other`); `has('inbox')` returns `true` because the branch exists. ```ts // catalog: { inbox: 'One message|{count} messages' } (pipe-plural → inbox.one, inbox.other) i18n.has('inbox'); // true — branch exists i18n.has('inbox.one'); // true — explicit sub-key i18n.has('missing'); // false ``` ### `isLoaded()` ```ts isLoaded(locale: Locale): boolean ``` Returns `true` if the catalog for `locale` is fully resolved (i.e. not a pending async loader). Returns `false` for unregistered locales, pending loaders, and invalid locale tags — never throws. Primary use case: guarding `serializeI18n()` in SSR to avoid silently omitting locales that were registered as async loaders but never preloaded. ```ts // SSR guard — ensure all locales are loaded before serialising const locales = i18n.getSupportedLocales(); await Promise.all(locales.filter((l) => !i18n.isLoaded(l)).map((l) => i18n.preload(l))); const state = serializeI18n(i18n); // now includes all locales ``` ### `isRegistered()` ```ts isRegistered(locale: Locale): boolean ``` Returns `true` if `locale` is in the known locale registry — either as a resolved catalog or as a pending async loader. Returns `false` for locales that have never been registered, and for invalid locale tags (never throws). Use `isRegistered` + `isLoaded` together to distinguish the three states: | Condition | `isRegistered` | `isLoaded` | | --------------------------------------- | -------------- | ---------- | | Locale never configured | `false` | `false` | | Async loader registered, not yet called | `true` | `false` | | Catalog fully resolved | `true` | `true` | ```ts if (!i18n.isRegistered('fr')) throw new Error('fr locale not configured'); if (!i18n.isLoaded('fr')) await i18n.preload('fr'); const state = serializeI18n(i18n); // 'fr' guaranteed to be present ``` ### `disposalSignal` ```ts get disposalSignal(): AbortSignal ``` `AbortSignal` aborted when `dispose()` is called. Use to tie external resource lifetimes (SSE streams, polling intervals, child `I18n` instances) to this i18n instance. ```ts startPolling({ signal: routeI18n.disposalSignal }); // polling stops automatically when routeI18n.dispose() is called ``` --- ### `disposed` ```ts get disposed(): boolean ``` `true` after `dispose()` has been called. --- ### `[Symbol.dispose]()` ```ts [Symbol.dispose](): void ``` Delegates to `dispose()`. Enables the `using` declaration: ```ts { using i18n = createI18n({ catalogs: { en: messages } }); // dispose() called automatically at block exit } ``` --- ### `dispose()` ```ts dispose(): void ``` Releases all subscribers, catalogs, loaders, and namespace state. Calling `dispose()` more than once is safe (idempotent). After disposal: - `t()` / `tp()` fall back to `onMissingKey` for every key (returning the key string by default). - `isLoaded()` and `isRegistered()` return `false` for all locales. - No subscribers are notified of further changes. - `setLocale()` and `preload()` reject with `LinguaDisposedError`. - `register()` throws `LinguaDisposedError`. - `subscribe()` throws `LinguaDisposedError`. - `extend()` throws `LinguaDisposedError`. Primarily useful for long-lived SPA instances that are replaced at runtime (e.g. route-level i18n) to prevent subscriber and catalog memory from accumulating. ```ts // Clean up a route-level i18n instance when the route is destroyed const routeI18n = i18n.fork({ locale: 'de' }); onRouteDestroy(() => routeI18n.dispose()); ``` ## validateCatalog ```ts import { validateCatalog } from '@vielzeug/lingua/validate'; validateCatalog(messages: Messages, locale: Locale): ValidationWarning[] ``` Checks a flat or nested message catalog against CLDR plural rules for `locale`. Returns an array of `ValidationWarning` objects for every plural branch that is missing one or more expected forms. Import from the separate `@vielzeug/lingua/validate` entry — do not include it in your production bundle. Returns an empty array when there are no issues. **Note:** A branch is treated as a plural branch when any of its child keys is a CLDR form (`zero`, `one`, `two`, `few`, `many`, `other`). A mixed-use branch (e.g. `{ count: 'x', one: 'y' }`) will also be flagged and may produce spurious warnings for non-CLDR sibling keys. `validateCatalog` also checks for a common authoring error: a form template for `other`, `two`, `few`, or `many` that does not contain `{count}`. Since `tp()` injects `count` automatically, omitting it from a non-singleton form is almost always a mistake. These warnings use `form: ':missing-count'` (e.g. `'other:missing-count'`). The `zero` and `one` forms are exempt — intentionally omitting `{count}` is normal there (e.g. `'No messages'`, `'One message'`). **Parameters:** | Parameter | Type | Description | | ---------- | ---------- | ----------------------------------------- | | `messages` | `Messages` | A locale catalog (nested objects allowed) | | `locale` | `Locale` | The BCP 47 locale to validate against | **Returns:** `ValidationWarning[]` **Example:** ```ts import { validateCatalog } from '@vielzeug/lingua/validate'; const warnings = validateCatalog( { inbox: { one: 'One message', other: '{count} messages' }, }, 'ar', ); // Arabic requires: zero, one, two, few, many, other // => [{ key: 'inbox', locale: 'ar', form: 'zero' }, { key: 'inbox', locale: 'ar', form: 'two' }, ...] if (warnings.length > 0) throw new Error(`Missing plural forms:\n${JSON.stringify(warnings, null, 2)}`); ``` ## createFormatter ```ts import { createFormatter } from '@vielzeug/lingua'; createFormatter(source: string | (() => string)): Formatter ``` Creates an Intl formatter. Pass a static locale string or a getter that reads the current locale. **Parameters:** | Parameter | Type | Description | | --------- | -------------------------- | --------------------------------------------------------------------------- | | `source` | `string \| (() => string)` | Static locale string, or a getter called on every format method invocation. | **Returns:** `Formatter` **Example:** ```ts import { createFormatter } from '@vielzeug/lingua'; // Follows locale changes automatically const fmt = createFormatter(() => i18n.locale); fmt.number(1_234_567.89); fmt.currency(19.99, 'EUR'); fmt.date(new Date(), { dateStyle: 'medium' }); fmt.relative(-3, 'day'); fmt.list(['apples', 'bananas', 'oranges']); fmt.duration({ hours: 1, minutes: 30 }); ``` **Methods:** | Method | Intl primitive | Description | | ------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | | `number(value, options?)` | `Intl.NumberFormat` | Format a number | | `currency(value, currency, options?)` | `Intl.NumberFormat` | Format a number as currency | | `date(value, options?)` | `Intl.DateTimeFormat` | Format a `Date` or timestamp | | `relative(value, unit, options?)` | `Intl.RelativeTimeFormat` | Format a relative time value | | `list(value, options?)` | `Intl.ListFormat` | Join an array of strings or numbers | | `duration(value, options?)` | `Intl.DurationFormat` | Format a duration object. **Fallback labels are English-only** when `Intl.DurationFormat` is unavailable. | | `clear()` | — | Evict all cached `Intl` instances | Each `Intl` instance is cached by a locale + options key. The cache per method is capped at 128 entries (LRU eviction), so memory is bounded even in SSR workloads that create many distinct option combinations. ## Types ### `I18n` The object returned by `createI18n`. See the [I18n Interface](#i18n-interface) section for member documentation. ### `I18nOptions` ```ts type I18nOptions = { catalogs?: Record>; fallback?: Locale | Locale[]; locale?: Locale; onMissingKey?: (key: string, locale: string) => string; onMissingVar?: (varName: string, key: string, locale: string) => string; onSubscriberError?: (error: unknown) => void; }; ``` ### `I18nSnapshot` ```ts type I18nSnapshot = { readonly locale: Locale; readonly t: (key: string, vars?: TranslateVars) => string; readonly tp: (key: string, count: number, options?: TpOptions) => string; }; ``` Object identity changes on every observable change — use as a change-detection sentinel. The `t` and `tp` accessors are bound to the same translation functions as the instance, making the snapshot a self-contained translation unit suitable for passing to framework components. ### `I18nState` ```ts type I18nState = { readonly catalogs: Record>; readonly locale: Locale; }; ``` Produced by `getState()` / `serializeI18n()` and consumed by `restoreState()` / `hydrateI18n()`. Catalogs are stored as flat dot-notation maps. ### `NamespaceFactory` ```ts type NamespaceFactory = (locale: Locale) => Promise; ``` Factory passed to `registerNamespace()` / `extend()`. Receives the target locale and must return a `Promise` with the namespace messages for that locale. Namespace content is independent of the instance's catalog type `M` — a namespace can introduce keys not present in the initial catalog shape. ### `TpOptions` ```ts type TpOptions = { ordinal?: boolean; vars?: TranslateVars; }; ``` Options for `tp()`. Pass `{ ordinal: true }` for ordinal plural forms (1st, 2nd, 3rd). Pass `vars` to inject additional interpolation variables alongside the automatically injected `count`. ### `SubscribeOptions` ```ts type SubscribeOptions = { immediate?: boolean; signal?: AbortSignal; }; ``` ### `ValidationWarning` ```ts import type { ValidationWarning } from '@vielzeug/lingua/validate'; type ValidationWarning = { form: string; // missing CLDR plural form (e.g. 'few', 'many') or ':missing-count' for {count} warnings key: string; // dot-notation path to the plural branch locale: Locale; // the locale being validated }; ``` Returned by [`validateCatalog()`](#validatecatalog). Import from `@vielzeug/lingua/validate` — not re-exported from the main entry point. The `form` field uses plain CLDR form names (e.g. `'other'`) for missing-form warnings, and `':missing-count'` (e.g. `'other:missing-count'`) for templates that are missing `{count}` interpolation. ### `Messages` ```ts interface Messages { [key: string]: string | Messages; } ``` Shape of a locale catalog. Leaf values are strings; branch values are nested `Messages` objects. ### `LocaleSource` ```ts type LocaleSource = M | Loader; ``` ### `Loader` ```ts type Loader = () => Promise; ``` ### `ScopedI18n` ```ts type ScopedI18n = { readonly fmt: Formatter; has(key: string): boolean; t(key: string, vars?: TranslateVars): string; tp(key: string, count: number, options?: TpOptions): string; }; ``` Returned by `i18n.scope(prefix)`. The `fmt` property is the same formatter instance as `i18n.fmt`. ### `TranslateVars` ```ts type TranslateVars = Record; ``` ### `Locale` ```ts type Locale = string; ``` A BCP 47 language tag (e.g. `'en'`, `'en-US'`, `'zh-Hant-TW'`). ### `Unsubscribe` ```ts type Unsubscribe = () => void; ``` ### `MessageLeafKeys` Recursively infers all dot-separated paths to `string` leaf values in a `Messages` type. Constrains the `key` parameter of `t()` and `has()`. Recursion is capped at depth 7. ```ts type MessageLeafKeys = /* recursive conditional type */ ``` ### `MessageBranchKeys` Recursively infers all dot-separated paths to non-string (branch) values in a `Messages` type. Constrains the `key` parameter of `tp()` and `scope()`. Recursion is capped at depth 7. ```ts type MessageBranchKeys = /* recursive conditional type */ ``` ### `Formatter` ```ts type Formatter = { clear(): void; currency(value: number, currency: string, options?: Omit): string; date(value: Date | number, options?: Intl.DateTimeFormatOptions): string; duration(value: DurationValue, options?: DurationFormatOptions): string; list(value: Array, options?: ListFormatOptions): string; number(value: number, options?: Intl.NumberFormatOptions): string; relative(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string; }; ``` ### `DurationValue` ```ts type DurationValue = Partial >; ``` ### `DurationFormatOptions` ```ts type DurationFormatOptions = { hours?: '2-digit' | 'numeric'; microseconds?: 'numeric'; milliseconds?: 'numeric'; minutes?: '2-digit' | 'numeric'; nanoseconds?: 'numeric'; seconds?: '2-digit' | 'numeric'; style?: 'digital' | 'long' | 'narrow' | 'short'; }; ``` ### `ListFormatOptions` ```ts type ListFormatOptions = { style?: 'long' | 'narrow' | 'short'; type?: 'and' | 'or'; }; ``` ## serializeI18n ```ts import { serializeI18n } from '@vielzeug/lingua'; serializeI18n(i18n: I18n): I18nState ``` Serialises the current loaded catalogs and active locale into an `I18nState` object. Use this on the server before embedding state in the HTML response. Loader-only locales that have not been preloaded are silently omitted — call `isLoaded()` to verify all locales are resolved before calling `serializeI18n()`. ```ts // Server const i18n = createI18n({ catalogs: { de: deMessages, en: enMessages }, locale: 'de' }); const state = serializeI18n(i18n); // Embed in the HTML response: // window.__I18N__ = ${JSON.stringify(state)} ``` ## hydrateI18n ```ts import { hydrateI18n } from '@vielzeug/lingua'; hydrateI18n(i18n: I18n, state: I18nState): void ``` Hydrates a client-side instance from server-serialised state. Replaces all catalogs and switches the active locale. Notifies subscribers once after hydration. Throws `LinguaRestoreError` if `state.locale` has no corresponding entry in `state.catalogs`. ```ts // Client const i18n = createI18n(); hydrateI18n(i18n, window.__I18N__); // Catalogs from state are immediately available; no network request needed. ``` **Parameters:** | Parameter | Type | Description | | --------- | ----------- | ------------------------------------------ | | `i18n` | `I18n` | The instance to hydrate. | | `state` | `I18nState` | State object produced by `serializeI18n()` | ## Error Classes All errors thrown by the `@vielzeug/lingua` runtime extend `LinguaError`. Use `instanceof LinguaError` to catch any lingua error, or `instanceof` the specific subclass for precise handling. ```ts import { LinguaDisposedError, LinguaError, LinguaMissingLocaleError } from '@vielzeug/lingua'; try { await i18n.setLocale('de'); } catch (err) { if (err instanceof LinguaMissingLocaleError) { // locale not registered — handle gracefully } else if (err instanceof LinguaError) { throw err; } } ``` | Class | When thrown | | --------------------------- | ------------------------------------------------------------------------------- | | `LinguaError` | Base class — `instanceof LinguaError` catches all lingua errors | | `LinguaDisposedError` | Any mutating API called on a disposed instance | | `LinguaInvalidCountError` | `tp()` — `count` is non-finite | | `LinguaCountInVarsError` | `tp()` — `vars.count` was passed explicitly | | `LinguaMissingLocaleError` | `preload()` / `setLocale()` — locale has no registered source | | `LinguaInvalidLocaleError` | Any API receiving an invalid BCP 47 tag | | `LinguaNamespaceMissingError` | Namespace requested but not loaded for the current locale | | `LinguaRestoreError` | `hydrateI18n()` / `restoreState()` — `state.locale` absent from `state.catalogs` | ### Usage Guide ## Setup ```ts import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ locale: 'en', fallback: 'en', catalogs: { en: { greeting: 'Hello, {name}!', inbox: { zero: 'No messages', one: 'One message', other: '{count} messages', }, }, de: () => import('./locales/de.json').then((m) => m.default), }, }); ``` All locale strings must be valid BCP 47 tags. `createI18n`, `setLocale`, and `register` throw `LinguaInvalidLocaleError` for unrecognised tags. ## Locale Lifecycle ```ts await i18n.preload('de'); await i18n.setLocale('de'); await i18n.register('fr', () => import('./locales/fr.json').then((m) => m.default)); const locales = i18n.getSupportedLocales(); ``` - `preload(locale)` loads the catalog without switching the active locale. Use it to warm up a locale before the user requests it. - `setLocale(locale)` loads if needed, then atomically switches and bumps the version. - `register(locale, source)` returns `Promise`. For async loaders it resolves when the catalog is loaded. For static objects it resolves immediately. Subscribers are notified after the catalog is available. Locale lookup expands subtags automatically — `en-US` checks `en-US` then `en` before moving to explicit fallbacks. ## Translation ```ts i18n.t('greeting', { name: 'Alice' }); i18n.tp('inbox', 3); i18n.tp('position', 2, { ordinal: true }); i18n.tp('position', 1, { vars: { name: 'Alice' }, ordinal: true }); ``` `t()` resolves leaf keys. `tp()` resolves plural branch keys (`.zero`, then CLDR category, then `.other`). `count` is injected automatically — do not include it in `vars`. ## Key Inspection Use `has(key)` to check whether a key exists in the active fallback chain. It returns `true` for leaf keys, branch keys, and pipe-plural base keys. ```ts // catalog: { inbox: 'One message|{count} messages' } (expands to inbox.one, inbox.other) i18n.has('inbox'); // true — branch exists after pipe-plural expansion i18n.has('inbox.one'); // true — explicit sub-key i18n.has('missing'); // false ``` `has()` walks the full fallback chain, so it returns `true` if any fallback locale provides the key. ## Scoped Helpers `scope(prefix)` returns a `{ fmt, t, tp, has }` helper bound to a key prefix. Use it inside a component or module to avoid repeating the same key segment. ```ts const nav = i18n.scope('nav'); nav.t('home'); // resolves 'nav.home' nav.t('menu.settings'); // resolves 'nav.menu.settings' nav.has('logout'); // checks 'nav.logout' nav.fmt.number(1234); // same as i18n.fmt.number(1234) ``` `scope()` is memoized per prefix — repeated calls with the same prefix string return the same object reference. ## Formatting Import `createFormatter` from the main entry: ```ts import { createFormatter } from '@vielzeug/lingua'; // Pass a getter so the formatter follows locale changes const fmt = createFormatter(() => i18n.locale); fmt.number(1234567.89); fmt.currency(19.99, 'EUR'); fmt.date(new Date(), { dateStyle: 'medium' }); fmt.relative(-3, 'day'); fmt.list(['a', 'b', 'c']); ``` Alternatively, access `i18n.fmt` which is a formatter pre-wired to the instance locale: ```ts const price = i18n.fmt.currency(49.95, 'USD'); ``` ## Namespace-based Lazy Loading There are two ways to load namespaces. Use `extend()` as a one-call convenience, or split the steps with `registerNamespace()` + `loadNamespace()` when you want to defer loading. **`extend(ns, factory, locale?)`** — register and load in one call: ```ts // Load when entering the settings route async function onEnterSettings() { await i18n.extend('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default)); // Keys from settings.json are now merged into the active locale catalog } // Pre-load for a specific locale await i18n.extend('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default), 'de'); ``` **`registerNamespace()` + `loadNamespace()`** — register eagerly, load on demand: ```ts const settingsFactory = (locale: string) => import(`./locales/${locale}/settings.json`).then((m) => m.default); // Register at startup — no network request yet i18n.registerNamespace('settings', settingsFactory); // Load lazily when the route is activated async function onEnterSettings() { await i18n.loadNamespace('settings'); // deduplicates concurrent calls } ``` Key characteristics: - All three methods (`extend`, `registerNamespace`, `loadNamespace`) deduplicate per `ns + locale` — the factory runs at most once. - `isNamespaceRegistered(ns)` returns `true` after `registerNamespace()` or `extend()`. - `isNamespaceLoaded(ns, locale?)` returns `true` only after a successful load for that locale. ## Missing Handling Pass `onMissingKey` and/or `onMissingVar` to `createI18n` to handle missing keys and unresolved interpolation variables. ```ts const strictI18n = createI18n({ onMissingKey(key, locale) { return `[missing:${key}]`; }, onMissingVar(varName, key, locale) { return ``; }, }); ``` Without `onMissingKey`, missing keys return the key string. Without `onMissingVar`, missing variables keep their `{placeholder}` text. ## Validating Catalogs Use `validateCatalog()` during development or CI to detect plural branches that are missing CLDR forms for a target locale. Import it from the dedicated `@vielzeug/lingua/validate` entry — never from the main entry or it will end up in your production bundle. ```ts import { validateCatalog } from '@vielzeug/lingua/validate'; import ar from './locales/ar.json'; const warnings = validateCatalog(ar, 'ar'); // Arabic requires: zero, one, two, few, many, other // warnings = [{ key: 'inbox', locale: 'ar', form: 'zero' }, ...] if (warnings.length > 0) { throw new Error(`Missing plural forms:\n${JSON.stringify(warnings, null, 2)}`); } ``` The function compares present plural forms against the full CLDR set for the given locale using `Intl.PluralRules`. It also warns when a `other`, `two`, `few`, or `many` form template does not contain `{count}` — these warnings carry `form: ':missing-count'`. The `zero` and `one` forms are exempt. ## Forking `fork(overrides?)` creates a child instance that inherits the parent's current catalog snapshot and namespace registry, but has its own locale, fallback chain, and subscribers. Use it to isolate per-request locale state in SSR, or to create a test instance without polluting the shared one. ```ts // SSR: share catalog setup; one fork per request const reqI18n = i18n.fork({ locale: req.locale }); await reqI18n.setLocale(req.locale); const html = `${reqI18n.t('title')}`; // Tests: custom missing-key handler without polluting the shared instance const testI18n = i18n.fork({ onMissingKey: (k) => `MISSING:${k}` }); ``` Key characteristics: - Catalog mutations on the fork do not affect the parent, and vice versa. - Namespace dedup markers are copied at fork time. Calling `extend()` on a fork for an already-loaded `ns + locale` pair is a no-op. - Forks do not inherit subscribers. ## SSR Hydration Prefer the instance methods `getState()` and `restoreState()` — they are equivalent to `serializeI18n` / `hydrateI18n` but require no extra imports: ```ts import { createI18n } from '@vielzeug/lingua'; // Server (Node.js / Deno) const i18n = createI18n({ catalogs: { de: deMessages, en: enMessages }, locale: 'de' }); const state = i18n.getState(); // Embed state in the HTML response: // window.__I18N__ = ${JSON.stringify(state)} // Client const i18n = createI18n(); i18n.restoreState(window.__I18N__); // Catalogs from state are immediately available; no network request needed. ``` `restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once. The free functions `serializeI18n()` and `hydrateI18n()` are equivalent alternatives. **Warning:** `getState()` silently omits locales registered as async loaders but not yet preloaded. Use `isLoaded()` to guard: ```ts const locales = i18n.getSupportedLocales(); await Promise.all(locales.filter((l) => !i18n.isLoaded(l)).map((l) => i18n.preload(l))); const state = i18n.getState(); // all locales guaranteed to be present ``` ## Subscriptions `subscribe(callback, options?)` fires on every locale or catalog change. It returns an `Unsubscribe` function. ```ts const unsubscribe = i18n.subscribe( ({ locale }) => { document.documentElement.lang = locale; }, { immediate: true }, ); // Later unsubscribe(); ``` Pass `{ signal }` to tie the subscription lifetime to an `AbortController` — useful in component lifecycle hooks: ```ts // React useEffect useEffect(() => { const controller = new AbortController(); i18n.subscribe( ({ locale }) => { document.documentElement.lang = locale; }, { immediate: true, signal: controller.signal }, ); return () => controller.abort(); }, []); // Svelte onDestroy const controller = new AbortController(); i18n.subscribe( ({ locale }) => { snapshot = locale; }, { signal: controller.signal }, ); onDestroy(() => controller.abort()); ``` If the signal is already aborted when `subscribe()` is called, no subscription is created and the callback is never invoked. ## Framework Integration `i18n` exposes `subscribe` / `getSnapshot` semantics and wires directly into any framework reactive system. ```tsx [React] import { useSyncExternalStore } from 'react'; import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ locale: 'en', catalogs: { en: { greeting: 'Hello, {name}!' }, de: () => import('./de.json').then((m) => m.default) }, }); function useI18nSnapshot() { return useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot); } function Greeting({ name }: { name: string }) { useI18nSnapshot(); // re-renders when locale changes return {i18n.t('greeting', { name })}; } ``` ```ts [Vue 3] import { shallowRef, onScopeDispose } from 'vue'; import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ locale: 'en', catalogs: { en: { greeting: 'Hello, {name}!' } }, }); function useI18n() { const snapshot = shallowRef(i18n.getSnapshot()); const stop = i18n.subscribe( (s) => { snapshot.value = s; }, { immediate: true }, ); onScopeDispose(stop); return snapshot; } ``` ```svelte [Svelte] import { onDestroy } from 'svelte'; import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ locale: 'en', catalogs: { en: { greeting: 'Hello, {name}!' } }, }); let snapshot = i18n.getSnapshot(); const stop = i18n.subscribe( (s) => { snapshot = s; }, { immediate: true }, ); onDestroy(() => stop()); {i18n.t('greeting', { name: 'Alice' })} ``` ## Working with Other Vielzeug Libraries ### With Wayfinder Use Wayfinder path params or query params as the source of truth for locale selection. ```ts import { createI18n } from '@vielzeug/lingua'; import { createBrowserHistory, createRouter } from '@vielzeug/wayfinder'; const i18n = createI18n({ locale: 'en', catalogs: { en: { title: 'Home' }, de: { title: 'Startseite' } } }); const router = createRouter({ history: createBrowserHistory(), routes: [{ path: '/:locale/home', id: 'home' }] }); router.subscribe(() => { const locale = router.current.params.locale; if (locale) i18n.setLocale(locale); }); ``` ## Best Practices - Call `preload(locale)` before `setLocale(locale)` to avoid a render with missing translations. - Use lazy catalog functions (`() => import('./locales/de.json')`) for locales not needed at startup. - Keep translation keys flat or one level deep — deeply nested keys are harder to refactor. - Set `fallback` to a locale with 100% coverage so missing keys degrade gracefully. - Use `extend(ns, factory, locale?)` or `registerNamespace()` + `loadNamespace()` for per-route or per-feature key sets. - Use `isLoaded(locale)` before `getState()` / `serializeI18n()` in SSR to avoid silently omitting async-loader locales. - Use `isRegistered(locale)` to check if a locale is configured; use `isLoaded(locale)` to check if it is ready. - Call `dispose()` on route-level or request-scoped `fork()` instances when they are no longer needed. - Use `{ signal }` in `subscribe()` for lifecycle-safe subscriptions; use the returned `Unsubscribe` otherwise. - Use `onMissingKey` and `onMissingVar` in development to surface authoring errors early; omit them in production. - Import `validateCatalog` from `@vielzeug/lingua/validate` in CI only — never in application code. - Share one `i18n` instance per app entry point; avoid creating separate instances per component. ### Examples ## Examples - [Shared Instance Setup](./examples/shared-instance-setup.md) - [Locale Switcher](./examples/locale-switcher.md) - [Prefixed Translation Helper](./examples/prefixed-translation-helper.md) - [Async Loading and Reload](./examples/async-loading-and-reload.md) - [Route-based Namespace Loading](./examples/route-based-merge.md) - [Catalog Replacement](./examples/catalog-replacement.md) - [Diagnostics Hook](./examples/diagnostics-hook.md) - [Per-request Locale Handling](./examples/per-request-locale-handling.md) - [SSR Rendering](./examples/ssr-rendering.md) ### REPL Examples - Fallback Resolution (id: `array-formatting`) - Async Locale Loading (id: `async-loading`) - Basic Setup - Initialize i18n (id: `basic-setup`) - Basic Translation (id: `basic-translation`) - isRegistered(), dispose() — lifecycle management (id: `dispose-lifecycle`) - Formatter (fmt) (id: `fmt-formatter`) - fork() — SSR & Test Isolation (id: `fork-ssr`) - Snapshots and Subscriptions (id: `formatting-helpers`) - Namespaces and scope() (id: `namespaces`) - Nested Message Objects (id: `nested-objects`) - Namespace Lazy Loading with extend() (id: `partial-merge`) - Pipe-Plural Shorthand (id: `pipe-plural`) - Pluralization Rules (id: `pluralization`) - Preload Pattern (id: `preload-pattern`) - scope(), has(), isLoaded(), extend() (id: `scope-bind`) - serializeI18n() / hydrateI18n() — SSR hydration (id: `ssr-hydration`) - createFormatter() — standalone (no createI18n) (id: `standalone-formatter`) - Variable Interpolation (id: `variable-interpolation`) --- ## @vielzeug/orbit **Category:** ui **Keywords:** floating-ui, tooltip, popover, dropdown, positioning, middleware, placement, presets **Key exports:** float, floatWithAnchor, isCssAnchorSupported, computePosition, computePositionAsync, computePositionRaf, getRects, autoUpdate, detectOverflow, offset, flip, shift (+13 more) **Related:** ore, refine, dnd ### Overview ## Why Orbit? Positioning floating UI by hand quickly turns into repeated math for viewport boundaries, arrow offsets, and scroll/resize tracking. | Feature | Orbit | Floating UI | Popper | | ------------------------ | ------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~10 kB | ~6 kB | | Middleware pipeline | | | | | Direct compute API | | | | | High-level follow API | | | Partial | | Inline anchor middleware | | | | | Auto-update helpers | | | | | Framework agnostic | | | | | Zero dependencies | | | | **Use Orbit when** you want a lightweight, DOM-first positioning engine with direct control and no framework adapter requirements. **Consider larger alternatives when** you need their existing integration ecosystem or migration compatibility. ## Installation ```sh [pnpm] pnpm add @vielzeug/orbit ``` ```sh [npm] npm install @vielzeug/orbit ``` ```sh [yarn] yarn add @vielzeug/orbit ``` ## Quick Start Use `float()` for the common case — it writes `left`/`top` and keeps the position in sync. It returns a `FloatHandle`; always call `handle.dispose()` on teardown. ```ts import { flip, float, offset, shift } from '@vielzeug/orbit'; const trigger = document.querySelector('#trigger')!; const tooltip = document.querySelector('#tooltip')!; const handle = float(trigger, tooltip, { placement: 'top', middleware: [offset(8), flip(), shift({ padding: 6 })], }); // Call dispose when the tooltip is removed handle.dispose(); ``` Or use `presets` for ready-made middleware stacks: ```ts import { float } from '@vielzeug/orbit'; import { tooltip as tooltipPreset } from '@vielzeug/orbit/presets'; const handle = float(trigger, tooltip, tooltipPreset()); handle.dispose(); ``` ## Features - `float()` covers the common position-and-follow case; returns a `FloatHandle` with `dispose()`, `update()`, and `getPosition()` - Custom `apply(result)` callback on `float()` for transforms or custom rendering - `computePosition()` returns `x`, `y`, `placement`, and `middlewareData` without touching the DOM - `detectOverflow()` returns per-side overflow offsets; used by built-in middleware and available for custom middleware - Global `boundary` and `padding` on `computePosition()` and `float()` — set once, all overflow-aware middleware inherit them - `containingBlock` option for `position: absolute` floating elements - Built-in middleware: `offset`, `flip`, `autoPlacement`, `shift`, `size`, `arrow`, `hide` - `compose()` — falsy-filter helper for building middleware arrays - `limitShift()` — constrains `shift()` drift to keep the float visually connected to its reference - `inline` middleware for multi-line inline references (now part of main entry) - Pre-configured presets (sub-path `@vielzeug/orbit/presets`): `tooltip`, `dropdown`, `popover`, `contextMenu` - `autoUpdate()` supports scroll, resize, ResizeObserver, visualViewport, animation frames, throttle, ancestor scroll containers, and `pauseWhenHidden` via IntersectionObserver - `getSide()` and `getAlignment()` utilities for reading placement components - `floatWithAnchor()` — CSS Anchor Positioning progressive enhancement (browser-native, no JS loop) - Reactive signal adapter (sub-path `@vielzeug/orbit/reactive`) — wraps `float()` with a `@vielzeug/ripple` signal - SSR-safe no-op stubs (sub-path `@vielzeug/orbit/ssr`) - Zero dependencies (optional peer: `@vielzeug/ripple` for the reactive sub-path) ## Sub-paths | Import | Purpose | | -------------------------- | ---------------------------------------------------------- | | `@vielzeug/orbit` | Core API, middleware (`inline` included), utilities, types | | `@vielzeug/orbit/presets` | Pre-configured middleware stacks | | `@vielzeug/orbit/reactive` | Reactive signal adapter (`@vielzeug/ripple`) | | `@vielzeug/orbit/devtools` | Visual debug overlay (development only) | | `@vielzeug/orbit/ssr` | No-op stubs for server-side rendering | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Refine](/refine/) — accessible web components that use Orbit internally for dropdown, tooltip, and popover positioning - [Dnd](/dnd/) — drag-and-drop engine; use Orbit to reposition drop targets and drag previews relative to containers - [Ore](/ore/) — web-component authoring framework; Orbit integrates as a positioning primitive for overlay elements ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------------------------ | ------------------------------------------------------------ | ------------------------------- | ------------------------------------------------------- | | `float()` | Position a floating element and auto-update | Sync, returns `FloatHandle` | Call `handle.dispose()` on teardown | | `computePosition()` | Compute position once without auto-update | Sync | Does not watch for layout changes | | `floatWithAnchor()` | CSS Anchor Positioning (browser-native, no JS loop) | Sync, returns `CssAnchorHandle` | Use `isCssAnchorSupported()` to guard in production | | `computePositionAsync()` | One-shot async position via microtask deferral | Async | Defers to microtask queue, not next animation frame | | `computePositionRaf()` | One-shot async position deferred to next animation frame | Async | Waits for the next rAF; use for post-paint measurements | | `autoUpdate()` | Re-run position on scroll/resize/resize-observer | Sync, returns cleanup | Call cleanup on teardown | | `detectOverflow()` | Per-side overflow of the floating rect against boundary | Sync | Positive = overflow, negative = remaining space | | `compose()` | Filter falsy middleware entries, return `Middleware[]` | Sync | Ordering validation fires via `computePosition` | | `getRects()` | Read bounding rects of reference and floating from the DOM | Sync | Advanced: useful for custom update loops | | `getSide()` | Extract the primary side from a placement string | Sync | — | | `getAlignment()` | Extract the alignment from a placement string | Sync | Returns `null` for cardinal placements | | `offset()` | Add space between reference and floating element | Middleware | Apply before `flip` so flip accounts for the gap | | `flip()` | Flip to opposite side when clipped | Middleware | Do not combine with `autoPlacement` | | `autoPlacement()` | Automatically pick the placement with the most space | Middleware | Do not combine with `flip` | | `shift()` | Shift along boundary to keep element in view | Middleware | Does not change placement, only adjusts coordinates | | `limitShift()` | Constrain shift drift to keep float near reference | `ShiftLimiter` | Pass as `limiter` option to `shift()` | | `size()` | Report available space between reference and boundary | Middleware | Read `middlewareData.size` in `apply` or after compute | | `arrow()` | Position an arrow element pointing to the reference | Middleware | Arrow element must be a child of the floating element | | `hide()` | Detect when reference or floating is hidden outside boundary | Middleware | Combine with CSS `visibility` or `opacity` | | `inline()` | Accurate rect for inline references spanning multiple lines | Middleware | Place before `flip()`; part of main entry | | presets | Pre-configured placement + middleware stacks | Factory | Import from `@vielzeug/orbit/presets` | | `debugFloat()` | Wraps `float()` with a visual debug overlay | Sync, returns `FloatHandle` | Import from `@vielzeug/orbit/devtools`, not the main entry point | | `OrbitError` | Base class for all orbit errors | — | `OrbitError.is(e)` catches any orbit error | | `OrbitConfigError` | Thrown when a middleware pipeline is misconfigured | — | e.g. bad ordering, `flip()` + `autoPlacement()` together | ## Package Entry Points | Import | Purpose | | -------------------------- | ---------------------------------------------------------- | | `@vielzeug/orbit` | Core API, middleware (`inline` included), utilities, types | | `@vielzeug/orbit/presets` | Pre-configured middleware stacks | | `@vielzeug/orbit/reactive` | Reactive signal adapter (`@vielzeug/ripple`) | | `@vielzeug/orbit/devtools` | Visual debug overlay (dev only) | | `@vielzeug/orbit/ssr` | No-op stubs for server-side rendering | ## Core Functions ### `float(reference, floating, options?)` ```ts float(reference: ReferenceElement, floating: HTMLElement, options?: FloatOptions): FloatHandle; ``` Positions `floating` relative to `reference` and keeps it in sync. Returns a `FloatHandle` — **always call `handle.dispose()`** to remove scroll and resize listeners. By default, writes `left` and `top` CSS properties. The floating element must have `position: fixed`. **Example:** ```ts import { float, flip, offset, shift } from '@vielzeug/orbit'; const handle = float(trigger, tooltip, { placement: 'top', middleware: [offset(8), flip(), shift({ padding: 6 })], }); // on teardown: handle.dispose(); ``` **Options — `FloatOptions`** | Option | Type | Default | Description | | ------------ | ----------------------------------------- | ---------- | --------------------------------------------------------------------- | | `placement` | `Placement` | `'bottom'` | Initial placement. Middleware may change it. | | `middleware` | `Middleware[]` | `[]` | Middleware pipeline. | | `apply` | `(result: ComputePositionResult) => void` | — | Custom DOM write callback. Defaults to writing `left`/`top`. | | `autoUpdate` | `AutoUpdateOptions \| false` | `{}` | Auto-update options. Pass `false` to position once without listeners. | **Returns:** `FloatHandle` --- ### `floatWithAnchor(reference, floating, options?)` ```ts floatWithAnchor(reference: HTMLElement, floating: HTMLElement, options?: { placement?: Placement }): CssAnchorHandle; ``` Uses CSS Anchor Positioning to let the browser reposition the floating element natively — no JavaScript update loop, no scroll or resize listeners. Suitable when you don't need middleware or a custom `apply` callback. > **Experimental.** CSS Anchor Positioning has [varying browser support](https://caniuse.com/css-anchor-positioning). Always guard with `isCssAnchorSupported()`. **Example:** ```ts import { floatWithAnchor, isCssAnchorSupported } from '@vielzeug/orbit'; if (isCssAnchorSupported()) { const handle = floatWithAnchor(trigger, tooltip, { placement: 'top' }); // on teardown: handle.dispose(); } ``` The handle exposes `cssAnchor: true` (always) and the standard `FloatHandle` lifecycle methods (`dispose`, `disposed`, `disposalSignal`). `getPosition()` always returns `null` — position is managed by the browser. **Returns:** `CssAnchorHandle` (extends `FloatHandle` with `cssAnchor: true`) --- ### `isCssAnchorSupported()` ```ts isCssAnchorSupported(): boolean; ``` Returns `true` when the current browser supports CSS Anchor Positioning. Use as a guard before calling `floatWithAnchor()`. --- ### `computePositionAsync(reference, floating, options?)` ```ts computePositionAsync(reference: ReferenceElement, floating: HTMLElement, options?: ComputePositionOptions): Promise; ``` Deferred one-shot position computation. Schedules `computePosition` in the next microtask and resolves with the result. Useful in async component lifecycles (e.g. after `await nextTick()`) where DOM layout may not yet be stable. > **Note:** This defers to the microtask queue, not the next animation frame. For post-layout measurements, wrap in `requestAnimationFrame` instead. **Returns:** `Promise` **Example:** ```ts import { computePositionAsync } from '@vielzeug/orbit'; // e.g. in a Vue onMounted or React useEffect: const result = await computePositionAsync(reference, floating, { placement: 'top' }); floating.style.left = `${result.x}px`; floating.style.top = `${result.y}px`; ``` --- ### `computePositionRaf(reference, floating, options?)` ```ts computePositionRaf(reference: ReferenceElement, floating: HTMLElement, options?: ComputePositionOptions): Promise; ``` Deferred one-shot position computation. Schedules `computePosition` in the next `requestAnimationFrame` callback and resolves with the result. Use when you need a position after the next paint — for example, immediately after a CSS transition starts. > **Note:** This defers to the next animation frame (≈16 ms). For most async lifecycle hooks, `computePositionAsync` (microtask) is faster and sufficient. **Returns:** `Promise` **Example:** ```ts import { computePositionRaf } from '@vielzeug/orbit'; const result = await computePositionRaf(reference, floating, { placement: 'top' }); floating.style.left = `${result.x}px`; floating.style.top = `${result.y}px`; ``` --- ### `computePosition(reference, floating, options?)` ```ts computePosition(reference: ReferenceElement, floating: HTMLElement, options?: ComputePositionOptions): ComputePositionResult; ``` Synchronously computes the position of `floating` relative to `reference`. Returns coordinates and middleware data without mutating the DOM. **Example:** ```ts import { arrow, computePosition, flip, offset } from '@vielzeug/orbit'; const { x, y, placement, middlewareData } = computePosition(trigger, panel, { placement: 'bottom-start', middleware: [offset(8), flip(), arrow({ element: arrowEl })], }); ``` **Options — `ComputePositionOptions`** | Option | Type | Default | Description | | ----------------- | ------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | | `placement` | `Placement` | `'bottom'` | Initial placement for this computation. | | `middleware` | `Array` | `[]` | Middleware pipeline. Falsy entries are skipped. | | `containingBlock` | `Element \| null` | — | Subtract the block's origin. Use when the floating element is `position: absolute`. | | `boundary` | `Element \| Rect` | viewport | Default boundary for all overflow-aware middleware. Per-middleware `boundary` takes precedence. | | `padding` | `Padding` | `0` | Default padding for all overflow-aware middleware. Per-middleware `padding` takes precedence. | **Returns — `ComputePositionResult`** | Field | Type | Description | | ---------------- | ---------------- | ------------------------------------------ | | `x` | `number` | Left position in viewport-relative pixels. | | `y` | `number` | Top position in viewport-relative pixels. | | `placement` | `Placement` | Resolved placement after middleware. | | `middlewareData` | `MiddlewareData` | Accumulated data from all middleware. | --- ### `autoUpdate(reference, floating, update, options?)` > **Advanced.** Most applications should use `float()` instead. Use `autoUpdate` directly only when you need full control over the update callback (e.g. integrating with a custom rendering pipeline). ```ts autoUpdate(reference: ReferenceElement, floating: HTMLElement, update: () => void, options?: AutoUpdateOptions): Cleanup; ``` Calls `update` immediately, then re-calls it whenever the reference or floating element could have moved. Returns a `Cleanup` function. **Example:** ```ts import { autoUpdate, computePosition } from '@vielzeug/orbit'; const cleanup = autoUpdate(reference, floating, () => { const { x, y } = computePosition(reference, floating, options); floating.style.left = `${x}px`; floating.style.top = `${y}px`; }); ``` Supported triggers: - `scroll` on `window` (capture phase) - `resize` on `window` - `ResizeObserver` on the reference and optionally the floating element - `visualViewport` resize and scroll (pinch-zoom) - `requestAnimationFrame` loop when `animationFrame: true` **Options — `AutoUpdateOptions`** | Option | Type | Default | Description | | ----------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | `observeFloating` | `boolean` | `true` | Watch the floating element for size changes. | | `observeAncestors` | `boolean` | `true` | Attach scroll listeners to ancestor scroll containers of the reference. More reliable than window-only in nested scroll contexts. | | `observeVisualViewport` | `boolean` | `true` | Track visual viewport scroll and resize. | | `pauseWhenHidden` | `boolean` | `true` | Pause updates when the reference is off-screen (IntersectionObserver). Fires one update when visible again. | | `animationFrame` | `boolean` | `false` | Re-position on every animation frame. Use only when the reference itself animates. | | `throttle` | `number` | `0` | Throttle updates to at most once every N ms. Uses leading + trailing strategy. `0` = no throttle. | **Returns:** `Cleanup` (`() => void`) --- ### `detectOverflow(state, options?)` ```ts detectOverflow(state: MiddlewareState, options?: DetectOverflowOptions): SideObject; ``` Returns the per-side overflow of the floating element's current rect against its boundary. Positive values indicate overflow; negative values indicate remaining space. Used internally by all overflow-aware middleware and available for custom middleware authors. **Example:** ```ts import { detectOverflow } from '@vielzeug/orbit'; const overflow = detectOverflow(state, { boundary: document.querySelector('#scroll-container'), padding: { top: 8, bottom: 8 }, }); // overflow.top > 0 → element is clipped at the top ``` **Options — `DetectOverflowOptions`** | Option | Type | Default | Description | | ---------- | ----------------- | --------------- | ---------------------------------- | | `boundary` | `Element \| Rect` | visual viewport | Boundary to check against. | | `padding` | `Padding` | `0` | Inset padding inside the boundary. | **Returns:** `SideObject` --- ### `getSide(placement)` ```ts getSide(placement: Placement): Side; ``` Extracts the primary side from a placement string. **Returns:** `Side` **Example:** ```ts import { getSide } from '@vielzeug/orbit'; getSide('bottom-start'); // → 'bottom' getSide('left'); // → 'left' ``` --- ### `getAlignment(placement)` ```ts getAlignment(placement: Placement): Alignment | null; ``` Extracts the alignment from a placement string. Returns `null` for cardinal placements. **Returns:** `Alignment | null` **Example:** ```ts import { getAlignment } from '@vielzeug/orbit'; getAlignment('top-start'); // → 'start' getAlignment('bottom'); // → null ``` --- ### `getRects(reference, floating)` ```ts getRects(reference: ReferenceElement, floating: HTMLElement): { reference: Rect; floating: Rect }; ``` Reads the bounding rects of the reference and floating elements from the DOM by calling `getBoundingClientRect()` on each. Useful when building custom update loops that need access to the raw rects without running the full positioning pipeline. **Returns:** `{ reference: Rect; floating: Rect }` **Example:** ```ts import { getRects } from '@vielzeug/orbit'; const { reference, floating } = getRects(referenceEl, floatingEl); console.log(reference.width, floating.height); ``` --- ## Errors ### `OrbitError` ```ts class OrbitError extends Error { static is(err: unknown): err is OrbitError; } ``` Base class for all orbit errors. Use `OrbitError.is(err)` (or `instanceof OrbitError`) to catch any orbit-originated error. ### `OrbitConfigError` ```ts class OrbitConfigError extends OrbitError {} ``` Thrown when a middleware pipeline is misconfigured — a known-bad ordering (e.g. `flip` scheduled before `inline`), combining `flip()` and `autoPlacement()`, or a middleware chain that triggers more than 8 resets in a single `computePosition` call. **Example:** ```ts import { computePosition, flip, autoPlacement, OrbitConfigError } from '@vielzeug/orbit'; try { computePosition(reference, floating, { middleware: [flip(), autoPlacement()] }); } catch (err) { if (err instanceof OrbitConfigError) { console.error('Fix the middleware pipeline:', err.message); } } ``` ## Middleware Middleware are pure functions: `(state: MiddlewareState) => MiddlewareResult | void`. They run in array order on each positioning cycle. Return `void` or `undefined` when making no change. ### `offset(value)` ```ts offset(value: OffsetValue): Middleware; ``` Adds distance along the main axis, cross axis, or both. Apply before `flip` or `autoPlacement` so those middlewares account for the gap. **Returns:** `Middleware` **Example:** ```ts import { offset } from '@vielzeug/orbit'; offset(8); offset({ mainAxis: 8, crossAxis: 4 }); offset((state) => ({ mainAxis: state.placement.startsWith('top') ? 12 : 8 })); ``` **`OffsetValue`** ```ts type OffsetValue = | number | { mainAxis?: number; crossAxis?: number } | ((state: MiddlewareState) => number | { mainAxis?: number; crossAxis?: number }); ``` --- ### `flip(options?)` ```ts flip(options?: FlipOptions): Middleware; ``` Changes placement to the opposite side (or a custom fallback) when the current placement overflows the boundary. When no candidate fits, picks the one with the least total overflow. Do not combine with `autoPlacement()`. **Returns:** `Middleware` **Example:** ```ts import { flip } from '@vielzeug/orbit'; flip(); flip({ fallbackPlacements: ['right', 'left'], padding: 8 }); ``` **Options — `FlipOptions`** (extends `DetectOverflowOptions`) | Option | Type | Default | Description | | -------------------- | ----------------- | --------------- | --------------------------------------------------------------- | | `fallbackPlacements` | `Placement[]` | opposite side | Ordered candidates to try when the current placement overflows. | | `padding` | `Padding` | `0` | Inset from boundary edges. | | `boundary` | `Element \| Rect` | visual viewport | Boundary to use for overflow detection. | --- ### `autoPlacement(options?)` ```ts autoPlacement(options?: AutoPlacementOptions): Middleware; ``` Evaluates all allowed placements and picks the one with the most available space and least overflow. Do not combine with `flip()`. **Returns:** `Middleware` **Example:** ```ts import { autoPlacement } from '@vielzeug/orbit'; autoPlacement({ allowedPlacements: ['top', 'bottom'] }); ``` **Options — `AutoPlacementOptions`** (extends `DetectOverflowOptions`) | Option | Type | Default | Description | | ------------------- | ----------------- | --------------------------------- | --------------------------------------- | | `allowedPlacements` | `Placement[]` | `['top','right','bottom','left']` | Placements to consider. | | `padding` | `Padding` | `0` | Inset from boundary edges. | | `boundary` | `Element \| Rect` | visual viewport | Boundary to use for overflow detection. | --- ### `shift(options?)` ```ts shift(options?: ShiftOptions): Middleware; ``` Shifts the floating element along the cross axis to keep it inside the boundary. Enable `mainAxis` to also shift along the main axis (useful when `flip` is not in the pipeline). | Placement | Cross axis (default) | Main axis (opt-in) | | ---------------- | -------------------- | ------------------ | | `top` / `bottom` | horizontal | vertical | | `left` / `right` | vertical | horizontal | **Returns:** `Middleware` **Example:** ```ts import { shift } from '@vielzeug/orbit'; shift({ padding: 6 }); shift({ padding: { top: 8, bottom: 8 }, mainAxis: true }); ``` **Options — `ShiftOptions`** (extends `DetectOverflowOptions`) | Option | Type | Default | Description | | ----------- | ----------------- | --------------- | --------------------------- | | `crossAxis` | `boolean` | `true` | Shift along the cross axis. | | `mainAxis` | `boolean` | `false` | Shift along the main axis. | | `padding` | `Padding` | `0` | Inset from boundary edges. | | `boundary` | `Element \| Rect` | visual viewport | Boundary to shift within. | --- ### `size(options?)` ```ts size(options?: SizeOptions): Middleware; ``` Reports available space between the reference and boundary edges. Writes `{ availableWidth, availableHeight }` to `middlewareData.size`. Read the data in a `float()` `apply` callback or after `computePosition`. **Returns:** `Middleware` **Example:** ```ts import { computePosition, float, size } from '@vielzeug/orbit'; // Read from float apply (preferred for auto-updating): const handle = float(ref, el, { middleware: [flip(), shift(), size()], apply(result) { if (result.middlewareData.size) { el.style.maxHeight = `${result.middlewareData.size.availableHeight}px`; } el.style.left = `${result.x}px`; el.style.top = `${result.y}px`; }, }); // Or with computePosition: const { middlewareData } = computePosition(ref, el, { middleware: [size()] }); el.style.maxHeight = `${middlewareData.size!.availableHeight}px`; ``` **Options — `SizeOptions`** (extends `DetectOverflowOptions`) | Option | Type | Description | | ---------- | ----------------- | ---------------------------- | | `padding` | `Padding` | Inset from boundary edges. | | `boundary` | `Element \| Rect` | Boundary to measure against. | **`SizeData`** (`middlewareData.size`) | Field | Type | Description | | ----------------- | -------- | ----------------------------- | | `availableWidth` | `number` | Available pixels to the side. | | `availableHeight` | `number` | Available pixels above/below. | --- ### `arrow(options)` ```ts arrow(options: ArrowOptions): Middleware; ``` Positions an arrow element inside the floating element. Writes `{ x?, y?, centerOffset }` to `middlewareData.arrow`. Place `arrow()` after `flip()` and `shift()` so the arrow is positioned against the final placement and coordinates. **Returns:** `Middleware` **Example:** ```ts import { arrow, computePosition, flip, offset, shift } from '@vielzeug/orbit'; import type { ArrowData } from '@vielzeug/orbit'; const { middlewareData } = computePosition(ref, floating, { middleware: [offset(12), flip(), shift({ padding: 8 }), arrow({ element: arrowEl, padding: 6 })], }); const { x, y } = middlewareData.arrow as ArrowData; arrowEl.style.left = x != null ? `${x}px` : ''; arrowEl.style.top = y != null ? `${y}px` : ''; ``` **Options — `ArrowOptions`** | Option | Type | Default | Description | | --------- | ------------- | ------- | --------------------------------------------------- | | `element` | `HTMLElement` | — | The arrow element. Must be a child of the floating. | | `padding` | `Padding` | `0` | Minimum distance from floating element corners. | **`ArrowData`** (`middlewareData.arrow`) | Field | Type | Description | | -------------- | --------------------- | ------------------------------------------------------------------------------------- | | `x` | `number \| undefined` | Arrow x offset (set for `top`/`bottom` placements). | | `y` | `number \| undefined` | Arrow y offset (set for `left`/`right` placements). | | `centerOffset` | `number` | Non-zero when the arrow was clamped away from the ideal centered position. | | `constrained` | `boolean` | `true` when the arrow was clamped (e.g. due to `padding` or the float being shifted). | --- ### `hide(options?)` ```ts hide(options?: HideOptions): Middleware; ``` Detects when the reference or floating element is hidden outside the boundary. Writes to `middlewareData.hide`. **Returns:** `Middleware` **Example:** ```ts import { computePosition, hide } from '@vielzeug/orbit'; import type { HideData } from '@vielzeug/orbit'; const { middlewareData } = computePosition(ref, floating, { middleware: [hide()], }); const { referenceHidden, escaped } = middlewareData.hide as HideData; floating.style.visibility = referenceHidden ? 'hidden' : 'visible'; ``` **Options — `HideOptions`** (extends `DetectOverflowOptions`) | Option | Type | Default | Description | | ---------- | ------------------------------------------ | -------- | ------------------------------- | | `strategy` | `'referenceHidden' \| 'escaped' \| 'both'` | `'both'` | Which hidden states to compute. | | `padding` | `Padding` | `0` | Inset from boundary edges. | | `boundary` | `Element \| Rect` | viewport | Boundary to check against. | **`HideData`** | Field | Type | Description | | ------------------------ | ------------------------- | ------------------------------------------------------------- | | `referenceHidden` | `boolean \| undefined` | `true` when the reference is fully clipped by the boundary. | | `referenceHiddenOffsets` | `SideObject \| undefined` | Per-side overflow of the reference rect. | | `escaped` | `boolean \| undefined` | `true` when the floating element has fully left the boundary. | | `escapedOffsets` | `SideObject \| undefined` | Per-side overflow of the floating element. | --- ### `inline(options?)` ```ts inline(options?: InlineOptions): Middleware; ``` Improves positioning accuracy for inline references that wrap across line breaks (e.g. `` elements). Must be placed **first** in the pipeline — before `flip()`, `shift()`, and `autoPlacement()`. `compose()` enforces this at call time in development. Exported from the main entry `@vielzeug/orbit` alongside all other middleware. **Returns:** `Middleware` **Example:** ```ts import { float, flip, inline, shift } from '@vielzeug/orbit'; float(selectionRef, tooltip, { placement: 'top', middleware: [inline({ x: pointerX, y: pointerY }), flip(), shift({ padding: 6 })], }); ``` **Options — `InlineOptions`** | Option | Type | Description | | --------- | --------- | -------------------------------------------------------------------------------------------------------------------- | | `x` | `number` | Cursor x. When both `x` and `y` are provided, picks the client rect containing the cursor. | | `y` | `number` | Cursor y. | | `padding` | `Padding` | Hit-test tolerance around rect edges when using cursor coordinates. Has no effect without `x` and `y`. Default: `2`. | ## Presets — `@vielzeug/orbit/presets` Ready-made `{ placement, middleware }` objects for common patterns. Spread into `float()` or `computePosition()` options. ```ts import { dropdown, tooltip } from '@vielzeug/orbit/presets'; const handle = float(trigger, tooltip, tooltip()); // Customize: const handle2 = float(trigger, menu, { ...dropdown({ placement: 'top-start', offset: 4 }), autoUpdate: { throttle: 16 }, }); ``` **`presets.tooltip(options?)`** Stack: `offset(8) → flip({ padding }) → shift({ padding })` Default placement: `'top'` **`presets.dropdown(options?)`** Stack: `[offset] → flip({ padding }) → shift({ padding }) → size({ padding })` Default placement: `'bottom-start'` **`presets.popover(options?)`** Stack: `offset(12) → flip({ padding }) → shift({ padding })` Default placement: `'top'` **`presets.contextMenu(options?)`** Stack: `[offset] → flip({ padding }) → shift({ padding })` Default placement: `'bottom-start'` **`PresetOptions`** (all fields optional) | Option | Type | Description | | ----------- | ----------- | --------------------------------------------- | | `offset` | `number` | Gap in pixels between reference and floating. | | `padding` | `number` | Distance from boundary edges. | | `placement` | `Placement` | Override the default placement. | Both `PositioningPreset` and `PresetOptions` are also exported as types from the main entry point: ```ts import type { PositioningPreset, PresetOptions } from '@vielzeug/orbit'; ``` ## `compose(...middleware)` ```ts compose(...middleware: Array): Middleware[]; ``` Filters falsy entries and returns a plain `Middleware[]` for use in `computePosition()` or `float()`. Middleware ordering validation runs automatically inside `computePosition()` in development — `compose()` does not duplicate that check, it exists purely for its falsy-filter ergonomics when conditionally including middleware. **Example:** ```ts import { arrow, compose, flip, offset, shift, size } from '@vielzeug/orbit'; const middleware = compose(offset(8), flip(), shift({ padding: 6 }), size(), arrow({ element: arrowEl })); const handle = float(trigger, floating, { middleware }); ``` ## `shift` — `limitShift(options?)` ```ts limitShift(options?: LimitShiftOptions): ShiftLimiter; ``` Returns a `ShiftLimiter` for `shift()`'s `limiter` option. Constrains the cross-axis drift so the floating element stays visually connected to the reference (within its cross-axis extent). Without `limitShift`, `shift()` will push the float as far as necessary to keep it in the boundary — potentially sliding it far from the reference. `limitShift` caps the drift to `[refStart - offset, refEnd + offset - floatSize]`. **Example:** ```ts import { limitShift, shift } from '@vielzeug/orbit'; // Arrow stays within the reference's width shift({ padding: 6, limiter: limitShift() }); // Allow up to 10px of drift beyond the reference's edges shift({ padding: 6, limiter: limitShift({ offset: 10 }) }); ``` **Options — `LimitShiftOptions`** | Option | Type | Default | Description | | -------- | ---------------------------------------------- | ------- | ---------------------------------------------------------- | | `offset` | `number \| (state: MiddlewareState) => number` | `0` | Extra pixels of drift allowed past the reference's extent. | ## Reactive Adapter — `@vielzeug/orbit/reactive` ```ts import { createFloatState } from '@vielzeug/orbit/reactive'; ``` ### `createFloatState(reference, floating, options?)` ```ts createFloatState( reference: ReferenceElement, floating: HTMLElement, options?: Omit, ): ReactiveFloatHandle; ``` Like `float()`, but exposes a `@vielzeug/ripple` signal that updates on every position change. DOM styles are **not** automatically applied — consume `position` in a ripple `effect`. **Example:** ```ts import { effect } from '@vielzeug/ripple'; import { createFloatState } from '@vielzeug/orbit/reactive'; import { flip, offset, shift } from '@vielzeug/orbit'; const handle = createFloatState(trigger, tooltip, { placement: 'top', middleware: [offset(8), flip(), shift({ padding: 6 })], }); effect(() => { const pos = handle.position.value; if (!pos) return; tooltip.style.left = `${pos.x}px`; tooltip.style.top = `${pos.y}px`; }); // on teardown: handle.dispose(); ``` **Returns — `ReactiveFloatHandle`** | Field | Type | Description | | ------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------- | | `position` | `Readable` | Reactive signal. `null` before the first update. Read-only; position is managed internally. | | `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie external lifetimes. | | `dispose()` | `() => void` | Removes all listeners. Always call on teardown. Idempotent. | | `disposed` | `boolean` | `true` after `dispose()` has been called. | | `update()` | `() => void` | Manually trigger a position recalculation. | | `[Symbol.dispose]` | `() => void` | Delegates to `dispose()`. Enables `using` declarations. | ## Devtools — `@vielzeug/orbit/devtools` ```ts import { debugFloat } from '@vielzeug/orbit/devtools'; ``` ### `debugFloat(reference, floating, options?)` ```ts debugFloat(reference: ReferenceElement, floating: HTMLElement, options?: FloatOptions): FloatHandle; ``` Wraps `float()` and attaches a persistent visual debug overlay to `document.body`: a dashed outline for the viewport boundary, a dashed outline for the reference element's bounding rect, and a label showing the active placement string. The overlay updates on every position change and is automatically removed when `handle.dispose()` is called. Development use only — import from this dedicated sub-path so it is tree-shaken from production bundles. **Example:** ```ts import { debugFloat } from '@vielzeug/orbit/devtools'; import { flip, offset, shift } from '@vielzeug/orbit'; const handle = debugFloat(reference, tooltip, { placement: 'top', middleware: [offset(8), flip(), shift({ padding: 6 })], }); // on teardown: handle.dispose(); ``` **Returns:** `FloatHandle` — identical contract to `float()`. ## SSR Stubs — `@vielzeug/orbit/ssr` ```ts import { autoUpdate, computePosition, computePositionAsync, computePositionRaf, float } from '@vielzeug/orbit/ssr'; ``` No-op stubs for server-side rendering. All exports mirror the real API signatures but perform no DOM operations: - `computePosition` — returns `{ x: 0, y: 0, placement, middlewareData: {} }` - `computePositionAsync` — resolves immediately with `{ x: 0, y: 0, placement, middlewareData: {} }` - `computePositionRaf` — resolves immediately with `{ x: 0, y: 0, placement, middlewareData: {} }` - `autoUpdate` — returns a no-op cleanup; does **not** call `update` - `float` — returns a `FloatHandle` with no-op methods; `getPosition()` returns `null`; `disposed` is correctly tracked ```ts // vite.config.ts resolve: { alias: { '@vielzeug/orbit': process.env.SSR ? '@vielzeug/orbit/ssr' : '@vielzeug/orbit', }, } ``` ## Types ### `Placement` ```ts type Side = 'top' | 'bottom' | 'left' | 'right'; type Alignment = 'start' | 'end'; type Placement = Side | `${Side}-${Alignment}`; ``` ### `Padding` ```ts type Padding = number | Partial; ``` ### `Rect` ```ts interface Rect { x: number; y: number; width: number; height: number; } ``` ### `SideObject` ```ts interface SideObject { top: number; right: number; bottom: number; left: number; } ``` ### `ReferenceElement` ```ts interface VirtualReference { getBoundingClientRect: () => DOMRect | Rect; getClientRects?: () => DOMRectList | DOMRect[]; } type ReferenceElement = Element | VirtualReference; ``` `computePosition`, `float`, and `autoUpdate` all accept `ReferenceElement`. ### `Middleware` ```ts type Middleware = (state: MiddlewareState) => MiddlewareResult | void; ``` ### `MiddlewareState` ```ts interface MiddlewareState { x: number; y: number; initialPlacement: Placement; placement: Placement; rects: { reference: Rect; floating: Rect }; elements: { reference: ReferenceElement; floating: HTMLElement }; middlewareData: MiddlewareData; /** Global boundary inherited from ComputePositionOptions. Per-middleware boundary takes precedence. */ boundary?: Element | Rect; /** Global padding inherited from ComputePositionOptions. Per-middleware padding takes precedence. */ padding?: Padding; } ``` ### `MiddlewareResult` ```ts type MiddlewareReset = { placement?: Placement; rects?: { reference: Rect; floating: Rect }; remeasure?: boolean; }; interface MiddlewareResult { x?: number; y?: number; placement?: Placement; data?: MiddlewareData; reset?: MiddlewareReset; } ``` - `reset: {}` — restart the pipeline with the same rects and placement - `reset: { placement }` — restart with a new placement - `reset: { remeasure: true }` — re-read both rects from the DOM before restarting (takes precedence over `rects`) - `reset: { rects: { reference, floating } }` — restart with the provided rects directly ### `ComputePositionOptions` ```ts interface ComputePositionOptions { placement?: Placement; middleware?: Array; containingBlock?: Element | null; boundary?: Element | Rect; padding?: Padding; } ``` ### `ComputePositionResult` ```ts interface ComputePositionResult { x: number; y: number; placement: Placement; middlewareData: MiddlewareData; } ``` ### `DetectOverflowOptions` ```ts interface DetectOverflowOptions { boundary?: Element | Rect; padding?: Padding; } ``` ### `ArrowData` ```ts interface ArrowData { x?: number; y?: number; centerOffset: number; constrained: boolean; } ``` ### `FlipData` ```ts interface FlipData { /** All placements evaluated and overflowed before the winning placement was chosen. */ skippedPlacements: Placement[]; } ``` Written to `middlewareData.flip` only when `flip()` changes the placement. ### `ShiftData` ```ts interface ShiftData { /** Pixels shifted on the x axis. */ x: number; /** Pixels shifted on the y axis. */ y: number; } ``` Always written to `middlewareData.shift` (zero when no shift was needed). ### `FloatHandle` ```ts interface FloatHandle { readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; getPosition(): ComputePositionResult | null; update(): void; [Symbol.dispose](): void; } ``` ### `HideData` ```ts interface HideData { referenceHidden?: boolean; referenceHiddenOffsets?: SideObject; escaped?: boolean; escapedOffsets?: SideObject; } ``` ### `PositioningPreset` ```ts interface PositioningPreset { placement: Placement; middleware: Middleware[]; } ``` ### `SizeData` ```ts interface SizeData { availableWidth: number; availableHeight: number; } ``` Written to `middlewareData.size` by the `size()` middleware. ### `TypedMiddleware` ```ts type TypedMiddleware = Middleware & { readonly __brand: readonly [K, D]; }; ``` A branded `Middleware` subtype returned by built-in middleware factories (`flip`, `shift`, `size`, `arrow`, `hide`). The `__brand` field is never accessed at runtime — it is a compile-time marker identifying which `middlewareData` key the middleware writes, for custom middleware authors who want the same pattern via `tagMiddleware`-style branding. ### `MiddlewareData` ```ts interface MiddlewareData { arrow?: ArrowData; flip?: FlipData; hide?: HideData; shift?: ShiftData; size?: SizeData; [key: string]: unknown; // custom middleware data } ``` ### Usage Guide ## Basic Usage Use `float()` for the common case — it positions the floating element and keeps it in sync. It returns a `FloatHandle`; call `handle.dispose()` on teardown. ```ts import { float, flip, offset, shift } from '@vielzeug/orbit'; const trigger = document.querySelector('#trigger')!; const tooltip = document.querySelector('#tooltip')!; const handle = float(trigger, tooltip, { placement: 'top', middleware: [offset(8), flip(), shift({ padding: 6 })], }); // Call on teardown handle.dispose(); ``` ### `computePosition` Use `computePosition` when you want raw coordinates or need to consume `middlewareData` without automatic DOM updates. ```ts import { computePosition, flip, offset } from '@vielzeug/orbit'; const { x, y, placement, middlewareData } = computePosition(reference, floating, { placement: 'bottom-start', middleware: [offset(8), flip()], }); floating.style.left = `${x}px`; floating.style.top = `${y}px`; ``` ### `float` with Custom Apply Pass `apply` for custom rendering or to use CSS transforms instead of `left`/`top`. The callback receives the full `ComputePositionResult`; DOM references are available by closure. ```ts import { float, flip, offset, shift } from '@vielzeug/orbit'; const handle = float(reference, floating, { placement: 'bottom-start', middleware: [offset(8), flip(), shift({ padding: 6 })], apply(result) { floating.style.transform = `translate(${result.x}px, ${result.y}px)`; floating.dataset.placement = result.placement; }, }); // on teardown: handle.dispose(); ``` ### Presets `@vielzeug/orbit/presets` provides ready-made middleware stacks for common patterns. Spread into `float()` or `computePosition()`. ```ts import { float } from '@vielzeug/orbit'; import { dropdown, tooltip } from '@vielzeug/orbit/presets'; // One-liner for a tooltip: const handle = float(trigger, tooltip, tooltip()); // Customize a dropdown: const handle2 = float(trigger, menu, { ...dropdown({ placement: 'top-start', offset: 4 }), autoUpdate: { throttle: 16 }, }); ``` Available presets: `tooltip`, `dropdown`, `popover`, `contextMenu`. Each accepts optional `{ offset, padding, placement }`. ## Middleware Model Middleware are pure functions that receive the current state and return partial updates. Return `undefined` when making no change. ```ts import type { Middleware } from '@vielzeug/orbit'; const snap = (grid: number): Middleware => ({ x, y }) => ({ data: { snap: { grid } }, x: Math.round(x / grid) * grid, y: Math.round(y / grid) * grid, }); ``` Available return fields: - `x` and `y` — override the floating element's position - `placement` — change side or alignment for the current pass - `data` — append to `middlewareData` - `reset` — restart the cycle with fresh coordinates, a new placement, or re-measured rects ## Built-in Middleware ### `offset` Adds a gap along the main axis, cross axis, or both. ```ts offset(8); offset({ mainAxis: 8, crossAxis: 4 }); offset((state) => ({ mainAxis: state.placement.startsWith('top') ? 12 : 8 })); ``` Apply `offset` as the first middleware so that `flip` and `shift` account for the gap. ### `flip` Preserves the preferred placement until it overflows, then tries a fallback. ```ts middleware: [flip({ fallbackPlacements: ['right', 'left'] })]; ``` When no candidate fits, `flip` picks the placement with the least total overflow rather than leaving the element clipped. Do not combine `flip()` with `autoPlacement()`. ### `autoPlacement` Chooses the placement with the most usable space instead of preserving a preferred side. ```ts middleware: [autoPlacement({ allowedPlacements: ['top', 'bottom'] })]; ``` Do not combine `autoPlacement()` with `flip()`. ### `shift` Keeps the floating element inside the boundary by shifting along the cross axis. Optionally enable `mainAxis` shifting. ```ts middleware: [shift({ padding: { top: 8, bottom: 16, left: 6, right: 6 } })]; // Also shift on the main axis when flip is not in the pipeline: middleware: [shift({ mainAxis: true, padding: 8 })]; ``` ### `size` Reports available space so the floating element can be constrained. Read `middlewareData.size` in the `apply` callback or a subsequent `computePosition` call. ```ts const handle = float(ref, el, { middleware: [flip(), shift(), size()], apply(result) { const { size } = result.middlewareData; if (size) el.style.maxHeight = `${size.availableHeight}px`; el.style.left = `${result.x}px`; el.style.top = `${result.y}px`; }, }); // Or with computePosition: const { middlewareData } = computePosition(ref, el, { middleware: [flip(), size()] }); el.style.maxHeight = `${middlewareData.size!.availableHeight}px`; ``` ### `arrow` Produces coordinates for an arrow element. Place after `flip()` and `shift()` so the arrow reflects the final position. ```ts import type { ArrowData } from '@vielzeug/orbit'; const { middlewareData } = computePosition(reference, floating, { middleware: [offset(12), flip(), shift({ padding: 8 }), arrow({ element: arrowEl, padding: 6 })], }); const { x, y } = middlewareData.arrow as ArrowData; arrowEl.style.left = x != null ? `${x}px` : ''; arrowEl.style.top = y != null ? `${y}px` : ''; ``` ### `hide` Reports whether the reference is clipped or the floating element has escaped the boundary. ```ts import type { HideData } from '@vielzeug/orbit'; const { middlewareData } = computePosition(reference, floating, { middleware: [hide()], }); const { referenceHidden } = middlewareData.hide as HideData; floating.style.visibility = referenceHidden ? 'hidden' : 'visible'; ``` Use `strategy` to compute only what you need: ```ts hide({ strategy: 'referenceHidden' }); // only tracks reference hide({ strategy: 'escaped' }); // only tracks floating hide({ strategy: 'both' }); // default — both ``` ### `inline` Improves positioning for inline references spanning multiple lines. Place before `flip()`. ```ts import { inline } from '@vielzeug/orbit'; middleware: [inline({ x: event.clientX, y: event.clientY }), flip(), shift({ padding: 6 })]; ``` ## Middleware Order Recommended order for the most common full stack: ```ts middleware: [ offset(8), inline({ x: pointerX, y: pointerY }), // only for multi-line inline refs flip(), // or autoPlacement() — not both shift({ padding: 6 }), size(), arrow({ element: arrowEl, padding: 6 }), hide(), ]; ``` Rules: - `offset` first — ensures flip/shift account for the gap - `inline` before `flip` — corrects the reference rect before overflow detection - `flip` XOR `autoPlacement` — combining them has no effect and adds overhead - `arrow` after `flip`/`shift` — arrow is positioned against the final coordinates ### `compose()` for ordered validation `compose()` is a drop-in replacement for an inline array literal. It filters falsy entries and throws at call time if middleware are in a known-bad order. ```ts import { arrow, compose, flip, offset, shift, size } from '@vielzeug/orbit'; const middleware = compose(offset(8), flip(), shift({ padding: 6 }), size(), arrow({ element: arrowEl })); const handle = float(trigger, floating, { middleware }); ``` ## Virtual References Any object with `getBoundingClientRect()` works as a reference. Use virtual references for context menus and text selection anchors. ```ts import { computePosition, flip, shift } from '@vielzeug/orbit'; document.addEventListener('contextmenu', (e) => { e.preventDefault(); const { x, y } = computePosition( { getBoundingClientRect: () => DOMRect.fromRect({ x: e.clientX, y: e.clientY, width: 0, height: 0 }) }, menu, { middleware: [flip(), shift({ padding: 8 })] }, ); menu.style.left = `${x}px`; menu.style.top = `${y}px`; }); ``` Or use the preset, which sets the correct defaults: ```ts import { computePosition } from '@vielzeug/orbit'; import { contextMenu } from '@vielzeug/orbit/presets'; const { x, y } = computePosition(virtualRef, menu, contextMenu()); ``` ## `autoUpdate` `autoUpdate` is the lower-level primitive behind `float`. ```ts import { autoUpdate, computePosition, arrow, flip, offset, shift } from '@vielzeug/orbit'; const cleanup = autoUpdate( reference, floating, () => { const { x, y, placement, middlewareData } = computePosition(reference, floating, { middleware: [offset(8), flip(), shift({ padding: 6 }), arrow({ element: arrowEl })], }); floating.style.left = `${x}px`; floating.style.top = `${y}px`; floating.dataset.placement = placement; }, { animationFrame: false, throttle: 0 }, ); ``` Use `animationFrame: true` only when the reference itself animates between frames. Use `throttle: N` to rate-limit updates in busy scroll containers. ### `pauseWhenHidden` Set `pauseWhenHidden: true` (default) to suspend updates while the reference element is scrolled out of the viewport. Uses `IntersectionObserver` internally. A single update fires when the reference becomes visible again. ```ts const cleanup = autoUpdate(reference, floating, update, { pauseWhenHidden: true, // default }); ``` Pass `pauseWhenHidden: false` to keep updating unconditionally (e.g. for pinned headers that are always in view). ### `observeAncestors` By default (`observeAncestors: true`), Orbit attaches scroll listeners to ancestor scroll containers of the reference element in addition to `window`. This fires more reliably in nested scroll contexts. Pass `false` to use only a capture-phase window listener. ```ts const cleanup = autoUpdate(reference, floating, update, { observeAncestors: false, // single window capture listener }); ``` ## Global Boundary and Padding Pass `boundary` and `padding` on `computePosition()` or `float()` to set defaults for all overflow-aware middleware. Per-middleware options take precedence. ```ts import { flip, float, shift, size } from '@vielzeug/orbit'; const container = document.querySelector('#scroll-container')!; const handle = float(trigger, floating, { // All middleware will clip to #scroll-container instead of the viewport boundary: container, // 8px inset on all sides padding: 8, middleware: [flip(), shift(), size()], }); ``` ## Containing Block For floating elements with `position: absolute`, provide `containingBlock` (the `offsetParent`) so Orbit subtracts its offset and returns coordinates relative to the containing block. ```ts const handle = float(trigger, floating, { containingBlock: floating.offsetParent as Element, placement: 'bottom', middleware: [flip(), shift()], }); ``` Without `containingBlock`, coordinates are viewport-relative (correct for `position: fixed`). ## CSS Anchor Positioning Use `floatWithAnchor()` to let the browser handle repositioning natively — no JS loop, no event listeners. ```ts import { floatWithAnchor, isCssAnchorSupported } from '@vielzeug/orbit'; if (isCssAnchorSupported()) { const handle = floatWithAnchor(trigger, tooltip, { placement: 'top' }); // handle.dispose() on teardown } else { // fall back to float() } ``` Requirements and fallback behaviour: - Falls back to JS positioning when the browser does not support CSS Anchor Positioning - Use `float()` instead when you need middleware or a custom `apply` callback - `position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline` is applied automatically - Check `isCssAnchorSupported()` before calling `floatWithAnchor()` in production ## Reactive Adapter Import from `@vielzeug/orbit/reactive` to get a `@vielzeug/ripple` signal that updates on every position change. DOM styles are **not** automatically applied — use a ripple `effect` to consume `position` and write to the DOM. ```ts import { effect } from '@vielzeug/ripple'; import { createFloatState } from '@vielzeug/orbit/reactive'; import { flip, offset, shift } from '@vielzeug/orbit'; const handle = createFloatState(trigger, tooltip, { placement: 'top', middleware: [offset(8), flip(), shift({ padding: 6 })], }); effect(() => { const pos = handle.position.value; if (!pos) return; tooltip.style.left = `${pos.x}px`; tooltip.style.top = `${pos.y}px`; }); // on teardown: handle.dispose(); ``` `createFloatState` accepts all `FloatOptions` except `apply` (which is used internally to update the signal). ## One-shot Async Positioning Use `computePositionAsync()` when you need a single position result inside an async function, such as after `await nextTick()` in Vue or after React's `useLayoutEffect` has flushed. ```ts import { computePositionAsync } from '@vielzeug/orbit'; // Inside an async lifecycle (e.g. Vue onMounted with async) const result = await computePositionAsync(reference, floating, { placement: 'top', }); floating.style.left = `${result.x}px`; floating.style.top = `${result.y}px`; ``` `computePositionAsync` defers to the microtask queue. If you need coordinates after the next paint (e.g. after CSS transitions), use `requestAnimationFrame` around `computePosition` directly. ## SSR For server-side rendering, import from `@vielzeug/orbit/ssr` instead of the main entry. All five exports (`computePosition`, `computePositionAsync`, `computePositionRaf`, `autoUpdate`, `float`) are no-ops that return zero-coordinate results and safe cleanup functions. ```ts // vite.config.ts resolve: { alias: { '@vielzeug/orbit': process.env.SSR ? '@vielzeug/orbit/ssr' : '@vielzeug/orbit', }, } ``` Or import directly when you know you are in an SSR context: ```ts import { computePosition } from '@vielzeug/orbit/ssr'; // Returns { x: 0, y: 0, placement: 'bottom', middlewareData: {} } const result = computePosition(reference, floating, { placement: 'bottom' }); ``` ## Framework Integration ```tsx [React] import { useEffect, useRef } from 'react'; import { float, offset, flip, shift } from '@vielzeug/orbit'; function Tooltip({ anchor, children }: { anchor: HTMLElement | null; children: React.ReactNode }) { const tooltipRef = useRef(null); useEffect(() => { if (!anchor || !tooltipRef.current) return; const handle = float(anchor, tooltipRef.current, { placement: 'bottom', middleware: [offset(6), flip(), shift({ padding: 8 })], }); return () => handle.dispose(); }, [anchor]); return ( {children} ); } ``` ```ts [Vue 3] import { watchEffect } from 'vue'; import { float, offset, flip, shift } from '@vielzeug/orbit'; function useFloat(referenceRef: { value: HTMLElement | null }, floatingRef: { value: HTMLElement | null }) { watchEffect((onCleanup) => { const reference = referenceRef.value; const floating = floatingRef.value; if (!reference || !floating) return; const handle = float(reference, floating, { placement: 'bottom', middleware: [offset(6), flip(), shift({ padding: 8 })], }); onCleanup(() => handle.dispose()); }); } ``` ```svelte [Svelte] import { onMount } from 'svelte'; import { float, offset, flip, shift } from '@vielzeug/orbit'; export let anchor: HTMLElement; let tooltipEl: HTMLDivElement; onMount(() => { const handle = float(anchor, tooltipEl, { placement: 'bottom', middleware: [offset(6), flip(), shift({ padding: 8 })], }); return () => handle.dispose(); }); ``` ### Pitfalls - **React:** `float()` must run after the tooltip is in the DOM. Use a `useEffect` dependency on `open` state, not just on `anchor`. - **Vue 3:** When using `v-if`, `ref.value` is `null` until the next tick. `watchEffect` re-runs automatically when the ref populates. - **Svelte:** `{#if}` defers `bind:this` to the next microtask. `onMount` runs after the DOM is ready, which is the correct place. ## Working with Other Vielzeug Libraries ### With Ore Use Orbit inside a Ore component to position tooltips and popovers reactively. ```ts import { define, html } from '@vielzeug/ore'; import { flip, float, offset, shift } from '@vielzeug/orbit'; define('x-tooltip', { setup(_props, { el, onMounted }) { onMounted(() => { const tooltipEl = el.querySelector('[role=tooltip]')!; const handle = float(el, tooltipEl, { placement: 'bottom', middleware: [offset(6), flip(), shift({ padding: 8 })], }); // Returned from onMounted — Ore calls this on disconnect return () => handle.dispose(); }); return html``; }, }); ``` ## Best Practices - Use `float()` for the common tooltip/popover case; use `computePosition()` when you need raw coordinates or custom rendering. - Always call `handle.dispose()` when the floating element is removed from the DOM. - Use either `flip()` or `autoPlacement()` — not both. - Apply `offset()` before `flip()` or `autoPlacement()` so overflow detection accounts for the gap. - Use `shift({ padding })` to keep the floating element away from viewport edges. - Use `compose()` in development to catch middleware order bugs at call time. - Use virtual references for context menus and cursor-anchored popovers. - Set `animationFrame: true` only when the reference itself animates between frames. - Use preset factories for common patterns to avoid repeating the same middleware stacks across your codebase. ### Examples ## Examples - [Tooltip](./examples/tooltip.md) - [Dropdown Select](./examples/dropdown-select.md) - [Context Menu](./examples/context-menu.md) - [Popover With Arrow](./examples/popover-with-arrow.md) - [Using Presets](./examples/using-presets.md) - [Custom Middleware](./examples/custom-middleware.md) - [With Ore Component](./examples/with-ore-component.md) - [Reactive Adapter](./examples/reactive-adapter.md) - [SSR Setup](./examples/ssr-setup.md) ### REPL Examples - autoUpdate - Track on Scroll/Resize (id: `auto-update`) - getRects - Raw DOM Measurements (id: `get-rects`) - inline - Multi-line Inline Reference (id: `inline-middleware`) - computePosition - Basic (id: `position-basic`) - float - With Middleware (id: `position-float`) - presets - Ready-made Middleware Stacks (id: `presets`) - size() - Constrain Height (id: `size-middleware`) --- ## @vielzeug/ore **Category:** ui-primitives **Keywords:** web-components, custom-elements, reactive, templates, signals, lifecycle **Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, useField, createFormContext, FORM_CONTEXT_KEY, createId (+5 more) **Related:** ripple, refine, orbit ### Overview ## Why Ore? Ore keeps custom elements functional and signal-driven while giving you direct control over templates, lifecycle hooks, host bindings, and form-associated behavior. ```ts // Before — vanilla custom element boilerplate class MyCounter extends HTMLElement { #count = 0; connectedCallback() { this.attachShadow({ mode: 'open' }); this.#render(); } #render() { this.shadowRoot!.innerHTML = `${this.#count}`; this.shadowRoot!.querySelector('button')!.onclick = () => { this.#count++; this.#render(); }; } } customElements.define('my-counter', MyCounter); // After — Ore import { signal } from '@vielzeug/ripple'; import { define, html } from '@vielzeug/ore'; define('my-counter', { setup() { const count = signal(0); return html` count.value++}>${count}`; }, }); ``` | Feature | Ore | Lit | Stencil | | -------------------------- | ------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------ | | Bundle size | | ~12 kB | ~60 kB+ toolchain | | Signal-first runtime | | (separate signals package) | | | Functional component setup | | Partial | | | Typed prop helpers | | Partial | | | Host binding helpers | | Partial | Partial | | Form-associated helpers | | Manual | Partial | | Zero dependencies | | | | **Use Ore when** you want typed, signal-driven custom elements with minimal runtime overhead and no framework lock-in. **Consider Lit when** you need a mature ecosystem with wide community adoption and don't need signal-based reactivity. ## Installation ```sh [pnpm] pnpm add @vielzeug/ore ``` ```sh [npm] npm install @vielzeug/ore ``` ```sh [yarn] yarn add @vielzeug/ore ``` ## Quick Start ```ts import { computed, signal } from '@vielzeug/ripple'; import { css, define, html, prop } from '@vielzeug/ore'; define('my-counter', { props: { label: prop.string('Count'), step: prop.number(1), }, styles: [ css` :host { display: inline-grid; gap: 0.5rem; } `, ], setup(props, { bind, onMounted }) { const count = signal(0); const doubled = computed(() => count.value * 2); bind({ class: { 'is-positive': () => count.value > 0 } }); onMounted(() => console.log('mounted')); return html` (count.value += props.step.value)}>${props.label}: ${count} Doubled: ${doubled} `; }, }); ``` ## Features - Signal-first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly - Functional component authoring via `define(tag, { props, setup, styles, formAssociated })` - Props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneOf`, `prop.json`, `prop.data`) or raw `PropDef` objects - Setup returns an `HTMLResult` directly: `return html\`...\`` - Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `effect` — accessed through the setup context bag (`ctx`) - Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `model`, `raw` - Host bindings via `ctx.bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off-host element - Reactive ARIA sync via `ctx.aria(target, config)` — applies `aria-*` attributes reactively to any element, auto-cleanup on disconnect - Form-associated helpers: `useField()`, `createFormContext()` — first-class public APIs for form-aware components - Observers (`@vielzeug/ore/observers`) - Testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderHook`, `fire`, `user`, `waitFor`, `cleanup` - Debug utilities (`@vielzeug/ore/devtools`) — `debugFlush()` for diagnosing update timing ## Package Entry Points | Import | Purpose | | --------------------------- | ----------------------------------------------------------------------------- | | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context, forms) | | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics (dev only) | | `@vielzeug/ore/directives` | `each`, `when`, `model`, `live`, `raw`, `classMap`, `styleMap` | | `@vielzeug/ore/observers` | `resizeObserver`, `intersectionObserver`, `mediaObserver`, `mutationObserver` | | `@vielzeug/ore/testing` | `mount`, `fire`, `user`, `waitFor`, `cleanup`, and helpers | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Refine](../refine/index.md) for prebuilt accessible components powered by Ore. - [Ripple](../ripple/index.md) for reactive state used inside Ore components. - [Forge](../forge/index.md) for typed form state that integrates with Ore. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------------- | ---------------------------------------------------- | -------------- | ---------------------------------------------------------------------- | | `define()` | Register a custom element with reactive setup | Sync | Tag must contain a hyphen; call before first use | | `html` | Tagged template literal returning HTMLResult | Sync | Expressions must be signals, functions, or primitives | | `prop.*` | Typed prop helpers (string, bool, number, …) | Sync | Prop values are signals — read `.value` | | `ctx.provide/inject` | Context API for parent-to-descendant sharing | Setup only | Must be called synchronously during `setup()` | | `ref()` | Reactive reference to a DOM element | Sync | Value is null until after first mount | | `createContext()` | Create a typed injection key | Sync | Context is scoped to the component tree | | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys warn in dev; plain `T[]` treated as one-time static render | | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription | | `model(signal)` | Two-way binding for input/select/textarea | Sync | `` uses `Signal`; `select` uses `change` | | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler | | `ctx.onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` | | `ctx.onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect | | `ctx.onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect | | `useField(options)` | Wire signal to form `ElementInternals` | Setup only | Requires `formAssociated: true` on the component definition | | `ctx.aria(target, config)` | Reactively sync ARIA attributes to any element | Setup only | Static values applied once; getter functions tracked as effects; auto-cleanup on disconnect | ## Package Entry Points | Import | Purpose | | ---------------------------- | ------------------------------------------------------------------ | | `@vielzeug/ore` | Core authoring/runtime API | | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics | | `@vielzeug/ore/directives` | Standalone directive imports (`each`, `when`, `classMap`, …) | | `@vielzeug/ore/observers` | Resize, intersection, mutation, and media observers | | `@vielzeug/ore/testing` | DOM-oriented test helpers | ## Core Component API ### `define(tag, definition)` ```ts define(tag: string, definition: ComponentDefinition): void; ``` The `setup()` function receives typed prop signals and a context bag: ```ts type SetupContextBag = { aria: (target: Element, config: AriaConfig) => () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect bind: HostBindFn; // Apply reactive bindings to the host or any target element el: HTMLElement; // The host element emit: EmitFn; // Dispatch typed custom events inject: (key: InjectionKey, fallback?: T) => T | undefined; // Resolve context from nearest ancestor onCleanup: (fn: CleanupFn) => void; // Register teardown; called on disconnect onElement: (ref, cb) => void; // Run callback when a ref resolves to an element onEvent: (target, event, listener, options?) => void; // Scoped event listener; auto-removed on disconnect onMounted: (fn: OnMountedCallback) => void; // DOM-ready callback provide: (key: InjectionKey, value: T) => void; // Register context on the host element slots: ComponentSlots; // Reactive slot signals watch: (fn: EffectCallback) => () => void; // Scoped reactive effect; auto-cleaned on disconnect }; ``` Lifecycle hooks (`onMounted`, `onCleanup`, `onEvent`, `onElement`, `watch`) are accessed exclusively through the setup context bag. They must be called synchronously during `setup()`. `setup()` returns an `HTMLResult` directly (not a function): ```ts setup(props, ctx) { return html`${props.label}`; } ``` ### ComponentDefinition ```ts type ComponentDefinition = { formAssociated?: boolean; loading?: () => HTMLResult; // Template shown while async setup is pending onError?: (error: OreLifecycleError, element: HTMLElement) => HTMLResult | void; props?: PropsDef; setup: ( props: InferProps>, ctx: SetupContextBag, ) => HTMLResult | Promise; shadow?: Partial | false; // false = light DOM (no shadow root) styles?: (string | CSSStyleSheet | CSSResult)[]; }; ``` Pass `SlotNames` as a type parameter to `define()` to get typed `ctx.slots` access: ```ts define, Record, 'header' | 'footer'>('my-card', { setup(_props, { slots }) { const hasHeader = slots.has('header'); // typed ✓ return html`...`; }, }); ``` #### Async setup When `setup()` returns a `Promise`, `loading()` is rendered immediately. The real template replaces it once the promise resolves. ```ts define('user-profile', { props: { userId: prop.string('') }, loading: () => html`Loading…`, onError: (_err, el) => html`Failed to load ${el.getAttribute('user-id')}`, async setup(props) { const user = await fetchUser(props.userId.value); return html`${user.name}`; }, }); ``` ## Runtime Helpers `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watch` are available on the setup context bag. Destructure them from the second argument to `setup()`. ```ts setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) { onMounted(() => { // DOM is ready; return a function for mount-scoped cleanup return () => { /* cleanup on unmount */ }; }); onCleanup(() => { /* called on disconnect */ }); onEvent(window, 'keydown', (e) => { /* auto-removed on disconnect */ }); return html`...`; } ``` When writing composable helpers called from inside `setup()`, thread the hooks explicitly via function parameters rather than relying on a shared context: ```ts type MyHelperOptions = { onCleanup: (fn: () => void) => void; }; function useMyHelper(options: MyHelperOptions) { options.onCleanup(() => { /* teardown */ }); } // In setup: setup(_props, { onCleanup }) { useMyHelper({ onCleanup }); return html`...`; } ``` ## Props API | Helper | Signature | Notes | | ----------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------ | | `prop.string(defaultValue?)` | `PropDef` | Reflects by default | | `prop.bool(defaultValue?)` | `PropDef` | Any non-null attribute value other than `"false"` parses as `true`; `"false"` or absent attribute is `false` | | `prop.number(defaultValue?)` | `PropDef` | Returns default (not NaN) and warns in dev when attribute is not a valid number | | `prop.oneOf(allowed, defaultValue)` | `PropDef` | Restricts to provided string union | | `prop.json(defaultValue)` | `PropDef` | JSON.parse from attribute; `reflect: false` | | `prop.data(defaultValue?)` | `PropDef` | JS-only — never reads/writes an attribute; use for objects, arrays, callbacks, or any non-serialisable value | > **Choosing the right prop helper:** > > - **`prop.json`** — value can be declared in HTML (``); attribute string is `JSON.parse`d. > - **`prop.data`** — value is always set from JavaScript (objects, arrays, callbacks, class instances); the attribute is never read. Use this for both data and function props. When you need custom parsing or `reflect: false`, use a raw `PropDef` object: ```ts props: { items: { default: [], parse: () => [], reflect: false }, } ``` Use `prop.data` for props that hold JS-only values (including callbacks) that cannot be serialised through an HTML attribute: ```ts define('data-grid', { props: { getRowKey: prop.data string>(), columns: prop.data([]), onSort: prop.data void>(), }, setup(props) { // Set from JS: grid.getRowKey = (row) => row.id return html`...`; }, }); ``` ## Template and Directives ### `html` Tagged template literal that returns an `HTMLResult`. Supports text interpolation, attributes (`:attr`), boolean attributes (`?attr`), events (`@event`), refs (`ref=`), and nested templates. ### `css` Tagged template literal that returns a `CSSResult` for use in `styles`. ### Directives | Directive | Purpose | | -------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `each(source, key, render, fallback?)` | Keyed reactive list; render receives `Readable` and `Readable`; plain `T[]` is a one-time static snapshot | | `when(condition, truthy, falsy?)` | Conditional rendering | | `classMap(record)` | Reactive class string from object map | | `styleMap(record)` | Reactive inline style string from object map | | `live(signal)` | One-way binding that skips stale writes during active user input; use with `@input` handler | | `model(signal)` | Two-way value binding for `input`, `select`, `textarea`; `` uses `Signal`; `select` uses `change` event | | `raw(value)` | Trusted HTML rendering (XSS risk without sanitizer) | ### Event Modifiers Event bindings support dot-separated modifiers: `@click.prevent.stop=${handler}` | Modifier | Effect | | --------- | ---------------------------- | | `prevent` | Calls `e.preventDefault()` | | `stop` | Calls `e.stopPropagation()` | | `self` | Only fires if target matches | | `capture` | Uses capture phase | | `once` | Fires once then removes | | `passive` | Sets passive listener option | ## Host Bindings The setup context provides `bind: HostBindFn`: ```ts bind({ attr: { role: 'button', 'aria-expanded': () => String(open.value) }, class: { 'is-open': open }, style: { '--height': () => height.value + 'px' }, on: { click: handleClick }, }); ``` `bind()` auto-registers cleanup with the component scope — no manual `onCleanup` needed. Returns a cleanup function for early teardown. ### Off-host bindings Pass `{ target: el }` as a second argument to bind to any element other than the host: ```ts bind( { attr: { 'aria-expanded': () => String(isOpen.value) } }, { target: triggerEl }, ); ``` Event listener options (`once`, `capture`, `passive`) are also accepted in the second argument. Cleanup is auto-registered with the component scope when called during setup. ### ctx.aria() For reactive ARIA attribute syncing, use `ctx.aria(target, config)`. Shorthand keys are normalised to `aria-*` automatically (`expanded` → `aria-expanded`; `role` is passed verbatim): ```ts // Inside setup — cleanup auto-registered aria(triggerEl, { expanded: () => isOpen.value, controls: panelId, haspopup: 'listbox', }); // Manage cleanup manually — aria() always returns a cleanup fn const stopAria = aria(triggerEl, { expanded: () => isOpen.value }); // Call stopAria() when the trigger is swapped out ``` Static values (strings, numbers, booleans) are applied once. Getter functions and signals create reactive effects. Setting a value to `null`, `undefined`, or `false` removes the attribute. ## Slots - `slots.has(name?)` — `Readable` — whether the named (or default) slot has assigned content - `slots.elements(name?)` — `Readable` — the assigned elements for the slot Slot signals update reactively when assigned content changes, including when slots are inserted dynamically (via `when()` or `each()`) after mount. ## Context API - `createContext(description?)` — Create a typed injection key - `ctx.provide(key, value)` — Provide a value to descendants; called via the setup context bag - `inject(key)` — Resolve from nearest ancestor; returns `undefined` if not found - `inject(key, fallback)` — Resolve with a fallback value - `injectStrict(key)` — Resolve or throw if absent `ctx.provide()` and `inject()` must be called synchronously during `setup()`. Calling them outside a setup context throws `'Lifecycle hooks must be called synchronously during component setup'`. Context resolution walks the ancestor chain including shadow DOM boundaries. ## Utilities - `ref()` — Create a `Signal` element reference. Set to the element via `ref=` in templates. - `createId(prefix = 'id')` — Generate a unique incremental string ID (e.g. `'id-1'`, `'id-2'`). Each call returns a new ID — it does not deduplicate by prefix. - `createStableId(prefix = 'id')` — Generate a unique ID that also embeds a short random tag shared across all IDs generated in the session (e.g. `'field-a3k21'`), reducing collision risk when multiple app instances run on the same page. Like `createId()`, every call returns a new ID. - `resetIdCounter()` — Reset the `createStableId()` counter to 0. Call in test `beforeEach` for deterministic IDs. ## Form-Associated API ### `useField(options)` Wire a form-associated element to `ElementInternals`. Requires `formAssociated: true` on the component definition. The `disabled` state tracking via `internals.states` (CustomStateSet) is skipped with a dev warning if the API is unavailable in the current environment. ```ts type FormFieldOptions = { disabled?: Readable; /** * When true, a null/undefined value is submitted as '' instead of null, * keeping the field's key present in FormData even when the value is absent. * Only applies to the default toFormValue; ignored if toFormValue is provided. * @default false */ emptyStringForNull?: boolean; toFormValue?: (value: T) => File | FormData | string | null; value: Signal | Readable; }; type FormFieldHandle = { checkValidity(): boolean; readonly internals: ElementInternals; reportValidity(): boolean; setCustomValidity(message: string): void; setValidity: ElementInternals['setValidity']; }; ``` ### Form Context Coordinate form state across child field components: - `createFormContext(options?)` — Create a `FormController`; call `ctx.provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants - `FORM_CONTEXT_KEY` — the `InjectionKey` used to provide/inject the form context ```ts type FormController = { clearStatus(): void; // Clears dirty + error signals; calls onReset callback readonly dirty: Readable; readonly error: Readable; // Last submit error; null if last submit succeeded markDirty(): void; // Call from input/change handlers registerField(validity: Readable): () => void; submit(e?: Event): Promise; // Resets dirty to false on success; preserves dirty on failure readonly submitting: Readable; readonly valid: Readable; // true when all registered fields are valid }; ``` ## Observer APIs Import from `@vielzeug/ore/observers`. - `resizeObserver(element)` — Returns `Readable`, initialised to `{ height: 0, width: 0 }` - `intersectionObserver(element, options?)` — Returns `Readable`, initialised to `null` - `mutationObserver(element, options?)` — Returns `Readable`, initialised to `{ entries: [], latest: null }` - `mediaObserver(query)` — Returns `Readable`, initialised to the query's current `matches` state ## Testing APIs Import from `@vielzeug/ore/testing`. | API | Purpose | | ------------------------ | ------------------------------------------------------------------------------------------ | | `mount(setup, options?)` | Mount a component and return a test fixture | | `cleanup()` | Remove all mounted elements and reset test state | | `install(afterEach)` | Register auto-cleanup; pass `afterEach` from your test framework | | `flush(options?)` | Drain reactive updates and animation frames | | `FLUSH_DEEP` | Pre-built options for deep async chains (`maxTurns: 12`) | | `mock(tag, template?)` | Register a no-op stub custom element | | `renderHook(setup)` | Run lifecycle hooks in isolation; overload accepts `propDefs` as first arg for typed props | | `fire.*` | Synchronous DOM event dispatchers | | `user.*` | Async user interactions (type, fill, click, press, …) | | `waitFor(fn, options?)` | Poll until an assertion passes or a condition is truthy | | `waitForEvent(el, name)` | Resolve when the target element emits the named event | | `within(element)` | Scoped query helpers (`query`, `queryAll`, …) | > **Test isolation:** `cleanup()` resets mounted elements, `live()` signal tracking, and the raw HTML sanitizer. Call it in `afterEach` to prevent state leaking between tests. #### `Fixture` interface ```ts interface Fixture { [Symbol.dispose](): void; // Delegates to dispose() — enables `using` declarations element: T; readonly disposed: boolean; // true after dispose() has been called readonly shadow: ShadowRoot | null; query(selector: string): E | null; queryAll(selector: string): E[]; queryByText(text: string, selector?: string): E | null; queryAllByText(text: string, selector?: string): E[]; queryByTestId(testId: string): E | null; queryAllByTestId(testId: string): E[]; attr(name: string, value: string | number | boolean): Promise; attrs(record: Record): Promise; flush(options?: FlushOptions): Promise; act(fn: () => unknown): Promise; dispose(): void; // Removes the component from the DOM — idempotent } ``` #### `renderHook` Useful for testing composable lifecycle hooks (`onMounted`, `effect`, `inject`, etc.) without a template: ```ts // Without props const { result, flush, dispose } = await renderHook((_props, { onMounted }) => { const count = signal(0); onMounted(() => { count.value = 1; }); return count; }); expect(result.value).toBe(1); // With typed props (prop-defs overload) const { result } = await renderHook({ label: prop.string('hello'), count: prop.number(0) }, (props) => props.label); expect(result.value).toBe('hello'); ``` ## Ripple Primitives Ore does **not** re-export reactive primitives. Import them directly from `@vielzeug/ripple`: ```ts import { batch, computed, signal, watch } from '@vielzeug/ripple'; ``` See the [Ripple documentation](/ripple/) for the full API. ## Lifecycle Events | Event | When | | ------------------ | ------------------------------------------------------------- | | `ore:connect` | After every `connectedCallback` (including reconnects) | | `ore:disconnect` | After `disconnectedCallback`, before component state is reset | | `ore:error` | When setup throws — bubbles, composed; detail is `OreLifecycleError` | ## Types ```ts type PropDef = { default: T; parse: (value: string | null) => T; reflect?: boolean; }; /** * Infer reactive props type from a PropInputDefs map. * Each entry becomes Readable keyed by prop name. */ type InferProps = { readonly [K in keyof D]-?: Readable>; }; type SetupContextBag = Record, SlotNames extends string = string, > = { aria: (target: Element, config: AriaConfig) => () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect bind: (config: HostBindConfig, options?: BindOptions) => () => void; // Bindings for host or any target element el: HTMLElement; // The host element emit: EmitFn; // Dispatch typed custom events inject: { (key: InjectionKey): T | undefined; (key: InjectionKey, fallback: T): T; }; onCleanup: (fn: CleanupFn) => void; // Register teardown; called on disconnect onElement: (ref: Readable, cb: (el: T) => CleanupFn | void) => () => void; onEvent: { ( target: EventTarget | null | undefined, event: K, listener: (e: HTMLElementEventMap[K]) => void, options?: AddEventListenerOptions, ): void; ( target: EventTarget | null | undefined, event: string, listener: EventListener, options?: AddEventListenerOptions, ): void; }; onMounted: (fn: OnMountedCallback) => void; // DOM-ready callback; runs after first render provide: (key: InjectionKey, value: T) => void; // Register a context value on the host element slots: ComponentSlots; // Reactive slot signals watch: (fn: EffectCallback) => () => void; // Scoped reactive effect; auto-cleaned on disconnect }; type ComponentDefinition = { formAssociated?: boolean; loading?: () => HTMLResult; // Shown while async setup is pending onError?: (error: OreLifecycleError, el: HTMLElement) => HTMLResult | void; props?: PropsDef; setup: ( props: InferProps>, ctx: SetupContextBag, ) => HTMLResult | Promise; shadow?: Partial | false; // false = light DOM styles?: (string | CSSStyleSheet | CSSResult)[]; }; type HostBindConfig = { attr?: Record; class?: (() => Record) | Record boolean) | Readable>; on?: Record void>; style?: Record; }; type ComponentSlots = { elements(name?: S): Readable; has(name?: S): Readable; }; type Ref = Signal; type RefCallback = (el: T | null) => void; type InjectionKey = symbol & { readonly __ore_injection_key?: T }; /** Phase in which a OreError occurred. */ type OreErrorPhase = 'async-setup' | 'mounted' | 'setup'; ``` ## Errors `OreError` is the base class for every error `ore` throws — `err instanceof OreError` catches all of them. `OreError.is(err)` is the equivalent static type-guard. - **`OreApiError`** — thrown when the `ore` API itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onMounted`, `onCleanup`, `onEvent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`. - **`OreLifecycleError`** — thrown by the runtime when a component's `setup()` throws or its async `setup()` promise rejects. Extends `OreError` with: - `component: string` — the element's local name - `phase: OreErrorPhase` — `'setup'` | `'async-setup'` | `'mounted'` - `cause: Error` — the original error thrown by `setup()` - **`OreTimeoutError`** — thrown by `waitFor()`/`waitForEvent()` (from `@vielzeug/ore/testing`) when the timeout elapses before the condition/event is met. If `onError(error, element)` is defined on the component definition and returns an `HTMLResult`, it replaces the failed template instead of throwing. `error` here is always an `OreLifecycleError`. This applies to both synchronous and async `setup()`. If `onError` returns `void` (no recovery), a subsequent reconnect can retry setup. ### Usage Guide ## Basic Usage `define(tag, definition)` registers a custom element. Your `setup()` function receives typed prop signals and a context bag, then returns an `HTMLResult` directly. ```ts import { signal } from '@vielzeug/ripple'; import { define, html } from '@vielzeug/ore'; define('status-chip', { setup() { const online = signal(true); return html` (online.value = !online.value)}>${() => (online.value ? 'Online' : 'Offline')} `; }, }); ``` The setup context bag provides `el`, `aria`, `bind`, `emit`, `slots`, `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watch`: ```ts define('my-widget', { setup(_props, { el, bind, emit, slots }) { // el — the host HTMLElement // bind — host binding helper (attr, class, style, on) // emit — typed event emitter // slots — reactive slot observation return html``; }, }); ``` ## signals and effects Ore does not re-export ripple primitives — import them directly from `@vielzeug/ripple`. ```ts import { batch, computed, effect, signal, watch } from '@vielzeug/ripple'; const count = signal(0); const doubled = computed(() => count.value * 2); effect(() => { console.log('doubled =', doubled.value); }); watch(count, (next, prev) => { console.log('count changed', prev, '->', next); }); batch(() => { count.value = 1; count.value = 2; }); ``` ## onMounted and lifecycle Use `ctx.onMounted()` for DOM-dependent initialization that must run after the template is mounted. Use `ctx.onElement(ref, cb)` for work tied to a specific DOM node. `ctx.onEvent()` attaches a listener that is automatically removed on disconnect. ```ts import { signal } from '@vielzeug/ripple'; import { define, html, ref } from '@vielzeug/ore'; define('deferred-init', { setup(_props, { slots, onMounted, onElement, onEvent }) { const tabIndex = signal(0); const inputRef = ref(); onMounted(() => { const items = slots.elements('items').value; console.log('Found', items.length, 'items'); }); onElement(inputRef, (input) => { input.focus(); }); onEvent(window, 'keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') tabIndex.value = 0; }); return html``; }, }); ``` ## prop definitions Use `prop.*` helpers for common cases, or raw `PropDef` objects for custom parsing or `reflect: false`. ```ts import { define, html, prop } from '@vielzeug/ore'; define('x-button', { props: { label: prop.string('Button'), disabled: prop.bool(false), variant: prop.oneOf(['primary', 'secondary'] as const, 'primary'), count: prop.number(0), }, setup(props) { return html` ${props.label} (${props.count}) `; }, }); ``` ## template bindings `html` supports text, attributes, booleans, properties, events, refs, and nested templates. ```ts import { computed, signal } from '@vielzeug/ripple'; import { define, html, ref } from '@vielzeug/ore'; define('profile-name', { setup() { const name = signal('Alice'); const inputRef = ref(); return html` 'Current: ' + name.value)}>Name 'Current name ' + name.value} @input=${(event: Event) => { name.value = (event.target as HTMLInputElement).value; }} /> Hello ${name} `; }, }); ``` ## directives Ore includes `each`, `classMap`, `styleMap`, `when`, `live`, and `raw` — import them from `@vielzeug/ore/directives`. ```ts import { signal } from '@vielzeug/ripple'; import { classMap, each, styleMap, when } from '@vielzeug/ore/directives'; import { define, html } from '@vielzeug/ore'; define('task-list', { setup() { const tasks = signal([{ id: 1, text: 'Write tests' }]); const active = signal(true); return html` tasks.value.length > 0 })}" :style=${styleMap({ opacity: () => (active.value ? 1 : 0.5) })}> ${when( () => active.value, () => html`Active`, () => html`Paused`, )} ${each( tasks, (task) => task.id, (task) => html`${() => task.value.text}`, )} `; }, }); ``` ### each() API `each(source, key, render, fallback?)` takes positional arguments: - **source** — signal, getter, or plain array - **key** — function returning a unique key per item - **render** — receives reactive `item` and `index` signals - **fallback** — optional, rendered when the list is empty ```ts each( items, (item) => item.id, (item, index) => html`#${index}: ${() => item.value.label}`, () => html`No items`, ); ``` ## live form bindings Use `live(signal)` for inputs that should preserve in-progress user edits instead of overwriting the DOM on stale writes. ```ts import { signal } from '@vielzeug/ripple'; import { live } from '@vielzeug/ore/directives'; import { define, html } from '@vielzeug/ore'; define('live-search', { setup() { const query = signal(''); return html` (query.value = (e.target as HTMLInputElement).value)} /> `; }, }); ``` ## host bindings The setup context provides `bind` for wiring the host element. ```ts import { signal } from '@vielzeug/ripple'; import { define, html } from '@vielzeug/ore'; define('x-toggle', { setup(_props, { bind }) { const open = signal(false); bind({ attr: { 'aria-expanded': () => String(open.value), role: 'button', tabindex: 0 }, class: { 'is-open': open }, on: { click: () => (open.value = !open.value) }, }); return html``; }, }); ``` The `bind` config supports `attr`, `class`, `style`, and `on` sections. ## ARIA bindings Use `ctx.aria(target, config)` to reactively sync ARIA attributes to any element. Shorthand keys are normalised to `aria-*` automatically — `expanded` becomes `aria-expanded`, `role` is set verbatim. ```ts import { signal } from '@vielzeug/ripple'; import { define, html } from '@vielzeug/ore'; define('x-disclosure', { setup(_props, { aria, bind, onMounted }) { const open = signal(false); const panelId = 'disclosure-panel'; bind({ attr: { role: 'button', tabindex: 0 }, on: { click: () => (open.value = !open.value) }, }); onMounted(() => { const trigger = document.querySelector('#trigger') as HTMLElement; if (trigger) { // aria() registers cleanup automatically when called inside setup aria(trigger, { controls: panelId, expanded: () => String(open.value), haspopup: 'region', }); } }); return html``; }, }); ``` Static values are applied once. Getter functions create reactive effects. Setting a value to `null`, `undefined`, or `false` removes the attribute. `aria()` always returns a cleanup function. Use it to stop syncing early when a trigger element can be swapped out: ```ts onMounted(() => { const trigger = document.querySelector('#trigger') as HTMLElement; const stopAria = aria(trigger, { expanded: () => String(open.value) }); // Stop syncing when the trigger is replaced onCleanup(stopAria); }); ``` ### Binding a non-host element with `bind()` Pass `{ target: el }` as a second argument to bind attributes, classes, styles, or events to any element: ```ts import { signal } from '@vielzeug/ripple'; import { define, html, ref } from '@vielzeug/ore'; define('button-wrapper', { setup(_props, { bind, onMounted }) { const visible = signal(false); const btnRef = ref(); onMounted(() => { const btn = btnRef.value; if (!btn) return; bind( { attr: { 'aria-pressed': () => String(visible.value) }, on: { click: () => (visible.value = !visible.value) }, }, { target: btn }, ); }); return html`Toggle`; }, }); ``` ## slots and emits ```ts import { when } from '@vielzeug/ore/directives'; import { define, html } from '@vielzeug/ore'; define, Record, 'header' | 'footer'>('card-with-footer', { setup(_props, { slots, emit }) { return html` ${when(slots.has('footer'), () => html``)} emit('action')}>Go `; }, }); ``` Pass `SlotNames` as a type parameter to `define()` to get typed `slots.has()` and `slots.elements()` calls. ## context provide/inject ```ts import { signal } from '@vielzeug/ripple'; import { createContext, define, html, injectStrict } from '@vielzeug/ore'; const COUNT_CTX = createContext>>('count'); define('count-provider', { setup(_props, { provide }) { const count = signal(0); provide(COUNT_CTX, count); return html` count.value++}>`; }, }); define('count-consumer', { setup() { const count = injectStrict(COUNT_CTX); return html`Count: ${count}`; }, }); ``` ## form-associated elements ```ts import { signal } from '@vielzeug/ripple'; import { define, html, prop, useField } from '@vielzeug/ore'; define('rating-input', { formAssociated: true, setup() { const value = signal(0); const field = useField({ value }); return html` (value.value = 1)}>1 (value.value = 2)}>2 (value.value = 3)}>3 field.reportValidity()}>Validate Current: ${value} `; }, }); ``` ## async setup When `setup()` returns a `Promise`, ore renders `loading()` immediately and swaps in the real template once the promise resolves. Use `onError` to handle failures gracefully. ```ts import { define, html, prop } from '@vielzeug/ore'; define('user-profile', { props: { userId: prop.string('') }, loading: () => html`Loading…`, onError: (_err, el) => html`Failed to load for ${el.getAttribute('user-id')}`, async setup(props) { const user = await fetch(`/api/users/${props.userId.value}`).then((r) => r.json()); return html`${user.name}`; }, }); ``` ## platform observers Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `ctx.onMounted()`. ```ts import { effect } from '@vielzeug/ripple'; import { define, html, ref } from '@vielzeug/ore'; import { intersectionObserver, mediaObserver, resizeObserver } from '@vielzeug/ore/observers'; define('x-observed', { setup(_props, { onMounted }) { const boxRef = ref(); onMounted(() => { const element = boxRef.value; if (!element) return; const size = resizeObserver(element); const visible = intersectionObserver(element, { threshold: 0.5 }); const dark = mediaObserver('(prefers-color-scheme: dark)'); // effect() auto-tracks every signal read inside — re-runs when any of the three change. effect(() => { console.log(size.value.width, visible.value?.isIntersecting, dark.value); }); }); return html`Observe me`; }, }); ``` ## testing utilities Import from `@vielzeug/ore/testing`. ```ts import { afterEach, describe, expect, it } from 'vitest'; import { signal } from '@vielzeug/ripple'; import { html } from '@vielzeug/ore'; import { cleanup, fire, mount } from '@vielzeug/ore/testing'; describe('my-counter', () => { afterEach(cleanup); it('increments on click', async () => { let count!: ReturnType>; const { query, act } = await mount(() => { count = signal(0); return html` count.value++}>${count}`; }); expect(query('button')?.textContent).toBe('0'); await act(() => fire.click(query('button')!)); expect(query('button')?.textContent).toBe('1'); }); }); ``` ## Framework Integration Ore components are standard custom elements and work natively in any framework. ```tsx [React] // React 19+ supports custom elements natively. import './x-toggle'; // wherever define('x-toggle', { ... }) is called function App() { return ; } ``` ```ts [Vue 3] import './x-toggle'; // wherever define('x-toggle', { ... }) is called import { ref } from 'vue'; const open = ref(false); ``` ```svelte [Svelte] import './x-toggle'; // wherever define('x-toggle', { ... }) is called function handleClick() { console.log('toggled'); } ``` ## Working with Other Vielzeug Libraries ### With Ripple Import ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components. ```ts import { signal, computed } from '@vielzeug/ripple'; import { define, html } from '@vielzeug/ore'; // Shared state created outside any component const theme = signal('light'); const isDark = computed(() => theme.value === 'dark'); define('theme-toggle', { setup() { return html` (theme.value = isDark.value ? 'light' : 'dark')}> ${() => isDark.value ? '' : ''} `; }, }); ``` ### With Forge Use `@vielzeug/forge` for typed form state alongside Ore's `useField()` for form-associated elements. ```ts import { createForm } from '@vielzeug/forge'; import { s } from '@vielzeug/spell'; import { createFormContext, define, html, FORM_CONTEXT_KEY } from '@vielzeug/ore'; define('signup-form', { setup(_props, { provide }) { const formCtx = createFormContext({ onSubmit: async (e) => { e?.preventDefault(); // submit logic }, }); provide(FORM_CONTEXT_KEY, formCtx); return html` formCtx.submit()}> `; }, }); ``` ## Best Practices - Setup returns `html\`...\`` directly — not a function wrapping the template. - Use `ctx.watch()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect. - Use `ctx.onElement(ref, cb)` instead of `ctx.onMounted` when the work is tied to a single DOM node. - Bind host attributes and classes via `ctx.bind()` rather than mutating the element directly. - Provide context at the nearest ancestor — avoid global context singletons. - Call `ctx.onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions). - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits. - Thread lifecycle hooks explicitly into composable helpers via function parameters — do not rely on implicit module-level context. - Test with `@vielzeug/ore/testing` helpers (`mount`, `flush`, `waitFor`) rather than direct DOM manipulation. ### Examples ## Examples - [Counter Component](./examples/counter-component.md) - [Typed Props And Emits](./examples/typed-props-and-emits.md) - [Observers In onMounted()](./examples/observers-in-onmount.md) - [Search List With Directives](./examples/search-list-with-directives.md) - [Context Provider And Consumer](./examples/context-provider-and-consumer.md) - [Prop Helpers And Raw PropDef](./examples/propsof-builder-api.md) - [Form Associated Rating Input](./examples/form-associated-rating-input.md) - [Test Example With @vielzeug/ore/testing](./examples/test-example-at-vielzeug-ore-testing.md) --- ## @vielzeug/prism **Category:** ui **Keywords:** chart, svg, visualization, reactive, line-chart, bar-chart, area-chart, signals, typescript **Key exports:** createLineChart, createBarChart, createAreaChart, createPieChart, createSparkline, linearScale, timeScale, bandScale, seriesColor, setTheme, resetTheme, animate (+11 more) **Related:** ripple, refine, orbit ### Overview ## Why Prism? Charting libraries typically require a framework binding, bundle heavy dependencies, or force canvas rendering that can't be styled with CSS. Prism takes a different approach: ```ts // Before — Chart.js, imperative setup with a canvas you can't CSS-theme import Chart from 'chart.js/auto'; const ctx = document.getElementById('myChart') as HTMLCanvasElement; new Chart(ctx, { type: 'line', data: { labels, datasets: [{ data: values }] }, // re-render manually when data changes, no signals, canvas not CSS-styleable }); // After — Prism, declarative SVG chart driven by a signal import { createLineChart } from '@vielzeug/prism'; import { signal } from '@vielzeug/ripple'; const data = signal([ { key: 1, value: 12 }, { key: 2, value: 40 }, { key: 3, value: 28 }, ]); const chart = createLineChart(document.getElementById('chart')!, { series: [{ name: 'Users', data }], tooltip: true, }); // chart auto-updates when data.value changes — no manual re-render data.value = [...data.value, { key: 4, value: 65 }]; ``` | Feature | Prism | Chart.js | Lightweight Charts | D3 | | ------------------ | ------------------------------------------ | -------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | ~8 kB | ~60 kB | ~45 kB | ~30 kB (core) | | Renderer | SVG | Canvas | Canvas | SVG/Canvas | | Zero external deps | | | | | | CSS themeable | | | Limited | | | Reactive (signals) | | | | | | Accessible SVG | | | | Manual | | TypeScript-first | | Partial | | Types available | **Use Prism when** you need lightweight, reactive charts that integrate with signal-based state and can be styled purely with CSS. Ideal for dashboards, admin panels, and data-heavy applications using Vielzeug. **Consider alternatives when** you need 50+ chart types (ECharts), financial trading charts (Lightweight Charts), or low-level visualization grammar (D3). ## Installation ```sh [pnpm] pnpm add @vielzeug/prism ``` ```sh [npm] npm install @vielzeug/prism ``` ```sh [yarn] yarn add @vielzeug/prism ``` ## Quick Start ```ts import { createLineChart } from '@vielzeug/prism'; import { signal } from '@vielzeug/ripple'; import '@vielzeug/prism/theme'; const data = signal([ { key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }, ]); const chart = createLineChart(document.getElementById('chart')!, { series: [{ name: 'Revenue', data, color: '#3b82f6' }], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, crosshair: true, onHover: (event) => console.log(event?.datum), }); // Update data → chart re-renders automatically data.value = [...data.value, { key: 5, value: 28 }]; // Cleanup when done chart.dispose(); ``` ## Features - **`createLineChart(container, config)`** — line chart with linear, monotone, or step interpolation - **`createBarChart(container, config)`** — bar chart with four layout variants: grouped, stacked, grouped-horizontal, stacked-horizontal - **`createAreaChart(container, config)`** — filled area with configurable opacity - **`createSparkline(container, config)`** — minimal inline sparkline (line, area, or bar variant) - **`createPieChart(container, config)`** — pie, donut, or semi-circle donut chart - **`linearScale(config)`** — continuous numeric scale with nice tick generation - **`timeScale(config)`** — date/time scale with interval-based ticks - **`bandScale(config)`** — categorical scale for bar charts - **`MaybeSignal`** — pass plain values or `@vielzeug/ripple` signals; both work seamlessly - **`seriesColor(index, override?)`** — resolve CSS palette color by series index - **`setTheme(theme)` / `resetTheme()`** — apply or clear custom colors, font, and grid tokens at runtime - **Event hooks** — `onClick` and `onHover` callbacks on every chart - **Plugin system** — extend charts with `ChartPlugin` (`install()`/`dispose()` lifecycle, each isolated from the other's failures); supported by all chart types including `createPieChart` - **Devtools** — `debugChart()` from `@vielzeug/prism/devtools` logs mount/resize/dispose to `console.debug`; tree-shaken from production unless imported - **CSS custom properties** — full theme control via `--prism-*` tokens - **Responsive** — auto-resizes via `ResizeObserver` - **Accessible** — ARIA labels and semantic SVG structure - **`Symbol.dispose`** — explicit resource management following TC39 proposal ## Sub-paths | Import | Purpose | | -------------------------- | -------------------------------------------------------------------- | | `@vielzeug/prism` | All chart factories, scales, and types | | `@vielzeug/prism/theme` | Default CSS (custom properties + dark mode) | | `@vielzeug/prism/devtools` | `debugChart()` — opt-in `console.debug` lifecycle logging, tree-shaken in production | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — reactive signals that power Prism's auto-updating charts - [Refine](/refine/) — accessible web components that pair well with Prism for dashboards - [Orbit](/orbit/) — floating element positioning for chart tooltips and popovers ### API Reference ## API Overview | Symbol | Purpose | Returns | | -------------------- | ----------------------------------------------------- | ------------------------------ | | `createLineChart()` | Reactive line chart with curves and interpolation | `ChartHandle` | | `createBarChart()` | Bar chart: grouped, stacked, horizontal variants | `ChartHandle` | | `createAreaChart()` | Filled area chart | `ChartHandle` | | `linearScale()` | Continuous numeric → pixel scale | `Scale` | | `timeScale()` | Date → pixel scale | `Scale` | | `bandScale()` | Categorical → pixel band scale | `BandScale` | | `createSparkline()` | Minimal inline sparkline (line/area/bar) | `ChartHandle` | | `createPieChart()` | Pie, donut, or semi-circle donut chart | `ChartHandle` | | `seriesColor()` | CSS variable color for series index | `string` | | `setTheme()` | Apply custom palette / CSS tokens at runtime | `void` | | `resetTheme()` | Clear all custom theme overrides back to defaults | `void` | | `animate()` | Animate SVG element attributes via RAF | `() => void` (cancel function) | | `debugChart()` | Wrap a `ChartHandle` with lifecycle logging (`/devtools` subpath) | `ChartHandle` | | `PrismError` | Base class for all prism-originated errors | class | | `LegendState` | Live legend state object (plugin API) | type | | `TooltipState` | Live tooltip state object (plugin API) | type | | `ChartPluginContext` | Context object passed to `ChartPlugin.install()` | type | ## Package Entry Points | Import | Purpose | | -------------------------- | --------------------------------------------------------------------------- | | `@vielzeug/prism` | All chart factories, scales, types, and utilities | | `@vielzeug/prism/theme` | Default CSS custom properties (light + dark) | | `@vielzeug/prism/devtools` | `debugChart()` — opt-in `console.debug` lifecycle logging, tree-shaken in production | --- ## Chart Factories ### `createLineChart` ```ts function createLineChart(container: HTMLElement, config: LineChartConfig): ChartHandle; ``` Creates a reactive line chart. Supports multiple series, curve interpolation, tooltips, crosshair, and event hooks. | Parameter | Type | Description | | ----------- | ----------------- | --------------------------------------------------- | | `container` | `HTMLElement` | DOM element to render into (must have width/height) | | `config` | `LineChartConfig` | Chart configuration | **Returns** — [`ChartHandle`](#charthandle) --- ### `createBarChart` ```ts function createBarChart(container: HTMLElement, config: BarChartConfig): ChartHandle; ``` Creates a reactive bar chart. Use `variant` to switch between grouped, stacked, horizontal variants. | Parameter | Type | Description | | ----------- | ---------------- | -------------------------- | | `container` | `HTMLElement` | DOM element to render into | | `config` | `BarChartConfig` | Chart configuration | **Returns** — [`ChartHandle`](#charthandle) --- ### `createAreaChart` ```ts function createAreaChart(container: HTMLElement, config: AreaChartConfig): ChartHandle; ``` Creates a reactive filled area chart with configurable opacity, curve, and event hooks. | Parameter | Type | Description | | ----------- | ----------------- | -------------------------- | | `container` | `HTMLElement` | DOM element to render into | | `config` | `AreaChartConfig` | Chart configuration | **Returns** — [`ChartHandle`](#charthandle) --- ### `createPieChart` ```ts function createPieChart(container: HTMLElement, config: PieChartConfig): ChartHandle; ``` Creates a pie, donut, or semi-circle donut chart. All three variants share the same `PieChartConfig` — select via `variant`. | Parameter | Type | Description | | ----------- | ---------------- | ----------------------------------------- | | `container` | `HTMLElement` | DOM element to render into (sized by CSS) | | `config` | `PieChartConfig` | Chart configuration | **Returns** — [`ChartHandle`](#charthandle) --- ### `createSparkline` ```ts function createSparkline(container: HTMLElement, config: SparklineConfig): ChartHandle; ``` Creates a minimal inline chart with no axes, no legend, and no margin. Designed for use in tables, cards, and inline data contexts. | Parameter | Type | Description | | ----------- | ----------------- | ----------------------------------------- | | `container` | `HTMLElement` | DOM element to render into (sized by CSS) | | `config` | `SparklineConfig` | Sparkline configuration | **Returns** — [`ChartHandle`](#charthandle) --- ## Scale Factories ### `linearScale` ```ts function linearScale(config: LinearScaleConfig): Scale; ``` Continuous linear scale mapping a numeric domain to a pixel range. Unlike chart config fields, scale factory config is not `MaybeSignal` — pass plain values and call `linearScale()` again if the domain/range changes. | Field | Type | Default | Description | | --------------- | ------------------ | ------- | ----------------------------------------------------------------- | | `config.domain` | `[number, number]` | — | Input data range `[min, max]`. A reversed domain (`min > max`) is supported for inverted axes. | | `config.range` | `[number, number]` | — | Output pixel range `[min, max]` | | `config.nice` | `boolean` | `true` | Extend domain to nice round numbers | | `config.clamp` | `boolean` | `false` | Clamp output to range bounds | --- ### `timeScale` ```ts function timeScale(config: TimeScaleConfig): Scale; ``` Time scale mapping `Date` values to pixels. Automatically selects tick intervals (seconds → years). | Field | Type | Default | Description | | --------------- | ----------------- | ------- | -------------------------------- | | `config.domain` | `[Date, Date]` | — | Input date range `[start, end]` | | `config.range` | `[number, number]` | — | Output pixel range | | `config.nice` | `boolean` | `true` | Extend domain to nice boundaries | --- ### `bandScale` ```ts function bandScale(config: BandScaleConfig): BandScale; ``` Categorical scale dividing the range into equal bands with configurable padding. | Field | Type | Default | Description | | --------------------- | ------------------ | ----------------- | ------------------------- | | `config.domain` | `string[]` | — | Category names | | `config.range` | `[number, number]` | — | Output pixel range | | `config.padding` | `number` | `0.1` | Inner padding ratio (0–1) | | `config.paddingOuter` | `number` | same as `padding` | Outer edge padding ratio | --- ## Types ### `ChartHandle` Returned by all chart factories. ```ts interface ChartHandle { readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly el: SVGSVGElement; dispose(): void; [Symbol.dispose](): void; } ``` | Member | Description | | -------------------- | ----------------------------------------------------------------------------------------------- | | `el` | The root `SVGSVGElement` (for styling or external manipulation) | | `disposed` | `true` once `dispose()` has run; useful for guarding late callbacks | | `disposalSignal` | Aborted when the chart is disposed — tie your own cleanup (RAF loops, observers) to this instead of overriding `dispose()` | | `dispose()` | Tear down all effects, observers, DOM nodes, tooltip, and legend. Calling it more than once is a no-op | | `[Symbol.dispose]()` | Same as `dispose()` — for TC39 `using` declarations | > **Note:** Charts re-render automatically when signal data changes. There is no `update()` method — reactivity is fully automatic. --- ### `ChartEvent` Passed to `onClick` and `onHover` callbacks. ```ts interface ChartEvent { datum: Datum; originalEvent: MouseEvent; series: Series; } ``` --- ### `ChartPlugin` Interface for extending charts with custom behavior. Plugins are installed after the chart is mounted and torn down on `dispose()`. ```ts interface ChartPlugin { install(ctx: ChartPluginContext): void; dispose(): void; } ``` See [`ChartPluginContext`](#chartplugincontext) for the object passed to `install()`. --- ### `BaseChartConfig` Shared configuration inherited by all chart config types. ```ts interface BaseChartConfig { ariaLabel?: string; legend?: boolean | LegendConfig; margin?: Partial; onClick?: (event: ChartEvent) => void; onHover?: (event: ChartEvent | null) => void; plugins?: ChartPlugin[]; tooltip?: boolean | TooltipConfig; transition?: TransitionConfig; xAxis?: AxisConfig; yAxis?: AxisConfig; } ``` | Field | Type | Description | | ------------ | ------------------------------------- | --------------------------------------- | | `ariaLabel` | `string` | Accessible label on the SVG element | | `legend` | `boolean \| LegendConfig` | Show a series legend | | `margin` | `Partial` | Override chart margins | | `onClick` | `(event: ChartEvent) => void` | Fired when a data point is clicked | | `onHover` | `(event: ChartEvent \| null) => void` | Fired on mousemove (null on mouseleave) | | `plugins` | `ChartPlugin[]` | Extension plugins installed at mount | | `tooltip` | `boolean \| TooltipConfig` | Hover tooltip | | `transition` | `TransitionConfig` | Enter/update animation | | `xAxis` | `AxisConfig` | X-axis configuration | | `yAxis` | `AxisConfig` | Y-axis configuration | --- ### `MaybeSignal` ```ts type MaybeSignal = Readable | T; ``` Accepts either a plain value or a `@vielzeug/ripple` `Readable` signal (e.g. one created with `signal()`). Used for `series`/`data` fields on chart configs — when a signal is passed, the chart re-renders automatically on `.value` changes. Not used by the scale factories (`linearScale`/`timeScale`/`bandScale`), whose config fields are always plain values. --- ### `Scale` ```ts interface Scale { readonly domain: readonly [T, T]; readonly range: readonly [number, number]; map(value: T): number; invert(pixel: number): T; ticks(count?: number): T[]; } ``` | Member | Description | | --------------- | --------------------------------------------------- | | `domain` | Input domain `[min, max]` — readonly computed tuple | | `range` | Output pixel range — readonly computed tuple | | `map(value)` | Domain value → pixel position | | `invert(pixel)` | Pixel position → domain value | | `ticks(count?)` | Nicely-spaced tick values (default: 10) | --- ### `BandScale` ```ts interface BandScale { readonly domain: readonly string[]; readonly range: readonly [number, number]; map(value: string): number; bandwidth(): number; gap(): number; ticks(count?: number): string[]; } ``` | Member | Description | | --------------- | --------------------------------------------------------------- | | `map(value)` | Left edge pixel position of a category's band | | `bandwidth()` | Width of each band in pixels | | `gap()` | Pixel gap between adjacent bands (`bandwidth × padding`) | | `ticks(count?)` | All domain categories, or at most `count` evenly sampled values | --- ### `Point` ```ts interface Point { x: number; y: number; } ``` A pixel-space 2D point used by path builders and area renderers. Exported for plugin authors who build custom SVG paths. --- ### `Datum` A single data point in a cartesian chart series. ```ts interface Datum { key: Date | number | string; value: number; meta?: Record; } ``` | Field | Type | Description | | ------- | -------------------------- | ----------------------------------------------------------------------------------------- | | `key` | `Date \| number \| string` | X-axis identity. Use `number` or `Date` for line/area charts; `string` for bar categories | | `value` | `number` | Y-axis measured quantity | | `meta` | `Record` | Optional arbitrary metadata (available in tooltip `render` callbacks) | --- ### `Series` ```ts interface Series { name: string; data: MaybeSignal; color?: string; } ``` --- ### `ScaffoldContext` Passed to `renderFn` inside `createChartScaffold` — the internal building block behind `createLineChart`/`createBarChart`/`createAreaChart`. Relevant only if you're building a custom cartesian chart type on top of prism's scaffold, not to `ChartPlugin.install()` (see [`ChartPluginContext`](#chartplugincontext) for that). ```ts interface ScaffoldContext { chartArea: SVGGElement; container: HTMLElement; dimensions: Readable; disposalSignal: AbortSignal; groups: ScaffoldGroups; legend: LegendState | null; svg: SVGSVGElement; tooltip: TooltipState | null; } ``` --- ### `RadialScaffoldContext` The `createRadialScaffold` counterpart to `ScaffoldContext`, for chart types with no cartesian axis groups (pie, donut, semi). Backs `createPieChart`. ```ts interface RadialScaffoldContext { container: HTMLElement; dimensions: Readable; disposalSignal: AbortSignal; legend: LegendState | null; svg: SVGSVGElement; tooltip: TooltipState | null; } ``` --- ### `ScaffoldGroups` ```ts interface ScaffoldGroups { grid: SVGGElement; series: SVGGElement; xAxis: SVGGElement; yAxis: SVGGElement; } ``` SVG `` elements created by `createChartScaffold`. Children of `chartArea`, appended in render order: `grid` → `xAxis` → `yAxis` → `series`. --- ### `ChartEventHandlers` ```ts interface ChartEventHandlers { onClick?: (event: MouseEvent) => void; onMouseLeave?: (event: MouseEvent) => void; onMouseMove?: (event: MouseEvent) => void; } ``` Returned by the `renderFn` passed to `createChartScaffold`. The scaffold attaches and tears down these listeners automatically before each re-render. --- ### `AnimationTarget` ```ts interface AnimationTarget { attrs: Record; el: SVGElement; } ``` One element + attribute map for use with `animate()`. Each attribute entry specifies the start (`from`) and end (`to`) pixel value. --- ## Pie / Donut Types ### `PieChartConfig` Extends [`BaseChartConfig`](#basechartconfig) (inherits `ariaLabel`, `legend`, `margin`, `plugins`, `tooltip`, `transition`). Overrides `onClick`/`onHover` with pie-specific slice signatures. ```ts interface PieChartConfig extends Omit { cornerRadius?: number; data: MaybeSignal; innerRadius?: number; onClick?: (slice: PieSliceConfig, index: number) => void; onHover?: (slice: PieSliceConfig | null, index: number | null) => void; padPixels?: number; variant?: PieVariant; } ``` | Field | Type | Default | Description | | -------------- | ------------------------------------ | -------------------------------------- | ------------------------------------------------------- | | `data` | `MaybeSignal` | — | Slice definitions | | `variant` | `PieVariant` | `'pie'` | Chart style: `'pie'`, `'donut'`, or `'semi'` | | `innerRadius` | `number` | `55%` of outer (donut/semi), `0` (pie) | Inner hole radius in pixels | | `padPixels` | `number` | `0` (pie), `8` (donut/semi) | Pixel gap between slices (uniform across arc thickness) | | `cornerRadius` | `number` | `0` (pie), `8` (donut/semi) | Rounded arc corners (pixels) | | `onClick` | `(slice, index) => void` | — | Fired on slice click | | `onHover` | `(slice\|null, index\|null) => void` | — | Fired on hover; `null` on mouseleave | > Inherited `BaseChartConfig` fields (`tooltip`, `transition`, `legend`, `margin`, `ariaLabel`, `plugins`) behave identically to other chart types. ### `PieSliceConfig` ```ts interface PieSliceConfig { color?: string; label?: string; value: number; } ``` | Field | Type | Description | | ------- | -------- | ------------------------------------------------- | | `value` | `number` | Numeric weight of the slice | | `color` | `string` | Slice fill color; defaults to `--prism-color-{n}` | | `label` | `string` | Optional text rendered at the arc centroid | ### `PieVariant` ```ts type PieVariant = 'donut' | 'pie' | 'semi'; ``` - **`pie`** — full circle, no hole - **`donut`** — full circle with inner hole (~55% of outer radius by default) - **`semi`** — top-half semicircle with inner hole (useful for gauges/progress) --- ## Sparkline Types ### `SparklineConfig` ```ts interface SparklineConfig { ariaLabel?: string; color?: string; cornerRadius?: number; curve?: 'linear' | 'monotone' | 'step'; data: MaybeSignal; fillOpacity?: number; onClick?: (index: number, value: number) => void; onHover?: (index: number | null, value: number | null) => void; padPixels?: number; strokeWidth?: number; transition?: TransitionConfig; variant?: SparklineVariant; } ``` | Field | Type | Default | Description | | -------------- | ----------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------- | | `data` | `MaybeSignal` | — | Numeric values, or `StackSegment[]` for `'stack'` variant | | `variant` | `SparklineVariant` | `'line'` | Chart style | | `color` | `string` | `var(--prism-color-1)` | Stroke/fill color (line/area/bar only) | | `curve` | `'linear' \| 'monotone' \| 'step'` | `'linear'` | Line interpolation (line/area only) | | `strokeWidth` | `number` | `1.5` | Line stroke width (line/area only) | | `fillOpacity` | `number` | `0.2` | Fill opacity (area only) | | `cornerRadius` | `number` | `4` | Rounded corners for stack segments in pixels. Stack variant only — no effect on line/area/bar | | `padPixels` | `number` | `0` | Gap between stack segments in pixels. Stack variant only — no effect on line/area/bar | | `ariaLabel` | `string` | — | Accessible label; sets `role="img"` on the SVG. If omitted the SVG is marked `aria-hidden="true"` (decorative) | | `transition` | `TransitionConfig` | — | Enter animation (bar/stack only; line/area use RAF interpolation) | | `onClick` | `(index, value) => void` | — | Called on click with nearest data index. Not fired for 0- or 1-point data | | `onHover` | `(index\|null, value\|null) => void` | — | Called on mousemove; `null` on mouseleave. Not fired for 0- or 1-point data | ### `SparklineVariant` ```ts type SparklineVariant = 'area' | 'bar' | 'line' | 'stack'; ``` - **`line`** — polyline path (default) - **`area`** — filled area + line overlay - **`bar`** — vertical bar per data point - **`stack`** — horizontal proportional segments; use `StackSegment[]` for `data` with per-segment colors ### `StackSegment` ```ts interface StackSegment { color?: string; label?: string; value: number; } ``` > **Accessibility:** Without `ariaLabel` the SVG is marked `aria-hidden="true"` (decorative). Set `ariaLabel` to expose the chart to assistive technology — the SVG will carry `role="img"` and the provided label. --- ## Chart Config Types ### `LineChartConfig` Extends [`BaseChartConfig`](#basechartconfig). ```ts interface LineChartConfig extends BaseChartConfig { series: MaybeSignal; crosshair?: boolean | CrosshairConfig; } ``` ### `LineSeriesConfig` ```ts interface LineSeriesConfig extends Series { curve?: 'linear' | 'monotone' | 'step'; // default: 'linear' strokeWidth?: number; // default: 2 showPoints?: boolean; // default: false pointRadius?: number; // default: 3 } ``` --- ### `BarChartConfig` Extends [`BaseChartConfig`](#basechartconfig). ```ts type BarVariant = | 'grouped' // vertical grouped (default) | 'stacked' // vertical stacked | 'grouped-horizontal' // horizontal grouped | 'stacked-horizontal'; // horizontal stacked interface BarChartConfig extends BaseChartConfig { series: MaybeSignal; variant?: BarVariant; // default: 'grouped' } ``` ### `BarSeriesConfig` ```ts interface BarSeriesConfig extends Series { borderRadius?: number; // default: 0 } ``` --- ### `AreaChartConfig` Extends [`BaseChartConfig`](#basechartconfig). ```ts interface AreaChartConfig extends BaseChartConfig { series: MaybeSignal; crosshair?: boolean | CrosshairConfig; } ``` ### `AreaSeriesConfig` ```ts interface AreaSeriesConfig extends Series { curve?: 'linear' | 'monotone' | 'step'; // default: 'linear' fillOpacity?: number; // default: 0.3 showLine?: boolean; // default: true } ``` --- ## Shared Config Types ### `AxisConfig` ```ts interface AxisConfig { position: 'top' | 'bottom' | 'left' | 'right'; tickCount?: number; tickFormat?: (value: Date | number | string) => string; label?: string; grid?: boolean | GridConfig; } ``` ### `GridConfig` ```ts interface GridConfig { color?: string; dash?: string; // SVG stroke-dasharray value, e.g. '4 2' } ``` ### `TooltipConfig` ```ts interface TooltipConfig { offset?: number; // default: 8 render?: (datum: Datum, series: Series) => string; // returns HTML string sanitize?: (html: string) => string; // applied before innerHTML injection } ``` The tooltip is appended inside the chart container (not `document.body`), so it is automatically scoped and cleaned up on `dispose()`. > ⚠️ **Security:** The string returned by `render` is injected via `innerHTML`. Pass `sanitize` to apply a sanitizer (e.g. DOMPurify) before injection, or ensure all user-supplied values are escaped before interpolation. A `warn` is emitted in development when `render` is set without `sanitize`. ### `CrosshairConfig` ```ts interface CrosshairConfig { vertical?: boolean; // default: true horizontal?: boolean; // default: false snap?: boolean; // default: true } ``` ### `LegendConfig` ```ts interface LegendConfig { position?: 'top' | 'bottom' | 'left' | 'right'; // default: 'bottom' } ``` ### `TransitionConfig` ```ts interface TransitionConfig { duration?: number; // ms, default: 300 easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | ((t: number) => number); stagger?: number; // ms delay between bar enter animations, default: 0 } ``` > **`stagger`** applies only to bar chart enter animations — new bars grow in sequence with a `stagger`ms delay between each one. ### `ChartMargin` ```ts interface ChartMargin { top: number; // default: 20 right: number; // default: 20 bottom: number; // default: 40 left: number; // default: 50 } ``` --- ## Utilities ### `seriesColor` ```ts function seriesColor(index: number, override?: string): string; ``` Returns the CSS variable reference for palette color at `index` (wraps at 8). If `override` is provided it is returned as-is. Used internally by all chart factories. ```ts import { seriesColor } from '@vielzeug/prism'; seriesColor(0); // 'var(--prism-color-1)' seriesColor(0, '#ff0'); // '#ff0' ``` ### `setTheme` ```ts interface PrismTheme { colors?: string[]; // replaces --prism-color-1 … -8 fontFamily?: string; // sets --prism-font-family gridColor?: string; // sets --prism-grid-color gridOpacity?: number; // sets --prism-grid-opacity } function setTheme(theme: PrismTheme): void; ``` Applies CSS custom properties to `document.documentElement`. Call once at app startup before mounting charts. Setting `colors` clears any unset color slots left over from a previous `setTheme()` call, so a theme with fewer colors than the last one doesn't leave stale high-index colors behind. ```ts import { setTheme } from '@vielzeug/prism'; setTheme({ colors: ['#6366f1', '#22d3ee', '#f59e0b', '#10b981'] }); ``` ### `resetTheme` ```ts function resetTheme(): void; ``` Clears every CSS custom property `setTheme()` can set, restoring prism's default theme (from `@vielzeug/prism/theme`). Useful for test teardown or a theme-switcher's "reset to default" action. ```ts import { resetTheme, setTheme } from '@vielzeug/prism'; setTheme({ colors: ['#6366f1'] }); resetTheme(); // back to the default palette ``` > `seriesColor`, `setTheme`, and `resetTheme` are all exported from `@vielzeug/prism` (not from the `/theme` CSS subpath). --- ## Interaction Types > Exported from `@vielzeug/prism` for use in plugins and custom chart extensions. Both types reflect the live state object created internally; `el` is `null` when no legend/tooltip is configured. ### `LegendState` ```ts interface LegendState { dispose(): void; [Symbol.dispose](): void; el: HTMLDivElement | null; update(series: { color: string; name: string }[]): void; } ``` The live legend object available on `ctx.legend` inside `ChartPlugin.install`. Call `update()` to re-render legend items, `dispose()` to remove the element. ### `TooltipState` ```ts interface TooltipState { dispose(): void; [Symbol.dispose](): void; el: HTMLElement | null; hide(): void; show(x: number, y: number, datum: Datum, series: Series): void; } ``` The live tooltip object available on `ctx.tooltip` inside `ChartPlugin.install`. `x`/`y` are pixel coordinates relative to the chart area; `show()` positions and renders the tooltip. --- ### `ChartPluginContext` ```ts interface ChartPluginContext { container: HTMLElement; dimensions: Readable; disposalSignal: AbortSignal; svg: SVGSVGElement; } ``` Passed to `ChartPlugin.install(ctx)`. Gives plugins access to the reactive `dimensions` signal, the host `container`, the root `svg` element, and a `disposalSignal` aborted when the chart is torn down. ```ts import type { ChartPlugin } from '@vielzeug/prism'; import { effect } from '@vielzeug/ripple'; const watermarkPlugin: ChartPlugin = { dispose() {}, install(ctx) { // React to size changes effect(() => { const { width, height } = ctx.dimensions.value; /* re-layout watermark */ }); }, }; ``` > **Note:** To observe future resize events use `effect(() => { ctx.dimensions.value; })` from `@vielzeug/ripple` within a reactive scope. To run cleanup when the chart is disposed without relying on your own `dispose()` implementation being called, add a listener to `ctx.disposalSignal` instead: `ctx.disposalSignal.addEventListener('abort', cleanup)`. > > **Error isolation:** if a plugin's `install()` or `dispose()` throws, the error is logged (dev builds only) and the rest of the chart — and any other installed plugins — continues to work. A throwing plugin never aborts chart creation or teardown. --- ## Animation Utilities > Exported from `@vielzeug/prism` for use in plugins and custom chart extensions. ### `animate` ```ts function animate( targets: AnimationTarget[], config?: TransitionConfig, onComplete?: () => void, signal?: AbortSignal, ): () => void; ``` Animates SVG element attributes from `from` to `to` values over the given `TransitionConfig` duration. Calls `onComplete` when all animations finish. Returns a cancel function — call it to stop the in-flight animation early (its `requestAnimationFrame` loop is cancelled and `onComplete` is not called). - **Empty targets or `duration: 0`** — attributes are set immediately and `onComplete` is called synchronously; no RAF is scheduled. The returned cancel function is a no-op in this case. - **Negative `stagger`** — clamped to `0`; all elements animate in parallel. - **`signal`** — if provided and already aborted (or aborted mid-animation), the RAF loop stops rescheduling itself on its next frame, same effect as calling the returned cancel function. **Parameters — `AnimationTarget`:** | Field | Type | Description | | ------- | ---------------------------------------------- | --------------------------------- | | `el` | `SVGElement` | Target element | | `attrs` | `Record` | Attribute name → start/end values | ```ts import { animate } from '@vielzeug/prism'; const cancel = animate([{ attrs: { opacity: { from: 0, to: 1 } }, el: rect }], { duration: 300, easing: 'ease-out' }); // Stop early if the element is removed before the animation completes: cancel(); ``` ### `EasingFn` ```ts type EasingFn = (t: number) => number; ``` A custom easing function. Receives a normalised time value `t ∈ [0, 1]` and returns a progress value (also typically `[0, 1]`). Pass as `TransitionConfig.easing`. Unknown or invalid easing name strings fall back to `'ease-out'` rather than throwing. --- ## Devtools > **Import:** `@vielzeug/prism/devtools` Opt-in debug logging, separate from the internal dev-mode validation warnings in `_dev.ts` (those run automatically and need no import). Tree-shaken from production bundles when this sub-path isn't imported — there is no environment gate to configure. ### `debugChart` ```ts interface DebugChartOptions { label?: string; // defaults to 'chart', producing log prefixes like [prism:chart] } function debugChart(handle: T, options?: DebugChartOptions): T; ``` Wraps an already-created `ChartHandle` with lifecycle logging to `console.debug`. Logs the chart's mount, every resize (via its own `ResizeObserver` on `handle.el`, independent of the chart's internal one), and disposal — each prefixed with `[prism:]`. Returns the same handle unchanged, so it can wrap any `create*Chart()` call in place. ```ts import { createLineChart } from '@vielzeug/prism'; import { debugChart } from '@vielzeug/prism/devtools'; const chart = debugChart(createLineChart(container, config), { label: 'revenue' }); // [prism:revenue] mounted // [prism:revenue] resized 600×300 chart.dispose(); // [prism:revenue] disposed ``` --- ## Errors ### `PrismError` Base class for all prism errors. Use `instanceof PrismError` or `PrismError.is()` to catch any prism-originated error. ```ts class PrismError extends Error { static is(err: unknown): err is PrismError; } ``` **Named subclasses** | Class | Thrown when | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PrismRenderError` | A chart is given a structurally invalid configuration it cannot render at all (e.g. a non-`Element` `container`). Recoverable issues like empty or malformed data emit a dev-mode warning instead — they do not throw. | | `PrismDisposedError` | Reserved for future disposal-sensitive APIs on `ChartHandle`. No code path throws this yet — calling `dispose()` more than once is currently a documented no-op, not an error. | ### Usage Guide ## Basic Setup Every chart needs a container element with defined dimensions and the theme CSS: ```ts import '@vielzeug/prism/theme'; ``` ```html ``` Prism observes the container size via `ResizeObserver` and re-renders automatically on resize. If the container has zero dimensions at mount time, a `warn` is emitted in development — ensure the container has layout before calling the chart factory. ## Reactivity with Signals Prism accepts both plain values and `@vielzeug/ripple` signals for any data property. When a signal changes, the chart re-renders automatically in the next animation frame. ### Static Data ```ts import { createLineChart } from '@vielzeug/prism'; const chart = createLineChart(container, { series: [ { name: 'Static', data: [ { key: 1, value: 10 }, { key: 2, value: 20 }, ], }, ], }); ``` ### Reactive Data ```ts import { createLineChart } from '@vielzeug/prism'; import { signal } from '@vielzeug/ripple'; const data = signal([ { key: 1, value: 10 }, { key: 2, value: 20 }, ]); const chart = createLineChart(container, { series: [{ name: 'Live', data }], }); // Later — chart updates automatically data.value = [...data.value, { key: 3, value: 30 }]; ``` ### The `MaybeSignal` Pattern All data-bearing config fields use the `MaybeSignal` type: ```ts type MaybeSignal = Readable | T; ``` Pass a plain value when data is fixed, or a `@vielzeug/ripple` signal when it changes dynamically. The chart handles both identically. ## Line Charts ```ts import { createLineChart } from '@vielzeug/prism'; const chart = createLineChart(container, { series: [ { name: 'Revenue', data: [ { key: 1, value: 100 }, { key: 2, value: 150 }, { key: 3, value: 130 }, ], color: '#3b82f6', curve: 'monotone', // 'linear' | 'monotone' | 'step' strokeWidth: 2, showPoints: true, pointRadius: 4, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, crosshair: true, }); ``` ### Multiple Series ```ts const chart = createLineChart(container, { series: [ { name: 'Revenue', data: revenueData, color: '#3b82f6' }, { name: 'Expenses', data: expenseData, color: '#ef4444' }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, }); ``` ### Time-based X Axis When data points use `Date` objects for `key`, Prism automatically applies a time scale: ```ts const chart = createLineChart(container, { series: [ { name: 'Signups', data: [ { key: new Date('2024-01-01'), value: 50 }, { key: new Date('2024-02-01'), value: 80 }, { key: new Date('2024-03-01'), value: 120 }, ], }, ], xAxis: { position: 'bottom', tickFormat: (d) => (d as Date).toLocaleDateString() }, yAxis: { position: 'left' }, }); ``` ## Bar Charts ```ts import { createBarChart } from '@vielzeug/prism'; const chart = createBarChart(container, { series: [ { name: 'Sales', data: [ { key: 'Q1', value: 200 }, { key: 'Q2', value: 350 }, { key: 'Q3', value: 280 }, { key: 'Q4', value: 400 }, ], borderRadius: 4, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, }); ``` ### Variants Select the bar layout with `variant`: | Value | Layout | | ---------------------- | -------------------------- | | `'grouped'` | Vertical grouped (default) | | `'stacked'` | Vertical stacked | | `'grouped-horizontal'` | Horizontal grouped | | `'stacked-horizontal'` | Horizontal stacked | > **Note:** `tooltip` and `legend` are always available on the scaffold — omitting them uses a no-op null-object internally, so no conditional checks are needed in plugins or custom render logic. ```ts const chart = createBarChart(container, { variant: 'stacked', series: [ { name: 'Mobile', data: mobileData, color: '#3b82f6', borderRadius: 0 }, { name: 'Desktop', data: desktopData, color: '#10b981', borderRadius: 0 }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, legend: true, }); ``` For horizontal layouts, categories appear on the Y axis and values on the X axis: ```ts const chart = createBarChart(container, { variant: 'grouped-horizontal', series: [{ name: 'Revenue', data, color: '#3b82f6' }], xAxis: { position: 'bottom', grid: true }, yAxis: { position: 'left' }, }); ``` ### Grouped Bars Multiple series with `variant: 'grouped'` (default) render side-by-side: ```ts const chart = createBarChart(container, { series: [ { name: '2023', data: lastYearData, color: '#94a3b8' }, { name: '2024', data: thisYearData, color: '#3b82f6' }, ], }); ``` ## Area Charts ```ts import { createAreaChart } from '@vielzeug/prism'; const chart = createAreaChart(container, { series: [ { name: 'Users', data: userData, curve: 'monotone', fillOpacity: 0.2, showLine: true, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, crosshair: true, }); ``` ## Pie, Donut, and Semi-circle Charts All three variants use `createPieChart` with the `variant` field: ```ts import { createPieChart } from '@vielzeug/prism'; const chart = createPieChart(container, { data: [ { label: 'Direct', value: 42, color: '#3b82f6' }, { label: 'Organic', value: 28, color: '#10b981' }, { label: 'Referral', value: 18, color: '#f59e0b' }, { label: 'Social', value: 12, color: '#8b5cf6' }, ], variant: 'donut', // 'pie' | 'donut' | 'semi' tooltip: true, transition: { duration: 400, easing: 'ease-out' }, }); ``` ### Variants | Value | Shape | | --------- | ------------------------------------------------------- | | `'pie'` | Full circle, no hole | | `'donut'` | Full circle with inner hole (~55% of outer by default) | | `'semi'` | Top-half semicircle with inner hole — useful for gauges | ### Inner Radius `innerRadius` overrides the automatic calculation: ```ts createPieChart(container, { data, variant: 'donut', innerRadius: 60, // explicit pixels }); ``` ### Slice Labels Set `label` on each `PieSliceConfig` to render text at the arc centroid: ```ts { value: 42, label: '42%' } ``` Style labels via CSS: ```css :root { --prism-pie-label-color: #fff; --prism-pie-label-size: 11px; } ``` ### Reactive Data ```ts import { signal } from '@vielzeug/ripple'; const data = signal([ { label: 'A', value: 40 }, { label: 'B', value: 60 }, ]); const chart = createPieChart(container, { data, variant: 'donut' }); data.value = [ { label: 'A', value: 55 }, { label: 'B', value: 45 }, ]; ``` ### Event Hooks ```ts createPieChart(container, { data, onHover: (slice, index) => { // slice/index are null on mouseleave if (slice) console.log(slice.label, slice.value); }, onClick: (slice, index) => { console.log('clicked', slice.label); }, }); ``` ## Sparklines Sparklines are minimal inline charts with no axes, no legend, and no margin — designed to live inline with text or inside table cells. ```ts import { createSparkline } from '@vielzeug/prism'; const spark = createSparkline(container, { data: [12, 18, 14, 22, 19, 28], variant: 'line', // 'line' | 'area' | 'bar' (default: 'line') color: '#3b82f6', curve: 'monotone', strokeWidth: 1.5, }); spark.dispose(); ``` ### Variants - **`line`** — simple polyline path (default) - **`area`** — filled area + line overlay - **`bar`** — vertical bar for each data point - **`stack`** — horizontal proportional segments; use `StackSegment[]` for `data` with per-segment colors ### Reactive Data ```ts import { signal } from '@vielzeug/ripple'; const data = signal([12, 18, 14, 22]); const spark = createSparkline(container, { data, variant: 'area' }); data.value = [...data.value, 30]; // re-renders automatically ``` ### Event Hooks Sparklines use simplified hooks — index-based rather than full `ChartEvent`: ```ts const spark = createSparkline(container, { data: [10, 20, 30], onHover: (index, value) => { // index/value are null on mouseleave if (index !== null) console.log(`Hovering point ${index}: ${value}`); }, onClick: (index, value) => { console.log(`Clicked point ${index}: ${value}`); }, }); ``` > **Note:** Sparkline SVGs are marked `aria-hidden="true"` since they are decorative. Provide meaningful surrounding text context for accessibility. ## Axes and Grid ```ts { xAxis: { position: 'bottom', // 'top' | 'bottom' tickCount: 5, tickFormat: (v) => `$${v}`, label: 'Month', grid: true, // or { color: '#ddd', dash: '4 2' } }, yAxis: { position: 'left', // 'left' | 'right' grid: { color: '#f0f0f0' }, label: 'Revenue ($)', }, } ``` ## Tooltips Enable with `tooltip: true` for default rendering, or provide a custom `render` function returning an HTML string: ```ts { tooltip: { offset: 12, render: (datum, series) => ` ${series.name} Value: ${datum.value.toLocaleString()} `, }, } ``` The `render` output is injected via `innerHTML`. If you interpolate user-supplied data, pass a `sanitize` function to guard against XSS: ```ts import DOMPurify from 'dompurify'; { tooltip: { render: (datum, series) => `${series.name}: ${datum.value}`, sanitize: (html) => DOMPurify.sanitize(html), }, } ``` The tooltip element is scoped inside the chart container (not `document.body`) and is removed automatically on `dispose()`. ## Crosshair A vertical guide that snaps to the nearest data point: ```ts { crosshair: true, // or configure: crosshair: { vertical: true, horizontal: true, snap: true }, } ``` ## Legend Enable with `legend: true` (defaults to `bottom`) or configure position: ```ts { legend: true, // or: legend: { position: 'top' }, // 'top' | 'bottom' | 'left' | 'right' } ``` The legend renders as a `div` placed outside the SVG. Each item shows a color swatch and the series `name`. Customize via CSS: ```css :root { --prism-legend-gap: 1rem; --prism-legend-dot-size: 0.5rem; --prism-legend-font-size: 0.75rem; } ``` ## Event Hooks All charts expose `onClick` and `onHover` callbacks on the config: ```ts const chart = createLineChart(container, { series: [{ name: 'Revenue', data }], onHover: (event) => { // event is ChartEvent | null (null on mouseleave) if (event) console.log(event.datum, event.series); }, onClick: (event) => { console.log('clicked', event.datum); }, }); ``` `ChartEvent` provides: - `datum` — the nearest `Datum` - `series` — the corresponding `Series` config - `originalEvent` — the raw `MouseEvent` > **Pie chart events differ** — `onHover` and `onClick` receive `(slice: PieSliceConfig, index: number)` instead of `ChartEvent`. See [`PieChartConfig`](./api.md#piechartconfig) for details. ## Plugins Extend any chart with custom behavior using the `ChartPlugin` interface. All chart types — including `createPieChart` — support `plugins`. ```ts import type { ChartPlugin } from '@vielzeug/prism'; function createClickLogger(): ChartPlugin { const handler = (e: MouseEvent) => console.log('chart clicked', e); // `dispose()` receives no arguments, so capture whatever `install()` needs // to clean up (here, the svg it attached the listener to) in this closure. let svg: SVGSVGElement | undefined; return { install(ctx) { svg = ctx.svg; svg.addEventListener('click', handler); }, dispose() { svg?.removeEventListener('click', handler); }, }; } const chart = createLineChart(container, { series: [{ name: 'Revenue', data }], plugins: [createClickLogger()], }); // Works for pie charts too: const pie = createPieChart(container, { data, plugins: [createClickLogger()], }); ``` > **Alternative to `dispose()`:** `install(ctx)` can instead listen for `ctx.disposalSignal`'s `abort` event to run cleanup, without needing to capture anything for a separate `dispose()` implementation: `ctx.disposalSignal.addEventListener('abort', () => svg.removeEventListener('click', handler))`. > > **Error isolation:** if a plugin's `install()` or `dispose()` throws, the error is logged in development and the rest of the chart — plus any other installed plugins — keeps working. A throwing plugin never aborts chart creation or teardown. ## Animations Pass a `transition` config to animate enter and update transitions: ```ts { transition: { duration: 400, easing: 'ease-out', stagger: 30, // bar charts only: ms delay between each bar's enter animation }, } ``` All chart types use requestAnimationFrame-based interpolation. Bar charts additionally support `stagger` — a per-bar delay that creates a cascade effect on first render. ## Theming Import the default theme: ```ts import '@vielzeug/prism/theme'; ``` ### Programmatic Theme with `setTheme` Call `setTheme` once at app startup to apply custom tokens programmatically: ```ts import { setTheme } from '@vielzeug/prism'; setTheme({ colors: ['#6366f1', '#22d3ee', '#f59e0b', '#10b981'], // replaces --prism-color-1 through -4 fontFamily: 'Inter, system-ui, sans-serif', // sets --prism-font-family gridColor: '#e2e8f0', // sets --prism-grid-color gridOpacity: 0.6, // sets --prism-grid-opacity }); ``` `setTheme` writes to `document.documentElement` style, so it takes precedence over CSS file defaults. Call `resetTheme()` to clear every custom property `setTheme` can set and restore the default theme — useful for a theme-switcher's "reset" action or test teardown: ```ts import { resetTheme } from '@vielzeug/prism'; resetTheme(); ``` ### Custom Theme (CSS) ```css :root { --prism-color-1: #6366f1; --prism-color-2: #22c55e; --prism-axis-color: #71717a; --prism-grid-color: #f4f4f5; --prism-text-color: #18181b; --prism-tooltip-bg: #27272a; --prism-font-family: 'Inter', system-ui, sans-serif; } ``` ### Scoped Themes Apply tokens to a specific container: ```css .dark-dashboard { --prism-axis-color: #64748b; --prism-grid-color: #334155; --prism-text-color: #e2e8f0; } ``` ### Available Tokens | Token | Default | Description | | ------------------------- | ---------------- | ---------------------- | | `--prism-color-{1-8}` | Tailwind palette | Series color palette | | `--prism-bg` | `transparent` | Chart background | | `--prism-axis-color` | `#94a3b8` | Axis lines and ticks | | `--prism-grid-color` | `#e2e8f0` | Grid lines | | `--prism-text-color` | `#334155` | Axis labels and text | | `--prism-font-family` | `system-ui` | Chart font | | `--prism-font-size` | `12px` | Label font size | | `--prism-tooltip-bg` | `#1e293b` | Tooltip background | | `--prism-tooltip-color` | `#f8fafc` | Tooltip text | | `--prism-tooltip-radius` | `6px` | Tooltip border radius | | `--prism-crosshair-color` | `#64748b` | Crosshair line | | `--prism-crosshair-dash` | `4 2` | Crosshair dash pattern | ## Scales (Standalone) Scales can be used independently for custom visualizations: ```ts import { linearScale, timeScale, bandScale } from '@vielzeug/prism'; const y = linearScale({ domain: [0, 100], range: [300, 0] }); y.map(50); // → 150 y.invert(150); // → 50 y.ticks(5); // → [0, 20, 40, 60, 80, 100] const x = bandScale({ domain: ['A', 'B', 'C'], range: [0, 300] }); x.map('B'); // → pixel left edge of band B x.bandwidth(); // → width of each band ``` ## Lifecycle and Cleanup Every chart returns a `ChartHandle`. Always call `dispose()` when removing a chart: ```ts const chart = createLineChart(container, config); // When done: chart.dispose(); // Or with TC39 explicit resource management: { using chart = createLineChart(container, config); // auto-disposed at block end } ``` Calling `dispose()`: - Cancels all reactive signal effects - Disconnects the `ResizeObserver` - Removes the SVG element, tooltip, and legend from the DOM - Calls `dispose()` on all plugins (a plugin that throws is logged and skipped — it never blocks the rest of teardown) - Is idempotent — safe to call multiple times > **Reactivity is automatic** — charts re-render whenever signal data changes. There is no manual `update()` call needed. ## Responsive Behavior Charts resize automatically when the container dimensions change. Prism uses `ResizeObserver` internally — no manual `resize()` call is needed. ## Devtools Import `debugChart()` from the `/devtools` subpath to log a chart's mount, resize, and dispose events to `console.debug`. It's separate from prism's internal validation warnings (those run automatically in development, no import needed) and is tree-shaken from production bundles when this subpath isn't imported. ```ts import { createLineChart } from '@vielzeug/prism'; import { debugChart } from '@vielzeug/prism/devtools'; const chart = debugChart(createLineChart(container, config), { label: 'revenue' }); // [prism:revenue] mounted // [prism:revenue] resized 600×300 chart.dispose(); // [prism:revenue] disposed ``` > `debugChart()` wraps and returns the same `ChartHandle` unchanged, so it drops into any `create*Chart()` call without restructuring your code. ## Framework Integration Prism renders into a plain DOM element. Attach charts inside mount/unmount lifecycle hooks for any framework. ```tsx [React] import { useEffect, useRef } from 'react'; import { createLineChart, type Datum } from '@vielzeug/prism'; function LineChart({ data }: { data: Datum[] }) { const containerRef = useRef(null); useEffect(() => { const chart = createLineChart(containerRef.current!, { series: [{ data, name: 'Series' }], }); return () => chart.dispose(); }, [data]); return ; } ``` ```ts [Vue 3] import { onMounted, onUnmounted, ref } from 'vue'; import { createLineChart, type ChartHandle, type Datum } from '@vielzeug/prism'; function useLineChart(data: Datum[]) { const containerRef = ref(null); let chart: ChartHandle | null = null; onMounted(() => { chart = createLineChart(containerRef.value!, { series: [{ data, name: 'Series' }] }); }); onUnmounted(() => chart?.dispose()); return { containerRef }; } ``` ```svelte [Svelte] import { onMount } from 'svelte'; import { createLineChart, type Datum } from '@vielzeug/prism'; export let data: Datum[] = []; let container: HTMLDivElement; onMount(() => { const chart = createLineChart(container, { series: [{ data, name: 'Series' }] }); return () => chart.dispose(); }); ``` ## Working with Other Vielzeug Libraries ### With Ripple Pass Ripple signals as chart data properties. Prism re-renders automatically when a signal changes. ```ts import { signal } from '@vielzeug/ripple'; import { createLineChart } from '@vielzeug/prism'; const data = signal([ { key: 1, value: 10 }, { key: 2, value: 20 }, ]); const chart = createLineChart(container, { series: [{ data, name: 'Series' }], // signal passed directly }); // Updating the signal triggers an automatic re-render: data.value = [ { key: 1, value: 15 }, { key: 2, value: 25 }, ]; ``` ### With Sourcerer Bind chart data to a Sourcerer remote source so charts update whenever the list refreshes. ```ts import { createRemoteSource } from '@vielzeug/sourcerer'; import { computed } from '@vielzeug/ripple'; import { createBarChart } from '@vielzeug/prism'; const source = createRemoteSource({ fetch: ({ page }) => api.stats.list({ page }) }); const chartData = computed(() => source.items.value.map((item) => ({ key: item.label, value: item.count }))); const chart = createBarChart(container, { series: [{ data: chartData, name: 'Series' }], }); ``` ## Best Practices - Ensure the container element has explicit dimensions before calling a chart factory — `ResizeObserver` needs a non-zero layout size to trigger the first render. - Call `chart.dispose()` in your framework's unmount/cleanup phase to cancel signal effects and remove DOM nodes. - Prefer `signal()` from Ripple for mutable data properties — charts re-render automatically when signals change, with no manual `update()` call. - Wrap a chart with `debugChart()` from the `/devtools` subpath only in development code paths; it is tree-shaken in production. - For SSR, skip chart creation server-side — Prism depends on DOM APIs and `ResizeObserver`. Render charts only after hydration in a `onMounted`/`useEffect` callback. ### Examples ## Line Chart Basic line chart with tooltip and crosshair: ```html const { createLineChart } = Prism; createLineChart(document.getElementById('ex-line'), { series: [ { name: 'Revenue', data: [ { key: 1, value: 120 }, { key: 2, value: 180 }, { key: 3, value: 150 }, { key: 4, value: 220 }, { key: 5, value: 195 }, { key: 6, value: 280 }, ], color: '#3b82f6', curve: 'monotone', strokeWidth: 2, showPoints: true, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, crosshair: true, }); ``` ## Multi-series Line Chart Multiple lines with different curves: ```html const { createLineChart } = Prism; createLineChart(document.getElementById('ex-multi-line'), { series: [ { name: 'Product A', data: [ { key: 1, value: 40 }, { key: 2, value: 65 }, { key: 3, value: 55 }, { key: 4, value: 80 }, { key: 5, value: 72 }, ], color: '#3b82f6', curve: 'monotone', }, { name: 'Product B', data: [ { key: 1, value: 20 }, { key: 2, value: 35 }, { key: 3, value: 60 }, { key: 4, value: 45 }, { key: 5, value: 90 }, ], color: '#10b981', curve: 'monotone', }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, crosshair: true, }); ``` ## Legend — Line Chart Add `legend: true` to label each series below the chart: ```html const { createLineChart } = Prism; createLineChart(document.getElementById('ex-legend-line'), { series: [ { name: 'Revenue', data: [ { key: 1, value: 120 }, { key: 2, value: 180 }, { key: 3, value: 150 }, { key: 4, value: 220 }, { key: 5, value: 195 }, ], color: '#3b82f6', curve: 'monotone', }, { name: 'Expenses', data: [ { key: 1, value: 80 }, { key: 2, value: 95 }, { key: 3, value: 110 }, { key: 4, value: 130 }, { key: 5, value: 125 }, ], color: '#ef4444', curve: 'monotone', }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, crosshair: true, legend: true, }); ``` ## Bar Chart Grouped bar chart comparing categories: ```html const { createBarChart } = Prism; createBarChart(document.getElementById('ex-bar'), { series: [ { name: '2023', data: [ { key: 'Q1', value: 120 }, { key: 'Q2', value: 180 }, { key: 'Q3', value: 150 }, { key: 'Q4', value: 210 }, ], color: '#94a3b8', borderRadius: 4, }, { name: '2024', data: [ { key: 'Q1', value: 150 }, { key: 'Q2', value: 220 }, { key: 'Q3', value: 190 }, { key: 'Q4', value: 280 }, ], color: '#3b82f6', borderRadius: 4, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, }); ``` ## Stacked Bar Chart Bar chart with `variant: 'stacked'` — series stack vertically per category: ```html const { createBarChart } = Prism; createBarChart(document.getElementById('ex-bar-stacked'), { series: [ { name: 'Mobile', data: [ { key: 'Q1', value: 80 }, { key: 'Q2', value: 110 }, { key: 'Q3', value: 95 }, { key: 'Q4', value: 130 }, ], color: '#3b82f6', borderRadius: 0, }, { name: 'Desktop', data: [ { key: 'Q1', value: 60 }, { key: 'Q2', value: 90 }, { key: 'Q3', value: 75 }, { key: 'Q4', value: 100 }, ], color: '#10b981', borderRadius: 0, }, { name: 'Tablet', data: [ { key: 'Q1', value: 20 }, { key: 'Q2', value: 30 }, { key: 'Q3', value: 25 }, { key: 'Q4', value: 35 }, ], color: '#f59e0b', borderRadius: 0, }, ], variant: 'stacked', xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, legend: true, }); ``` ## Horizontal Bar Chart Bar chart with `variant: 'grouped-horizontal'` — categories on the Y axis, values on the X axis: ```html const { createBarChart } = Prism; createBarChart(document.getElementById('ex-bar-horizontal'), { variant: 'grouped-horizontal', series: [ { name: 'Revenue', data: [ { key: 'Q1', value: 80 }, { key: 'Q2', value: 110 }, { key: 'Q3', value: 95 }, { key: 'Q4', value: 130 }, ], color: '#3b82f6', }, ], xAxis: { position: 'bottom', grid: true }, yAxis: { position: 'left' }, tooltip: true, }); ``` ## Horizontal Stacked Bar Chart Use `variant: 'stacked-horizontal'` — horizontal bars stacked per category: ```html const { createBarChart } = Prism; createBarChart(document.getElementById('ex-bar-h-stacked'), { variant: 'stacked-horizontal', series: [ { name: 'Mobile', data: [ { key: 'Q1', value: 80 }, { key: 'Q2', value: 110 }, { key: 'Q3', value: 95 }, { key: 'Q4', value: 130 }, ], color: '#3b82f6', borderRadius: 0, }, { name: 'Desktop', data: [ { key: 'Q1', value: 60 }, { key: 'Q2', value: 90 }, { key: 'Q3', value: 75 }, { key: 'Q4', value: 100 }, ], color: '#10b981', borderRadius: 0, }, ], xAxis: { position: 'bottom', grid: true }, yAxis: { position: 'left' }, tooltip: true, legend: true, }); ``` ## Legend — Bar Chart Grouped bar chart with a legend positioned at the top: ```html const { createBarChart } = Prism; createBarChart(document.getElementById('ex-legend-bar'), { series: [ { name: '2023', data: [ { key: 'Q1', value: 120 }, { key: 'Q2', value: 180 }, { key: 'Q3', value: 150 }, { key: 'Q4', value: 210 }, ], color: '#94a3b8', borderRadius: 4, }, { name: '2024', data: [ { key: 'Q1', value: 150 }, { key: 'Q2', value: 220 }, { key: 'Q3', value: 190 }, { key: 'Q4', value: 280 }, ], color: '#3b82f6', borderRadius: 4, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, legend: { position: 'top' }, }); ``` ## Area Chart Filled area with monotone curve and low opacity: ```html const { createAreaChart } = Prism; createAreaChart(document.getElementById('ex-area'), { series: [ { name: 'Signups', data: [ { key: 1, value: 500 }, { key: 2, value: 650 }, { key: 3, value: 800 }, { key: 4, value: 720 }, { key: 5, value: 900 }, { key: 6, value: 1100 }, ], color: '#8b5cf6', curve: 'monotone', fillOpacity: 0.2, showLine: true, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: { color: '#f1f5f9' } }, crosshair: { vertical: true }, }); ``` ## Legend — Area Chart Multi-series area chart with a bottom legend: ```html const { createAreaChart } = Prism; createAreaChart(document.getElementById('ex-legend-area'), { series: [ { name: 'Mobile', data: [ { key: 1, value: 300 }, { key: 2, value: 420 }, { key: 3, value: 510 }, { key: 4, value: 480 }, { key: 5, value: 620 }, { key: 6, value: 750 }, ], color: '#8b5cf6', curve: 'monotone', fillOpacity: 0.25, }, { name: 'Desktop', data: [ { key: 1, value: 200 }, { key: 2, value: 230 }, { key: 3, value: 290 }, { key: 4, value: 240 }, { key: 5, value: 280 }, { key: 6, value: 350 }, ], color: '#06b6d4', curve: 'monotone', fillOpacity: 0.25, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, crosshair: true, legend: true, }); ``` ## Step Line Chart Line chart with step interpolation: ```html const { createLineChart } = Prism; createLineChart(document.getElementById('ex-step'), { series: [ { name: 'Status', data: [ { key: 1, value: 0 }, { key: 2, value: 1 }, { key: 3, value: 1 }, { key: 4, value: 0 }, { key: 5, value: 1 }, { key: 6, value: 0 }, ], color: '#f59e0b', curve: 'step', strokeWidth: 3, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left' }, }); ``` ## Reactive Chart Chart that updates when signal data changes: ```html Add Data Point const { createLineChart } = Prism; const { signal } = Ripple; const data = signal([ { key: 1, value: 20 }, { key: 2, value: 35 }, { key: 3, value: 28 }, { key: 4, value: 45 }, ]); createLineChart(document.getElementById('ex-reactive'), { series: [{ name: 'Live', data, color: '#10b981', curve: 'monotone', showPoints: true }], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, crosshair: true, transition: { duration: 400, easing: 'ease-out' }, }); document.getElementById('ex-reactive-btn').addEventListener('click', function () { var prev = data.value; var nextX = prev.length + 1; var nextY = 20 + Math.floor(Math.random() * 40); data.value = prev.concat([{ key: nextX, value: nextY }]); }); ``` ## Reactive Bar Chart Bar chart that updates when signal data changes, with stagger animation on new bars: ```html Add Category const { createBarChart } = Prism; const { signal } = Ripple; const barData = signal([ { key: 'Q1', value: 120 }, { key: 'Q2', value: 180 }, { key: 'Q3', value: 150 }, { key: 'Q4', value: 210 }, ]); createBarChart(document.getElementById('ex-reactive-bar'), { series: [{ name: 'Revenue', data: barData, color: '#6366f1', borderRadius: 4 }], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, transition: { duration: 400, easing: 'ease-out', stagger: 40 }, }); var quarters = ['Q5', 'Q6', 'Q7', 'Q8']; var qIdx = 0; document.getElementById('ex-reactive-bar-btn').addEventListener('click', function () { if (qIdx >= quarters.length) return; var nextY = 150 + Math.floor(Math.random() * 120); barData.value = barData.value.concat([{ key: quarters[qIdx++], value: nextY }]); }); ``` ## Event Hooks Using `onHover` and `onClick` to react to chart interactions: ```html const { createLineChart } = Prism; const info = document.getElementById('ex-events-info'); createLineChart(document.getElementById('ex-events'), { series: [ { name: 'Revenue', data: [ { key: 1, value: 120 }, { key: 2, value: 180 }, { key: 3, value: 150 }, { key: 4, value: 220 }, { key: 5, value: 195 }, { key: 6, value: 280 }, ], color: '#3b82f6', curve: 'monotone', showPoints: true, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, onHover: function (event) { info.textContent = event ? 'Hovering key=' + event.datum.key + ' value=' + event.datum.value : ''; }, onClick: function (event) { info.textContent = 'Clicked key=' + event.datum.key + ' value=' + event.datum.value; }, }); ``` ## Pie Chart Basic pie chart with labeled slices: ```html const { createPieChart } = Prism; createPieChart(document.getElementById('ex-pie'), { data: [ { label: 'Direct', value: 42, color: '#3b82f6' }, { label: 'Organic', value: 28, color: '#10b981' }, { label: 'Referral', value: 18, color: '#f59e0b' }, { label: 'Social', value: 12, color: '#8b5cf6' }, ], variant: 'pie', transition: { duration: 600, easing: 'ease-out' }, }); ``` ## Donut Chart Donut chart with tooltip: ```html const { createPieChart } = Prism; createPieChart(document.getElementById('ex-donut'), { data: [ { label: 'Direct', value: 42, color: '#3b82f6' }, { label: 'Organic', value: 28, color: '#10b981' }, { label: 'Referral', value: 18, color: '#f59e0b' }, { label: 'Social', value: 12, color: '#8b5cf6' }, ], variant: 'donut', tooltip: true, transition: { duration: 600, easing: 'ease-out' }, }); ``` ## Semi-circle Donut Semicircle donut — useful for gauges and progress indicators: ```html const { createPieChart } = Prism; createPieChart(document.getElementById('ex-semi'), { data: [ { label: 'Used', value: 68, color: '#3b82f6' }, { label: 'Free', value: 32, color: '#e2e8f0' }, ], variant: 'semi', transition: { duration: 800, easing: 'ease-out' }, }); ``` ## Sparkline — Line Minimal inline sparkline inside a table cell or card: ```html const { createSparkline } = Prism; createSparkline(document.getElementById('ex-spark-line'), { data: [12, 18, 14, 22, 19, 28, 24, 32], variant: 'line', color: '#3b82f6', curve: 'monotone', strokeWidth: 1.5, }); ``` ## Sparkline — Area Area variant with fill: ```html const { createSparkline } = Prism; createSparkline(document.getElementById('ex-spark-area'), { data: [12, 18, 14, 22, 19, 28, 24, 32], variant: 'area', color: '#8b5cf6', curve: 'monotone', fillOpacity: 0.25, }); ``` ## Sparkline — Bar Bar variant — one rect per value: ```html const { createSparkline } = Prism; createSparkline(document.getElementById('ex-spark-bar'), { data: [12, 18, 14, 22, 19, 28, 24, 32], variant: 'bar', color: '#10b981', transition: { duration: 400, easing: 'ease-out', stagger: 30 }, }); ``` ## Sparkline — Reactive Sparkline that updates when signal data changes: ```html Add Point const { createSparkline } = Prism; const { signal } = Ripple; const sparkData = signal([10, 15, 12, 18, 14]); createSparkline(document.getElementById('ex-spark-reactive'), { data: sparkData, variant: 'area', color: '#f59e0b', curve: 'monotone', fillOpacity: 0.2, transition: { duration: 300, easing: 'ease-out' }, }); document.getElementById('ex-spark-btn').addEventListener('click', function () { sparkData.value = sparkData.value.concat([10 + Math.floor(Math.random() * 25)]); }); ``` ## Sparkline — Stack Horizontal stacked bar — proportional segments with per-segment colors: ```html const { createSparkline } = Prism; createSparkline(document.getElementById('ex-spark-stack'), { variant: 'stack', data: [ { label: 'Chrome', value: 341, color: '#3b82f6' }, { label: 'Safari', value: 217, color: '#06b6d4' }, { label: 'Firefox', value: 124, color: '#10b981' }, { label: 'Edge', value: 53, color: '#f59e0b' }, ], cornerRadius: 4, padPixels: 4, }); ``` ## Custom Tooltip Rich HTML tooltip with custom formatting: ```html const { createBarChart } = Prism; createBarChart(document.getElementById('ex-tooltip'), { series: [ { name: 'Revenue', data: [ { key: 'Jan', value: 4200 }, { key: 'Feb', value: 5100 }, { key: 'Mar', value: 4800 }, { key: 'Apr', value: 6300 }, { key: 'May', value: 5900 }, { key: 'Jun', value: 7200 }, ], color: '#6366f1', borderRadius: 6, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: { render: function (datum, series) { return ( '' + series.name + '' + '' + datum.key + '' + '$' + datum.value.toLocaleString() + '' ); }, }, }); ``` ## Pie Chart with Plugin A donut chart that installs a custom plugin to draw a total count label in the center hole: ```html const { createPieChart } = Prism; const data = [ { label: 'Direct', value: 42, color: '#6366f1' }, { label: 'Organic', value: 28, color: '#10b981' }, { label: 'Social', value: 18, color: '#f59e0b' }, { label: 'Referral', value: 12, color: '#8b5cf6' }, ]; const total = data.reduce((s, d) => s + d.value, 0); let centerLabel; const centerPlugin = { install(ctx) { const ns = 'http://www.w3.org/2000/svg'; centerLabel = document.createElementNS(ns, 'text'); centerLabel.setAttribute('text-anchor', 'middle'); centerLabel.setAttribute('dominant-baseline', 'middle'); centerLabel.setAttribute('font-size', '20'); centerLabel.setAttribute('font-weight', '600'); centerLabel.setAttribute('fill', 'var(--prism-text-color, #334155)'); centerLabel.textContent = total; ctx.svg.appendChild(centerLabel); // Position at SVG center once dimensions are available requestAnimationFrame(() => { const { width, height } = ctx.dimensions.value; if (width && height) { centerLabel.setAttribute('x', String(width / 2)); centerLabel.setAttribute('y', String(height / 2)); } }); }, dispose() { centerLabel?.remove(); }, }; createPieChart(document.getElementById('ex-pie-plugin'), { data, variant: 'donut', tooltip: true, transition: { duration: 400, easing: 'ease-out' }, plugins: [centerPlugin], }); ``` --- ## @vielzeug/pulse **Category:** websockets **Keywords:** websocket, realtime, channels, presence, reconnect, heartbeat, typed-messaging, ripple **Key exports:** createPulse, Pulse, PulseChannel, PresenceChannel, PulseOptions, BufferOptions, PulseError, PulseConnectionError, PulseTimeoutError, PulseAbortError, PulseDisposedError, PulseProtocolError **Related:** herald, ripple, courier, clockwork ### Overview ## Why Pulse? Raw WebSocket gives you an untyped message stream — no event routing, no reconnection, no presence, no lifecycle management. Building those primitives for every project is repetitive and error-prone. ```ts // Before — raw WebSocket const ws = new WebSocket('wss://api.example.com/ws'); ws.addEventListener('message', (ev) => { const { type, payload } = JSON.parse(ev.data); // untyped if (type === 'chat:message') renderMessage(payload); // manual routing }); ws.addEventListener('close', () => setTimeout(reconnect, 3_000)); // manual reconnect // No channels, no presence, no heartbeat, no disposal // After — Pulse const pulse = createPulse('wss://api.example.com/ws', { reconnect: { maxAttempts: 5 }, heartbeat: true, }); pulse.on('chat:message', ({ user, text }) => renderMessage({ user, text })); // fully typed pulse.send('chat:send', { text: 'Hello!' }); effect(() => console.log('status:', pulse.status.value)); // reactive via ripple ``` | Feature | Pulse | Native WebSocket | socket.io-client | | --------------------- | ---------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- | | Bundle size | | 0 B (native) | ~44 kB gzip | | TypeScript inference | Full | None | Basic | | Auto-reconnect | | | | | Heartbeat (ping/pong) | | | | | Channel multiplexing | | | | | Reactive presence | | | Manual | | Reactive status | | | | | Server lock-in | None | None | Required | | Zero dependencies | ripple | | | **Use Pulse when** you need typed, multiplexed real-time messaging with reactive state and a clean disposal lifecycle — without being locked to a specific server stack. **Consider native WebSocket when** you need the absolute minimum footprint and are building a one-off, untyped connection with no reuse patterns. ## Installation ```sh [pnpm] pnpm add @vielzeug/pulse @vielzeug/ripple ``` ```sh [npm] npm install @vielzeug/pulse @vielzeug/ripple ``` ```sh [yarn] yarn add @vielzeug/pulse @vielzeug/ripple ``` ## Quick Start ```ts import { createPulse } from '@vielzeug/pulse'; import { effect } from '@vielzeug/ripple'; type ServerEvents = { 'chat:message': { user: string; text: string }; 'user:joined': { userId: string }; }; type ClientEvents = { 'chat:send': { text: string }; }; const pulse = createPulse('wss://api.example.com/ws', { reconnect: { maxAttempts: 5, delay: (n) => Math.min(1000 * 2 ** n, 30_000) }, heartbeat: true, }); // Reactive status via ripple signal effect(() => console.log('connection:', pulse.status.value)); // Typed server events pulse.on('chat:message', ({ user, text }) => console.log(`${user}: ${text}`)); // Typed client messages pulse.send('chat:send', { text: 'Hello!' }); // Isolated channel namespace const notif = pulse.channel('notifications'); notif.on('alert', ({ level, msg }) => showNotification(level, msg)); // Reactive presence tracking const lobby = pulse.presence('lobby'); effect(() => console.log('online:', [...lobby.state.value.keys()])); lobby.update({ name: 'Alice', status: 'active' }); // Clean disposal using _ = pulse; ``` ## Features - **Typed event maps** — `TServer` and `TClient` generics enforce payload types on both sides of the wire - **`on()` / `once()` / `wait()`** — persistent, one-shot, and async-await event subscriptions - **`channel()`** — isolated namespaces multiplexed over the shared connection; **same name returns the same object** (memoized); auto-resubscribed on reconnect; `dispose()` sends an `unsubscribe` frame - **`join()` / `leave()`** — room membership with server-confirmation promises; optional `timeout` and `AbortSignal` support - **`presence()`** — reactive `Signal>` state, with `onJoin`/`onLeave` callbacks and `update()` for broadcasting state - **Middleware pipeline** — intercept every outgoing `send()` call; omit `next()` to suppress - **Auto-reconnect** — exponential backoff (full-jitter by default), configurable `maxAttempts`, custom `delay` function, and `onReconnect` callback - **Heartbeat** — configurable ping/pong keep-alive with dead-connection detection and automatic reconnect trigger - **Reactive `status` signal** — `'connecting' | 'open' | 'reconnecting' | 'closed'` exposed as a ripple `Readable` - **Reactive `rooms` signal** — current room membership as a `Readable>` - **`disposalSignal`** — `AbortSignal` that fires on `dispose()`; ties external cleanup to the connection lifetime - **`dispose()` and `[Symbol.dispose]`** — deterministic teardown; closes the socket, clears all listeners, aborts pending `wait()` calls - **Message buffering** — `buffer: true` queues outgoing frames while disconnected and flushes on reconnect; configurable `maxSize` - **Lazy connection** — `lazy: true` defers the initial connection until `connect()` is called explicitly - **Protocol-agnostic** — works with any WebSocket server that speaks the Pulse JSON frame format - **Single dependency** — only requires `@vielzeug/ripple` for reactive state ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — the reactive signal library powering `pulse.status`, `pulse.rooms`, and `presence.state` - [Herald](/herald/) — typed in-process event bus; complement Pulse by bridging incoming WebSocket events to application-wide bus dispatches - [Courier](/courier/) — typed HTTP client for the request/response traffic that runs alongside your WebSocket connection - [Clockwork](/clockwork/) — finite state machine; model complex reconnection or auth-handshake logic as a proper state machine ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------- | --------------------------------------------- | -------------- | ------------------------------------------------------------------------- | | `createPulse()` | Create a typed WebSocket client instance | Sync | Connects immediately by default; pass `lazy: true` to defer | | `pulse.on()` | Subscribe to a typed server event | Sync | Returns an `Unsubscribe`; always call it on component teardown | | `pulse.once()` | One-shot server event subscription | Sync | Listener auto-removes after first fire | | `pulse.send()` | Send a typed client event | Sync | Buffered when `buffer: true`; dropped (dev warn) otherwise | | `pulse.wait()` | Await the next server event | Async | Rejects with `PulseAbortError` on disposal; use `timeout` for a deadline | | `pulse.connect()` | Open the connection explicitly | Async | Required when `lazy: true`; otherwise called automatically on creation | | `pulse.disconnect()` | Close without triggering reconnect | Sync | Pass code `1000` for a clean close | | `pulse.join()` | Join a room; resolves on server confirmation | Async | Rejects with `PulseAbortError` if pulse is disposed before server replies | | `pulse.leave()` | Leave a room; resolves on server confirmation | Async | Room is removed from `pulse.rooms` only after server confirms | | `pulse.channel()` | Create an isolated channel namespace | Sync | Same name returns the **same** object; `dispose()` sends unsubscribe | | `pulse.presence()` | Reactive presence channel for a room | Sync | Implicitly joins the room; `dispose()` to stop tracking | | `pulse.dispose()` | Permanently close and release all resources | Sync | Idempotent; also aborts `disposalSignal` | ## Package Entry Point | Import | Purpose | | ----------------- | ---------------------------- | | `@vielzeug/pulse` | All public exports and types | ## `createPulse()` ```ts createPulse( url: string, opts?: PulseOptions, ): Pulse ``` Creates and returns a new `Pulse` instance. The WebSocket connection opens immediately on creation. **Parameters:** | Parameter | Type | Description | | --------- | -------------- | ---------------------------------- | | `url` | `string` | WebSocket server URL (`wss://…`) | | `opts` | `PulseOptions` | Optional configuration (see below) | **Parameters — `PulseOptions`:** | Option | Type | Default | Description | | ------------- | ---------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | | `buffer` | `boolean \| BufferOptions` | `false` | `true` uses defaults (`maxSize: 50`); buffers outgoing frames while disconnected, flushes on reconnect | | `heartbeat` | `boolean \| HeartbeatOptions` | `false` | `true` uses defaults; `false` disables; object for custom interval/timeout | | `lazy` | `boolean` | `false` | `true` defers the initial connection until `connect()` is called explicitly | | `middleware` | `readonly Middleware[]` | `[]` | Functions run on every outgoing `send()` before the message is written to the socket | | `onClose` | `(code: number, reason: string) => void` | — | Called when the connection is closed by either side | | `onError` | `(error: Error) => void` | — | Called on a WebSocket error event; errors almost always precede a close | | `onMessage` | `(event: MessageEvent) => void` | — | Called with every raw `MessageEvent` before parsing; useful for low-level debugging | | `onOpen` | `() => void` | — | Called when the connection is established or re-established | | `onReconnect` | `(attempt: number) => void` | — | Called at the start of each reconnect attempt; `attempt` is 1-based | | `protocols` | `string \| string[]` | — | Sub-protocols passed to the `WebSocket` constructor | | `reconnect` | `boolean \| ReconnectOptions` | `false` | `true` uses defaults; `false` disables; object for custom delay/maxAttempts | **Returns:** `Pulse` **Example:** ```ts import { createPulse } from '@vielzeug/pulse'; type ServerEvents = { 'chat:message': { user: string; text: string } }; type ClientEvents = { 'chat:send': { text: string } }; const pulse = createPulse('wss://api.example.com/ws', { reconnect: { maxAttempts: 5 }, heartbeat: true, onOpen: () => console.log('connected'), onClose: (code, reason) => console.log('closed', code, reason), }); ``` ## Pulse Interface ### `pulse.status` Type: `Readable` Reactive connection status. Subscribe with ripple `effect()` to react to status changes. ```ts import { effect } from '@vielzeug/ripple'; effect(() => updateStatusBadge(pulse.status.value)); // Read without subscribing console.log(pulse.status.value); // 'connecting' | 'open' | 'reconnecting' | 'closed' ``` --- ### `pulse.rooms` Type: `Readable>` Reactive set of rooms the client is currently a confirmed member of. ```ts import { computed } from '@vielzeug/ripple'; const roomCount = computed(() => pulse.rooms.value.size); ``` --- ### `pulse.disposed` Type: `readonly boolean` `true` after `dispose()` has been called. --- ### `pulse.disposalSignal` Type: `readonly AbortSignal` An `AbortSignal` that aborts when `dispose()` is called. Use it to tie external lifetimes to the connection. ```ts // Cancel a fetch when the pulse is disposed fetch('/api/stream', { signal: pulse.disposalSignal }); ``` --- ### `pulse.on()` ```ts on>(event: K, handler: (payload: TServer[K]) => void): Unsubscribe ``` Subscribe to a typed server event. Returns an `Unsubscribe` function; call it to remove the listener. | Parameter | Type | Description | | --------- | ------------------------------- | -------------------------- | | `event` | `K` (EventKey of TServer) | Server event name | | `handler` | `(payload: TServer[K]) => void` | Callback for each delivery | **Returns:** `Unsubscribe` ```ts const unsub = pulse.on('chat:message', ({ user, text }) => appendToLog(user, text)); unsub(); // remove when done ``` --- ### `pulse.once()` ```ts once>(event: K, handler: (payload: TServer[K]) => void): Unsubscribe ``` Registers a listener that fires exactly once, then removes itself. Returns an `Unsubscribe` for early cancellation. ```ts pulse.once('user:joined', ({ userId }) => showWelcome(userId)); ``` --- ### `pulse.send()` ```ts send>(event: K, payload: TClient[K]): void ``` Send a typed event to the server. When the connection is not open: - If `buffer: true` is set, the message is queued and flushed on the next successful open. - Otherwise the message is dropped and a dev warning is emitted. ```ts pulse.send('chat:send', { text: 'Hello!' }); ``` --- ### `pulse.wait()` ```ts wait>(event: K, opts?: { signal?: AbortSignal; timeout?: number }): Promise ``` Returns a promise that resolves with the payload of the next server emission of `event`. | Parameter | Type | Description | | -------------- | ------------- | ------------------------------------------------- | | `event` | `K` | Server event name to await | | `opts.signal` | `AbortSignal` | Optional; rejects with `PulseAbortError` when it fires | | `opts.timeout` | `number` | Optional; rejects with `PulseTimeoutError` after ms | **Rejects when:** - `opts.signal` fires — rejects with `PulseAbortError` - `opts.timeout` elapses — rejects with `PulseTimeoutError` - The pulse is disposed — rejects with `PulseAbortError` ```ts const msg = await pulse.wait('chat:message', { timeout: 5_000 }); ``` --- ### `pulse.connect()` ```ts connect(): Promise ``` Opens the WebSocket connection. Resolves when the connection is open. Returns immediately if already open. > **Note:** When `lazy: false` (default) the connection opens automatically on construction. Use `lazy: true` to defer and call `connect()` explicitly. **Rejects when:** - Already disposed — `PulseDisposedError` - Socket closes before it opens — `PulseConnectionError` - Socket error — `PulseConnectionError` ```ts await pulse.connect(); ``` --- ### `pulse.disconnect()` ```ts disconnect(code?: number, reason?: string): void ``` Closes the WebSocket without triggering auto-reconnect. Status transitions to `'closed'`. | Parameter | Type | Default | Description | | --------- | -------- | ------- | --------------------- | | `code` | `number` | `1000` | WebSocket close code | | `reason` | `string` | `''` | Human-readable reason | ```ts pulse.disconnect(1000, 'user signed out'); ``` --- ### `pulse.join()` ```ts join(room: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise ``` Requests to join a room. Resolves when the server confirms with a `joined` frame. The room is added to `pulse.rooms` on confirmation. | Parameter | Type | Description | | -------------- | ------------- | ------------------------------------------------- | | `room` | `string` | Room name | | `opts.signal` | `AbortSignal` | Optional; rejects with `PulseAbortError` on fire | | `opts.timeout` | `number` | Optional; rejects with `PulseTimeoutError` after ms | **Rejects when:** - Already disposed — `PulseDisposedError` - The signal fires — `PulseAbortError` - `opts.timeout` elapses — `PulseTimeoutError` - The pulse is disposed before confirmation — `PulseAbortError` ```ts await pulse.join('lobby', { timeout: 5_000 }); ``` --- ### `pulse.leave()` ```ts leave(room: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise ``` Requests to leave a room. Resolves when the server confirms with a `left` frame. The room is removed from `pulse.rooms` on confirmation. If the socket is not open, `leave()` connects first (mirroring `join()` behaviour). **Rejects when:** - Already disposed — `PulseDisposedError` - The signal fires — `PulseAbortError` - `opts.timeout` elapses — `PulseTimeoutError` - Connection fails — `PulseConnectionError` ```ts await pulse.leave('lobby'); ``` --- ### `pulse.channel()` ```ts channel( name: string, ): PulseChannel ``` Returns a `PulseChannel` scoped to `name`. Multiple calls with the **same name return the same object** — the channel is memoized. The subscription is automatically re-sent on reconnect. Disposing the channel sends an `unsubscribe` frame and evicts it from the cache. ```ts const chat = pulse.channel('chat'); const same = pulse.channel('chat'); console.log(chat === same); // true ``` --- ### `pulse.presence()` ```ts presence(room: string): PresenceChannel ``` Returns a `PresenceChannel` that tracks all members' state in `room`. Implicitly joins the room. ```ts const lobby = pulse.presence('lobby'); ``` --- ### `pulse.dispose()` ```ts dispose(): void ``` Permanently closes the connection and releases all resources: - Closes the WebSocket with code `1000` - Clears all listeners - Rejects all pending `wait()`, `join()`, and `leave()` promises with `PulseDisposedError` - Rejects any in-flight `connect()` with `PulseDisposedError` - Aborts `disposalSignal` Idempotent — safe to call multiple times. --- ### `pulse[Symbol.dispose]()` ```ts [Symbol.dispose](): void ``` Alias for `dispose()`. Enables the `using` keyword: ```ts { using pulse = createPulse('wss://api.example.com/ws'); // ... } // dispose() called automatically ``` ## PulseChannel Interface Obtain via `pulse.channel(name)`. ### `channel.on()` ```ts on>(event: K, handler: (payload: TServer[K]) => void): Unsubscribe ``` Subscribe to a server event scoped to this channel. Listeners are auto-removed on `channel.dispose()`. --- ### `channel.once()` ```ts once>(event: K, handler: (payload: TServer[K]) => void): Unsubscribe ``` One-shot subscription scoped to this channel. --- ### `channel.send()` ```ts send>(event: K, payload: TClient[K]): void ``` Send a typed message to the server scoped to this channel. No-op if the pulse connection is not open. --- ### `channel.wait()` ```ts wait>(event: K, opts?: { signal?: AbortSignal; timeout?: number }): Promise ``` Resolves on the next emission of the given event within this channel. Rejects when: - `opts.signal` fires — `PulseAbortError` - `opts.timeout` elapses — `PulseTimeoutError` - The channel is disposed — `PulseAbortError` --- ### `channel.dispose()` ```ts dispose(): void ``` Removes all channel listeners, sends an `unsubscribe` frame, and evicts the channel from the memoization cache. The underlying connection is unaffected. --- ### `channel.disposed` Type: `readonly boolean` `true` after `dispose()` has been called. --- ### `channel.disposalSignal` Type: `readonly AbortSignal` An `AbortSignal` that aborts when `dispose()` is called. ```ts fetch('/api', { signal: channel.disposalSignal }); ``` --- ### `channel.name` Type: `readonly string` The channel name passed to `pulse.channel()`. --- ### `channel[Symbol.dispose]()` Alias for `dispose()`. Enables `using` declarations. ## PresenceChannel Interface Obtain via `pulse.presence(room)`. ### `presence.state` Type: `Readable>` Reactive map of `memberId → state`. Updates whenever any member joins, leaves, or changes state. ```ts import { effect } from '@vielzeug/ripple'; effect(() => { for (const [id, state] of lobby.state.value) { renderAvatar(id, state); } }); ``` --- ### `presence.onJoin()` ```ts onJoin(handler: (memberId: string, state: T) => void): Unsubscribe ``` Registers a callback fired whenever a new member joins with their initial state. Returns an `Unsubscribe`. --- ### `presence.onLeave()` ```ts onLeave(handler: (memberId: string) => void): Unsubscribe ``` Registers a callback fired whenever a member leaves. Returns an `Unsubscribe`. --- ### `presence.update()` ```ts update(state: T): void ``` Broadcasts this client's presence state to all room members. Also serves as an implicit join if not already in the room. --- ### `presence.room` Type: `readonly string` The room name passed to `pulse.presence()`. --- ### `presence.disposed` Type: `readonly boolean` `true` after `dispose()` has been called. --- ### `presence.dispose()` ```ts dispose(): void ``` Stops tracking the room, removes all join/leave callbacks, and sends a `leave` frame to the server. --- ### `presence.disposalSignal` Type: `readonly AbortSignal` An `AbortSignal` that aborts when `dispose()` is called. --- ### `presence[Symbol.dispose]()` Alias for `dispose()`. Enables `using` declarations. ## Types ```ts /** A map of event name → payload type. */ type MessageMap = Record; /** Extract valid event key strings from a MessageMap. */ type EventKey = keyof T & string; /** A function that removes a listener subscription. */ type Unsubscribe = () => void; /** Lifecycle state of a Pulse connection. */ type PulseStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'; /** A read-only view of a Map — callers cannot mutate the entries. */ type ReadonlyMap = Omit, 'clear' | 'delete' | 'set'>; ``` ```ts type ReconnectOptions = { /** * Delay strategy between attempts (ms). * number = fixed delay; function = (attempt: number) => ms. * Defaults to full-jitter exponential backoff capped at 30 s. */ delay?: number | ((attempt: number) => number); /** Maximum reconnect attempts. Default: 5. */ maxAttempts?: number; }; ``` ```ts type HeartbeatOptions = { /** Interval between pings in ms. Default: 30_000. */ interval?: number; /** How long to wait for a pong before treating the connection as dead. Default: 5_000. */ timeout?: number; }; ``` ```ts /** * Intercepts outgoing messages. Call next() to allow; omit to suppress. */ type Middleware = (event: string, payload: unknown, next: () => void) => void; ``` ```ts type BufferOptions = { /** Maximum number of frames to buffer. Oldest evicted when full. Default: 50. */ maxSize?: number; }; ``` ```ts type PulseOptions = { buffer?: boolean | BufferOptions; heartbeat?: boolean | HeartbeatOptions; lazy?: boolean; middleware?: readonly Middleware[]; onClose?: (code: number, reason: string) => void; onError?: (error: Error) => void; onMessage?: (event: MessageEvent) => void; onOpen?: () => void; onReconnect?: (attempt: number) => void; protocols?: string | string[]; reconnect?: boolean | ReconnectOptions; }; ``` ## Errors All errors extend `PulseError`. Use `instanceof PulseError` to catch any pulse-originated error in one branch. All error constructors accept a trailing `opts?: ErrorOptions`, so you can chain a `cause`: `new PulseTimeoutError('chat:message', { cause })`. | Class | Extends | Triggers when | Notable properties | | ---------------------- | ------------ | --------------------------------------------------------------------------- | ------------------ | | `PulseError` | `Error` | Base class — never thrown directly | — | | `PulseConnectionError` | `PulseError` | Connection cannot be established or is lost with reconnect budget exhausted | `url: string` | | `PulseTimeoutError` | `PulseError` | `wait()` `timeout` elapses before the event arrives | `event: string` | | `PulseAbortError` | `PulseError` | `wait()`, `join()`, or `leave()` is aborted via signal or pulse disposal | — | | `PulseDisposedError` | `PulseError` | A method is called on a disposed instance or channel | — | | `PulseProtocolError` | `PulseError` | The server sends a frame that cannot be parsed or has no `type` field | `raw: unknown` | ```ts import { PulseAbortError, PulseError, PulseTimeoutError } from '@vielzeug/pulse'; try { await pulse.wait('chat:message', { timeout: 5_000 }); } catch (err) { if (err instanceof PulseTimeoutError) { console.warn('no message in 5 s, event:', err.event); } else if (err instanceof PulseAbortError) { console.log('aborted or pulse disposed'); } else if (err instanceof PulseError) { console.error('unexpected pulse error', err); } } ``` ### Usage Guide Start with the [Overview](./index.md) for installation and a quick start, then return here for in-depth usage patterns. ## Basic Usage A message map is a plain TypeScript type where each key is an event name and each value is the payload type. Define separate maps for server-to-client and client-to-server traffic. ```ts import { createPulse } from '@vielzeug/pulse'; type ServerEvents = { 'chat:message': { user: string; text: string }; 'user:joined': { userId: string }; }; type ClientEvents = { 'chat:send': { text: string }; }; const pulse = createPulse('wss://api.example.com/ws'); pulse.on('chat:message', ({ user, text }) => { console.log(`${user}: ${text}`); // payload is fully typed }); pulse.send('chat:send', { text: 'Hello!' }); ``` ## Connection Management ### Reactive status `pulse.status` is a ripple `Reactive`. Use `effect()` to react to connection state changes. ```ts import { effect } from '@vielzeug/ripple'; // 'connecting' | 'open' | 'reconnecting' | 'closed' effect(() => { document.title = pulse.status.value === 'open' ? 'Live' : 'Reconnecting…'; }); ``` ### Explicit connect and disconnect By default the connection opens as soon as `createPulse` is called. Pass `lazy: true` to defer it until you call `connect()` explicitly — useful when the connection should not open until after a user gesture or auth check. ```ts // Default — connects immediately const pulse = createPulse('wss://api.example.com/ws'); // Lazy — deferred until connect() is called const pulse = createPulse('wss://api.example.com/ws', { lazy: true }); await pulse.connect(); // resolves when the socket is open pulse.disconnect(1000, 'user logged out'); // clean close, no reconnect ``` `disconnect()` closes the socket and sets status to `'closed'` without triggering auto-reconnect. ## Subscribing to Server Events ### `on()` — Persistent listener `on()` subscribes to every future emission of an event. It returns an `Unsubscribe` function. ```ts const unsub = pulse.on('chat:message', ({ user, text }) => { appendToChat(user, text); }); // Remove the listener when no longer needed unsub(); ``` ### `once()` — One-shot listener `once()` fires exactly once, then removes itself. ```ts pulse.once('user:joined', ({ userId }) => { showWelcomeBanner(userId); }); ``` ### `wait()` — Async one-shot `wait()` returns a promise that resolves with the next emitted payload. Pass `signal` or `timeout` to add a deadline. ```ts // Wait for the next server-push notification const msg = await pulse.wait('chat:message'); // With a timeout (ms) const msg = await pulse.wait('chat:message', { timeout: 5_000 }); // With an AbortSignal const msg = await pulse.wait('chat:message', { signal: AbortSignal.timeout(5_000) }); ``` `wait()` rejects with `PulseTimeoutError` when `timeout` elapses, with `PulseAbortError` when the signal fires, and with `PulseAbortError` when the pulse is disposed before the event arrives. ## Channels A channel is an isolated message namespace multiplexed over the same WebSocket connection. Use separate channels to scope events to logical subsystems. ```ts // Separate type maps per channel type NotifServer = { alert: { level: 'info' | 'warn' | 'error'; msg: string } }; type ChatServer = { message: { user: string; text: string } }; type ChatClient = { send: { text: string } }; const notif = pulse.channel('notifications'); const chat = pulse.channel('chat'); notif.on('alert', ({ level, msg }) => showToast(level, msg)); chat.on('message', ({ user, text }) => appendToLog(user, text)); chat.send('send', { text: 'hi' }); ``` Multiple calls with the same name return the **same channel object** — the channel is memoized. Dispose it once to fully remove the subscription and send an `unsubscribe` frame. After disposal, calling `pulse.channel()` with the same name creates a fresh channel. ### Channel disposal Disposing a channel removes all its listeners, sends an `unsubscribe` frame to the server, and evicts it from the cache. The underlying connection is unaffected. ```ts using chat = pulse.channel('chat'); // — or manually: chat.dispose(); chat.disposed; // true // pulse.channel('chat') now returns a fresh channel ``` ## Rooms `join()` requests membership in a named room. It resolves when the server confirms with a `joined` frame. ```ts await pulse.join('lobby'); console.log(pulse.rooms.value.has('lobby')); // true — reactive signal await pulse.leave('lobby'); console.log(pulse.rooms.value.has('lobby')); // false ``` Pass a `timeout` or `AbortSignal` to bound the wait: ```ts // Reject with PulseTimeoutError if server doesn't confirm within 5 s await pulse.join('lobby', { timeout: 5_000 }); // Or cancel with an AbortSignal const ctrl = new AbortController(); const joinP = pulse.join('arena', { signal: ctrl.signal }); ctrl.abort(); // rejects joinP with PulseAbortError ``` `pulse.rooms` is a `Readable>`. Derive computed views with ripple: ```ts import { computed } from '@vielzeug/ripple'; const roomCount = computed(() => pulse.rooms.value.size); ``` ## Presence `presence()` returns a presence channel for a room. It implicitly joins the room and begins tracking members. ```ts type MemberState = { name: string; status: 'online' | 'away' }; const lobby = pulse.presence('lobby'); // Reactive member map — updates on every join, leave, or state change import { effect } from '@vielzeug/ripple'; effect(() => { for (const [id, state] of lobby.state.value) { console.log(id, state.name, state.status); } }); // React to membership events lobby.onJoin((memberId, state) => showJoinBanner(state.name)); lobby.onLeave((memberId) => removeAvatarFromList(memberId)); // Broadcast your own state (also serves as join confirmation) lobby.update({ name: 'Alice', status: 'online' }); ``` ### Presence disposal Disposing a presence channel stops tracking, removes all join/leave callbacks, and sends a `leave` frame to the server. ```ts using _ = lobby; // — or — lobby.dispose(); // also sends 'leave' frame ``` ## Middleware Middleware intercepts every outgoing `send()` before the message hits the socket. Call `next()` to allow the send; omit it to suppress. ```ts const pulse = createPulse('wss://api.example.com/ws', { middleware: [ // Logging middleware (event, payload, next) => { console.debug('[ws out]', event, payload); next(); }, // Rate-limiting middleware (event, _payload, next) => { if (rateLimiter.allow(event)) next(); // omit next() to drop the message }, ], }); ``` Middleware only applies to application messages sent via `send()`. Internal frames (ping, join, subscribe) bypass the pipeline. ## Reconnect & Heartbeat ### Auto-reconnect Enable reconnect with `true` (uses defaults) or a `ReconnectOptions` object. ```ts const pulse = createPulse('wss://api.example.com/ws', { reconnect: { maxAttempts: 10, // default: 5 delay: 1_000, // fixed 1 s delay — or a function: // delay: (n) => Math.min(500 * 2 ** n, 30_000) }, }); ``` When reconnect is enabled, an unexpected close transitions `status` to `'reconnecting'`. After the budget is exhausted without success, `status` moves to `'closed'`. The default `delay` is full-jitter exponential backoff: `Math.random() * Math.min(1000 * 2^n, 30_000)`. ### Heartbeat Enable heartbeat with `true` (uses defaults) or a `HeartbeatOptions` object. ```ts const pulse = createPulse('wss://api.example.com/ws', { heartbeat: { interval: 30_000, // ms between pings — default: 30_000 timeout: 5_000, // ms to wait for pong before treating connection as dead — default: 5_000 }, }); ``` When a pong is not received within `timeout` ms, the socket is closed and — if reconnect is enabled — a reconnect attempt is triggered. ## Disposal Disposing a pulse instance closes the WebSocket, clears all listeners, rejects pending `wait()` / `join()` / `leave()` promises, and aborts the `disposalSignal`. ```ts // using declaration — dispose() called automatically at block exit using pulse = createPulse('wss://api.example.com/ws'); // — or manually: pulse.dispose(); pulse.disposed; // true ``` ### `disposalSignal` `pulse.disposalSignal` is an `AbortSignal` that fires when `dispose()` is called. Use it to tie external cleanup to the connection lifetime. ```ts // Automatically cancel a fetch when the pulse is disposed fetch('/api/init', { signal: pulse.disposalSignal }); // Unsubscribe from another system when pulse tears down externalBus.on('theme', applyTheme, { signal: pulse.disposalSignal }); ``` ## Framework Integration ```ts [React] import { createPulse } from '@vielzeug/pulse'; import { useEffect, useSyncExternalStore } from 'react'; function usePulseStatus(pulse: ReturnType) { return useSyncExternalStore( (cb) => { const unsub = pulse.status.subscribe(cb); return unsub; }, () => pulse.status.value, ); } function Chat() { const status = usePulseStatus(pulse); useEffect(() => { const unsub = pulse.on('chat:message', ({ user, text }) => { appendToLog(user, text); }); return unsub; }, []); return Status: {status}; } ``` ```ts [Vue 3] import { createPulse } from '@vielzeug/pulse'; import { onUnmounted, ref, watchEffect } from 'vue'; export function usePulse(url: string) { const pulse = createPulse(url, { reconnect: true }); const status = ref(pulse.status.value); const unsub = pulse.status.subscribe((s) => { status.value = s; }); onUnmounted(() => pulse.dispose()); return { pulse, status }; } ``` ```ts [Svelte] import { createPulse } from '@vielzeug/pulse'; import { onDestroy } from 'svelte'; import { readable } from 'svelte/store'; const pulse = createPulse('wss://api.example.com/ws', { reconnect: true }); // Wrap ripple signal in a Svelte readable store const status = readable(pulse.status.value, (set) => { return pulse.status.subscribe(set); }); onDestroy(() => pulse.dispose()); ``` ## Working with Other Vielzeug Libraries ### Herald — bridge WebSocket events to an app bus Route incoming server events through a Herald bus so the rest of your application doesn't need to know about the WebSocket. ```ts import { createPulse } from '@vielzeug/pulse'; import { createBus } from '@vielzeug/herald'; type AppEvents = { 'chat:message': { user: string; text: string }; }; const pulse = createPulse('wss://api.example.com/ws'); const bus = createBus(); // Forward all WebSocket events to the Herald bus pulse.on('chat:message', (payload) => bus.emit('chat:message', payload)); // The rest of the app only knows about the bus bus.on('chat:message', ({ user, text }) => appendToLog(user, text)); // Dispose both together pulse.disposalSignal.addEventListener('abort', () => bus.dispose(), { once: true }); ``` ### Ripple — derive computed views from reactive signals ```ts import { computed, effect } from '@vielzeug/ripple'; const pulse = createPulse('wss://api.example.com/ws'); const lobby = pulse.presence('lobby'); const memberCount = computed(() => lobby.state.value.size); const memberNames = computed(() => [...lobby.state.value.values()].map((s) => s.name)); effect(() => { document.querySelector('#count')!.textContent = String(memberCount.value); }); ``` ## Message Buffering Enable `buffer: true` to queue outgoing frames while the connection is not open. The queue is flushed automatically on the next successful open. ```ts const pulse = createPulse('wss://api.example.com/ws', { reconnect: true, buffer: true, // queue up to 50 frames (default) // buffer: { maxSize: 20 }, // or a custom limit }); // Messages sent during reconnect are queued, not dropped pulse.send('chat:send', { text: 'Queued while offline' }); ``` When the buffer is full, the **oldest** frame is evicted to make room for the new one. With buffering disabled (default), a `send()` while disconnected is a no-op and emits a dev-mode warning. ## Best Practices - **Define message maps upfront.** Separate `ServerEvents` and `ClientEvents` types make protocol changes a compile error, not a runtime surprise. - **One `createPulse` instance per connection.** Multiple instances to the same URL open multiple sockets. Share a single instance across your application. - **Always dispose.** Call `pulse.dispose()` or use `using` to prevent socket and listener leaks in component/module teardown. - **Use `disposalSignal` to chain cleanups.** Pass `pulse.disposalSignal` to any external subscription or fetch so teardown is automatic. - **Enable reconnect for production.** `reconnect: true` uses sensible defaults; override `maxAttempts` and `delay` only when you have measured the right values. - **Scope messages with channels.** Use `channel()` when building features that own a domain namespace — it keeps listener cleanup isolated. - **Enable buffering when ordering matters.** Use `buffer: true` with reconnect so messages sent during brief disconnects are not silently dropped. ### Examples ## Examples - [Basic Connection](./examples/basic-connection.md) - [Channel Multiplexing](./examples/channels.md) - [Rooms and Presence](./examples/rooms-and-presence.md) - [Reconnect and Heartbeat](./examples/reconnect-and-heartbeat.md) - [Outgoing Middleware](./examples/middleware.md) ### REPL Examples - Typed Channels (id: `channels`) - Connect & Send (id: `connect-and-send`) - Lifecycle & Disposal (id: `lifecycle`) - Reconnect & onReconnect (id: `reconnect`) - Rooms & Presence (id: `rooms-presence`) --- ## @vielzeug/refine **Category:** ui-components **Keywords:** web-components, accessible, themeable, ui, components, design-system **Key exports:** ore-accordion, ore-accordion-item, ore-alert, ore-async, ore-avatar, ore-avatar-group, ore-badge, ore-box, ore-breadcrumb, ore-breadcrumb-item, ore-button, ore-button-group (+48 more) **Related:** ore, orbit, forge ### Overview ## Why Refine? Every project needs UI primitives. Refine provides accessible web components that work natively anywhere HTML is rendered—no framework required. ```html Save Save ``` | Feature | Refine | Shoelace | Material Web | | ------------------ | ------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~145 kB | ~200 kB | | Built with | Ore | Lit | Lit | | Accessible | WCAG AA | WCAG AA | WCAG AA | | Framework agnostic | | | | **Use Refine when** you want accessible web components that match the Vielzeug design system without a heavy framework dependency. **Consider Shoelace or Material Web** if your team is already standardized on those ecosystems and you need their established component catalogs. ## Installation ```sh [pnpm] pnpm add @vielzeug/refine ``` ```sh [npm] npm install @vielzeug/refine ``` ```sh [yarn] yarn add @vielzeug/refine ``` ## Quick Start ```ts // 1. Import global styles once import '@vielzeug/refine/styles'; // 2. Register only the elements you need import '@vielzeug/refine/button'; import '@vielzeug/refine/input'; import '@vielzeug/refine/card'; ``` ```html Save Account Card content goes here. ``` ```ts // Or register everything at once import '@vielzeug/refine/styles'; import '@vielzeug/refine'; ``` ### CDN / Vanilla HTML Use the self-contained IIFE bundle to load Refine directly from a CDN in any HTML page — no build step required: ```html ``` For bundler-based projects that still want a CDN URL, use the ESM bundle via an import map: ```html { "imports": { "@vielzeug/refine": "https://esm.sh/@vielzeug/refine", "@vielzeug/refine/button": "https://esm.sh/@vielzeug/refine/button", "@vielzeug/refine/input": "https://esm.sh/@vielzeug/refine/input" } } import '@vielzeug/refine/button'; import '@vielzeug/refine/input'; ``` ### Package Entry Points | Import | Purpose | | ------------------------ | ----------------------------------------- | | `@vielzeug/refine` | Register all published components | | `@vielzeug/refine/styles` | Global tokens and shared component styles | | `@vielzeug/refine/types` | Shared TypeScript types | Component registration happens through side-effect imports such as `@vielzeug/refine/button` and `@vielzeug/refine/dialog`. Headless widget controllers (`createTextField`, `createListControl`, `createOverlayControl`, and others) are exported directly from `@vielzeug/refine` alongside component types. ### Components **Content:** `ore-avatar`, `ore-avatar-group`, `ore-breadcrumb`, `ore-card`, `ore-carousel`, `ore-carousel-slide`, `ore-icon`, `ore-pagination`, `ore-separator`, `ore-table`, `ore-text` **Disclosure:** `ore-accordion`, `ore-accordion-item`, `ore-tabs`, `ore-tab-item`, `ore-tab-panel` **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast` **Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-form`, `ore-input`, `ore-number-input`, `ore-otp-input`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker` **Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar` **Overlay:** `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip` ## Features - **Accessible** — keyboard navigation, ARIA wiring, and focus management across interactive components - **Themeable** — global tokens plus component-level CSS custom properties - **Framework agnostic** — works anywhere HTML can be rendered - **Tree-shakeable** — import only the component entry points you register - **Comprehensive surface** — inputs, content, disclosure, feedback, layout, and overlay primitives - **Zero runtime deps** — gzipped ### Prerequisites - Browser runtime with Custom Elements support. - Import `@vielzeug/refine/styles` before rendering components. - For SSR, render placeholders server-side and hydrate components only on the client. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ore](/ore/) — Web component runtime that powers Refine - [Orbit](/orbit/) — Floating UI positioning used in Refine's overlays - [Forge](/forge/) — Form state management for use with Refine inputs ### API Reference # API Reference ## Import Paths Every published subpath and what it does: | Import path | Purpose | | -------------------------------------- | ---------------------------------------------------------------------- | | `@vielzeug/refine/styles` | Global design tokens and base styles — **import before components** | | `@vielzeug/refine/styles/theme.css` | Theme token stylesheet (direct CSS path) | | `@vielzeug/refine/styles/animation.css` | Animation helpers | | `@vielzeug/refine/styles/layers.css` | Cascade layer definitions | | `@vielzeug/refine/styles/preflight.css` | CSS reset / preflight (importable separately to opt out) | | `@vielzeug/refine/` | Register a single component as a custom element | | `@vielzeug/refine` | Register all published components + re-export shared symbols | | `@vielzeug/refine/headless` | Headless primitive controllers for custom component authoring | | `@vielzeug/refine/testing` | Test utilities: ARIA helpers, DOM queries, typed mount wrappers | | `@vielzeug/refine/types` | Shared TypeScript types | Prefer per-component imports to keep bundles small and registration explicit. Import `@vielzeug/refine` only when convenience matters more than granular control. ## Runtime Registration Register only the elements you need: ```ts import '@vielzeug/refine/styles'; import '@vielzeug/refine/button'; import '@vielzeug/refine/input'; import '@vielzeug/refine/dialog'; ``` Or register everything: ```ts import '@vielzeug/refine/styles'; import '@vielzeug/refine'; ``` ## Published Component Subpaths ```ts import '@vielzeug/refine/accordion'; import '@vielzeug/refine/accordion-item'; import '@vielzeug/refine/alert'; import '@vielzeug/refine/async'; import '@vielzeug/refine/avatar'; import '@vielzeug/refine/avatar-group'; import '@vielzeug/refine/badge'; import '@vielzeug/refine/box'; import '@vielzeug/refine/breadcrumb'; import '@vielzeug/refine/button'; import '@vielzeug/refine/button-group'; import '@vielzeug/refine/calendar'; import '@vielzeug/refine/card'; import '@vielzeug/refine/carousel'; import '@vielzeug/refine/checkbox'; import '@vielzeug/refine/checkbox-group'; import '@vielzeug/refine/chip'; import '@vielzeug/refine/copy-command'; import '@vielzeug/refine/combobox'; import '@vielzeug/refine/datagrid'; import '@vielzeug/refine/date-picker'; import '@vielzeug/refine/dialog'; import '@vielzeug/refine/drawer'; import '@vielzeug/refine/file-input'; import '@vielzeug/refine/form'; import '@vielzeug/refine/grid'; import '@vielzeug/refine/grid-item'; import '@vielzeug/refine/icon'; import '@vielzeug/refine/input'; import '@vielzeug/refine/menu'; import '@vielzeug/refine/navbar'; import '@vielzeug/refine/number-input'; import '@vielzeug/refine/otp-input'; import '@vielzeug/refine/pagination'; import '@vielzeug/refine/password-strength'; import '@vielzeug/refine/popover'; import '@vielzeug/refine/progress'; import '@vielzeug/refine/radio'; import '@vielzeug/refine/radio-group'; import '@vielzeug/refine/rating'; import '@vielzeug/refine/select'; import '@vielzeug/refine/separator'; import '@vielzeug/refine/sidebar'; import '@vielzeug/refine/skeleton'; import '@vielzeug/refine/slider'; import '@vielzeug/refine/switch'; import '@vielzeug/refine/tab-item'; import '@vielzeug/refine/tab-panel'; import '@vielzeug/refine/table'; import '@vielzeug/refine/tabs'; import '@vielzeug/refine/text'; import '@vielzeug/refine/textarea'; import '@vielzeug/refine/time-picker'; import '@vielzeug/refine/toast'; import '@vielzeug/refine/tooltip'; ``` ## Shared Exported Symbols The package root re-exports these symbols alongside component registration: | Symbol | Description | | ----------------------- | ------------------------------------------------------------- | | `toast` | Programmatic toast notification singleton | | `lifecycleSignal()` | `AbortSignal` tied to a Ore component's cleanup | | `createTextField()` | Headless text/textarea field controller | | `createChoiceField()` | Headless single/multi-select controller | | `createCheckable()` | Headless checkbox/radio controller | | `createOverlayControl()`| Headless open/close/toggle for overlays | | `createOptionList()` | Headless dropdown list with open state, navigation, positioner| | `createListControl()` | Keyboard-navigable list without open state | | Tag constants | `BUTTON_TAG`, `INPUT_TAG`, `DIALOG_TAG`, … | | Context constants | `FORM_CTX`, `TABS_CTX`, `CHECKBOX_GROUP_CTX`, … | | Component prop types | `OreButtonProps`, `OreInputEvents`, … | ## TypeScript Types Every component exports types named after the component. Import from the component subpath: ```ts import type { OreButtonProps, OreButtonEvents } from '@vielzeug/refine/button'; import type { OreInputProps, OreInputEvents } from '@vielzeug/refine/input'; import type { OreDialogProps, OreDialogEvents } from '@vielzeug/refine/dialog'; ``` Shared types used across components: | Type | Path | Values | | ------------------- | ----------------------- | ----------------------------------------------------------------------------------- | | `ThemeColor` | `@vielzeug/refine/types` | `'primary' \| 'secondary' \| 'info' \| 'success' \| 'warning' \| 'error'` | | `VisualVariant` | `@vielzeug/refine/types` | `'solid' \| 'flat' \| 'bordered' \| 'outline' \| 'ghost' \| 'text' \| 'frost'` | | `SurfaceVariant` | `@vielzeug/refine/types` | `VisualVariant \| 'glass'` | | `ComponentSize` | `@vielzeug/refine/types` | `'sm' \| 'md' \| 'lg'` | | `RoundedSize` | `@vielzeug/refine/types` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' \| 'full'` | | `PaddingSize` | `@vielzeug/refine/types` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | | `AddEventListeners` | `@vielzeug/refine/types` | Mixin that adds typed `addEventListener` / `removeEventListener` overloads | ## Component Documentation Index Per-component API — attributes, events, slots, CSS custom properties: ### Disclosure - [Accordion](./components/accordion.md) - [Tabs](./components/tabs.md) ### Feedback - [Alert](./components/alert.md) - [Async](./components/async.md) - [Badge](./components/badge.md) - [Chip](./components/chip.md) - [Password Strength](./components/password-strength.md) - [Progress](./components/progress.md) - [Skeleton](./components/skeleton.md) - [Toast](./components/toast.md) ### Content - [Avatar](./components/avatar.md) - [Breadcrumb](./components/breadcrumb.md) - [Card](./components/card.md) - [Carousel](./components/carousel.md) - [Copy Command](./components/copy-command.md) - [Icon](./components/icon.md) - [Pagination](./components/pagination.md) - [Separator](./components/separator.md) - [Table](./components/table.md) - [Text](./components/text.md) ### Overlay - [Dialog](./components/dialog.md) - [Drawer](./components/drawer.md) - [Menu](./components/menu.md) - [Popover](./components/popover.md) - [Tooltip](./components/tooltip.md) ### Inputs - [Button (+ Button Group)](./components/button.md) - [Calendar](./components/calendar.md) - [Checkbox (+ Checkbox Group)](./components/checkbox.md) - [Combobox](./components/combobox.md) - [Data Grid](./components/datagrid.md) - [Date Picker](./components/date-picker.md) - [File Input](./components/file-input.md) - [Form](./components/form.md) - [Input](./components/input.md) - [Number Input](./components/number-input.md) - [OTP Input](./components/otp-input.md) - [Radio (+ Radio Group)](./components/radio.md) - [Rating](./components/rating.md) - [Select](./components/select.md) - [Slider](./components/slider.md) - [Switch](./components/switch.md) - [Textarea](./components/textarea.md) - [Time Picker](./components/time-picker.md) ### Layout - [Box](./components/box.md) - [Grid (+ Grid Item)](./components/grid.md) - [Navbar](./components/navbar.md) - [Sidebar](./components/sidebar.md) ## Headless API Headless primitives let you build fully custom components that share Refine's interaction logic — keyboard navigation, overlay management, field validation — without any Refine styling. Import from `@vielzeug/refine/headless`: ```ts import { lifecycleSignal, createTextField, createChoiceField, createCheckable, createOverlayControl, createOptionList, createListControl, } from '@vielzeug/refine/headless'; ``` All stateful primitives require a `signal: AbortSignal` option. Use `lifecycleSignal(onCleanup)` inside a Ore `setup()` function to produce one tied to the component's lifecycle. ### `lifecycleSignal(onCleanup)` ```ts lifecycleSignal(onCleanup: (fn: () => void) => void): AbortSignal ``` Creates an `AbortSignal` that aborts when the Ore component disconnects. Pass it as `signal` to any stateful headless primitive. ### `createTextField(options)` ```ts createTextField(options: TextFieldOptions): TextFieldHandle ``` Controller for `` and ``. Manages value sync, validation triggers, character counter, and event wiring. Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `counter`. ### `createChoiceField(options)` ```ts createChoiceField(options: ChoiceFieldOptions): ChoiceFieldHandle ``` Controller for single and multi-select inputs. Normalises `string | string[]` values. Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue`. ### `createCheckable(options)` ```ts createCheckable(options: CheckableOptions): CheckableHandle ``` Controller for checkboxes and radios. Handles checked/indeterminate state, group delegation, and keyboard activation. Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`. ### `createOverlayControl(options)` ```ts createOverlayControl(options: OverlayControlOptions): OverlayControl ``` Open/close/toggle controller for dialogs, drawers, menus, and popovers. Handles outside-click, focus restoration, and positioner lifecycle. `dispose()` closes silently — it does not fire `onClose`. Useful for component teardown. Methods: `open(reason?)`, `close(reason?, restoreFocus?)`, `toggle()`, `dispose()`. ### `createOptionList(options)` ```ts createOptionList(options: OptionListOptions): OptionListHandle ``` Composed primitive for dropdown option lists (select, combobox, menu). Owns `isOpen`, `focusedIndex`, the dropdown positioner, list navigation, and overlay wiring. Required DOM accessors (top-level options, not nested): `getBoundary`, `getPanel`, `getReference`. Missing any of them throws `RefineConfigError`. Key members: `isOpen`, `focusedIndex`, `ariaExpanded`, `ariaActiveDescendant`, `open(reason?)`, `close(reason?)`, `toggle(openReason?, closeReason?)`, `navigate(action)` (`'first' | 'last' | 'next' | 'prev'`), `set(index)`, `getActiveItem()`, `handleKeydown()`, `scrollFocusedIntoView()`, `updatePosition()`. ### `createListControl(options)` ```ts createListControl(options: ListNavigationOptions): ListControl ``` Keyboard-navigable list without open state. Supports vertical/horizontal/omni navigation, disabled-item skipping, looping, and typeahead. Navigation methods return the resolved index, or `-1` when no enabled item was found. ### Other Headless Exports | Export | Description | | ---------------------------- | ------------------------------------------------------------------ | | `createSpinnerControl()` | Number spinner step/clamp/keyboard logic | | `createSliderControl()` | Range slider value/step/clamp/keyboard | | `createSwipeControl()` | Touch/pointer swipe gesture detection | | `createPaginatedList()` | Reactive page-index + page-items controller | | `createDatePickerControl()` | Full date-picker state (calendar, view switching, ISO parsing) | | `createDataGridControl()` | Data grid state (sorting, selection, column management, pagination)| | `createTypeahead()` | Standalone typeahead buffer with debounced reset | | `createDropdownPositioner()` | Floating dropdown positioner (wraps Orbit) | | `createDialogFocusControl()` | Dialog-specific focus entry and restoration | | `createInteraction()` | Unified click/keyboard press handler for interactive elements | | `dispatchKeyboardAction()` | Low-level keymap dispatcher | | `createSelectionControl()` | Single/multi/none row-selection controller (used by the data grid) | | `createSortControl()` | Client-side column sort controller — cycles asc → desc → none | | `announce()` | ARIA live-region announcer (polite or assertive) | | `syncedSignal()` | Local writable signal synced from a `Reactive` source | | `parseStringTriggers()` | Parse comma-separated trigger strings against an allowed set | | `getChoiceLabel()` | Read the display label from `` or similar | | `getLightChildrenByTag()` | Collect light-DOM children matching a tag name | | `parseIso()` | Parse an ISO `yyyy-MM-dd` string to `Temporal.PlainDate`, `null` if invalid | | `toIsoString()` | Serialise a `Temporal.PlainDate` to an ISO `yyyy-MM-dd` string | | `formatDisplayDate()` | Format a `Temporal.PlainDate` for display in a given locale | | `toFiniteNumber()` | Parse a value to a finite number, `undefined` for non-finite | | `toFiniteNumberOr()` | Parse a value to a finite number with a fallback | | `toPositiveStep()` | Coerce a step value to a positive finite number with a fallback | ### Usage Guide # Usage Guide Refine components are native Web Components. Once imported, they behave like regular HTML elements — set attributes, listen to DOM events, use slots for content projection. ## Installation Import the global styles first, then register only the components you need: ```ts import '@vielzeug/refine/styles'; import '@vielzeug/refine/button'; import '@vielzeug/refine/input'; import '@vielzeug/refine/dialog'; ``` The styles import loads design tokens and base styles. Components still render without it, but they will miss tokens and visual polish. Always import it first. To register every component at once (larger bundle): ```ts import '@vielzeug/refine/styles'; import '@vielzeug/refine'; ``` ## Attributes and Events Set attributes directly on the element. Attributes map to component props: ```html Large Outline Button ``` Components emit standard DOM events. Common event names: `click`, `input`, `change`, `open`, `close`, `select`. Custom events carry a `detail` object: ```javascript const input = document.querySelector('ore-input'); input.addEventListener('change', (event) => { console.log(event.detail.value); }); ``` Native browser events (`click`, `focus`, `blur`) work as normal. Custom events with `event.detail` require `addEventListener` in React 18 and earlier — see the [Framework Integration](./frameworks.md) guide. ## Slots Slots let you pass HTML into named regions of a component without JavaScript. Content placed directly inside the element fills the default slot: ```html Save Changes Any HTML content here ``` Components with distinct regions expose named slots: ```html Card Heading Main body content fills the default slot. Cancel Confirm ``` Many input components expose `prefix` and `suffix` slots for icons or actions: ```html Back ``` Each component's available slots are listed in its API Reference table. ## Composing with Ore and Ripple Refine components are plain HTML elements — they compose naturally with [Ore](/ore/) custom elements and [Ripple](/ripple/) signals. **Build a custom component that wraps Refine elements:** ```ts import '@vielzeug/refine/button'; import '@vielzeug/refine/input'; import { define, html } from '@vielzeug/ore'; import { signal } from '@vielzeug/ripple'; define('my-search-bar', () => { const query = signal(''); return html` (query.value = e.detail.value)} label="Search" /> search(query.value)} variant="solid" color="primary"> Search `; }); ``` **Drive component state from reactive signals:** ```ts import { signal, effect } from '@vielzeug/ripple'; const isLoading = signal(false); const btn = document.querySelector('ore-button'); effect(() => { btn.loading = isLoading.value; }); ``` ## Framework Integration For React, Vue, Svelte, and Angular wiring — including event handling, TypeScript declarations, Vite setup, and SSR guards — see the [Framework Integration](./frameworks.md) guide. ## Accessibility All Refine components target WCAG 2.1 AA. ARIA roles and states are managed automatically. For the full compliance contract, per-component coverage, and testing strategy, see the [Accessibility](./accessibility.md) page. The two things you always control: - **Icon-only buttons** require a `label` attribute — it becomes `aria-label`. - **Decorative icons** should have `aria-hidden="true"` so screen readers skip them. --- ## @vielzeug/ripple **Category:** state **Keywords:** reactive, signals, computed, effects, store, observable, fine-grained, watch, batch, scope, lens, async, history **Key exports:** signal, computed, effect, effectAsync, resource, watch, batch, store, storeWithHistory, untrack, scope, onCleanup (+17 more) **Related:** ore, forge, herald ### Overview ## Why Ripple? Plain variables don't notify anything when they change. Framework-specific stores add boilerplate and coupling. ```ts // Before — manual notification let count = 0; const listeners: Array void> = []; function setCount(n: number) { count = n; listeners.forEach((fn) => fn()); } // After — Ripple signals import { signal, effect } from '@vielzeug/ripple'; const count = signal(0); effect(() => console.log(count.value)); // auto-tracks dependencies count.value = 1; // notifies automatically ``` | Feature | Ripple | Zustand | Jotai | Nanostores | | ---------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------- | | Bundle size | | ~3.5 kB | ~7 kB | ~2 kB | | Zero dependencies | | | | | | Framework-agnostic | | | React-first | | | Fine-grained reactivity | (per-property) | (whole store) | | (atom) | | Structured object stores | (`store`, `lens`) | | Manual atoms | | | Async computed | (`resource`) | Manual | | | | Undo / redo history | (`storeWithHistory`) | Manual | | | | Computed signals | (lazy, glitch-free) | Selectors | (atoms) | | | Batched writes | (`batch`) | | | | | Explicit cleanup / scopes | (`scope`, `onCleanup`) | | | | | SSR support | | | | | | TypeScript — strict generics | | | | Partial | | React Suspense | | | | | | Redux DevTools | | | | | **Use Ripple when** you need fine-grained, per-property reactivity without framework lock-in — especially for web components, vanilla JS apps, or any environment where you want zero runtime dependencies and explicit lifecycle control. **Consider alternatives when** you are React-only and need Suspense or React Query integration (Jotai), need Redux DevTools out of the box (Zustand), or need a minimal atom store with no extra features (Nanostores). ## Installation ```sh [pnpm] pnpm add @vielzeug/ripple ``` ```sh [npm] npm install @vielzeug/ripple ``` ```sh [yarn] yarn add @vielzeug/ripple ``` ## Quick Start ```ts import { signal, computed, effect, watch, batch } from '@vielzeug/ripple'; const count = signal(0); const doubled = computed(() => count.value * 2); // Computed // Side-effect: runs immediately and re-runs on change const sub = effect(() => { console.log('doubled:', doubled.value); }); count.value = 5; // → logs "doubled: 10" // Explicit subscription — only fires on change, not immediately const stopWatch = watch(count, (next, prev) => { console.log(prev, '→', next); }); batch(() => { count.value = 10; count.value = 20; // one notification fires with the final value }); sub.dispose(); stopWatch.dispose(); doubled.dispose(); ``` ```ts import { store, watch, batch, computed } from '@vielzeug/ripple'; const counter = store({ count: 0, label: 'counter' }); // Read console.log(counter.value.count); // 0 // Watch a typed lens — only fires when that path changes const countLens = counter.lens('count'); // Signal const stopWatch = watch(countLens, (next, prev) => { console.log('count:', prev, '→', next); }); // Derived slice const label = computed(() => counter.value.label); // Computed // Mutations counter.patch({ count: 1 }); // shallow merge counter.replace((s) => ({ ...s, count: s.count + 1 })); // replace via fn countLens.value = 10; // write directly through the lens batch(() => { counter.patch({ count: 5 }); counter.patch({ label: 'done' }); }); counter.reset(); stopWatch.dispose(); label.dispose(); ``` ## Features - **`signal(value, options?)`** — reactive atom; read `.value`, write `.value = next`; equality check prevents notification when value is unchanged - **`computed(fn, options?)`** — lazy derived signal; glitch-free; auto-tracks dependencies read inside `fn` - **`effect(fn, options?)`** — side-effect that re-runs when dependencies change; returns `EffectHandle` with `getDependencies()`; options: `scheduler` (`'sync'` | `'microtask'`), `name` - **`effectAsync(fn, options?)`** — async side-effect with an `AbortSignal` that fires on re-run or dispose; returns `AsyncSubscription` with `[Symbol.asyncDispose]` - **`resource(factory, options?)`** — reactive async data source; emits a `ResourceState` discriminated union (`loading` / `ready` / `error`); factory receives an `AbortSignal` that fires on re-run or dispose - **`watch(source, cb, options?)`** — explicit subscription that fires only when the value changes; returns a `Subscription` - **`batch(fn)`** — flush all notifications once after bulk updates - **`untrack(fn)`** — read signals inside an effect without creating subscriptions - **`onCleanup(fn)`** — register teardown from inside an effect or `scope` without using the return value - **`scope(setup?)`** — isolated cleanup context; collect teardown via `onCleanup` inside `scope.run(fn)`; release with `scope.dispose()` - **`readonly(source)`** — wraps any signal as `Readable` — read-only at the type level; no `dispose()` (caller retains ownership) - **`debugEffect(fn, options?)`** — like `effect()`, but logs reactive deps on every run; import from `@vielzeug/ripple/devtools` — tree-shaken from production bundles - **`isSignal(v)`**, **`isComputed(v)`**, **`isStore(v)`** — type guards using an internal symbol marker - **`store(init, options?)`** — structured reactive object container - **`.patch(partial)`** — shallow-merge a `Partial` into state - **`.replace(fn)`** — derive next state from current via a function; same-reference return is a no-op - **`.reset()`** — restore the initial state baseline - **`.lens(path)`** — cached writable `Signal` for a property or dot-path; writes produce an immutable copy - **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo` - **`getDevToolsHook()`** — returns the currently installed DevTools hook, or `null`; install via `@vielzeug/ripple/devtools` - **Glitch-free propagation** — computed signals propagate in dependency order; effects always observe a consistent snapshot - **Infinite loop detection** — built-in guard against effect re-entry cycles (100 iterations default) - **Automatic computed disposal** — `computed()` created inside `effect()` auto-disposes with the effect ## Sub-paths | Import | Purpose | | --------------------------- | -------------------------------------------------------------------------------------------------------- | | `@vielzeug/ripple` | All exports and types | | `@vielzeug/ripple/devtools` | `installDevTools`, `debugEffect` — DevTools hook and reactive source tracing (dev-only, tree-shaken) | | `@vielzeug/ripple/ssr` | SSR tracking isolation helpers (`withProvider`, `runWithProvider`, `createAsyncProvider`). Node.js only. | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ore](/ore/) — web-component authoring framework built on ripple - [Forge](/forge/) — typed form state that uses signals for field reactivity and submission tracking - [Herald](/herald/) — typed event bus; use alongside ripple for cross-module messaging without shared signals ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------- | --------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- | | `signal()` | Create reactive primitive values | Sync | Write signals inside batch/effect-safe flows | | `computed()` | Derive memoized values from dependencies | Sync | Avoid side effects inside computed callbacks | | `effect()` | Run and re-run sync side effects | Sync | Dispose when no longer needed to prevent memory leaks | | `effectAsync()` | Run async side effects with AbortSignal | Async | Read reactive deps synchronously before the first `await` | | `resource()` | Reactive async data source; emits a `ResourceState` discriminated union | Async | `status` starts `'loading'`; call `.refresh()` to force a re-fetch without a dep change | | `watch()` | Subscribe to value changes | Sync | Does not fire immediately unlike `effect()` | | `batch()` | Coalesce multiple writes | Sync | Nested batches merge into the outermost | | `untrack()` | Read without subscribing | Sync | Only suppresses dependency registration, value is still read | | `readonly()` | Wrap any signal as a read-only view | Sync | Returns `Readable` — no `dispose()` method; the caller retains ownership of the source | | `scope()` | Isolated cleanup context | Sync | Must call `scope.run()` to activate; `dispose()` is LIFO | | `debugEffect()` | Effect that logs changed sources before re-run | Sync | Sub-path only: `@vielzeug/ripple/devtools`; tree-shaken from production | | `store()` | Create object-like state container | Sync | Store is a branded signal; use `.patch()`, `.replace()`, `.reset()` | | `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync | Call `.push()` / `.pushNamed()` explicitly to record a checkpoint; `maxHistory` caps the buffer | | `installDevTools()` | Install DevTools observation hook | Sync | Sub-path only: `@vielzeug/ripple/devtools`; pass `null` to uninstall | | `getDevToolsHook()` | Return current DevTools hook | Sync | Returns `null` if none installed | | `isSignal()` | Type guard for any signal/computed/store | Sync | Uses an internal symbol marker, not duck-typing | | `isComputed()` | Type guard for computed signals | Sync | Returns `false` for plain signals, stores, and `readonly()` wrappers | | `isStore()` | Type guard for stores | Sync | Returns `false` for plain signals and computed signals | ## Package Entry Point | Import | Purpose | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@vielzeug/ripple` | All core exports and types | | `@vielzeug/ripple/devtools` | `installDevTools`, `debugEffect`, and hook types (`RippleDevToolsHook`, `WriteEvent`, `NamedEvent`, `DisposeEvent`, `MutateEvent`) — dev-only, tree-shaken from prod | | `@vielzeug/ripple/ssr` | SSR tracking isolation helpers (`setTrackingProvider`, `createAsyncProvider`, `withProvider`, `runWithProvider`). Node.js only — do not import in browser builds. | ## Signal Primitives ### `signal` ```ts function signal(initial: T, options?: SignalOptions): Signal; ``` Creates a reactive atom. Read `.value` inside an `effect` or `computed` to subscribe. Write `.value = next` to update and notify dependents. Signals also expose: - `peek(): T` — read the current value without registering a dependency - `subscribe(onStoreChange): Subscription` — subscribe to future changes without an initial callback, suitable for `useSyncExternalStore()` ```ts const count = signal(0); count.value; // 0 — tracked read count.value = 1; // notifies dependents ``` **Parameters** | Parameter | Type | Description | | ----------------- | --------------- | ------------------------------------------------------------------------------------------------------ | | `initial` | `T` | The starting value | | `options.equals` | `EqualityFn` | Custom equality; skip notification when `true`. Default: `Object.is` | | `options.name` | `string` | Name used in DevTools and error messages | **Returns** — `Signal` See also: [`SignalOptions`](#signaloptions) --- ### `computed` ```ts function computed(compute: () => T, options?: ComputedOptions): Computed; ``` Creates a lazy derived read-only signal. The `compute` function runs on the first `.value` read and again after any dependency changes. Propagation is **glitch-free**: when a signal that multiple computed nodes share changes, all computed nodes are marked dirty before any subscribed effects run — effects always observe a consistent snapshot. Call `.dispose()` to detach from dependencies. If `computed()` is created inside an active `effect()` or `scope.run()` context, it is automatically registered for cleanup and disposed with that context. ```ts const count = signal(3); const doubled = computed(() => count.value * 2); doubled.value; // 6 — compute runs here count.value = 5; doubled.value; // 10 — recomputed on read doubled.dispose(); // stop tracking // or: using doubled = computed(...) — TC39 using declaration ``` When `options.equals` is provided, downstream subscribers are suppressed if the recomputed value equals the previous value. **Parameters** | Parameter | Type | Description | | ---------------- | --------------- | --------------------------------------------------------------------- | | `compute` | `() => T` | Computation function; signals read inside are tracked as dependencies | | `options.equals` | `EqualityFn` | Suppress downstream if result is unchanged. Default: `Object.is` | | `options.name` | `string` | Name used in DevTools and cycle error messages | **Returns** — `Computed` See also: [`ComputedOptions`](#computedoptions) --- ### `effect` ```ts function effect(fn: EffectCallback, options?: EffectOptions): EffectHandle; ``` Runs `fn` immediately and re-runs it whenever any signal read inside it changes. If `fn` returns a function, that function is called as cleanup before each re-run and on final dispose. Returns an `EffectHandle` — dispose is idempotent. ```ts const sub = effect(() => { document.title = count.value.toString(); return () => { /* cleanup */ }; }); count.value = 5; // effect re-runs (cleanup called first) sub.dispose(); // cleanup called, effect removed // or: using sub = effect(...) — TC39 using declaration ``` ```ts // With options const stop = effect(() => console.log('count:', count.value), { scheduler: 'microtask', // defer re-runs to a microtask queue name: 'count-logger', // appears in error messages }); // 'sync' (default) or 'microtask' const stop2 = effect(() => renderFrame(data.value), { scheduler: 'microtask' }); ``` **Parameters** | Parameter | Type | Default | Description | | ----------------------- | ----------------- | ----------- | ----------------------------------------------------------------------------- | | `fn` | `EffectCallback` | | Runs immediately and on each dependency change; may return a cleanup function | | `options.scheduler` | `EffectScheduler` | `'sync'` | `'sync'` or `'microtask'`; sync runs immediately, microtask defers and coalesces | | `options.name` | `string` | `undefined` | Name shown in error messages for loop and cycle errors | **Returns** — `EffectHandle` See also: [`EffectOptions`](#effectoptions), [`EffectHandle`](#effecthandle), [`EffectScheduler`](#effectscheduler) --- ### `effectAsync` ```ts function effectAsync(fn: AsyncEffectCallback, options?: EffectAsyncOptions): AsyncSubscription; ``` Like `effect()`, but the callback is async and receives an `AbortSignal` that fires when the effect re-runs or is disposed. Read reactive dependencies **synchronously** before the first `await` to register them as tracked. When the reactive dependencies change: 1. The current in-flight operation's `AbortSignal` is aborted. 2. Any cleanup returned by the previous run is called. 3. A new run starts. Errors from runs where `signal.aborted` is `true` are silently discarded. Other unhandled async errors are passed to `options.onError` (defaults to surfacing as an unhandled promise rejection). ```ts const userId = signal('u1'); const stop = effectAsync(async (signal) => { const id = userId.value; // sync dep — tracked const data = await fetchUser(id, { signal }); // automatically aborted if id changes renderUser(data); return () => cleanup(); // optional cleanup }); userId.value = 'u2'; // aborts in-flight fetch, starts a new one stop.dispose(); // aborts current fetch, calls cleanup ``` **Parameters** | Parameter | Type | Description | | ----------------- | --------------------- | ------------------------------------------------------------------- | | `fn` | `AsyncEffectCallback` | Async callback receiving an `AbortSignal` and an owner `Scope`; may return async cleanup | | `options.name` | `string` | Name used to identify this async effect in DevTools | | `options.onError` | `(err) => void` | Handler for non-aborted errors. Default: logs via `console.error` | **Returns** — `AsyncSubscription` — extends `Subscription`; also provides `run(): Promise` to await the current in-flight run, and `[Symbol.asyncDispose]()` for full async teardown. See also: [`EffectAsyncOptions`](#effectasyncoptions), [`AsyncEffectCallback`](#asynceffectcallback), [`AsyncSubscription`](#asyncsubscription) --- ### `watch` ```ts function watch( source: Readable, cb: (value: T, prev: T | undefined) => CleanupFn | void, options?: WatchOptions, ): Subscription; function watch( source: () => T, cb: (value: T, prev: T | undefined) => CleanupFn | void, options?: WatchOptions, ): Subscription; ``` Subscribes to value changes on `source`. Does **not** fire immediately by default (unlike `effect`). The callback may return a cleanup function called before the next invocation or on dispose; returning any other non-`undefined` value throws `RippleInvalidCleanupError`. `source` accepts two forms: - **A single `Readable`** (signal, computed, store, or lens) — the common case. - **A function `() => T`** — tracks every reactive dependency read inside it (like `computed()`), so a multi-signal derived watch no longer needs an intermediate `computed()` node. ```ts // Plain watch const sub = watch(count, (next, prev) => console.log(prev, '→', next)); count.value = 5; // fires sub.dispose(); // Slice watch — lens (preferred) const userStore = store({ name: 'Alice' }); watch(userStore.lens('name'), (name) => console.log('name:', name)); // Function form — tracks every signal read inside, no intermediate computed() needed const a = signal(1); const b = signal(2); watch( () => a.value + b.value, (sum, prevSum) => console.log(`sum: ${prevSum} → ${sum}`), ); a.value = 10; // fires — sum changed ``` **Parameters** | Parameter | Type | Description | | ------------------- | ------------------------------------------------------- | --------------------------------------------------------- | | `source` | `Readable \| (() => T)` | A signal, computed, store, or lens to watch directly — or a function whose reactive reads are tracked (like `computed()`) | | `cb` | `(value: T, prev: T \| undefined) => CleanupFn \| void` | Called on each change; may return a cleanup function | | `options.immediate` | `boolean` | Fire once immediately on subscription. Default `false` | | `options.equals` | `EqualityFn` | Custom equality for change detection. Default `Object.is` | | `options.name` | `string` | Name passed to the internal effect for DevTools tracing | | `options.once` | `boolean` | Auto-dispose after the first callback invocation. Default `false` | **Returns** — `Subscription` --- ### `batch` ```ts function batch(fn: () => T): T; ``` Runs `fn` and defers all signal/store notifications until it returns, then flushes once. Nested `batch()` calls coalesce into the outermost. If `fn` throws, the pending flush queue is discarded instead of flushed — writes made before the throw are not rolled back, but no effect observes them; the original error propagates. ```ts batch(() => { a.value = 1; b.value = 2; // one combined notification after fn returns }); ``` **Parameters** | Parameter | Type | Description | | --------- | --------- | --------------------- | | `fn` | `() => T` | Mutations to coalesce | **Returns** — The return value of `fn` --- ### `untrack` ```ts function untrack(fn: () => T): T; ``` Runs `fn` and returns its result without registering any reactive dependencies. Reads inside are still valid but do not subscribe. ```ts effect(() => { const x = a.value; // subscribed const y = untrack(() => b.value); // not subscribed console.log(x + y); }); ``` **Returns** — The return value of `fn` --- ### `readonly` ```ts function readonly(source: Readable): Readable; ``` Wraps `source` in a thin delegation object — the returned `Readable` exposes `value`, `peek()`, and `subscribe()`. Mutator methods are hidden at the type level. The wrapper has **no** `dispose()` method — it carries no ownership over the source. Always creates a new wrapper object — never returns the source directly. The caller retains full ownership of the source and is responsible for disposing it independently. ```ts const count = signal(0); const ro = readonly(count); console.log(ro.value); // 0 count.value = 1; console.log(ro.value); // 1 // ro has no dispose() — count remains owned by the caller count.dispose(); // disposes the source ``` **Parameters** | Parameter | Type | Description | | --------- | ------------- | ----------------------------------- | | `source` | `Readable` | Any signal/store/computed to expose | **Returns** — `Readable` --- ### `onCleanup` ```ts function onCleanup(fn: CleanupFn): void; ``` Registers a cleanup function within the currently active `effect()` or `scope.run()` context. Throws `RippleInvalidCleanupError` when called outside either context. ```ts effect(() => { const id = setInterval(() => tick(), 1000); onCleanup(() => clearInterval(id)); }); ``` --- ### `isSignal` ```ts function isSignal(value: unknown): value is Readable; ``` Type guard returning `true` for values created by `signal()`, `computed()`, or `store()`. Uses an internal symbol marker. ```ts isSignal(signal(42)); // true isSignal(computed(() => 1)); // true isSignal(store({ n: 0 })); // true isSignal({ value: 42 }); // false — not a real signal ``` --- ### `isComputed` ```ts function isComputed(value: unknown): value is Computed; ``` Type guard returning `true` only for values created by `computed()`. Returns `false` for plain `signal()`, `store()`, and `readonly()` wrappers. ```ts isComputed(computed(() => 1)); // true isComputed(signal(42)); // false isComputed(readonly(signal(0))); // false isComputed(store({ n: 0 })); // false ``` --- ### `isStore` ```ts function isStore>(value: unknown): value is Store; ``` Type guard returning `true` only for values created by `store()`. Returns `false` for plain signals and computed signals. ```ts isStore(store({ n: 0 })); // true isStore(signal(42)); // false isStore(computed(() => 1)); // false ``` --- ### `scope` ```ts function scope(setup?: () => void): Scope; ``` Creates an isolated cleanup context not tied to any reactive source. Use it to collect teardown callbacks and release them all at once. If `setup` is provided, it runs immediately inside the scope so `onCleanup()` calls in setup are captured without a separate `scope.run(setup)` call. Otherwise, call `scope.run(fn)` to activate the scope manually. `dispose()` runs all cleanups in **LIFO order** and is idempotent. ```ts // With optional setup (shorthand): const s = scope(() => { const id = setInterval(() => tick(), 1000); onCleanup(() => clearInterval(id)); }); // Without setup (explicit run): const s2 = scope(); s2.run(() => { onCleanup(() => cleanup()); }); // later: s.dispose(); // or: using s = scope(...) ``` **Parameters** | Parameter | Type | Description | | --------- | ------------ | -------------------------------------------------------- | | `setup` | `() => void` | Optional. Runs immediately inside the scope on creation. | **Returns** — `Scope` See also: [`Scope`](#scope-1) --- ### `resource` ```ts function resource(factory: (abortSignal: AbortSignal) => Promise, options?: ResourceOptions): Resource; ``` Creates a reactive async resource. The factory re-runs whenever its tracked dependencies change. Dependencies are tracked synchronously (before the first `await`). The factory receives an `AbortSignal` that is aborted when superseded or disposed. If `resource()` is created inside an active `effect()` or `scope.run()` context, it is automatically registered for cleanup and disposed with that context — matching `computed()`'s auto-disposal behavior. The returned `Resource` is a read-only disposable signal emitting a single discriminated union: ```ts type ResourceState = | { readonly data?: T; readonly status: 'loading' } | { readonly data: T; readonly status: 'ready' } | { readonly data?: T; readonly error: unknown; readonly status: 'error' }; ``` Call `refresh()` to force the factory to re-run immediately — even if no tracked dependency changed — aborting any in-flight run first. Useful for manual "retry"/"refetch" actions (e.g. a retry button after an error). No-op after `dispose()`. ```ts const userId = signal('u1'); const user = resource(async (abortSignal) => { const id = userId.value; // tracked dep — re-runs when userId changes return fetch(`/users/${id}`, { signal: abortSignal }).then((r) => r.json()); }); effect(() => { const s = user.value; // ResourceState if (s.status === 'loading') return showSpinner(); if (s.status === 'error') return showError(s.error); renderUser(s.data); // s.data is User here }); userId.value = 'u2'; // aborts in-flight fetch, re-runs factory user.refresh(); // force a re-fetch for the current userId — e.g. a "retry" button user.dispose(); console.log(user.disposed); // true ``` **Parameters** | Parameter | Type | Description | | ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | | `factory` | `(abortSignal: AbortSignal) => Promise` | Async factory; tracked deps must be read synchronously before `await` | | `options.initialValue` | `T` | Populates `data` in the initial `loading` state before the first result | | `options.name` | `string` | Debug name propagated to the internal signal and effect | **Returns** — `Resource` See also: [`ResourceOptions`](#resourceoptions), [`ResourceState`](#resourcestate), [`Resource`](#resourcet) --- ### `debugEffect` `debugEffect` is exported from `@vielzeug/ripple/devtools`, not the main entry point. This keeps it tree-shaken from production bundles. ```ts function debugEffect(fn: EffectCallback, options?: Omit): EffectHandle; ``` Like `effect()`, but logs reactive dependency information on every run using `console.group`: the initial run lists all subscribed deps; subsequent runs list which deps changed and their version delta. Use instead of `effect()` when debugging unexpected re-renders — the output shows which source triggered the re-run and how its version advanced. ```ts import { debugEffect } from '@vielzeug/ripple/devtools'; const stop = debugEffect(() => renderUser(userId.value, name.value), { name: 'renderUser' }); // On re-run: console.group '[ripple:debug] "renderUser" re-running — changed sources:' // → userId (v1 -> v2) ``` **Returns** — `EffectHandle` ## Store Functions ### `store` ```ts function store(initial: T, options?: { name?: string }): Store; ``` Creates a reactive store for the given object state. Stores accept `effect()`, `computed()`, `watch()`, and other primitives via `.value` and `.subscribe()`. `initial` is deep-cloned; external mutations after construction do not affect the store or its `reset()` baseline. `store.value` returns a read-only proxy: direct top-level set or delete throws `RippleInvalidStoreError`. Use `.patch()`, `.replace()`, or `.lens()` to mutate. **Parameters** | Parameter | Type | Description | | --------- | ---- | ------------------------------------------------------------------ | | `initial` | `T` | Starting state; must be a plain object (not an array or primitive) | **Returns** — `Store` --- ### `storeWithHistory` ```ts function storeWithHistory( storeOrInitial: Store | T, options?: { maxHistory?: number; name?: string }, ): StoreWithHistory; ``` Wraps a store (or creates one from an initial value) with snapshot-based undo/redo history. `StoreWithHistory` extends `Store` — call `.patch()`, `.replace()`, `.reset()`, or `.lens()` directly. Mutations do **not** automatically push snapshots. Call `.push()` (or `.pushNamed(label)`) explicitly to record a checkpoint. `undo()` and `redo()` navigate the snapshot buffer without re-running any logic. The initial state is saved as the first snapshot automatically. Snapshots are deep-frozen clones (`structuredClone`). `maxHistory` caps the ring buffer (default: `50`); the oldest entries are evicted when the limit is reached. **Ownership:** when called with an initial value (`T`), the adapter creates and owns the underlying store — `dispose()` also disposes it. When called with an existing `Store`, the adapter does **not** own it — `dispose()` leaves the store alive. ```ts const editor = storeWithHistory({ text: '' }, { maxHistory: 100 }); editor.patch({ text: 'hello' }); // direct — StoreWithHistory extends Store editor.push(); // checkpoint 1 editor.patch({ text: 'hello world' }); editor.push(); // checkpoint 2 console.log(editor.historyLength); // 3 (initial + 2 explicit pushes) editor.undo(); console.log(editor.peek().text); // 'hello' editor.redo(); console.log(editor.peek().text); // 'hello world' console.log(editor.historyAt(0).state); // { text: '' } // Wrap an existing store — adapter does not own it const s = store({ x: 0 }); const h = storeWithHistory(s); h.dispose(); // h is gone; s is still alive ``` **Parameters** | Parameter | Type | Default | Description | | -------------------- | --------------- | ------- | ------------------------------------------------------------------------ | | `storeOrInitial` | `Store \| T` | | Existing `Store` (not owned) or a plain object to create a store from | | `options.maxHistory` | `number` | `50` | Maximum number of snapshots in the history buffer | | `options.name` | `string` | | Name passed to the underlying `store()` when creating a new one | **Returns** — `StoreWithHistory` See also: [`StoreWithHistory`](#storewithhistory) --- ### `store.lens` ```ts store.lens(path: P): Signal>; ``` Returns a writable `Signal` scoped to a specific property or nested dot-path within the store. The lens is cached — calling `.lens('a.b')` twice on the same store returns the same instance. Writes through the lens produce an immutable structural copy of the store state; intermediary objects must not be `null` or a primitive or a `RippleInvalidStoreError` is thrown. Path segments `__proto__`, `constructor`, and `prototype` are forbidden and throw `RippleInvalidStoreError` immediately. The lens `Signal` is disposed and evicted from the cache when `store.lens()` is called with that same path after the lens was disposed. ```ts const settings = store({ user: { name: 'Alice', address: { city: 'Berlin' } }, theme: 'light' as 'light' | 'dark', }); // Top-level path const theme = settings.lens('theme'); // Signal theme.value = 'dark'; // Nested dot-path const city = settings.lens('user.address.city'); // Signal city.value = 'Hamburg'; console.log(settings.value.user.address.city); // 'Hamburg' console.log(settings.value.theme); // 'dark' // Watch a single field watch(theme, (next, prev) => console.log(prev, '→', next)); ``` **Parameters** | Parameter | Type | Description | | --------- | ---- | -------------------------------------------------- | | `path` | `P` | Dot-separated key path, e.g. `'user.address.city'` | **Returns** — `Signal>` See also: [`PathValue`](#pathvaluet-p) ## Signal Combinators Use `computed()` to project a reactive source into a derived value: ```ts const count = signal(5); const doubled = computed(() => count.value * 2); doubled.value; // 10 ``` All projection options (`equals`, `name`) are available on `computed()` directly via [`ComputedOptions`](#computedoptions). ## Errors ### `RippleError` Base class for all ripple errors. Use `instanceof RippleError` (or the static `RippleError.is()` helper) to catch any ripple-originated error in one branch. ```ts class RippleError extends Error { static is(err: unknown): err is RippleError; } ``` Each error condition has a dedicated named subclass — catch with `instanceof` for precise handling: ```ts import { RippleError, RippleInvalidStoreError } from '@vielzeug/ripple'; try { s.value = 1; // direct mutation — throws RippleInvalidStoreError } catch (e) { if (e instanceof RippleInvalidStoreError) { // precise narrow } else if (RippleError.is(e)) { // catch any other ripple error } } ``` **Named subclasses** | Class | Thrown when | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `RippleComputedCycleError` | A computed function reads another computed that depends on it | | `RippleDisposedScopeError` | `scope.run()` or `scope.add()` is called after `scope.dispose()` | | `RippleEnvironmentError` | An SSR API is used in a browser environment | | `RippleInfiniteLoopError` | Flush or effect loop exceeds the built-in guard limit (default 100 iterations) | | `RippleInvalidCleanupError` | `onCleanup()` is called outside an active effect or scope | | `RippleInvalidStoreError` | `store()` is called with a non-object, or its initial state has an unsafe top-level key (`__proto__`, `constructor`, `prototype`); `patch()`/`replace()` receive a non-object, or any top-level key is unsafe — validated upfront, before any key is applied; `store.lens()` path traverses a `null` or non-object intermediate; a lens path has an empty segment (e.g. `'a..b'`), a forbidden segment (`__proto__`, `constructor`, `prototype`), or exceeds 32 segments; or `store.value` is mutated directly | Errors from multiple subscribers or cleanup functions in the same flush are aggregated into a standard `AggregateError` with each original error as an element. ### Named subclasses Each subclass extends `RippleError` with no additional members — they exist solely for `instanceof` narrowing. ```ts class RippleComputedCycleError extends RippleError {} class RippleDisposedScopeError extends RippleError {} class RippleEnvironmentError extends RippleError {} class RippleInfiniteLoopError extends RippleError {} class RippleInvalidCleanupError extends RippleError {} class RippleInvalidStoreError extends RippleError {} ``` All six classes are exported from `@vielzeug/ripple`. `RippleEnvironmentError` is also exported from `@vielzeug/ripple/ssr` for use in SSR entry points. --- ## Types ### `Signal` ```ts interface Signal extends Computed { value: T; // notifying setter — write triggers downstream notifications } ``` Returned by `signal()` and `store.lens()`. Extends `Computed` (which extends `Readable`), so a `Signal` is usable wherever a `Readable` or `Computed` is expected. Writing `.value` notifies all dependents synchronously (or on the next microtask if `scheduler: 'microtask'` is set on an observing effect). --- ### `Readable` ```ts interface Readable { readonly disposed: boolean; readonly name?: string; // debug name assigned at creation, or undefined peek(): T; subscribe(listener: () => void): Subscription; readonly value: T; } ``` | Member | Description | | ------------- | ------------------------------------------------------------------- | | `name` | Debug name set at creation (`options.name`); `undefined` if unnamed | | `value` (get) | Returns current value; tracked inside `effect`/`computed` | | `peek()` | Returns current value without tracking | | `subscribe()` | Registers a change listener without an initial callback | --- ### `Computed` ```ts interface Computed extends Readable { dispose(): void; [Symbol.dispose](): void; } ``` Returned by `computed()`. A read-only derived signal with an explicit dispose method. `disposed` is `true` after `dispose()` is called. `readonly()` returns `Readable` (no dispose) — use `computed()` when ownership and explicit disposal are needed. --- ### `Store` ```ts interface Store extends Computed> { lens(path: P): Signal>; patch(partial: Partial): void; peek(): Readonly; replace(fn: (state: Readonly) => T): void; reset(): void; subscribe(listener: () => void): Subscription; } ``` | Member | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `.value` (get) | Read current state; tracked inside `effect`/`computed`; returns a read-only proxy | | `.peek()` | Read current state without tracking | | `.dispose()` | Permanently disposes the store — releases all internal prop signals and cached lenses. Idempotent. | | `.lens(path)` | Returns a cached, writable `Signal` for a property or dot-path; writes produce an immutable copy | | `.patch(partial)` | Shallow-merge when any provided key changes (`Object.is` comparison) | | `.replace(fn)` | Receive a deep clone (`structuredClone`) of current state; return the new state; returning the same clone reference is a silent no-op; a key present in the current state but omitted from the returned object is **removed**, not set to `undefined` | | `.reset()` | Restore the original `initial` state (deep-clones the stored baseline); any key added after construction (e.g. via `.replace()`) and absent from `initial` is **removed** | | `.subscribe()` | Fires on any mutation (`patch` / `replace` / `reset` / lens write) — use for external adapters; prefer `store.lens()` for reactive reads | `Store` extends `Computed>` — `dispose()`, `disposed`, `name`, and `[Symbol.dispose]()` are all inherited. `store.value` returns a proxy that throws `RippleInvalidStoreError` on any direct top-level set or delete. Use `.patch()`, `.replace()`, or `.lens()` to mutate state. --- ### `Scope` ```ts interface Scope { add(fn: CleanupFn): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; run(fn: () => T): T; [Symbol.dispose](): void; } ``` Returned by `scope()`. `run(fn)` activates the scope for `onCleanup()` calls. `add(fn)` explicitly registers a cleanup into the scope regardless of the current tracking context — use it to direct cleanups from inside an effect body into the scope rather than the effect. `dispose()` runs all registered cleanups in **LIFO order** and is idempotent. `disposed` is `true` after `dispose()` is called. `disposalSignal` is an `AbortSignal` that is aborted when `dispose()` is called — use it to tie external lifecycles to the scope. --- ### `Subscription` ```ts interface Subscription { dispose(): void; readonly disposed: boolean; [Symbol.dispose](): void; } ``` Returned by `effect()` and `watch()`. Use `.dispose()` or `using sub = ...`. `disposed` is `true` after the first `dispose()` call — idempotent. ```ts const sub = effect(() => ...); sub.dispose(); // dispose // or: using sub = effect(...) — TC39 using declaration ``` --- ### `AsyncSubscription` ```ts interface AsyncSubscription extends Subscription { run(): Promise; [Symbol.asyncDispose](): Promise; // ES2024 await using compatible } ``` Returned by `effectAsync()`. `run()` awaits the current in-flight async run without disposing. Use `[Symbol.asyncDispose]` for structured teardown with `await using`. ```ts // Await the current run without disposing: const stop = effectAsync(async (signal) => { ... }); await stop.run(); // waits for the in-flight run to finish stop.dispose(); // ES2024: await using declaration await using stop2 = effectAsync(async (signal) => { ... }); // stop2 is automatically disposed with [Symbol.asyncDispose] when the block exits ``` --- ### `AsyncEffectCallback` ```ts type AsyncEffectCallback = (signal: AbortSignal, owner: Scope) => Promise; ``` The callback passed to `effectAsync()`. Receives an `AbortSignal` that fires when the effect re-runs or is disposed, and a `Scope` (`owner`) for registering async cleanups after the first `await`. May return an async cleanup function. --- ### `SignalOptions` ```ts type SignalOptions = { equals?: EqualityFn; name?: string; }; ``` --- ### `ComputedOptions` ```ts type ComputedOptions = { equals?: EqualityFn; // default: Object.is name?: string; }; ``` --- ### `EffectOptions` ```ts type EffectOptions = { name?: string; // appears in error messages scheduler?: EffectScheduler; // default: 'sync' }; ``` All fields are optional. `name` is used in `RippleError` messages and in `RippleInfiniteLoopError` when the built-in loop guard fires. For debugging, use `debugEffect()` instead. --- ### `EffectScheduler` ```ts type EffectScheduler = 'microtask' | 'sync'; ``` | Value | Description | | ------------- | ---------------------------------------------------------------------- | | `'sync'` | (default) Re-run synchronously as part of the signal write propagation | | `'microtask'` | Re-run queued via `queueMicrotask()` — deferred but before next paint | For `'microtask'`, rapid signal writes within the same task coalesce into one re-run. --- ### `EffectAsyncOptions` ```ts type EffectAsyncOptions = { name?: string; // identifies the async effect in DevTools onError?: (error: unknown) => void; }; ``` Options for `effectAsync()`. `name` is passed to the internal `effect()` and appears in DevTools events. Provide `onError` to handle unhandled async errors from effect runs; defaults to `console.error` with a `[ripple]` prefix. --- ### `PathValue` ```ts type PathValue = ...; // recursive conditional type ``` Extracts the type at a dot-separated property path. Used as the return type of `store.lens(path): Signal>`. ```ts type Settings = { user: { name: string; address: { city: string } }; theme: 'light' | 'dark' }; type ThemeType = PathValue; // 'light' | 'dark' type CityType = PathValue; // string ``` --- ### `ResourceState` ```ts type ResourceState = | { readonly data?: T; readonly status: 'loading' } | { readonly data: T; readonly status: 'ready' } | { readonly data?: T; readonly error: unknown; readonly status: 'error' }; ``` Discriminated-union state emitted by a `resource()` signal. Narrow on `status` to access typed fields: ```ts const user = resource(async () => fetchUser(id.value)); effect(() => { const s = user.value; if (s.status === 'loading') return showSpinner(); if (s.status === 'error') return showError(s.error); renderUser(s.data); // s.data is T here }); ``` | Status | Fields present | | ----------- | ------------------------------------------------ | | `'loading'` | `data?: T` (carries last fulfilled value if any) | | `'ready'` | `data: T` | | `'error'` | `data?: T`, `error: unknown` | --- ### `ResourceOptions` ```ts type ResourceOptions = { initialValue?: T; name?: string; }; ``` Options for `resource()`. `initialValue` populates the `data` field in the initial `loading` state before the first result. `name` is a debug identifier passed to the internal signal and effect. --- ### `Resource` ```ts interface Resource extends Computed> { refresh(): void; } ``` Returned by `resource()`. Extends `Computed>` with `refresh()` — forces the factory to re-run immediately, aborting any in-flight run, even when no tracked dependency changed. No-op after `dispose()`. --- ### `StoreWithHistory` ```ts interface StoreWithHistory extends Store { readonly canUndo: boolean; readonly canRedo: boolean; historyAt(index: number): HistoryEntry | undefined; readonly historyLength: number; push(): void; pushNamed(label: string): void; undo(): void; redo(): void; /** The underlying store — escape hatch for adapters that need direct store access. */ readonly store: Store; } ``` Returned by `storeWithHistory()`. Extends `Store` directly — all store methods (`patch`, `replace`, `reset`, `lens`) are available without `.store` indirection. | Member | Description | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `push()` | Save the current state as an explicit undo checkpoint | | `pushNamed(label)` | Save the current state as an annotated checkpoint with a descriptive label | | `canUndo` | `true` when there is at least one snapshot to undo to. **Reactive** — participates in the reactive graph | | `canRedo` | `true` when there is at least one snapshot ahead to redo. **Reactive** — participates in the reactive graph | | `historyAt(i)` | `HistoryEntry` at index `i` (0 = oldest); `undefined` if out of range. After `maxHistory` eviction, index 0 is the oldest remaining entry | | `historyLength` | Number of snapshots currently in the buffer (≤ `maxHistory`) | | `undo()` | Move cursor back one step; no-op at the oldest state | | `redo()` | Move cursor forward one step; no-op at the newest state | | `dispose()` | Disposes the history adapter and cursor signal. Also disposes the underlying store only when the adapter created it (ownership). Idempotent. | | `[Symbol.dispose]()` | Same as `dispose()` — enables `using h = storeWithHistory(...)` declarations | | `store` | The underlying `Store` — escape hatch for adapters; prefer calling mutations directly on `h` | --- ### `EffectHandle` ```ts interface EffectHandle extends Subscription { getDependencies(): ReadonlyArray; } type DepInfo = { readonly kind: 'computed' | 'signal'; readonly name?: string; }; ``` Returned by `effect()` and `debugEffect()`. `getDependencies()` returns the reactive sources the effect is currently subscribed to, as collected during the last completed run. Returns an empty array after `dispose()`. ```ts const count = signal(0, { name: 'count' }); const handle = effect(() => { console.log(count.value); }); console.log(handle.getDependencies()); // [{ kind: 'signal', name: 'count' }] handle.dispose(); ``` --- ### `HistoryEntry` ```ts type HistoryEntry = { readonly label?: string; readonly state: Readonly; }; ``` A single snapshot entry returned by `StoreWithHistory.historyAt()`. `state` is a deep-frozen clone of the store state at that point. `label` is the string passed to `.pushNamed(label)`, or `undefined` for anonymous checkpoints created by `.push()`. --- ### `RippleDevToolsHook` ```ts // Shared by compute() and run() — only carries the node name. type NamedEvent = { name: string | undefined }; type WriteEvent = { name: string | undefined; newValue: unknown; oldValue: unknown }; type DisposeEvent = { kind: 'signal' | 'computed' | 'effect' | 'store'; name: string | undefined }; type MutateEvent = { kind: 'patch' | 'replace' | 'reset' | 'lens'; name: string | undefined; path?: string; // populated for kind: 'lens' }; type RippleDevToolsHook = { compute?(event: NamedEvent): void; dispose?(event: DisposeEvent): void; mutate?(event: MutateEvent): void; run?(event: NamedEvent): void; write?(event: WriteEvent): void; }; ``` All methods are optional. Each receives a single event object — add new fields in the future without breaking existing consumers. Install via `installDevTools(hook)`, uninstall with `installDevTools(null)`. The active hook is stored in a module-level variable; `globalThis.__RIPPLE_DEVTOOLS__` is kept in sync as a mirror for browser-extension DevTools. ```ts import { installDevTools } from '@vielzeug/ripple/devtools'; installDevTools({ write({ name, oldValue, newValue }) { console.log(`[ripple] ${name ?? '(unnamed)'}: ${String(oldValue)} → ${String(newValue)}`); }, run({ name }) { performance.mark(`effect:${name ?? 'anon'}`); }, dispose({ kind, name }) { console.log(`[ripple] ${kind} "${name ?? '(unnamed)'}" disposed`); }, mutate({ kind, name, path }) { const target = path ? `${name ?? '(unnamed)'}[${path}]` : (name ?? '(unnamed)'); console.log(`[ripple] store ${target} ${kind}`); }, }); ``` --- ### `CleanupFn` / `EffectCallback` / `EqualityFn` / `WatchOptions` ```ts type CleanupFn = () => void; type EffectCallback = () => CleanupFn | void; type EqualityFn = (a: T, b: T) => boolean; type WatchOptions = { equals?: EqualityFn; immediate?: boolean; name?: string; once?: boolean; // auto-dispose after first invocation }; ``` ## DevTools `installDevTools` and `debugEffect` are exported from `@vielzeug/ripple/devtools`, not the main entry point. This keeps them tree-shaken from production bundles. ### `installDevTools` ```ts function installDevTools(hook: RippleDevToolsHook | null): void; ``` Installs a DevTools observation hook. The hook is stored in a module-level variable (O(1) read on every signal write). `globalThis.__RIPPLE_DEVTOOLS__` is kept in sync as a mirror for browser-extension tools. Pass `null` to uninstall. ```ts import { installDevTools } from '@vielzeug/ripple/devtools'; installDevTools({ write({ name, oldValue, newValue }) { console.log(`${name ?? 'signal'}: ${String(oldValue)} → ${String(newValue)}`); }, }); // later: installDevTools(null); ``` --- ### `getDevToolsHook` ```ts function getDevToolsHook(): RippleDevToolsHook | null; ``` Returns the currently installed hook, or `null` if none is installed. ## Notification Timing All signal and store notifications fire **synchronously** — the subscriber callback runs before the next line after the write. ```ts const s = store({ count: 0 }); const sub = watch( () => s.value.count, (count) => console.log('changed:', count), ); s.patch({ count: 1 }); // 'changed: 1' has already been logged here ``` To coalesce multiple writes into a single notification, use `batch()`: ```ts batch(() => { s.patch({ count: 1 }); s.patch({ count: 2 }); s.patch({ count: 3 }); }); // One notification fires after the batch with the final state: { count: 3 } ``` Nested `batch()` calls merge into the outermost — only one flush occurs. ### Usage Guide ## Basic Usage ```ts import { signal, computed, effect } from '@vielzeug/ripple'; const count = signal(0); const doubled = computed(() => count.value * 2); const sub = effect(() => { console.log('doubled:', doubled.value); }); // → logs "doubled: 0" immediately count.value = 5; // → logs "doubled: 10" sub.dispose(); doubled.dispose(); ``` ## Signals A **signal** is the fundamental reactive primitive. It holds a single value and notifies dependents when that value changes. ### Creating a Signal ```ts const count = signal(0); const name = signal('Alice'); const items = signal([]); ``` ### Reading and Writing ```ts count.value; // read — tracked inside effect/computed count.value = 42; // write — notifies all dependents count.peek(); // read without registering a subscription untrack(() => count.value); // equivalent escape hatch for arbitrary reads ``` ### External Store Interop Every signal exposes a small external-store interface: ```ts const unsubscribe = count.subscribe(() => { console.log('changed:', count.value); }); count.value = 1; unsubscribe(); ``` `subscribe()` does not fire immediately on subscription. It only fires after the value changes, which matches React's `useSyncExternalStore()` contract. ## Effects `effect()` runs a function immediately and re-runs it whenever any signal read inside it changes. Returns a `Subscription` handle. ```ts const count = signal(0); const sub = effect(() => { console.log('count is:', count.value); }); // → logs "count is: 0" immediately count.value = 1; // → logs "count is: 1" count.value = 2; // → logs "count is: 2" sub.dispose(); // dispose — no more runs // or: using sub = effect(...) — TC39 using declaration ``` ### Effect Cleanup Return a cleanup function from the effect callback; it runs before the next re-execution and when the effect is disposed: ```ts const sub = effect(() => { const id = setInterval(() => console.log('tick'), 1000); return () => clearInterval(id); // cleanup on next run or dispose }); ``` ### `onCleanup` Register teardown from inside nested helpers without using the return value: ```ts function useInterval(ms: number) { const id = setInterval(() => console.log('tick'), ms); onCleanup(() => clearInterval(id)); // registers cleanup in the current effect } const sub = effect(() => { useInterval(1000); // cleanup registered automatically }); ``` Ripple includes a built-in loop guard (100 iterations by default) to protect against accidental self-triggering effect cycles. ### Effect Options `effect()` accepts an optional `EffectOptions` object to control scheduling, debugging, and loop protection: ```ts import { effect } from '@vielzeug/ripple'; // Named effect — name appears in RippleError messages for easier debugging const sub = effect(() => console.log('count:', count.value), { name: 'count-logger' }); // Microtask scheduler — re-runs are deferred and coalesce within the same task effect(() => (document.title = count.value.toString()), { scheduler: 'microtask' }); ``` | Option | Type | Default | Description | | ----------- | ----------------- | -------- | ------------------------- | | `scheduler` | `EffectScheduler` | `'sync'` | `'sync'` or `'microtask'` | | `name` | `string` | — | Shown in error messages | For debugging which deps trigger re-runs, use `debugEffect()` instead of `effect()` — see [debugEffect](#debugeffect) below. ### `untrack` Reads signals inside an effect without creating reactive subscriptions: ```ts const a = signal(1); const b = signal(2); effect(() => { // only subscribed to `a`; changes to `b` will not re-run this effect const sum = a.value + untrack(() => b.value); console.log('sum:', sum); }); ``` ## Computed `computed()` creates a derived read-only signal whose value is automatically recomputed when its dependencies change. ```ts const count = signal(3); const doubled = computed(() => count.value * 2); console.log(doubled.value); // 6 count.value = 10; console.log(doubled.value); // 20 ``` Call `.dispose()` when the computed is no longer needed to detach it from its dependencies and stop recomputation: ```ts doubled.dispose(); // or: using doubled = computed(...) — TC39 using declaration ``` ### Automatic Disposal Inside Effects When `computed()` is called inside an `effect()`, the computed signal is automatically disposed when the effect cleans up. This prevents memory leaks from derived computations that only exist within the effect scope: ```ts effect(() => { // This computed is automatically disposed when the effect is disposed const derived = computed(() => expensiveCalc(source.value)); doSomething(derived.value); }); ``` This behavior is an ergonomic convenience and works because `computed()` detects the active effect scope and registers itself for automatic cleanup. ### `peek` Computed signals also support `.peek()` for non-tracked reads: ```ts const total = computed(() => subtotal.value + tax.value); effect(() => { console.log('tracked total', total.value); }); const snapshot = total.peek(); ``` ### Chaining Computeds ```ts const a = signal(2); const b = computed(() => a.value * 3); // 6 const c = computed(() => b.value + 1); // 7 a.value = 4; console.log(c.value); // 13 ``` ## `watch` (Signals) `watch()` is an explicit subscription that fires only when the signal's value changes — it does **not** run immediately like `effect()`. Returns a `Subscription`. ```ts const count = signal(0); const sub = watch(count, (next, prev) => { console.log(prev, '→', next); }); count.value = 1; // → logs "0 → 1" sub.dispose(); ``` ### Options ```ts // Fire once immediately on subscription watch(count, (v) => console.log(v), { immediate: true }); // Auto-dispose after the first change — one-shot listener watch(status, (v) => onFirstChange(v), { once: true }); // Custom equality — suppress callback when result is considered equal watch(list, (v) => renderList(v), { equals: (a, b) => a.length === b.length }); ``` ### Watching a Slice Use a lens or a `computed()` to watch a derived slice: ```ts // Lens — fine-grained subscription to one field const userStore = store({ name: 'Alice', role: 'user' }); watch(userStore.lens('name'), (name, prevName) => { console.log('name:', prevName, '→', name); }); // computed() — arbitrary derived slice const nameSignal = computed(() => userStore.value.name); watch(nameSignal, (name, prev) => console.log('name:', prev, '→', name)); nameSignal.dispose(); ``` ### Watching Multiple Sources Pass a function instead of a single `Readable` to track every signal it reads — no intermediate `computed()` node needed: ```ts const firstName = signal('Ada'); const lastName = signal('Lovelace'); const sub = watch( () => `${firstName.value} ${lastName.value}`, (fullName, prev) => console.log(`${prev} → ${fullName}`), ); lastName.value = 'King'; // → "Ada Lovelace → Ada King" sub.dispose(); ``` ## `batch` (Signals) `batch()` defers all signal notifications until the callback returns, then flushes once. Nested batches coalesce into the outermost. ```ts const a = signal(0); const b = signal(0); let fires = 0; effect(() => { a.value; b.value; fires++; }); // fires is 1 (initial run) batch(() => { a.value = 1; b.value = 2; }); // fires is 2 (one flush for both) ``` If the callback throws, the pending flush queue is discarded (not flushed) — writes made before the throw are not rolled back, but no effect observes them. The original error propagates as-is. ## `scope` `scope()` creates an isolated cleanup context that is not tied to any reactive effect. Use it when you want to collect teardown callbacks and release them all at once — without needing an effect or a component lifecycle hook. Pass an optional `setup` function to register cleanups inline at construction time. This is equivalent to calling `scope.run(setup)` immediately after creation: ```ts import { scope, onCleanup } from '@vielzeug/ripple'; // Shorthand — setup runs immediately: const s = scope(() => { const id = setInterval(() => tick(), 1000); onCleanup(() => clearInterval(id)); const ws = new WebSocket('wss://example.com'); onCleanup(() => ws.close()); }); // Later — tears down all cleanups in LIFO order: s.dispose(); ``` `scope.run()` can also be called multiple times to incrementally register cleanups into the same scope. The `using` declaration auto-disposes at block end: ```ts { using s = scope(); s.run(() => { onCleanup(() => console.log('cleaned up')); }); } // ← scope.dispose() called here automatically ``` **Inside ore components**, `scope()` is available via `@vielzeug/ore` and is useful for managing sub-scoped cleanup (e.g., an animation controller or WebSocket owned by one part of a component): ```ts import { scope, onCleanup, effect } from '@vielzeug/ore'; define('my-component', { setup() { const animScope = scope(); onCleanup(() => animScope.dispose()); // tie sub-scope to component lifetime onMounted(() => { animScope.run(() => { const raf = requestAnimationFrame(animate); onCleanup(() => cancelAnimationFrame(raf)); }); }); return () => html`...`; }, }); ``` ## `debugEffect` `debugEffect(fn, options?)` is identical to `effect()` but logs the reactive sources that changed before each re-run. Use it as a drop-in replacement for debugging unexpected re-renders. ```ts import { debugEffect } from '@vielzeug/ripple/devtools'; const stop = debugEffect(() => renderUser(userId.value, name.value), { name: 'renderUser' }); // Console output on re-run: // [ripple:trace] "renderUser" re-running — changed sources: // userId (v1 -> v2) ``` ## Async Computed `resource(factory, options?)` tracks reactive dependencies inside an async factory and re-runs when they change. The factory receives an `AbortSignal` that fires when the factory is superseded or disposed. The returned `Resource` emits a single discriminated union: - `{ status: 'loading', data: T | undefined }` — initial state or while re-running - `{ status: 'ready', data: T }` — last successful result - `{ status: 'error', data: T | undefined, error: unknown }` — last thrown error ```ts import { signal, effect, resource } from '@vielzeug/ripple'; const userId = signal('u1'); const user = resource(async (abortSignal) => { const id = userId.value; // tracked dep — must be read synchronously const res = await fetch(`/users/${id}`, { signal: abortSignal }); if (!res.ok) throw new Error('Not found'); return res.json() as Promise; }); effect(() => { const s = user.value; // ResourceState if (s.status === 'loading') return showSpinner(); if (s.status === 'error') return showError(s.error); renderUser(s.data); // narrowed to User }); userId.value = 'u2'; // aborts the in-flight fetch, re-runs factory user.refresh(); // force a re-fetch for the current userId — e.g. a "retry" button user.dispose(); ``` `resource` tracks dependencies the same way `computed` does: only reads that happen **synchronously**, before the first `await`, are tracked. Reads inside `await` expressions are NOT tracked. Call `.refresh()` to force the factory to re-run immediately, aborting any in-flight run — even if no tracked dependency changed. This is the idiomatic way to implement a "retry" button after an error, or a manual "refetch" action. Like `computed()`, a `resource()` created inside an active `effect()` or `scope.run()` context is automatically registered for cleanup and disposed with that context: ```ts effect(() => { // Automatically disposed when the effect re-runs or is disposed const user = resource(() => fetchUser(userId.value)); render(user.value); }); ``` ## Store History / Time-Travel `storeWithHistory(initial, options?)` wraps a store with snapshot-based undo/redo. Mutations do **not** automatically push snapshots — call `.push()` (or `.pushNamed(label)`) explicitly after each logical change. History navigation with `undo()` and `redo()` never re-runs logic — it replays snapshots directly. ```ts import { storeWithHistory } from '@vielzeug/ripple'; const editor = storeWithHistory({ text: '', cursor: 0 }, { maxHistory: 100 }); editor.patch({ text: 'hello', cursor: 5 }); editor.push(); // checkpoint 1 editor.patch({ text: 'hello world', cursor: 11 }); editor.push(); // checkpoint 2 console.log(editor.historyLength); // 3 (initial + 2 explicit pushes) console.log(editor.historyAt(0).state); // { text: '', cursor: 0 } editor.undo(); console.log(editor.peek().text); // 'hello' editor.redo(); console.log(editor.peek().text); // 'hello world' ``` `canUndo` and `canRedo` are reactive boolean properties — read them inside `effect()` or `computed()` and they will re-run automatically when the history cursor moves: ```ts effect(() => { undoButton.disabled = !editor.canUndo; redoButton.disabled = !editor.canRedo; }); ``` `StoreWithHistory` extends `Store` directly — all methods (`patch`, `replace`, `reset`, `lens`) are available on the adapter itself. The `.store` accessor is an escape hatch for adapters that need direct `Store` access. Call `dispose()` when the store is no longer needed to release the internal reactive cursor signal: ```ts const s = storeWithHistory({ count: 0 }); // ... use s s.dispose(); ``` Once the buffer reaches `maxHistory`, the oldest snapshot is evicted on each new write. `historyAt(0)` always returns the oldest _remaining_ snapshot — it is not guaranteed to be the initial state once eviction has occurred. ## Stores A `Store` adds structured state helpers on top of a `.value` getter. Every signal primitive (`computed`, `effect`, `watch`, `batch`, `untrack`) works on stores directly. ### Creating a Store ```ts import { store } from '@vielzeug/ripple'; const s = store({ count: 0, user: null as User | null }); ``` ### Reading State ```ts const state = s.value; // { count: 0, user: null } const count = s.value.count; // 0 ``` `.value` is a synchronous getter — no method call needed. ### Writing State #### Partial Patch Shallow-merges the patch into the current state: ```ts s.patch({ count: 1 }); // Equivalent to: { ...current, count: 1 } ``` #### Updater Function Receives a plain shallow copy of the current state; return value replaces it. The argument is a regular object — you can mutate it freely inside the callback: ```ts s.replace((current) => ({ ...current, count: current.count + 1 })); ``` `replace()` is a no-op when `fn` returns the same object reference it received. Every key is validated (including the prototype-pollution guard against `__proto__`, `constructor`, and `prototype`) before any key is applied. If any key is rejected, the call throws `RippleInvalidStoreError` and **none** of the keys are applied — no partial writes, no stray un-notified state changes. A key present in the current state but **omitted** from the object `fn` returns is actually removed — not set to `undefined`: ```ts const s = store({ count: 0, note: 'draft' }); s.replace((state) => { const { note, ...rest } = state; // drop `note` entirely return rest; }); Object.hasOwn(s.value, 'note'); // false ``` ### Resetting State ```ts // Restore to the state passed to store() s.reset(); ``` `reset()` triggers a notification if the state actually changes. The initial state is defensively copied at construction time — external mutations to the original object cannot corrupt `reset()`. Any key added after construction (e.g. via `.replace()`) and absent from the original `initial` state is removed on reset, same as `.replace()`. ### Derived Slices ### Via `computed()` Use `computed()` to derive a signal from a slice of the store's state: ```ts const countSignal = computed(() => s.value.count); console.log(countSignal.value); // 0 // Compose with watch() to react to slice changes only const sub = watch(countSignal, (count, prev) => { console.log('count changed:', prev, '→', count); }); // Clean up when done sub.dispose(); countSignal.dispose(); ``` Pass a custom `equals` option for arrays and objects to avoid re-rendering when contents haven't changed: ```ts const items = computed(() => s.value.items, { equals: (a, b) => a.length === b.length }); ``` ### Via `store.lens()` `store.lens(path)` returns a writable `Signal` scoped to a specific property or dot-path. Lenses are cached per path and produce immutable copies on write: ```ts const settings = store({ user: { name: 'Alice', address: { city: 'Berlin' } }, theme: 'light' as 'light' | 'dark', }); // Top-level lens const theme = settings.lens('theme'); // Signal theme.value = 'dark'; // Nested dot-path lens const city = settings.lens('user.address.city'); // Signal city.value = 'Hamburg'; console.log(settings.value.theme); // 'dark' console.log(settings.value.user.address.city); // 'Hamburg' // Watch a single field watch(theme, (next, prev) => console.log(prev, '→', next)); // Write directly theme.value = theme.value === 'light' ? 'dark' : 'light'; ``` Lenses are cached: `settings.lens('theme')` called twice returns the same `Signal`. Disposing a lens removes it from the cache — the next call to `settings.lens('theme')` creates a fresh instance. Every intermediate segment of the path must resolve to a non-null object. Writing through `settings.lens('user.address.city')` will throw `RippleInvalidStoreError` if `settings.value.user` or `settings.value.user.address` is `null` or not an object. Paths are also capped at **32 segments**. Paths exceeding this limit throw `RippleInvalidStoreError` with a descriptive message. ### Watching State #### Full-State Watch ```ts // Does not fire immediately — use { immediate: true } to opt in const sub = watch(s, (curr, prev) => { console.log('state changed', curr); }); sub.dispose(); // stop receiving updates ``` When `immediate: true`, the listener fires once synchronously on subscription with both `curr` and `prev` set to the current value: ```ts watch( s, (curr, prev) => { console.log('initial or changed:', curr.count); }, { immediate: true }, ); ``` To auto-stop after the first change, dispose manually inside the callback: ```ts const stop = watch(s, (curr) => { console.log('first change:', curr); stop.dispose(); }); ``` #### Slice Watch Use a getter source to watch a slice — only fires when the derived value changes: ```ts // Only fires when `count` changes — unrelated state changes are ignored watch( () => s.value.count, (count, prev) => console.log('count changed to', count), ); // With computed() for a reusable or shareable slice signal const countSignal = computed(() => s.value.count); watch(countSignal, (count, prev) => console.log('count changed to', count), { equals: (a, b) => a === b, }); ``` ### Batching Store Mutations `batch()` groups multiple writes into a single notification: ```ts import { batch } from '@vielzeug/ripple'; const result = batch(() => { s.patch({ firstName: 'Alice' }); s.patch({ lastName: 'Smith' }); s.patch({ age: 30 }); return 'profile updated'; }); // One notification for all three patch() calls // result === 'profile updated' ``` Nested `batch()` calls merge into the outermost — only one notification fires when the outermost batch completes. ### Narrowing to Read-Only To expose a store at API boundaries where consumers should observe but not mutate, wrap it with `readonly()`: ```ts import { readonly, store } from '@vielzeug/ripple'; import type { Readable } from '@vielzeug/ripple'; type CounterService = { state: Readable; increment(): void; decrement(): void; }; function createCounterService(): CounterService { const s = store({ count: 0 }); return { state: readonly(s), increment() { s.replace((st) => ({ count: st.count + 1 })); }, decrement() { s.replace((st) => ({ count: st.count - 1 })); }, }; } const counter = createCounterService(); counter.state.value.count; // readable // counter.state.value = ...; // TS compile error — read-only ``` ## Signal Combinators Use `computed()` to project a reactive source into a derived value: ```ts import { signal, computed } from '@vielzeug/ripple'; const count = signal(3); const doubled = computed(() => count.value * 2); // Computed console.log(doubled.value); // 6 count.value = 5; console.log(doubled.value); // 10 doubled.dispose(); ``` For a named projection, pass `options.name`: ```ts const doubled = computed(() => count.value * 2, { name: 'doubled' }); ``` ## `Symbol.dispose` / `using` Declarations All `Subscription`, `Computed`, and `Scope` handles implement `[Symbol.dispose]`, enabling the TC39 [explicit resource management](https://github.com/tc39/proposal-explicit-resource-management) syntax: ```ts { using sub = effect(() => console.log(count.value)); using doubled = computed(() => count.value * 2); // both are automatically disposed when the block exits } ``` `AsyncSubscription` (returned by `effectAsync()`) also implements `[Symbol.asyncDispose]`, enabling `await using`: ```ts { await using stop = effectAsync(async (signal) => { await fetchData(signal); }); // stop[Symbol.asyncDispose]() is called automatically — awaits the in-flight run } ``` ## Testing Ripple stores are plain objects — no special test utilities needed. Create a fresh store in `beforeEach` and dispose any active effects in `afterEach`. ```ts import { store, watch } from '@vielzeug/ripple'; import type { Store } from '@vielzeug/ripple'; describe('counter', () => { let s: Store; beforeEach(() => { s = store({ count: 0 }); }); it('patches count', () => { s.patch({ count: 1 }); expect(s.value.count).toBe(1); }); it('notifies watcher on change', () => { const listener = vi.fn(); const sub = watch(s, listener); s.patch({ count: 5 }); // notifications are synchronous — no await needed expect(listener).toHaveBeenCalledWith({ count: 5 }, { count: 0 }); sub.dispose(); }); }); ``` For isolated signal tests, create signals in the test scope — they are garbage-collected unless an active `effect()` holds a reference: ```ts it('computed updates reactively', () => { const n = signal(2); const sq = computed(() => n.value ** 2); expect(sq.value).toBe(4); n.value = 3; expect(sq.value).toBe(9); sq.dispose(); }); ``` ## DevTools Import `installDevTools` from the dedicated sub-path. This keeps DevTools code out of production bundles when unused. ```ts import { installDevTools } from '@vielzeug/ripple/devtools'; installDevTools({ write({ name, oldValue, newValue }) { console.log(`[write] ${name ?? '(unnamed)'}: ${String(oldValue)} → ${String(newValue)}`); }, run({ name }) { performance.mark(`effect:${name ?? 'anon'}`); }, dispose({ kind, name }) { console.log(`[dispose] ${kind} "${name ?? '(unnamed)'}"`); }, compute({ name }) { console.log(`[compute] ${name ?? '(unnamed)'}`); }, mutate({ kind, name, path }) { const target = path ? `${name ?? '(unnamed)'}[${path}]` : (name ?? '(unnamed)'); console.log(`[mutate] store ${target} — ${kind}`); }, }); // Uninstall when no longer needed: installDevTools(null); ``` All hook methods are optional. The hook is stored in a module-level variable (not `globalThis`); `globalThis.__RIPPLE_DEVTOOLS__` is kept in sync as a mirror for browser-extension tools. ## Framework Integration ```tsx [React] import { useSyncExternalStore } from 'react'; import { signal, computed, effect, type Readable } from '@vielzeug/ripple'; // Generic hook — works with any signal or computed function useSignalValue(source: Readable): T { return useSyncExternalStore(source.subscribe, () => source.value); } // Usage in a component const count = signal(0); const doubled = computed(() => count.value * 2); function Counter() { const value = useSignalValue(count); const doubledValue = useSignalValue(doubled); return ( {value} × 2 = {doubledValue} count.value++}>Increment ); } ``` ```ts [Vue 3] import { customRef, onScopeDispose } from 'vue'; import { signal, computed, watch, type Readable, type Signal } from '@vielzeug/ripple'; // Composable for read/write signals function useSignal(source: Signal) { return customRef((track, trigger) => ({ get() { track(); return source.value; }, set(value) { source.value = value; trigger(); }, })); } // Composable for read-only signals and computeds function useSignalValue(source: Readable) { const stop = watch(source, () => {}, { immediate: true }); onScopeDispose(() => stop.dispose()); return customRef((track) => ({ get() { track(); return source.value; }, set(value) { void value; }, })); } ``` ```svelte [Svelte] import { signal, computed } from '@vielzeug/ripple'; import type { Readable } from '@vielzeug/ripple'; // Manual Svelte store adapter — calls run() immediately, then on each change function toSvelteStore(source: Readable) { return { subscribe(run: (value: T) => void) { run(source.value); // Svelte contract: fire immediately with current value const sub = source.subscribe(() => run(source.value)); return () => sub.dispose(); }, }; } const count = signal(0); const doubled = computed(() => count.value * 2); const countStore = toSvelteStore(count); const doubledStore = toSvelteStore(doubled); // Use $countStore and $doubledStore in the template {$countStore} × 2 = {$doubledStore} count.value++}>Increment ``` ## Working with Other Vielzeug Libraries ### With Sourcerer Use Ripple signals for local UI intent and Sourcerer for remote/list data orchestration. ```ts import { signal } from '@vielzeug/ripple'; import { createRemoteSource } from '@vielzeug/sourcerer'; const search = signal(''); const source = createRemoteSource({ fetch: ({ page }) => api.items.list({ page, search: search.value }), }); search.subscribe(() => { source.page(1); void source.refresh(); }); ``` ## Best Practices ### 1. Signals for Primitive Values, Stores for Objects ```ts // signal for a single scalar const isOpen = signal(false); // store for structured objects const user = store({ id: '', name: '', role: 'guest' }); // store for a simple boolean — overcomplicated const isOpen = store({ value: false }); ``` ### 2. Computed for Derived Values ```ts // computed instead of duplicating logic in effects const fullName = computed(() => `${firstName.value} ${lastName.value}`); // avoid manually syncing derived state in an effect const fullNameState = signal(''); effect(() => { fullNameState.value = `${firstName.value} ${lastName.value}`; }); ``` ### 3. Watch Slices with Getter Sources or `computed()` Both approaches work; choose based on reuse needs: ```ts // getter source — simple for one-off watches watch( () => userStore.value.count, (count) => console.log('count:', count), ); // composed with computed() — better for shared/complex selections const countSignal = computed(() => userStore.value.count); watch(countSignal, (count) => console.log('count:', count)); countSignal.dispose(); ``` ### 4. Batch Multiple Updates ```ts // one notification instead of two batch(() => { x.value = 1; y.value = 2; }); ``` ### 5. Use Direct Assignment on Signals; `.replace()` on Stores ```ts // signals: read-modify-write in one line count.value = count.value + 1; // stores: replace via function cart.replace((s) => ({ ...s, items: [...s.items, newItem] })); ``` ### 6. Dispose Effects and Computeds When No Longer Needed ```ts const sub = effect(() => (document.title = `Count: ${count.value}`)); // when component unmounts: sub.dispose(); ``` ### 7. Use `untrack` to Break Unwanted Dependencies ```ts effect(() => { const id = userId.value; // tracked const name = untrack(() => users.value[id]); // NOT tracked — avoids re-run on users change render(id, name); }); ``` ### Examples ## Examples - [Signals](./examples/signals.md) - [Stores](./examples/stores.md) - [Projecting Signals](./examples/derive-and-filter.md) - [Async Computed](./examples/async-computed.md) - [Store History (Undo/Redo)](./examples/store-history.md) - [Pattern Batch For Complex Mutations](./examples/pattern-batch-for-complex-mutations.md) - [Pattern Async Workflows With Watch](./examples/pattern-nextvalue-in-async-workflows.md) - [Pattern Shared Module Store](./examples/pattern-shared-module-store.md) ### REPL Examples - Async Resource — fetch, errors & manual refresh (id: `async-resource`) - Signal, Computed & Effect (id: `basic-signal`) - Batch & Untrack (id: `batch-untrack`) - Derived Signals (id: `derived-signals`) - Disposal & .name (id: `disposal`) - Effect Options — scheduler (id: `effect-options`) - Async Workflows with watch() (id: `next-value`) - Scope & onCleanup (id: `scope-cleanup`) - Scope — setup shorthand (id: `scope-setup`) - Store — patch, lens & computed (id: `store-basics`) - Store History — Undo/Redo (id: `store-history`) - Store — fine-grained lens reactivity (id: `store-lenses`) - Store - Todo List (id: `store-todo-list`) - Watch, Lens & Map (id: `watch-and-subscribe`) - watch — multiple sources (id: `watch-multi-source`) - watch — once (id: `watch-once`) --- ## @vielzeug/rune **Category:** logging **Keywords:** logging, console, structured, scoped, transports, remote-logging, levels, namespaces, lazy-bindings **Key exports:** createLogger, defaultLogger, consoleTransport, remoteTransport, jsonTransport, batchTransport, sampleTransport, redactTransport, pipe, lazy, isLevelEnabled, resolveTheme (+4 more) **Related:** courier, herald, familiar ### Overview ## Why Rune? Plain `console.log` lacks structure: no log levels, no namespacing, no remote delivery, no way to silence logs in production. ```ts // Before — manual approach if (process.env.NODE_ENV !== 'production') { console.log('[api] GET /users', data); } fetch('/api/logs', { method: 'POST', body: JSON.stringify({ level: 'error', msg }) }); // After — Rune import { createLogger } from '@vielzeug/rune'; import { consoleTransport, pipe, remoteTransport } from '@vielzeug/rune'; const api = createLogger({ namespace: 'api', transports: [ pipe(consoleTransport({ level: 'debug' }), remoteTransport({ handler: sendToCollector, level: 'error' })), ], }); api.info({ data }, 'GET /users'); ``` | Feature | Rune | Winston | Pino | console | | -------------------- | ------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------- | ------------------------------------------ | | Bundle size | | ~44 kB | ~4 kB | 0 kB | | Browser support | | | | | | Scoped loggers | | Manual | Child | | | Pluggable transports | Built-in factories | Transports | Streams | | | Structured log entry | `LogEntry` type | Partial | | | | Lazy bindings | `lazy(fn)` | | | | | Styled output | CSS badges | Text only | Text only | Manual | | Zero dependencies | | (15+) | (5+) | N/A | **Use Rune when** you need isomorphic logging (browser + Node.js), namespaced module loggers, or remote error delivery without a heavy dependency chain. **Consider alternatives when** you need high-throughput file-based logging (Pino), file rotation (Winston), or your team already uses a logging framework. ## Installation ```sh [pnpm] pnpm add @vielzeug/rune ``` ```sh [npm] npm install @vielzeug/rune ``` ```sh [yarn] yarn add @vielzeug/rune ``` ## Quick Start ```ts import { createLogger, defaultLogger, lazy } from '@vielzeug/rune'; import { consoleTransport, pipe, remoteTransport, jsonTransport } from '@vielzeug/rune'; // Default logger — uses consoleTransport() automatically defaultLogger.info('Boot complete'); defaultLogger.warn('Cache stale'); defaultLogger.fatal({ err: new Error('unrecoverable') }, 'startup failed'); // Namespaced child loggers const api = createLogger('api'); api.info({ method: 'GET', path: '/users' }, 'request'); // Pinned bindings — lazy() only evaluates when the level passes const reqLog = api.withBindings({ requestId: 'abc-123', diagnostics: lazy(() => buildDiagnostics()), }); reqLog.debug('processing'); // diagnostics() called only here // Structured timing — label is the message; emits { duration_ms } in context await reqLog.time('db.query', () => runQuery()); // Custom transport pipeline const serverLog = createLogger({ logLevel: 'info', namespace: 'server', transports: [ consoleTransport({ timestamp: true }), remoteTransport({ handler: async (type, data) => { await fetch('/api/logs', { body: JSON.stringify(data), method: 'POST' }); }, level: 'error', }), ], }); // Node.js: structured JSON for log aggregation const nodeLog = createLogger({ transports: [jsonTransport({ level: 'warn' })], }); ``` ## Features - Level filtering (`debug` to `off`) with `enabled()` checks, including `fatal` above `error` - Immutable config after construction — use `child()` or `withBindings()` to scope - Three call forms: `log.info('msg')`, `log.error(err, { id }, 'msg')` (Error-first), or `log.info({ key: 'val' }, 'msg')` — Error-first form auto-serializes to `data.err` - `Error` values in context fields are also auto-serialized to `{ message, name, stack }` — survives JSON.stringify - Pinned context bindings via `withBindings({ requestId })` — fields on every line - Lazy bindings via `lazy(fn)` — expensive computations gated behind the level check - Namespaced child loggers via `createLogger('name')` or `logger.child({ namespace })` - Middleware pipeline via `use(fn)` — transform or filter entries before transport dispatch - Pluggable transport pipeline: `consoleTransport`, `remoteTransport`, `jsonTransport`, `batchTransport`, `sampleTransport`, `redactTransport` - Fan-out via `pipe()` — dispatch to multiple transports independently, fault-tolerant - Structured `time()` wrapper: emits the label as message with `{ duration_ms }` in context - `group()` and `groupCollapsed()` wrappers that auto-close on throw/reject - `LogEntry.data` — single merged flat object for transports; no manual merging needed - Zero dependencies — gzipped ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Courier](/courier/) — HTTP client with built-in request/response interception; pipe Rune as a transport to log every API call with structured context - [Herald](/herald/) — typed event bus; emit log-level change or flush events across modules without coupling loggers directly - [Familiar](/familiar/) — Web Worker pool; use Rune inside task functions to surface structured worker-side logs back to the main thread ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------- | ------------------------------------------------ | -------------- | ------------------------------------------------------------ | | `createLogger()` | Create an isolated `Logger` instance | Sync | Omitting `transports` defaults to `consoleTransport()` | | `defaultLogger` | Pre-created default logger singleton | — | Shared instance — use `child()` or `withBindings()` to scope | | `lazy(fn)` | Defer a binding value past the level check | Sync | Factory runs on every emit, not once | | `pipe()` | Fan-out dispatcher to multiple transports | Sync | Errors in one transport don't propagate to others | | `isLevelEnabled()` | Utility: test whether a level passes a threshold | Sync | `'off'` always returns `false` | | `PRIORITY` | Numeric priority table backing `isLevelEnabled()`| — | Lower number = more verbose | | `resolveTheme()` | Merge a partial theme onto the default | Sync | Returns a fully-populated `ResolvedTheme` | | `RuneError` | Base class for all `rune`-originated errors | — | Use `RuneError.is(err)` as the type guard | | `RuneTransportError` | Internal transport-failure error (never thrown) | — | Inspect via dev-only warnings, not `try`/`catch` | | `consoleTransport()` | Styled console output | Sync | Theme is resolved once at factory call, not per entry | | `remoteTransport()` | Async HTTP/webhook delivery | Async | Handler errors are swallowed to `console.warn` | | `jsonTransport()` | NDJSON to stdout or a custom sink | Sync | `process.stdout` is unavailable in browsers | | `batchTransport()` | Buffered batch delivery with flush interval | Sync/Interval | Must call `.dispose()` on shutdown to flush remaining | | `sampleTransport()` | Probabilistic entry forwarding | Sync | `rate: 1` forwards all entries; `rate: 0` forwards none | | `redactTransport()` | Sensitive field stripping before forwarding | Sync | Place this closest to the remote transport, not console | ## Package Entry Point | Import | Purpose | | ---------------- | -------------------------------------------------------- | | `@vielzeug/rune` | All exports — logger, transport factories, `lazy`, types | ## createLogger(initial?, options?) Creates an isolated logger instance. ```ts createLogger(namespace: string, options?: Omit): Logger createLogger(options?: RuneOptions): Logger ``` - `string` shorthand sets namespace: `createLogger('api')` or `createLogger('api', { logLevel: 'warn' })`. - Each call produces a fully independent instance — no shared mutable state. - Default transport is `consoleTransport()` when `transports` is omitted. > **Note — disposed loggers:** after `dispose()` is called, all log methods (`debug`, `info`, `warn`, `error`, `fatal`), `time()`, and `group()` / `groupCollapsed()` silently no-op. The `fn` callback in `group()` still runs — only the group header is suppressed. > **Note — transport/middleware fault isolation:** if a transport or middleware function throws, the logger catches it, reports it via a dev-only warning (the transport case wraps the error in `RuneTransportError`), and continues — a single misbehaving transport can never crash the caller of `log.info()`/etc., and sibling transports still receive the entry. A throwing middleware drops just that one entry. **Returns:** `Logger` **Example:** ```ts import { createLogger } from '@vielzeug/rune'; import { consoleTransport, remoteTransport } from '@vielzeug/rune'; const log = createLogger({ logLevel: 'warn', namespace: 'app' }); const serverLog = createLogger({ namespace: 'server', transports: [ consoleTransport(), remoteTransport({ handler: async (type, data) => { await fetch('/api/logs', { body: JSON.stringify(data), method: 'POST' }); }, level: 'error', }), ], }); ``` ## defaultLogger `defaultLogger` is the pre-created default logger (`createLogger()` called once at module load). Use it as a quick-start singleton or create a child for module-level use: ```ts import { defaultLogger } from '@vielzeug/rune'; const log = defaultLogger.child({ namespace: 'app.worker' }); ``` ## lazy(fn) Defers evaluation of an expensive binding value until after the level check passes. The factory function is never called when the log level suppresses the entry. ```ts lazy(fn: () => unknown): LazyBinding ``` ```ts import { lazy } from '@vielzeug/rune'; const reqLog = log.withBindings({ diagnostics: lazy(() => buildExpensiveDiagnostics()), }); reqLog.debug('trace'); // diagnostics() only called when debug is enabled ``` **Returns:** `LazyBinding` ## Logger Methods ### Logging All five methods share the same signature: ```ts log.debug / info / warn / error / fatal(message: string): void log.debug / info / warn / error / fatal(error: Error, context?: Bindings, message?: string): void log.debug / info / warn / error / fatal(context: Bindings, message?: string): void ``` Argument rules: - String-only calls accept a single message argument. - **Error-first form:** pass an `Error` as the first argument — it is auto-serialized to `{ message, name, stack }` under the `err` key. Optionally follow with a `Bindings` object and/or a message string. - Context object comes first when providing structured data without a top-level Error. `Error` values inside the context object are also auto-serialized to `{ message, name, stack }`. ```ts log.error(err, 'request failed'); // err auto-serialized to data.err log.error(err, { requestId }, 'request failed'); // err + context + message log.error({ err: new Error('boom') }, 'failed'); // Error nested in context object ``` ### Composition | Method | Returns | What it does | | ---------------------- | -------- | ----------------------------------------------------------------- | | `child(overrides?)` | `Logger` | Clones config, applies overrides, inherits bindings | | `withBindings(fields)` | `Logger` | Pins fields to every subsequent call, returns a new child logger | | `use(middleware)` | `Logger` | Appends a middleware function to the pipeline, returns new logger | `child()` transport inheritance: - Omit `transports` → inherit parent transports (default). - Pass `transports: []` → disable all transports on the child. - Pass `transports: [...]` → replace entirely with the given list. `child()` namespace joining: - `parent.child({ namespace: 'auth' })` on a logger with namespace `'api'` produces `'api.auth'`. - Omit `namespace` → inherits parent namespace unchanged. ### Utilities | Method | Returns | Description | | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `enabled(level)` | `boolean` | True if entries at this level pass the configured threshold | | `time(label, fn, level?)` | `T` | Measures sync/async execution; emits at `level` (default `'debug'`), label as message, `{ duration_ms }` in `data`. When `fn` throws or rejects, `{ err }` is also included. | | `group(label, fn, level?)` | `T` | Wraps callback in `console.group`; closes even on throw/reject. Pass `level` to gate the group header on the configured threshold (e.g. `'debug'` suppresses when `logLevel` is `'warn'`). | | `groupCollapsed(label, fn, level?)` | `T` | Same as `group`, using `console.groupCollapsed`. | | `dispose()` | `void` | Silences all subsequent log calls on this logger instance. Does **not** auto-dispose batch transports — hold a reference and call `batchTransport.dispose()` on shutdown. Idempotent. | ### Properties | Property | Type | Description | | ------------------ | -------------------------- | ------------------------------------------------------------------ | | `logLevel` | `LogLevel` | Active log level threshold | | `namespace` | `string` | Effective namespace string | | `middleware` | `readonly LogMiddleware[]` | Middleware pipeline snapshot | | `transports` | `readonly Transport[]` | Transport pipeline snapshot | | `bindings` | `Readonly` | Snapshot of currently pinned fields | | `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie external lifetimes. | | `disposed` | `boolean` | `true` after `dispose()` has been called | | `[Symbol.dispose]` | `() => void` | Delegates to `dispose()`. Enables `using` declarations. | ## Transport Factories ### consoleTransport(options?) ```ts consoleTransport(options?: ConsoleTransportOptions): Transport ``` Writes styled output to the browser console (CSS badges) or Node terminal (plain text). This is the default transport. | Option | Type | Default | Description | | ----------- | ------------------------ | --------- | --------------------------------------------------- | | `level` | `LogLevel` | `'debug'` | Minimum level to output | | `timestamp` | `boolean` | `true` | Include `HH:MM:SS.mmm` | | `ansi` | `boolean` | auto | Force ANSI color codes on/off (Node only) | | `format` | `'json' \| 'raw'` | `'raw'` | Context serialization: `'json'` uses JSON.stringify | | `inspectFn` | `(v: unknown) => string` | — | Custom object formatter (e.g. `util.inspect`) | | `theme` | `ConsoleTheme` | — | Override default badge colours for this transport | **Returns:** `Transport` **Example:** ```ts import { consoleTransport, createLogger } from '@vielzeug/rune'; import { inspect } from 'node:util'; const log = createLogger({ transports: [consoleTransport({ level: 'info', timestamp: true, inspectFn: inspect })], }); ``` ### remoteTransport(options) ```ts remoteTransport(options: RemoteTransportOptions): Transport ``` Forwards entries asynchronously to a remote handler. Fire-and-forget — handler errors are swallowed to `console.warn` and never propagate to the caller. | Option | Type | Default | Description | | --------- | ------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `handler` | `(type: LogType, data: RemoteLogData) => void` | — | Required. Receives each forwarded entry | | `level` | `LogLevel` | `'debug'` | Minimum level to forward | | `env` | `'production' \| 'development'` | auto-detected | Override the runtime environment marker | | `onError` | `(error: unknown, data: RemoteLogData) => void` | — | Called when the handler throws or rejects. Default: a dev-only `console.warn`. Silent in production — provide an explicit handler for production observability. | **Returns:** `Transport` **Example:** ```ts import { createLogger, remoteTransport } from '@vielzeug/rune'; const log = createLogger({ transports: [ remoteTransport({ handler: async (type, data) => { await fetch('/api/logs', { body: JSON.stringify(data), method: 'POST' }); }, level: 'error', }), ], }); ``` ### jsonTransport(options?) ```ts jsonTransport(options?: JsonTransportOptions): Transport ``` Outputs newline-delimited JSON (NDJSON) to `stdout` or a custom function. Useful for server-side log aggregation pipelines (ELK, Datadog, etc.). Each line is a flat JSON object with `level`, `time` (ISO), and optional `ns`, `msg`, plus all merged context fields. | Option | Type | Default | Description | | -------- | ------------------------------ | ---------------- | -------------------------------------------------------------------------------------- | | `level` | `LogLevel` | `'debug'` | Minimum level | | `output` | `(line: string) => void` | `process.stdout` | Custom output sink | | `safe` | `boolean` | `false` | Replace circular references with `'[Circular]'` instead of throwing | | `fields` | `{ level?, msg?, ns?, time? }` | — | Custom output field names for aggregator compatibility (e.g. `'severity'` for Datadog) | **Returns:** `Transport` **Example:** ```ts import { createLogger, jsonTransport } from '@vielzeug/rune'; const log = createLogger({ namespace: 'api', transports: [jsonTransport({ level: 'info' })], }); log.info({ path: '/users', status: 200 }, 'request'); // {"path":"/users","status":200,"level":"info","time":"2026-05-30T...","ns":"api","msg":"request"} ``` ### batchTransport(options) ```ts batchTransport(options: BatchTransportOptions): BatchHandle ``` Buffers entries and delivers them in batches. Flushes when the buffer reaches `maxSize` or after `interval` elapses. | Option | Type | Default | Description | | -------------- | ------------------------------------------------ | --------- | --------------------------------------------------------------------------------------- | | `onFlush` | `(entries: LogEntry[]) => void \| Promise` | — | Required. Receives each batch (may be async) | | `onFlushError` | `(entries: LogEntry[], error: unknown) => void` | — | Called when `onFlush` throws or rejects | | `level` | `LogLevel` | `'debug'` | Minimum level to buffer | | `interval` | `number` | `5000` | Flush interval in milliseconds | | `maxSize` | `number` | `50` | Max buffer size before an early flush | | `maxBuffer` | `number` | unbounded | Hard cap — oldest entries are dropped silently when exceeded. Does not trigger a flush. | Returns a `BatchHandle` with: - `.transport` — the `Transport` function to pass to `createLogger({ transports: [handle.transport] })`. - `.flush()` — immediately send buffered entries without stopping the timer. - `.dispose()` — stop the interval and flush remaining entries. **Call on shutdown.** Idempotent. - `.disposed` — `true` after `dispose()` has been called. - `[Symbol.dispose]()` — delegates to `.dispose()`. Enables `using` declarations. After `dispose()`, the transport becomes inert: new entries are silently dropped. **Returns:** `BatchHandle` **Example:** ```ts import { batchTransport, createLogger } from '@vielzeug/rune'; const batch = batchTransport({ onFlush: (entries) => sendToCollector(entries), interval: 10_000, maxSize: 100, }); // Pass batch.transport to the logger — batch holds flush/dispose const log = createLogger({ transports: [batch.transport] }); process.on('exit', () => batch.dispose()); ``` ### sampleTransport(options) ```ts sampleTransport(options: SampleTransportOptions): Transport ``` Probabilistically forwards entries to a downstream transport. | Option | Type | Default | Description | | ----------- | ----------- | --------- | ---------------------------------------------- | | `rate` | `number` | — | Required. Fraction of entries to forward (0–1) | | `transport` | `Transport` | — | Required. Downstream transport | | `level` | `LogLevel` | `'debug'` | Minimum level to sample | **Returns:** `Transport` **Example:** ```ts import { createLogger, remoteTransport, sampleTransport } from '@vielzeug/rune'; const log = createLogger({ transports: [ sampleTransport({ rate: 0.1, transport: remoteTransport({ handler }), }), ], }); ``` ### redactTransport(options) ```ts redactTransport(options: RedactTransportOptions): Transport ``` Strips sensitive fields from `bindings` and `context` before forwarding. Redaction is applied recursively at any depth (up to 20 levels). `keys` matches **exact field names** at any nesting depth. Dot-path notation (e.g. `'user.password'`) is **not** supported — use `'password'` to redact every field named `password` regardless of nesting. | Option | Type | Default | Description | | ------------- | ----------- | -------------- | ------------------------------- | | `keys` | `string[]` | — | Required. Field names to redact | | `replacement` | `string` | `'[REDACTED]'` | Replacement value | | `transport` | `Transport` | — | Required. Downstream transport | **Returns:** `Transport` **Example:** ```ts import { createLogger, redactTransport, remoteTransport } from '@vielzeug/rune'; const log = createLogger({ transports: [ redactTransport({ keys: ['password', 'token', 'ssn'], transport: remoteTransport({ handler }), }), ], }); ``` ### pipe(...transports) / pipe(options, ...transports) ```ts pipe(...transports: Transport[]): Transport pipe(options: PipeOptions, ...transports: Transport[]): Transport ``` Dispatches each `LogEntry` to every transport in the list independently. An error thrown by one transport does not stop the others. Use in place of separate array entries when you want fault isolation or a shared error observer. `pipe()` with no arguments creates a valid no-op transport — useful for conditional pipeline construction: `pipe(condition ? remoteTransport(opts) : undefined!)` pattern, or simply as a placeholder during development. | Option | Type | Description | | --------- | ------------------------------------------- | --------------------------------------------------------- | | `onError` | `(error: unknown, entry: LogEntry) => void` | Called with the error and entry when any transport throws | **Returns:** `Transport` **Example:** ```ts import { consoleTransport, createLogger, pipe, remoteTransport } from '@vielzeug/rune'; const log = createLogger({ transports: [ pipe( { onError: (err) => metrics.increment('log.transport.error') }, consoleTransport(), remoteTransport({ handler, level: 'error' }), ), ], }); ``` ```` ## Utilities ### isLevelEnabled(threshold, level) ```ts isLevelEnabled(threshold: LogLevel, level: LogLevel): boolean ```` Returns `true` when `level` is at or above `threshold`. Always returns `false` when `level` is `'off'`. Useful for building custom transports that respect level filtering. ```ts import { isLevelEnabled } from '@vielzeug/rune'; isLevelEnabled('warn', 'error'); // true isLevelEnabled('warn', 'info'); // false isLevelEnabled('debug', 'off'); // false ``` ### resolveTheme(override?) ```ts resolveTheme(override: ConsoleTheme | undefined): ResolvedTheme ``` Deep-merges a partial `ConsoleTheme` override onto `DEFAULT_THEME`. Returns a fully-populated `ResolvedTheme` where every level and every field is present. Used internally by `consoleTransport()` — call directly when building a custom transport that needs to honour theme overrides. ```ts import { resolveTheme } from '@vielzeug/rune'; const theme = resolveTheme({ warn: { badge: '⚡' } }); // theme.warn.badge === '⚡', theme.warn.bg === DEFAULT_THEME.warn.bg (unchanged) ``` ### DEFAULT_THEME The built-in badge and namespace colour definitions used by `consoleTransport()`. Override per-transport via `ConsoleTransportOptions.theme`. ### PRIORITY ```ts PRIORITY: Record ``` Numeric priority for each level (`debug: 0`, `info: 1`, `warn: 2`, `error: 3`, `fatal: 4`, `off: 5`) — lower is more verbose. Exported for transport/middleware authors building custom level-comparison logic; `isLevelEnabled()` is built directly on top of it. ## Errors ### RuneError Base class for all `rune`-originated errors. Use `instanceof RuneError` or `RuneError.is(err)` to catch any error the package throws. ```ts import { RuneError } from '@vielzeug/rune'; try { // ... } catch (err) { if (RuneError.is(err)) { // handle a rune-originated error } } ``` **Static methods:** | Method | Returns | Description | | ------------ | ----------------------- | --------------------------------------------- | | `is(err)` | `err is RuneError` | Type guard — `true` for `RuneError` and subclasses | ### RuneTransportError ```ts class RuneTransportError extends RuneError {} ``` Constructed internally when a transport function throws during log entry emission. It is **never thrown or propagated** to application code — the logger catches the underlying error, wraps it here (available as `.cause`), and reports it via a dev-only warning. See the transport/middleware fault-isolation note under `createLogger()` above. ## Types ### LogType `'debug' | 'error' | 'fatal' | 'info' | 'warn'` ### LogLevel `LogType | 'off'` — threshold order: `debug ` — Key-value context pinned via `withBindings()` or passed per-call. ### LogEntry The structured record produced by every log call and dispatched to all transports. | Field | Type | Description | | ----------- | -------------------- | ------------------------------------------------------------------------ | | `data` | `Readonly` | Merged result of pinned bindings and per-call context — already resolved | | `level` | `LogType` | Log level | | `message` | `string?` | Log message | | `namespace` | `string` | Effective namespace at time of call | | `timestamp` | `Date` | Exact moment of the call, shared across transports | ### Transport ```ts type Transport = (entry: LogEntry) => void; ``` Receives every `LogEntry` that passes the logger's level threshold. Responsible for its own formatting, delivery, and per-transport level filtering. ### RemoteLogData Payload shape delivered to `RemoteTransportOptions.handler`: | Field | Type | Description | | ----------- | ------------------------------- | ----------------------------------------- | | `data` | `Bindings?` | Merged structured data (omitted if empty) | | `env` | `'production' \| 'development'` | Runtime env marker | | `level` | `LogType` | Log level | | `message` | `string?` | Log message | | `namespace` | `string?` | Effective namespace | | `timestamp` | `string` | Full ISO timestamp | ### PipeOptions | Field | Type | Description | | --------- | ------------------------------------------- | ----------------------------------------------------- | | `onError` | `(error: unknown, entry: LogEntry) => void` | Called when a transport in the pipe throws or rejects | ### ResolvedTheme `Record` — fully resolved theme with all fields populated. ### RuneOptions | Field | Type | Default | Description | | ------------ | ------------------ | ---------------------- | ---------------------------- | | `logLevel` | `LogLevel?` | `'debug'` | Logger level threshold | | `namespace` | `string?` | `''` | Namespace prefix | | `transports` | `Transport[]?` | `[consoleTransport()]` | Transport pipeline | | `bindings` | `Bindings?` | `{}` | Initial pinned bindings | | `middleware` | `LogMiddleware[]?` | `[]` | Entry transform/filter chain | ### LogMethod ```ts type LogMethod = { (message: string): void; (error: Error, context?: Bindings, message?: string): void; (context: Bindings, message?: string): void; }; ``` Every log-level method uses this signature. Three call forms are supported: - **String-only:** `log.info('message')` - **Error-first:** `log.error(err, { requestId }, 'failed')` — `Error` is auto-serialized to `{ message, name, stack }` under `data.err`. Optionally follow with a `Bindings` object and/or a message string. - **Context-first:** `log.info({ key: 'value' }, 'message')` — structured context object, optional message. `Error` values nested inside the context are also auto-serialized. ### LogMiddleware ```ts type LogMiddleware = (entry: LogEntry) => LogEntry | null; ``` Middleware functions intercept entries before they reach transports. Return the (optionally mutated) entry to continue, or return `null` to drop the entry. Added via `use(fn)` or `RuneOptions.middleware`. ### LazyBinding Opaque type returned by `lazy()`. Pass as a value inside `withBindings()`. The factory is only called when the entry is actually emitted (after the level check passes). ### BatchHandle ```ts type BatchHandle = { [Symbol.dispose]: () => void; dispose: () => void; readonly disposed: boolean; flush: () => void; transport: Transport; }; ``` Returned by `batchTransport()`. Pass `handle.transport` to `createLogger({ transports })`; call `handle.dispose()` on shutdown. `disposed` is `true` after `dispose()` has been called. ### Logger The full interface returned by `createLogger()` and `defaultLogger`: ```ts type Logger = { [Symbol.dispose]: () => void; readonly bindings: Readonly; child: (overrides?: RuneOptions) => Logger; debug: LogMethod; readonly disposalSignal: AbortSignal; dispose: () => void; readonly disposed: boolean; enabled: (type: LogLevel) => boolean; error: LogMethod; fatal: LogMethod; group: (label: string, fn: () => T, level?: LogType) => T; groupCollapsed: (label: string, fn: () => T, level?: LogType) => T; info: LogMethod; readonly logLevel: LogLevel; readonly middleware: readonly LogMiddleware[]; readonly namespace: string; time: (label: string, fn: () => T, level?: LogType) => T; readonly transports: readonly Transport[]; use: (middleware: LogMiddleware) => Logger; warn: LogMethod; /** Returns a new child logger with additional pinned bindings. The returned logger is fully independent — disposing it does not affect the parent, and vice versa. */ withBindings: (bindings: Bindings) => Logger; }; ``` ### ConsoleTransportOptions | Field | Type | Default | Description | | ----------- | ------------------------ | --------- | --------------------------------------------------- | | `level` | `LogLevel` | `'debug'` | Minimum level to output | | `timestamp` | `boolean` | `true` | Include `HH:MM:SS.mmm` | | `ansi` | `boolean` | auto | Force ANSI color codes on/off (Node only) | | `format` | `'json' \| 'raw'` | `'raw'` | Context serialization: `'json'` uses JSON.stringify | | `inspectFn` | `(v: unknown) => string` | — | Custom object formatter (e.g. `util.inspect`) | | `theme` | `ConsoleTheme` | — | Override default badge colours for this transport | ### RemoteTransportOptions | Field | Type | Default | Description | | --------- | ------------------------------- | ------------- | --------------------------------------- | | `handler` | `(type: LogType, data: RemoteLogData) => void` | — | Required. Receives each forwarded entry | | `level` | `LogLevel` | `'debug'` | Minimum level to forward | | `env` | `'production' \| 'development'` | auto-detected | Override the runtime environment marker | | `onError` | `(error: unknown, data: RemoteLogData) => void` | — | Called when the handler throws | ### JsonTransportOptions | Field | Type | Default | Description | | -------- | ------------------------------ | ---------------- | ------------------------------------------------------------------- | | `level` | `LogLevel` | `'debug'` | Minimum level | | `output` | `(line: string) => void` | `process.stdout` | Custom output sink | | `safe` | `boolean` | `false` | Replace circular references with `'[Circular]'` instead of throwing | | `fields` | `{ level?, msg?, ns?, time? }` | — | Custom output field names (e.g. `level: 'severity'` for Datadog) | ### BatchTransportOptions | Field | Type | Default | Description | | -------------- | ------------------------------------------------ | --------- | --------------------------------------------------------- | | `onFlush` | `(entries: LogEntry[]) => void \| Promise` | — | Required. Receives each batch (may be async) | | `onFlushError` | `(entries: LogEntry[], error: unknown) => void` | — | Called when `onFlush` throws or rejects | | `level` | `LogLevel` | `'debug'` | Minimum level to buffer | | `interval` | `number` | `5000` | Flush interval in milliseconds | | `maxSize` | `number` | `50` | Max buffer size before an early flush | | `maxBuffer` | `number` | unbounded | Hard cap — drops oldest when exceeded, no flush triggered | ### SampleTransportOptions | Field | Type | Default | Description | | ----------- | ----------- | --------- | ---------------------------------------------- | | `rate` | `number` | — | Required. Fraction of entries to forward (0–1) | | `transport` | `Transport` | — | Required. Downstream transport | | `level` | `LogLevel` | `'debug'` | Minimum level to sample | ### RedactTransportOptions | Field | Type | Default | Description | | ------------- | ----------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `keys` | `string[]` | — | Required. Field names to redact at any depth | | `maxDepth` | `number` | `20` | Maximum object nesting depth to traverse. Fields deeper than this are not redacted — a dev-only warning is emitted when hit. **Security:** the warning is suppressed in production; ensure sensitive fields are not nested beyond this limit. | | `replacement` | `string` | `'[REDACTED]'` | Replacement value | | `transport` | `Transport` | — | Required. Downstream transport | ### Usage Guide Start with the [Overview](./index.md), then use this page for detailed usage patterns. ## Basic Usage `defaultLogger` is the default singleton logger instance. Use `createLogger()` for isolated config. ```ts import { createLogger, defaultLogger } from '@vielzeug/rune'; const appLog = defaultLogger; const apiLog = createLogger({ namespace: 'api' }); const authLog = createLogger('auth'); // shorthand namespace ``` Each `createLogger()` call is fully independent with its own transport pipeline. The two-arg shorthand combines namespace and options cleanly: ```ts const log = createLogger('api', { logLevel: 'warn', transports: [transport] }); ``` ## Transports Transports are the delivery layer. Every `LogEntry` that passes the logger's level threshold is dispatched to each transport in order. Transports handle their own formatting, level filtering, and delivery. ```ts import { createLogger } from '@vielzeug/rune'; import { consoleTransport, pipe, remoteTransport, jsonTransport } from '@vielzeug/rune'; const log = createLogger({ logLevel: 'debug', transports: [ // Console output with CSS badges (browser) or plain text (Node) consoleTransport({ timestamp: true }), // Remote delivery — only errors and above remoteTransport({ handler: async (type, data) => { await fetch('/api/logs', { body: JSON.stringify(data), method: 'POST' }); }, level: 'error', }), ], }); ``` When `transports` is omitted, `consoleTransport()` is used automatically. ### Built-in Transport Factories | Factory | Use case | | -------------------- | ------------------------------------------- | | `consoleTransport()` | Styled console output (default) | | `remoteTransport()` | HTTP/webhook delivery | | `jsonTransport()` | NDJSON for server-side log aggregation | | `batchTransport()` | Buffered delivery to reduce I/O overhead | | `sampleTransport()` | Probabilistic volume reduction | | `redactTransport()` | Sensitive field stripping before forwarding | | `pipe()` | Fan-out dispatcher to multiple transports | ### Composing Transports Transport factories are composable wrappers. Chain them to build a pipeline. `pipe()` dispatches a single entry to multiple transports independently — an error in one transport does not prevent the others from running: ```ts import { batchTransport, pipe, redactTransport, remoteTransport, sampleTransport } from '@vielzeug/rune'; const log = createLogger({ transports: [ consoleTransport({ level: 'debug' }), // redact sensitive fields, sample at 10 %, batch + flush every 30 s redactTransport({ keys: ['password', 'token'], transport: sampleTransport({ rate: 0.1, transport: batchTransport({ onFlush: (entries) => sendToDatadog(entries), interval: 30_000, }), }), }), ], }); ``` Use `pipe()` when you want all transports to receive every entry regardless of per-transport failures: ```ts import { pipe } from '@vielzeug/rune'; const fanout = pipe( { onError: (err) => console.warn('transport error', err) }, consoleTransport(), remoteTransport({ handler, level: 'error' }), ); const log = createLogger({ transports: [fanout] }); ``` ### Batch Transport Lifecycle `batchTransport` starts an interval timer on first use. Call `.dispose()` on application shutdown to flush remaining entries and stop the timer: ```ts const batch = batchTransport({ onFlush: (entries) => sendToCollector(entries), interval: 10_000, maxSize: 100, }); // Pass batch.transport to the logger — batch itself holds flush/dispose const log = createLogger({ transports: [batch.transport] }); // on shutdown — dispose the batch directly process.on('exit', () => batch.dispose()); ``` `batchTransport.dispose()` is idempotent — calling it twice is safe and will not double-flush. `[Symbol.dispose]` is also available for `using` declarations. `log.dispose()` silences the logger but does **not** flush or stop batch transports. Always hold a reference to the `batchTransport` and call `.dispose()` on it explicitly at shutdown. After `log.dispose()`, the logger is silenced — all log calls (`debug`, `info`, `warn`, `error`, `fatal`, `time`, `group`) become no-ops. The `fn` callback in `group()` still executes, but no group header is rendered. This is intentional to prevent logging after application teardown. ### Node.js: Structured JSON Logging For server-side log pipelines (ELK, Datadog, CloudWatch), `jsonTransport` emits NDJSON to stdout: ```ts import { jsonTransport } from '@vielzeug/rune'; const log = createLogger({ namespace: 'api', transports: [jsonTransport({ level: 'info' })], }); log.info({ path: '/users', status: 200 }, 'request'); // Outputs: {"level":"info","time":"2026-05-30T...","ns":"api","path":"/users","status":200,"msg":"request"} ``` ## Configuration Use `child()` to derive immutable logger variants. ```ts const AppLog = defaultLogger.child({ logLevel: 'warn', namespace: 'App', // transports inherited from defaultLogger by default // pass transports: [] to disable all, or transports: [...] to replace }); // Individual getters — no config snapshot console.log(AppLog.logLevel); // 'warn' console.log(AppLog.namespace); // 'App' console.log(AppLog.transports); // [...] ``` Level threshold order: `debug` buildLargePayload()) }); reqLog.debug('diagnostics'); // buildLargePayload() only called when debug is enabled ``` ## Pinned Bindings `withBindings(fields)` returns a child logger where the given fields are merged into every log call. This is the idiomatic way to attach per-request or per-user context. ```ts const api = defaultLogger.child({ namespace: 'api' }); const reqLog = api.withBindings({ requestId: 'abc-123', userId: 42 }); reqLog.info('GET /users'); // always includes requestId and userId reqLog.warn({ slow: true }, 'query took 2s'); // call-site fields merged in ``` The parent logger is not affected. Bindings stack additively through chained `withBindings()` calls: ```ts const base = defaultLogger.withBindings({ service: 'api' }); const req = base.withBindings({ requestId: 'xyz' }); // req emits both service and requestId on every call ``` The `bindings` getter returns a defensive snapshot: ```ts console.log(reqLog.bindings); // { requestId: 'abc-123', userId: 42 } ``` ## Lazy Bindings `lazy(fn)` defers evaluation of a binding value until after the level check passes. The factory is never called when the entry would be suppressed. ```ts import { lazy } from '@vielzeug/rune'; const log = defaultLogger.withBindings({ // Only called when debug entries are emitted snapshot: lazy(() => JSON.stringify(getFullAppState())), // Regular values are always included as-is service: 'api', }); log.debug('state trace'); // snapshot() only called here log.warn('cache miss'); // snapshot() NOT called — warn doesn't need it ``` Lazy bindings are resolved on every emitted call, not cached: ```ts const counter = { n: 0 }; const log = defaultLogger.withBindings({ tick: lazy(() => ++counter.n) }); log.info('a'); // tick: 1 log.info('b'); // tick: 2 ``` ## Child Loggers `child(overrides?)` creates a new logger scoped to a namespace, level, or transport set. Use it to create module-level or service-level loggers. ```ts const api = defaultLogger.child({ namespace: 'api' }); const auth = api.child({ namespace: 'auth' }); // → 'api.auth' (dot-joined automatically) api.info('GET /users'); auth.warn('token expiring'); ``` `child(overrides?)` clones current config and applies overrides. Transports are inherited by default. ```ts const base = createLogger({ logLevel: 'info', namespace: 'app' }); const verbose = base.child({ logLevel: 'debug' }); // inherits transports // Replace transports entirely on the child const silent = base.child({ transports: [] }); // no output // Override with a different transport set const jsonChild = base.child({ transports: [jsonTransport()] }); ``` Child and parent configs remain independent after creation. ## Timing `time(label, fn, level?)` measures execution time of sync or async functions. Emits a structured entry with `{ duration_ms }` in `data` and `label` as the message. When `fn` throws or rejects, the entry also includes `{ err }` with the serialized error. ```ts // Sync const result = log.time('parse', () => parseDocument(input)); // Emits: { level: 'debug', message: 'parse', data: { duration_ms: 2.4 } } // Async const users = await log.time('db.users', () => db.query('SELECT * FROM users')); // Emits even on rejection, with { err } included in data // Custom level log.time('health-check', () => ping(), 'info'); // Skipped when logLevel is 'off', but fn still executes ``` To forward timing data to a remote endpoint, include `remoteTransport` in the pipeline — `debug`-level entries will be forwarded at its threshold. ## Groups `group(label, fn, level?)` and `groupCollapsed(label, fn, level?)` wrap a callback in a console group, ensuring `groupEnd` is called even when the callback throws or rejects. ```ts await log.groupCollapsed('Job', async () => { await log.time('process', () => runJob()); log.info('Done'); }); // Gate the group header on a log level — suppresses when logLevel is above 'debug' log.group( 'verbose trace', () => { log.debug('internal state', state); }, 'debug', ); ``` When `logLevel` is `'off'`, the group wrapper is bypassed but the callback still executes. When a `level` is provided and it is below the configured threshold, the group header is skipped but the callback still runs. ## Testing Use a test transport to assert log entries without mocking `console`. This approach is more robust and does not require spy cleanup: ```ts import { expect, it } from 'vitest'; import { createLogger } from '@vielzeug/rune'; import type { LogEntry, Transport } from '@vielzeug/rune'; function createTestTransport() { const entries: LogEntry[] = []; const transport: Transport = (entry) => entries.push(entry); return { entries, transport }; } it('logs errors when enabled', () => { const { entries, transport } = createTestTransport(); const log = createLogger({ logLevel: 'error', transports: [transport] }); log.error('boom'); expect(entries).toHaveLength(1); expect(entries[0].level).toBe('error'); expect(entries[0].message).toBe('boom'); }); it('suppresses debug when logLevel is warn', () => { const { entries, transport } = createTestTransport(); const log = createLogger({ logLevel: 'warn', transports: [transport] }); log.debug('silent'); log.warn('loud'); expect(entries).toHaveLength(1); }); ``` You can still spy on `console` methods when testing `consoleTransport` output directly: ```ts import { afterEach, expect, it, vi } from 'vitest'; import { consoleTransport, createLogger } from '@vielzeug/rune'; afterEach(() => vi.restoreAllMocks()); it('writes error to console.error', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const log = createLogger({ logLevel: 'error', transports: [consoleTransport({ timestamp: false })] }); log.error('boom'); expect(spy).toHaveBeenCalled(); }); ``` ## Framework Integration Rune is framework-agnostic and works as a module-level singleton or a context-injected instance. ```tsx [React] import { createContext, useState, useContext } from 'react'; import { createLogger } from '@vielzeug/rune'; const LogContext = createContext(createLogger({ namespace: 'app' })); function useLogger() { return useContext(LogContext); } function App() { const [requestLogger] = useState(() => createLogger({ namespace: 'app' }).withBindings({ userId: '42' })); return ( ); } function Dashboard() { const log = useLogger(); log.info('Dashboard mounted'); return Dashboard; } ``` ```ts [Vue 3] import { inject, provide } from 'vue'; import { createLogger, type Logger } from '@vielzeug/rune'; const LoggerKey = Symbol('logger'); function provideLogger(namespace: string) { const logger = createLogger({ namespace }); provide(LoggerKey, logger); return logger; } function useLogger(): Logger { const logger = inject(LoggerKey); if (!logger) throw new Error('Logger not provided'); return logger; } ``` ```svelte [Svelte] import { setContext, getContext } from 'svelte'; import { createLogger } from '@vielzeug/rune'; const logger = createLogger({ namespace: 'app' }); setContext('logger', logger); import { getContext } from 'svelte'; import type { Logger } from '@vielzeug/rune'; const logger = getContext('logger'); logger.info('component mounted'); ``` ### Pitfalls - **React:** Creating the logger without a stable initializer recreates it on every re-render. Use `useState(() => createLogger(...))`. - **Vue 3:** `inject()` must be called at the top level of `setup()`, not inside callbacks. - **Svelte:** `getContext()` must be called synchronously during component initialization. ## Working with Other Vielzeug Libraries ### With Courier ```ts import { createApi } from '@vielzeug/courier'; import { createLogger } from '@vielzeug/rune'; const log = createLogger({ namespace: 'courier' }); const api = createApi({ baseUrl: 'https://api.example.com', onError: (err) => log.error(err, 'request failed'), }); ``` ### With Herald ```ts import { createBus } from '@vielzeug/herald'; import { createLogger } from '@vielzeug/rune'; const log = createLogger({ namespace: 'bus' }); const bus = createBus({ onDispatch: (event, payload) => log.debug({ event, payload }, 'dispatched'), onError: (err, event) => log.error(err, `handler error in "${event}"`), }); ``` ## Best Practices - Create one child logger per module boundary using `defaultLogger.child({ namespace: 'module.name' })` or `createLogger('module.name')`. - Use `withBindings()` to pin request/session context instead of repeating fields on each call. - Use `lazy()` for expensive diagnostics bindings only needed at `debug` level. - Set `logLevel` from environment (`'debug'` in dev, `'warn'` or `'error'` in prod). - Use `enabled()` before expensive payload construction that `lazy()` cannot defer. - Configure transports at the application root; pass scoped loggers via DI or context. - Keep remote handlers resilient — network failures should not block app flow. - Call `batchTransport.dispose()` on shutdown to flush remaining buffered entries. - Use `redactTransport` closest to any remote/persistent transport — never strip before console. - To style console output, pass `consoleTransport({ theme })` explicitly in `transports`. - Use `fatal()` only for genuinely unrecoverable states. ### Examples ## Examples - [Module Logger Pattern](./examples/module-logger-pattern.md) - [Child Logger Overrides](./examples/child-logger-overrides.md) - [Production Setup](./examples/production-setup.md) - [Timing And Grouping](./examples/timing-and-grouping.md) - [React Integration](./examples/react-integration.md) - [Request Middleware](./examples/request-middleware.md) - [Testing](./examples/testing.md) ### REPL Examples - Basic Logging (id: `basic-logging`) - Lazy Bindings & Timing (id: `lazy-and-timing`) - Level Filtering (id: `level-filtering`) - Logger Lifecycle & Disposal (id: `lifecycle`) - Scoped Loggers (id: `scoped-loggers`) - Transport Pipeline (id: `transport-pipeline`) --- ## @vielzeug/sandbox **Category:** ui-primitives **Keywords:** sandbox, iframe, isolation, playground, csp, postmessage, security, components **Key exports:** createSandbox, buildCsp, buildDocument, SandboxError, SandboxTimeoutError, SandboxHandle, SandboxOptions, SandboxBridge, SandboxMessage, SandboxStateUpdateDetail, Unsubscribe **Related:** codex, refine ### Overview ## Why Sandbox? Running untrusted HTML in the main window is unsafe — arbitrary code can access the DOM, cookies, and user data. Sandbox creates an isolated `` that receives content over a typed postMessage bridge. The sandbox cannot reach the host page. Common use cases: - **Component previews** — render isolated HTML/CSS examples in documentation or design tools - **Code playgrounds** — execute user-provided code with full error forwarding and state injection - **Plugin sandboxes** — host third-party or user-authored plugin UI without granting host access - **User-generated content** — display untrusted HTML (emails, form output, external widgets) safely - **Widget embedding** — wrap third-party widgets with strict CSP and bidirectional messaging - **AI-generated UI** — render LLM-produced HTML components with guaranteed isolation | Feature | Raw `` | Sandbox | | -------------------------- | --------------------------- | ------------------------------------------------------ | | Bundle size | 0 B (built-in) | | | Zero dependencies | | | | Content-Security-Policy | Manual | Auto-generated, strict by default | | Typed postMessage protocol | | `setState()` / `SandboxMessage` union | | Error forwarding | | `onerror` + `unhandledrejection` → host | | Dispose / `using` | Manual `remove()` | `dispose()` + `[Symbol.dispose]` | **Use Sandbox when** you need to render untrusted or user-provided HTML in the browser with guaranteed isolation, CSP enforcement, and a typed event bridge. **Stick with a raw `` when** you only need to embed a known third-party URL — Sandbox is for programmatic `srcdoc` content, not URL-based embedding. ## Installation ```sh [pnpm] pnpm add @vielzeug/sandbox ``` ```sh [npm] npm install @vielzeug/sandbox ``` ```sh [yarn] yarn add @vielzeug/sandbox ``` ## Quick Start ```ts import { createSandbox } from '@vielzeug/sandbox'; const container = document.getElementById('preview')!; const sandbox = createSandbox(container); // render() returns a Promise that resolves when the document is ready await sandbox.render('Click me'); // Push state into the sandbox sandbox.setState('theme', 'dark'); // Receive events from sandbox code (ready is not forwarded — internal use only) sandbox.onMessage((msg) => { if (msg.type === 'custom') console.log(msg.event, msg.detail); if (msg.type === 'error') console.error(msg.message); if (msg.type === 'resize') console.log('height:', msg.height); }); // Re-render: await the returned Promise await sandbox.render(newHtml); // Clean up — removes iframe, clears listeners sandbox.dispose(); // or: using sandbox = createSandbox(container); ``` ## Features - `createSandbox()` — Creates an isolated `` in the given container - `SandboxHandle.ready` — Promise resolving on first render's ready signal (also resolves on dispose; check `sandbox.disposed` to distinguish) - `SandboxHandle.disposalSignal` — `AbortSignal` aborted when the sandbox is disposed; tie async work to sandbox lifetime - `SandboxHandle.disposed` — Observable disposed state; check before deferred calls - `render(html, { signal? })` — Lazy iframe creation; returns `Promise` resolving when ready, or rejecting with `SandboxTimeoutError` if the bridge never signals ready; pass `AbortSignal` to skip cancelled renders - `patch(html)` — Incremental body update without page reset; preserves scripts, listeners, and CSS state; ideal for streaming content - `updateStyle(id, css)` — Hot-patch a named `` block live without re-rendering; also updates baseline for next render - `setState(key, value)` — Push state into the sandbox; received as `sandbox:state-update` CustomEvent - `setStateAll(record)` — Push multiple state values in a single postMessage; more efficient than repeated `setState()` calls for initial setup - `namedStyles` option — Named `` blocks in document ``; individually patchable via `updateStyle()` - `lang` / `title` options — Set `` and `` on the generated document for screen-reader correctness - `SandboxBridge` type — Ambient type for `window.__sandbox__` in sandbox-side TypeScript; `onState(key, handler)` subscribes to state pushed via `setState()`/`setStateAll()` - `custom` messages — Sandbox code emits `window.__sandbox__.emit(event, detail)` to the host - `resize` messages — Auto-emitted by the bridge's built-in `ResizeObserver`; no manual wiring needed - Strict CSP — `default-src 'none'`, inline scripts only, no network by default - `nonce` option — Cryptographic nonce for bridge `` tag and `script-src` CSP - `scripts` option — Inject CDN scripts with `crossorigin="anonymous"`; origins auto-added to `script-src` - `buildCsp()` — Build a standalone CSP string using the same `SandboxOptions` - `buildDocument()` — Build a complete sandbox HTML document for server-side or offline use - Error forwarding — `onerror` + `unhandledrejection` forwarded as `{ type: 'error' }` messages - Disposable — `dispose()` + `[Symbol.dispose]` for `using` declarations ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Codex](/codex/) — MCP server with `generate-sandbox-document` and `get-state-bridge-spec` tools; generates document templates for use with Sandbox - [Refine](/refine/) — Web component library; renders correctly inside the sandbox via `` injection and `allowedScriptOrigins` ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------ | ------- | -------------- | ------------- | | `createSandbox()` | Create an isolated sandboxed iframe runtime | Sync (returns handle); `render()` is async | Iframe DOM is created lazily — nothing exists until the first `render()` call | | `buildCsp()` | Build a CSP string from `SandboxOptions` | Sync | Origins and the `nonce` are sanitized — characters that could break out of the policy are silently stripped | | `buildDocument()` | Build a complete standalone sandbox HTML document | Sync | `lang`/`title` are HTML-escaped automatically; don't pre-escape them yourself | | `SandboxHandle` | Object returned by `createSandbox()` | — | `setState()`/`setStateAll()` warn in dev if called before `render()` resolves | | `SandboxOptions` | Unified options for `createSandbox`, `buildCsp`, `buildDocument` | — | All fields are optional; defaults documented per field below | | `SandboxBridge` | Bridge API at `window.__sandbox__` inside sandbox documents | — | `emit()` sends events to the host; `onState()` only receives — there is no way to call host functions directly | | `SandboxMessage` | Application messages the sandbox sends to the host | — | `'ready'` is not part of this union — it resolves `render()` internally instead | | `SandboxError` | Base error class for `@vielzeug/sandbox` | — | Use `SandboxError.is(err)` to narrow — catches `SandboxTimeoutError` and any future subclasses | | `SandboxTimeoutError` | Thrown by `render()` when no `'ready'` signal arrives in time | — | Extends `SandboxError`; the document is likely missing the bridge script | | `SandboxStateUpdateDetail` | Detail payload of the sandbox-side `sandbox:state-update` CustomEvent | — | Only relevant inside sandbox documents, not on the host | | `Unsubscribe` | Return type of `onMessage()` and `SandboxBridge.onState()` | — | Calling it more than once is a safe no-op | ## Package Entry Points | Import | Purpose | | ------ | ------- | | `@vielzeug/sandbox` | Main exports and types | | `@vielzeug/sandbox/testing` | `createSandboxTestHelpers` — postMessage simulation helpers for tests | ```ts import { buildCsp, buildDocument, createSandbox, SandboxError, SandboxTimeoutError } from '@vielzeug/sandbox'; import type { SandboxBridge, SandboxHandle, SandboxMessage, SandboxOptions, SandboxStateUpdateDetail, Unsubscribe, } from '@vielzeug/sandbox'; import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing'; ``` ## `createSandbox(container, options?)` Creates a sandboxed `` inside `container` and returns a `SandboxHandle`. ```ts function createSandbox(container: HTMLElement, options?: SandboxOptions): SandboxHandle ``` The iframe is created lazily on the first `render()` call — `createSandbox()` is a cheap factory with no DOM work until content is ready. The iframe uses `sandbox="allow-scripts"` and `referrerpolicy="no-referrer"`. Content is loaded via `srcdoc` with an auto-generated CSP meta tag. The sandbox cannot access host cookies, storage, or the DOM. **Parameters** - `container` — The DOM element to append the iframe to. - `options` — Optional `SandboxOptions`. **Returns** a `SandboxHandle`. **Example** ```ts const sandbox = createSandbox(document.getElementById('preview')!); await sandbox.render('Hello from the sandbox'); ``` ## `SandboxHandle` ```ts interface SandboxHandle { readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly ready: Promise; dispose(): void; onMessage(handler: (msg: SandboxMessage) => void): Unsubscribe; patch(html: string): void; render(html: string, options?: { signal?: AbortSignal }): Promise; setState(key: string, value: unknown): void; setStateAll(record: Record): void; updateStyle(id: string, css: string): void; [Symbol.dispose](): void; } ``` | Member | Description | | ------ | ----------- | | `disposalSignal` | `AbortSignal` that is aborted when `dispose()` is called. Pass to `fetch` and other async operations to tie their lifetime to the sandbox. | | `disposed` | `true` once `dispose()` has been called. | | `ready` | Promise that resolves when the **first** sandbox document signals it has loaded. Also resolves if the sandbox is disposed before the first render — check `sandbox.disposed` after awaiting to distinguish the two cases. Does **not** reset on re-renders — use the Promise returned by `render()` for subsequent renders. | | `patch(html)` | Incrementally update the sandbox body without a full page reset. Replaces `document.body.innerHTML` via postMessage — scripts, event listeners, and `namedStyles` CSS are preserved. Must be called after `render()` resolves. Warns in dev if the bridge is not yet ready. | | `render(html, options?)` | Replace the entire sandboxed document (full page reset). Creates the iframe lazily. Returns a `Promise` that resolves when the new document signals ready, or **rejects with `SandboxTimeoutError`** if no `'ready'` signal arrives within 5s. If a second `render()` starts before the first resolves, the first Promise resolves (not rejects) immediately — the document simply navigated away. Pass `options.signal` to skip if already aborted. Emits a dev warning when `html` is empty or whitespace-only. | | `updateStyle(id, css)` | Hot-patch a named `` block in the live iframe via postMessage, and update the baseline for the next `render()`. No-ops if the sandbox is disposed. Safe to call before the first render (baseline only). Warns in dev if `id` is not a known key in `namedStyles`. | | `setState(key, value)` | Push a state value into the sandbox. Dispatches a `sandbox:state-update` CustomEvent inside the iframe. Warns in dev if called before `render()` resolves. | | `setStateAll(record)` | Push multiple state values in a single postMessage. Dispatches one `sandbox:state-update` CustomEvent per key inside the iframe. More efficient than calling `setState()` repeatedly for initial state setup. Warns in dev if called before `render()` resolves. | | `onMessage(handler)` | Subscribe to `SandboxMessage` events (`error`, `custom`, and `resize`). The `ready` lifecycle signal is not forwarded. Returns an `Unsubscribe` function. | | `dispose()` | Remove the iframe from the DOM and clear all listeners. Resolves any pending `ready` Promise and aborts `disposalSignal`. | | `[Symbol.dispose]()` | Alias for `dispose()` — enables `using sandbox = createSandbox(…)`. | Calling `render()`, `setState()`, `setStateAll()`, `updateStyle()`, or `onMessage()` on a disposed sandbox emits a warning in development (when `import.meta.env.PROD` is not `true`). Calling `setState()` or `setStateAll()` before `render()` resolves emits a dev warning — the bridge may not have set up its listener yet and the state update may be silently dropped. Always await the Promise returned by `render()` before calling either. In production all guard paths are silent no-ops (no warnings). Unlike the other guard paths above, the `SandboxTimeoutError` rejection from `render()` is **not** a dev-only warning — it fires in every build. Always attach a `.catch()` or wrap `await sandbox.render(...)` in `try`/`catch`: ```ts try { await sandbox.render(html); } catch (err) { if (SandboxError.is(err)) { console.error('Sandbox failed to load:', err.message); } } ``` ## `SandboxOptions` Unified options for `createSandbox`, `buildCsp`, and `buildDocument`. All fields are optional. ```ts interface SandboxOptions { allowedFontOrigins?: string[]; allowedImageOrigins?: string[]; allowedScriptOrigins?: string[]; allowedStyleOrigins?: string[]; lang?: string; namedStyles?: Record; nonce?: string; scripts?: string[]; title?: string; } ``` | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `allowedFontOrigins` | `string[]` | `[]` | Origins added to `font-src`. Default directive value: `'none'`. | | `allowedImageOrigins` | `string[]` | `[]` | Origins added to `img-src`. `data:` is always included. | | `allowedScriptOrigins` | `string[]` | `[]` | Extra origins added to `script-src`. Merged with origins auto-extracted from `scripts`. | | `allowedStyleOrigins` | `string[]` | `[]` | Origins added to `style-src`. `'unsafe-inline'` is always included. | | `lang` | `string` | `'en'` | BCP 47 language tag for the generated document's `` attribute. Pass the primary language of the sandbox content for correct screen-reader behaviour. | | `namedStyles` | `Record` | `{}` | Named CSS blocks injected as `` elements in the document ``. Each block is individually patchable via `updateStyle(id, css)` without re-rendering. | | `nonce` | `string` | `undefined` | Cryptographic nonce added to the bridge `` tag and to `script-src`. In CSP Level 3 browsers the nonce suppresses `'unsafe-inline'`; `'unsafe-inline'` is retained for CSP Level 2 fallback only. | | `scripts` | `string[]` | `[]` | External script URLs injected before user content with `crossorigin="anonymous"`. Origins are automatically added to `script-src`. | | `title` | `string` | `''` | Title for the generated document, placed in `` in ``. Providing a title improves screen reader compatibility. | `lang`, `title`, `namedStyles` keys, script URLs, and `nonce` are all HTML-escaped or sanitized before interpolation into the generated document — they cannot be used to break out of their attribute or inject markup. CSP origins and `nonce` are stripped of characters (`;`, `"`, `'`, newlines) that could inject a new CSP directive. ## `buildCsp(options?)` Builds a strict Content-Security-Policy string for sandboxed iframe documents. ```ts function buildCsp(options?: SandboxOptions): string ``` Accepts `SandboxOptions` directly. Origins from `scripts` URLs are extracted and merged with `allowedScriptOrigins` automatically. Returns a semicolon-separated CSP string with eight directives. `base-uri 'none'` is always included to block ``-tag injection, and `connect-src 'none'` / `form-action 'none'` block network requests and form submission by default. **Default output (no options)** ``` default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none' ``` **Example** ```ts const csp = buildCsp({ allowedStyleOrigins: ['https://fonts.googleapis.com'], allowedFontOrigins: ['https://fonts.gstatic.com'], scripts: ['https://cdn.example.com/refine.iife.js'], }); // script-src includes 'unsafe-inline' + https://cdn.example.com automatically ``` ## `buildDocument(html, options?)` Builds a complete, standalone sandbox HTML document. ```ts function buildDocument(html: string, options?: SandboxOptions): string ``` Includes the `` attribute, ``, CSP meta tag, injected scripts, `namedStyles` rendered as `` blocks, user content, and the bridge script. Suitable as an `iframe` `srcdoc` value or for server-side sandbox document generation (e.g., via `@vielzeug/codex`). External scripts are placed **before** user content with `crossorigin="anonymous"`, so the bridge's error handler receives full error details for cross-origin script errors. The bridge fires the `ready` message after all preceding parser-blocking scripts have executed, then sets up a `ResizeObserver` on `document.body` that automatically emits `resize` messages as content height changes. `lang` defaults to `'en'` and `title` defaults to `''` — both are HTML-escaped before interpolation. **Example** ```ts import { buildDocument } from '@vielzeug/sandbox'; const html = buildDocument('Hello', { lang: 'de', title: 'Component Preview', namedStyles: { base: 'body { font-family: sans-serif; }', theme: ':root { --bg: #fff; }', }, }); iframe.srcdoc = html; ``` ## Bridge Protocol ### `SandboxMessage` Application-level messages the sandbox sends to the host, received via `sandbox.onMessage(handler)`. The `ready` lifecycle signal is **intentionally excluded** — it resolves `sandbox.ready` and the Promise returned by `render()` internally and is not forwarded to subscribers. ```ts type SandboxMessage = | { type: 'error'; message: string; stack?: string } | { type: 'custom'; event: string; detail: unknown } | { type: 'resize'; height: number }; ``` | Type | Fields | Description | | ---- | ------ | ----------- | | `error` | `message: string`, `stack?: string` | Fired on uncaught errors or unhandled promise rejections inside the sandbox. | | `custom` | `event: string`, `detail: unknown` | User-defined events emitted from sandbox code via `window.__sandbox__.emit(event, detail)`. | | `resize` | `height: number` | Emitted automatically when sandbox content height changes. The bridge script sets up a `ResizeObserver` on `document.body` — no manual wiring needed. | ### `SandboxStateUpdateDetail` Detail payload of the `sandbox:state-update` CustomEvent dispatched **inside** sandbox documents by `setState()`/`setStateAll()`. Only relevant to sandbox-side code — the host never sees this type directly. ```ts interface SandboxStateUpdateDetail { key: string; value: unknown; } ``` **Emitting custom events from inside the sandbox:** ```js window.__sandbox__.emit('button:click', { label: 'Save', timestamp: Date.now() }); ``` **Receiving on the host:** ```ts sandbox.onMessage((msg) => { if (msg.type === 'custom' && msg.event === 'button:click') { console.log('Button clicked:', msg.detail); } if (msg.type === 'error') { console.error('[sandbox]', msg.message, msg.stack); } if (msg.type === 'resize') { container.style.height = `${msg.height}px`; } }); ``` ### `SandboxBridge` The bridge API available as `window.__sandbox__` inside sandbox documents. Export this type to add TypeScript support for sandbox-side code: ```ts interface SandboxBridge { emit(event: string, detail?: unknown): void; onState(key: string, handler: (value: unknown) => void): Unsubscribe; } ``` Add an ambient declaration in your sandbox-side TypeScript project: ```ts // sandbox-env.d.ts declare interface Window { __sandbox__: import('@vielzeug/sandbox').SandboxBridge; } ``` `onState(key, handler)` subscribes to state pushed via `sandbox.setState()`/`setStateAll()` for a specific key — it wraps the raw `sandbox:state-update` CustomEvent so sandbox-side code doesn't need to filter by key manually. Returns an `Unsubscribe` function: ```ts const off = window.__sandbox__.onState('theme', (value) => { document.body.dataset.theme = String(value); }); // Later, stop listening: off(); ``` ### State updates `sandbox.setState(key, value)` sends a single state value into the sandbox; `sandbox.setStateAll(record)` sends multiple values in one postMessage. Both dispatch a `sandbox:state-update` CustomEvent per key, described by `SandboxStateUpdateDetail`. Inside the sandbox, either listen via the DOM directly or use `window.__sandbox__.onState()`: ```js document.addEventListener('sandbox:state-update', (e) => { const { key, value } = e.detail; if (key === 'theme') document.body.dataset.theme = value; }); ``` ```ts // Single value sandbox.setState('theme', 'dark'); // Multiple values in one postMessage — fires 'sandbox:state-update' twice, once per key sandbox.setStateAll({ theme: 'dark', locale: 'en' }); ``` Treat all `SandboxMessage` data as untrusted. The sandbox controls what `custom` event payloads contain — do not execute or evaluate any message field. ## Types ### `Unsubscribe` ```ts type Unsubscribe = () => void; ``` Return type of `onMessage()` and `SandboxBridge.onState()`. Calling it more than once is a safe no-op. ## Errors ### `SandboxError` Base class for all `@vielzeug/sandbox` errors. Extends `Error`. ```ts class SandboxError extends Error { static is(err: unknown): err is SandboxError; } ``` `SandboxError.is()` is a type-safe static predicate — prefer it over `instanceof` in catch blocks that may receive unknown values. It also matches subclasses like `SandboxTimeoutError`: ```ts import { SandboxError } from '@vielzeug/sandbox'; try { await sandbox.render(html); } catch (err) { if (SandboxError.is(err)) { console.error(err.message); } } ``` ### `SandboxTimeoutError` Thrown as a rejection from `render()` when no `'ready'` signal arrives within 5 seconds, in every build (not a dev-only warning). Extends `SandboxError`. The sandbox document is most likely missing the bridge script — use `buildDocument()` to generate documents that include it, rather than hand-writing the `srcdoc` HTML. ```ts import { SandboxTimeoutError } from '@vielzeug/sandbox'; try { await sandbox.render(customHtmlMissingBridge); } catch (err) { if (err instanceof SandboxTimeoutError) { console.error('Sandbox never signaled ready:', err.message); } } ``` ## Test Utilities `@vielzeug/sandbox/testing` exports helpers for code that integrates with the sandbox: ```ts import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing'; const helpers = createSandboxTestHelpers(container); sandbox.render('test'); helpers.fireReady(); // simulate bridge ready signal helpers.fireCustom('click', { x: 1 }); // simulate window.__sandbox__.emit() helpers.fireResize(420); // simulate ResizeObserver callback helpers.fireError('TypeError: x is not defined', 'at eval:1'); ``` These helpers encapsulate the internal postMessage protocol so test code doesn't need to know message shapes. ### Usage Guide Start with the [Overview](./index.md) for installation and a quick example, then come back here for in-depth usage patterns. ## Basic Usage Create a sandbox by passing a container element. The returned `SandboxHandle` is your entire interface to the iframe. ```ts import { createSandbox } from '@vielzeug/sandbox'; const container = document.getElementById('preview')!; const sandbox = createSandbox(container); await sandbox.render('Hello from the sandbox'); ``` `render()` returns a `Promise` that resolves when the sandbox document signals it is ready. No DOM is created until `render()` is called — `createSandbox()` is a cheap factory. For reactive frameworks, subscribe via `onMessage` to receive `error`, `custom`, and `resize` events. ## Rendering HTML `render(html)` replaces the entire sandboxed document with a new one containing your HTML in the body. ```ts await sandbox.render(` body { font-family: sans-serif; } Component Preview Click me `); ``` Each call to `render()` is a full page reset — scripts reinitialise, CSS is re-applied, and any DOM state is lost. For incremental updates, push state via `setState()` or patch styles via `updateStyle()` rather than re-rendering. ## Incremental Updates with patch() `patch(html)` replaces `document.body.innerHTML` in the live document without a full page reset. Scripts, event listeners, `namedStyles` CSS blocks, and any injected global state are all preserved. Use it for streaming AI-generated output, live editor previews, or any scenario where you want to push new content without reinitialising the page. ```ts // Initial render — sets up the document, scripts, and styles await sandbox.render(` document.addEventListener('sandbox:state-update', (e) => { document.body.dataset.theme = e.detail.value; }); Loading… `); // Subsequent updates — body swapped, script listener preserved sandbox.patch('First chunk arrived'); sandbox.patch('First chunk arrivedSecond chunk…'); sandbox.patch('Complete response'); ``` **`patch()` vs `render()`:** | | `render()` | `patch()` | |---|---|---| | Full page reset | Yes | No | | Returns a Promise | Yes | No | | Scripts re-run | Yes | No | | `namedStyles` preserved | Re-injected | Yes | | State listeners preserved | No (must re-register) | Yes | | When to use | Initial load, major content change | Streaming, live updates | **`patch()` must be called after `render()` resolves.** The bridge must be initialized before patches can be received. A dev warning fires if called before the document is ready. ## Passing State `setState(key, value)` pushes data into the sandbox without re-rendering. Always call `setState()` after `render()` resolves — calling it before the bridge finishes initializing will silently drop the update in a real browser, and a dev warning will fire. ```ts // Correct: await render() before pushing state await sandbox.render(''); sandbox.setState('theme', 'dark'); sandbox.setState('user', { name: 'Alice' }); ``` Inside the sandbox document, listen for the `sandbox:state-update` custom event on `document`: ```html document.addEventListener('sandbox:state-update', (e) => { const { key, value } = e.detail; if (key === 'theme') document.body.dataset.theme = value; if (key === 'user') document.querySelector('#name').textContent = value.name; }); ``` ## Batch State Updates `setStateAll(record)` pushes multiple state values in a single postMessage — one call instead of one `setState()` per key. Use it for initial state setup where several values become available at the same time. ```ts await sandbox.render(''); // One postMessage instead of two setState() calls sandbox.setStateAll({ theme: 'dark', user: { name: 'Alice' }, }); ``` The sandbox side listens the same way as for `setState()` — each key in the record fires its own `sandbox:state-update` event. ## Handling Errors Subscribe to `onMessage` before calling `render()` to catch runtime errors in sandbox content. ```ts sandbox.onMessage((msg) => { if (msg.type === 'error') { console.error('[sandbox error]', msg.message); if (msg.stack) console.debug(msg.stack); } }); ``` Both synchronous errors (`window.onerror`) and unhandled promise rejections (`unhandledrejection`) are forwarded as `{ type: 'error' }` messages. ### `render()` rejection `render()` rejects with a `SandboxTimeoutError` if the document never signals `'ready'` within 5 seconds — this happens in every build, not just dev. It usually means the document is missing the bridge script (custom `srcdoc` HTML built by hand instead of via `buildDocument()`). Always handle it: ```ts import { SandboxError } from '@vielzeug/sandbox'; try { await sandbox.render(html); } catch (err) { if (SandboxError.is(err)) { console.error('Sandbox failed to load:', err.message); } } ``` A second `render()` call superseding the first does **not** trigger this — the superseded Promise resolves, not rejects. ## Injecting Scripts and Styles Use `SandboxOptions` to inject external scripts and styles into every rendered document. ```ts const sandbox = createSandbox(container, { scripts: [ 'https://cdn.example.com/ore.js', 'https://cdn.example.com/refine.js', ], namedStyles: { base: ` :root { --color-primary: #0066cc; } body { margin: 0; font-family: var(--font-sans); } `, }, }); ``` Script URLs are injected before user content. Their origins are automatically added to `script-src` in the CSP — you do not need to configure `buildCsp` separately. ## Setting Document Language and Title Use `lang` and `title` to set the generated document's `` attribute and ``. Both improve screen-reader behaviour for sandboxed content. ```ts const sandbox = createSandbox(container, { lang: 'de', title: 'Component Preview', }); ``` `lang` defaults to `'en'`, `title` defaults to `''`. Both values are HTML-escaped automatically before being written into the document. ## Hot-patching Named Styles `namedStyles` injects named `` blocks into the document ``. Named blocks can be updated live without a full re-render using `updateStyle(id, css)`. ```ts const sandbox = createSandbox(container, { namedStyles: { theme: ':root { --color-primary: #0066cc; --bg: #fff; }', }, }); await sandbox.render('Click me'); // Switch theme live — no re-render sandbox.updateStyle('theme', ':root { --color-primary: #bb33ff; --bg: #111; }'); ``` `updateStyle()` sends a postMessage to the iframe, patching `` in place. It also updates the baseline so the next `render()` starts with the patched CSS. Safe to call before the first render (baseline only — no postMessage sent to an uninitialized iframe). ## Resize Notifications The bridge script automatically emits `resize` messages via a `ResizeObserver` on `document.body`. No manual wiring is needed in your sandbox content. ```ts sandbox.onMessage((msg) => { if (msg.type === 'resize') { container.style.height = `${msg.height}px`; } }); ``` The `resize` message fires whenever the `document.body` height changes — on initial load, after content updates via `setState()`, and after style patches via `updateStyle()`. ## Tying Async Work to Sandbox Lifetime `disposalSignal` is an `AbortSignal` that is aborted when the sandbox is disposed. Pass it to any async operation that should stop when the sandbox is torn down. ```ts const sandbox = createSandbox(container); // Polling loop tied to sandbox lifetime async function poll() { while (!sandbox.disposalSignal.aborted) { const data = await fetch('/api/data', { signal: sandbox.disposalSignal }).then(r => r.json()).catch(() => null); if (data) sandbox.setState('data', data); await new Promise(resolve => setTimeout(resolve, 5000)); } } poll(); ``` When `sandbox.dispose()` is called, `disposalSignal` aborts, cancelling in-flight fetches and stopping the loop. ## Configuring CSP Use `allowedStyleOrigins`, `allowedFontOrigins`, and `allowedImageOrigins` to allow CDN resources. ```ts const sandbox = createSandbox(container, { allowedStyleOrigins: ['https://fonts.googleapis.com'], allowedFontOrigins: ['https://fonts.gstatic.com'], allowedImageOrigins: ['https://images.example.com'], }); ``` Then render HTML that uses those resources: ```ts await sandbox.render(` Hello `); ``` Origin values and the `nonce` are sanitized before being written into the policy, and the generated CSP always includes `base-uri 'none'` to block ``-tag injection — you do not need to strip untrusted characters yourself. ## Disposal Dispose the sandbox when it is no longer needed. This removes the iframe from the DOM and clears all message listeners. ```ts // Explicit sandbox.dispose(); // Using explicit resource management (TypeScript 5.2+) { using sandbox = createSandbox(container); await sandbox.render('Temporary preview'); } // sandbox.dispose() called automatically ``` ## Multiple Listeners `onMessage` supports multiple independent subscriptions. Each call returns its own unsubscribe function. ```ts const unsubErrors = sandbox.onMessage((msg) => { if (msg.type === 'error') logError(msg); }); const unsubEvents = sandbox.onMessage((msg) => { if (msg.type === 'custom') handleCustomEvent(msg); }); // Remove a single subscription unsubErrors(); // Remove all — dispose() clears all listeners at once sandbox.dispose(); ``` ## Receiving Events from the Sandbox Sandbox code calls `window.__sandbox__.emit(event, detail)` to send events to the host. Receive them via `onMessage` with `msg.type === 'custom'`. ```html Save ``` ```ts // Host sandbox.onMessage((msg) => { if (msg.type === 'custom' && msg.event === 'button:click') { console.log('Sandbox button clicked:', msg.detail); } }); ``` **TypeScript support for sandbox-side code** — add an ambient declaration referencing `SandboxBridge`: ```ts // sandbox-env.d.ts declare interface Window { __sandbox__: import('@vielzeug/sandbox').SandboxBridge; } ``` ## Awaiting Subsequent Renders `render()` returns a `Promise` that resolves when the new document signals ready. Await it directly for each render: ```ts await sandbox.render(firstHtml); // first render complete await sandbox.render(secondHtml); // second render complete ``` If a second `render()` starts before the first resolves, the first Promise resolves immediately (superseded). Multiple concurrent callers can each await their own returned Promise. ## Cancelling Renders with AbortSignal Pass an `AbortSignal` to `render()` to skip the render if it has already been cancelled. Useful in streaming or queued workflows: ```ts let controller = new AbortController(); async function streamRender(html: string) { controller.abort(); // cancel previous pending render controller = new AbortController(); await sandbox.render(html, { signal: controller.signal }); } ``` If the signal is already aborted when `render()` is called, the render is skipped with no warning and no DOM change. ## Building Sandbox Documents Directly To generate a complete sandbox HTML document outside of `createSandbox` (for example in a server context or `@vielzeug/codex`), use `buildDocument`. ```ts import { buildDocument } from '@vielzeug/sandbox'; const html = buildDocument('Hello', { allowedStyleOrigins: ['https://fonts.googleapis.com'], allowedFontOrigins: ['https://fonts.gstatic.com'], namedStyles: { theme: ':root { --bg: #fff; }', }, }); // html is a complete document — assign directly to srcdoc iframe.srcdoc = html; ``` Use `buildCsp` if you only need the CSP string for an existing document template: ```ts import { buildCsp } from '@vielzeug/sandbox'; const csp = buildCsp({ allowedFontOrigins: ['https://fonts.gstatic.com'] }); // → "default-src 'none'; ... font-src https://fonts.gstatic.com; ..." ``` ## Testing Use `createSandboxTestHelpers` from the `/testing` subpath to simulate sandbox→host messages without a real `srcdoc` script execution (jsdom does not execute iframe `srcdoc` scripts). ```ts import { createSandbox } from '@vielzeug/sandbox'; import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing'; import { describe, expect, it } from 'vitest'; describe('preview panel', () => { it('forwards a custom event from the sandbox', async () => { const container = document.createElement('div'); const sandbox = createSandbox(container); const helpers = createSandboxTestHelpers(container); const received: unknown[] = []; sandbox.onMessage((msg) => received.push(msg)); const renderPromise = sandbox.render('Save'); helpers.fireReady(); // simulate the bridge script's initial postMessage await renderPromise; helpers.fireCustom('button:click', { label: 'Save' }); expect(received).toEqual([{ type: 'custom', event: 'button:click', detail: { label: 'Save' } }]); sandbox.dispose(); }); }); ``` `SandboxTestHelpers` also exposes `fireResize(height)` and `fireError(message, stack?)` for testing resize and error handling without a live browser. ## Framework Integration Create the sandbox once per mount and dispose it on unmount — the container element is stable for the component's lifetime. ```tsx [React] import { useEffect, useRef } from 'react'; import { createSandbox } from '@vielzeug/sandbox'; function SandboxPreview({ html }: { html: string }) { const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const sandbox = createSandbox(containerRef.current); sandbox.render(html); return () => sandbox.dispose(); }, [html]); return ; } ``` ```vue [Vue 3] import { onMounted, onUnmounted, ref } from 'vue'; import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox'; const props = defineProps(); const containerRef = ref(); let sandbox: SandboxHandle | undefined; onMounted(() => { if (!containerRef.value) return; sandbox = createSandbox(containerRef.value); sandbox.render(props.html); }); onUnmounted(() => sandbox?.dispose()); ``` ```svelte [Svelte] import { onMount } from 'svelte'; import { createSandbox } from '@vielzeug/sandbox'; export let html: string; let container: HTMLDivElement; onMount(() => { const sandbox = createSandbox(container); sandbox.render(html); return () => sandbox.dispose(); }); ``` ## Working with Other Vielzeug Libraries **With Codex:** The `generate-sandbox-document` and `get-state-bridge-spec` MCP tools in `@vielzeug/codex` are designed to work with Sandbox. They generate complete sandbox-ready document templates and document the bridge protocol. ```ts // After codex generates an HTML document: await sandbox.render(generatedDocument); ``` **With Refine:** Inject the Refine/Ore runtime into the sandbox via `scripts`: ```ts const sandbox = createSandbox(container, { scripts: ['https://cdn.example.com/refine.iife.js'], namedStyles: { theme: '/* refine theme tokens */', }, }); await sandbox.render('Save'); ``` ## Best Practices - **Await `render()` before calling `setState()`/`setStateAll()`** — both warn in dev if called before the bridge is ready. Use `setStateAll()` to bootstrap several values in one postMessage instead of calling `setState()` repeatedly. - **Use `await sandbox.render(html)` for each render** — `render()` returns a `Promise` that resolves when the document is ready. No separate readiness API is needed. - **Use `updateStyle()` for theme switching** — patching a named style is faster than a full `render()` and preserves all script and DOM state. - **Check `disposed` before deferred calls** — across async operations, check `sandbox.disposed` before calling any method to avoid spurious dev warnings. - **Tie async work to `disposalSignal`** — pass `disposalSignal` to `fetch` and other async operations so they cancel automatically on dispose. - **Treat all messages as untrusted** — sandbox code controls `SandboxMessage` payloads. Do not `eval()` or execute any message field. - **One sandbox per preview** — `createSandbox()` is a cheap factory; create a new sandbox per user session or component rather than reusing across unrelated renders. - **Use `using` in functions** — in TypeScript 5.2+ contexts, `using` guarantees cleanup even on exceptions. - **Prefer `patch()` or `setState()` over re-renders for incremental updates** — `render()` resets all script state. Use `patch()` to swap body content and `setState()` to push data without losing listeners or CSS state. ### Examples ## Examples - [Component Preview](./examples/component-preview.md) - [User Script Sandbox](./examples/user-script-sandbox.md) - [Embedded Widget](./examples/embedded-widget.md) - [AI UI Renderer](./examples/ai-ui-renderer.md) ### REPL Examples - Build CSP String (id: `build-csp`) - Build Document (id: `build-document`) - Normalize Errors with SandboxError (id: `error-normalize`) --- ## @vielzeug/scout **Category:** utilities **Keywords:** fuzzy-search, search, trigram, full-text, filter, highlight, reactive, ripple **Key exports:** createIndex, createReactiveSearch, createSearch, debugSearch, findMatchRanges, highlight, highlightField, segmentWords, toFilterPredicate, toSearchFn **Related:** arsenal, sourcerer, vault, ripple ### Overview ## Why Scout? Arsenal's `fuzzy` / `fuzzyFilter` helpers perform pairwise Levenshtein distance — O(n·m) per item per query. For ≤200 items they are fine. For 500–100k items with real-time keystrokes, you need an index. Scout builds a **trigram inverted index** at construction time. Query time is O(candidates) — only items that share at least one trigram with the query are scored, so performance stays flat as the corpus grows. | Feature | Arsenal `fuzzy*` | Scout `createIndex` | Fuse.js | | ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- | | Bundle size | ~3 KB | | ~23 KB | | Zero dependencies | | `@vielzeug/ripple` peer (reactive layer only) | | | Algorithm | Levenshtein | Trigram + Dice coefficient | Bitap | | Query time | O(n·m) | O(candidates) | O(n·m) | | Stateful index | | | | | Match highlighting | | | | | Reactive layer | | ripple signals + debounce | | | Incremental updates | | | Partial | **Use Scout when** you need search over 500+ items, real-time UI search boxes (combobox, command palette), or reactive query state with ripple signals. **Consider `arsenal.fuzzyFilter` when** you have fewer than 200 items and don't need a persistent index. ## Installation ```sh [pnpm] pnpm add @vielzeug/scout ``` ```sh [npm] npm install @vielzeug/scout ``` ```sh [yarn] yarn add @vielzeug/scout ``` ## Quick Start ```ts import { createIndex } from '@vielzeug/scout'; const index = createIndex(users, { fields: [ { field: 'name', weight: 2 }, // name ranks higher { field: 'email' }, ], }); const results = index.search('alice'); // [{ item: User, score: 0.85, matches: [{ field: 'name', ranges: [[0, 5]] }] }] ``` ## Features - `createIndex()` — Trigram inverted index; construction O(corpus × field_length), query O(candidates) - Per-field weights — Promote `name` matches over secondary fields; any field accepts a custom `stringify` - `createReactiveSearch()` — Index + reactive `SearchState` in one call; `.index` for incremental mutations - `createSearch()` — Reactive search state backed by an existing `ScoutIndex`; share one index across many states - `highlight()` / `highlightField()` — Split field text into `HighlightPart[]` fragments for styled rendering - `findMatchRanges()` — Compute match ranges for custom display strings (truncated previews, formatted values) - `toSearchFn()` — Drop-in `searchFn` adapter for sourcerer's `LocalSource` - `toFilterPredicate()` — Snapshot `(item: T) => boolean` predicate for `Array.filter` or vault queries - Incremental updates — `add()` / `remove()` / `reindex()` patch the index in O(field_length); no full rebuild - `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity to `add`/`remove`/`reindex` - `segmentWords()` — Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter` - Debug logging via `debugSearch()` (`@vielzeug/scout/devtools`) — logs query/results transitions, tree-shaken from production bundles ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Arsenal](/arsenal/) — Use `fuzzyFilter` for ad-hoc filtering of small lists ( ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------------------------- | ----------------------------------------------------- | -------------- | ------------------------------------------------------------- | | `createIndex()` | Build trigram index from an item array | Sync | Index is built at call time — pass all initial items | | `ScoutIndex.search()` | Query the index, returns scored + highlighted results | Sync | Empty query returns all items with `score = 1` | | `ScoutIndex.add()` | Add one item to the index | Sync | No-op if same reference already indexed | | `ScoutIndex.remove()` | Remove one item by reference | Sync | No-op for unknown references | | `ScoutIndex.reindex()` | Re-index a mutated item in-place; preserves order | Sync | Call after mutating item properties; no-op if not in index | | `ScoutIndex.items` | All indexed items in insertion order | Sync | Returns a new array snapshot each call | | `ScoutIndex.onMutate()` | Subscribe to `add`/`remove`/`reindex` mutations | Sync | Only fires on mutations that actually change the index — not on no-ops | | `createSearch()` | Reactive search state backed by a `ScoutIndex` | Sync | Requires `@vielzeug/ripple` — dispose when done | | `createReactiveSearch()` | One-call index + reactive search state | Sync | Exposes `.index` for incremental mutations | | `findMatchRanges()` | Compute match ranges for a text + query pair | Sync | Returns sorted, non-overlapping `[start, end]` ranges | | `highlight()` | Split text into highlighted/unhighlighted fragments | Sync | Ranges must be sorted and non-overlapping | | `highlightField()` | Highlight a named field from a `SearchResult` | Sync | Shorthand for the `matches.find(…).ranges → highlight()` pattern | | `toSearchFn()` | Adapt `ScoutIndex` to sourcerer's `searchFn` API | Sync | Ignores the `items` arg — index is the source of truth | | `toFilterPredicate()` | Snapshot predicate from a one-time query | Sync | Re-call when query or corpus changes | | `segmentWords()` | Split unsegmented-script text (CJK, Thai, ...) into words | Sync | Uses native `Intl.Segmenter` — not applied inside `tokenize()` itself (see Pitfalls) | | `debugSearch()` | Log a `SearchState`'s query/results transitions | Sync | Import from `@vielzeug/scout/devtools`, not the main entry point | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/scout` | All exports — `createIndex`, `createReactiveSearch`, `createSearch`, `findMatchRanges`, `highlight`, `highlightField`, `segmentWords`, `toSearchFn`, `toFilterPredicate`, all types | | `@vielzeug/scout/devtools` | `debugSearch` — reactive search state logger (dev only) | --- ## `createIndex(items, options)` Builds a trigram inverted index from `items`. Construction is O(corpus × field_length); subsequent `search()` calls are O(candidates). ```ts function createIndex(items: T[], options: ScoutIndexOptions): ScoutIndex ``` **Parameters** | Param | Type | Description | | --- | --- | --- | | `items` | `T[]` | Initial corpus to index. | | `options.fields` | `ReadonlyArray>` | Fields to index. Required; at least one entry. | | `options.threshold` | `number` | Min Dice score for a result (default `0.2`). | | `options.limit` | `number` | Max results returned by `search()` (default `50`). | | `options.minQueryLength` | `number` | Min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). | **Example** ```ts const index = createIndex(products, { fields: [ { field: 'title', weight: 2 }, { field: 'sku' }, ], threshold: 0.25, limit: 20, }); ``` --- ## `ScoutIndex` Returned by `createIndex()`. ### `.search(query, options?)` ```ts search(query: string, options?: SearchConstraints): SearchResult[] ``` Returns results sorted by score descending. Empty query returns all items with `score = 1`. Results below `threshold` are excluded; at most `limit` results are returned. ```ts const results = index.search('alice'); // [{ item, score, matches }] ``` ### `.add(item)` Adds `item` to the index. No-op if the same reference is already indexed. O(field_length). ### `.remove(item)` Removes `item` by reference equality. No-op if not found. O(field_length). ### `.reindex(item)` Re-reads the item's current field values and rebuilds its index entry in-place, updating only fields whose values changed. Preserves insertion order. No-op if the item is not in the index. ```ts item.name = 'new name'; index.reindex(item); ``` ### `.size` `number` — current number of indexed items. ### `.items` `readonly T[]` — all indexed items in insertion order. Returns a new array snapshot each call. ```ts const all = index.items; ``` ### `.onMutate(listener)` ```ts onMutate(listener: () => void): () => void ``` Subscribes `listener` to run after every `add()` / `remove()` / `reindex()` call that actually changes the index — no-ops (e.g. removing an item that isn't indexed) don't fire it. Returns an unsubscribe function. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly won't need to call it themselves. ```ts const unsubscribe = index.onMutate(() => { console.log(`Index changed — now ${index.size} items`); }); index.add(newUser); // logs "Index changed — now 6 items" unsubscribe(); ``` --- ## `createSearch(index, options?)` Wraps a `ScoutIndex` in a reactive search state powered by `@vielzeug/ripple` signals. ```ts function createSearch(index: ScoutIndex, options?: CreateSearchOptions): SearchState ``` **Parameters** | Param | Type | Description | | --- | --- | --- | | `options.debounce` | `number` | ms to wait before committing a query change (default `200`). Pass `0` for immediate updates. | | `options.limit` | `number` | Override index-level limit. | | `options.threshold` | `number` | Override index-level threshold. | | `options.minQueryLength` | `number` | Override index-level minimum query length. | **Returns `SearchState`** | Member | Type | Description | | --- | --- | --- | | `query` | `Signal` | Writable search query. Set `.value` to trigger search. | | `results` | `Computed[]>` | Reactive results, updated after debounce. | | `isSearching` | `Computed` | `true` during the debounce window. | | `clear()` | `() => void` | Resets query, cancels debounce, clears results synchronously. | | `dispose()` | `() => void` | Releases all reactive subscriptions. | | `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. | **Example** ```ts const search = createSearch(index, { debounce: 150 }); effect(() => { if (search.isSearching.value) showSpinner(); else renderList(search.results.value); }); search.query.value = 'alice'; ``` --- ## `createReactiveSearch(items, options)` Creates a `ScoutIndex` and a reactive `SearchState` in one call — the shorthand for `createIndex` + `createSearch`. Returns a `ReactiveSearch` which extends `SearchState` with a `.index` property for incremental mutations. ```ts function createReactiveSearch( items: T[], options: ScoutIndexOptions & { debounce?: number }, ): ReactiveSearch ``` **Parameters** | Param | Type | Description | | --- | --- | --- | | `items` | `T[]` | Initial corpus to index. | | `options.fields` | `ReadonlyArray>` | Fields to index. Required. | | `options.debounce` | `number` | Debounce ms (default `200`). | | `options.threshold` | `number` | Min Dice score (default `0.2`). | | `options.limit` | `number` | Max results (default `50`). | | `options.minQueryLength` | `number` | Min chars before trigram scoring (default `3`). | **Returns `ReactiveSearch`** — all `SearchState` members plus: | Member | Type | Description | | --- | --- | --- | | `index` | `ScoutIndex` | The underlying index for `add`, `remove`, `reindex`. | **Example** ```ts const search = createReactiveSearch(users, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }); effect(() => renderList(search.results.value.map(r => r.item))); // Add a new item at runtime search.index.add(newUser); search.dispose(); ``` --- ## `findMatchRanges(text, query)` Computes sorted, non-overlapping match ranges for each word in `query` within `text`. Useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string). ```ts function findMatchRanges(text: string, query: string): [number, number][] ``` **Example** ```ts const ranges = findMatchRanges('Alice Johnson', 'alice'); // [[0, 5]] const parts = highlight('Alice Johnson', ranges); // [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }] ``` Returns an empty array if either `text` or `query` is empty. --- ## `highlight(text, ranges)` Splits `text` into `HighlightPart[]` fragments based on `ranges` from `FieldMatch.ranges`. ```ts function highlight(text: string, ranges: [number, number][]): HighlightPart[] ``` **Example** ```ts highlight('Hello World', [[0, 5]]); // [{ text: 'Hello', highlighted: true }, { text: ' World', highlighted: false }] ``` Returns an empty array when `text` is empty. Returns a single unhighlighted part when `ranges` is empty. --- ## `highlightField(result, field, text)` Convenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. Eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup. ```ts function highlightField(result: SearchResult, field: keyof T & string, text: string): HighlightPart[] ``` **Example** ```ts for (const result of index.search('alice')) { const parts = highlightField(result, 'name', result.item.name); console.log(parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join('')); } ``` When the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part. --- ## `toSearchFn(index, options?)` Returns a `(items, query) => items` function compatible with `sourcerer`'s `searchFn` option. ```ts function toSearchFn(index: ScoutIndex, options?: SearchConstraints): (items: readonly T[], query: string) => readonly T[] ``` The `items` argument is ignored — the index is always the source of truth. ```ts const source = createLocalSource(users, { searchFn: toSearchFn(index) }); ``` --- ## `toFilterPredicate(index, query, options?)` Returns a `(item: T) => boolean` predicate computed from a one-time query. Use with `Array.filter` or vault's `query.filter()`. ```ts function toFilterPredicate( index: ScoutIndex, query: string, options?: SearchConstraints, ): (item: T) => boolean ``` The predicate is a snapshot — re-call `toFilterPredicate` if the query or corpus changes. ```ts const results = products.filter(toFilterPredicate(index, 'widget')); // Cap results via limit const top5 = products.filter(toFilterPredicate(index, 'widget', { limit: 5 })); ``` --- ## `segmentWords(text)` Splits `text` into whitespace-joined word segments using the runtime's native `Intl.Segmenter` — no dependency beyond the platform API. Falls back to returning `text` unchanged where `Intl.Segmenter` isn't available. ```ts function segmentWords(text: string): string ``` `tokenize()`'s trigram-based scoring already works on unsegmented scripts (Chinese, Japanese, Thai, ...) without this — trigrams are generated per-character, not per-word. `segmentWords()` is for `findMatchRanges()` / highlighting and the multi-word query semantics on `SearchConstraints`, which assume space-separated words. **Not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace-delimited case, which would regress `createIndex()`'s construction cost for every caller, not just those indexing unsegmented scripts. **Example** ```ts const index = createIndex(documents, { fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }], }); ``` --- ## `debugSearch(search)` ```ts debugSearch(search: SearchState): () => void ``` Logs `query` → `isSearching` → `results` transitions of a `SearchState` to `console.debug`. Returns a function that unsubscribes all listeners installed by this call. Import from the dedicated sub-path so it's tree-shaken from production bundles. Logs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production. **Example** ```ts import { debugSearch } from '@vielzeug/scout/devtools'; const search = createSearch(index); const stopDebugging = debugSearch(search); search.query.value = 'alice'; // [scout:search] query -> "alice" // [scout:search] isSearching -> true // [scout:search] isSearching -> false // [scout:search] results -> 1 item(s) stopDebugging(); ``` --- ## Types ### `SearchConstraints` Shared search-tuning knobs used by `ScoutIndexOptions`, `CreateSearchOptions`, and all search functions. ```ts type SearchConstraints = { limit?: number; // default 50 minQueryLength?: number; // default 3 threshold?: number; // default 0.2 }; ``` ### `FieldDef` ```ts type FieldDef = | (keyof T & string) | { field: keyof T & string; weight?: number; // default 1 stringify?: (value: unknown) => string; }; ``` ### `ScoutIndexOptions` ```ts type ScoutIndexOptions = SearchConstraints & { fields: ReadonlyArray>; }; ``` ### `CreateSearchOptions` ```ts type CreateSearchOptions = SearchConstraints & { debounce?: number; // default 200 }; ``` ### `SearchResult` ```ts type SearchResult = { item: T; matches: FieldMatch[]; // field is narrowed to indexed field names score: number; // [0, 1]; 1 when query is empty }; ``` ### `FieldMatch` Generic over the union of field names — `match.field` is typed to the actual fields of `T`. ```ts type FieldMatch = { field: F; ranges: [number, number][]; // [start, end] in original field value }; ``` ### `HighlightPart` ```ts type HighlightPart = { highlighted: boolean; text: string; }; ``` ### `SearchState` See `createSearch()` above. ### `ReactiveSearch` ```ts type ReactiveSearch = SearchState & { readonly index: ScoutIndex; }; ``` See `createReactiveSearch()` above. --- ## Errors ### `ScoutError` Base class for all scout errors. Use `instanceof ScoutError` or `ScoutError.is()` to catch any scout-originated error. ```ts class ScoutError extends Error { static is(err: unknown): err is ScoutError; } ``` **Named subclasses** | Class | Thrown when | | ------------------- | ---------------------------------------------------------------------- | | `ScoutDisposedError` | A method is called on a disposed `SearchState` instance | | `ScoutIndexError` | An index is built or queried with an invalid configuration (e.g. zero fields) | ### Usage Guide ## Basic Usage ### Building an index Pass your item array and field configuration to `createIndex`. All items are indexed immediately at construction time. ```ts import { createIndex } from '@vielzeug/scout'; const index = createIndex(users, { fields: ['name', 'email'], }); ``` ### Searching Call `index.search(query)` with any string. Results are sorted by score descending. ```ts const results = index.search('alice'); for (const { item, score, matches } of results) { console.log(item.name, score); } ``` An empty `query` returns all items with `score = 1`: ```ts index.search(''); // All items, score = 1 each ``` ### Per-field weights Give fields different weights to control score ranking. A match on a high-weight field ranks the item higher than a match on a low-weight field. ```ts const index = createIndex(users, { fields: [ { field: 'name', weight: 3 }, // name matches rank 3× higher { field: 'department', weight: 1 }, { field: 'bio', weight: 0.5 }, ], }); ``` ### Non-string fields Use `stringify` to convert numeric or boolean fields to searchable text. ```ts const index = createIndex(products, { fields: [ 'title', { field: 'price', stringify: (v) => `$${v}` }, { field: 'inStock', stringify: (v) => (v ? 'available in stock' : 'out of stock') }, ], }); ``` ### Non-Latin scripts (CJK, Thai, ...) `tokenize()` indexes any script correctly — trigrams are generated per-character, so Chinese, Japanese, Cyrillic, and accented Latin text are all searchable out of the box. What it doesn't do is insert word boundaries for scripts that don't use spaces (Chinese, Japanese, Thai, ...), which affects `findMatchRanges()` / highlighting and multi-word query semantics. Pre-segment those fields with `segmentWords()`: ```ts import { createIndex, segmentWords } from '@vielzeug/scout'; const docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }]; const index = createIndex(docs, { fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }], }); index.search('日本語'); // matches the first document ``` `segmentWords()` uses the runtime's native `Intl.Segmenter` — no dependency. It's opt-in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace-delimited text. ### Limiting results Pass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality. ```ts // At most 10 results, minimum Dice score 0.3 const results = index.search('widget', { limit: 10, threshold: 0.3 }); ``` Per-call options override the index-level defaults set in `createIndex`. ### Controlling short-query behaviour Queries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`. ```ts // Use trigram scoring even for 1-char queries (good for small corpora) const index = createIndex(items, { fields: ['name'], minQueryLength: 1 }); // Force containment scan for all queries up to 8 chars (good for autocomplete on large sets) const results = index.search('alice', { minQueryLength: 8 }); ``` ## Reactive Search ### `createReactiveSearch()` — recommended For most use cases, `createReactiveSearch` builds the index and reactive state together in one call. It returns a `ReactiveSearch` — a `SearchState` with an extra `.index` property for incremental mutations: ```ts import { createReactiveSearch } from '@vielzeug/scout'; import { effect } from '@vielzeug/ripple'; const search = createReactiveSearch(users, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }); effect(() => { if (search.isSearching.value) showLoadingSpinner(); else renderResults(search.results.value.map(r => r.item)); }); input.addEventListener('input', e => { search.query.value = e.currentTarget.value; }); // Add items at runtime via the exposed index search.index.add(newUser); onUnmount(() => search.dispose()); ``` ### `createSearch()` — separate index and state Use `createSearch` when you need to create the index independently — for example when sharing it across multiple reactive states: ```ts import { createIndex, createSearch } from '@vielzeug/scout'; const index = createIndex(users, { fields: ['name', 'email'] }); const search = createSearch(index, { debounce: 150 }); ``` ### `using` declaration ```ts { using search = createReactiveSearch(users, { fields: ['name'] }); // search.dispose() called automatically at scope exit } ``` ### Zero debounce for synchronous updates Pass `debounce: 0` if you want results updated synchronously (no `isSearching` flash). ```ts const search = createReactiveSearch(users, { fields: ['name'], debounce: 0 }); search.query.value = 'alice'; console.log(search.results.value); // Already updated ``` ### Resetting search ```ts search.clear(); // Resets query + results + isSearching synchronously ``` ### Composing with ripple signals `search.results` is a `Computed` signal — compose it into other computed values: ```ts import { computed } from '@vielzeug/ripple'; const topResult = computed(() => search.results.value[0]?.item ?? null); ``` ## Incremental Updates `add()`, `remove()`, and `reindex()` patch the inverted index in O(field_length) — no full rebuild needed. ```ts const index = createIndex(products, { fields: ['title'] }); // Add a newly created item const newProduct = { id: 99, title: 'New Widget' }; index.add(newProduct); // Remove a deleted item (by reference) index.remove(products[0]); // Re-index a mutated item after in-place mutation products[1].title = 'Updated Title'; index.reindex(products[1]); ``` > `remove()` and `reindex()` use **reference equality** (`===`). Pass the same object reference that was originally added. ### Inspecting the corpus Use `.items` to read all currently indexed items in insertion order, or `.size` for a count: ```ts console.log(index.size); // 42 console.log(index.items); // [{ id: 1, title: ... }, ...] ``` ### Reacting to mutations directly `createSearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()` internally. If you're building your own reactivity on top of a plain `ScoutIndex` (no `ripple` involved), subscribe with `onMutate()`: ```ts const unsubscribe = index.onMutate(() => { rerenderResultsList(); }); index.add(newProduct); // triggers rerenderResultsList() unsubscribe(); // when done ``` `onMutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no-op and doesn't notify listeners. ## Match Highlighting Every `SearchResult` carries `matches` — per-field character ranges where the query was found. ### `highlightField()` — recommended `highlightField(result, field, text)` is the shorthand that does the field lookup and fragment split in one step: ```ts import { highlightField } from '@vielzeug/scout'; for (const result of index.search('alice')) { const parts = highlightField(result, 'name', result.item.name); // [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }] renderHighlightedText(parts); } ``` `highlight()` / `highlightField()` return the **original, unescaped** field text split into fragments — never concatenate `part.text` into an HTML string for `innerHTML`. Render each part as text (`textContent`, a framework's text binding) and wrap `highlighted` parts in your own element: ```ts function renderHighlightedText(parts: HighlightPart[]): DocumentFragment { const fragment = document.createDocumentFragment(); for (const part of parts) { if (part.highlighted) { const mark = document.createElement('mark'); mark.textContent = part.text; // textContent — never innerHTML fragment.appendChild(mark); } else { fragment.appendChild(document.createTextNode(part.text)); } } return fragment; } ``` ### `findMatchRanges()` + `highlight()` — manual Use `findMatchRanges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string: ```ts import { findMatchRanges, highlight } from '@vielzeug/scout'; const [result] = index.search('alice'); const preview = result.item.bio.slice(0, 100); const ranges = findMatchRanges(preview, 'alice'); const parts = highlight(preview, ranges); ``` Or use `highlight()` directly when you already have the ranges from `result.matches`: ```ts const [result] = index.search('alice'); const nameMatch = result.matches.find(m => m.field === 'name'); const parts = highlight(result.item.name, nameMatch?.ranges ?? []); ``` ## Debug Logging Import `debugSearch` from the dedicated `/devtools` sub-path to log a `SearchState`'s `query` → `isSearching` → `results` transitions to `console.debug`. The sub-path is tree-shaken from production bundles when not imported. `debugSearch()` logs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production. ```ts import { debugSearch } from '@vielzeug/scout/devtools'; const search = createSearch(index, { debounce: 150 }); const stopDebugging = debugSearch(search); search.query.value = 'alice'; // [scout:search] query -> "alice" // [scout:search] isSearching -> true // [scout:search] isSearching -> false // [scout:search] results -> 1 item(s) stopDebugging(); ``` ## Framework Integration ```tsx [React] import { createReactiveSearch } from '@vielzeug/scout'; import { useEffect, useRef, useSyncExternalStore } from 'react'; type User = { id: number; name: string; email: string }; function useScoutSearch(items: User[]) { const ref = useRef( createReactiveSearch(items, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }), ); const search = ref.current; const results = useSyncExternalStore( (cb) => search.results.subscribe(cb), () => search.results.value, ); useEffect(() => () => search.dispose(), [search]); return { query: search.query, results }; } ``` ```ts [Vue 3] import { createReactiveSearch } from '@vielzeug/scout'; import { onScopeDispose, ref, watch } from 'vue'; type User = { id: number; name: string; email: string }; function useScoutSearch(items: User[]) { const search = createReactiveSearch(items, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }); const query = ref(''); const results = ref(search.results.value); const unsub = search.results.subscribe(() => { results.value = search.results.value; }); watch(query, (q) => { search.query.value = q; }); onScopeDispose(() => { unsub(); search.dispose(); }); return { query, results }; } ``` ```svelte [Svelte] import { createReactiveSearch } from '@vielzeug/scout'; import { onDestroy } from 'svelte'; type User = { id: number; name: string; email: string }; export let items: User[]; const search = createReactiveSearch(items, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }); let query = ''; let results = search.results.value; const unsub = search.results.subscribe(() => { results = search.results.value; }); $: search.query.value = query; onDestroy(() => { unsub(); search.dispose(); }); {#each results as { item }} {item.name} {/each} ``` ## Working with Other Vielzeug Libraries ### With Sourcerer `toSearchFn()` adapts a `ScoutIndex` to the `searchFn` slot in sourcerer's `createLocalSource` — Scout handles fuzzy matching, sourcerer handles pagination and filtering. ```ts import { createIndex, toSearchFn } from '@vielzeug/scout'; import { createLocalSource } from '@vielzeug/sourcerer'; const index = createIndex(users, { fields: [{ field: 'name', weight: 2 }, 'email'], }); const source = createLocalSource(users, { searchFn: toSearchFn(index), }); source.patch({ search: 'alice' }); ``` > The `items` argument received by `searchFn` is ignored — the index is the source of truth. Keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`. ### With Vault `toFilterPredicate()` returns an `(item: T) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `Array.filter`. ```ts import { createIndex, toFilterPredicate } from '@vielzeug/scout'; const index = createIndex(products, { fields: ['title', 'sku'] }); const matching = products.filter(toFilterPredicate(index, 'widget')); const rows = await db.query('products') .filter(toFilterPredicate(index, searchTerm)) .toArray(); ``` Call `toFilterPredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive. ## Best Practices - **Build the index once** — `createIndex()` runs in O(corpus × field_length). Create it at module level or in an effect, not inside render loops. - **Keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. Stale index entries return wrong scores. - **Tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length. - **Set `minQueryLength` for your corpus size** — the default `3` works well for most cases. Lower it for small corpora where single-char queries are expected; raise it for large corpora to avoid expensive O(n) scans. - **Dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts. - **Weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`. - **Segment CJK/Thai fields explicitly** — `segmentWords()` is opt-in per field, not automatic, to keep `createIndex()` fast for the common whitespace-delimited case. ### Examples Browse runnable examples of `@vielzeug/scout`: - [Basic Search](./examples/basic-search) — `createIndex` + `search()` + highlighting - [Reactive Combobox](./examples/reactive-combobox) — `createSearch` signal wiring with debounce - [Sourcerer Integration](./examples/sourcerer-integration) — `toSearchFn` with `createLocalSource` ### REPL Examples - Basic Search (id: `basic-search`) - Highlight Results (id: `highlight-results`) - Incremental Updates (id: `incremental-updates`) - Reactive Search (id: `reactive-search`) - Segmenting Non-Latin Text (id: `segment-words`) --- ## @vielzeug/scroll **Category:** ui-performance **Keywords:** virtual-list, virtualization, windowing, scroll, performance, large-lists **Key exports:** createVirtualizer, createDomVirtualList, createVirtualScroller, createGroupedVirtualizer, createGridVirtualizer, createReactiveVirtualizer, createReactiveGroupedVirtualizer, createMeasurementCache, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN **Related:** dnd, ore, refine ### Overview ## Why Scroll? Rendering thousands of items as real DOM nodes freezes the browser. Each node consumes layout, paint, and memory — long lists need to render only what is visible in the viewport. ```ts // Before — render all 10 000 items (browser freezes) list.innerHTML = ''; items.forEach((item) => { const el = document.createElement('div'); el.textContent = item.name; list.appendChild(el); // 10 000 DOM nodes }); // After — Scroll (only ~15 visible rows in the DOM at any time) import { createVirtualizer } from '@vielzeug/scroll'; const virt = createVirtualizer(scrollEl, { count: items.length, estimateSize: 36, onChange: ({ items, totalSize }) => { list.style.height = `${totalSize}px`; list.innerHTML = ''; for (const { index, start } of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${start}px;height:36px;`; el.textContent = items[index].name; list.appendChild(el); } }, }); ``` | Feature | Scroll | TanStack Virtual | react-window | | ------------------ | --------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------- | | Bundle size | | ~5 kB | ~8 kB | | Framework agnostic | | | React only | | Variable heights | Measured | | Static | | O(log n) lookup | | | | | `using` support | | | | | Zero dependencies | | | | **Use Scroll when** you need to render large lists in a framework-agnostic environment with precise control over item measurement and scroll position. **Consider TanStack Virtual** if you need its framework adapters and ecosystem integration. ## Installation ```sh [pnpm] pnpm add @vielzeug/scroll ``` ```sh [npm] npm install @vielzeug/scroll ``` ```sh [yarn] yarn add @vielzeug/scroll ``` ## Quick Start ```ts import { createVirtualizer } from '@vielzeug/scroll'; const scrollEl = document.querySelector('.scroll-container')!; const spacer = document.querySelector('.spacer')!; const list = document.querySelector('.list')!; const virt = createVirtualizer(scrollEl, { count: 10_000, estimateSize: 36, onChange: ({ items, totalSize }) => { // Stretch the container so the scrollbar reflects the full list spacer.style.height = `${totalSize}px`; list.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`; el.textContent = `Row ${item.index}`; list.appendChild(el); } }, }); // Clean up virt.dispose(); ``` ### Entry Points All APIs export from a single entry: `@vielzeug/scroll`. ## Features - **Framework-agnostic** — callback-based `onChange` connects to any rendering layer (React, Vue, Svelte, Lit, vanilla DOM) - **Fixed and variable heights** — pass a fixed number, a per-index estimator function, or call `measure()` after rendering for exact heights - **Batched measurements** — calling `measure()` many times in a single tick coalesces into one prefix-sum rebuild via `queueMicrotask` - **Stable-key reflow** — call `refresh()` after reorder/filter changes to rebuild offsets without discarding measured sizes - **Sticky headers** — mark items with `sticky` to pin them at the viewport top; `createGroupedVirtualizer` handles section headers automatically - **Grouped sections** — `createGroupedVirtualizer` virtualizes sectioned data with per-section headers, `onChange` state, and `scrollToSection`/`scrollToItem` - **Grid virtualization** — `createGridVirtualizer` virtualizes two-dimensional data with independent row/column measurement and `scrollToCell` - **Reactive integration** — `createReactiveVirtualizer` and `createReactiveGroupedVirtualizer` expose state as a `Signal` compatible with `@vielzeug/ripple` - **DOM adapter** — `createDomVirtualList` and `createVirtualScroller` manage virtualizer lifecycle, list-height styles, and DOM node pooling - **Skipped re-renders** — `onChange` is not called when a scroll event doesn't move the visible window across an item boundary - **Programmatic scrolling** — `scrollToIndex()` with `start`, `end`, `center`, and `auto` alignment; `scrollToOffset()` for pixel control; `scrollToRow()`/`scrollToColumn()` for grids; all support `behavior: 'smooth'` - **Horizontal + window targets** — supports both element and `window` scrolling, in vertical or horizontal mode - **Asymmetric overscan + gap** — tune start/end overscan independently and add inter-item spacing - **Atomic updates** — `virt.update(...)` lets you change count, estimator, overscan, and more in one call - **Clamp-safe** — `scrollToIndex` silently clamps out-of-range indices - **Scroll state events** — `onScrollingChange` fires when scrolling starts/stops; `onScrollEnd` fires once scrolling settles (native `scrollend` or debounce fallback); `isScrolling` getter available at any time - **Scroll anchor** — viewport position is preserved visually when `estimateSize` changes via `update()` - **Prepend support** — `prepend()` adds items at the top while keeping the viewport visually stable - **Disposable** — implements `[Symbol.dispose]` for `using` declarations - **Zero runtime dependencies** (ripple is a peer dependency used only by `createReactiveVirtualizer`) ## How It Works Scroll maintains a prefix-sum offset array. On every scroll event it runs two binary searches — one for the first visible index, one for the last — to determine the render window in O(log n) time. Only the items within that window (plus `overscan` on each side) are passed to `onChange`. ```text Items: [0] [1] [2] [3] [4] [5] [6] ... Offsets: 0 36 72 108 144 180 216 ... scrollTop = 90, containerHeight = 120 → visible items 2–5 With overscan=3: render items 0–8 ``` The offset array is rebuilt (O(n)) only when layout inputs change: on `measure()` flush, `refresh()`, `update({ count })`, `update({ estimateSize })`, or `invalidate()`. Scroll and resize events recompute the visible window without rebuilding offsets. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Refine](/refine/) — accessible web components that use Scroll internally for virtualized listboxes and comboboxes - [Ore](/ore/) — web-component authoring layer; use with Scroll to build virtualizing custom elements - [Dnd](/dnd/) — drag-and-drop engine; combine with Scroll to make sortable virtual lists ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------------------------------------ | ----------------------------------------------- | -------------- | ------------------------------------------------------------------------------------- | | `createVirtualizer()` | Core 1D virtualizer | Sync | `onChange` fires on construction — wire DOM first | | `createDomVirtualList()` | DOM adapter for dropdown/listbox UIs | Sync | Virtualizer is created lazily on first `setItems()` | | `createVirtualScroller()` | Self-contained scroller (creates its own DOM) | Sync | `dispose()` removes the generated scroll element | | `createGroupedVirtualizer()` | Sectioned list with sticky headers | Sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes | | `createGridVirtualizer()` | Two-dimensional grid virtualizer | Sync | `onRangeChange` fires even when `onChange` is omitted | | `createReactiveVirtualizer()` | Virtualizer with reactive signal output | Sync | `onChange` must not be passed — it is wired internally | | `createReactiveGroupedVirtualizer()` | Grouped virtualizer with reactive signal output | Sync | `onChange` must not be passed — it is wired internally | ## Package Entry Point Everything exports from a single entry: ```ts import { createVirtualizer, createDomVirtualList, createVirtualScroller, createGroupedVirtualizer, createGridVirtualizer, createReactiveVirtualizer, type Virtualizer, type VirtualItem, type VirtualizerState, } from '@vielzeug/scroll'; ``` ## `createVirtualizer(target, options)` ```ts createVirtualizer(target: ScrollTarget, options: VirtualizerOptions): Virtualizer; ``` Creates and immediately attaches a virtualizer to the provided scroll container. `onChange` fires synchronously on construction with the initial visible window. Call `dispose()` on unmount. ```ts import { createVirtualizer } from '@vielzeug/scroll'; const virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, gap: 8, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const row = document.createElement('div'); row.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`; row.textContent = rows[item.index]?.label ?? ''; listEl.appendChild(row); } }, }); ``` ### Parameters | Parameter | Type | Description | | --------- | ----------------------- | --------------------------- | | `target` | `HTMLElement \| Window` | Scroll container to observe | | `options` | `VirtualizerOptions` | Initial options | ### `VirtualizerOptions` | Option | Type | Default | Description | | ------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------ | | `count` | `number` | required | Total item count | | `estimateSize` | `number \| (index: number) => number` | `36` | Fixed size or per-index estimate in pixels | | `gap` | `number` | `0` | Gap between adjacent items in pixels | | `getItemKey` | `(index: number) => string \| number` | `index => index` | Stable key for the measurement cache | | `horizontal` | `boolean` | `false` | Virtualize along the X axis instead of Y | | `initialOffset` | `number` | — | Initial scroll position; applied once on construction | | `measurementCache` | `MeasurementCache` | — | Shared external cache for scroll restoration or SSR pre-measurement | | `onChange` | `(state: VirtualizerState) => void` | — | Called when the visible window changes. **Fixed at construction.** | | `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles (native `scrollend` or debounce). **Fixed at construction.** | | `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops. **Fixed at construction.** | | `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric on both sides | | `scrollEndDelay` | `number` | `150` | Debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable | | `sticky` | `(index: number) => boolean` | — | Mark an item as a sticky header (pinned at viewport top) | `onChange`, `onScrollEnd`, `onScrollingChange`, and `scrollEndDelay` are fixed at construction — they cannot be changed via `update()`. **Returns:** `Virtualizer` ### `VirtualizerState` ```ts interface VirtualizerState { readonly items: VirtualItem[]; readonly stickyItems: VirtualItem[]; readonly totalSize: number; } ``` `items` contains the currently visible items plus overscan. `stickyItems` contains items marked sticky that are pinned at the viewport top. ### `Virtualizer` — read-only properties | Property | Type | Description | | -------------- | --------------- | ----------------------------------------------------------- | | `count` | `number` | Current item count | | `isScrolling` | `boolean` | `true` while the user is scrolling; `false` once settled | | `items` | `VirtualItem[]` | Currently rendered items. Always populated. | | `scrollOffset` | `number` | Current scroll position in pixels | | `stickyItems` | `VirtualItem[]` | Items pinned at the viewport top (requires `sticky` option) | | `totalSize` | `number` | Total height (or width in horizontal mode) | ### `Virtualizer` — methods | Method | Signature | Description | | ------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------- | | `update` | `(next: VirtualizerUpdateOptions) => void` | Atomically update live options | | `measure` | `(index: number, size: number) => void` | Record one measured size; rebuild batched in microtask | | `measureBatch` | `(entries: Array) => void` | Record many sizes; single rebuild | | `measureEl` | `(index: number, el: HTMLElement) => () => void` | Attach ResizeObserver to auto-measure. Returns a disconnect function | | `refresh` | `() => void` | Rebuild offset table and re-emit; preserves cached measurements | | `prepend` | `(additionalCount: number) => void` | Add items at the top; adjusts scroll offset to keep viewport stable | | `scrollToIndex` | `(index: number, options?: ScrollToIndexOptions) => void` | Scroll to an item; out-of-range indices are clamped | | `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset | | `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` | | `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list | | `invalidate` | `() => void` | Clear all measurements and rebuild from estimates | | `dispose` | `() => void` | Detach listeners; idempotent | | `disposed` | `boolean` | `true` after `dispose()` is called | | `[Symbol.dispose]` | `() => void` | Delegates to `dispose()` — enables `using` declarations | ### `update(next)` Atomically updates one or more live options. Accepts: `count`, `estimateSize`, `gap`, `getItemKey`, `measurementCache`, `overscan`, `sticky`. Creation-time options (`horizontal`, `initialOffset`, `onChange`, `onScrollEnd`, `onScrollingChange`, `scrollEndDelay`) cannot be changed after construction. When `estimateSize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable. ```ts virt.update({ count: rows.length }); virt.update({ estimateSize: 40 }); virt.update({ gap: 8, overscan: { start: 5, end: 5 } }); ``` ### `measure(index, size)` and `measureBatch(entries)` Report exact sizes for variable-height rows. Calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no-op when the new size equals the current effective size. ```ts virt.measure(item.index, el.offsetHeight); // Prefer measureBatch for ResizeObserver batches virt.measureBatch(entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height }))); ``` ### `measureEl(index, el)` Attaches a `ResizeObserver` to auto-measure `el` on resize. Returns a disconnect function. The observer is also disconnected automatically when the virtualizer is disposed, so calling the returned function is only needed to stop observing a specific element early (e.g. before it is recycled or removed). ```ts const disconnect = virt.measureEl(item.index, rowEl); // later: disconnect(); ``` ### `refresh()` Rebuilds the full offset table and re-emits. Preserves cached measurements. Use after reordering, filtering, or any data change where sizes may have changed. ### `prepend(additionalCount)` Adds `additionalCount` items at the front while adjusting scroll offset so the viewport stays visually stable. Use for "load previous page" patterns. ### `scrollToIndex(index, options?)` Scroll to an item. Out-of-range indices are clamped silently. | `align` | Behavior | | ------------------ | ------------------------------------------------------------ | | `'start'` | Item top at viewport top | | `'end'` | Item bottom at viewport bottom | | `'center'` | Item centered in the viewport | | `'auto'` (default) | No scroll if already fully visible; otherwise minimum scroll | ```ts virt.scrollToIndex(0, { align: 'start' }); virt.scrollToIndex(500, { align: 'center', behavior: 'smooth' }); virt.scrollToIndex(focusedIndex, { align: 'auto' }); ``` ### `scrollToOffset(offset, options?)` ```ts virt.scrollToOffset(Number(sessionStorage.getItem('scrollOffset') ?? '0')); ``` ### `invalidate()` Clears all measured sizes and rebuilds from estimator values. ```ts document.fonts.ready.then(() => virt.invalidate()); ``` ### `dispose()` and `[Symbol.dispose]()` `dispose()` detaches observers and event listeners. It is idempotent. ```ts { using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render }); } // → dispose() called automatically ``` ## `createDomVirtualList(options)` ```ts createDomVirtualList(options: DomVirtualListOptions): DomVirtualListController; ``` DOM-focused adapter. Manages virtualizer lifecycle, applies list-height styles automatically, and provides a node pool via `recycle`. The virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called. ```ts import { createDomVirtualList } from '@vielzeug/scroll'; const ctrl = createDomVirtualList({ estimateSize: 36, getItemKey: (_, row) => row.id, listElement: listEl, scrollElement: scrollEl, render: ({ items, listEl, recycle }) => { for (const item of items) { const el = recycle(item.data.id, () => document.createElement('div')); el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`; el.textContent = item.data.label; listEl.appendChild(el); } }, }); ctrl.setItems(rows); ctrl.scrollToIndex(focusedIndex, { align: 'auto' }); ctrl.dispose(); ``` ### `DomVirtualListOptions` | Option | Type | Default | Description | | ------------------ | --------------------------------------------- | -------- | ---------------------------------------------------------- | | `scrollElement` | `HTMLElement \| Window` | required | Scroll container to observe | | `listElement` | `HTMLElement` | required | Element that receives height and item children | | `render` | `(args: DomVirtualListRenderArgs) => void` | required | Called on every visible-window change | | `estimateSize` | `number \| (index, item) => number` | `36` | Fixed or per-item size estimate | | `gap` | `number` | `0` | Gap between items in pixels | | `getItemKey` | `(index, item) => string \| number` | — | Stable key; keeps measurements across `setItems()` calls | | `horizontal` | `boolean` | `false` | Virtualize along X axis | | `measurementCache` | `MeasurementCache` | — | External measurement cache | | `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric | | `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers | | `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` | Without `getItemKey`, each `setItems()` call drops cached measurements. ### `DomVirtualListRenderArgs` ```ts type DomVirtualListRenderArgs = { items: Array>; // visible items — each has .data + layout fields listEl: HTMLElement; recycle: RecycleFn; // node pool — returns existing node or calls create() stickyItems: Array>; // sticky items (requires sticky option) totalSize: number; }; ``` `VirtualRenderItem` is `VirtualItem` (`start`, `end`, `size`, `index`) enriched with `data: T`. `recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. Nodes not reused in a render cycle are removed automatically. `listEl.style.height` is set before `render` is called — you do not need to set it yourself. ### `DomVirtualListController` Extends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virtualizer methods and live getters are available directly. | Member | Description | | ------------------ | ------------------------------------------------------------------------------------------- | | `setItems(items)` | Set the current item array. Spawns virtualizer on first non-empty call; destroys it on `[]` | | `count` | Current item count (live getter) | | `items` | Currently rendered virtual items (live getter) | | `totalSize` | Total list size in pixels (live getter) | | `scrollOffset` | Current scroll position (live getter) | | `stickyItems` | Sticky items pinned at viewport top (live getter) | | `measure` | Delegate to underlying virtualizer; no-op before first `setItems` | | `measureBatch` | Batch measurement delegate | | `measureEl` | Attach auto-measuring ResizeObserver | | `refresh` | Rebuild offset table and re-emit | | `invalidate` | Clear measurements and rebuild from estimates | | `scrollToIndex` | Scroll to an item | | `scrollToOffset` | Scroll to a pixel offset | | `dispose` | Teardown; idempotent | | `disposed` | `true` after `dispose()` is called (live getter) | | `[Symbol.dispose]` | Delegates to `dispose()` | ## `createVirtualScroller(container, options)` ```ts createVirtualScroller(container: HTMLElement, options: VirtualScrollerOptions): DomVirtualListController; ``` Creates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `DomVirtualListController`. Useful when the scroll DOM doesn't already exist. ```ts const list = createVirtualScroller(document.getElementById('root')!, { estimateSize: 36, render: ({ items, listEl, recycle }) => { for (const item of items) { const el = recycle(item.data.id, () => document.createElement('div')); el.textContent = item.data.label; el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);`; listEl.appendChild(el); } }, }); list.setItems(rows); list.dispose(); // also removes the generated scroll container ``` `VirtualScrollerOptions` is `DomVirtualListOptions` minus `listElement`/`scrollElement`, plus: | Option | Type | Description | | ---------------- | -------- | ------------------------------------------------- | | `containerClass` | `string` | CSS class applied to the generated scroll element | `dispose()` removes the generated scroll container from the DOM. ## `createGroupedVirtualizer(target, options)` ```ts createGroupedVirtualizer(target: ScrollTarget, options: GroupVirtualizerOptions): GroupVirtualizer; ``` Virtualizes a sectioned list. Headers are automatically sticky (pinned at viewport top while the section is in view). ```ts import { createGroupedVirtualizer } from '@vielzeug/scroll'; type Contact = { id: number; name: string }; const virt = createGroupedVirtualizer(scrollEl, { estimateHeaderSize: 32, estimateItemSize: 48, sections: [ { label: 'A', items: [{ id: 1, name: 'Alice' }] }, { label: 'B', items: [{ id: 2, name: 'Bob' }] }, ], onChange: ({ headers, items, stickyHeader, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; if (stickyHeader) { const el = document.createElement('div'); el.className = 'sticky-header'; el.textContent = stickyHeader.label; listEl.appendChild(el); } for (const header of headers) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${header.start}px;height:${header.size}px;`; el.textContent = header.label; listEl.appendChild(el); } for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`; el.textContent = item.data.name; listEl.appendChild(el); } }, }); virt.scrollToSection(1, { align: 'start' }); virt.update(nextSections); virt.dispose(); ``` ### `GroupVirtualizerOptions` | Option | Type | Default | Description | | -------------------- | ------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------- | | `sections` | `Array>` | required | Initial sections | | `onChange` | `(state: GroupVirtualizerState) => void` | — | Called when the visible window changes. **Fixed at construction.** | | `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles. **Fixed at construction.** | | `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops. **Fixed at construction.** | | `estimateHeaderSize` | `number \| (section, sectionIndex) => number` | `36` | Header height estimate | | `estimateItemSize` | `number \| (item, itemIndex, sectionIndex) => number` | `36` | Item height estimate | | `getItemKey` | `(item: T, itemIndex: number, sectionIndex: number) => VirtualKey` | — | Stable key for measurement cache | | `horizontal` | `boolean` | `false` | Virtualize along X axis | | `measurementCache` | `MeasurementCache` | — | External measurement cache | | `overscan` | `number \| { start?: number; end?: number }` | `3` | Overscan on each side (number = symmetric) | | `scrollEndDelay` | `number` | `150` | Debounce delay (ms) for scroll-end detection | ### `GroupSection` ```ts interface GroupSection { items: T[]; label: string; } ``` ### `GroupVirtualizerState` ```ts interface GroupVirtualizerState { readonly headers: GroupVirtualHeader[]; readonly items: Array>; readonly stickyHeader: GroupVirtualHeader | null; readonly totalSize: number; } ``` `stickyHeader` is the header of the section currently at or above the viewport top, or `null` when at the very top. Render it as a floating overlay above the list. ### `GroupVirtualItem` and `GroupVirtualHeader` ```ts interface GroupVirtualItem extends VirtualItem { data: T; itemIndex: number; // index within the section sectionIndex: number; } interface GroupVirtualHeader extends VirtualItem { label: string; sectionIndex: number; } ``` ### `GroupVirtualizer` — methods `GroupVirtualizer` is an independent interface that exposes all core virtualizer methods directly, plus grouped-specific navigation. | Method / Property | Description | | ---------------------------------- | ------------------------------------------------------------------------ | | `update(sections, opts?)` | Replace all sections with optional config overrides; see `GroupVirtualizerUpdateOptions` | | `scrollToSection(i, options?)` | Scroll to section header at index `i`. Out-of-range is a no-op | | `scrollToItem(s, i, options?)` | Scroll to item `i` in section `s`. Out-of-range is a no-op | | `scrollToIndex(i, options?)` | Scroll to flat index `i` (from underlying virtualizer) | | `scrollToOffset(offset, options?)` | Scroll to a raw pixel offset | | `scrollToTop(options?)` | Scroll to offset `0` | | `scrollToBottom(options?)` | Scroll to the end of the list | | `measure(index, size)` | Record a measurement for a flat index | | `measureBatch(entries)` | Batch-record measurements for flat indices | | `measureEl(index, el)` | Attach auto-measuring ResizeObserver. Returns disconnect function | | `invalidate()` | Clear all measurements and rebuild | | `refresh()` | Rebuild offset table without clearing measurements | | `isScrolling` | `true` while the user is scrolling; `false` once scroll settles | | `scrollOffset` | Current scroll position in pixels (live getter) | | `dispose()` | Teardown; idempotent | | `disposed` | `true` after `dispose()` is called | | `[Symbol.dispose]()` | Delegates to `dispose()` | All scroll methods accept an optional `ScrollToIndexOptions` object (`{ align?, behavior?, onComplete? }`). ### `GroupVirtualizerUpdateOptions` Passed as the second argument to `groupVirtualizer.update()`. All fields are optional — omit any you don't want to change. | Option | Type | Description | | -------------------- | ------------------------------------------------------------- | -------------------------------------------------------- | | `estimateHeaderSize` | `number \| (section, sectionIndex) => number` | New header size estimate, applied on next rebuild | | `estimateItemSize` | `number \| (item, itemIndex, sectionIndex) => number` | New item size estimate, applied on next rebuild | | `getItemKey` | `(item, itemIndex, sectionIndex) => VirtualKey` | New item key function | | `measurementCache` | `MeasurementCache` | Hot-swap the measurement cache | | `overscan` | `number \| { start?, end? }` | New overscan count | > **Note:** `onChange`, `onScrollEnd`, `onScrollingChange`, and `horizontal` are fixed at construction and cannot be changed via `update()`. ## `createGridVirtualizer(target, options)` ```ts createGridVirtualizer(target: ScrollTarget, options: GridVirtualizerOptions): GridVirtualizer; ``` Two-dimensional virtualizer. Fires `onChange` with visible row and column descriptors. Callers form the cross-product `rows × cols` to render visible cells. ```ts import { createGridVirtualizer } from '@vielzeug/scroll'; const grid = createGridVirtualizer(scrollEl, { rowCount: 10_000, colCount: 50, estimateRowSize: 36, estimateColSize: 120, onChange: ({ rows, cols, totalHeight, totalWidth }) => { containerEl.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`; containerEl.innerHTML = ''; for (const row of rows) { for (const col of cols) { const cell = document.createElement('div'); cell.style.cssText = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`; cell.textContent = `${row.index},${col.index}`; containerEl.appendChild(cell); } } }, }); grid.scrollToCell(500, 10, { rowAlign: 'center', colAlign: 'start' }); grid.dispose(); ``` ### `GridVirtualizerOptions` | Option | Type | Default | Description | | --------------------- | --------------------------------------- | ---------------------- | -------------------------------------- | | `rowCount` | `number` | required | Total row count | | `colCount` | `number` | required | Total column count | | `estimateRowSize` | `number \| (row) => number` | `36` | Row height estimate | | `estimateColSize` | `number \| (col) => number` | `36` | Column width estimate | | `rowGap` | `number` | `0` | Gap between rows | | `colGap` | `number` | `0` | Gap between columns | | `overscanY` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Row overscan | | `overscanX` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Column overscan | | `initialScrollTop` | `number` | — | Initial vertical scroll position | | `initialScrollLeft` | `number` | — | Initial horizontal scroll position | | `onChange` | `(state: GridVirtualizerState) => void` | — | Called when the visible window changes | | `onRangeChange` | `(range: GridRangeChangeEvent) => void` | — | Zero-allocation range callback | | `rowMeasurementCache` | `Map` | — | External row measurement cache | | `colMeasurementCache` | `Map` | — | External column measurement cache | ### `GridVirtualizerState` ```ts interface GridVirtualizerState { readonly cols: VirtualItem[]; readonly rows: VirtualItem[]; readonly totalHeight: number; readonly totalWidth: number; } ``` ### `GridVirtualizer` — properties and methods **Read-only properties:** `rows`, `cols`, `scrollTop`, `scrollLeft`, `totalHeight`, `totalWidth` | Method | Description | | ---------------------------------- | --------------------------------------------------------------------------------- | | `update(next)` | Atomically update row/col counts, estimates, gaps, and overscan | | `measureRow(row, size)` | Record a row height | | `measureColumn(col, size)` | Record a column width | | `measureBatch(rows, cols)` | Measure rows and columns in a single coordinated rebuild pass | | `measureRowEl(row, el)` | Auto-measure row height via ResizeObserver. Returns disconnect fn | | `measureColEl(col, el)` | Auto-measure column width via ResizeObserver. Returns disconnect fn | | `refresh()` | Rebuild offset tables from current measurements | | `invalidate()` | Clear all measurements and rebuild from estimates | | `scrollToCell(row, col, options?)` | Scroll to bring a cell into view; no-op when `rowCount === 0` or `colCount === 0` | | `scrollToRow(row, options?)` | Scroll to bring a row into view; `rowAlign` controls alignment | | `scrollToColumn(col, options?)` | Scroll to bring a column into view; `colAlign` controls alignment | | `prependRows(n)` | Add `n` rows at the top; adjusts scroll offset to keep viewport stable | | `dispose()` | Teardown; idempotent | | `[Symbol.dispose]()` | Delegates to `dispose()` | `measureRowEl`/`measureColEl`'s `ResizeObserver` is also disconnected automatically on `dispose()` — the returned disconnect function is only needed to stop observing a specific element early. ### `ScrollToCellOptions` ```ts interface ScrollToCellOptions { behavior?: ScrollBehavior; colAlign?: 'auto' | 'center' | 'end' | 'start'; rowAlign?: 'auto' | 'center' | 'end' | 'start'; } ``` ## `createReactiveGroupedVirtualizer(target, options)` ```ts createReactiveGroupedVirtualizer( target: ScrollTarget, options: Omit, 'onChange'>, ): ReactiveGroupVirtualizer; ``` Wraps `createGroupedVirtualizer` and exposes state as a `Signal>` from `@vielzeug/ripple`. All `GroupVirtualizer` methods and live getters are available. `onChange` must not be provided — it is wired internally. ```ts import { createReactiveGroupedVirtualizer } from '@vielzeug/scroll'; import { effect } from '@vielzeug/ripple'; const virt = createReactiveGroupedVirtualizer(scrollEl, { estimateHeaderSize: 32, estimateItemSize: 48, sections, }); effect(() => { const { headers, items, stickyHeader, totalSize } = virt.state.value; render(headers, items, stickyHeader, totalSize); }); virt.update(nextSections); virt.dispose(); ``` ### `ReactiveGroupVirtualizer` ```ts interface ReactiveGroupVirtualizer extends GroupVirtualizer { readonly state: Signal>; } ``` The `state` signal is updated synchronously on every render cycle. All live getters remain current via Proxy. ## `createReactiveVirtualizer(target, options)` ```ts createReactiveVirtualizer( target: ScrollTarget, options: Omit, ): ReactiveVirtualizer; ``` Wraps `createVirtualizer` and exposes state as a `Signal` from `@vielzeug/ripple`. All `Virtualizer` methods and live getters are available on the returned object. `onChange` must not be provided — it is wired internally. ```ts import { createReactiveVirtualizer } from '@vielzeug/scroll'; import { effect } from '@vielzeug/ripple'; const virt = createReactiveVirtualizer(scrollEl, { count: 1000, estimateSize: 40, }); effect(() => { const { items, totalSize } = virt.state.value; listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`; listEl.appendChild(el); } }); virt.update({ count: 2000 }); virt.dispose(); ``` ### `ReactiveVirtualizer` ```ts interface ReactiveVirtualizer extends Virtualizer { readonly state: Signal; } ``` The `state` signal is updated synchronously whenever the visible window changes. All live getters (`count`, `items`, `totalSize`, `scrollOffset`, `stickyItems`) remain current. ## Types ### `VirtualItem` ```ts interface VirtualItem { end: number; index: number; size: number; start: number; } ``` ### `VirtualizerState` ```ts interface VirtualizerState { readonly items: VirtualItem[]; readonly stickyItems: VirtualItem[]; readonly totalSize: number; } ``` ### `ScrollToIndexOptions` ```ts interface ScrollToIndexOptions { align?: 'auto' | 'center' | 'end' | 'start'; behavior?: ScrollBehavior; /** Called when the scroll animation completes (instant scrolls: next microtask). */ onComplete?: () => void; } ``` ### `Overscan` ```ts type Overscan = number | { end?: number; start?: number }; ``` Passing a number is shorthand for symmetric overscan on both sides. ### `VirtualKey` ```ts type VirtualKey = number | string; ``` ### `VirtualRenderItem` ```ts type VirtualRenderItem = VirtualItem & { readonly data: T }; ``` ### `ScrollTarget` ```ts type ScrollTarget = HTMLElement | Window; ``` ### `MeasurementCache` ```ts type MeasurementCache = Map; ``` Use `createMeasurementCache()` to create an empty cache: ```ts import { createMeasurementCache } from '@vielzeug/scroll'; const cache = createMeasurementCache(); const virt1 = createVirtualizer(el1, { count: 100, measurementCache: cache }); const virt2 = createVirtualizer(el2, { count: 100, measurementCache: cache }); ``` ### `RecycleFn` ```ts type RecycleFn = (key: VirtualKey, create: () => HTMLElement) => HTMLElement; ``` ### `VirtualizerUpdateOptions` ```ts interface VirtualizerUpdateOptions { count?: number; estimateSize?: number | ((index: number) => number); gap?: number; getItemKey?: (index: number) => VirtualKey; /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */ measurementCache?: MeasurementCache; overscan?: Overscan; sticky?: (index: number) => boolean; } ``` ### `VirtualScrollerOptions` `DomVirtualListOptions` minus `listElement` and `scrollElement`, plus: ```ts type VirtualScrollerOptions = Omit, 'listElement' | 'scrollElement'> & { /** CSS class applied to the generated scroll container element. */ containerClass?: string; }; ``` ### `GridVirtualizerUpdateOptions` ```ts interface GridVirtualizerUpdateOptions { colCount?: number; colGap?: number; estimateColSize?: number | ((col: number) => number); estimateRowSize?: number | ((row: number) => number); overscanX?: { end?: number; start?: number }; overscanY?: { end?: number; start?: number }; rowCount?: number; rowGap?: number; } ``` ### `GridRangeChangeEvent` Fired by `onRangeChange` on `createGridVirtualizer`. Zero-allocation alternative to `onChange` — no `rows`/`cols` arrays are allocated. ```ts interface GridRangeChangeEvent { firstCol: number; firstRow: number; lastCol: number; lastRow: number; } ``` ### Constants ```ts const DEFAULT_ESTIMATE_SIZE = 36; // default estimateSize const DEFAULT_OVERSCAN = 3; // default overscan on each side ``` ### Usage Guide ## Basic Usage Render only visible rows by passing a scroll container, a total item count, and a size estimate. Scroll calls `onChange` with the visible window whenever it changes. ```ts import { createVirtualizer } from '@vielzeug/scroll'; const scrollEl = document.querySelector('.scroll-container')!; const listEl = document.querySelector('.list')!; const virt = createVirtualizer(scrollEl, { count: 10_000, estimateSize: 36, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`; el.textContent = `Row ${item.index}`; listEl.appendChild(el); } }, }); // Cleanup virt.dispose(); ``` ```html ``` ## DOM Layout Requirements Scroll uses **absolute positioning** for rendered items inside a relative container that stretches to the full list height. Your HTML needs three elements: ```html ``` A common alternative is to make the spacer and item container the same element: ```html ``` ## DOM Adapter for Dropdowns and Listboxes If your component already has a dropdown scroll container and a listbox element, use `createDomVirtualList`. It wraps the `Virtualizer` lifecycle and keeps the integration surface small. Items arrive as `VirtualRenderItem` — a `VirtualItem` enriched with a `.data` field. Use `recycle` for efficient DOM node reuse. The virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called (clearing list styles in the process). ```ts import { createDomVirtualList } from '@vielzeug/scroll'; type Option = { disabled?: boolean; label: string; value: string }; let options: Option[] = []; const domVirtualList = createDomVirtualList({ estimateSize: 36, gap: 6, getItemKey: (_index, option) => option.value, listElement: listboxEl, overscan: { end: 4, start: 4 }, render: ({ items, listEl, recycle }) => { for (const item of items) { const row = recycle(item.data.value, () => document.createElement('button')); row.type = 'button'; row.className = 'option'; row.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`; row.textContent = item.data.label; row.disabled = !!item.data.disabled; listEl.appendChild(row); } }, scrollElement: dropdownEl, }); // Keep in sync when options change domVirtualList.setItems(options); // Open: setItems populates the list // Close: setItems([]) destroys the virtualizer and clears list styles domVirtualList.setItems(isOpen ? options : []); // Keyboard nav domVirtualList.scrollToIndex(focusedIndex, { align: 'auto' }); // Component teardown domVirtualList.dispose(); ``` For variable-height rows, pass `getItemKey` so measurements survive `setItems()` calls when items reorder or are filtered. When multiple sizes are available at once, use `measureBatch` to coalesce into a single rebuild: ```ts domVirtualList.measureBatch( entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })), ); ``` Use `domVirtualList.invalidate()` to discard all cached measurements. ## Fixed Heights Pass a single number to `estimateSize` when all rows are the same height. This is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling. ```ts const virt = createVirtualizer(scrollEl, { count: 10_000, estimateSize: 36, // every row is 36px onChange: ({ items, totalSize }) => { list.style.height = `${totalSize}px`; list.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`; el.textContent = data[item.index].name; list.appendChild(el); } }, }); ``` ## Variable Heights — Estimator Pass a **per-index function** to `estimateSize` when rows have predictable but non-uniform heights (e.g. group headers vs. regular rows). The offset table is built once at attach time using these estimates. ```ts const virt = createVirtualizer(scrollEl, { count: flatList.length, estimateSize: (i) => (flatList[i].type === 'header' ? 48 : 36), onChange: ({ items, totalSize }) => { // render... }, }); ``` ## Variable Heights — Measured For truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with `measure()`. Scroll will coalesce all measurement calls within a single microtask tick into one offset rebuild. ```ts const virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 60, // initial estimate onChange: ({ items, totalSize }) => { list.style.height = `${totalSize}px`; list.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.dataset.index = String(item.index); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`; el.innerHTML = rows[item.index].html; list.appendChild(el); } // Measure after the DOM has painted requestAnimationFrame(() => { for (const item of items) { const el = list.querySelector(`[data-index="${item.index}"]`); if (el) virt.measure(item.index, el.offsetHeight); } }); }, }); ``` `measure(index, height)` is a no-op when the new height matches the current effective height (measured or estimated). It is safe to call on every render without triggering unnecessary rebuilds. ## Variable Heights — Batch Measurement When a `ResizeObserver` fires with multiple entries at once, use `measureBatch()` to apply all sizes in a single offset rebuild instead of triggering one rebuild per `measure()` call. ```ts const observer = new ResizeObserver((entries) => { virt.measureBatch( entries .filter((e) => e.target instanceof HTMLElement && e.target.dataset.index) .map((e) => ({ index: Number((e.target as HTMLElement).dataset.index), size: e.contentRect.height, })), ); }); // Observe each rendered row for (const item of virt.items) { const el = listEl.querySelector(`[data-index="${item.index}"]`); if (el) observer.observe(el); } ``` ## Overscan `overscan` controls how many extra items render outside the visible viewport on each side. Higher values reduce the chance of blank rows during fast scrolling; lower values keep the DOM smaller. ```ts createVirtualizer(scrollEl, { count: 1_000, estimateSize: 36, overscan: 5, // symmetric shorthand — same as { start: 5, end: 5 } (default: 3) onChange: () => { /* ... */ }, }); ``` Asymmetric overscan: ```ts createVirtualizer(scrollEl, { count: 1_000, estimateSize: 36, overscan: { start: 8, end: 2 }, onChange: () => { /* ... */ }, }); ``` ## Horizontal Lists Set `horizontal: true` to virtualize along the X axis. ```ts const virt = createVirtualizer(scrollEl, { count: chips.length, estimateSize: 120, horizontal: true, onChange: ({ items, totalSize }) => { list.style.width = `${totalSize}px`; for (const item of items) { const chip = document.createElement('button'); chip.style.cssText = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`; chip.textContent = chips[item.index].label; list.appendChild(chip); } }, }); ``` ## Window Scroll Target `createVirtualizer` accepts `window` as the scroll target. ```ts const virt = createVirtualizer(window, { count: rows.length, estimateSize: 40, initialOffset: 320, onChange: ({ items, totalSize }) => { spacer.style.height = `${totalSize}px`; renderRows(items); }, }); ``` ## Scroll State Use `virt.scrollOffset` to read the current scroll position at any time. ```ts const virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: render }); // Accessed outside onChange console.log(virt.scrollOffset); ``` ## Updating Options When your data or render strategy changes, call `update()` with one or more option fields. Updates are applied atomically and trigger re-render when needed. ```ts // Load more data data.push(...newItems); virt.update({ count: data.length }); ``` ```ts // Change multiple options together virt.update({ count: data.length, overscan: { start: 5, end: 5 } }); // Rebuild after reordering/filtering stable-key rows virt.refresh(); ``` ## Switching Row Density Updating `estimateSize` clears all previously measured heights, rebuilds offsets, and re-renders. This makes density switching (compact / comfortable / spacious views) straightforward. ```ts function setDensity(mode: 'compact' | 'comfortable') { virt.update({ estimateSize: mode === 'compact' ? 32 : 48 }); } ``` ## Programmatic Scrolling ### `scrollToIndex(index, options?)` Scroll to bring a specific item into view. | `align` | Behaviour | | ------------------ | ------------------------------------------------------------------------ | | `'start'` | Item top aligns with the container top | | `'end'` | Item bottom aligns with the container bottom | | `'center'` | Item is centered in the viewport | | `'auto'` (default) | No scroll if already fully visible; otherwise scrolls the minimum amount | ```ts // Jump to item 500 at the top of the viewport virt.scrollToIndex(500, { align: 'start' }); // Smooth-scroll to an item, centering it virt.scrollToIndex(500, { align: 'center', behavior: 'smooth' }); // Scroll only if the item is not already visible virt.scrollToIndex(focusedIndex, { align: 'auto' }); ``` Out-of-range indices are clamped silently: negative values scroll to item `0`, values ≥ `count` scroll to the last item. ### `scrollToOffset(offset, options?)` Scroll to an exact pixel position, useful for restoring a previously saved scroll state. ```ts // Restore scroll position const savedOffset = sessionStorage.getItem('scrollOffset'); if (savedOffset) virt.scrollToOffset(Number(savedOffset)); // Save on scroll scrollEl.addEventListener('scroll', () => { sessionStorage.setItem('scrollOffset', String(scrollEl.scrollTop)); }); ``` ### `scrollToTop(options?)` / `scrollToBottom(options?)` Convenience wrappers to jump directly to the start or end of the list. ```ts // Jump to the top virt.scrollToTop(); // Jump to the bottom with smooth scroll virt.scrollToBottom({ behavior: 'smooth' }); ``` ## Shared Measurement Cache When the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `MeasurementCache` created by `createMeasurementCache()`. Measurements recorded by one virtualizer are immediately available to all others using the same cache. ```ts import { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll'; const cache = createMeasurementCache(); const listVirt = createVirtualizer(listScrollEl, { count: rows.length, estimateSize: 36, measurementCache: cache, onChange: renderList, }); const previewVirt = createVirtualizer(previewScrollEl, { count: rows.length, estimateSize: 36, measurementCache: cache, onChange: renderPreview, }); // A measurement on listVirt is reflected in previewVirt immediately. listVirt.measure(0, 72); ``` The cache is a plain `Map` — you can pre-populate it from server data or persist it across sessions. ```ts // Pre-populate from server-sent sizes const cache = createMeasurementCache(); for (const { id, height } of serverSizes) cache.set(id, height); ``` ## Invalidating Measurements Call `invalidate()` after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout. ```ts document.fonts.ready.then(() => virt.invalidate()); ``` On variable-height lists, `scrollToIndex()` uses the current estimate/measured cache. If you need an exact post-layout position after heights change, call `invalidate()` before scrolling again. For same-length updates, call `setItems()` (DOM adapter) or `update()` (core). If the rendered height of rows changed, call `invalidate()` before scrolling again. ## Lifecycle — create and dispose `createVirtualizer(el, options)` attaches immediately to the provided scroll container. If your container is replaced, dispose the old instance and create a new one. ```ts let virt = createVirtualizer(scrollContainerEl, { count: rows.length, estimateSize: 36, onChange: render, }); function remount(nextScrollContainerEl: HTMLElement) { virt.dispose(); virt = createVirtualizer(nextScrollContainerEl, { count: rows.length, estimateSize: 36, onChange: render, }); } ``` `dispose()` is idempotent and safe to call multiple times. ### Explicit Resource Management ```ts // The `using` keyword calls virt.dispose() automatically at block exit { using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render }); // ... use virt ... } // → virt.dispose() called here ``` ## Framework Integration Scroll is rendering-layer agnostic. The pattern is always the same: create the virtualizer when your scroll container is mounted, re-render your DOM in `onChange`, and call `dispose()` on unmount. ```tsx [React] import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll'; import { useEffect, useRef } from 'react'; interface Row { id: number; label: string; } function VirtualList({ rows }: { rows: Row[] }) { const scrollRef = useRef(null); const listRef = useRef(null); const virtRef = useRef(null); useEffect(() => { const scrollEl = scrollRef.current; const listEl = listRef.current; if (!scrollEl || !listEl) return; const virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`; el.textContent = rows[item.index]?.label ?? ''; listEl.appendChild(el); } }, }); virtRef.current = virt; return () => virt.dispose(); }, []); // attach once useEffect(() => { virtRef.current?.update({ count: rows.length }); }, [rows.length]); return ( ); } ``` ```vue [Vue 3] import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll'; import { onMounted, onUnmounted, ref, watch } from 'vue'; const props = defineProps(); const scrollRef = ref(null); const listRef = ref(null); let virt: Virtualizer | null = null; onMounted(() => { if (!scrollRef.value || !listRef.value) return; const listEl = listRef.value; virt = createVirtualizer(scrollRef.value, { count: props.rows.length, estimateSize: 36, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`; el.textContent = props.rows[item.index]?.label ?? ''; listEl.appendChild(el); } }, }); }); watch( () => props.rows.length, (n) => { virt?.update({ count: n }); }, ); onUnmounted(() => virt?.dispose()); ``` ```svelte [Svelte] import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll'; let { rows }: { rows: { id: number; label: string }[] } = $props(); let scrollEl: HTMLElement; let listEl: HTMLElement; let virt: Virtualizer; $effect(() => { virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`; el.textContent = rows[item.index]?.label ?? ''; listEl.appendChild(el); } }, }); return () => virt.dispose(); }); $effect(() => { virt?.update({ count: rows.length }); }); ``` ```ts [Web Components] import { LitElement, html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll'; @customElement('virtual-list') class VirtualList extends LitElement { static styles = css` .scroll { height: 400px; overflow: auto; position: relative; } .list { position: relative; } `; @property({ type: Array }) rows: { label: string }[] = []; #virt: Virtualizer | null = null; firstUpdated() { const scrollEl = this.renderRoot.querySelector('.scroll')!; const listEl = this.renderRoot.querySelector('.list')!; this.#virt = createVirtualizer(scrollEl, { count: this.rows.length, estimateSize: 36, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`; el.textContent = this.rows[item.index]?.label ?? ''; listEl.appendChild(el); } }, }); } updated() { this.#virt?.update({ count: this.rows.length }); } disconnectedCallback() { this.#virt?.dispose(); super.disconnectedCallback(); } render() { return html``; } } ``` ### Pitfalls - **React:** Putting `rows` in the `useEffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Call `virt.update({ count })` from a separate `useEffect` for data changes. - **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`. - **Svelte:** In Svelte 5, `$effect` with `bind:this` runs after the DOM is painted. The `bind:this` variable is available when the `$effect` runs — no extra tick needed. - **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes. ## Working with Other Vielzeug Libraries ### With Ore Build a virtualizing custom element using Ore for the component shell and Scroll for the rendering engine. ```ts import { define, html, onMounted, ref } from '@vielzeug/ore'; import { createVirtualizer } from '@vielzeug/scroll'; define('virtual-list', { setup() { const scrollRef = ref(); const listRef = ref(); onMounted(() => { if (!scrollRef.value || !listRef.value) return; const listEl = listRef.value; const virt = createVirtualizer(scrollRef.value, { count: 1000, estimateSize: 40, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.innerHTML = items.map((i) => `Row ${i.index}`).join(''); }, }, }, }); return () => virt.dispose(); }); return () => html` `; }, }); ``` ## Best Practices - Always provide `count` and `estimateSize` as a starting point, even for variable-height lists — measurements refine the estimates. - Call `dispose()` in the framework cleanup callback (useEffect return, onUnmounted, onDestroy) to free resize observers. - Use `overscan` to pre-render rows above and below the visible area to reduce blank flicker during fast scrolling. - Prefer `scrollToIndex()` with `align: 'start'` for programmatic navigation; use `align: 'center'` for focus management. - Use `createDomVirtualList()` for comboboxes, listboxes, and selects — it manages the virtualizer lifecycle and DOM node pooling for you. - Invalidate measurements with `invalidate()` when item content changes size (e.g., after expanding an accordion row). - For very large lists (>100k items), set a narrower `overscan` to limit DOM node count at any one time. - Use `refresh()` when item data or sizes may have changed; it rebuilds the offset table and re-emits. ### Examples ## Examples - [Basic Fixed Height List](./examples/basic-fixed-height-list.md) - [Variable Height With Measurement](./examples/variable-height-with-measurement.md) - [Grouped List Headers Plus Rows](./examples/grouped-list-headers-plus-rows.md) - [Infinite Scroll Load More](./examples/infinite-scroll-load-more.md) - [Keyboard Navigation](./examples/keyboard-navigation.md) - [Restore Scroll Position](./examples/restore-scroll-position.md) - [Density Toggle Compact Comfortable](./examples/density-toggle-compact-comfortable.md) - [DOM Virtual List Combobox Pattern](./examples/dom-virtual-list-combobox-pattern.md) - [Grid Virtualizer](./examples/grid-virtualizer.md) - [Reactive Virtualizer](./examples/reactive-virtualizer.md) - [Infinite Scroll with Analytics and Prefetch](./examples/on-range-change.md) - [Sticky Items in DOM Virtual List](./examples/dom-virtual-list-sticky.md) - [Recreate on Remount](./examples/using-virtualizer-directly-without-createvirtualizer.md) - [Explicit Resource Management (`using`)](./examples/explicit-resource-management-using.md) ### REPL Examples - Virtualizer - Basic List (id: `basic-list`) - Virtualizer - Dynamic Count (id: `dynamic-count`) - Grid Virtualizer (id: `grid-virtualizer`) - Grouped List with Sticky Headers (id: `grouped-list`) - createMeasurementCache - Shared Cache (id: `measurement-cache`) - Infinite Scroll (id: `on-range-change`) - Reactive Grouped Virtualizer (id: `reactive-grouped-list`) - Reactive Virtualizer (id: `reactive-virtualizer`) - Virtualizer - scrollToIndex (id: `scroll-to-index`) - Virtualizer - scrollToTop / scrollToBottom (id: `scroll-to-top-bottom`) - Virtualizer - Variable Height (id: `variable-height`) --- ## @vielzeug/sourcerer **Category:** data **Keywords:** pagination, filtering, sorting, search, data-source, query, remote, local, cursor, infinite-scroll **Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource, applyQuery, SourceDisposedError, SourcererError, SourceTimeoutError, sourceState, itemRange (+39 more) **Related:** courier, ripple, wayfinder ### Overview ## Why Sourcerer? Managing paginated lists across local and remote data usually means writing different state models for each case, wiring separate loading flags, and duplicating URL serialization logic. Sourcerer provides one typed contract — `current`, `meta`, and `subscribe` — that works the same whether data lives in memory or comes from a server. ```ts // Without Sourcerer — manual local list state const [items, setItems] = useState(allUsers); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const pageSize = 10; const filtered = items.filter((u) => u.name.includes(search)); const paginated = filtered.slice((page - 1) * pageSize, page * pageSize); const totalPages = Math.ceil(filtered.length / pageSize); // ... repeat for remote with loading/error/abort logic ... // With Sourcerer — same API for both const source = createLocalSource(allUsers, { limit: 10 }); // or createRemoteSource(...) await source.search(search, { immediate: true }); // source.current, source.meta.pageCount — both cases handled ``` | Feature | Sourcerer | TanStack Query | SWR | | ----------------------------------- | ----------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------- | | Bundle size | | ~16 kB | ~6 kB | | In-memory source primitive | | | | | Remote source primitive | | | | | Cursor-based pagination | | Partial | Partial | | Infinite scroll source | | | | | Typed page/filter/sort/search model | | Partial | Partial | | Optimistic updates | | | | | URL query encode/decode helpers | | Partial | Partial | | Framework agnostic | | | React-first | **Use Sourcerer when** you want one typed source abstraction for both local collections and server-backed lists, with built-in support for pagination, search, filters, and optimistic mutation. **Consider TanStack Query when** your data layer is already built around query keys, cache invalidation across many components, and React-first DevTools integration. ## Installation ```sh [pnpm] pnpm add @vielzeug/sourcerer ``` ```sh [npm] npm install @vielzeug/sourcerer ``` ```sh [yarn] yarn add @vielzeug/sourcerer ``` ## Quick Start ```ts import { createLocalSource } from '@vielzeug/sourcerer'; const source = createLocalSource( [ { id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }, { id: 3, name: 'Linus' }, ], { limit: 2 }, ); await source.search('ada', { immediate: true }); console.log(source.current); // [{ id: 1, name: 'Ada' }] console.log(source.meta.pageNumber); // 1 ``` ```ts import { createRemoteSource } from '@vielzeug/sourcerer'; const source = createRemoteSource({ fetch: async ({ filter, limit, page, search, sort }, signal) => { const res = await fetch(`/api/users?page=${page}&limit=${limit}`, { signal }); return res.json(); // { items: User[], total: number } }, limit: 20, }); // autoFetch is true by default — initial data loads immediately await source.ready(); console.log(source.current, source.meta.totalItems); ``` ## Features | Factory | Data model | Navigation | Key extras | | ------------------------ | --------------- | ------------------- | ------------------------------------------------------------------- | | `createLocalSource()` | In-memory array | Page number | `filterAsync`, `sortAsync`, `patch()`, custom `searchFn`, `ready()` | | `createRemoteSource()` | Server fetch | Page number | `staleTime`, `optimisticUpdate`, `patch()`, `ready()`, `queryKey` | | `createCursorSource()` | Server fetch | Cursor tokens | `patch()`, `ready()`, `queryKey` | | `createInfiniteSource()` | Server fetch | Append (`loadMore`) | `patch()`, `loadedPages`, `ready()`, `queryKey` | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — reactive signals; Sourcerer's loading, error, and data state are exposed as signals for framework-agnostic UI binding - [Arsenal](/arsenal/) — utility functions used inside Sourcerer's fetch and transform pipelines - [Wayfinder](/wayfinder/) — client-side router; sync Sourcerer's pagination and filter state with URL search params ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- | | `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` is fuzzy, not substring | | `createRemoteSource()` | Async server-backed collection with page navigation | Async | Fetches on creation; set `autoFetch: false` to delay | | `createCursorSource()` | Async collection navigated by cursor tokens | Async | `next()`/`prev()` are no-ops when the cursor is absent | | `createInfiniteSource()` | Async append-mode (infinite scroll) collection | Async | `loadMore()` is a no-op once `meta.hasMore` is `false` | | `deriveSource()` | Create a reactive projection of another source | Sync | Derived source disposes automatically when parent disposes | | `mergeSource()` | Combine multiple sources into one `MergedSource` | Sync | No `meta` field — returned type is `MergedSource`, not `ReactiveSource` | | `applyQuery()` | Apply a partial query patch to any source with `patch()` — fires one fetch | Async | Ignores `page` on Cursor/InfiniteSource — no page concept there | | `SourcererError` | Base error class for all sourcerer errors; carries `message`, `cause`, `context`, `attempt` | Class | Extends `Error`; access context via getters, not object spread | | `SourceTimeoutError` | Error thrown when `ready()` times out; has `timeoutMs` property | Class | Extends `SourcererError`; also caught by `instanceof SourcererError` | | `SourceDisposedError` | Error thrown by `ready()` when the source is disposed | Class | Extends `SourcererError`; catch separately from `SourceTimeoutError` if needed | | `sourceState()` | Derive a discriminated union (`loading`/`error`/`success`) from any source | Sync | Returns `'loading'` when `isSearchPending` is true too | | `itemRange()` | Compute 1-based display range from `SourceMeta` | Sync | Returns `{ start: 0, end: 0 }` when `totalItems === 0` | | `prefetchSource()` | SSR: fetch first page, return serialisable snapshot; source is disposed immediately | Async | **Throws `SourcererError`** if fetch fails | | `prefetchSourceAndKeep()` | SSR: fetch first page, return both snapshot and live source (no double-fetch) | Async | Caller must call `source.dispose()` on the returned source | | `filterContains()` | Preset predicate: case-insensitive substring match | Sync | Matches against a getter's string value | | `filterEquals()` | Preset predicate: strict equality match | Sync | Uses `Object.is` semantics | | `filterRange()` | Preset predicate: inclusive min/max range | Sync | Works with numbers and Dates | | `sortBy()` | Preset comparator: sort by a getter value | Sync | Supports `'asc'` / `'desc'`; handles strings, numbers, Dates | | `encodeQuery()` | Serialize source query to URL params | Sync | Filter and sort are JSON-stringified | | `decodeQuery()` | Deserialize URL params (or `URLSearchParams`) to a source query | Sync | Malformed JSON is silently dropped by default | | `FetchEvent` | Type for `onFetch` telemetry callbacks | Type | — | | `SearchOptions` | Options bag for `search()` — only field is `immediate?: boolean` | Type | `search()` always returns `Promise`; debounced unless `{ immediate: true }` | | `DecodeQueryOptions` | Options for `decodeQuery()` — `defaultLimit` and `strict` | Type | `strict: true` throws on malformed JSON; default silently drops it | ## Package Entry Point | Import | Purpose | | --------------------- | ---------------------- | | `@vielzeug/sourcerer` | Main exports and types | ## Core Factories ### `createLocalSource` ```ts createLocalSource( initialData: readonly T[], cfg?: LocalSourceConfig, ): LocalSource ``` ```ts type LocalSourceConfig = { debounceMs?: number; // default: 300 initialData?: readonly T[]; // alternative to positional data arg; takes precedence when both are provided filter?: Predicate; filterAsync?: (items: readonly T[], signal: AbortSignal) => Promise; limit?: number; // default: 20 searchFn?: (items: readonly T[], query: string) => readonly T[]; sort?: Sorter; sortAsync?: (items: readonly T[], signal: AbortSignal) => Promise; }; ``` The default `searchFn` performs a case-insensitive JSON substring match — i.e. it stringifies each item with `JSON.stringify` and checks if the query string appears anywhere in the result. Provide a custom `searchFn` for domain-specific relevance or exact field matching. `filterAsync` and `sortAsync` run after their synchronous counterparts. They set `meta.isLoading = true` during computation and accept an `AbortSignal` — a new call aborts any running async computation. **Returns:** `LocalSource` — reactive in-memory source with pagination, filter, sort, and search. **Example:** ```ts import { createLocalSource } from '@vielzeug/sourcerer'; const source = createLocalSource( [ { id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }, ], { limit: 1 }, ); await source.search('ad', { immediate: true }); console.log(source.current); // [{ id: 1, name: 'Ada' }] ``` --- ### `createRemoteSource` ```ts createRemoteSource( cfg: RemoteConfig, ): RemoteSource ``` ```ts type RemoteConfig = { autoFetch?: boolean; // default: true — fetches on creation debounceMs?: number; // default: 300 fetch: ( q: { filter?: TFilter; limit: number; page: number; search?: string; sort?: TSort }, signal: AbortSignal, ) => Promise; filter?: TFilter; limit?: number; // default: 20 onFetch?: (event: FetchEvent>) => void; // telemetry callback queryKey?: (q: RemoteSourceQuery) => string; refreshInterval?: number; // auto re-fetch every N ms; cancelled on dispose() retry?: RetryConfig; snapshot?: SourceSnapshot; // pre-populate from SSR snapshot sort?: TSort; staleTime?: number; // skip re-fetch if same query key fetched within N ms (default: 0) }; ``` `queryKey` defaults to a stable JSON serialization with recursively sorted keys. `staleTime` compares the **query key** — navigating to a different page always fetches even within the stale window. **Returns:** `RemoteSource` — async server-backed source with page navigation and optimistic update support. **Example:** ```ts import { createRemoteSource } from '@vielzeug/sourcerer'; const source = createRemoteSource({ fetch: async ({ limit, page }, signal) => { const res = await fetch(`/api/items?page=${page}&limit=${limit}`, { signal }); return res.json(); }, limit: 20, staleTime: 5000, }); await source.ready(); ``` --- ### `createCursorSource` ```ts createCursorSource( cfg: CursorConfig, ): CursorSource ``` ```ts type CursorConfig = { autoFetch?: boolean; // default: true debounceMs?: number; // default: 300 fetch: ( q: { after?: TCursor; before?: TCursor; limit: number; search?: string }, signal: AbortSignal, ) => Promise; limit?: number; // default: 20 onFetch?: (event: FetchEvent>) => void; queryKey?: (q: CursorSourceQuery) => string; refreshInterval?: number; // auto re-fetch every N ms; cancelled on dispose() retry?: RetryConfig; }; ``` **Returns:** `CursorSource` — async source navigated by opaque cursor tokens instead of page numbers. **Example:** ```ts import { createCursorSource } from '@vielzeug/sourcerer'; const source = createCursorSource({ fetch: async ({ after, limit }, signal) => { const res = await fetch(`/api/items?after=${after ?? ''}&limit=${limit}`, { signal }); return res.json(); // { items, nextCursor, prevCursor, total } }, limit: 20, }); await source.ready(); if (source.meta.hasNextPage) await source.next(); ``` --- ### `createInfiniteSource` ```ts createInfiniteSource(cfg: InfiniteConfig): InfiniteSource ``` ```ts type InfiniteConfig = { autoFetch?: boolean; // default: true debounceMs?: number; // default: 300 fetch: (q: InfiniteSourceQuery, signal: AbortSignal) => Promise; limit?: number; // default: 20 onFetch?: (event: FetchEvent) => void; queryKey?: (q: InfiniteSourceQuery) => string; // custom deduplication key refreshInterval?: number; retry?: RetryConfig; }; ``` **Returns:** `InfiniteSource` — async append-mode source. Use `loadMore()` to add pages; read all accumulated items from `source.current`. **Example:** ```ts import { createInfiniteSource } from '@vielzeug/sourcerer'; const source = createInfiniteSource({ fetch: async ({ limit, page }, signal) => { const res = await fetch(`/api/posts?page=${page}&limit=${limit}`, { signal }); return res.json(); }, limit: 20, }); await source.ready(); await source.loadMore(); // appends page 2 console.log(source.current.length, source.meta.hasMore); ``` ## `LocalSource` Methods All methods return `Promise` unless noted. | Method / Property | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dispose()` | Release internal resources; idempotent — safe to call multiple times | | `disposed` | `true` after `dispose()` has been called | | `disposalSignal` | `AbortSignal` that is aborted when `dispose()` is called; useful for framework lifecycle hooks | | `goTo(page)` | Navigate to the given page number | | `goToLast()` | Navigate to the last page | | `next()` | Navigate to the next page (no-op at last page) | | `patch(changes)` | Apply one or more query changes atomically — a single recompute for any combination of `limit`, `page`, `search`, `filter`, `sort` | | `prev()` | Navigate to the previous page (no-op at first page) | | `query` | Current state as a `SourceQuery` (`limit`/`page`/`search` only — filter/sort aren't part of the query snapshot) — read-only snapshot; stable between changes | | `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` | | `reset()` | Restore initial config and return to page 1 | | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately | | `setData(data)` | Replace the dataset and reset to page 1 | | `subscribe(listener)` | Subscribe to state changes; returns unsubscribe function | ## `RemoteSource` Methods All methods return `Promise` except `optimisticUpdate` and `subscribe`. | Method / Property | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `dispose()` | Release internal resources, cancel pending requests and refresh interval; idempotent | | `disposed` | `true` after `dispose()` has been called | | `disposalSignal` | `AbortSignal` aborted when `dispose()` is called | | `goTo(page)` | Navigate to page and fetch | | `goToLast()` | Navigate to the last page based on current `total` | | `next()` | Next page (no-op at last page) | | `optimisticUpdate(mutator, options?)` | Apply instant UI update; returns rollback function | | `patch(changes)` | Apply one or more query changes atomically — a single fetch for any combination of `limit`, `page`, `search`, `filter`, `sort` | | `prev()` | Previous page (no-op at first page) | | `query` | Current state as a `RemoteSourceQuery` — read-only snapshot; stable between changes | | `ready(timeout?)` | Resolve when no requests are pending; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` | | `refresh()` | Re-fetch the current query | | `reset()` | Restore initial config and refetch | | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately | | `subscribe(listener)` | Subscribe to state changes; returns unsubscribe function | ### `optimisticUpdate` ```ts optimisticUpdate( mutator: (current: readonly T[]) => readonly T[], options?: { total?: number }, ): () => void // returns rollback function ``` - Applies `mutator` to the current items immediately. - The returned rollback function is a **no-op** once the next successful fetch has settled. - On fetch failure, state is restored to the pre-optimistic items (not empty). - Only one optimistic update can be active at a time — a second call throws. - If `mutator` throws, the optimistic state is **not applied** and no `rollback` is needed — the source remains in its pre-update state. ## `CursorSource` Methods | Method / Property | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `dispose()` | Release internal resources; idempotent | | `disposed` | `true` after `dispose()` has been called | | `disposalSignal` | `AbortSignal` aborted when `dispose()` is called | | `next()` | Advance using `nextCursor` (no-op if none) | | `patch(changes)` | Apply `limit` and/or `search` atomically — a single fetch; resets cursor position | | `prev()` | Go back using `prevCursor` (no-op if none) | | `query` | Current state as a `CursorSourceQuery` — read-only snapshot; stable between changes | | `ready(timeout?)` | Resolve when idle; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` | | `refresh()` | Re-fetch current cursor position | | `reset()` | Clear cursors and fetch from the start | | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await. Resets cursor position. | | `subscribe(listener)` | Subscribe; returns unsubscribe | ## `InfiniteSource` Methods | Method / Property | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dispose()` | Release internal resources; idempotent | | `disposed` | `true` after `dispose()` has been called | | `disposalSignal` | `AbortSignal` aborted when `dispose()` is called | | `loadMore()` | Fetch the next page and append to `current` (no-op when `meta.hasMore === false`) | | `patch(changes)` | Apply `limit` and/or `search` atomically — **clears items immediately** and fetches from page 1 | | `query` | Current state as an `InfiniteSourceQuery` — read-only snapshot; stable between changes | | `ready(timeout?)` | Resolve when idle; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` | | `reset()` | Clear accumulated items **immediately** and fetch from page 1 | | `search(query, opts?)` | Always returns `Promise`. Debounced by default — **clears items immediately**; fetch fires after debounce. Pass `{ immediate: true }` to skip the window. | | `subscribe(listener)` | Subscribe; returns unsubscribe | ## Query Utilities ### `applyQuery` ```ts applyQuery): Promise }>( source: T, changes: Partial, ): Promise ``` Applies a partial `SourceQuery` (`limit`/`page`/`search`) patch to any source that exposes a compatible `patch()` — delegates directly to `source.patch(changes)`. Fires a single fetch or recomputation for any combination of changed fields. No-op when `changes` is empty or all values are unchanged, per each source's own `patch()` implementation. `CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation) — their `patch()` only reads `limit`/`search`, so a `page` field from `decodeQuery()` output is silently ignored on those two source types. **Example:** ```ts import { applyQuery, decodeQuery } from '@vielzeug/sourcerer'; const q = decodeQuery(new URLSearchParams(location.search)); await applyQuery(source, q); ``` --- ## Error Utilities ### `SourcererError` ```ts class SourcererError extends Error { readonly name = 'SourcererError'; get attempt(): number; // retry attempt that produced this error (0-based); defaults to 0 get context(): SourcererErrorContext | undefined; // structured context bag — safe to log static is(err: unknown): err is SourcererError; // Also inherits: .message, .cause, .stack } ``` Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `SourceTimeoutError` and `SourceDisposedError` both extend this class, so a single `instanceof SourcererError` check covers all sourcerer errors. ### `SourceTimeoutError` ```ts class SourceTimeoutError extends SourcererError { readonly name = 'SourceTimeoutError'; readonly timeoutMs: number; // message: 'Source.ready() timed out after Nms' } ``` Thrown by `ready(timeout)` when the timeout expires before the source becomes idle. Also caught by `instanceof SourcererError`. ### `SourceDisposedError` ```ts class SourceDisposedError extends SourcererError { readonly name = 'SourceDisposedError'; // message: 'Source disposed while waiting for ready()' } ``` Thrown by `ready()` when the source is disposed before becoming idle. Also caught by `instanceof SourcererError`. **Example:** ```ts try { await prefetchSource({ fetch: fetchUsers, limit: 20 }); } catch (err) { if (SourcererError.is(err)) { console.error(err.message, err.context, err.cause); } } ``` --- ### `sourceState` ```ts sourceState(source: { readonly current: readonly T[]; readonly meta: { readonly error: SourcererError | null; readonly isLoading: boolean; readonly isSearchPending?: boolean; // optional — treated as false when absent }; }): SourceState ``` Derives a discriminated union from any source. Returns `'loading'` when either `isLoading` or `isSearchPending` is true — so callers see a spinner during the search debounce window as well as during network requests. ```ts type SourceState = | { readonly status: 'loading' } | { readonly error: SourcererError; readonly status: 'error' } | { readonly items: readonly T[]; readonly status: 'success' }; ``` **Example:** ```ts import { sourceState } from '@vielzeug/sourcerer'; const state = sourceState(source); switch (state.status) { case 'loading': return renderSpinner(); case 'error': return renderError(state.error.message); case 'success': return renderList(state.items); } ``` --- ### `itemRange` ```ts itemRange(meta: Readonly): { end: number; start: number } ``` Computes 1-based display item numbers. Returns `{ start: 0, end: 0 }` when `totalItems === 0`. **Example:** ```ts import { itemRange } from '@vielzeug/sourcerer'; const { start, end } = itemRange(source.meta); // page 2 of 20 per page, 150 total -> { start: 21, end: 40 } console.log(`Showing ${start}–${end} of ${source.meta.totalItems}`); ``` ## SSR Prefetch ### `prefetchSource` ```ts prefetchSource( cfg: Omit, 'autoFetch' | 'refreshInterval'>, ): Promise> ``` Fetches the first page server-side, then **disposes the internal source immediately** and returns a serialisable `SourceSnapshot`. **Throws `SourcererError`** if the fetch fails. ```ts type SourceSnapshot = Readonly; ``` **Example:** ```ts import { createRemoteSource, prefetchSource } from '@vielzeug/sourcerer'; // server.ts — fetch and discard the source: const snapshot = await prefetchSource({ fetch: fetchUsers, limit: 20 }); // client.ts — start populated, no loading flash: const source = createRemoteSource({ fetch: fetchUsers, limit: 20, snapshot }); ``` --- ### `prefetchSourceAndKeep` ```ts prefetchSourceAndKeep( cfg: Omit, 'autoFetch' | 'refreshInterval'>, ): Promise; source: RemoteSource }> ``` Fetches the first page and returns both a serialisable `SourceSnapshot` and the **still-live** `RemoteSource`. Use when you need the snapshot for SSR HTML serialisation **and** the live source for subsequent client-side updates — avoiding a double-fetch. **The caller is responsible for calling `source.dispose()`.** **Example:** ```ts import { prefetchSourceAndKeep } from '@vielzeug/sourcerer'; const { snapshot, source } = await prefetchSourceAndKeep({ fetch: fetchUsers, limit: 20 }); // embed snapshot in SSR HTML; hand source to client // caller must dispose when done: source.dispose(); ``` ## Codec Utilities ### `encodeQuery` ```ts encodeQuery( query: SourceQuery | RemoteSourceQuery, ): QueryParams // Record ``` Serializes `filter` and `sort` as JSON when present. Omits `search` when absent. > `filter` and `sort` are serialised with `JSON.stringify`, without a try/catch. A circular object reference throws a native `TypeError` ("Converting circular structure to JSON") straight out of `encodeQuery` — ensure filter/sort values are plain serialisable objects, or wrap the call yourself. > > `encodeQuery` and `decodeQuery` form a round-trip pair: `filter`/`sort` are JSON-stringified on encode and JSON-parsed on decode. Validate/narrow the decoded values before passing them to a source. **Example:** ```ts import { encodeQuery } from '@vielzeug/sourcerer'; const params = encodeQuery({ page: 2, limit: 20, search: 'ada', filter: { role: 'admin' } }); // { page: '2', limit: '20', search: 'ada', filter: '{"role":"admin"}' } new URLSearchParams(params).toString(); ``` --- ### `decodeQuery` ```ts decodeQuery( params: QueryParamsInput | URLSearchParams, options?: { defaultLimit?: number; strict?: boolean }, ): Partial> ``` Accepts either a `Record` or a `URLSearchParams` instance directly. Not generic — `filter`/`sort` on the result are always typed `unknown`. Narrow them with a runtime schema (e.g. Zod) or an explicit cast before use, rather than trying to pass type arguments to `decodeQuery` itself. - `defaultLimit` defaults to `20`. - When `strict: false` (default), malformed `filter`/`sort` JSON is silently dropped. - When `strict: true`, malformed JSON throws. - `search` is omitted from the result when absent (no `search: ''` default). - Array-valued params (`filter[]`, `sort[]`, etc.) use the first element, consistent with `search`. **Example:** ```ts import { applyQuery, decodeQuery } from '@vielzeug/sourcerer'; // Pass URLSearchParams directly — filter/sort come back as `unknown`, narrow before use const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 }); await applyQuery(source, query); ``` ## Types ```ts type Predicate = (value: T, index: number, array: readonly T[]) => boolean; type Sorter = (a: T, b: T) => number; // search is OPTIONAL — omitted when no search is active type SourceQuery = Readonly; // Full set of fields patchable in one atomic recompute on a LocalSource type LocalSourceQuery = Partial | undefined; limit: number; page: number; search: string; sort: Sorter | undefined; }>; type SourceMeta = Readonly; type CursorMeta = Readonly; type InfiniteMeta = Readonly; type ReactiveSource = { readonly current: readonly T[]; readonly disposalSignal: AbortSignal; // aborted when dispose() is called dispose(): void; readonly disposed: boolean; readonly meta: TMeta; subscribe(listener: () => void): () => void; [Symbol.dispose](): void; }; // Returned by mergeSource() — has no meta because parent sources may have different meta shapes type MergedSource = { readonly current: readonly T[]; readonly disposalSignal: AbortSignal; // aborted when dispose() is called dispose(): void; readonly disposed: boolean; subscribe(listener: () => void): () => void; [Symbol.dispose](): void; }; type SourceState = | { readonly status: 'loading' } | { readonly error: SourcererError; readonly status: 'error' } | { readonly items: readonly T[]; readonly status: 'success' }; // Discriminated union — narrow with `context.kind` to access the fields for that source type type SourcererErrorContext = | Readonly | Readonly | Readonly; // Returned by deriveSource() — identical contract to ReactiveSource type DerivedSource = ReactiveSource; type QueryParams = Record; type QueryParamsInput = Record; type DecodeQueryOptions = Readonly; type SourceSnapshot = Readonly; type RetryConfig = { attempts?: number; // default: 0 (no retries) delay?: (attempt: number) => number; // default: exponential backoff }; // PageNavigator — shared navigation interface for page-based sources (local and remote) type PageNavigator = ReactiveSource & { goTo(page: number): Promise; goToLast(): Promise; next(): Promise; patch(changes: Partial): Promise; prev(): Promise; readonly query: SourceQuery; ready(timeout?: number): Promise; reset(): Promise; search(query: string, opts?: SearchOptions): Promise; }; // LocalSourceConfig — config passed to createLocalSource() type LocalSourceConfig = Readonly; filterAsync?: (items: readonly T[], signal: AbortSignal) => Promise; initialData?: readonly T[]; // alternative to positional first arg limit?: number; // default: 20 searchFn?: (items: readonly T[], query: string) => readonly T[]; sort?: Sorter; sortAsync?: (items: readonly T[], signal: AbortSignal) => Promise; }>; type FetchEvent = Readonly; ``` ## Errors ### `SourcererError` See [Error Utilities > `SourcererError`](#sourcererror) above. ### `SourceTimeoutError` ```ts class SourceTimeoutError extends SourcererError { readonly name = 'SourceTimeoutError'; readonly timeoutMs: number; // message: 'Source.ready() timed out after Nms' } ``` Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof SourceTimeoutError` for typed catch blocks: ```ts import { SourceTimeoutError } from '@vielzeug/sourcerer'; try { await source.ready(5000); } catch (err) { if (err instanceof SourceTimeoutError) { console.warn('Source did not load in time:', err.message); } } ``` ### `SourceDisposedError` ```ts class SourceDisposedError extends SourcererError { readonly name = 'SourceDisposedError'; // message: 'Source disposed while waiting for ready()' } ``` Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof SourceDisposedError` to distinguish it from `SourceTimeoutError`: ```ts import { SourceDisposedError, SourceTimeoutError } from '@vielzeug/sourcerer'; try { await source.ready(5000); } catch (err) { if (err instanceof SourceDisposedError) { // source was torn down — skip cleanup } else if (err instanceof SourceTimeoutError) { console.warn('timed out'); } } ``` ### Usage Guide ## Basic Usage `createLocalSource()` manages an in-memory array. All operations are synchronous; methods return resolved promises so code is uniform with remote sources. ```ts import { createLocalSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string; role: 'admin' | 'user' }; const users: User[] = [ { id: 1, name: 'Ada Lovelace', role: 'admin' }, { id: 2, name: 'Grace Hopper', role: 'admin' }, { id: 3, name: 'Linus Torvalds', role: 'user' }, ]; const source = createLocalSource(users, { limit: 2 }); await source.search('ada', { immediate: true }); console.log(source.current); // [{ id: 1, name: 'Ada Lovelace', role: 'admin' }] console.log(source.meta.pageNumber); // 1 console.log(source.meta.totalItems); // 1 ``` ## Local Source `createLocalSource()` manages an in-memory array. Recomputation is synchronous unless `filterAsync` or `sortAsync` is configured. ```ts import { createLocalSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string; role: 'admin' | 'user' }; const source = createLocalSource(users, { limit: 10 }); ``` ### Config options ```ts createLocalSource(data, { limit: 10, // items per page (default: 20) debounceMs: 300, // debounce delay for source.search() (default: 300) filter: (u) => u.active, // initial synchronous filter predicate sort: (a, b) => a.name.localeCompare(b.name), // initial sorter searchFn: (items, query) => items.filter(/* custom match */), // override default search // Async variants — enable Web Worker offloading via @vielzeug/familiar: filterAsync: async (items, signal) => items.filter(/* expensive filter */), sortAsync: async (items, signal) => [...items].sort(/* expensive sort */), }); ``` `filterAsync` and `sortAsync` run after their synchronous counterparts. They set `meta.isLoading = true` during computation and accept an `AbortSignal` — a new call aborts any running async computation. ### Mutations ```ts await source.search('ada', { immediate: true }); // cancels debounce and awaits the result void source.search('ada'); // debounced — resolves after debounceMs + recompute await source.patch({ filter: (u) => u.role === 'admin', sort: (a, b) => a.name.localeCompare(b.name) }); await source.patch({ search: 'ada', limit: 5 }); // apply multiple changes in one recompute await source.goTo(2); await source.setData(newUsers); // replace entire dataset await source.reset(); // restore initial filter/sort, reset to page 1 ``` ### Restoring from URL state Use `applyQuery()` + `decodeQuery()` to restore URL-decoded state in a single atomic recompute. ```ts import { applyQuery, decodeQuery } from '@vielzeug/sourcerer'; const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 10 }); await applyQuery(source, query); ``` ## Remote Source `createRemoteSource()` wraps an async `fetch` function and manages page state, loading, errors, debounced search, concurrency, and request cancellation. ```ts import { createRemoteSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string }; type UserFilter = { role?: 'admin' | 'user' }; type UserSort = { by: 'name' | 'id'; dir: 'asc' | 'desc' }; const source = createRemoteSource({ fetch: async ({ filter, limit, page, search, sort }, signal) => { const res = await fetch(`/api/users?page=${page}&limit=${limit}`, { signal }); return res.json(); // { items: User[], total: number } }, filter: { role: 'user' }, sort: { by: 'name', dir: 'asc' }, limit: 25, // autoFetch: true (default — fetches on creation) }); await source.ready(); ``` ### Config options ```ts createRemoteSource({ fetch, // required: (query, AbortSignal) => Promise limit: 25, // items per page (default: 20) debounceMs: 300, // debounce for source.search() (default: 300) filter, // initial filter value sort, // initial sort value autoFetch: true, // fetch on creation (default: true) queryKey: (q) => `${q.page}-${q.limit}`, // custom deduplication key staleTime: 5000, // skip re-fetch if last fetch was within this many ms (default: 0) refreshInterval: 30_000, // auto re-fetch every N ms; cancelled on dispose() retry: { attempts: 2, delay: (n) => n * 1000 }, // retry on failure onFetch: (event) => logger.info(event), // telemetry callback snapshot, // pre-populate from SSR snapshot }); ``` `staleTime` compares the **query key** — navigating to a different page always fetches even when the previous result is still within the stale window. ### The `fetch` callback The `fetch` function receives the current query and an `AbortSignal`. Pass the signal to your HTTP client to enable automatic cancellation of superseded requests. ```ts fetch: async ({ filter, limit, page, search, sort }, signal) => { const res = await fetch('/api/items', { method: 'POST', signal, body: JSON.stringify({ filter, limit, page, search, sort }), headers: { 'Content-Type': 'application/json' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }, ``` ### Mutations ```ts await source.search('ada', { immediate: true }); void source.search('ada'); // debounced — resolves after debounceMs + fetch await source.patch({ search: 'ada', filter: { role: 'admin' }, limit: 25 }); // atomic — one fetch await source.goTo(3); await source.next(); await source.prev(); await source.goToLast(); await source.reset(); // restore initial config and refetch await source.refresh(); // re-fetch current query ``` ### Restoring from URL state `applyQuery()` is a no-op when `changes` is empty — safe to call on every page load. ```ts import { applyQuery, decodeQuery } from '@vielzeug/sourcerer'; const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 }); await applyQuery(source, query); ``` ### Optimistic updates Apply a mutator immediately so the UI reflects the change before the server confirms. ```ts const rollback = source.optimisticUpdate((current) => current.filter((u) => u.id !== deletedId), { total: source.meta.totalItems - 1, }); try { await api.users.delete(deletedId); await source.refresh(); // server confirms — optimistic state cleared automatically } catch { rollback(); // server rejected — restore previous items } ``` - The rollback function is a **no-op** once the next successful fetch has settled. - On fetch failure, the pre-optimistic items are restored (not an empty array). - Only one optimistic update can be active at a time — a second call throws. ### Concurrency and request deduplication - Superseded requests (different query key) are **aborted** automatically via `AbortSignal`. - Duplicate requests (same query key, fired while one is in-flight) are **joined** — only one network call is made. - Stale responses are **discarded** — only the most recent query key's response is applied to state. ### `ready()` — waiting for initial load ```ts const source = createRemoteSource({ fetch, autoFetch: true }); await source.ready(); // resolves when pendingCount === 0 and no debounce timer is active await source.ready(5000); // rejects with timeout error after 5 s if still loading ``` Use `ready()` in server-side rendering, test setup, or any flow that needs initial data before rendering. ## Cursor Source `createCursorSource()` is for APIs that return opaque cursor tokens instead of page numbers — common with relay-style GraphQL, DynamoDB, and Stripe. ```ts import { createCursorSource } from '@vielzeug/sourcerer'; const source = createCursorSource({ fetch: async ({ after, before, limit, search }, signal) => { const res = await fetch(`/api/items?after=${after ?? ''}&limit=${limit}`, { signal }); const data = await res.json(); return { items: data.items, nextCursor: data.nextCursor, // string | undefined prevCursor: data.prevCursor, // string | undefined total: data.total, // optional }; }, limit: 20, }); await source.ready(); console.log(source.meta.hasNextPage, source.meta.hasPrevPage); await source.next(); // advance to next page using nextCursor await source.prev(); // go back using prevCursor await source.reset(); // clear cursors and refetch from the start ``` `next()` and `prev()` are no-ops if there is no cursor in that direction. ## Infinite Source `createInfiniteSource()` accumulates items in `source.current` as the user loads more pages. Searching and `reset()` clear the accumulator and start fresh from page 1. ```ts import { createInfiniteSource } from '@vielzeug/sourcerer'; const source = createInfiniteSource({ fetch: async ({ limit, page, search }, signal) => { const res = await fetch(`/api/posts?page=${page}&limit=${limit}`, { signal }); return res.json(); // { items: Post[], total: number } }, limit: 20, }); await source.ready(); console.log(source.current); // first page of posts console.log(source.meta.hasMore); // true if more pages exist console.log(source.meta.isLoadingMore); // true only during loadMore() fetches await source.loadMore(); // fetches page 2 and appends to source.current await source.loadMore(); // fetches page 3, appends again await source.reset(); // clear all, restart from page 1 ``` `loadMore()` is a no-op when `meta.hasMore` is `false`. `meta.isLoadingMore` is `true` only during `loadMore()` — distinct from `meta.isLoading` which is `true` during `reset()` and the initial fetch. ### Restoring from URL state Use `applyQuery()` + `decodeQuery()` to restore `limit` and `search`. `patch()` clears accumulated items and refetches from page 1 if any value changed. ```ts import { applyQuery, decodeQuery } from '@vielzeug/sourcerer'; const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 }); await applyQuery(source, { limit: query.limit, search: query.search }); ``` ## Error Handling All sources expose `meta.error` as a `SourcererError | null`. `SourcererError` extends `Error` and carries structured context for logging and display: ```ts if (source.meta.error) { console.error(source.meta.error.message); // human-readable message console.error(source.meta.error.cause); // original thrown value console.error(source.meta.error.context); // structured context bag (query fields, kind, etc.) } ``` For simpler branching, use `sourceState()`: ```ts import { sourceState } from '@vielzeug/sourcerer'; const state = sourceState(source); switch (state.status) { case 'loading': return renderSpinner(); case 'error': return renderError(state.error.message); case 'success': return renderList(state.items); } ``` `sourceState()` works with any source type. ## Read Model Every source exposes `current`, `meta`, and `subscribe`. ```ts source.current; // readonly T[] — items on the current page (or all accumulated for infinite) source.meta; // SourceMeta | CursorMeta | InfiniteMeta — pagination and status snapshot ``` ### `SourceMeta` ```ts type SourceMeta = Readonly; ``` Use `itemRange()` to compute display-level item numbers: ```ts import { itemRange } from '@vielzeug/sourcerer'; const { start, end } = itemRange(source.meta); // e.g. "Showing 21–40 of 150" console.log(`Showing ${start}–${end} of ${source.meta.totalItems}`); ``` ### `CursorMeta` ```ts type CursorMeta = Readonly; ``` ### `InfiniteMeta` ```ts type InfiniteMeta = Readonly; ``` `meta` is replaced with a new object reference on every change. Both `current` and `meta` are stable between changes — safe to compare with `===` to detect updates. ## Subscriptions and Disposal All sources expose a framework-agnostic `subscribe` method that returns an unsubscribe function: ```ts const unsubscribe = source.subscribe(() => { render(source.current, source.meta); }); // later: unsubscribe(); ``` All sources implement `[Symbol.dispose]()`. Use the TC39 `using` declaration to auto-dispose on scope exit: ```ts { using source = createLocalSource(data, { limit: 10 }); // source is automatically disposed when the block exits } ``` ## URL Query Param Sync `encodeQuery()` serializes source state to flat URL-safe string params. `decodeQuery()` parses URL params (or a `URLSearchParams` instance) back into a partial query object. ```ts import { decodeQuery, encodeQuery } from '@vielzeug/sourcerer'; // Serialize current state const params = encodeQuery(source.query); // -> { page: '2', limit: '25', search: 'ada', filter: '{"role":"admin"}' } // Restore from URLSearchParams directly const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 }); await applyQuery(source, query); ``` `decodeQuery` is fault-tolerant by default — malformed `filter`/`sort` JSON is silently dropped. Pass `{ strict: true }` to throw instead. `search` is omitted from both `source.query` and `decodeQuery()` output when no search is active (no `search: ''` noise in URLs). ## SSR Prefetch `prefetchSource()` fetches one page on the server and returns a serialisable `SourceSnapshot`. Pass the snapshot to `createRemoteSource({ snapshot })` on the client to skip the initial loading flash. ```ts // server.ts import { prefetchSource } from '@vielzeug/sourcerer'; const snapshot = await prefetchSource({ fetch: fetchUsers, limit: 20 }); // snapshot is JSON-serialisable: { items, total, page, search? } // client.ts import { createRemoteSource } from '@vielzeug/sourcerer'; const source = createRemoteSource({ fetch: fetchUsers, limit: 20, snapshot }); // source starts populated — no loading flash ``` `prefetchSource()` throws a `SourcererError` if the fetch fails. Handle it server-side before embedding the snapshot. To get both the snapshot and a live source without a double-fetch, use `prefetchSourceAndKeep()`: ```ts import { prefetchSourceAndKeep } from '@vielzeug/sourcerer'; const { snapshot, source } = await prefetchSourceAndKeep({ fetch: fetchUsers, limit: 20 }); // Use snapshot for SSR HTML embedding, source for subsequent client-side updates // Caller is responsible for calling source.dispose() source.dispose(); ``` ## Framework Integration ```tsx [React] import { useMemo, useSyncExternalStore } from 'react'; import { createLocalSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string }; function UsersList({ users }: { users: User[] }) { const source = useMemo(() => createLocalSource(users, { limit: 10 }), [users]); const current = useSyncExternalStore(source.subscribe, () => source.current); const meta = useSyncExternalStore(source.subscribe, () => source.meta); return ( <> source.search(e.target.value)} placeholder="Search" /> {current.map((u) => ( {u.name} ))} source.prev()} disabled={meta.pageNumber Prev source.next()} disabled={meta.pageNumber >= meta.pageCount}> Next ); } ``` ```ts [Vue 3] import { onUnmounted, shallowRef } from 'vue'; import { createLocalSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string }; const source = createLocalSource(users, { limit: 10 }); const state = shallowRef({ current: source.current, meta: source.meta }); const stop = source.subscribe(() => { state.value = { current: source.current, meta: source.meta }; }); onUnmounted(stop); ``` ```svelte [Svelte] import { onDestroy } from 'svelte'; import { createLocalSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string }; const source = createLocalSource(users, { limit: 10 }); let current = source.current; let meta = source.meta; const stop = source.subscribe(() => { current = source.current; meta = source.meta; }); onDestroy(stop); source.search(e.currentTarget.value)} /> {#each current as user} {user.name} {/each} source.prev()} disabled={meta.pageNumber Prev source.next()} disabled={meta.pageNumber >= meta.pageCount}>Next ``` ## Working with Other Vielzeug Libraries ### With Courier ```ts import { createApi } from '@vielzeug/courier'; import { createRemoteSource } from '@vielzeug/sourcerer'; const api = createApi({ baseUrl: '/api' }); const source = createRemoteSource({ fetch: async ({ filter, limit, page, search, sort }, signal) => api.get('/issues', { query: { filter, limit, page, search, sort }, signal }), limit: 25, }); ``` ### With Ripple Subscribe to the source and drive Ripple signals from the callback: ```ts import { effect, signal, store } from '@vielzeug/ripple'; import { createLocalSource } from '@vielzeug/sourcerer'; const source = createLocalSource(users, { limit: 10 }); const items = signal([]); const meta = signal(source.meta); // Drive Ripple signals from the source const unsub = source.subscribe(() => { items.value = source.current; meta.value = source.meta; }); const controls = store({ query: '' }); effect(() => { void source.search(controls.value.query); // debounced — re-runs on every query change }); // items.value and meta.value stay in sync automatically ``` ## Best Practices - Use `search(q, { immediate: true })` for form submit actions; use `search(q)` (debounced) for keypress flows. Both return `Promise`. - Use `patch({ search, filter, sort })` when you need to apply multiple query changes in a single fetch. - Pass the `AbortSignal` from the `fetch` callback to your HTTP client so superseded requests are cancelled. - Call `ready()` in server-side rendering or test setup — not in every render cycle. - Always call the unsubscribe function returned by `subscribe()` when the component is torn down. - For URL sync, use `decodeQuery()` + `applyQuery()` rather than reconstructing source state from params manually. - Use `staleTime` with `refreshInterval` for stale-while-revalidate patterns on dashboards. - Only one `optimisticUpdate()` can be active at a time — always handle the thrown error or check before calling. - When using `decodeQuery()`, validate the parsed `filter` and `sort` with a type guard before passing to the server — they are returned as-is without runtime validation. - For infinite sources, pass `{ limit: query.limit, search: query.search }` to `applyQuery()` for URL state sync — `page` is not restorable since items accumulate across pages. ### Examples ## Examples - [Local Pagination and Filtering](./examples/local-pagination-and-filtering.md) - [Remote Search with URL State](./examples/remote-search-with-url-state.md) - [Cursor-Based Pagination](./examples/cursor-based-pagination.md) - [Infinite Scroll](./examples/infinite-scroll.md) - [Reactive Controls with Ripple](./examples/sourcerer-with-ripple.md) - [Remote Data with Courier](./examples/sourcerer-with-courier.md) - [URL-Synced List with Wayfinder](./examples/sourcerer-with-wayfinder.md) - [Framework Integration](./examples/framework-integration.md) ### REPL Examples - Cursor Source (id: `cursor-source`) - deriveSource & mergeSource (id: `derive-merge`) - encodeQuery & decodeQuery (id: `encode-decode-query`) - Error Handling (id: `error-handling`) - Infinite Source (id: `infinite-source`) - Source Lifecycle (id: `lifecycle`) - Local Source (id: `local-source`) - LocalSource patch() with filter/sort (id: `local-source-patch`) - Presets (filterContains, filterEquals, filterRange, sortBy) (id: `presets`) - Remote Source (id: `remote-source`) - sourceState & SourceTimeoutError (id: `source-state`) --- ## @vielzeug/spell **Category:** validation **Keywords:** schema, validation, parsing, json-schema, locale, typescript, descriptors **Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, setMessages, setLogger, resetMessages (+1 more) **Related:** forge, courier, vault ### Overview ## Why Spell? Spell keeps runtime validation, static inference, and schema introspection in one API. You can use the namespace form for ergonomics or the `sXxx` exports for tree shaking. Descriptor and JSON Schema output make Spell useful at API boundaries, build tooling, and documentation layers. This example shows the difference between manual branching and a single reusable schema. ```ts // Before function parseUserBefore(value: unknown) { if (typeof value !== 'object' || value === null) throw new Error('Expected object'); const candidate = value as Record; if (typeof candidate.email !== 'string' || !candidate.email.includes('@')) { throw new Error('Expected valid email'); } if (typeof candidate.role !== 'string' || !['admin', 'editor', 'viewer'].includes(candidate.role)) { throw new Error('Expected valid role'); } return { email: candidate.email, role: candidate.role, }; } // After import { s } from '@vielzeug/spell'; const User = s.object({ email: s.string().email(), role: s.enum(['admin', 'editor', 'viewer'] as const), }); const user = User.parse({ email: 'ada@example.com', role: 'admin' }); ``` | Feature | Spell | Zod | Yup | | ----------------- | ------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~62 kB | ~14 kB | | Type inference | `Infer` | | Partial | | Coercion API | `s.coerce.*` | | | | Async validation | `.validate()` | | | | Error flattening | `flatten()` + `flattenFirst()` | | Partial | | Zero dependencies | | | | **Use Spell when** you want a fluent schema API with strong TypeScript inference, structured errors, and zero dependencies. **Consider alternatives when** you are already standardized on another validator ecosystem and migration cost outweighs the API benefits. ## Installation Use your workspace package manager to add Spell. ```sh [pnpm] pnpm add @vielzeug/spell ``` ```sh [npm] npm install @vielzeug/spell ``` ```sh [yarn] yarn add @vielzeug/spell ``` ## Quick Start Start with a schema, then parse unknown input and use the inferred output type everywhere else. ```ts import { s, type Infer } from '@vielzeug/spell'; const User = s .object({ email: s.string().email(), name: s.string().min(1), role: s.enum(['admin', 'editor', 'viewer'] as const), }) .relaxed(); // allow extra keys — omit for strict-mode (default) type User = Infer; const payload: unknown = { email: 'ada@example.com', name: 'Ada', role: 'admin', team: 'platform', }; const user = User.parse(payload); ``` ## Features - Namespace and tree-shakeable schema builders. - Sync and async parsing with `parse()`, `safeParse()`, `parseAsync()`, and `safeParseAsync()`. - Unified `validate()` for both sync and async custom rules; boolean/string shorthand supported. - Wrapper modes for `optional`, `nullable`, `nullish`, `default`, `catch`, and `required`. - Descriptor serialization with `toDescriptor()` and JSON Schema export via `descriptorToJsonSchema()`. - Message overrides via `setMessages()` and logger routing via `setLogger()`. - Standalone validators for sizes, numeric ranges, and common string formats. - Structured errors with flattened, formatted, and best-match union diagnostics. - Object parsing and error formatting hardened against prototype-pollution-style keys. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Forge](/forge/) — typed form state that uses Spell schemas as its validation layer - [Courier](/courier/) — HTTP client for validating request and response payloads at service boundaries - [Vault](/vault/) — unified storage API that accepts Spell schemas to type-gate persisted data ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------- | | `s` | Namespace of all schema builders (`s.string()`, `s.object()`, etc.) | Sync setup | All builders are accessed via this single export. | | `Schema.parse()` / `safeParse()` | Validate synchronously; `parse()` throws, `safeParse()` returns tagged result | Sync | See `Schema` class section below. | | `s.coerce.*` | Coerce string-like input before validation | Sync setup | Coercion changes the accepted input type, not only the output type. | | `Schema.parseAsync()` / `safeParseAsync()` | Validate including async `validate()` callbacks | Async | Required when any nested rule uses an async `validate()` callback. | | `descriptorToJsonSchema()` | Convert a `SchemaDescriptor` to JSON Schema | Sync setup | Uses `toDescriptor()` output, not custom transforms. | | `schemaToJsonSchema()` | Convert a `Schema` instance directly to JSON Schema | Sync setup | Calls `toDescriptor()` internally; same limitations apply. | | `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger | Sync setup | `setMessages()` replaces the active message set each call. | | `SpellValidationError` | Inspect validation failures | Sync/async failures | `format()` returns nested objects, `flatten()` returns path arrays. | | `prependIssuePath()` | Prefix a path segment to an array of issues | Sync | Use inside custom parsers that delegate to inner schemas. | ## Package Entry Point | Import path | Format | Notes | | ----------------- | ----------- | ------------------------------------------------------------ | | `@vielzeug/spell` | ESM and CJS | Public entry point for every export documented on this page. | ## Export Inventory Use this table to scan every runtime export. | Category | Exports | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Classes | `Schema`, `PipeSchema`, `SpellError`, `SpellValidationError` | | Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages` | | Descriptor helpers | `descriptorToJsonSchema`, `schemaToJsonSchema` | | Pure validators | `hasMaxLength`, `hasMinLength`, `isArray`, `isBoolean`, `isDate`, `isInteger`, `isMultipleOf`, `isNegative`, `isNonNegative`, `isNullOrUndefined`, `isNumber`, `isPositive`, `isString`, `isInRange` | | String format validators | `isBase64`, `isBase64url`, `isCuid`, `isCuid2`, `isDuration`, `isEmail`, `isEmoji`, `isHex`, `isHexColor`, `isIp`, `isIsoDate`, `isIsoDateTime`, `isJwt`, `isNanoid`, `isNumeric`, `isSemver`, `isSlug`, `isTime`, `isUlid`, `isUrl`, `isUuid` | | Namespace | `s` | | Schema builders (via `s`) | `s.string()`, `s.number()`, `s.object()`, `s.array()`, `s.union()`, `s.variant()`, `s.coerce.*`, `s.enum()`, `s.tuple()`, `s.record()`, `s.map()`, `s.set()`, `s.lazy()`, `s.literal()`, `s.and()`, `s.or()`, `s.instanceof()`, `s.bigint()`, `s.boolean()`, `s.date()`, `s.null()`, `s.undefined()`, `s.unknown()`, `s.never()` | ## Factories and Namespace ### `s` Use the namespace form when you want all builders behind one import. ```ts const s: { and: typeof sAnd; any: typeof sAny; array: typeof sArray; bigint: typeof sBigint; boolean: typeof sBoolean; coerce: typeof sCoerce; date: typeof sDate; enum: typeof sEnum; instanceof: typeof sInstanceof; intersect: typeof sIntersect; lazy: typeof sLazy; literal: typeof sLiteral; map: typeof sMap; never: typeof sNever; null: typeof sNull; number: typeof sNumber; object: typeof sObject; or: typeof sOr; record: typeof sRecord; set: typeof sSet; string: typeof sString; tuple: typeof sTuple; undefined: typeof sUndefined; union: typeof sUnion; unknown: typeof sUnknown; variant: typeof sVariant; }; ``` **Returns:** A namespace object that exposes the same builders as the tree-shakeable `sXxx` exports. Use the namespace when you want one import in application code. ```ts import { s } from '@vielzeug/spell'; const Session = s.object({ expiresAt: s.date(), token: s.string().min(1), }); ``` --- ### Schema builders (via `s`) All schema builders are accessed as methods on the `s` object — they are not individually importable. Access all builders through the `s` namespace: ```ts import { s } from '@vielzeug/spell'; const Filter = s.union( s.object({ type: s.enum(['tag'] as const), value: s.array(s.string().min(1)) }), s.object({ type: s.enum(['owner'] as const), value: s.string().email() }), ); ``` Builder reference: | Builder | Returns | Notes | | ----------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `s.any()` | `Schema` | Accepts any value. | | `s.unknown()` | `Schema` | Accepts any value and keeps `unknown`. | | `s.never()` | `NeverSchema` | Always fails. | | `s.null()` | `LiteralSchema` | Useful inside unions. | | `s.undefined()` | `LiteralSchema` | Useful inside unions. | | `s.string()` | `StringSchema` | String constraints and string format helpers. | | `s.number()` | `NumberSchema` | Numeric range, integer, sign, and multiplicity helpers. | | `s.boolean()` | `BooleanSchema` | Boolean parsing and coercion helpers. | | `s.bigint()` | `BigIntSchema` | Integer boundaries for `bigint`. Constraints are runtime-only — `toDescriptor()` warns and does not serialize `min()`, `max()`, etc. | | `s.date()` | `DateSchema` | Date instance validation and range helpers. | | `s.literal(value)` | `LiteralSchema` | Exact primitive matching. | | `s.enum(values)` | `EnumSchema` | Fixed string union from a readonly tuple. | | `s.array(schema)` | `ArraySchema` | Element validation plus min/max/length/nonEmpty. | | `s.tuple(items)` | `TupleSchema` | Fixed positions with typed output. | | `s.object(shape)` | `ObjectSchema` | Strict object parsing by default. | | `s.record(key, val)` | `RecordSchema` | String-keyed record validation. | | `s.set(schema)` | `SetSchema` | Set size and element validation. | | `s.map(key, val)` | `MapSchema` | Map entry validation. | | `s.union(...items)` | `UnionSchema` | First successful branch wins. | | `s.or(a, b)` | `UnionSchema` | Alias for `s.union()` with exactly two schemas. | | `s.and(a, b)` | `IntersectSchema` | Alias for `s.intersect()` with two schemas. | | `s.intersect(...items)` | `IntersectSchema` | Merges compatible outputs deeply and safely. | | `s.variant(key, map)` | `VariantSchema` | Discriminated object union. Async field validators on branch objects are silently skipped — use `s.object()` with `parseAsync()` directly if you need async branch-field rules. | | `s.lazy(getter)` | `LazySchema` | Recursive schema definitions. | | `s.instanceof(cls)` | `InstanceOfSchema` | Runtime class instance checks. | --- ### `s.coerce` Use `s.coerce` when input arrives as strings or loosely typed values. ```ts s.coerce: { bigint(): BigIntSchema; boolean(): BooleanSchema; date(): DateSchema; number(): NumberSchema; string(): StringSchema; }; ``` **Returns:** Coercing variants of the primitive schemas. Use coercion at API and form boundaries, then keep the parsed output typed afterwards. ```ts import { s } from '@vielzeug/spell'; const Page = s.coerce.number().int().positive().default(1); const PublishedAt = s.coerce.date().nullable(); ``` ## Core Classes ### `Schema` Use `Schema` when you need the shared methods that every schema instance exposes. ```ts class Schema { parse(value: unknown): Output; safeParse(value: unknown): ParseResult; parseAsync(value: unknown): Promise; safeParseAsync(value: unknown): Promise>; validate(fn: (value: Output, ctx: CheckContext) => ValidateResult | Promise): this; refine(predicate: (value: Output) => boolean, message?: MessageFn): this; optional(): WrapperSchema; nullable(): WrapperSchema; nullish(): WrapperSchema; required(): Schema, Exclude>; default(defaultValue: Output | (() => Output)): this; catch(fallback: Output | (() => Output)): this; transform(fn: (value: Output) => NewOutput): Schema; preprocess(fn: (value: unknown) => unknown): this; pipe(next: Schema): Schema; label(description: string): this; toDescriptor(): SchemaDescriptor; toJsonSchema(): JsonSchema; assert(value: unknown, label?: string): asserts value is Output; walk(visitor: SchemaWalker): R | null; equals(other: AnySchema): boolean; get description(): string | undefined; get isOptional(): boolean; get isNullable(): boolean; get kind(): string; is(value: unknown): value is Output; } ``` **Returns:** Parsed values, schema wrappers, transformed schemas, or schema metadata depending on the method. Use `Schema` methods to choose how validation failures should move through your code. ```ts import { s } from '@vielzeug/spell'; const Username = s.string().trim().min(3).label('Username'); Username.assert('ada'); const descriptor = Username.toDescriptor(); const sameShape = Username.equals(s.string().trim().min(3).label('Username')); console.log(descriptor.description, sameShape); ``` Use this table to decide which methods to call most often. | Method family | What it does | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `parse*` | Returns data or throws / returns an error object. | | `validate` / `refine` | Adds custom validation. `validate()` accepts sync or async callbacks and boolean/string shorthands. `refine()` is the predicate-only alias for boolean predicates. | | `optional` / `nullable` / `nullish` / `required` | Changes missing-value semantics. | | `default` / `catch` | Supplies fallback output on `undefined` or validation failure. | | `transform` / `preprocess` / `pipe` | Converts input before or after validation. | | `label` / `description` | Adds a human-readable description that also appears in descriptors. | | `is(value)` | Type-predicate guard. Returns `true` if `value` passes `safeParse()`. | | `kind` | Read-only string identifier for this schema's type (e.g. `'string'`, `'object'`). | | `equals(other)` | Structural equality check comparing shape, constraints, and annotations (not pre/postprocessors). | | `toDescriptor` / `toJsonSchema` / `walk` / `equals` | Supports tooling and schema introspection. `toDescriptor()` emits a dev warning if the schema has preprocessors (e.g. `trim()`, `coerce`), since they cannot survive a round-trip. `walk()` returns `null` if no visitor handler matches and no `unknown` fallback is defined. | ### `Schema.validate()` Use `validate()` to add a custom synchronous or asynchronous rule to any schema. ```ts validate(fn: (value: Output, ctx: CheckContext) => ValidateResult | Promise): this ``` **Parameters** | Name | Type | Notes | | ---- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `fn` | `(value: Output, ctx: CheckContext) => ValidateResult \| Promise` | Sync or async. Return `false` or a `string` to fail; `true`, `null`, or `void` to pass. | **Returns:** `this` (fluent) The callback receives the parsed `value` and a `ctx` object with `addIssue()`. All three of the following forms are equivalent: ```ts import { s } from '@vielzeug/spell'; // Boolean shorthand const EvenNumber = s.number().validate((n) => n % 2 === 0); // String message shorthand (falsy condition || message) const Email = s.string().validate((v) => v.includes('@') || 'Must be a valid email'); // Explicit ctx.addIssue() for multiple issues or custom codes const Signup = s.object({ email: s.string(), username: s.string() }).validate((v, ctx) => { if (v.email === v.username) { ctx.addIssue({ code: 'custom', message: 'Email and username must differ', path: ['email'] }); } }); ``` Async callbacks are awaited only in `parseAsync()` / `safeParseAsync()`. Passing an async callback to `validate()` and calling `parse()` synchronously silently skips the async rule. Use `parseAsync()` whenever any `validate()` callback may return a `Promise`. --- ### `Schema.refine()` Use `refine()` as the predicate-only alias for boolean validation. Familiar for users coming from other schema libraries. ```ts refine(predicate: (value: Output) => boolean, message?: MessageFn): this ``` **Parameters** | Name | Type | Notes | | ----------- | ------------------------------ | -------------------------------------------------------------- | | `predicate` | `(value: Output) => boolean` | Return `false` to fail. | | `message` | `MessageFn` | Optional. Static string or function that receives `{ value }`. | **Returns:** `this` (fluent) For context-based checks that call `ctx.addIssue()`, use `validate()` directly. ```ts import { s } from '@vielzeug/spell'; const PositiveNumber = s.number().refine( (n) => n > 0, () => 'Must be positive', ); PositiveNumber.parse(5); // 5 // PositiveNumber.parse(-1); // throws ``` --- ### `ObjectSchema.merge()` Use `merge()` to combine two object schemas into one. Fields from the right-hand schema override same-named fields from the left. ```ts merge(other: ObjectSchema): ObjectSchema ``` **Returns:** A new `ObjectSchema` whose shape is the left shape plus the right shape (right wins on conflict). The merged schema inherits the **right-hand schema's strict/relaxed mode**. A strict right-hand schema produces a strict merge; a relaxed right-hand schema produces a relaxed merge. ```ts import { s } from '@vielzeug/spell'; const Base = s.object({ id: s.string() }); const Extra = s.object({ name: s.string() }).relaxed(); const Merged = Base.merge(Extra); Merged.parse({ extra: 'ok', id: '1', name: 'Ada' }); // relaxed — extra keys allowed ``` --- ### `ObjectSchema.keyof()` Use `keyof()` to get a union schema of the object's own string keys. ```ts keyof(): UnionSchema, ...LiteralSchema[]]> ``` **Returns:** A `UnionSchema` whose output is the union of the object's literal key strings. Use it when you need to validate that a string is one of the known keys of a schema. ```ts import { s } from '@vielzeug/spell'; const Product = s.object({ id: s.string(), price: s.number() }); const ProductKey = Product.keyof(); ProductKey.parse('id'); // 'id' ProductKey.parse('price'); // 'price' // ProductKey.parse('name'); // throws ``` --- ### `ObjectSchema.defaults()` Returns a fully default-filled object by parsing `{}` against the schema. Every required field must have a `.default()` value set; fields without defaults cause a `SpellValidationError` to be thrown. ```ts defaults(): InferObject ``` **Returns:** The parsed object with all default values applied. ```ts import { s } from '@vielzeug/spell'; const Config = s.object({ host: s.string().default('localhost'), port: s.number().default(3000), }); Config.defaults(); // { host: 'localhost', port: 3000 } ``` Use `.partial()` before `.defaults()` if all fields should be optional: ```ts const schema = s.object({ name: s.string() }).partial(); schema.defaults(); // {} ``` --- ### `ObjectSchema.partialDefaults()` Returns a partial object containing only the fields that have a default value set. Fields without a `.default()` or `.catch()` are silently omitted rather than throwing. ```ts partialDefaults(): Partial> ``` **Returns:** A partial object with only the defaulted fields filled in. Use `partialDefaults()` for pre-filling forms where only some fields have defaults. ```ts import { s } from '@vielzeug/spell'; const Form = s.object({ name: s.string(), role: s.string().default('viewer'), }); Form.partialDefaults(); // { role: 'viewer' } — name is omitted ``` --- ### `ObjectSchema.requiredFields()` Returns the keys of fields that are required (not optional and not nullish). ```ts requiredFields(): (keyof T & string)[] ``` **Returns:** An array of field key strings that do not accept `undefined`. ```ts import { s } from '@vielzeug/spell'; const User = s.object({ id: s.number(), name: s.string().optional() }); User.requiredFields(); // ['id'] ``` --- ### `ObjectSchema.optionalFields()` Returns the keys of fields that are optional (accept `undefined`). ```ts optionalFields(): (keyof T & string)[] ``` **Returns:** An array of field key strings that accept `undefined`. ```ts import { s } from '@vielzeug/spell'; const User = s.object({ id: s.number(), name: s.string().optional() }); User.optionalFields(); // ['name'] ``` --- ### `PipeSchema` Use `pipe()` when one schema should feed another schema instead of a custom transform. ```ts class PipeSchema extends Schema { readonly from: Schema; readonly to: Schema; } ``` **Returns:** A schema that parses with `from`, then validates the result with `to`. Use `pipe()` when the second step should reuse another schema's constraints and error messages. ```ts import { s } from '@vielzeug/spell'; const Slug = s.string().trim().pipe(s.string().slug()); Slug.parse('release-notes'); ``` ## Descriptor and Helper Functions ### `descriptorToJsonSchema()` Use `descriptorToJsonSchema()` when another tool expects JSON Schema instead of Spell descriptors. ```ts descriptorToJsonSchema(descriptor: SchemaDescriptor): JsonSchema ``` **Parameters** | Name | Type | Notes | | ------------ | ------------------ | -------------------------------------------- | | `descriptor` | `SchemaDescriptor` | Any descriptor produced by `toDescriptor()`. | **Returns:** `JsonSchema` Use it to generate OpenAPI components, editor tooling, or external validation contracts. ```ts import { descriptorToJsonSchema, s } from '@vielzeug/spell'; const schema = s.object({ id: s.string().uuid(), total: s.number().nonNegative(), }); const jsonSchema = descriptorToJsonSchema(schema.toDescriptor()); ``` --- ### `schemaToJsonSchema()` Use `schemaToJsonSchema()` as a shorthand when you have a schema instance and want JSON Schema without calling `toDescriptor()` explicitly. ```ts schemaToJsonSchema(schema: AnySchema): JsonSchema ``` **Parameters** | Name | Type | Notes | | -------- | ----------- | --------------------------------------------- | | `schema` | `AnySchema` | Any schema instance from `s.*` or `new XxxSchema(...)`. | **Returns:** `JsonSchema` Internally calls `schema.toDescriptor()` then `descriptorToJsonSchema()`. The same limitations apply: schemas with preprocessors (`trim()`, `coerce.*`) emit a dev warning and may not round-trip exactly. ```ts import { schemaToJsonSchema, s } from '@vielzeug/spell'; const Product = s.object({ id: s.string().uuid(), name: s.string().min(1), }).label('Product'); const jsonSchema = schemaToJsonSchema(Product); console.log(jsonSchema.title); // 'Product' ``` --- ### `setMessages()` Use `setMessages()` to override any subset of the global validation message catalog. ```ts setMessages(messages: DeepPartial): void ``` **Parameters** | Name | Type | Notes | | ---------- | ----------------------- | -------------------------------------------------------------------------------------- | | `messages` | `DeepPartial` | Partial message overrides. Merged into the built-in defaults, not composed additively. | **Returns:** `void` Each `setMessages()` call replaces the active overrides. Call `resetMessages()` to restore the built-in defaults. ```ts import { setMessages } from '@vielzeug/spell'; setMessages({ string: { email: 'Use a valid work email address', min: ({ min }) => `Must be at least ${min} characters`, }, }); ``` To integrate with `@vielzeug/lingua` (or any i18n library), call `setMessages()` from your locale change callback: ```ts import { setMessages } from '@vielzeug/spell'; // spellMessages is your locale → DeepPartial map i18n.subscribe(() => setMessages(spellMessages[i18n.locale])); ``` --- ### `setLogger()` Use `setLogger()` to route or silence internal Spell development warnings. ```ts setLogger(logger: Logger | null): void ``` **Parameters** | Name | Type | Notes | | -------- | ---------------- | -------------------------------------------------------- | | `logger` | `Logger \| null` | Custom `(msg: string) => void` fn, or `null` to silence. | **Returns:** `void` Internal warnings include things like multiple `regex()` constraints on a single string schema. Pass `null` to silence them completely. ```ts import { setLogger } from '@vielzeug/spell'; // Silence all internal warnings setLogger(null); // Redirect to your own logging infrastructure setLogger((msg) => myLogger.warn(msg)); ``` --- ### `resetMessages()` Use `resetMessages()` to restore the built-in message catalog and the default warning logger. ```ts resetMessages(): void ``` **Returns:** `void` Useful in tests to ensure each test starts from a clean global state. ```ts import { resetMessages, setMessages } from '@vielzeug/spell'; setMessages({ string: { email: 'Custom message' } }); // ... run tests ... resetMessages(); // restore defaults ``` --- ### `fail()` Use `fail()` inside custom validators when you need a typed issue array. ```ts fail(code: C, message: string, params: Extract['params']): Issue[] fail(code: string, message: string, params?: Record): Issue[] ``` **Returns:** A one-item `Issue[]` array. Use it to keep custom validators consistent with Spell's internal issue shape. ```ts import { fail } from '@vielzeug/spell'; const issues = fail('custom', 'Expected a company email', { value: 'ada@example.com' }); ``` --- ### `prependIssuePath()` Use `prependIssuePath()` to move nested issues under a parent field path. ```ts prependIssuePath(issues: Issue[], prefix: string | number): Issue[] ``` **Returns:** A new issue array with the path prefix applied. Use it when a custom parser delegates to another schema and wants nested paths to stay accurate. ```ts import { fail, prependIssuePath } from '@vielzeug/spell'; const nested = prependIssuePath(fail('custom', 'Missing field'), 'profile'); ``` --- ### `errorsAt()` Use `errorsAt()` to read nested messages from `SpellValidationError.format()` output. ```ts errorsAt(formatted: FormattedErrors, ...path: (string | number)[]): string[] ``` **Returns:** A list of messages at the requested path. Use it when UI code works with the nested `format()` result instead of flat arrays. ```ts import { SpellValidationError, errorsAt, s } from '@vielzeug/spell'; const Schema = s.object({ profile: s.object({ name: s.string().min(2) }) }); const result = Schema.safeParse({ profile: { name: '' } }); if (!result.success && SpellValidationError.is(result.error)) { console.log(errorsAt(result.error.format(), 'profile', 'name')); } ``` ## Standalone Validators ### General validators Use these helpers when you need a boolean check without allocating a schema. ```ts hasMinLength(value: { length: number }, min: number): boolean hasMaxLength(value: { length: number }, max: number): boolean isArray(value: unknown): value is unknown[] isBoolean(value: unknown): value is boolean isDate(value: unknown): value is Date isInteger(value: unknown): value is number isMultipleOf(value: number, multipleOf: number): boolean isNegative(value: number): boolean isNonNegative(value: number): boolean isNullOrUndefined(value: unknown): value is null | undefined isNumber(value: unknown): value is number isPositive(value: number): boolean isString(value: unknown): value is string isInRange(value: number, min: number, max: number): boolean ``` **Returns:** A boolean or a type predicate. Use them in adapters, preprocessors, or guard clauses that do not need full schema errors. ```ts import { hasMinLength, isInRange, isString } from '@vielzeug/spell'; const value: unknown = 'release'; if (isString(value) && hasMinLength(value, 3)) { console.log(isInRange(value.length, 3, 12)); } ``` --- ### Format validators Use these helpers when you need the same format checks outside a schema definition. ```ts isBase64(value: string): boolean isBase64url(value: string): boolean isCuid(value: string): boolean isCuid2(value: string): boolean isDuration(value: string): boolean isEmail(value: string): boolean isEmoji(value: string): boolean isHex(value: string): boolean isHexColor(value: string): boolean isIp(value: string): boolean isIsoDate(value: string): boolean isIsoDateTime(value: string): boolean isJwt(value: string): boolean isNanoid(value: string): boolean isNumeric(value: string): boolean isSemver(value: string): boolean isSlug(value: string): boolean isTime(value: string): boolean isUlid(value: string): boolean isUrl(value: string): boolean isUuid(value: string): boolean ``` **Returns:** `boolean` Use them to preflight input before building a schema or to reuse Spell's format logic in other utilities. ```ts import { isEmail, isSlug, isUuid } from '@vielzeug/spell'; console.log(isEmail('ada@example.com')); console.log(isSlug('release-notes')); console.log(isUuid('550e8400-e29b-41d4-a716-446655440000')); ``` ## Types Use these exported types when Spell drives your public TypeScript API. | Type | Purpose | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `AnySchema` | Union of all schema instances. Useful for generic helpers. | | `Infer` | Output type alias for a schema instance. | | `InferInput` | Accepted input type for a schema instance. | | `InferOutput` | Explicit output type helper for a schema instance. | | `ParseResult` | Result union used by `safeParse()` and `safeParseAsync()`. | | `ValidateFn` | Low-level validator function signature used by custom schema implementations. | | `CheckContext` | Context object passed to `validate()` callbacks for explicit issue emission. | | `ValidateResult` | Allowed return type from `validate()` callbacks: `boolean \| string \| null \| void`. | | `SchemaWalker` | Visitor interface used by `walk()`. | | `OptionalSchema` / `NullableSchema` / `NullishSchema` | Wrapper output aliases for common wrapper modes. | | `WrapperMode` | `'optional' \| 'nullable' \| 'nullish'` | | `SchemaDescriptor` | Full serializable descriptor produced by `toDescriptor()`. | | `JsonSchema` | JSON Schema output shape returned by `toJsonSchema()` and `descriptorToJsonSchema()`. | | `ErrorCode` | String union derived from the `ErrorCode` constant. Useful for typed custom issues. | | `Issue` | Single validation issue object with `code`, `message`, `path`, and `params`. | | `MessageFn` | Message callback signature for schema and locale overrides. | | `Messages` | Full locale message catalog shape. | | `DeepPartial` | Deep-optional version of `Messages`; accepted by `setMessages()`. | | `Logger` | Warning logger signature used by `setLogger()`: `(msg: string) => void`. | | `FormattedErrors` | Nested error object returned by `SpellValidationError.format()`. | | `FlatError` | `{ path, messages }` entry returned by `flatten()`. | | `FlatErrorFirst` | `{ path, message }` entry returned by `flattenFirst()`. | ## Errors ### `SpellError` Base class for every error spell throws. Use `SpellError.is()` to catch anything spell-originated regardless of subtype. ```ts class SpellError extends Error { constructor(message: string, opts?: ErrorOptions); static is(err: unknown): err is SpellError; } ``` `SpellValidationError` is currently the only subtype, but catching `SpellError` future-proofs error-handling code against new subtypes. --- ### `SpellValidationError` Use `SpellValidationError` to inspect failures from throwing and safe parsing APIs. ```ts class SpellValidationError extends SpellError { readonly issues: Issue[]; constructor(issues: Issue[], cause?: unknown); static is(value: unknown): value is SpellValidationError; bestMatch(): Issue[] | null; flatten(): { fieldErrors: FlatError[]; formErrors: string[] }; flattenFirst(): { fieldErrors: FlatErrorFirst[]; formErrors: string[] }; format(): FormattedErrors; } ``` **Returns:** Structured views over the underlying `issues` array. Use the instance helpers to shape errors for logs, forms, or API responses. ```ts import { SpellValidationError, s } from '@vielzeug/spell'; const Payload = s.object({ email: s.string().email() }); const result = Payload.safeParse({ email: 'invalid' }); if (!result.success && SpellValidationError.is(result.error)) { console.log(result.error.flatten()); } ``` `format()` guards unsafe path keys when building nested objects. You can safely hand its result to UI code without letting hostile keys write through the prototype chain. Path segments named `'_errors'` are automatically remapped to `'_errors_'` to avoid colliding with the reserved `_errors` field in each `FormattedErrors` node. Use `errorsAt()` with the same path to retrieve messages consistently. > **Note:** `SpellValidationError.message` (the human-readable error string) may contain constraint parameter values such as string suffixes, pattern prefixes, or min/max bounds when those appear in your validation messages. For structured access to individual issue details, use `.issues` or the flattening helpers instead of serializing `.message` directly into API responses or logs. --- ### `ErrorCode` Use `ErrorCode` when you need Spell's built-in code registry at runtime and the matching string union at type level. ```ts const ErrorCode = { custom: 'custom', invalid_type: 'invalid_type', too_small: 'too_small', too_big: 'too_big', // ...other built-in codes } as const; type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; ``` **Returns:** A frozen object of built-in codes and the matching exported string union type. Use the constant to avoid typos in custom helpers and the type to keep issue handling exhaustive. ```ts import { ErrorCode, fail } from '@vielzeug/spell'; import type { ErrorCode as SpellErrorCode } from '@vielzeug/spell'; const code: SpellErrorCode = ErrorCode.custom; const issues = fail(ErrorCode.custom, 'Expected a string'); ``` ### Usage Guide ## Basic Usage Start with `safeParse()` when you want explicit success and failure branches. ```ts import { s } from '@vielzeug/spell'; const Signup = s.object({ email: s.string().email(), password: s.string().min(12), referralCode: s.string().optional(), }); const result = Signup.safeParse({ email: 'ada@example.com', password: 'horse-battery-staple', }); if (!result.success) { console.error(result.error.format()); } else { console.log(result.data.email); } ``` Use `parse()` when invalid input should throw immediately. Use `safeParse()` when invalid input is part of normal control flow. ## Building Schemas Use the namespace form when readability matters more than bundle trimming. ```ts import { s } from '@vielzeug/spell'; const Article = s.object({ id: s.string().uuid(), title: s.string().trim().min(1).max(120), slug: s.string().slug(), tags: s.array(s.string().min(1)).default(() => []), meta: s .object({ published: s.boolean(), publishedAt: s.date().nullable(), }) .relaxed(), }); ``` ```ts import { s } from '@vielzeug/spell'; const Todo = s.object({ done: s.boolean(), tags: s.array(s.string().min(1)).default(() => []), title: s.string().min(1), }); ``` Object schemas reject unknown keys by default. Call `.relaxed()` when you need to preserve extra properties. Call `.defaults()` to get a fully default-filled object without providing any input. Every required field must have a `.default()` set, or a `SpellValidationError` is thrown. Call `.partialDefaults()` when only some fields have defaults — fields without a default are silently omitted instead of throwing. ```ts const Config = s.object({ host: s.string().default('localhost'), port: s.number().default(3000), }); Config.defaults(); // { host: 'localhost', port: 3000 } const Form = s.object({ name: s.string(), role: s.string().default('viewer') }); Form.partialDefaults(); // { role: 'viewer' } ``` ## Wrapper Modes, Defaults, and Fallbacks Chain wrappers to describe missing values and recovery rules without losing schema metadata. ```ts import { s } from '@vielzeug/spell'; const DisplayName = s.string().trim().min(2).label('Display name').optional().default('Guest').nullable(); DisplayName.parse(undefined); // 'Guest' DisplayName.parse(null); // null DisplayName.description; // 'Display name' ``` Call `.required()` to remove `undefined` without removing `null`. ```ts import { s } from '@vielzeug/spell'; const NullableButRequired = s.string().optional().nullable().required(); NullableButRequired.parse('Ada'); NullableButRequired.parse(null); // NullableButRequired.parse(undefined); // throws ``` Use `.catch()` when you want a fallback output after validation fails. ```ts import { s } from '@vielzeug/spell'; const Port = s.number().int().min(1).max(65535).catch(3000); Port.parse('not-a-number'); // 3000 ``` ## Custom Validation Use `validate()` for domain rules — both synchronous and asynchronous. A single method handles all cases. ```ts import { s } from '@vielzeug/spell'; // Boolean shorthand: return false to fail with default message const EvenNumber = s.number().validate((n) => n % 2 === 0); // String shorthand: return the message as a string const Username = s .string() .min(3) .validate((v) => !v.startsWith('_') || 'Cannot start with underscore'); // Multiple issues via ctx.addIssue() const Signup = s.object({ confirm: s.string(), password: s.string() }).validate((v, ctx) => { if (v.password !== v.confirm) { ctx.addIssue({ code: 'custom', message: 'Passwords must match', path: ['confirm'] }); } }); ``` Async rules work in the same method. Spell awaits them only in `parseAsync()` — async callbacks passed to `validate()` are silently skipped in synchronous `parse()`. ```ts import { s } from '@vielzeug/spell'; const takenEmails = new Set(['ada@example.com']); const AccountEmail = s .string() .email() .validate(async (value, ctx) => { if (takenEmails.has(value)) { ctx.addIssue({ code: 'custom', message: 'Email is already taken', path: [] }); } }); // Must use parseAsync when any validate() callback is async await AccountEmail.parseAsync('grace@example.com'); ``` Use `refine()` when you only need a boolean predicate and an optional message function. ```ts import { s } from '@vielzeug/spell'; const PositivePrice = s.number().refine( (n) => n > 0, () => 'Must be positive', ); PositivePrice.parse(9.99); ``` ## Strings, Numbers, and Safe Regex Usage Use schema helpers for common string and number constraints instead of hand-written predicates. ```ts import { s } from '@vielzeug/spell'; const Password = s.string().min(12).regex(/[A-Z]/).regex(/[0-9]/); const Price = s.number().nonNegative().multipleOf(0.01); const LaunchWindow = s.date().min(new Date('2025-01-01T00:00:00.000Z')); ``` Spell strips stateful `/g` and `/y` flags from `regex()` patterns before validation. Repeated parses stay deterministic even when the original regular expression is reused. ## Coercion and Transforms Use coercion when input arrives as strings, query parameters, or form values. ```ts import { s } from '@vielzeug/spell'; const Query = s.object({ draft: s.coerce.boolean().default(false), limit: s.coerce.number().int().positive().default(20), publishedAt: s.coerce.date().nullable(), search: s.coerce.string().trim().min(1).optional(), }); const parsed = Query.parse({ draft: 'true', limit: '50', publishedAt: '2025-04-01T12:00:00.000Z', search: ' vielzeug ', }); ``` Use `transform()` or `pipe()` after validation when downstream code needs a different output shape. ```ts import { s } from '@vielzeug/spell'; const TrimmedTags = s.array(s.string().trim().min(1)).transform((tags) => tags.map((tag) => tag.toLowerCase())); const Slug = s.string().trim().min(1).pipe(s.string().slug()); ``` ## Introspection, Round-Trips, and JSON Schema Use descriptors when schemas need to cross process boundaries or feed tooling. ```ts import { descriptorToJsonSchema, s } from '@vielzeug/spell'; const Product = s .object({ id: s.string().uuid(), name: s.string().min(1), price: s.number().positive().multipleOf(0.01), }) .label('Product'); const descriptor = Product.toDescriptor(); const jsonSchema = descriptorToJsonSchema(descriptor); Product.parse({ id: '550e8400-e29b-41d4-a716-446655440000', name: 'Keyboard', price: 129.99 }); console.log(jsonSchema.title); ``` Descriptors are serializable snapshots of the schema structure. Use `toDescriptor()` to produce one and `descriptorToJsonSchema()` to convert it to JSON Schema for external consumers. ## Messages Use `setMessages()` to replace the active validation message catalog. Each call replaces the current overrides — it does not accumulate. ```ts import { resetMessages, setMessages } from '@vielzeug/spell'; setMessages({ string: { email: 'Use a valid work email address', min: ({ min }) => `Must be at least ${min} characters`, }, number: { min: ({ min }) => `Use a value of ${min} or greater`, }, }); // Restore the built-in defaults when done resetMessages(); ``` Use `setLogger()` to route or silence internal development warnings (e.g. conflicting `regex()` constraints). ```ts import { setLogger } from '@vielzeug/spell'; setLogger(null); // silence setLogger((msg) => myLogger.warn(msg)); // redirect ``` To integrate with `@vielzeug/lingua`, call `setMessages()` from your locale change callback: ```ts import { setMessages } from '@vielzeug/spell'; // spellMessages maps locale keys to DeepPartial i18n.subscribe(() => setMessages(spellMessages[i18n.locale])); ``` ## Working with Validation Errors Use `SpellValidationError` helpers when you need UI-ready error structures. ```ts import { SpellValidationError, errorsAt, s } from '@vielzeug/spell'; const User = s.object({ email: s.string().email(), profile: s.object({ name: s.string().min(2), }), }); const result = User.safeParse({ email: 'nope', profile: { name: '' } }); if (!result.success && SpellValidationError.is(result.error)) { const formatted = result.error.format(); const profileErrors = errorsAt(formatted, 'profile', 'name'); console.log(profileErrors); } ``` Use `bestMatch()` on a union failure when you want the branch that came closest to succeeding. ## Schema Traversal with walk() Use `walk()` to inspect or transform a schema tree without importing internal implementation classes. ```ts import { s, type SchemaWalker } from '@vielzeug/spell'; const fields: string[] = []; const collectFields: SchemaWalker = { object(schema) { for (const [key, child] of Object.entries(schema.shape)) { fields.push(key); child.walk(collectFields); } }, unknown() {}, }; const User = s.object({ email: s.string().email(), profile: s.object({ name: s.string() }), }); User.walk(collectFields); console.log(fields); // ['email', 'profile', 'name'] ``` `walk()` dispatches by `schema.kind`. If no handler matches and no `unknown` fallback is provided, `walk()` returns `null`. Add an `unknown` handler to capture any kind not explicitly listed in your visitor. ## Framework Integration Spell works anywhere you can call a function before state enters your app. ```tsx [React] import { s } from '@vielzeug/spell'; const SearchParams = s .object({ page: s.coerce.number().int().positive().default(1), q: s.string().trim().optional(), }) .relaxed(); export function SearchPage({ rawParams }: { rawParams: unknown }) { const params = SearchParams.parse(rawParams); return ( {params.q ?? 'All results'} — page {params.page} ); } ``` ```ts [Vue] import { computed, ref } from 'vue'; import { s } from '@vielzeug/spell'; const Settings = s.object({ locale: s.string().min(2), compact: s.coerce.boolean().default(false), }); const raw = ref({ locale: 'en', compact: 'true' }); const settings = computed(() => Settings.parse(raw.value)); ``` Use `safeParse()` at event boundaries and `parse()` inside trusted data flows. ## Working with Other Vielzeug Libraries Use Spell as the validation layer and let other packages focus on transport, forms, or storage. ```ts import { createForm } from '@vielzeug/forge'; import { createApi } from '@vielzeug/courier'; import { s } from '@vielzeug/spell'; const Profile = s.object({ displayName: s.string().min(2), newsletter: s.boolean(), }); const form = createForm({ defaultValues: { displayName: '', newsletter: false, }, validator: Profile, }); const api = createApi({ baseUrl: '/api' }); const profile = Profile.parse(await api.get('/profile')); ``` Use Spell descriptors with `@vielzeug/codex` or other tooling when you need generated docs or external schema consumers. ## Best Practices - Keep schemas close to the boundary where unknown data enters your app. - Prefer tree-shakeable `sXxx` exports in libraries and the `s` namespace in app code. - Use `.default(() => value)` for mutable defaults such as arrays, objects, `Map`, and `Set`. - Call `.required()` when you want to remove `undefined` but keep `null` semantics intact. - Use `validate()` with a `ctx` argument when you need `ctx.addIssue()`; use `refine()` for simple boolean predicates. - Switch to `parseAsync()` as soon as any `validate()` callback is async. - Call `resetMessages()` in test `afterEach()` when tests call `setMessages()` to prevent state leakage. - Use `toDescriptor()` for tooling and `descriptorToJsonSchema()` for external JSON Schema consumers. ### Examples ## Examples - [Validating API Payloads](./examples/api.md) - [Form-Safe Parsing](./examples/forms.md) - [Async Business Rules](./examples/async.md) - [Schema Introspection and Round-Trips](./examples/introspection.md) - [Unions, Intersections, and Variants](./examples/unions.md) - [Schema Traversal with walk()](./examples/walk.md) ### REPL Examples - Array Validation (id: `array-validation`) - Async Validation (id: `async-validate`) - Basic Parsing (id: `basic-parsing`) - Basic Schema Validation (id: `basic-schema`) - Type Coercion (id: `coercion`) - Descriptor & JSON Schema (id: `descriptor-roundtrip`) - Discriminated Union (id: `discriminated-union`) - Format Validators (id: `format-validators`) - Message Overrides (id: `messages-override`) - Variant Responses (id: `nested-objects`) - Number Validation (id: `number-validation`) - Object Defaults (id: `object-defaults`) - Object Merge & Aliases (id: `object-merge`) - Optional and Nullable Fields (id: `optional-nullable`) - Custom Validation (id: `refinements`) - Schema Traversal (id: `schema-walk`) - String Validation (id: `string-validation`) - Wrappers & Defaults (id: `wrappers-and-defaults`) --- ## @vielzeug/tempo **Category:** time **Keywords:** temporal, date-time, timezone, formatting, arithmetic, dst, intl, calendar **Key exports:** now, nowInstant, parse, parsePlainDate, parsePlainDateTime, parseInstant, parseZoned, isValid, toInstant, inTz, shift, difference (+26 more) **Related:** arsenal ### Overview ## Why Tempo? Manual date handling breaks at daylight-saving boundaries, timezone edges, and DST transitions. ```ts // Before — fragile, loses timezone context const reminder = new Date(meeting.getTime() - 15 * 60_000); // After — DST-safe, handles transitions correctly const reminder = shift(meeting, { minutes: -15 }); ``` | Feature | Tempo | date-fns | Day.js | Native Date | | -------------- | ---------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | -------------------------------------- | | Bundle size | | ~10 kB | ~3 kB | 0 kB | | DST-safe math | (Temporal) | Manual | Manual | | | Timezone aware | Full support | | | Partial | | Immutable | | | | | | Format presets | (`'short'`, `'medium'`, `'long'`, etc.) | | | | | Type inference | Full TypeScript | Partial | Partial | | **Use Tempo when** you need reliable timezone handling, DST-safe arithmetic, and clean Temporal-based APIs without heavy dependencies. **Consider alternatives when** you need extensive locale data (date-fns). ## Installation ```sh [pnpm] pnpm add @vielzeug/tempo ``` ```sh [npm] npm install @vielzeug/tempo ``` ```sh [yarn] yarn add @vielzeug/tempo ``` ## Quick Start ```ts import { format, formatInstant, inTz, parsePlainDateTime, shift, toInstant } from '@vielzeug/tempo'; // Parse a wall-clock string (no timezone attached) const localMeeting = parsePlainDateTime('2026-03-21T10:30:00'); // Convert to an absolute instant using the user's timezone const meetingInstant = toInstant(localMeeting, { tz: 'America/New_York' }); // Project to a zoned view and subtract 15 minutes (DST-safe) const meetingNY = inTz(meetingInstant, 'America/New_York'); const reminder = shift(meetingNY, { minutes: -15 }); // Format for display const text = format(reminder, { pattern: 'short', locale: 'en-US', tz: 'America/New_York' }); // Format for APIs/logs (stable UTC instant string) const stable = formatInstant(reminder); ``` > **No `Temporal.*` imports needed.** Tempo re-exports `Temporal` and provides `parseInstant`, `parseZoned`, `parsePlainDateTime`, `parsePlainDate`, `nowInstant`, and `now` as drop-in replacements for every common Temporal constructor. Use `parse(input, as?)` for flexible input detection. ## Features - **Zero Temporal imports** — `parseInstant()`, `parseZoned()`, `parsePlainDateTime()`, `parsePlainDate()`, `nowInstant()`, `now()` replace every common `Temporal.*` constructor; import only from `@vielzeug/tempo` - **DST-safe arithmetic** — `shift()` handles transitions correctly; always returns `ZonedDateTime` (call `.toInstant()` if needed) - **Timezone conversion** — `inTz()` to project any input into a timezone, `toInstant()` to normalize to UTC; invalid timezone strings throw `TempoError` - **Formatting split by intent** — `format()` for UI (with presets and `intl` escape hatch), `formatInstant()` for UTC strings, `formatZoned()` for zoned strings - **Relative and range formatting** — `formatRelative()` for UX copy, `formatRange()` / `formatRangeParts()` for localized time spans, `formatParts()` for custom rendering - **Range + comparison helpers** — `within()`, `clamp()`, `isBefore()`, `isAfter()`, `isSame()` with calendar-unit and week-start support - **Boundary helpers** — `startOf()` and `endOf()` for day/week/month/year-style snapping - **Duration tools** — `difference()`, `parseDuration()`, `formatDuration()` - **Expiry classification** — `expires()` for flexible threshold-based TTL bucketing; `timeDiff()` for structured time differences; `humanize()` for human-readable output - **Recurrence generation** — `recurrence()` for lazily generating repeating dates (daily/weekly/monthly/yearly); `dateRange()` for step-based date sequences; timezone inferred from `ZonedDateTime` inputs - **Intl integration** — formatting respects locale and calendar systems - **Polyfilled Temporal** — works in runtimes without native support via `@js-temporal/polyfill` - gzipped ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Spell](/spell/) — schema validation with a similar `v` namespace pattern; combine with Tempo's date validators for typed form fields that accept date strings - [Rune](/rune/) — structured logger; use Tempo to format timestamps consistently in log entries and audit trails ### API Reference ## API Overview | Symbol | Purpose | Common gotcha | | ---------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `now(tz)` | Current zoned date/time | Requires a valid IANA timezone string | | `nowInstant()` | Current UTC instant | Use instead of `Temporal.Now.instant()` | | `parse(input, as?)` | Parse any ISO 8601 string; `as` pins the return type | Without `as`, auto-detects: ZonedDateTime → Instant → PlainDateTime → PlainDate | | `parseInstant(input)` | Parse a UTC ISO string to `Instant` | Input must end in `Z` or include an offset | | `parseZoned(input)` | Parse a zoned ISO string to `ZonedDateTime` | Must include offset and timezone (`[Region/City]`) | | `parsePlainDateTime(input)` | Parse a wall-clock string to `PlainDateTime` | No timezone attached — use `toInstant()` to pin it | | `parsePlainDate(input)` | Parse a date-only string to `PlainDate` | Use instead of `Temporal.PlainDate.from()` | | `isValid(value)` | Type guard for any `TimeInput` | Returns `false` for strings, numbers, and `null` | | `toInstant(input, options?)` | Normalize any input to a UTC instant | Plain inputs require `options.tz` | | `inTz(input, tz)` | Project any input into a specific timezone | Returns `ZonedDateTime`; re-projects `ZonedDateTime` inputs (wall-clock changes) | | `shift(input, duration, options?)` | DST-safe add/subtract | `options.tz` required for plain/instant inputs | | `difference(start, end, options?)` | Duration between two values | Requires `tz` only for calendar units or plain inputs | | `within(value, start, end, options?)` | Inclusive range check | Bounds auto-normalized; use `unit` for calendar checks | | `clamp(value, start, end, options?)` | Clamp to `[start, end]`; see notes for return type | With `unit` + `ZonedDateTime` input, returns `ZonedDateTime`; otherwise `Instant` | | `isBefore(a, b, options?)` | Returns `true` when `a` is earlier than `b` | Omit `unit` for raw timeline comparison | | `isAfter(a, b, options?)` | Returns `true` when `a` is later than `b` | Omit `unit` for raw timeline comparison | | `isSame(a, b, options?)` | Returns `true` when `a` and `b` are equal | Set `unit` for calendar-unit equality | | `startOf(input, unit, options?)` | Snap to start of a calendar unit | Week boundaries depend on `weekStartsOn` (default Monday) | | `endOf(input, unit, options?)` | Snap to end of a calendar unit | Returns 1 nanosecond before the next unit starts | | `format(input, options?)` | Localized display formatting with presets | `intl` and `pattern` are mutually exclusive | | `formatParts(input, options?)` | Raw `Intl.DateTimeFormatPart[]` for custom rendering | Same options as `format()` | | `formatInstant(input, options?)` | UTC ISO-8601 instant string | Always UTC regardless of input zone | | `formatZoned(input, options?)` | Zoned ISO-8601 string | `options.tz` required for non-zoned inputs | | `formatRange(start, end, options?)` | Localized time-span string | Throws when zoned inputs are in different zones without `tz` | | `formatRangeParts(start, end, options?)` | Raw `Intl.DateTimeRangeFormatPart[]` array | Same zone-mismatch rules as `formatRange()` | | `formatRelative(input, options?)` | UX relative text ("in 2 hours", "3 days ago") | Input restricted to `Instant` or `ZonedDateTime` | | `parseDuration(input)` | Parse ISO 8601 duration or `DurationLike` object | Throws `TempoError` for invalid strings | | `formatDuration(input, options?)` | Format a duration for display | Falls back to plain English if `Intl.DurationFormat` absent | | `expires(date, thresholds, options?)` | Classify a date against named threshold buckets | Returns `null` when no threshold matches; define thresholds at module scope for best perf | | `timeDiff(a, b?, options?)` | Largest-unit difference as `{ unit, value }` | No `tz` needed when both inputs are `Instant` | | `humanize(diff, options?)` | `TimeDiffResult` → human-readable string | English-only; use `formatRelative()` for localized output | | `dateRange(start, end, step, options?)` | Lazy generator of `ZonedDateTime` values | `step` must advance time forward; `tz` inferred from `ZonedDateTime`, required otherwise | | `recurrence(start, rule, options?)` | Lazy generator for repeating dates | `count` or `until` required; `tz` inferred from `ZonedDateTime` start | | `TempoError` | Base error class thrown by all tempo functions | `instanceof TempoError` catches every subtype below | | `TempoInvalidInputError` | Subtype — parse/duration input could not be understood | Thrown by `parse()`, `parseInstant()`, `parseZoned()`, `parsePlainDate()`, `parsePlainDateTime()`, `parseDuration()` | | `TempoInvalidTzError` | Subtype — timezone string is not a valid IANA name or offset | Thrown by any function that resolves a `tz` string | | `TempoMissingTzError` | Subtype — operation needs a timezone but none could be inferred | Thrown when a plain input is passed without `options.tz` | | `TempoUnsupportedInputError` | Subtype — input value is not a recognised `TimeInput` | Thrown by `toInstant()` for non-`TimeInput` values | ## Package Entry Point ```ts import { Temporal, TempoError, TempoInvalidInputError, TempoInvalidTzError, TempoMissingTzError, TempoUnsupportedInputError, clamp, dateRange, difference, endOf, expires, format, formatDuration, formatInstant, formatParts, formatRange, formatRangeParts, formatRelative, formatZoned, humanize, inTz, isAfter, isBefore, isSame, isValid, now, nowInstant, parse, parseDuration, parseInstant, parsePlainDateTime, parsePlainDate, parseZoned, recurrence, shift, startOf, timeDiff, toInstant, within, } from '@vielzeug/tempo'; ``` `Temporal` is re-exported directly — consumers never need to import `@js-temporal/polyfill` themselves. Use `parse(input, as?)` for flexible input detection, or the specific helpers (`parseInstant`, `parseZoned`, `parsePlainDateTime`, `parsePlainDate`) when the input format is known. ## Core Functions ### `now(tz): Temporal.ZonedDateTime` ```ts now(tz: string): Temporal.ZonedDateTime; ``` Returns the current time as a `ZonedDateTime` in `tz`. **Example:** ```ts import { now } from '@vielzeug/tempo'; now('UTC'); now('Europe/Berlin'); now('Asia/Tokyo'); ``` --- ### `nowInstant(): Temporal.Instant` ```ts nowInstant(): Temporal.Instant; ``` Returns the current absolute instant (UTC point in time). Use this instead of `Temporal.Now.instant()` so your code only imports from `@vielzeug/tempo`. **Example:** ```ts import { expires, nowInstant, timeDiff } from '@vielzeug/tempo'; // Snapshot the current instant const t = nowInstant(); timeDiff(t); // { unit: 'millisecond', value: 0 } expires(t, { expired: { days: 0 }, safe: { years: 100 } }); // 'safe' ``` --- ### `parseZoned(input): Temporal.ZonedDateTime` ```ts parseZoned(input: string): Temporal.ZonedDateTime; ``` Parses a full ISO 8601 zoned date-time string (must include both offset and `[Region/City]` identifier) into a `ZonedDateTime`. Throws a descriptive `[tempo]` error on invalid input. Use instead of `Temporal.ZonedDateTime.from()`. **Example:** ```ts import { parseZoned } from '@vielzeug/tempo'; parseZoned('2026-03-21T11:00:00+01:00[Europe/Berlin]'); parseZoned('2026-03-21T00:00:00[UTC]'); ``` --- ### `parsePlainDate(input): Temporal.PlainDate` ```ts parsePlainDate(input: string): Temporal.PlainDate; ``` Parses an ISO 8601 date-only string into a timezone-free `PlainDate`. Use this instead of `Temporal.PlainDate.from()`. Pair with `inTz()` or `toInstant()` when a timezone is needed. **Example:** ```ts import { inTz, parsePlainDate } from '@vielzeug/tempo'; parsePlainDate('2026-03-21'); // 2026-03-21 // Attach a timezone when needed inTz(parsePlainDate('2026-03-21'), 'America/New_York'); ``` --- ### `parsePlainDateTime(input): Temporal.PlainDateTime` ```ts parsePlainDateTime(input: string): Temporal.PlainDateTime; ``` Parses an ISO 8601 date or date-time string into a timezone-free `PlainDateTime`. Use this at the boundary where user input or database values arrive as wall-clock strings. **Example:** ```ts import { parsePlainDateTime } from '@vielzeug/tempo'; parsePlainDateTime('2026-03-21'); // 2026-03-21T00:00:00 parsePlainDateTime('2026-03-21T10:15:30'); // 2026-03-21T10:15:30 ``` --- ### `parseInstant(input): Temporal.Instant` ```ts parseInstant(input: string): Temporal.Instant; ``` Parses a UTC ISO 8601 string into a `Temporal.Instant`. The input must include an offset or end in `Z`. Throws a `TempoError` with code `INVALID_INPUT` on invalid input. **Example:** ```ts import { parseInstant } from '@vielzeug/tempo'; parseInstant('2026-03-21T10:15:30Z'); parseInstant('2026-03-21T11:15:30+01:00'); ``` --- ### `parse(input, as?): TimeInput` ```ts parse(input: string, as: 'zoned'): Temporal.ZonedDateTime; parse(input: string, as: 'instant'): Temporal.Instant; parse(input: string, as: 'plain-datetime'): Temporal.PlainDateTime; parse(input: string, as: 'plain-date'): Temporal.PlainDate; parse(input: string, as?: ParseAs): TimeInput; ``` Parses any ISO 8601 string. Without `as`, auto-detects the most specific type in order: `ZonedDateTime` → `Instant` → `PlainDateTime` → `PlainDate`. With `as`, the return type is narrowed at compile time. Throws a descriptive `[tempo]` error if parsing fails. The `as` parameter accepts a `ParseAs` value: `'zoned' | 'instant' | 'plain-datetime' | 'plain-date'`. **Example:** ```ts import { parse } from '@vielzeug/tempo'; // Auto-detect parse('2026-03-21T11:00:00+01:00[Europe/Berlin]'); // Temporal.ZonedDateTime parse('2026-03-21T10:00:00Z'); // Temporal.Instant parse('2026-03-21T10:00:00'); // Temporal.PlainDateTime parse('2026-03-21'); // Temporal.PlainDate // Typed overloads — return type is narrowed parse('2026-03-21T10:00:00Z', 'instant'); // Temporal.Instant parse('2026-03-21', 'plain-date'); // Temporal.PlainDate ``` --- ### `isValid(value): value is TimeInput` ```ts isValid(value: unknown): value is TimeInput; ``` Type guard that returns `true` when `value` is a valid `Temporal.Instant`, `ZonedDateTime`, `PlainDateTime`, or `PlainDate`. **Example:** ```ts import { isValid, parseInstant } from '@vielzeug/tempo'; isValid(parseInstant('2026-03-21T10:00:00Z')); // true isValid('2026-03-21'); // false isValid(null); // false ``` --- ### `toInstant(input, options?): Temporal.Instant` ```ts toInstant(input: TimeInput, options?: TimeOptions): Temporal.Instant; ``` Normalizes any `TimeInput` to an absolute `Temporal.Instant`. **Parameters — `TimeOptions`:** | Option | Type | Description | | ------ | -------- | -------------------------------------------------- | | `tz` | `string` | Required when input is `PlainDate`/`PlainDateTime` | **Example:** ```ts import { parseInstant, parsePlainDateTime, toInstant } from '@vielzeug/tempo'; toInstant(parseInstant('2026-03-21T10:15:30Z')); toInstant(parsePlainDateTime('2026-03-21T10:15:30'), { tz: 'America/New_York' }); ``` --- ### `inTz(input, tz): Temporal.ZonedDateTime` ```ts inTz(input: TimeInput, tz: string): Temporal.ZonedDateTime; ``` Projects any `TimeInput` into `tz`. When `input` is already a `ZonedDateTime`, it is **re-projected** via `withTimeZone()` — the absolute instant is preserved but the wall-clock time changes. **Example:** ```ts import { inTz, parseInstant, parseZoned } from '@vielzeug/tempo'; inTz(parseInstant('2026-03-21T10:15:30Z'), 'Europe/Berlin'); // → 2026-03-21T11:15:30+01:00[Europe/Berlin] // ZonedDateTime re-projected — same instant, different wall clock inTz(parseZoned('2026-03-21T11:15:30+01:00[Europe/Berlin]'), 'UTC'); // → 2026-03-21T10:15:30+00:00[UTC] ``` --- ### `shift(input, duration, options?): Temporal.ZonedDateTime` ```ts // ZonedDateTime input — tz inferred, options optional shift(input: Temporal.ZonedDateTime, duration: Temporal.DurationLike, options?: TimeOptions): Temporal.ZonedDateTime; // Instant / PlainDate / PlainDateTime — tz required shift(input: Temporal.Instant | Temporal.PlainDate | Temporal.PlainDateTime, duration: Temporal.DurationLike, options: ShiftOptions & { tz: string }): Temporal.ZonedDateTime; ``` Adds `duration` to `input` using DST-aware calendar arithmetic. Negative values subtract. **Always returns `Temporal.ZonedDateTime`** — call `.toInstant()` if you need an `Instant` back. **Parameters — `ShiftOptions` (extends `TimeOptions`):** | Option | Type | Default | Description | | -------- | ------------------------ | -------------- | ----------------------------------------------------- | | `tz` | `string` | — | Required for plain/instant inputs; inferred for zoned | | `prefer` | `DateTimeDisambiguation` | `'compatible'` | DST disambiguation for `PlainDateTime` inputs | **Example:** ```ts import { parseInstant, parseZoned, shift } from '@vielzeug/tempo'; // Zoned input — timezone inferred shift(parseZoned('2026-03-08T01:30:00-05:00[America/New_York]'), { hours: 1 }); // → 2026-03-08T03:30:00-04:00[America/New_York] (DST spring-forward handled) // Instant input — timezone required shift(parseInstant('2026-03-21T10:00:00Z'), { days: 1 }, { tz: 'UTC' }); ``` --- ### `difference(start, end, options?): Temporal.Duration` ```ts difference(start: TimeInput, end: TimeInput, options?: DifferenceOptions): Temporal.Duration; ``` Returns the signed duration from `start` to `end`. When `start > end` the result is negative. **Parameters — `DifferenceOptions`:** | Option | Type | Default | Description | | ------------------- | ------------------------ | -------------- | ------------------------------------------- | | `tz` | `string` | — | Required for plain inputs or calendar units | | `largestUnit` | `Temporal.DateTimeUnit` | `'second'` | Largest unit in the returned duration | | `smallestUnit` | `Temporal.DateTimeUnit` | `'second'` | Smallest unit; excess is rounded | | `roundingMode` | `Temporal.RoundingMode` | `'trunc'` | Rounding direction for `smallestUnit` | | `roundingIncrement` | `number` | `1` | Rounding granularity for `smallestUnit` | | `prefer` | `DateTimeDisambiguation` | `'compatible'` | DST disambiguation | Two `Temporal.Instant` inputs with sub-day `largestUnit`/`smallestUnit` do not need `tz`. Calendar units (`day`, `week`, `month`, `year`) always require `tz`. **Example:** ```ts import { difference, parseInstant, parseZoned } from '@vielzeug/tempo'; // Instant-to-instant, sub-day units — no tz needed difference(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:30:00Z'), { largestUnit: 'hour', smallestUnit: 'minute', }); // PT2H30M // DST-correct day count across spring-forward difference( parseZoned('2026-03-08T00:00:00-05:00[America/New_York]'), parseZoned('2026-03-09T00:00:00-04:00[America/New_York]'), { largestUnit: 'hour' }, ).hours; // 23 (one hour shorter due to DST) ``` ## Query and Comparison ### `within(value, start, end, options?): boolean` ```ts within(value: TimeInput, start: TimeInput, end: TimeInput, options?: CompareOptions): boolean; ``` Returns `true` when `value` falls within `[start, end]` (inclusive). Bounds are automatically normalized, so `within(v, hi, lo)` behaves the same as `within(v, lo, hi)`. Set `options.unit` to compare on calendar-unit boundaries (e.g., same day, same week). **Example:** ```ts import { parseInstant, within } from '@vielzeug/tempo'; const lo = parseInstant('2026-03-21T10:00:00Z'); const hi = parseInstant('2026-03-21T12:00:00Z'); within(parseInstant('2026-03-21T11:00:00Z'), lo, hi); // true within(lo, lo, hi); // true (inclusive) within(parseInstant('2026-03-22T05:00:00Z'), lo, hi, { unit: 'day', tz: 'UTC' }); // true ``` --- ### `clamp(value, start, end, options?): Temporal.Instant` ```ts clamp(value: TimeInput, start: TimeInput, end: TimeInput, options?: CompareOptions): Temporal.Instant; ``` Returns `value` clamped to `[start, end]`. Returns a `Temporal.Instant` for non-zoned inputs. When `value` is a `ZonedDateTime` and `options.unit` is set, returns a `Temporal.ZonedDateTime` floored to the start of the clamped unit. Bounds are automatically normalized when `start > end`. **Example:** ```ts import { clamp, parseInstant } from '@vielzeug/tempo'; const lo = parseInstant('2026-03-21T10:00:00Z'); const hi = parseInstant('2026-03-21T12:00:00Z'); clamp(parseInstant('2026-03-21T13:00:00Z'), lo, hi).toString(); // '2026-03-21T12:00:00Z' clamp(parseInstant('2026-03-23T05:00:00Z'), lo, hi, { unit: 'day', tz: 'America/New_York' }); ``` --- ### `isBefore(a, b, options?): boolean` ```ts isBefore(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean; ``` Returns `true` when `a` is earlier than `b` on the timeline. Set `options.unit` for calendar-unit comparison. **Example:** ```ts import { isBefore, parseInstant } from '@vielzeug/tempo'; isBefore(parseInstant('2026-03-21T23:30:00Z'), parseInstant('2026-03-22T00:15:00Z'), { unit: 'day', tz: 'UTC', }); // true — different UTC days ``` --- ### `isAfter(a, b, options?): boolean` ```ts isAfter(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean; ``` Returns `true` when `a` is later than `b` on the timeline. Set `options.unit` for calendar-unit comparison. **Example:** ```ts import { isAfter, parseInstant } from '@vielzeug/tempo'; isAfter(parseInstant('2026-03-21T12:00:00Z'), parseInstant('2026-03-21T10:00:00Z')); // true ``` --- ### `isSame(a, b, options?): boolean` ```ts isSame(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean; ``` Returns `true` when `a` and `b` represent the same point or the same calendar unit. Infers timezone from `ZonedDateTime` inputs; throws when both are zoned in different zones and `options.tz` is omitted. **Example:** ```ts import { isSame, parseInstant } from '@vielzeug/tempo'; isSame(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T10:00:00Z')); // true // Same calendar day in New York even though UTC dates differ isSame(parseInstant('2026-03-21T23:30:00Z'), parseInstant('2026-03-22T00:15:00Z'), { unit: 'day', tz: 'America/New_York', }); // true ``` ## Boundary Helpers ### `startOf(input, unit, options?): Temporal.ZonedDateTime` ```ts startOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime; ``` Snaps `input` to the start of `unit`. For `ZonedDateTime` inputs, `options.tz` is inferred automatically. **Parameters — `BoundaryOptions`:** | Option | Type | Default | Description | | -------------- | -------------- | ------- | ------------------------------------ | | `tz` | `string` | — | Required for non-zoned inputs | | `weekStartsOn` | `WeekStartDay` | `1` | ISO weekday (1 = Monday, 7 = Sunday) | Supported units: `'minute'` · `'hour'` · `'day'` · `'week'` · `'month'` · `'year'` **Example:** ```ts import { parseInstant, startOf } from '@vielzeug/tempo'; startOf(parseInstant('2026-03-21T10:15:30Z'), 'day', { tz: 'UTC' }); // → 2026-03-21T00:00:00+00:00[UTC] startOf(parseInstant('2026-03-25T12:00:00Z'), 'week', { tz: 'UTC', weekStartsOn: 1 }); // → 2026-03-23T00:00:00+00:00[UTC] (Monday) ``` --- ### `endOf(input, unit, options?): Temporal.ZonedDateTime` ```ts endOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime; ``` Snaps `input` to the last nanosecond of `unit` (`startOf(nextUnit) - 1ns`). **Example:** ```ts import { endOf, parseInstant } from '@vielzeug/tempo'; endOf(parseInstant('2026-03-21T10:15:30Z'), 'day', { tz: 'UTC' }); // → 2026-03-21T23:59:59.999999999+00:00[UTC] ``` ## Formatting ### `format(input, options?): string` ```ts format(input: TimeInput, options?: FormatOptions): string; ``` Formats `input` for display using `Intl.DateTimeFormat`. Use `pattern` for common presets or `intl` for a custom `Intl.DateTimeFormatOptions` object. `intl` and `pattern` are **mutually exclusive** — enforced at the type level. **Parameters — `FormatOptions` (discriminated union):** | Variant | Option | Type | Default | Description | | ---------------- | --------- | ---------------------------- | ------------- | -------------------------------------------------------- | | `pattern` branch | `pattern` | `FormatPattern` | `'medium'` | Preset shorthand | | `intl` branch | `intl` | `Intl.DateTimeFormatOptions` | — | Full `Intl` spec; mutually exclusive with `pattern` | | Both variants | `locale` | `Intl.LocalesArgument` | system locale | BCP 47 locale tag | | Both variants | `tz` | `string` | — | Inferred from `ZonedDateTime` inputs; required otherwise | **Patterns:** | Pattern | Equivalent | | ------------- | --------------------------------------------- | | `'short'` | `{ dateStyle: 'short', timeStyle: 'short' }` | | `'medium'` | `{ dateStyle: 'medium', timeStyle: 'short' }` | | `'long'` | `{ dateStyle: 'full', timeStyle: 'long' }` | | `'date-only'` | `{ dateStyle: 'short' }` | | `'time-only'` | `{ timeStyle: 'short' }` | **Example:** ```ts import { format, parseInstant } from '@vielzeug/tempo'; format(parseInstant('2026-03-21T10:15:30Z'), { pattern: 'short', locale: 'en-GB', tz: 'UTC' }); // → '21/03/2026, 10:15' // Escape hatch: full Intl spec format(parseInstant('2026-03-21T10:15:30Z'), { intl: { hour: '2-digit', minute: '2-digit', hour12: false }, locale: 'en-US', tz: 'UTC', }); // → '10:15' ``` --- ### `formatParts(input, options?): Intl.DateTimeFormatPart[]` ```ts formatParts(input: TimeInput, options?: FormatOptions): Intl.DateTimeFormatPart[]; ``` Returns the raw `Intl.DateTimeFormatPart[]` array for `input`, enabling custom rendering where individual parts (year, month, day, etc.) need to be styled or composed differently. Accepts the same options as `format()`. **Example:** ```ts import { formatParts, parseInstant } from '@vielzeug/tempo'; const parts = formatParts(parseInstant('2026-03-21T10:15:30Z'), { pattern: 'date-only', tz: 'UTC' }); // [{ type: 'month', value: '3' }, { type: 'literal', value: '/' }, ...] ``` --- ### `formatInstant(input, options?): string` ```ts formatInstant(input: TimeInput, options?: TimeOptions): string; ``` Returns a UTC ISO-8601 instant string (`YYYY-MM-DDTHH:mm:ssZ`). Use this for transport, logging, and APIs. **Example:** ```ts import { formatInstant, parseInstant } from '@vielzeug/tempo'; formatInstant(parseInstant('2026-03-21T10:15:30Z')); // → '2026-03-21T10:15:30Z' ``` --- ### `formatZoned(input, options?): string` ```ts formatZoned(input: TimeInput, options?: TimeOptions): string; ``` Returns a full zoned ISO-8601 string including offset and timezone ID. Infers timezone from `ZonedDateTime` inputs; requires `options.tz` for all other input types. **Example:** ```ts import { formatZoned, parseInstant, parseZoned } from '@vielzeug/tempo'; formatZoned(parseInstant('2026-03-21T10:15:30Z'), { tz: 'Europe/Berlin' }); // → '2026-03-21T11:15:30+01:00[Europe/Berlin]' formatZoned(parseZoned('2026-03-21T10:15:30+01:00[Europe/Berlin]')); // → '2026-03-21T10:15:30+01:00[Europe/Berlin]' ``` --- ### `formatRange(start, end, options?): string` ```ts formatRange(start: TimeInput, end: TimeInput, options?: FormatOptions): string; ``` Formats a localized time span using `Intl.DateTimeFormat.formatRange`. Infers a shared timezone from `ZonedDateTime` inputs; throws if they are in different zones and `options.tz` is omitted. **Example:** ```ts import { formatRange, parseInstant } from '@vielzeug/tempo'; formatRange(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:00:00Z'), { pattern: 'short', locale: 'en-US', tz: 'UTC', }); // → '3/21/2026, 10:00 – 12:00 AM' ``` --- ### `formatRangeParts(start, end, options?): Intl.DateTimeRangeFormatPart[]` ```ts formatRangeParts( start: TimeInput, end: TimeInput, options?: FormatOptions, ): ReturnType; ``` Returns the raw `Intl.DateTimeRangeFormatPart[]` array for a time span, enabling fine-grained rendering of range start, end, and shared parts separately. Applies the same timezone-inference and mismatch-detection rules as `formatRange()`. **Example:** ```ts import { formatRangeParts, parseInstant } from '@vielzeug/tempo'; const parts = formatRangeParts(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:00:00Z'), { pattern: 'short', locale: 'en-US', tz: 'UTC', }); // [{ type: 'month', value: '3', source: 'shared' }, ...] // Render only the start part const startParts = parts.filter((p) => p.source === 'startRange' || p.source === 'shared'); ``` --- ### `formatRelative(input, options?): string` ```ts formatRelative(input: RelativeTimeInput, options?: RelativeFormatOptions): string; ``` Returns a UX-friendly relative time string ("in 2 hours", "3 days ago") using `Intl.RelativeTimeFormat`. When `options.base` is omitted, the current instant is used. **Parameters — `RelativeFormatOptions`:** | Option | Type | Default | Description | | --------- | -------------------------------- | -------- | -------------------------------------------------------- | | `base` | `RelativeTimeInput` | `now` | Reference point for the relative calculation | | `locale` | `Intl.LocalesArgument` | system | BCP 47 locale tag | | `numeric` | `Intl.RelativeTimeFormatNumeric` | `'auto'` | `'always'` forces "1 day ago"; `'auto'` uses "yesterday" | | `style` | `Intl.RelativeTimeFormatStyle` | `'long'` | `'short'` or `'narrow'` for compact labels | Unit selection uses approximate thresholds: differences under 60 s → `'second'`, under 60 min → `'minute'`, under 24 h → `'hour'`, under 7 d → `'day'`, under ~4.35 weeks → `'week'`, under 12 months → `'month'`, otherwise `'year'`. These thresholds use fixed second constants (1 month ≈ 30.4375 days) and do not account for DST — use `difference()` with `ZonedDateTime` inputs for calendar-accurate results. **Example:** ```ts import { formatRelative, parseInstant } from '@vielzeug/tempo'; const base = parseInstant('2026-03-21T10:00:00Z'); formatRelative(parseInstant('2026-03-21T12:00:00Z'), { base, locale: 'en-US', numeric: 'always' }); // → 'in 2 hours' formatRelative(parseInstant('2026-03-19T10:00:00Z'), { base, locale: 'en-US' }); // → '2 days ago' ``` ## Duration Helpers ### `parseDuration(input): Temporal.Duration` ```ts parseDuration(input: string | Temporal.DurationLike): Temporal.Duration; ``` Parses an ISO 8601 duration string or a `Temporal.DurationLike` object into a `Temporal.Duration`. Throws `TempoError` with code `INVALID_INPUT` for invalid input. **Example:** ```ts import { parseDuration } from '@vielzeug/tempo'; parseDuration('PT2H30M'); parseDuration({ hours: 2, minutes: 30 }); parseDuration('-PT1H'); // negative duration ``` --- ### `formatDuration(input, options?): string` ```ts formatDuration(input: string | Temporal.DurationLike, options?: DurationFormatOptions): string; ``` Formats a duration for display. Uses `Intl.DurationFormat` when available; falls back to a plain English representation ("2 hours, 30 minutes"). **Parameters — `DurationFormatOptions`:** | Option | Type | Default | Description | | -------- | -------------------------------------------- | -------- | ----------------- | | `locale` | `Intl.LocalesArgument` | system | BCP 47 locale tag | | `style` | `'digital' \| 'long' \| 'narrow' \| 'short'` | `'long'` | Display style | **Example:** ```ts import { formatDuration } from '@vielzeug/tempo'; formatDuration('PT2H30M', { locale: 'en-US', style: 'short' }); formatDuration({ hours: 1, minutes: 30 }, { locale: 'de-DE' }); ``` ## Expiry and Classification ### `expires(date, thresholds, options?, now?): K | null` ```ts expires( date: TimeInput, thresholds: Record, options?: TimeOptions, now?: Temporal.Instant, ): K | null; ``` Classifies `date` into a named bucket by computing `diff = date − now` and returning the key of the first threshold where `diff ≤ threshold`. Thresholds are sorted ascending before comparison. - Returns `null` when no threshold matches. - Requires `options.tz` when input is `PlainDate` or `PlainDateTime`. - Pass `now` to fix the reference point (useful in tests). **Negative thresholds classify past dates.** A threshold of `{ days: -N }` matches dates that are more than N days in the _past_ (i.e. `diff ≤ -N days`). Use negative thresholds at the front of your map for "expired" or "overdue" buckets: ```ts const THRESHOLDS = { longExpired: { days: -30 }, // more than 30 days ago expired: { days: 0 }, // any past date (diff ≤ 0) critical: { days: 3 }, // within 3 days in the future warning: { days: 14 }, safe: { years: 100 }, } as const; ``` **Performance:** define threshold objects at module scope (not inline literals) so the internal `WeakMap` sort-cache is effective. **Example:** ```ts import { expires } from '@vielzeug/tempo'; const THRESHOLDS = { longExpired: { days: -30 }, // more than 30 days past expired: { days: 0 }, // any past date critical: { days: 3 }, // within 3 days warning: { days: 14 }, // within 14 days safe: { years: 100 }, // catch-all far future } as const; expires(Temporal.Now.instant().subtract({ days: 60 }), THRESHOLDS); // 'longExpired' expires(Temporal.Now.instant().subtract({ hours: 6 }), THRESHOLDS); // 'expired' expires(Temporal.Now.instant().add({ hours: 48 }), THRESHOLDS); // 'critical' expires(Temporal.Now.instant().add({ days: 10 }), THRESHOLDS); // 'warning' expires(Temporal.Now.instant().add({ years: 1 }), THRESHOLDS); // 'safe' // No threshold matches — returns null expires(Temporal.Now.instant().add({ years: 200 }), { soon: { days: 3 } }); // null ``` --- ### `timeDiff(a, b?, options?): TimeDiffResult` ```ts timeDiff(a: TimeInput, b?: TimeInput, options?: TimeOptions): TimeDiffResult; ``` Returns the absolute difference between `a` and `b` as `{ unit, value }` in the largest meaningful unit. When `b` is omitted, the current instant is used. **Timezone requirement:** - When both inputs are `Temporal.Instant`: no `tz` needed — uses millisecond arithmetic (1 day = 86 400 s). - When either input is `PlainDate`, `PlainDateTime`, or `ZonedDateTime`: `tz` is required (inferred from `ZonedDateTime` inputs). Sub-second differences return `{ unit: 'millisecond', value: }`. Zero difference returns `{ unit: 'millisecond', value: 0 }`. **Example:** ```ts import { parseInstant, parseZoned, timeDiff } from '@vielzeug/tempo'; // No tz needed for two Instants — uses ms arithmetic (1 year ≈ 365.25 days) // 2026-01 → 2027-06 ≈ 17 months; floor(17/12) = 1, so unit is 'year', value is 1 timeDiff(parseInstant('2026-01-01T00:00:00Z'), parseInstant('2027-06-01T00:00:00Z')); // { unit: 'year', value: 1 } // b defaults to now timeDiff(Temporal.Now.instant().subtract({ hours: 3 })); // { unit: 'hour', value: 3 } // Calendar-accurate with tz timeDiff(parseInstant('2026-01-01T00:00:00Z'), parseZoned('2026-06-15T00:00:00[UTC]'), { tz: 'UTC', }); // { unit: 'month', value: 5 } ``` --- ### `humanize(diff, options?): string` ```ts humanize(diff: TimeDiffResult, options?: { locale?: Intl.LocalesArgument }): string; ``` Converts a `TimeDiffResult` to a human-readable string. Uses the singular form when `value === 1`, plural otherwise. Unit names are **English-only** — use `formatRelative()` or `formatDuration()` for fully localized output. **Parameters — `options`:** | Option | Type | Default | Description | | -------- | ---------------------- | ----------- | --------------------------------------------------- | | `locale` | `Intl.LocalesArgument` | `undefined` | Locale for the numeric part via `Intl.NumberFormat` | **Returns:** `string` **Example:** ```ts import { humanize, timeDiff } from '@vielzeug/tempo'; humanize({ unit: 'day', value: 1 }); // '1 day' humanize({ unit: 'hour', value: 7 }); // '7 hours' humanize({ unit: 'millisecond', value: 0 }); // '0 milliseconds' humanize({ unit: 'day', value: 3 }, { locale: 'ar' }); // '٣ days' // Typical combined usage humanize(timeDiff(publishedAt)); // '3 days' ``` ## Range and Recurrence ### `dateRange(start, end, step, options?): Generator` ```ts dateRange( start: TimeInput, end: TimeInput, step: Temporal.DurationLike, options?: TimeOptions, ): Generator; ``` Lazily generates `ZonedDateTime` values between `start` and `end` (inclusive), advancing by `step`. Returns a generator — use `for...of` for lazy consumption or spread (`[...dateRange(...)]`) to collect into an array. Returns nothing when `start > end`. Throws `RangeError` if `step` does not advance the date forward. When `start` is a `ZonedDateTime`, `options.tz` is inferred from it automatically. If `end` is in a different timezone it is silently re-projected into `start`'s timezone. Pass `options.tz` explicitly to override. For plain inputs, `options.tz` is required. **Example:** ```ts import { dateRange, parsePlainDate, parseZoned } from '@vielzeug/tempo'; const start = parseZoned('2026-03-01T00:00:00[UTC]'); const end = parseZoned('2026-03-31T00:00:00[UTC]'); // ZonedDateTime inputs — tz inferred, no need to pass options for (const day of dateRange(start, end, { days: 1 })) { render(day); if (someCondition) break; // safe to break early } // Collect to array const days = [...dateRange(start, end, { days: 1 })]; // [Mar 1, Mar 2, ..., Mar 31] // Plain inputs still require tz const days = [...dateRange(parsePlainDate('2026-03-01'), parsePlainDate('2026-03-31'), { days: 1 }, { tz: 'UTC' })]; ``` --- ### `recurrence(start, rule, options?): Generator` ```ts recurrence( start: TimeInput, rule: RecurrenceRule, options?: TimeOptions, ): Generator; ``` Lazily generates `ZonedDateTime` occurrences according to a recurrence rule. Supports `daily`, `weekly`, `monthly`, and `yearly` frequencies. Either `count` or `until` (or both) **must** be provided — this is enforced at compile time by the `RecurrenceRule` type and validated eagerly at call time for JavaScript callers. When `start` is a `ZonedDateTime`, `options.tz` is inferred automatically. **Parameters — `RecurrenceRule`:** | Field | Type | Required | Description | | ----------- | ---------------------------------------------- | ------------------------------------------ | ------------------------------------- | | `frequency` | `'daily' \| 'weekly' \| 'monthly' \| 'yearly'` | | Recurrence frequency | | `interval` | `number` | — | Step multiplier (default `1`) | | `count` | `number` | One of these two | Maximum number of occurrences to emit | | `until` | `TimeInput` | One of these two | Inclusive end boundary | **Example:** ```ts import { parseInstant, parseZoned, recurrence } from '@vielzeug/tempo'; const start = parseZoned('2026-01-05T09:00:00[Europe/Berlin]'); // ZonedDateTime start — tz inferred, no options needed const mondays = [...recurrence(start, { frequency: 'weekly', count: 4 })]; // Bi-weekly meetings until a deadline const deadline = parseZoned('2026-06-30T00:00:00[Europe/Berlin]'); for (const meeting of recurrence(start, { frequency: 'weekly', interval: 2, until: deadline })) { schedule(meeting); } // Plain input — tz required const quarters = [ ...recurrence(parseInstant('2026-01-05T09:00:00Z'), { frequency: 'monthly', interval: 3, count: 6 }, { tz: 'UTC' }), ]; ``` ## Errors All errors thrown by tempo are instances of `TempoError`. Catch the base class to handle any tempo-originated error, or catch a specific subtype to distinguish failure modes. ```ts import { TempoError, TempoMissingTzError, parse, parsePlainDateTime, toInstant } from '@vielzeug/tempo'; try { parse('not-a-date'); } catch (e) { if (e instanceof TempoError) { console.log(e.name); // 'TempoInvalidInputError' console.log(e.message); // 'Unable to parse date/time string: "not-a-date". ...' } } try { toInstant(parsePlainDateTime('2026-03-21T10:00:00')); } catch (e) { if (e instanceof TempoMissingTzError) { // narrow to this specific failure mode } } ``` ### `TempoError` Base class for every error tempo throws. Use `TempoError.is()` to catch anything tempo-originated regardless of subtype. ```ts class TempoError extends Error { constructor(message: string, opts?: ErrorOptions); static is(err: unknown): err is TempoError; } ``` --- ### `TempoInvalidInputError` Thrown by `parse()`, `parseInstant()`, `parseZoned()`, `parsePlainDate()`, `parsePlainDateTime()`, and `parseDuration()` when the input string cannot be understood. Also the default error class for any `fail()` call site that doesn't specify a more specific subtype (e.g. cross-timezone mismatches in `within()` / `clamp()` / `difference()`). ### `TempoInvalidTzError` Thrown when a timezone string is not a valid IANA name or UTC offset — from `validateTz()`, used by every function that resolves a `tz` option (`now()`, `inTz()`, `shift()`, `startOf()`, `endOf()`, etc.). ### `TempoMissingTzError` Thrown when an operation requires a timezone but none could be inferred — a `PlainDate` or `PlainDateTime` input was passed without `options.tz`, or without a shared timezone across two/more `TimeInput` values. ### `TempoUnsupportedInputError` Thrown by `toInstant()` when the input value is not one of the four `TimeInput` types (`Instant`, `ZonedDateTime`, `PlainDateTime`, `PlainDate`). --- ## Types ```ts type TimeInput = Temporal.Instant | Temporal.PlainDate | Temporal.PlainDateTime | Temporal.ZonedDateTime; type RelativeTimeInput = Temporal.Instant | Temporal.ZonedDateTime; type DateTimeDisambiguation = 'compatible' | 'earlier' | 'later' | 'reject'; /** Discriminant for the parse() `as` parameter. Controls the expected return type. */ type ParseAs = 'instant' | 'plain-date' | 'plain-datetime' | 'zoned'; type FormatPattern = 'date-only' | 'long' | 'medium' | 'short' | 'time-only'; // Discriminated union — intl and pattern are mutually exclusive type FormatOptions = | { intl: Intl.DateTimeFormatOptions; locale?: Intl.LocalesArgument; pattern?: never; tz?: string } | { intl?: never; locale?: Intl.LocalesArgument; pattern?: FormatPattern; tz?: string }; type TempoUnit = | 'day' | 'hour' | 'microsecond' | 'millisecond' | 'minute' | 'month' | 'nanosecond' | 'second' | 'week' | 'year'; type CalendarUnit = Extract; type BoundaryUnit = Exclude; type TimeDiffUnit = Exclude; type TimeDiffResult = { unit: TimeDiffUnit; value: number }; type WeekStartDay = 1 | 2 | 3 | 4 | 5 | 6 | 7; // Either count or until (or both) must be provided type RecurrenceRule = { frequency: 'daily' | 'monthly' | 'weekly' | 'yearly'; interval?: number; } & ({ count: number; until?: TimeInput } | { count?: number; until: TimeInput }); interface TimeOptions { tz?: string; } interface DisambiguationOptions { prefer?: DateTimeDisambiguation; } interface ShiftOptions extends DisambiguationOptions, TimeOptions {} interface DifferenceOptions extends DisambiguationOptions, TimeOptions { largestUnit?: Temporal.DateTimeUnit; roundingIncrement?: number; roundingMode?: Temporal.RoundingMode; smallestUnit?: Temporal.DateTimeUnit; } interface RelativeFormatOptions { base?: RelativeTimeInput; locale?: Intl.LocalesArgument; numeric?: Intl.RelativeTimeFormatNumeric; style?: Intl.RelativeTimeFormatStyle; } interface BoundaryOptions extends TimeOptions { weekStartsOn?: WeekStartDay; } interface CompareOptions extends TimeOptions { unit?: BoundaryUnit; weekStartsOn?: WeekStartDay; } interface DurationFormatOptions { locale?: Intl.LocalesArgument; style?: 'digital' | 'long' | 'narrow' | 'short'; } ``` ### Usage Guide ## Basic Usage Use named imports from `@vielzeug/tempo` for tree-shaking. ```ts import { format, inTz, now, parsePlainDateTime, shift, toInstant } from '@vielzeug/tempo'; // Current time in a timezone const berlin = now('Europe/Berlin'); // Parse a wall-clock string, then pin it to a timezone const local = parsePlainDateTime('2026-03-21T10:15:30'); const instant = toInstant(local, { tz: 'America/New_York' }); // Project to a different timezone const tokyo = inTz(instant, 'Asia/Tokyo'); // Format for display format(instant, { pattern: 'short', locale: 'en-US', tz: 'America/New_York' }); ``` ## Parsing and Conversion All common Temporal constructors have a tempo equivalent — import only from `@vielzeug/tempo`: | Instead of… | Use… | | ----------------------------------------------------------- | --------------------------------- | | `Temporal.Now.instant()` | `nowInstant()` | | `Temporal.Now.zonedDateTimeISO(tz)` | `now(tz)` | | `Temporal.Instant.from(str)` | `parseInstant(str)` | | `Temporal.ZonedDateTime.from(str)` | `parseZoned(str)` | | `Temporal.PlainDateTime.from(str)` | `parsePlainDateTime(str)` | | `Temporal.PlainDate.from(str)` | `parsePlainDate(str)` | | `Temporal.ZonedDateTime.from` / `Temporal.Instant.from` / … | `parse(str)` (unknown format) | ```ts import { inTz, isValid, nowInstant, parse, parseInstant, parsePlainDateTime, parsePlainDate, parseZoned, toInstant, } from '@vielzeug/tempo'; // Current instant const t = nowInstant(); // Wall-clock string from user input or database const local = parsePlainDateTime('2026-03-21T10:15:30'); const instant = toInstant(local, { tz: 'Europe/Berlin' }); const tokyo = inTz(instant, 'Asia/Tokyo'); // UTC ISO string from an API response const ts = parseInstant('2026-03-21T10:15:30Z'); // Zoned date-time string const meeting = parseZoned('2026-03-21T11:00:00+01:00[Europe/Berlin]'); // Date-only string const date = parsePlainDate('2026-03-21'); // Unknown ISO format — picks the most specific type automatically parse('2026-03-21T11:00:00+01:00[Europe/Berlin]'); // ZonedDateTime parse('2026-03-21T10:00:00Z'); // Instant parse('2026-03-21T10:00:00'); // PlainDateTime parse('2026-03-21'); // PlainDate // Type guard — validate before passing to Tempo functions if (isValid(externalValue)) { format(externalValue, { pattern: 'short', tz: 'UTC' }); } ``` ## DST-Safe Arithmetic `shift()` handles DST transitions correctly. ```ts import { parseZoned, shift } from '@vielzeug/tempo'; const before = parseZoned('2026-03-08T01:30:00-05:00[America/New_York]'); const after = shift(before, { hours: 1 }); console.log(after.toString()); // 2026-03-08T03:30:00-04:00[America/New_York] ``` ## Difference and Range Tools ```ts import { clamp, difference, parseInstant, within } from '@vielzeug/tempo'; // difference() returns a signed duration: negative when start is after end const duration = difference(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:30:00Z'), { tz: 'UTC', largestUnit: 'hour', smallestUnit: 'minute', }); const inWindow = within( parseInstant('2026-03-21T11:00:00Z'), parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:00:00Z'), ); const inWindowByDay = within( parseInstant('2026-03-22T04:59:00Z'), parseInstant('2026-03-21T06:00:00Z'), parseInstant('2026-03-22T03:00:00Z'), { unit: 'day', tz: 'America/New_York' }, ); // clamp returns Temporal.Instant — project to a timezone as needed const clamped = clamp( parseInstant('2026-03-21T13:00:00Z'), parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:00:00Z'), ); const bounded = clamped.toZonedDateTimeISO('UTC'); // with unit comparison, clamp aligns to the requested unit boundary const clampedByDay = clamp( parseInstant('2026-03-23T05:00:00Z'), parseInstant('2026-03-21T09:00:00Z'), parseInstant('2026-03-22T18:00:00Z'), { unit: 'day', tz: 'America/New_York' }, ); ``` ## Comparison Helpers ```ts import { isAfter, isBefore, isSame, parseInstant } from '@vielzeug/tempo'; isBefore(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T11:00:00Z')); isAfter(parseInstant('2026-03-21T12:00:00Z'), parseInstant('2026-03-21T11:00:00Z')); isSame(parseInstant('2026-03-21T23:30:00Z'), parseInstant('2026-03-22T00:15:00Z'), { unit: 'day', tz: 'America/New_York', }); isBefore(parseInstant('2026-03-21T23:30:00Z'), parseInstant('2026-03-22T00:15:00Z'), { unit: 'day', tz: 'UTC', }); ``` ## Start and End Boundaries ```ts import { endOf, parseInstant, startOf } from '@vielzeug/tempo'; const dayStart = startOf(parseInstant('2026-03-21T10:15:30Z'), 'day', { tz: 'UTC' }); const dayEnd = endOf(parseInstant('2026-03-21T10:15:30Z'), 'day', { tz: 'UTC' }); const weekStart = startOf(parseInstant('2026-03-21T10:15:30Z'), 'week', { tz: 'Europe/Berlin', weekStartsOn: 1, }); ``` ## Formatting Use `format()` for UI, `formatInstant()`/`formatZoned()` for machine output, `formatRelative()` for UX copy. ```ts import { format, formatInstant, formatParts, formatRange, formatRangeParts, formatRelative, formatZoned, parseInstant, } from '@vielzeug/tempo'; const instant = parseInstant('2026-03-21T10:15:30Z'); format(instant, { pattern: 'short', locale: 'en-GB', tz: 'UTC' }); formatInstant(instant); formatZoned(instant, { tz: 'Europe/Berlin' }); formatRange(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:00:00Z'), { pattern: 'short', locale: 'en-US', tz: 'America/New_York', }); formatRelative(parseInstant('2026-03-21T12:00:00Z'), { base: parseInstant('2026-03-21T10:00:00Z'), locale: 'en-US', numeric: 'always', }); // formatParts / formatRangeParts — raw Intl parts for custom rendering const parts = formatParts(instant, { pattern: 'date-only', tz: 'UTC' }); // [{ type: 'month', value: '3' }, { type: 'literal', value: '/' }, ...] const rangeParts = formatRangeParts(parseInstant('2026-03-21T10:00:00Z'), parseInstant('2026-03-21T12:00:00Z'), { pattern: 'short', locale: 'en-US', tz: 'UTC', }); const startOnly = rangeParts.filter((p) => p.source === 'startRange' || p.source === 'shared'); ``` ## Duration Helpers ```ts import { formatDuration, parseDuration } from '@vielzeug/tempo'; const duration = parseDuration('PT2H30M'); const text = formatDuration(duration, { locale: 'en-US', style: 'short' }); ``` > **Note:** `formatDuration()` uses `Intl.DurationFormat` when available. In environments that do not support it, it falls back to a plain **English-only** representation (e.g., `'2 hours, 30 minutes'`). ## Expiry and Classification Use `expires()` to classify a date into a named threshold bucket of your choosing. ```ts import { expires, humanize, now, parseInstant, shift, timeDiff } from '@vielzeug/tempo'; const THRESHOLDS = { longExpired: { days: -30 }, // more than 30 days past expired: { days: 0 }, // any past date critical: { days: 3 }, // within 3 days warning: { days: 14 }, // within 14 days safe: { years: 100 }, } as const; // Use shift(now(tz), ...) for day-level offsets expires(shift(now('UTC'), { days: -60 }).toInstant(), THRESHOLDS); // 'longExpired' expires(shift(now('UTC'), { hours: 48 }).toInstant(), THRESHOLDS); // 'critical' expires(shift(now('UTC'), { years: 200 }).toInstant(), THRESHOLDS); // null (no match) // Pin the reference time for deterministic behavior in tests const pinnedNow = parseInstant('2026-06-01T00:00:00Z'); expires(parseInstant('2026-06-04T00:00:00Z'), THRESHOLDS, {}, pinnedNow); // 'critical' // timeDiff — largest-unit human-readable time difference // No tz needed when both are Instants timeDiff(parseInstant('2026-01-01T00:00:00Z'), parseInstant('2027-06-01T00:00:00Z')); // { unit: 'year', value: 1 } // humanize converts a TimeDiffResult to a readable string (English only) humanize(timeDiff(expiresAt)); // '3 days', '1 hour', etc. ``` ## Date Ranges and Recurrence Use `dateRange()` to lazily generate sequences of `ZonedDateTime` values for calendars, reports, or iteration. When `start` is a `ZonedDateTime`, the timezone is inferred automatically — no need to pass `options`. For plain inputs, pass `options.tz` explicitly. ```ts import { dateRange, parseZoned, recurrence } from '@vielzeug/tempo'; // dateRange returns a Generator — use for...of or spread to collect const start = parseZoned('2026-03-01T00:00:00[UTC]'); const end = parseZoned('2026-03-31T00:00:00[UTC]'); // ZonedDateTime inputs — tz inferred, no options needed for (const day of dateRange(start, end, { days: 1 })) { render(day); } // Collect to array const days = [...dateRange(start, end, { days: 1 })]; // Every Monday in a date range const mondays = [ ...dateRange(parseZoned('2026-03-02T00:00:00[UTC]'), parseZoned('2026-03-30T00:00:00[UTC]'), { weeks: 1 }), ]; // recurrence — repeating dates with count or until const meetingStart = parseZoned('2026-01-05T09:00:00[Europe/Berlin]'); const deadline = parseZoned('2026-06-30T00:00:00[Europe/Berlin]'); // ZonedDateTime start — tz inferred, no options needed for (const meeting of recurrence(meetingStart, { frequency: 'weekly', until: deadline })) { schedule(meeting); } // Every 3 months for 6 occurrences (tz inferred from ZonedDateTime start) const quarters = [...recurrence(meetingStart, { frequency: 'monthly', interval: 3, count: 6 })]; ``` ## Framework Integration Tempo is a pure-utility library with no subscription model. Use its functions directly wherever date/time values are formatted or computed. ```tsx [React] import { format, now, parseInstant, shift } from '@vielzeug/tempo'; function DeadlineLabel({ iso }: { iso: string }) { const deadline = parseInstant(iso); const tomorrow = shift(now('UTC'), { days: 1 }); const isUrgent = deadline.epochMilliseconds {format(deadline, { locale: navigator.language })}; } ``` ```ts [Vue 3] import { computed } from 'vue'; import { format, now, parseInstant, shift } from '@vielzeug/tempo'; function useDeadlineLabel(iso: string) { return computed(() => { const deadline = parseInstant(iso); const tomorrow = shift(now('UTC'), { days: 1 }); const isUrgent = deadline.epochMilliseconds import { format, now, parseInstant, shift } from '@vielzeug/tempo'; export let iso: string; $: deadline = parseInstant(iso); $: isUrgent = deadline.epochMilliseconds {label} ``` ## Working with Other Vielzeug Libraries ### With Rune Format timestamps for structured log output using Tempo. ```ts import { createLogger } from '@vielzeug/rune'; import { formatInstant, now } from '@vielzeug/tempo'; const log = createLogger({ namespace: 'app' }); log.info({ timestamp: formatInstant(now('UTC')) }, 'server started'); ``` ### With Vault Use TTL values derived from Tempo duration helpers. ```ts import { createLocalStorage, table, ttl } from '@vielzeug/vault'; import { shift, now } from '@vielzeug/tempo'; type Session = { id: string; token: string }; const schema = { sessions: table('id') }; const db = createLocalStorage('app', schema); // Store session with a 1-hour TTL const expiresIn = shift(now('UTC'), { hours: 1 }).toInstant().epochMilliseconds - Date.now(); await db.put('sessions', { id: '1', token: 'abc' }, ttl.ms(expiresIn)); ``` ## Best Practices - Store `Temporal.Instant` values in databases and APIs — never store offset-aware strings. - Use `parsePlainDateTime()` at the system boundary when receiving wall-clock strings from external sources; use `parseInstant()` for UTC ISO strings; use `parse()` when the format is unknown. - Use `isValid()` as a type guard when accepting `TimeInput` from external data. - Convert to `ZonedDateTime` only when rendering to users; keep instants everywhere else. - Always pass `tz` when calling `toInstant()`, `shift()`, or `difference()` with plain inputs. - Use `format()` for UI labels, `formatInstant()` for transport/logging, and `formatZoned()` for zoned ISO strings. - Use `formatParts()` / `formatRangeParts()` when individual date parts need separate styling. - Use `formatRelative()` for UX copy ("3 hours ago") rather than computing the difference manually. - Prefer `dateRange()` over manual `while` loops when generating sequences of dates. ### Examples ## Examples - [DST-Safe Arithmetic](./examples/dst-safe-arithmetic.md) - [Locale Formatting](./examples/locale-formatting.md) - [Timezone Conversion](./examples/timezone-conversion.md) - [Expiry Classification](./examples/expiry-classification.md) - [Date Ranges and Recurrence](./examples/date-ranges-and-recurrence.md) ### REPL Examples - Boundary & Relative Time (id: `boundary-and-relative`) - DST-Safe Arithmetic (id: `dst-safe-arithmetic`) - Duration & Timezone Projection (id: `duration-and-projection`) - TempoError — instanceof checks across the error hierarchy (id: `error-handling`) - Expires & Date Range (id: `expires-and-date-range`) - Meeting Duration (id: `meeting-duration`) - Timezone-Aware Scheduling (id: `timezone-aware-scheduling`) - Timezone Projection with inTz (id: `timezone-conversion`) --- ## @vielzeug/vault **Category:** storage **Keywords:** indexeddb, localstorage, storage, offline, ttl, query, schema, session, reactive, signals **Key exports:** createLocalStorage, createSessionStorage, createIndexedDB, createMemory, createVersionedCodec, table, ttl, defaultCodec, isExpired, scheduleExpiredPrune, defineMigration, toReadableStream (+5 more) **Related:** courier, rune, ripple, spell, arsenal ### Overview ## Why Vault? Native browser storage APIs require manual serialisation, no types, and separate APIs per backend. ```ts // Before — raw localStorage with no typing const raw = localStorage.getItem('app:users:1'); const user = raw ? JSON.parse(raw) : null; // unknown type, no TTL, no queries // After — Vault typed adapter import { createLocalStorage, table } from '@vielzeug/vault'; type User = { id: number; name: string; age: number }; const schema = { users: table('id') }; const db = createLocalStorage({ name: 'app', schema }); const user = await db.get('users', 1); // User | undefined — fully typed await db.put('users', { id: 2, name: 'Bob', age: 25 }, ttl.hours(1)); // TTL built in const adults = await db.query('users').between('age', 18, 99).orderBy('name').toArray(); ``` | Feature | Vault | Dexie.js | idb-keyval | Raw Web Storage | | ----------------------- | ------------------------------------------- | ------------------------------------------ | ------------------------------------------ | -------------------------------------- | | Bundle size | | ~26 kB | ~1.3 kB | Native | | TypeScript schema types | | | | | | Query builder | | | | | | TTL | | | | Manual | | Multiple backends | | IDB only | IDB only | localStorage only | | Reactivity | | `liveQuery` | | | | Zero dependencies | | | | Native | **Use Vault when** you need typed, queryable browser storage with TTL and reactivity across LocalStorage, SessionStorage, IndexedDB, and Memory from a single consistent API. **Consider alternatives when** you need a mature IDB-first solution with a large ecosystem — use Dexie.js. For the smallest possible IDB wrapper without abstractions, use `idb-keyval`. For raw performance without any library, use the Web Storage and IndexedDB APIs directly. ## Installation ```sh [pnpm] pnpm add @vielzeug/vault ``` ```sh [npm] npm install @vielzeug/vault ``` ```sh [yarn] yarn add @vielzeug/vault ``` ## Quick Start ```ts import { createIndexedDB, table, ttl } from '@vielzeug/vault'; type User = { id: number; name: string; age: number }; const schema = { users: table('id'), }; const db = createIndexedDB({ name: 'app', schema, version: 1 }); await db.put('users', { id: 1, name: 'Alice', age: 30 }); await db.put('users', { id: 2, name: 'Bob', age: 25 }, ttl.hours(1)); const adults = await db.query('users').between('age', 18, 99).orderBy('name').toArray(); void adults; ``` ## Features - **`table(key)`** — typed schema entry; infers record type and primary-key field; chain `.ttl(ms)` for a per-table default TTL - **`createLocalStorage`** / **`createSessionStorage`** / **`createIndexedDB`** / **`createMemory`** — four adapters sharing one `Adapter` interface; swap backends without touching application code - **`put`** / **`putAll`** — write one or many records; TTL enforced via the branded `TtlMs` type - **`get`** / **`getAll`** / **`getMany`** — point lookups and bulk fetch; preserves key order, missing keys yield `undefined` - **`update(table, key, changes)`** — shallow-merge partial fields; **`upsert`** for read-modify-write; **`getOrDefault`** for read-or-insert - **`delete`** / **`deleteMany`** / **`clear`** — single, bulk, or full-table deletion - **`query(table)`** — lazy `QueryBuilder` with `.filter()`, `.equals()`, `.between()`, `.startsWith()`, `.orderBy()`, `.limit()`, `.offset()`; terminal `.toArray()` / `.first()` / `.delete()` - **`table('id').index(field)`** — secondary indexes (IndexedDB only); a leading `equals`/`between`/`startsWith` on an indexed field uses a native IDB index instead of a full-table scan - **`observe(table, fn, opts?)`** — subscribe to table changes; fires immediately by default (`{ immediate: false }` to skip); returns unsubscribe function - **`observeMany(tables, fn, opts?)`** — combined snapshot across multiple tables; `{ eager: true }` fires on first partial snapshot; coalesces batch writes - **`watch(table)`** — `AsyncIterable` of fresh snapshots; `mode: 'latest'` drops intermediates; `signal` stops from outside - **`batch(tables, tx => ...)`** — deferred observer notifications on all adapters; atomic IDB transaction on IndexedDB - **`ttl.ms / .seconds / .minutes / .hours / .days`** — branded duration helpers; raw numbers are rejected by the type system - **`pruneExpired`** / **`scheduleExpiredPrune`** — sweep expired records manually or on an interval; pass `signal` to auto-stop on abort - **`keys(table, filter?)`** — return primary keys; pass a predicate to filter without loading records into userland first - **`createVersionedCodec`** — versioned codec for safe upgrades; old records decode with their original codec as long as it is still registered - **`iterate(table)`** — cursor-based `AsyncIterable` over live records (IndexedDB only); avoids loading the full table into memory - Ripple signals plugin, Rune logger plugin, and Spell validators plugin — pass any compatible object; structural, not coupled ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — sync persisted values into signals for reactive UI updates whenever storage changes - [Courier](/courier/) — HTTP client; hydrate Courier's cache from Vault on startup to avoid redundant network requests - [Rune](/rune/) — structured logger; audit storage reads and writes with a Rune transport - [Spell](/spell/) — schema validation; pass a Spell schema to Vault to type-gate values before they are persisted ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | ------------------------------------- | -------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------- | | `table(key)` | Create a typed schema entry | Sync | `key` must be a `string` field of `T` | | `ttl` | Duration helpers for TTL values | Sync | Raw numbers are rejected at the type level — always use `ttl.*` | | `createLocalStorage(opts)` | LocalStorage adapter | Sync | Quota errors surface as `VaultQuotaError`; configure `onQuotaExceeded` | | `createSessionStorage(opts)` | SessionStorage adapter | Sync | Data is lost when the tab closes | | `createIndexedDB(opts)` | IndexedDB adapter with iterate and atomic batch | Sync (lazy open) | First operation opens the DB; call `dispose()` to close it | | `createMemory(opts)` | In-memory adapter for tests and SSR | Sync | Data is not persisted across reloads | | `scheduleExpiredPrune(adapter, opts)` | Schedule periodic TTL pruning | Sync | Auto-stops on `VaultDisposedError`; pass `onError` to surface non-disposal errors | | `db.put / putAll` | Write one or many records | Async | Validators run on every write — a failed `parse()` throws before touching storage | | `db.get / getAll / getMany` | Read records | Async | Expired records are never returned — check `db.debug()` for expired count | | `db.keys(table, filter?)` | Return primary keys; optional filter predicate | Async | With `filter`, fetches all records internally — no native key-only path | | `createVersionedCodec(versions, v)` | Codec that dispatches by version number | Sync | Records from other codecs (no `__v` field) decode as `undefined` — migrate first | | `db.entries(table)` | Return all `[key, record]` pairs | Async | Skips expired records | | `db.getOrDefault(table, key, fn)` | Read-or-insert at the adapter level | Async | Not atomic on memory/WebStorage; wrap in `batch()` on IDB for atomicity | | `db.query(table)` | Start a lazy query pipeline | Sync (lazy) | `count()` respects `limit`/`offset`; use `totalCount()` for the full set size | | `db.batch(tables, fn)` | Multi-table write with deferred notifications | Async | Atomic only on IndexedDB — a dev warning fires on Memory/WebStorage adapters | | `db.isEmpty(table)` | Returns `true` when the table has no live records | Async | Treats TTL-expired records as absent — consistent with `count()` | | `db.observe(table, fn, opts?)` | Subscribe to table changes — fires immediately on registration | Sync | Pass `{ immediate: false }` to skip the initial snapshot; returns `Unsubscribe` | | `db.watch(table, opts?)` | Async iterable of table snapshots | Async | Subscribes eagerly on `[Symbol.asyncIterator]()`; always pass `signal` or `break` | | `db.iterate(table)` | Lazy async iteration over live records | Async | Available on IndexedDB and Memory adapters only — not on LocalStorage/SessionStorage | | `toReadableStream(iterable)` | Convert `db.watch()` to a `ReadableStream` | Sync | Always cancel the stream when done to stop the underlying observer | | `isExpired(expiresAt)` | Check if an epoch-ms timestamp has passed | Sync | Safe to call with `undefined` — returns `false` | | `db.update(table, key, changes)` | Merge fields into an existing record | Async | Returns `undefined` when the key does not exist — use `upsert` for insert-or-update | | `db.upsert(table, key, fn)` | Read-modify-write | Async | `fn` always receives the current record; never the stale previous value | | `db.disposalSignal` | `AbortSignal` aborted on disposal | Sync getter | Tie external lifetimes (timers, streams) to this adapter | | `db.dispose()` | Release all resources | Async | Idempotent; all subsequent operations throw `VaultDisposedError` | | `db.disposed` | `true` after `dispose()` is called | Sync getter | — | | `db[Symbol.asyncDispose]()` | Delegates to `dispose()` | Async | Enables `await using` declarations | ## Package Entry Point | Import | Purpose | | ----------------- | ---------------------- | | `@vielzeug/vault` | Main exports and types | ## Schema Helper ### `table` ```ts function table, Key extends keyof T & string = keyof T & string>( key: Key, ): TableBuilder; ``` Creates a typed schema entry. The primary-key field `key` must be a field of `T`. ```ts type User = { id: number; name: string }; const schema = { users: table('id'), }; ``` Chain `.ttl(ms)` to apply a default TTL to all writes on the table: ```ts import { table, ttl } from '@vielzeug/vault'; const schema = { sessions: table('id').ttl(ttl.minutes(30)), }; ``` The TypeScript compiler will reject keys that do not exist on `T`, and downstream operations (`get`, `delete`, `has`, `upsert`) accept only the correct key type. Chain `.index(field)` to register secondary indexes (IndexedDB only). Calling `.index()` twice with the same field throws `VaultError` synchronously: ```ts // valid const schema = { products: table('id').index('category').index('name') }; // throws VaultError: table index "category" is already registered const bad = table('id').index('category').index('category'); ``` > **Custom codec caveat:** the IndexedDB adapter creates each index with keyPath `value.`, which assumes the default `{ value, expiresAt? }` storage envelope (see [`VaultCodec`](#vaultcodec)). A custom codec that changes the top-level shape — including [`createVersionedCodec`](#createversionedcodec) — breaks index push-down silently: queries on the indexed field return empty results instead of throwing. Don't combine `.index()` with a non-default codec unless the codec preserves `value.`. ## TTL Helper ```ts import { ttl, type TtlMs } from '@vielzeug/vault'; ttl.ms(n: number): TtlMs ttl.seconds(n: number): TtlMs ttl.minutes(n: number): TtlMs ttl.hours(n: number): TtlMs ttl.days(n: number): TtlMs ``` `TtlMs` is a branded `number` type. Raw numeric literals are rejected by the type checker — always use these helpers. All helpers throw synchronously if `n` is not a finite positive number (zero is rejected because it would create an immediately-expired record). Values that overflow to `Infinity` after multiplication are also rejected. Passing an invalid `TtlMs` value directly to a write method also throws. ## `scheduleExpiredPrune` ```ts function scheduleExpiredPrune( adapter: Pick, 'pruneExpired'>, options: { interval: number; onError?: (err: unknown) => void; signal?: AbortSignal; }, ): () => void; ``` Calls `adapter.pruneExpired()` on a repeating interval. Returns a `stop` function. The schedule stops automatically if `pruneExpired()` throws `VaultDisposedError` — no cleanup needed after `dispose()` if the adapter is disposed before the timer fires. Without `onError`, other errors from `pruneExpired()` (e.g. IDB failures) emit a `[@vielzeug/vault]` dev warning and the interval continues running. Pass `onError` to handle them programmatically: ```ts import { scheduleExpiredPrune, ttl } from '@vielzeug/vault'; const stop = scheduleExpiredPrune(db, { interval: ttl.hours(1), onError: (err) => console.error('prune failed:', err), }); // cancel on teardown (before dispose) stop(); ``` Pass `signal` to tie the schedule lifetime to an `AbortController` or `db.disposalSignal`: ```ts // auto-stop when the adapter is disposed scheduleExpiredPrune(db, { interval: ttl.hours(1), signal: db.disposalSignal, }); ``` ## Factories All four factories accept the same optional plugin options and return `Adapter`. ### `createLocalStorage` ```ts createLocalStorage(options: { codec?: VaultCodec; logger?: VaultLogger; name: string; onMetrics?: (event: MetricsEvent) => void; onQuotaExceeded?: (table: keyof S, error: VaultQuotaError) => 'ignore' | 'throw'; schema: S; signals?: TableSignals; validators?: TableValidators; }): Adapter ``` `onQuotaExceeded` is called when a `setItem` throws a `QuotaExceededError`. Return `'ignore'` to silently drop the write, or `'throw'` (default) to rethrow. > **Note:** If the underlying storage is unavailable (e.g. private browsing, sandboxed iframe), the factory throws a `VaultError` synchronously. ### `createSessionStorage` ```ts createSessionStorage(options: { codec?: VaultCodec; logger?: VaultLogger; name: string; onMetrics?: (event: MetricsEvent) => void; onQuotaExceeded?: (table: keyof S, error: VaultQuotaError) => 'ignore' | 'throw'; schema: S; signals?: TableSignals; validators?: TableValidators; }): Adapter ``` ### `createIndexedDB` ```ts createIndexedDB(options: { codec?: VaultCodec; logger?: VaultLogger; migrate?: MigrationFn; name: string; onMetrics?: (event: MetricsEvent) => void; schema: S; signals?: TableSignals; validators?: TableValidators; version?: number; }): IndexedDbAdapter ``` Returns an `IndexedDbAdapter`, which extends `Adapter` with the cursor-based `iterate()` method. The IDB adapter opens the database lazily on first operation. `migrate` is called during `onupgradeneeded` when `version` is higher than the stored version. `version` defaults to `1` when omitted. The adapter also opens a `BroadcastChannel` (when available) so observer notifications propagate across tabs. ### `createMemory` ```ts createMemory(options: { codec?: VaultCodec; logger?: VaultLogger; name?: string; onMetrics?: (event: MetricsEvent) => void; schema: S; signals?: TableSignals; validators?: TableValidators; }): Adapter ``` When `name` is provided and `BroadcastChannel` is available, all `createMemory` instances with the same `name` in the same origin replicate mutations to each other (cross-tab synchronisation). If `BroadcastChannel` is not available, the option is silently ignored. ## IndexedDbAdapter `IndexedDbAdapter` is the type returned by `createIndexedDB`. It extends `Adapter` with one additional method: ```ts export type IndexedDbAdapter = Adapter & { /** * Cursor-based lazy iteration over all live records in the table. * Records are streamed via an IDB cursor — the full table is never materialized in memory. * Expired records are skipped automatically. * * Each call opens a fresh readonly IDB transaction. * Throws `VaultDisposedError` if called after `dispose()`. */ iterate(table: K): AsyncIterable>; }; ``` **Usage:** ```ts import { createIndexedDB, table } from '@vielzeug/vault'; import type { IndexedDbAdapter } from '@vielzeug/vault'; type User = { id: number; name: string }; const schema = { users: table('id') }; const db: IndexedDbAdapter = createIndexedDB({ name: 'app', schema, version: 1 }); for await (const user of db.iterate('users')) { await processUser(user); } ``` > `iterate` is also available on `MemoryAdapter` (see below). It is **not** available on `createLocalStorage` / `createSessionStorage` — use `getAll()` or `query().toArray()` on those adapters. ## MemoryAdapter `MemoryAdapter` is the type returned by `createMemory`. It extends `Adapter` with the same `iterate()` method as `IndexedDbAdapter`, backed by a plain in-memory `Map` scan instead of an IDB cursor: ```ts export type MemoryAdapter = Adapter & { /** Lazy iteration over all live records in the table. Expired records are skipped automatically. */ iterate(table: K): AsyncIterable>; }; ``` ```ts import { createMemory, table } from '@vielzeug/vault'; import type { MemoryAdapter } from '@vielzeug/vault'; type User = { id: number; name: string }; const schema = { users: table('id') }; const db: MemoryAdapter = createMemory({ schema }); for await (const user of db.iterate('users')) { await processUser(user); } ``` ## Adapter Interface ```ts interface Adapter { /** * Multi-table write with deferred notifications. Atomic on IndexedDB. * Only tables listed in `tables` can be accessed inside the callback. */ batch(tables: readonly K[], fn: (tx: TransactionContext) => Promise): Promise; /** Count of live (non-expired) records. */ count(table: K): Promise; /** Live vs expired record counts per table. For development use. * Also warms the internal `count()` cache for every table. */ debug(): Promise>; delete(table: K, key: KeyOf): Promise; /** Delete multiple records by key in a single operation. Returns the count of deleted records. */ deleteMany(table: K, keys: KeyOf[]): Promise; /** Remove all records from the table. */ clear(table: K): Promise; /** `AbortSignal` aborted when `dispose()` is called. Use to tie external lifetimes to this adapter. */ readonly disposalSignal: AbortSignal; /** Release all resources (observers, signal subscriptions, channel, DB connection). Idempotent. */ dispose(): Promise; /** `true` after `dispose()` has been called. */ readonly disposed: boolean; /** Delegates to `dispose()`. Enables `await using` declarations. */ [Symbol.asyncDispose](): Promise; /** Return all `[key, record]` pairs in the table. Expired records are excluded. */ entries(table: K): Promise, RecordOf]>>; get(table: K, key: KeyOf): Promise | undefined>; getAll(table: K): Promise[]>; /** * Fetch multiple records by key in a single operation. * Preserves input key order. Missing keys yield `undefined`. */ getMany(table: K, keys: KeyOf[]): Promise | undefined>>; /** * Read-or-insert: returns the existing record if present, otherwise calls `defaultFn()`, * writes the result, and returns it. * * **Not atomic on memory and WebStorage adapters.** For guaranteed atomicity, wrap in * `batch(['table'], tx => tx.getOrDefault(...))` with the IndexedDB adapter. */ getOrDefault( table: K, key: KeyOf, defaultFn: () => RecordOf, ttl?: TtlMs, ): Promise>; has(table: K, key: KeyOf): Promise; /** Returns `true` when the table has no live (non-expired) records. Equivalent to `(await count(table)) === 0`. */ isEmpty(table: K): Promise; /** * Return all primary key values in the table. Without `filter`, uses a key-only backend path (no full records fetched). * With `filter`, fetches all records internally and applies the predicate before key extraction. * Expired records are excluded. */ keys(table: K, filter?: (record: RecordOf) => boolean): Promise[]>; /** * Subscribe to table changes. **Always fires immediately** with the current table state on * registration, then fires again on every subsequent mutation. * Returns an unsubscribe function — call it on teardown. * * Pass `{ signal }` to unsubscribe via an `AbortController` instead of — or in addition to — * calling the returned function. */ observe( table: K, listener: Observer>, options?: { /** Skip the automatic initial snapshot. Defaults to `true` (fire immediately). */ immediate?: boolean; signal?: AbortSignal; }, ): Unsubscribe; /** * Subscribe to multiple tables at once. Fires a combined snapshot `{ [tableName]: records[] }` * once all tables have delivered their initial state, then fires again whenever any observed * table changes. Multiple tables mutated inside a single `batch()` coalesce into one callback. * Throws `VaultScopeError` when `tables` is empty. * Returns an `Unsubscribe` function — call it on teardown. */ observeMany( tables: readonly K[], listener: (snapshots: { [T in K]: RecordOf[] }) => void, options?: { /** * When `true`, fires as soon as any table delivers its first snapshot. * Tables not yet resolved are represented as empty arrays. * Defaults to `false` (wait for all tables). */ eager?: boolean; signal?: AbortSignal; }, ): Unsubscribe; /** * Explicitly delete TTL-expired records from the specified tables (or all tables when * no filter is provided). Returns the count of records pruned per table. * Does not trigger observer callbacks. Invalidates the internal `count()` cache for pruned tables. */ pruneExpired(tables?: readonly (keyof S & string)[]): Promise; put(table: K, value: RecordOf, ttl?: TtlMs): Promise; putAll(table: K, values: RecordOf[], ttl?: TtlMs): Promise; query(table: K): QueryBuilder>; /** * Merge `changes` into the existing record. Returns the merged record, or `undefined` when * the key does not exist — use `upsert` for insert-or-update semantics. */ update( table: K, key: KeyOf, changes: Partial>, ttl?: TtlMs, ): Promise | undefined>; /** Read-modify-write. `fn` receives the current record (or `undefined`) and returns the new record. */ upsert( table: K, key: KeyOf, fn: (existing: RecordOf | undefined) => RecordOf, ttl?: TtlMs, ): Promise>; /** * Async iterable that yields a fresh snapshot on every table change, starting immediately. * The observer is cleaned up automatically when the loop exits. * * @param options.mode - `'latest'` (default) drops intermediate snapshots when the consumer * lags. `'all'` queues every snapshot. * @param options.signal - An `AbortSignal` that stops the iteration from outside the loop. */ watch( table: K, options?: { mode?: 'all' | 'latest'; signal?: AbortSignal }, ): AsyncIterable[]>; } ``` ### `observe` behavior `observe` **fires immediately** with the current table state on registration by default, then again on every subsequent mutation. Pass `{ immediate: false }` to skip the initial snapshot — useful when you already have the table state from a preceding `getAll()` call and only want change notifications. | Option | Type | Default | Description | | ----------- | ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | | `immediate` | `boolean` | `true` | When `false`, skips the automatic initial snapshot fired on registration. | | `signal` | `AbortSignal` | — | When aborted, automatically unsubscribes the listener. Already-aborted signals are a no-op — no initial snapshot is fired. | Returns an `Unsubscribe` function. Calling it and aborting the signal both cancel the observer — either approach works. ### `observeMany` behavior `observeMany` populates the snapshot map immediately on registration. By default, the combined listener fires once all tables have reported their initial state. Set `eager: true` to fire as soon as any table delivers its first snapshot — tables not yet resolved appear as empty arrays. This is useful when some tables are large and you want to render partial data immediately. Duplicate entries in the `tables` array are silently deduplicated. The combined snapshot will still include a key for each entry in the original array, but duplicate keys reference the same data. | Option | Type | Default | Description | | -------- | ------------- | ------- | --------------------------------------------------------------------------------------------------------------- | | `eager` | `boolean` | `false` | When `true`, fires after the first table delivers its snapshot, using empty arrays for tables not yet resolved. | | `signal` | `AbortSignal` | — | When aborted, unsubscribes all underlying observers. Already-aborted signals return a no-op immediately. | ### `watch` options | Option | Type | Default | Description | | -------- | ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | | `mode` | `'latest' \| 'all'` | `'latest'` | Whether intermediate snapshots are dropped (`latest`) or queued (`all`) when the consumer lags | | `signal` | `AbortSignal` | — | When aborted, terminates the iteration. If already aborted before the first `next()` call, the iterator is done immediately. | > **Resource note:** The observer subscription is created eagerly on `[Symbol.asyncIterator]()` — not on the first `next()` call. This prevents mutations from being silently lost in the window between iterator creation and first consumption. Always `break`, `return`, or pass a `signal` to clean up the subscription; otherwise it remains active until the adapter is disposed. ## `toReadableStream` ```ts function toReadableStream(iterable: AsyncIterable): ReadableStream; ``` Converts an `AsyncIterable` (such as `db.watch()`) into a Web Standard `ReadableStream`. Use it when you need to pipe vault snapshots into a `WritableStream` or `TransformStream`. ```ts import { toReadableStream } from '@vielzeug/vault'; const stream = toReadableStream(db.watch('users')); await stream.pipeTo(new WritableStream({ write: (users) => render(users) })); ``` The stream closes when the iterable is exhausted or the stream is cancelled. Pass an `AbortSignal` to `db.watch()` to cancel from outside: ```ts const controller = new AbortController(); const stream = toReadableStream(db.watch('users', { signal: controller.signal })); controller.abort(); // closes the stream ``` ## `isExpired` ```ts function isExpired(expiresAt: number | undefined): boolean; ``` Returns `true` when an epoch-ms expiry timestamp has passed. Safe to call with `undefined` — returns `false`. Useful for custom codec implementations or TTL-aware utilities. ```ts import { isExpired } from '@vielzeug/vault'; isExpired(undefined); // false isExpired(Date.now() - 1000); // true isExpired(Date.now() + 1000); // false ``` ## TransactionContext `TransactionContext` is the type of the `tx` parameter inside `batch()` callbacks. It exposes the same CRUD methods as `Adapter` but restricts access to the tables declared in `batch(tables, fn)` — accessing any other table at runtime throws `VaultScopeError`. ```ts type TransactionContext = { // clear, count, delete, deleteMany, entries, get, getAll, getMany, // getOrDefault, has, isEmpty, keys, put, putAll, query, update, upsert }; ``` `batch()` scopes all operations to the tables declared in its first argument. Accessing any other table at runtime throws `VaultScopeError`. The first argument must not be empty. ## QueryBuilder Queries are lazy pipelines. Operations accumulate until a terminal method is called. `QueryBuilder` carries two type parameters: `T` is the base record type, `N` is the progressively-narrowed type built up by `equals()` calls. The narrowed type flows through to `toArray()`, `first()`, `count()`, `totalCount()`, and `delete()`. ```ts interface QueryBuilder, N extends T = T> { // filter operators (chainable) filter(fn: (value: N) => boolean): QueryBuilder; /** * Narrows the result type: `equals('role', 'admin')` returns * `QueryBuilder`. Subsequent operators * and terminal calls reflect this constraint. */ equals(field: K, value: V): QueryBuilder>; /** Inclusive range filter. Preserves existing `N` narrowing. */ between>( field: K, lower: Extract, number | string>, upper: Extract, number | string>, ): QueryBuilder; /** Prefix filter. Preserves existing `N` narrowing. */ startsWith(field: K, prefix: string, options?: { ignoreCase?: boolean }): QueryBuilder; orderBy(field: K, direction?: 'asc' | 'desc'): QueryBuilder; limit(n: number): QueryBuilder; offset(n: number): QueryBuilder; // terminal methods exists(): Promise; toArray(): Promise; /** * Number of matching records after all operations, including `limit` and `offset`. * Use `totalCount()` to get the full filtered set size ignoring pagination. */ count(): Promise; /** * Number of records matching the applied filter predicates. * `limit`, `offset`, and `orderBy` are ignored — always returns the full filtered set size. * Use this for "page X of N" UIs where you need the total alongside a paginated slice. */ totalCount(): Promise; first(): Promise; delete(): Promise; } ``` ### `exists()` Returns `true` if at least one record matches all applied filter operations. Equivalent to `(await query.first()) !== undefined` but expresses the intent more clearly. ```ts // check if any admin exists const hasAdmin = await db.query('users').equals('role', 'admin').exists(); // check if table is non-empty const hasPosts = await db.query('posts').exists(); ``` Presentation-only ops (`limit`, `offset`, `orderBy`) are respected before checking. An empty table always returns `false`. `delete()` returns the number of records removed. `between` and `orderBy` accept `number | string` fields. ### Secondary Index Push-down On `createIndexedDB`, when the **first** operation in the chain is `equals()`, `between()`, or a case-sensitive `startsWith()` (`ignoreCase: false`, the default) on the primary key or a field registered with `.index()`, the query uses a native IDB range or index fetch instead of scanning the full table. Every subsequent operator still runs in-memory against that narrowed result set. ```ts const schema = { products: table('id').index('category') }; const db = createIndexedDB({ name: 'shop', schema, version: 1 }); // pushed down to the "category" IDB index — no full-table scan const electronics = await db.query('products').equals('category', 'electronics').toArray(); // NOT pushed down — equals() is not the first op const filtered = await db .query('products') .filter((p) => p.price > 0) .equals('category', 'electronics') .toArray(); ``` `createMemory` and `createLocalStorage` / `createSessionStorage` always scan in memory — `.index()` has no effect on those adapters. ## Migration ```ts type MigrationContext = { db: IDBDatabase; newVersion: number | null; oldVersion: number; tx: IDBTransaction; }; type MigrationFn = (ctx: MigrationContext) => void; ``` Pass a `MigrationFn` as `migrate` to `createIndexedDB`. It runs inside IDB's `onupgradeneeded` and receives the raw `IDBDatabase` and `IDBTransaction` for manual schema changes. ### `defineMigration` ```ts function defineMigration(steps: MigrationStep[]): MigrationFn; ``` ```ts type MigrationStep = | { field: string; table: string; type: 'addIndex' } | { field: string; table: string; type: 'removeIndex' } | { name: string; type: 'addTable' } | { name: string; type: 'removeTable' }; ``` Builds a `MigrationFn` from a declarative list of schema-change steps instead of hand-writing raw `IDBDatabase` / `IDBTransaction` calls. Each step is idempotent — safe to run when the target already exists or was already removed. ```ts import { createIndexedDB, defineMigration } from '@vielzeug/vault'; const migrate = defineMigration([ { type: 'addTable', name: 'sessions' }, { type: 'addIndex', table: 'users', field: 'email' }, { type: 'removeTable', name: 'legacyTokens' }, ]); const db = createIndexedDB({ name: 'app', version: 2, schema, migrate }); ``` > `addIndex` assumes the default storage envelope (keyPath `value.`) — see the [custom codec caveat](#table). ## Types ### `AnySchema` The constraint type for all schema objects — a record whose values are `SchemaEntry` instances. ```ts type AnySchema = Record, string>>; ``` ### `SchemaEntry` The opaque type produced by `table(key)`. Carries the record type and primary-key field at the type level. ```ts type SchemaEntry, Key extends keyof T & string> = { defaultTtl?: TtlMs; key: Key; }; ``` ### `RecordOf` Extracts the record type for a given table key. ```ts type RecordOf = /* inferred from SchemaEntry */; ``` ### `KeyOf` Extracts the primary-key value type for a given table key. ```ts type KeyOf = /* inferred from SchemaEntry */; ``` ### `TtlMs` A branded `number` representing milliseconds. Produced only by `ttl.*` helpers. Raw numbers are rejected by the type system. ```ts type TtlMs = number & { readonly __brand: 'TtlMs' }; ``` ### `Observer` Callback type for `observe()` and `observeMany()`. ```ts type Observer = (records: T[]) => void; ``` ### `Unsubscribe` Returned by `observe()` and `observeMany()`. Calling it cancels the subscription; equivalent to aborting a `signal` passed to those methods. ```ts type Unsubscribe = () => void; ``` ### `BaseAdapterOptions` Shared plugin options accepted by all four adapter factories. ```ts type BaseAdapterOptions = { codec?: VaultCodec; logger?: VaultLogger; onMetrics?: (event: MetricsEvent) => void; schema: S; signals?: TableSignals; validators?: TableValidators; }; ``` ### `MigrationContext` / `MigrationFn` Passed to the `migrate` callback in `createIndexedDB`. ```ts type MigrationContext = { db: IDBDatabase; newVersion: number | null; oldVersion: number; tx: IDBTransaction; }; type MigrationFn = (ctx: MigrationContext) => void; ``` ### `DebugStats` / `DebugInfo` Returned by `db.debug()`. ```ts type DebugStats = { expiredCount: number; // TTL-expired records not yet evicted recordCount: number; // live (non-expired) records }; type DebugInfo = { tables: Array; }; ``` ### `VaultCodec` Pluggable serialization contract. Implement to change how vault stores values at rest (e.g. compact keys, encryption, msgpack). ```ts type VaultCodec = { /** Parse a raw stored value into `{ value, expiresAt? }`. Return `undefined` for corrupt data. */ decode(raw: unknown): { expiresAt?: number; value: T } | undefined; /** Encode a value and optional absolute expiry timestamp (epoch ms) into the storage format. */ encode(value: T, expiresAt?: number): unknown; }; ``` `defaultCodec` (exported) stores `{ value: T, expiresAt?: number }` verbatim — identical to the original behaviour. Use it as a reference or extend it: ```ts import { defaultCodec, type VaultCodec } from '@vielzeug/vault'; const loggingCodec: VaultCodec = { decode: (raw) => { const result = defaultCodec.decode(raw); if (!result) console.warn('[vault] corrupt record:', raw); return result; }, encode: defaultCodec.encode, }; ``` Pass `codec` to any factory: ```ts const db = createLocalStorage({ name: 'app', schema, codec: loggingCodec }); ``` > **IndexedDB + `.index()` caveat:** see [Custom codec caveat](#table) — a custom codec that changes the envelope shape breaks secondary index push-down silently. ### `createVersionedCodec` ```ts function createVersionedCodec(versions: CodecVersion[], currentVersion: number): VaultCodec; ``` Creates a `VaultCodec` that prepends a `__v` version field to every encoded envelope. When decoding, the `__v` value selects the matching codec from `versions`. This allows safe codec upgrades: old records encoded with a previous codec continue to decode correctly as long as the old codec remains in `versions`. ```ts import { createVersionedCodec, createMemory, table } from '@vielzeug/vault'; const v1Codec = { encode: (v) => ({ a: v }), decode: (r) => (r && typeof r === 'object' && 'a' in r ? { value: (r as { a: unknown }).a } : undefined), }; const v2Codec = { encode: (v, e) => ({ b: v, e }), decode: (r) => (r && typeof r === 'object' && 'b' in r ? { value: (r as { b: unknown }).b } : undefined), }; const codec = createVersionedCodec( [ { version: 1, codec: v1Codec }, { version: 2, codec: v2Codec }, ], 2, // write new records with version 2; read old version-1 records with v1Codec ); const db = createMemory({ schema: { users: table('id') }, codec }); ``` Throws `VaultError` if: - `versions` is empty - any version number is not a non-negative integer - version numbers are not unique - `currentVersion` is not listed in `versions` > **Migration note:** Records written by any other codec (including `defaultCodec`) lack the `__v` field and decode as `undefined`. Clear or migrate existing data before switching to a versioned codec. > **Secondary index caveat:** the `{ __d, __v }` envelope replaces the default `{ value, expiresAt? }` shape, so IndexedDB secondary indexes (created via [`.index()`](#table) with keyPath `value.`) silently stop matching. Don't combine a versioned codec with `.index()` on the same table. ### `CodecVersion` ```ts type CodecVersion = { codec: VaultCodec; version: number; // non-negative integer, unique across the versions array }; ``` A single entry in the `versions` array passed to `createVersionedCodec`. `version` must be a non-negative integer. ### `TableBuilder` Fluent builder returned by `table()`. Export this type to annotate schema entry variables without using `ReturnType>`. ```ts import { type TableBuilder, table } from '@vielzeug/vault'; const entry: TableBuilder = table('id').index('email').ttl(ttl.minutes(30)); ``` ### `VaultLogger` Minimal logger interface satisfied structurally by `@vielzeug/rune` Logger. Pass a rune instance directly — no adapter needed. ```ts interface VaultLogger { error(messageOrContext?: Record | Error | string, message?: string): void; } ``` ### `ReactiveSignal` / `TableSignals` Plugin type for the `signals` option. `@vielzeug/ripple` `Signal` and `Store` both satisfy `ReactiveSignal` structurally. ```ts interface ReactiveSignal { update(fn: (current: T) => T): void; } type TableSignals = { [K in keyof S]?: ReactiveSignal[]>; }; ``` ### `RecordValidator` / `TableValidators` Plugin type for the `validators` option. A `@vielzeug/spell` schema satisfies `RecordValidator` directly. Validators run before every `put`, `putAll`, `update`, and `upsert`. ```ts interface RecordValidator { parse(value: unknown): T; } type TableValidators = { [K in keyof S]?: RecordValidator>; }; ``` ### `MetricsEvent` Passed to `onMetrics` after every operation. ```ts type MetricsEvent = { duration: number; operation: | 'batch' | 'clear' | 'count' | 'delete' | 'deleteMany' | 'entries' | 'get' | 'getAll' | 'getMany' | 'getOrDefault' | 'has' | 'isEmpty' | 'keys' | 'put' | 'putAll' | 'query' | 'queryDelete' | 'update' | 'upsert'; /** Table name. For `batch` operations this is `'*'` because a batch may span multiple tables. */ table: string; }; ``` ### `QueryBuilder` See the full signature in the [QueryBuilder section](#querybuilder) above. ## Errors All errors thrown by `@vielzeug/vault` extend `VaultError`. Catch the base class for a catch-all, or catch specific subclasses for fine-grained handling. ```ts import { VaultDisposedError, VaultError, VaultMigrationError, VaultQuotaError, VaultScopeError } from '@vielzeug/vault'; ``` | Class | Extends | Thrown when | | --------------------- | ------------ | ------------------------------------------------------------------------------------------ | | `VaultError` | `Error` | Base class — catch-all for any vault error | | `VaultDisposedError` | `VaultError` | Any operation after `dispose()` has been called | | `VaultScopeError` | `VaultError` | `batch()` accesses a table outside its declared scope; empty array passed to `observeMany` | | `VaultQuotaError` | `VaultError` | A LocalStorage / SessionStorage write exceeds the storage quota | | `VaultMigrationError` | `VaultError` | IndexedDB `onupgradeneeded` migration callback threw | ### Usage Guide ## Basic Usage ```ts import { table } from '@vielzeug/vault'; type User = { id: number; name: string; age: number }; type Post = { id: number; title: string; userId: number }; const schema = { users: table('id'), posts: table('id'), }; ``` `table(key)` captures both the record type and the primary-key field name. The TypeScript compiler enforces that `key` is a valid field of `T`, and downstream operations — `get`, `delete`, `has`, `upsert` — accept only the correct key type. You can set a per-table default TTL with `.ttl()`: ```ts import { table, ttl } from '@vielzeug/vault'; const schema = { // every write to sessions uses a 30-minute TTL unless overridden at the call site sessions: table('id').ttl(ttl.minutes(30)), }; ``` ## Create an Adapter All four factories return the same `Adapter` interface and accept the same optional plugin options. ### LocalStorage ```ts import { createLocalStorage } from '@vielzeug/vault'; const db = createLocalStorage({ name: 'app', schema }); ``` ### SessionStorage ```ts import { createSessionStorage } from '@vielzeug/vault'; const db = createSessionStorage({ name: 'app', schema }); ``` ### IndexedDB ```ts import { createIndexedDB } from '@vielzeug/vault'; const db = createIndexedDB({ name: 'app', schema, version: 1 }); ``` ### Memory ```ts import { createMemory } from '@vielzeug/vault'; const db = createMemory({ schema }); ``` No browser APIs required. Use this in unit tests and SSR environments. Pass an optional `name` to enable cross-tab (or cross-window) synchronisation via `BroadcastChannel`. All `createMemory` instances with the same `name` in the same origin replicate mutations to each other. ```ts const db = createMemory({ name: 'shared-state', schema }); ``` If `BroadcastChannel` is not available in the environment, the option is silently ignored. ## Basic CRUD ```ts // write await db.put('users', { id: 1, name: 'Alice', age: 30 }); await db.putAll('users', [ { id: 2, name: 'Bob', age: 25 }, { id: 3, name: 'Carol', age: 28 }, ]); // read const alice = await db.get('users', 1); // User | undefined const all = await db.getAll('users'); // User[] const total = await db.count('users'); // number (live records only) const exists = await db.has('users', 1); // boolean // update — merges fields, returns undefined if key does not exist const updated = await db.update('users', 1, { age: 31 }); // delete await db.delete('users', 1); await db.clear('users'); (void alice, all, total, exists, updated); ``` `update` returns the merged record, or `undefined` when the key is not found. Use `upsert` for insert-or-update semantics. ## Key and Entry Reads `keys(table)` returns the primary key of every live record without fetching the full records. Useful for existence checks, diffing, and cache invalidation. ```ts const ids = await db.keys('users'); // number[] ``` `entries(table)` returns all `[key, record]` pairs in a single call. ```ts const pairs = await db.entries('users'); // [number, User][] ``` Both are also available inside `batch()` callbacks. `isEmpty(table)` returns `true` when a table has no live records — equivalent to `(await db.count(table)) === 0`, including TTL-expired records being treated as absent. ```ts if (await db.isEmpty('users')) { await db.putAll('users', defaultUsers); } ``` ## Bulk Key Lookup `getMany(table, keys)` fetches multiple records in a single call. Missing keys return `undefined`. The result array preserves the input key order. ```ts const [alice, missing, carol] = await db.getMany('users', [1, 99, 3]); // → [User, undefined, User] ``` `getMany` is also available inside `batch()` callbacks. ## Use TTL TTL must always be specified via the `ttl` helper. Raw numbers are rejected at the type level. ```ts import { ttl } from '@vielzeug/vault'; await db.put('users', { id: 1, name: 'Alice', age: 30 }, ttl.minutes(5)); await db.put('users', { id: 2, name: 'Bob', age: 25 }, ttl.hours(24)); await db.put('users', { id: 3, name: 'Carol', age: 28 }, ttl.days(7)); await db.put('users', { id: 4, name: 'Dave', age: 22 }, ttl.ms(500)); ``` Expired records are evicted lazily on the next read. `count()` and `getAll()` exclude them. ### Prune Expired Records For write-heavy tables that are rarely read, expired records accumulate without lazy eviction. `pruneExpired()` sweeps tables explicitly and returns the count deleted per table. ```ts // Prune all tables const pruned = await db.pruneExpired(); // { users: 42, sessions: 10 } // Prune only specific tables const partial = await db.pruneExpired(['sessions']); // { sessions: 10, users: 0 } ``` Schedule periodic pruning with `scheduleExpiredPrune`: ```ts import { scheduleExpiredPrune, ttl } from '@vielzeug/vault'; const stop = scheduleExpiredPrune(db, { interval: ttl.hours(1) }); // on teardown (before dispose) stop(); ``` Pass `signal` to tie the schedule lifetime to an `AbortController` or `db.disposalSignal` — no manual `stop()` call needed: ```ts scheduleExpiredPrune(db, { interval: ttl.hours(1), signal: db.disposalSignal, // auto-stops when the adapter is disposed }); ``` The schedule also stops automatically if the adapter is disposed before the timer fires — `VaultDisposedError` thrown by `pruneExpired()` is caught and the interval is cleared. On **IndexedDB**, pruning uses a cursor-based pass — expired records are deleted without loading them into memory. On **LocalStorage / SessionStorage** and **Memory**, expired records are detected and removed during the scan. ## Upsert `upsert` performs a read-modify-write atomically. The callback receives the current record (or `undefined`) and must return the new record. ```ts // increment a counter even if the record doesn't yet exist await db.upsert('users', 42, (existing) => ({ id: 42, name: existing?.name ?? 'Unknown', age: (existing?.age ?? 0) + 1, })); ``` ## Query Data Queries are lazy pipelines that execute on the terminal call. ```ts const page = await db .query('users') .between('age', 18, 99) .startsWith('name', 'a', { ignoreCase: true }) .orderBy('name', 'asc') .limit(20) .offset(0) .toArray(); const first = await db.query('users').orderBy('age', 'asc').first(); // count() respects limit/offset — returns the number of records in the current page const count = await db.query('users').equals('age', 30).limit(10).count(); // totalCount() ignores limit/offset/orderBy — returns the full filtered set size const total = await db.query('users').equals('age', 30).totalCount(); (void page, first, count, total); ``` Use `totalCount()` alongside `limit`/`offset` for "page X of N" UIs: ```ts const pageSize = 20; const pageIndex = 2; const q = db.query('users').between('age', 18, 99).orderBy('name', 'asc'); const [page, total] = await Promise.all([ q .limit(pageSize) .offset(pageIndex * pageSize) .toArray(), q.totalCount(), ]); console.log(`Page ${pageIndex + 1} of ${Math.ceil(total / pageSize)}`, page); ``` ### Delete via Query ```ts const deleted = await db .query('users') .filter((u) => u.age = 18) { await processUser(user); } } ``` Expired records are skipped automatically. Each call to `iterate()` on `IndexedDbAdapter` opens a fresh readonly IDB transaction. ## Reactive Reads ### `observe` `observe(table, listener)` subscribes to table changes. **It fires immediately with the current table state on registration** by default, then fires again on every mutation. ```ts // Always fires immediately, then on every change const stop = db.observe('users', (rows) => { console.log('users snapshot:', rows.length); }); await db.put('users', { id: 1, name: 'Alice', age: 30 }); // triggers listener again stop(); ``` Pass `{ signal }` to cancel the observer via an `AbortController` — a clean alternative to storing and calling the returned stop function. ```ts const controller = new AbortController(); db.observe('users', (rows) => render(rows), { signal: controller.signal }); // later: controller.abort(); // stops the observer ``` Pass `{ immediate: false }` to skip the initial snapshot — useful when you already hold the current table state and only need change notifications: ```ts const rows = await db.getAll('users'); // immediate: false — no snapshot on registration, only subsequent mutations db.observe('users', (updated) => render(updated), { immediate: false }); render(rows); // render the initial state yourself ``` Always unsubscribe on teardown to prevent memory leaks. ### `watch` — AsyncIterable Stream `watch(table)` returns an `AsyncIterable` that yields a fresh snapshot on every change, **always starting with an immediate snapshot**. It is the `for await` companion to `observe`. ```ts for await (const users of db.watch('users')) { renderTable(users); } ``` The observer is cleaned up automatically when the loop exits via `break`, `return`, or an unhandled error. Stop the loop from outside using an `AbortSignal`: ```ts const controller = new AbortController(); for await (const users of db.watch('users', { signal: controller.signal })) { renderTable(users); } controller.abort(); // terminates the loop ``` By default (`mode: 'latest'`) intermediate snapshots are dropped if the consumer lags. Pass `mode: 'all'` to queue every snapshot instead. ### `toReadableStream` — ReadableStream To get a `ReadableStream` of snapshots, wrap `db.watch()` with `toReadableStream()`. Use it with WHATWG stream pipelines or in environments that consume `ReadableStream` directly. ```ts import { toReadableStream } from '@vielzeug/vault'; toReadableStream(db.watch('users')).pipeTo(new WritableStream({ write: (users) => render(users) })); ``` Always cancel the stream (or pass a `signal` to `watch()`) to stop the underlying observer: ```ts const controller = new AbortController(); const stream = toReadableStream(db.watch('users', { signal: controller.signal })); const reader = stream.getReader(); for (;;) { const { value: users, done } = await reader.read(); if (done) break; render(users); } await reader.cancel(); // unsubscribes the observer ``` The same `mode` and `signal` options as `watch()` apply. ### `observeMany` — Combined Multi-Table Snapshot `observeMany(tables, listener)` subscribes to multiple tables at once and delivers a single combined snapshot `{ [tableName]: RecordOf[] }` whenever any observed table changes. By default, the combined listener fires once all tables have reported their initial snapshot, ensuring the combined view is always complete. Pass `{ eager: true }` to fire as soon as any table delivers its first snapshot, using empty arrays for tables not yet resolved: ```ts const stop = db.observeMany(['users', 'posts'], ({ users, posts }) => { renderDashboard(users, posts); }); // eager: true — fires as soon as any table delivers its snapshot const stopEager = db.observeMany( ['users', 'posts'], ({ users, posts }) => renderDashboard(users, posts), { eager: true }, ); ``` Writes to multiple tables inside a single `batch()` call coalesce into one callback — the listener fires exactly once per microtask, not once per dirty table. Pass `{ signal }` to cancel all observers at once: ```ts const controller = new AbortController(); db.observeMany(['users', 'posts'], ({ users, posts }) => renderDashboard(users, posts), { signal: controller.signal, }); controller.abort(); ``` > `tables` must be non-empty. Passing an empty array throws `VaultScopeError`. ## Batch Writes `batch(tables, tx => ...)` defers all observer notifications until the callback resolves. On IndexedDB it also runs inside a real atomic IDB transaction. ```ts await db.batch(['users', 'posts'], async (tx) => { await tx.put('users', { id: 1, name: 'Alice', age: 30 }); await tx.put('posts', { id: 10, title: 'Hello', userId: 1 }); }); ``` `query()` and `query().delete()` are also available inside `batch()` callbacks: ```ts await db.batch(['users', 'posts'], async (tx) => { await tx.put('users', { id: 1, name: 'Alice', age: 30 }); // query and delete inside the batch scope await tx .query('posts') .filter((p) => p.title.startsWith('H')) .delete(); }); ``` `batch()` is table-scoped at runtime and type level: - the table list must not be empty - inside `tx`, operations on tables not included in `tables` throw `VaultScopeError` `getOrDefault` is also available inside `batch()` callbacks — it is a read-or-insert: returns the existing record if present, otherwise calls `defaultFn()`, writes and returns the result. ```ts await db.batch(['users'], async (tx) => { const user = await tx.getOrDefault('users', 42, () => ({ id: 42, name: 'Guest', age: 0 })); // user is either the existing record or the newly inserted default }); ``` For **IndexedDB**, `getOrDefault` inside `batch()` is atomic — the check and insert happen inside the same IDB transaction. For Memory and WebStorage adapters, it is a logical read-then-write but not physically atomic. If the callback throws on **IndexedDB**, the IDB transaction is rolled back automatically. On other adapters the callback's side effects are not rolled back, but no observer notifications are fired. ## Debug `debug()` returns live vs expired record counts per table. Useful during development. ```ts const info = await db.debug(); for (const table of info.tables) { console.log(table.name, '— live:', table.recordCount, 'expired:', table.expiredCount); } ``` ## Handle Schema Migrations (IndexedDB) ```ts import { createIndexedDB, type MigrationFn } from '@vielzeug/vault'; const migrate: MigrationFn = ({ db, oldVersion, tx }) => { if (oldVersion ([]); const db = createMemory({ schema, signals: { users: usersSignal }, }); // usersSignal.value is now always in sync with the users table ``` Any object with an `update(fn: (current: T) => T): void` method satisfies `ReactiveSignal` structurally. `@vielzeug/ripple` `Signal` and `Store` both satisfy this directly. ### Logger (`logger`) Pass a `@vielzeug/rune` logger or any object with an `error(...)` method. Observer notification errors are routed to `logger.error`. ```ts import { createLogger } from '@vielzeug/rune'; import { createMemory } from '@vielzeug/vault'; const db = createMemory({ schema, logger: createLogger('db'), }); ``` ### Record Validation (`validators`) Pass a `@vielzeug/spell` schema or any object with `parse(value): T`. Validators run before every `put`, `putAll`, `update`, and `upsert`. ```ts import { s } from '@vielzeug/spell'; import { createMemory } from '@vielzeug/vault'; const db = createMemory({ schema, validators: { users: s.object({ id: s.number(), name: s.string(), age: s.number() }), }, }); ``` Any value that fails `parse` causes the write to throw before touching storage. ### Metrics (`onMetrics`) `onMetrics` is called after every operation with timing and table name. Use it for performance monitoring. ```ts const db = createMemory({ schema, onMetrics: (event) => { console.log(`[${event.table}] ${event.operation} — ${event.duration}ms`); }, }); ``` Tracked operations: `get`, `getAll`, `getMany`, `getOrDefault`, `keys`, `entries`, `has`, `put`, `putAll`, `deleteMany`, `count`, `delete`, `clear`, `update`, `upsert`, `batch`, `query`, `queryDelete`. For `batch` operations, `event.table` is `'*'` because a batch may span multiple tables. ### Quota Exceeded Hook (`onQuotaExceeded`) — LocalStorage / SessionStorage Called when a write exceeds the storage quota. The callback receives a `VaultQuotaError`. Return `'ignore'` to silently drop the write, or `'throw'` (default) to rethrow. ```ts import { createLocalStorage, type VaultQuotaError } from '@vielzeug/vault'; const db = createLocalStorage({ name: 'app', schema, onQuotaExceeded: (table, error: VaultQuotaError) => { console.warn(`[${String(table)}] quota exceeded — dropping write`, error.message); return 'ignore'; }, }); ``` ## Environment-Based Adapter Selection All factories return the same `Adapter` type, making it straightforward to select a backend at runtime. ```ts import { createIndexedDB, createLocalStorage, createMemory, type Adapter } from '@vielzeug/vault'; function createStorage(): Adapter { if (typeof indexedDB !== 'undefined') { return createIndexedDB({ name: 'app', schema, version: 1 }); } if (typeof localStorage !== 'undefined') { return createLocalStorage({ name: 'app', schema }); } return createMemory({ schema }); } ``` ## Lifecycle Call `dispose()` when the adapter is no longer needed. This disconnects observers, signal subscriptions, the BroadcastChannel (IDB), and the IDB connection. ```ts db.dispose(); ``` ## Error Handling All errors thrown by `@vielzeug/vault` are instances of `VaultError`. Catch the base class to handle any vault-originated error, or catch specific subclasses for fine-grained handling. ```ts import { VaultDisposedError, VaultError, VaultMigrationError, VaultQuotaError, VaultScopeError } from '@vielzeug/vault'; try { await db.put('users', { id: 1, name: 'Alice', age: 30 }); } catch (err) { if (err instanceof VaultDisposedError) { // operation on a disposed adapter } else if (err instanceof VaultScopeError) { // table not in batch() scope, or empty tables array passed to observeMany } else if (err instanceof VaultQuotaError) { // WebStorage quota exceeded (also sent to onQuotaExceeded if configured) } else if (err instanceof VaultMigrationError) { // IndexedDB onupgradeneeded migration threw } else if (err instanceof VaultError) { // any other vault error } } ``` | Class | Thrown when | | --------------------- | ------------------------------------------------------------------------------------------ | | `VaultError` | Base class — catch all vault errors | | `VaultDisposedError` | Any operation after `dispose()` | | `VaultScopeError` | `batch()` accesses a table outside its declared scope; empty array passed to `observeMany` | | `VaultQuotaError` | A LocalStorage / SessionStorage write exceeds the storage quota | | `VaultMigrationError` | IndexedDB `onupgradeneeded` migration callback threw | ## Framework Integration Use `db.observe()` for framework subscriptions. The returned unsubscribe function maps directly to each framework's cleanup hook. ```tsx [React] import { useEffect, useState } from 'react'; import { createMemory, table } from '@vielzeug/vault'; type User = { id: number; name: string }; const schema = { users: table('id') }; const db = createMemory({ schema }); function useUsers(): User[] { const [users, setUsers] = useState([]); useEffect(() => { // observe always fires immediately — setUsers is called once on mount, then on each change return db.observe('users', setUsers); }, []); return users; } ``` ```ts [Vue 3] import { onScopeDispose, ref } from 'vue'; import { createMemory, table } from '@vielzeug/vault'; import type { Ref } from 'vue'; type User = { id: number; name: string }; const schema = { users: table('id') }; const db = createMemory({ schema }); export function useUsers(): { users: Ref } { const users = ref([]); // observe always fires immediately — users.value is populated on composition const stop = db.observe('users', (rows) => { users.value = rows; }); onScopeDispose(stop); return { users }; } ``` ```svelte [Svelte] import { onDestroy } from 'svelte'; import { createMemory, table } from '@vielzeug/vault'; type User = { id: number; name: string }; const schema = { users: table('id') }; const db = createMemory({ schema }); let users: User[] = []; const stop = db.observe('users', (rows) => { users = rows; }); onDestroy(stop); {#each users as user} {user.name} {/each} ``` For libraries with a reactive context (`scope`, `onUnmounted`, signal effects), you can also use `db.watch(table)` inside an async loop — the observer is cleaned up automatically when the loop exits. ## Working with Other Vielzeug Libraries ### With Ripple Wire a `@vielzeug/ripple` signal to a table at construction time. The signal is updated automatically on every table change — no `observe()` boilerplate required. ```ts import { signal, effect } from '@vielzeug/ripple'; import { createIndexedDB, table } from '@vielzeug/vault'; type User = { id: number; name: string }; const schema = { users: table('id') }; const usersSignal = signal([]); const db = createIndexedDB({ name: 'app', schema, signals: { users: usersSignal }, version: 1, }); // usersSignal.value stays in sync with the users table automatically effect(() => console.log('users:', usersSignal.value.length)); await db.put('users', { id: 1, name: 'Alice' }); // → effect re-runs ``` ### With Spell Pass a `@vielzeug/spell` schema as a validator. Vault calls `schema.parse(value)` before every `put`, `putAll`, `update`, and `upsert`. Invalid records throw without touching storage. ```ts import { s } from '@vielzeug/spell'; import { createMemory, table } from '@vielzeug/vault'; type User = { id: number; name: string; age: number }; const schema = { users: table('id') }; const db = createMemory({ schema, validators: { users: s.object({ id: s.number(), name: s.string(), age: s.number().min(0) }), }, }); // throws a spell validation error before writing await db.put('users', { id: 1, name: 'Alice', age: -1 }); ``` ### With Rune Pass a `@vielzeug/rune` logger to route observer notification errors to your structured log pipeline. ```ts import { createLogger } from '@vielzeug/rune'; import { createIndexedDB, table } from '@vielzeug/vault'; type User = { id: number; name: string }; const schema = { users: table('id') }; const db = createIndexedDB({ name: 'app', schema, version: 1, logger: createLogger('vault'), }); ``` ## Best Practices - Prefer `createIndexedDB` for large datasets or flows that require atomicity. - Use `createMemory` in tests — no cleanup, no browser API requirements. - Always call the `observe()` unsubscribe function on component teardown (or use `watch()` which auto-unsubscribes on loop exit). - Use `batch()` when writing to multiple tables to batch observer notifications. - Use `ttl.*` helpers — raw millisecond numbers are rejected by the type system. - Keep `batch()` callbacks focused on storage operations; avoid arbitrary async side effects. - Schedule `scheduleExpiredPrune` for write-heavy tables with TTL to reclaim storage proactively. ### Examples ## Examples - [CRUD](./examples/crud.md) - [Querying](./examples/querying.md) - [TTL and Pruning](./examples/ttl.md) - [Reactive Tables](./examples/reactive.md) - [Batch Writes](./examples/batch.md) - [Lazy Iteration](./examples/iterate.md) - [Plugins and Error Handling](./examples/plugins.md) ### REPL Examples - Basic Setup - Initialize Vault (id: `basic-setup`) - Bulk Operations (id: `bulk-operations`) - Cache-First with getOrDefault (id: `cache-first`) - CRUD Operations (id: `crud-operations`) - IndexedDB Adapter — Atomic Batch & iterate() (id: `indexed-db`) - TTL — scheduleExpiredPrune with onError (id: `prune-schedule`) - Query Builder — Filters, Pagination, totalCount (id: `query-builder`) - Reactive — observe & observeMany (id: `reactive-observe`) - Reactive — watch() AsyncIterable (id: `reactive-watch`) - TTL & Expiration (id: `ttl-expiration`) - Codec — createVersionedCodec for safe upgrades (id: `versioned-codec`) --- ## @vielzeug/ward **Category:** auth **Keywords:** rbac, permissions, roles, access-control, authorization, wildcards, predicates **Key exports:** createWard, allow, deny, ruleFor, predicate, owns, matchesPattern, patternCovers, guardRequest, guardRequestWith, WardPredicateError, WILDCARD (+20 more) **Related:** rune, wayfinder, conduit ### Overview ## Why Ward? Spreading authorization checks across route handlers, service methods, and UI components leads to inconsistent enforcement, no central place to audit permissions, and rules that drift as the codebase grows. ```ts // Before — ad-hoc checks scattered across handlers function deletePost(user: User, post: Post) { if (user.role !== 'admin' && user.id !== post.authorId) { throw new Error('Forbidden'); } // no logging, no explain, no wildcard, no composition } // After — Ward declarative rules with typed enforcement import { allow, createWard, predicate } from '@vielzeug/ward'; const ward = createWard([ ...allow('admin', '*', ['*']), ...allow('author', 'post', ['delete', 'edit'], { when: predicate.owns('authorId') }), ]); const guard = ward.forUser(currentUser); guard.explain('post', 'delete', post); // WardDecision — auditable guard.allowedActions('post', ['delete', 'edit'], post); // ['delete', 'edit'] or [] ``` | Feature | Ward | CASL | AccessControl | | --------------------------------- | ------------------------------------------------------ | ------------------------------------------ | --------------------------------------------------------------------- | | Bundle size | | ~11 kB | ~7 kB | | Typed rule contracts | | Partial | Partial | | Deterministic deny precedence | | | | | Rule predicates with request data | | | (manual patterns) | | Wildcard action support | | | | | Principal-bound API | (`forUser`) | Partial | | | Explainable decisions | | Partial | | | Zero dependencies | | | | **Use Ward when** you want predictable authorization decisions with typed rules and explicit introspection APIs. **Consider larger policy frameworks when** you need ecosystem-specific integrations or policy storage outside application code. ## Installation ```sh [pnpm] pnpm add @vielzeug/ward ``` ```sh [npm] npm install @vielzeug/ward ``` ```sh [yarn] yarn add @vielzeug/ward ``` ## Quick Start ```ts import { ANONYMOUS, WILDCARD, allow, createWard, deny, predicate } from '@vielzeug/ward'; const ward = createWard([ // Multi-role rule: viewer and editor can both read ...allow(['viewer', 'editor'], 'posts', ['read']), // Editor can update their own posts ...allow('editor', 'posts', ['update'], { when: predicate.owns('authorId') }), // High-priority deny overrides any allow rule for blocked principals ...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }), // Anonymous visitors can read posts ...allow(ANONYMOUS, 'posts', ['read']), ]); const editor = { id: 'u1', roles: ['editor'] }; // Full decision — narrow on .allowed for type-safe access to .reason / .rule const decision = ward.explain(editor, 'posts', 'update', { authorId: 'u2' }); if (!decision.allowed) console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny' // Decision trace — all candidates with index, score, priority, won (no logger fired) const trace = ward.trace(editor, 'posts', 'read'); trace.candidates.forEach((c) => console.log(`Rule[${c.index}]`, c.rule.effect, c.score, c.won)); // Detect policy conflicts at startup const conflicts = ward.detectConflicts(); if (conflicts.length > 0) console.warn('Policy conflicts:', conflicts); const bound = ward.forUser(editor); bound.allowedActions('posts', ['read', 'update', 'delete']); bound.explain('posts', 'update', { authorId: 'u2' }); bound.checkAll([ { resource: 'posts', action: 'read' }, { resource: 'posts', action: 'update', data: { authorId: 'u1' } }, ]); bound.rulesInScope('posts'); ``` ## Features - One rule primitive: `WardRule` passed directly to `createWard(rules)` - **Rule factories**: `allow(role, resource, actions, opts?)` and `deny(...)` — readable, spreadable arrays - **Grouped predicate namespace**: `predicate.owns()`, `predicate.and()`, `predicate.or()`, `predicate.not()` - **Multi-role rules**: `role` accepts a string or an array of strings (OR semantics) - Decision methods: `ward.explain(principal, resource, action, data?)` — full `WardDecision` object - Batch decisions: `ward.checkAll(principal, checks)` - Full decision trace: `ward.trace(principal, resource, action, data?)` — all candidates with `index`, `score`, `priority`, `won`; **does not fire the logger** - Rule introspection: `ward.rulesInScope(principal, resource, data?)` - Action enumeration: `ward.allowedActions(principal, resource, knownActions, data?)` - Policy conflict detection: `ward.detectConflicts()` — lazy, cached, O(n²) - Explicit wildcard support with `WILDCARD` - Anonymous checks via `null` principal plus `ANONYMOUS` role rules - Ownership helper via `owns(attributeKey)` or `predicate.owns(attributeKey)` - Principal-bound API via `ward.forUser(principal)` — principal snapshotted at bind time - Framework-agnostic guards: `guardRequest`, `guardRequestWith` - **Debug logging** via `debugWard()` (`@vielzeug/ward/devtools`) — logs `explain` and `checkAll` decisions with `[ward:decision]` prefixes; tree-shaken from production bundles ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Wayfinder](../wayfinder/index.md) for route-level authorization middleware. - [Rune](../rune/index.md) for structured audit logs of permission checks. - [Herald](../herald/index.md) for event-driven permission workflows. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | ------------------------------------------------------------------------ | ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------ | | `createWard(rules, options?)` | Create an immutable ward instance | Sync | Rules cannot be mutated after creation | | `allow(role, resource, actions, options?)` | Create allow rules — returns `WardRule[]` | Sync | Spread into `createWard([ ...allow(...) ])` — returns an array | | `deny(role, resource, actions, options?)` | Create deny rules — returns `WardRule[]` | Sync | Same spreading pattern as `allow` | | `ruleFor(effect, role, resource, actions, options?)` | Low-level rule factory (effect as first arg) | Sync | Prefer `allow`/`deny` for readability | | `predicate.owns(attributeKey)` | Ownership predicate — `data[key] === principal.id` | Sync | Returns `false` when `data` is absent, not an object, or key not an own property | | `predicate.and(...preds)` | Combine predicates with AND | Sync | Zero arguments → always returns `true` (vacuously) | | `predicate.or(...preds)` | Combine predicates with OR | Sync | Zero arguments → always returns `false` | | `predicate.not(pred)` | Invert a predicate | Sync | — | | `owns(attributeKey)` | Top-level alias for `predicate.owns` | Sync | Prefer `predicate.owns` when using other `predicate.*` helpers | | `matchesPattern(pattern, value)` | Test a pattern against a concrete string | Sync | Works for both resources and actions (namespace wildcards) | | `patternCovers(broad, narrow)` | Test whether one pattern statically covers another | Sync | Used by `detectConflicts`; exported for custom tooling | | `ward.checkAll(principal, checks)` | Evaluate multiple decisions in one call | Sync | Returns `WardDecisionResult[]` — each entry includes originating `resource`+`action` | | `ward.explain(principal, resource, action, data?)` | Full decision object with deny reason | Sync | `rule` only present on `'allow'` and `'explicit-deny'` variants; fires logger | | `ward.trace(principal, resource, action, data?)` | Decision trace with all matching candidates | Sync | **Does not fire the logger** — use `explain` when logger output is needed | | `ward.allowedActions(principal, resource, knownActions, data?)` | List allowed actions; no logger | Sync | Wildcard-action rules require a non-empty `knownActions` | | `ward.rulesInScope(principal, resource, data?)` | Rules in scope for introspection; no logger | Sync | Without `data`, predicate rules appear unfiltered | | `ward.detectConflicts()` | Lazily detect and cache policy conflicts | Sync | O(n²); predicate-gated rules excluded from static analysis | | `ward.forUser(principal)` | Create a principal-bound ward view | Sync | Principal is deep-snapshotted at bind time | | `guardRequest(ward, principal, resource, action, data?)` | Framework-agnostic sync guard — direct principal | Sync | Use `guardRequestWith` when the principal must be extracted from a request object | | `guardRequestWith(ward, req, extractPrincipal, resource, action, data?)` | Framework-agnostic async guard — request + extractor | Async | Extractor may be async (e.g. JWT verification) | ## Package Entry Points | Import | Purpose | | ------------------------- | ---------------------------------------- | | `@vielzeug/ward` | Main exports and types | | `@vielzeug/ward/devtools` | `debugWard` — decision logger (dev only) | ## Constants - `WILDCARD = '*'` - `ANONYMOUS = 'anonymous'` `WILDCARD` can be used as role, resource, or action. ## WardRule Fields | Field | Type | Required | Description | | ---------- | ----------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `role` | `string \| readonly string[]` | | One role or an array of roles. A rule matches if the principal holds **any** of the listed roles (OR semantics). Use `WILDCARD` for all authenticated principals, `ANONYMOUS` for unauthenticated requests. | | `resource` | `string` | | Resource identifier. Use `WILDCARD` to match any resource. | | `action` | `string` | | Action identifier. Use `WILDCARD` to match any action. | | `effect` | `'allow' \| 'deny'` | | Whether the rule grants or denies access. | | `priority` | `number` | — | Higher value wins. Optional when authoring a rule (defaults to `0`); always present on rules returned from decisions/trace/conflicts. Must be a finite number. | | `when` | `WardPredicate` | — | Runtime predicate evaluated only for authenticated principals. | ### Multi-Role Rules When `role` is an array, the rule matches if the principal holds **any** of the listed roles. This lets you consolidate rules that share identical permissions across several roles: ```ts // Instead of three separate allow rules, write one: const ward = createWard([ { role: ['viewer', 'editor', 'admin'], resource: 'posts', action: 'read', effect: 'allow' }, { role: ['editor', 'admin'], resource: 'posts', action: 'update', effect: 'allow' }, { role: 'admin', resource: 'posts', action: 'delete', effect: 'allow' }, ]); ``` `ANONYMOUS` works inside multi-role arrays too: ```ts // Allows both unauthenticated visitors and authenticated viewers to read { role: [ANONYMOUS, 'viewer'], resource: 'posts', action: 'read', effect: 'allow' } ``` For specificity scoring, a multi-role rule is treated as specific (score 1) unless the array contains `WILDCARD`. ## Core Functions ### `createWard()` ```ts createWard( rules?: readonly WardRule[], options?: WardOptions, ): Ward ``` Creates an immutable ward instance with the given rules. All rules are compiled once at creation time — pass a new array to update the policy. **Parameters — `WardOptions`:** | Option | Type | Default | Description | | -------------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `logger` | `(context: WardLoggerContext) => void` | `undefined` | Called after every decision method (`explain`, `checkAll`, `trace`). Not called by `allowedActions` or `rulesInScope`. | | `onConflict` | `(conflict: WardConflict) => void` | `undefined` | Called synchronously for each conflict detected at creation time. | | `strict` | `boolean` | `false` | Throws immediately if any rule conflicts are detected. | | `maxConflicts` | `number` | `Infinity` | Caps the number of conflicts returned by `detectConflicts()`. Set to `0` to disable conflict detection entirely. | **Winner selection** when multiple rules match: 1. Higher `priority` wins. 2. On priority tie, higher specificity wins (`exact > ns:* > *`, applied independently to role, resource, and action). 3. On specificity tie, `deny` beats `allow`. 4. On absolute tie (identical priority, specificity, and effect), the rule declared **first in the array** wins. **Returns:** `Ward` **Example:** ```ts import { createWard, owns } from '@vielzeug/ward'; const ward = createWard([ { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' }, { role: 'editor', resource: 'posts', action: 'update', effect: 'allow', when: owns('authorId') }, ]); ``` ## Ward Methods ### `checkAll()` ```ts ward.checkAll( principal: Principal, checks: readonly WardCheck[], ): WardDecisionResult[] ``` Evaluates each check independently and returns one `WardDecisionResult` per entry in the same order. Each result includes the originating `resource` and `action` fields, so callers do not need to zip the input array by index. Returns `[]` for an empty array without validating the principal. **Returns:** `WardDecisionResult[]` **Example:** ```ts const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, [ { resource: 'posts', action: 'read' }, { resource: 'posts', action: 'update', data: { authorId: 'u1' } }, ]); ``` --- ### `allowedActions()` ```ts ward.allowedActions( principal: Principal, resource: string, knownActions: readonly TAction[], data?: TData, ): TAction[] ``` Returns the subset of `knownActions` that the principal is currently allowed to perform on `resource`. Evaluates wildcard-action rules against each entry in `knownActions`. Deduplicates the input list. `allowedActions` does **not** invoke the logger. Use `checkAll` if you need an auditable batch decision. **Returns:** `TAction[]` **Example:** ```ts // Resolves wildcard-action rules against the provided list const actions = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete']); // With runtime data for predicate-gated rules const owned = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete'], { authorId: 'u1', }); ``` --- ### `explain()` ```ts ward.explain( principal: Principal, resource: string, action: TAction, data?: TData, ): WardDecision ``` Returns a full decision object including the winning rule (for allow and explicit deny). The returned `rule` object is **frozen** — mutations throw `TypeError`. Uses `'rule' in decision` to safely narrow across all three variants. **Returns:** `WardDecision` **Example:** ```ts const decision = ward.explain({ id: 'u1', roles: ['editor'] }, 'posts', 'delete'); if (!decision.allowed) { console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny' if (decision.reason === 'explicit-deny') { console.log(decision.rule.effect); // safe — rule is present } } ``` --- ### `trace()` ```ts ward.trace( principal: Principal, resource: string, action: TAction, data?: TData, ): WardTrace ``` Returns the complete decision trace: every rule that matched before the winner was selected, plus the final `WardDecision`. Each candidate exposes `priority`, `score`, `rule`, and a `won` flag. `trace()` fires the logger with the same context as `explain()`. Switching from `explain` to `trace` for richer diagnostics does not silently drop audit records. **Returns:** `WardTrace` **Example:** ```ts const { decision, candidates } = ward.trace({ id: 'u1', roles: ['editor'] }, 'posts', 'read'); candidates.forEach(({ rule, priority, score, won }) => { console.log(rule.effect, priority, score, won ? '← winner' : ''); }); ``` --- ### `rulesInScope()` ```ts ward.rulesInScope( principal: Principal, resource: string, data?: TData, ): ReadonlyArray>> ``` Returns all rules matching the principal/resource combination regardless of action. When `data` is provided, predicate rules are also evaluated and excluded if they do not match. Without `data`, predicate-gated rules appear unfiltered. Does not invoke the logger. **Returns:** `ReadonlyArray>>` **Example:** ```ts const rules = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts'); const narrowed = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts', { authorId: 'u1' }); ``` --- ### `detectConflicts()` ```ts ward.detectConflicts(): WardConflict[] ``` Returns all rule conflicts in the policy. Lazily computed and cached — every call after the first returns the same array reference. O(n²) in the number of rules. Two conflict kinds, narrowable by `kind`: - **`'duplicate'`** — two predicate-free rules share the same (role set, resource, action). Fields: `ruleA`/`indexA` (first-declared, wins) and `ruleB`/`indexB` (unreachable). - **`'shadowed'`** — a higher-ranked predicate-free rule covers the narrower rule's patterns entirely. Fields: `shadowingRule`/`shadowingIndex` (always wins) and `shadowedRule`/`shadowedIndex` (can never win). Rules with a `when` predicate are excluded from both checks — their applicability can only be determined at runtime. **Returns:** `WardConflict[]` **Example:** ```ts const conflicts = ward.detectConflicts(); conflicts.forEach((c) => { if (c.kind === 'duplicate') { console.warn(`Rule[${c.indexB}] is an unreachable duplicate of Rule[${c.indexA}]`); } else { console.warn(`Rule[${c.shadowedIndex}] is shadowed by Rule[${c.shadowingIndex}]`); } }); ``` --- ### `forUser()` ```ts ward.forUser(principal: UserPrincipal): BoundWard ``` Creates a principal-bound view of the ward. The principal — including nested `attributes` — is deep-snapshotted at call time; subsequent mutations to the original object have no effect on the bound view. **Returns:** `BoundWard` **Methods on `BoundWard`:** | Method | Signature | Description | | ---------------- | ---------------------------------------------- | ------------------------------ | | `checkAll` | `(checks) => WardDecisionResult[]` | Batch decisions | | `allowedActions` | `(resource, knownActions, data?) => TAction[]` | Action enumeration (no logger) | | `explain` | `(resource, action, data?) => WardDecision` | Full decision with reason | | `rulesInScope` | `(resource, data?) => ReadonlyArray>` | Rule introspection (no logger) | | `trace` | `(resource, action, data?) => WardTrace` | Decision trace (does not fire the logger) | **Example:** ```ts const bound = ward.forUser({ id: 'u1', roles: ['editor'] }); bound.explain('posts', 'read').allowed; bound.checkAll([ { resource: 'posts', action: 'read' }, { resource: 'posts', action: 'update', data: { authorId: 'u1' } }, ]); bound.allowedActions('posts', ['read', 'update', 'delete']); bound.allowedActions('posts', ['read', 'update', 'delete'], { authorId: 'u1' }); bound.explain('posts', 'delete'); bound.trace('posts', 'read'); bound.rulesInScope('posts'); ``` ## Helper Functions ### `allow()` / `deny()` ```ts allow( role: string | readonly string[], resource: string | typeof WILDCARD, actions: readonly (TAction | typeof WILDCARD)[], options?: { priority?: number; when?: WardPredicate }, ): WardRule[] deny( role: string | readonly string[], resource: string | typeof WILDCARD, actions: readonly (TAction | typeof WILDCARD)[], options?: { priority?: number; when?: WardPredicate }, ): WardRule[] ``` Ergonomic rule factories — one `WardRule` per action, with `effect` fixed to `'allow'` or `'deny'`. Spread the result into the array passed to `createWard`. **Returns:** `WardRule[]` **Example:** ```ts import { WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward'; const ward = createWard([ ...allow(['viewer', 'editor'], 'posts', ['read']), ...allow('editor', 'posts', ['update'], { when: owns('authorId'), priority: 5 }), ...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }), ]); ``` --- ### `ruleFor()` ```ts ruleFor( effect: 'allow' | 'deny', role: string | readonly string[], resource: string | typeof WILDCARD, actions: readonly (TAction | typeof WILDCARD)[], options?: { priority?: number; when?: WardPredicate }, ): WardRule[] ``` Low-level rule factory with `effect` as the first argument. `allow()` and `deny()` are thin wrappers over `ruleFor()` — prefer them for readability; use `ruleFor()` when the effect is only known dynamically. **Returns:** `WardRule[]` **Example:** ```ts import { ruleFor } from '@vielzeug/ward'; ruleFor('allow', 'viewer', 'posts', ['read', 'update']); ruleFor('deny', ['blocked', 'suspended'], WILDCARD, [WILDCARD], { priority: 100 }); ``` --- ### `owns()` / `predicate` ```ts owns(attributeKey: keyof TData & string): WardPredicate const predicate: { and(...preds: WardPredicate[]): WardPredicate; not(pred: WardPredicate): WardPredicate; or(...preds: WardPredicate[]): WardPredicate; owns(attributeKey: keyof TData & string): WardPredicate; }; ``` `owns()` returns a predicate that allows the action when `principal.id === data[attributeKey]`. Returns `false` when `data` is absent, not an object, or the attribute is not an own property (guards against prototype-inherited values). `predicate.owns` is the same function, grouped under the `predicate` namespace alongside the combinators: - `predicate.and(...preds)` — all predicates must return `true`. Zero arguments → `true` (vacuously). - `predicate.or(...preds)` — at least one predicate must return `true`. Zero arguments → `false`. - `predicate.not(pred)` — inverts a predicate. `owns()` (and any `when` predicate) must only be used with rules that require authentication (non-`ANONYMOUS` role). Predicates are skipped for unauthenticated requests — pairing `owns` with `ANONYMOUS` produces a rule that can never match. **Example:** ```ts import { allow, predicate } from '@vielzeug/ward'; allow('editor', 'posts:*', ['update'], { when: predicate.owns('authorId') }); allow('user', 'posts:*', ['read'], { when: predicate.and(predicate.owns('authorId'), isBusinessHours) }); ``` --- ### `matchesPattern()` ```ts matchesPattern(pattern: string, value: string): boolean ``` Tests whether a pattern matches a concrete value string. Works for both resource patterns and action patterns. **Pattern semantics:** | Pattern | Matches | | ----------- | -------------------------------------------------------------------- | | `*` | Any value | | `posts` | Exactly `posts` | | `posts:*` | Any value starting with `posts:` (e.g. `posts:123`, `posts:draft:1`) | | `posts:123` | Exactly `posts:123` | | `read:*` | Any action starting with `read:` (e.g. `read:own`, `read:all`) | **Example:** ```ts import { matchesPattern } from '@vielzeug/ward'; matchesPattern('posts:*', 'posts:123'); // true matchesPattern('posts:*', 'comments:1'); // false matchesPattern('read:*', 'read:own'); // true ``` --- ### `patternCovers()` ```ts patternCovers(broad: string, narrow: string): boolean ``` Returns `true` if every concrete value matching `narrow` also matches `broad`. This is the static coverage relation used by `detectConflicts`. Exported for custom policy analysis tooling. **Example:** ```ts import { patternCovers } from '@vielzeug/ward'; patternCovers('*', 'posts:*'); // true patternCovers('posts:*', 'posts:123'); // true patternCovers('posts:*', '*'); // false patternCovers('posts', 'posts:*'); // false ``` --- ### Middleware Factories #### `guardRequest()` ```ts guardRequest( ward: Ward, principal: Principal, resource: string, action: TAction, data?: TData, ): GuardResult ``` Framework-agnostic synchronous guard for a known principal. Returns a `GuardResult`: ```ts type GuardResult = | { granted: true; principal: Principal } | { granted: false; decision: WardDecision; principal: Principal; reason: 'explicit-deny' | 'no-matching-rule' }; ``` Use `guardRequestWith` when the principal must be resolved asynchronously from a request object. **Example:** ```ts import { guardRequest } from '@vielzeug/ward'; const result = guardRequest(ward, principal, 'posts', 'update'); if (!result.granted) { return response.status(403).json({ reason: result.reason }); } ``` --- #### `guardRequestWith()` ```ts guardRequestWith( ward: Ward, req: TReq, extractPrincipal: (req: TReq) => Principal | Promise, resource: string, action: TAction, data?: TData, ): Promise> ``` Framework-agnostic async guard that first extracts the principal from a request object. The extractor may be async (e.g. to verify a JWT). Use `guardRequest` when the principal is already resolved. **Example:** ```ts import { guardRequestWith } from '@vielzeug/ward'; const result = await guardRequestWith(ward, req, (req) => req.user ?? null, 'posts', 'update'); if (!result.granted) { return response.status(403).json({ reason: result.reason }); } ``` ## Types ### `UserPrincipal` ```ts type UserPrincipal = { id: string; roles: readonly string[]; attributes?: Record; }; ``` ### `Principal` ```ts type Principal = UserPrincipal | null; ``` `null` represents an unauthenticated (anonymous) user. ### `RuleContext` ```ts type RuleContext = { principal: UserPrincipal; data?: TData; }; ``` ### `WardPredicate` ```ts type WardPredicate = (ctx: RuleContext) => boolean; ``` ### `WardRule` The single rule shape — used both when authoring rules passed to `createWard` and when reading rules back from decisions, `trace()`, `rulesInScope()`, and `detectConflicts()`. ```ts type WardRule = { action: TAction | typeof WILDCARD; effect: 'allow' | 'deny'; priority?: number; // defaults to 0 resource: string | typeof WILDCARD; role: string | readonly string[]; when?: WardPredicate; }; ``` Internally, `createWard` normalizes each rule at compile time — `role` becomes a deduplicated `readonly string[]` and `priority` defaults to `0` — and freezes the result. Rules read back from `explain()`, `trace()`, `rulesInScope()`, or `detectConflicts()` are these normalized, frozen objects (`Readonly>`); mutating them throws `TypeError`. ### `WardDecision` Three distinct variants — use discriminated narrowing: ```ts type WardDecision = | { allowed: true; rule: WardRule } | { allowed: false; reason: 'explicit-deny'; rule: WardRule } | { allowed: false; reason: 'no-matching-rule' }; // no rule field ``` ```ts const d = ward.explain(principal, 'posts', 'delete'); if (d.allowed) { console.log(d.rule.effect); // 'allow' } else if (d.reason === 'explicit-deny') { console.log(d.rule.effect); // 'deny' } else { // d.reason === 'no-matching-rule' — no rule field present } // Generic narrowing: if ('rule' in d) console.log(d.rule); ``` ### `WardCheck` ```ts type WardCheck = { resource: string; action: TAction; data?: TData; }; ``` ### `WardLoggerContext` Structurally identical to `WardDecision` plus the request fields — narrow `rule` with the same `if (ctx.allowed)` pattern used for decisions: ```ts type WardLoggerContext = WardDecision & { action: TAction; data?: TData; principal: Principal; resource: string; }; ``` ```ts logger: (ctx) => { if (ctx.allowed) { console.log(ctx.rule.role); // no ?. needed — 'allowed: true' always carries a rule } else if (ctx.reason === 'explicit-deny') { console.log(ctx.rule.role); // 'explicit-deny' also carries a rule } }, ``` ### `WardOptions` ```ts type WardOptions = { logger?: (context: WardLoggerContext) => void; onConflict?: (conflict: WardConflict) => void; strict?: boolean; maxConflicts?: number; }; ``` ### `ConflictKind` / `WardConflict` `WardConflict` is a discriminated union, narrowable by `kind`: ```ts type ConflictKind = 'duplicate' | 'shadowed'; type WardConflict = | { kind: 'duplicate'; indexA: number; // first-declared rule (always wins) indexB: number; // second-declared rule (unreachable) ruleA: Readonly>; ruleB: Readonly>; } | { kind: 'shadowed'; shadowedIndex: number; // the rule that can never win shadowedRule: Readonly>; shadowingIndex: number; // the rule that always wins instead shadowingRule: Readonly>; }; ``` ### `WardTrace` / `WardTraceCandidate` ```ts type WardTraceCandidate = { index: number; // original index in the input array passed to createWard priority: number; rule: Readonly>; score: number; won: boolean; }; type WardTrace = { candidates: WardTraceCandidate[]; decision: WardDecision; }; ``` ## `debugWard(rules, options?)` ```ts import { debugWard } from '@vielzeug/ward/devtools'; const permit = debugWard(rules); permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read'); // [ward:decision] allow (allow) viewer posts read permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'delete'); // [ward:decision] no-matching-rule viewer posts delete ``` Wraps `createWard()` with a `logger` pre-wired to `console.debug`. Returns the same `Ward` instance — all methods are identical to `createWard()`. Debug output fires on `explain()` and `checkAll()`; `trace()` never fires the logger (by design — see `trace()` above). Import from the dedicated sub-path so the `console.debug` reference is tree-shaken from production bundles when not imported. Accepts the same `options` as `createWard()` except `logger`, which is reserved for the debug output. All other options (`maxConflicts`, `onConflict`, `strict`) pass through unchanged. ### `WardDecisionResult` ```ts type WardDecisionResult = WardDecision & { action: TAction; resource: string; }; ``` The return type of `checkAll()` — a `WardDecision` with the originating `resource` and `action` attached, so callers do not need to zip the result by index. ### `WardRequest` ```ts type WardRequest = Record; ``` Base constraint for the request object type used in `guardRequestWith`. Any object type satisfies this constraint. ### `Ward` / `BoundWard` `Ward` is returned by `createWard()`. `BoundWard` is returned by `ward.forUser()` and omits `forUser` and `detectConflicts`. Full method signatures are documented in the sections above. ### Usage Guide ## Basic Usage Create a ward instance with an array of rules. Rules are compiled once at creation time. ```ts import { WILDCARD, createWard } from '@vielzeug/ward'; const ward = createWard([ { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' }, { role: 'editor', resource: 'posts', action: 'update', effect: 'allow' }, // High-priority deny blocks the blocked role from every action on posts { role: 'blocked', resource: 'posts', action: WILDCARD, effect: 'deny', priority: 100 }, ]); ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'update').allowed; // false ward.explain({ id: 'u2', roles: ['blocked'] }, 'posts', 'read').allowed; // false ``` To update the policy, create a new instance — rules are immutable after creation. ## Rule Factories Use `allow()` / `deny()` as an alternative to raw rule objects. Each produces one `WardRule` per action — spread the result into the array passed to `createWard`. They read naturally: "allow editor to read/update posts". ```ts import { allow, createWard, owns } from '@vielzeug/ward'; const ward = createWard([ ...allow(['viewer', 'editor'], 'posts', ['read']), ...allow('editor', 'posts', ['update', 'delete'], { when: owns('authorId') }), ]); ``` Pass `{ priority: n }` and/or `{ when: predicate }` as the fourth argument. `deny()` is the same shape with `effect: 'deny'` fixed: ```ts import { WILDCARD, deny } from '@vielzeug/ward'; deny('blocked', 'posts', [WILDCARD], { priority: 100 }); ``` `ruleFor(effect, role, resource, actions, options?)` is the low-level factory that `allow()`/`deny()` wrap — use it when the effect is only known dynamically. ## Hierarchical Resources Use colon-namespaced patterns to scope rules to resource instances: ```ts const ward = createWard([ // Applies to any resource under 'posts:' namespace { role: 'editor', resource: 'posts:*', action: 'update', effect: 'allow' }, // Applies only to one specific post { role: 'viewer', resource: 'posts:123', action: 'read', effect: 'allow' }, ]); ward.explain(editor, 'posts:456', 'update').allowed; // true — matches posts:* ward.explain(viewer, 'posts:123', 'read').allowed; // true — exact match ward.explain(viewer, 'posts:456', 'read').allowed; // false — no matching rule ``` The same namespace-wildcard syntax works for actions (action hierarchy): ```ts // 'read:*' matches 'read:own', 'read:all', 'read:draft:1', etc. const ward = createWard([{ role: 'viewer', resource: 'posts', action: 'read:*', effect: 'allow' }]); ward.explain(viewer, 'posts', 'read:own').allowed; // true ward.explain(viewer, 'posts', 'read:all').allowed; // true ward.explain(viewer, 'posts', 'write').allowed; // false ``` `matchesPattern(pattern, value)` is exported for custom integration code. `patternCovers(broad, narrow)` is exported to test whether one pattern statically covers another (used by `detectConflicts`). ## Check Permissions ```ts const principal = { id: 'u1', roles: ['editor'] }; ward.explain(principal, 'posts', 'read').allowed; ward.explain(principal, 'posts', 'delete').allowed; ward.explain(null, 'posts', 'read').allowed; ``` `principal` must be either: - `null` for anonymous users - `{ id: string, roles: readonly string[] }` for authenticated users Malformed principal values throw errors. ## Bind a User with `forUser` `BoundWard` does not expose `detectConflicts()`. Run `ward.detectConflicts()` on the parent ward before calling `forUser()` — typically at startup or during policy initialization. ```ts const bound = ward.forUser({ id: 'u1', roles: ['editor'] }); bound.explain('posts', 'read').allowed; bound.explain('posts', 'update', { authorId: 'u1' }).allowed; ``` `forUser()` returns a reusable bound ward object and snapshots roles/attributes at binding time. ## Check Multiple Actions Ward has no dedicated "all"/"any" helper — use `checkAll()` and reduce with `Array.every` / `Array.some`: ```ts const checks = [ { action: 'read', resource: 'posts' }, { action: 'update', resource: 'posts', data: { authorId: 'u1' } }, ] as const; const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, checks); const allAllowed = decisions.every((d) => d.allowed); const anyAllowed = decisions.some((d) => d.allowed); ``` ## Batch Decisions with `checkAll` ```ts const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, [ { resource: 'posts', action: 'read' }, { resource: 'posts', action: 'update', data: { authorId: 'u1' } }, ]); const bound = ward.forUser({ id: 'u1', roles: ['editor'] }); const boundDecisions = bound.checkAll([ { resource: 'posts', action: 'read' }, { resource: 'posts', action: 'delete' }, ]); ``` `checkAll()` returns a `WardDecisionResult[]` — each entry is a `WardDecision` with the originating `resource` and `action` fields attached, so callers do not need to zip the result by index. ## List Allowed Actions `allowedActions(principal, resource, knownActions, data?)` returns the subset of `knownActions` that the principal is allowed to perform on `resource`. `knownActions` is required because Ward cannot enumerate actions on its own — an action defined with `WILDCARD` has no finite list of concrete values. Passing `knownActions` resolves wildcard-action rules against that set: ```ts // Returns the subset of the provided list that is allowed const actions = ward.allowedActions({ id: 'u1', roles: ['admin'] }, 'posts', ['read', 'update', 'delete']); // With runtime data for predicate-gated rules const ownedActions = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete'], { authorId: 'u1', }); ``` `allowedActions` does not invoke the logger — it is a side-effect-free enumeration helper. ## Inspect Rule Scope with `rulesInScope` ```ts const rules = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts'); const narrowed = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts', { authorId: 'u1' }); const bound = ward.forUser({ id: 'u1', roles: ['editor'] }); const boundRules = bound.rulesInScope('posts'); ``` `rulesInScope()` is introspection-only. It returns rules in scope for the principal/resource pair and never mutates the ward. If you pass `data`, Ward also filters predicate rules by whether they match that runtime payload. ## Explain Denials and Winners ```ts const decision = ward.explain({ id: 'u1', roles: ['editor'] }, 'posts', 'delete'); if (!decision.allowed) { console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny' // decision.rule is only present for 'explicit-deny', not 'no-matching-rule' if (decision.reason === 'explicit-deny') { console.log(decision.rule.effect); // 'deny' } } ``` `explain()` returns a **3-variant discriminated union**: | Variant | `allowed` | `reason` | `rule` | | ------------- | --------- | -------------------- | --------------------- | | Allow | `true` | — | The winning rule | | Explicit deny | `false` | `'explicit-deny'` | The winning deny rule | | No match | `false` | `'no-matching-rule'` | Not present | Use `'rule' in decision` to safely access the rule across all variants. ## Trace Decisions `trace()` returns the complete decision trace: every rule that matched the request before the winner was selected, with per-candidate scoring details. ```ts const { decision, candidates } = ward.trace({ id: 'u1', roles: ['editor'] }, 'posts', 'read'); candidates.forEach(({ rule, priority, score, won }) => { console.log(rule.effect, priority, score, won ? '← winner' : ''); }); ``` `trace()` does **not** fire the logger — it is a side-channel-free inspection tool. Use `explain()` when you need the logger to fire and only need the final decision. `trace()` is also available on `BoundWard`: `bound.trace(resource, action, data?)`. ## Detect Policy Conflicts `detectConflicts()` performs a static O(n²) analysis of your rule set and returns all detected conflicts. The result is lazily computed — every call after the first returns the same array reference. ```ts const conflicts = ward.detectConflicts(); conflicts.forEach((c) => { if (c.kind === 'duplicate') { console.warn(`Rule[${c.indexB}] is an unreachable duplicate of Rule[${c.indexA}]`); } else { console.warn(`Rule[${c.shadowedIndex}] is shadowed by Rule[${c.shadowingIndex}]`); } }); ``` Two conflict kinds, narrowable by `kind`: - **`'duplicate'`** — two predicate-free rules have the same (role set, resource, action). The second (`ruleB`/`indexB`) can never fire because the first (`ruleA`/`indexA`) always wins. - **`'shadowed'`** — a higher-ranked predicate-free rule (`shadowingRule`/`shadowingIndex`) covers the other's (`shadowedRule`/`shadowedIndex`) patterns entirely. The shadowed rule can never win. Rules with a `when` predicate are excluded from both checks because their applicability is determined at runtime, not statically. To surface conflicts eagerly at startup: ```ts // Warn on conflicts const ward = createWard(rules, { onConflict: (c) => console.warn(c.kind === 'duplicate' ? `[ward] duplicate: Rule[${c.indexB}]` : `[ward] shadowed: Rule[${c.shadowedIndex}]`), }); // Throw on first conflict (strict mode) const ward = createWard(rules, { strict: true }); // Cap O(n²) cost for large auto-generated policies const ward = createWard(rules, { maxConflicts: 20 }); ``` ## Use Dynamic Conditions with `when` ```ts const ward = createWard([ { role: 'editor', resource: 'posts', action: 'update', effect: 'allow', when: ({ principal, data }) => principal.id === data?.authorId, }, ]); ``` `when` only runs for authenticated principals. For anonymous (`null`) checks, predicates are skipped and the rule does not match. Do not pair `owns()` or any `when` predicate with an `ANONYMOUS`-role rule — it can never match. ### Ownership Checks with `owns` ```ts import { createWard, owns } from '@vielzeug/ward'; const ward = createWard([ { role: 'editor', resource: 'posts', action: 'update', effect: 'allow', when: owns('authorId'), }, ]); ``` `owns()` is a convenience helper for the common `principal.id === data[attributeKey]` pattern. ### Attribute-Based Conditions (ABAC) ```ts const ward = createWard([ { role: 'editor', resource: 'posts', action: 'publish', effect: 'allow', when: ({ principal }) => principal.attributes?.tier === 'pro', }, ]); ``` `principal.attributes` can store arbitrary user metadata for runtime policy checks. ## Multi-Role Rules The `role` field accepts either a single string or an array of strings. A rule matches if the principal holds **any** of the listed roles (OR semantics). Multi-role rules reduce repetition when several roles share identical permissions: ```ts import { createWard } from '@vielzeug/ward'; const ward = createWard([ // One rule instead of three separate allow rules { role: ['viewer', 'editor', 'admin'], resource: 'posts', action: 'read', effect: 'allow' }, { role: ['editor', 'admin'], resource: 'posts', action: 'update', effect: 'allow' }, { role: 'admin', resource: 'posts', action: 'delete', effect: 'allow' }, ]); ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true ward.explain({ id: 'u2', roles: ['editor'] }, 'posts', 'update').allowed; // true ward.explain({ id: 'u2', roles: ['editor'] }, 'posts', 'delete').allowed; // false ``` `ANONYMOUS` works inside multi-role arrays. The rule matches both unauthenticated visitors and any authenticated role listed alongside it: ```ts import { ANONYMOUS, createWard } from '@vielzeug/ward'; const ward = createWard([{ role: [ANONYMOUS, 'viewer'], resource: 'posts', action: 'read', effect: 'allow' }]); ward.explain(null, 'posts', 'read').allowed; // true (anonymous) ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true (viewer) ward.explain({ id: 'u2', roles: ['admin'] }, 'posts', 'read').allowed; // false (not in list) ``` ## Anonymous and Wildcards ```ts import { ANONYMOUS, WILDCARD } from '@vielzeug/ward'; const ward = createWard([ { role: ANONYMOUS, resource: 'posts', action: 'read', effect: 'allow' }, { role: WILDCARD, resource: 'status', action: 'read', effect: 'allow' }, ]); ``` Use `ANONYMOUS` for anonymous-only rules and `WILDCARD` for any role/resource/action. ## Logger and Auditing ```ts const ward = createWard([{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' }], { logger: (ctx) => { const subject = ctx.principal === null ? 'anonymous' : ctx.principal.id; const outcome = ctx.allowed ? 'allow' : ctx.reason; console.log(subject, ctx.resource, ctx.action, outcome); }, }); ``` The logger runs after `explain()` and `checkAll()` (including through a `BoundWard`). It does **not** run after `trace()`. Enumeration and introspection helpers (`allowedActions()`, `rulesInScope()`, `detectConflicts()`) stay side-effect free. `WardLoggerContext` is structurally identical to `WardDecision` plus the request fields, so `rule` narrows the same way — present when `allowed` is `true` or `reason` is `'explicit-deny'`: ```ts logger: (ctx) => { if (ctx.allowed || ctx.reason === 'explicit-deny') { console.log(ctx.rule.role); // no ?. needed — rule is present } }, ``` - `allowed: true` — a matching allow rule won - `allowed: false, reason: 'explicit-deny'` — a matching deny rule won - `allowed: false, reason: 'no-matching-rule'` — no rule matched at all (default deny) This lets you distinguish explicit blocks from gaps in your policy in audit logs and metrics. ## Decision Precedence Ward uses one deterministic model: 1. If no rule matches, decision is deny. 2. Higher `priority` wins. 3. For equal `priority`, higher specificity wins — `exact > namespace-wildcard (ns:*) > global-wildcard (*)`, applied independently to role, resource, and action. 4. For equal `priority` and specificity, deny overrides allow. 5. On absolute tie (identical priority, specificity, and effect), the rule declared **first in the array** wins. ## Exact Matching Ward uses exact string matching for role/resource/action. ```ts const ward = createWard([{ role: 'admin', resource: 'posts', action: 'read', effect: 'allow' }]); ward.explain({ id: 'u1', roles: ['admin'] }, 'posts', 'read').allowed; // true ward.explain({ id: 'u1', roles: ['ADMIN'] }, 'posts', 'read').allowed; // false ``` Adopt one identifier convention (for example all lowercase) at your app boundary. ## Framework Integration ```tsx [React] import { createContext, useContext, type ReactNode } from 'react'; import { createWard } from '@vielzeug/ward'; type User = { id: string; roles: string[] }; const ward = createWard([ { role: 'admin', resource: '*', action: '*', effect: 'allow' }, { role: 'editor', resource: 'posts', action: 'write', effect: 'allow' }, ]); const UserContext = createContext(null); function useWard(resource: string, action: string) { const user = useContext(UserContext); if (!user) return false; return ward.explain(user, resource, action).allowed; } function EditButton({ postId }: { postId: string }) { const canEdit = useWard('posts', 'write'); if (!canEdit) return null; return Edit {postId}; } ``` ```ts [Vue 3] import { computed } from 'vue'; import { createWard } from '@vielzeug/ward'; type User = { id: string; roles: string[] }; const ward = createWard([ { role: 'admin', resource: '*', action: '*', effect: 'allow' }, { role: 'editor', resource: 'posts', action: 'write', effect: 'allow' }, ]); function useWard(user: { value: User | null }, resource: string, action: string) { return computed(() => (user.value ? ward.explain(user.value, resource, action).allowed : false)); } ``` ```svelte [Svelte] import { createWard } from '@vielzeug/ward'; type User = { id: string; roles: string[] }; export let user: User; const ward = createWard([ { role: 'admin', resource: '*', action: '*', effect: 'allow' }, { role: 'editor', resource: 'posts', action: 'write', effect: 'allow' }, ]); $: canEdit = ward.explain(user, 'posts', 'write').allowed; {#if canEdit}Edit{/if} ``` ### Pitfalls - **React:** If the ward is created inside a component that re-renders often, `createWard()` runs on every render. Memoize with `useMemo(() => createWard(...), [role])`, or define it once at module scope as in the example above. - **Vue 3:** Injecting `ward` as a plain value (not a `ComputedRef`) means role changes don't propagate to child components. Always inject as a reactive ref. - **Svelte:** `setContext` must be called synchronously during component initialization. Calling it inside a reactive statement (`$:`) works only for setting the initial value — child components reading the context must use `getContext` in their own `` block. ## Middleware Integration Ward has no framework-specific middleware — `guardRequest` and `guardRequestWith` are small, framework-agnostic helpers you wire into a 2–3 line adapter for whichever server you use. ```ts import { guardRequest, guardRequestWith } from '@vielzeug/ward'; // Principal is already resolved (e.g. from session) — sync, no await needed const result = guardRequest(ward, principal, 'posts', 'update'); // Principal must be extracted from a request object (e.g. verify JWT) — async const result = await guardRequestWith(ward, req, getPrincipal, 'posts', 'update'); if (!result.granted) { return new Response(JSON.stringify({ reason: result.reason }), { status: 403 }); } ``` ### Express / Connect ```ts app.use('/posts', async (req, res, next) => { const result = await guardRequestWith(ward, req, (r) => r.user ?? null, 'posts:*', 'update'); result.granted ? next() : res.status(403).json({ reason: result.reason }); }); ``` ### Hono ```ts app.put('/posts/:id', async (c, next) => { const result = guardRequest(ward, c.get('user') ?? null, `posts:${c.req.param('id')}`, 'update'); return result.granted ? next() : c.json({ reason: result.reason }, 403); }); ``` ## Debug Mode Import `debugWard` from the dedicated sub-path to create a ward with decision logging pre-enabled. The sub-path is tree-shaken from production bundles when not imported. ```ts import { debugWard } from '@vielzeug/ward/devtools'; const permit = debugWard([ { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' }, { role: 'editor', resource: 'posts', action: 'update', effect: 'allow' }, ]); permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read'); // [ward:decision] allow (allow) viewer posts read permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'update'); // [ward:decision] no-matching-rule viewer posts update permit.explain(null, 'posts', 'read'); // [ward:decision] no-matching-rule anonymous posts read ``` The ward returned is identical to `createWard()` — all methods (`explain`, `checkAll`, `forUser`, etc.) work the same way. Alternatively, pass a custom `logger` directly to `createWard()` to route decisions to a structured logger: ```ts const permit = createWard(rules, { logger: (ctx) => myLogger.debug('access decision', ctx), }); ``` Debug logging fires on `explain()` and `checkAll()` (including through a `BoundWard`). It does **not** fire on `trace()`, or on the side-effect-free helpers `allowedActions()`, `rulesInScope()`, and `detectConflicts()`. ## Working with Other Vielzeug Libraries ### With Wayfinder Use ward guards inside Wayfinder middleware to protect routes. ```ts import { createWard } from '@vielzeug/ward'; import { createRouter } from '@vielzeug/wayfinder'; type User = { id: string; roles: string[] }; const ward = createWard([{ role: 'admin', resource: 'settings', action: 'read', effect: 'allow' }]); const router = createRouter({ routes: { settings: { path: '/settings', handler: ({ data }) => renderSettings(data), }, }, middleware: [ (ctx, next) => { const user: User = getSessionUser(); // your auth provider if (!ward.explain(user, 'settings', 'read').allowed) { return ctx.navigate({ path: '/login' }); } return next(); }, ], }); ``` ### With Rune Use ward's `logger` option to audit every access decision. ```ts import { createWard } from '@vielzeug/ward'; import { createLogger } from '@vielzeug/rune'; const log = createLogger({ namespace: 'ward' }); const ward = createWard( [ /* rules */ ], { logger: (decision) => log.info('access decision', decision), }, ); ``` ## Best Practices - Keep roles and resources explicit and predictable. - Use `priority` sparingly for explicit overrides. - Keep `when` predicates pure and side-effect free. - Prefer one ward instance per app boundary and keep rules centralized. - Use `forUser({ ... })` for repeated checks in UI or request scopes. ### Examples ## Examples - [Blog Roles](./examples/blog-roles.md) - [Multi-Role Rules](./examples/multi-role-rules.md) - [Wildcard Action](./examples/wildcard-action.md) - [Inheritance And Overrides](./examples/inheritance-and-overrides.md) - [Bound Guard In Ui Layer](./examples/bound-guard-in-ui-layer.md) - [Disabling Wildcard Fallback](./examples/disabling-wildcard-fallback.md) - [Logger For Auditing](./examples/logger-for-auditing.md) - [Snapshot Restore For Test Isolation](./examples/snapshot-restore-for-test-isolation.md) - [Conflict Detection](./examples/conflict-detection.md) - [Trace a Decision](./examples/trace-decision.md) ### REPL Examples - Basic Rules (id: `basic-rules`) - Basic Setup — Multi-Role Rules (id: `basic-setup`) - Batch Decisions (id: `batch-decisions`) - Bound View (id: `bound-view`) - Conflict Detection (id: `conflict-detection`) - Dynamic Permissions — Ownership Rules (id: `dynamic-permissions`) - Framework-Agnostic Guard (id: `middleware-guard`) - Multi-Role Rules (id: `multi-role-rules`) - Permission Checks (id: `permission-checks`) - Introspection and Batch Decisions (id: `permission-management`) - Bound Multi-Role Access (id: `role-hierarchy`) - Rule Factories & Predicates (id: `rule-factories`) - Trace — Inspect Matching Candidates (id: `trace-decision`) - Wildcard Rules (id: `wildcard-permissions`) --- ## @vielzeug/wayfinder **Category:** routing **Keywords:** router, client-side, middleware, guards, navigation, history, spa, typed-routes **Key exports:** createRouter, createBrowserHistory, createMemoryHistory, redirectTo, WayfinderError, WayfinderApiError, WayfinderDisposedError, WayfinderRedirectLoopError, WayfinderRouteError, debugRouter **Related:** ripple, ward, herald ### Overview ## Why Wayfinder? Managing navigation by hand means scattered `popstate` listeners, duplicated path checks, and no shared abstraction for loading data or blocking navigation. Wayfinder moves all of that into one declarative table. ```ts // Before — manual navigation with popstate window.addEventListener('popstate', () => { const path = window.location.pathname; if (path === '/') renderHome(); else if (path.startsWith('/dashboard')) renderDashboard(); else renderNotFound(); }); document.querySelectorAll('a[data-route]').forEach((a) => { a.addEventListener('click', (e) => { e.preventDefault(); history.pushState({}, '', (e.currentTarget as HTMLAnchorElement).href); dispatchEvent(new PopStateEvent('popstate')); }); }); // After — with Wayfinder import { createRouter } from '@vielzeug/wayfinder'; const router = createRouter({ routes: { home: { path: '/' }, dashboard: { path: '/dashboard' }, }, notFound: { component: NotFoundPage }, }); router.subscribe((state) => { render(state.matches.at(-1)?.component); }); ``` **Use Wayfinder when** you need named navigation, route-level data loading with cancellation, middleware, or leave guards in a framework-agnostic setup. **Consider a framework's built-in router when** you are deep in a single framework ecosystem (React Router, Vue Router) and want first-class component binding with no adapter layer. | Feature | Wayfinder | page.js | Navigo | | ------------------------------------ | ----------------------------------------------- | ------------------------------------------ | ------------------------------------------ | | Bundle size | | ~1 kB | ~5 kB | | History mode | | | | | Memory history (tests / non-browser) | | | | | Typed path params | | | | | Named navigation | | | Partial | | Middleware | | | | | Data loaders with AbortSignal | | | | | Lazy route loading | | | | | Declarative redirects | | | | | Search param validation | | | | | Error in state | | | | | History state in context | | | | | Leave guards | | | | | Hover prefetching (`preload()`) | | | | | Scroll restoration | | | | | View Transition API | | | | | Zero dependencies | | | | ## Installation ```sh [pnpm] pnpm add @vielzeug/wayfinder ``` ```sh [npm] npm install @vielzeug/wayfinder ``` ```sh [yarn] yarn add @vielzeug/wayfinder ``` ## Quick Start ```ts import { createRouter } from '@vielzeug/wayfinder'; const router = createRouter({ routes: { home: { path: '/' }, dashboard: { path: '/dashboard', children: { index: { index: true }, settings: { path: 'settings', data: async () => fetchSettings(), }, }, }, }, notFound: { component: NotFoundPage }, }); await router.navigate({ name: 'dashboard.settings' }); ``` ## Features - One declarative route table with nested names (`dashboard.settings`) - Named and raw-path navigation through one `navigate()` API - Lazy-load route modules on first navigation - Middleware for guards, analytics, and error boundaries - Route `data()` loaders with `AbortSignal` cancellation and async-generator streaming - Per-match `status` for granular loading/streaming feedback in nested layouts - Global `beforeLeave` leave guards with optional route scoping - Typed and coercible search params via `coerceSearch` - Per-route `onError` boundaries for degraded data states - Declarative `notFound` fallback in router options - Hover-prefetch via `router.preload()` - Branch resolve without navigation via `router.resolve()` - SSR data prefetch via `router.match(url)` - Scroll restoration via the `scroll` option - History entry state readable as `ctx.historyState` - Errors from data loaders exposed on `router.getSnapshot().error` - `router.waitFor(name)` for lifecycle coordination and testing - Memory history for tests and non-browser environments - Wildcard routes for catch-all cases - Base-path support for app subdirectories - View Transition API with per-navigation override - **Debug logging** via `debugRouter()` (`@vielzeug/wayfinder/devtools`) — logs every navigation phase change with `[wayfinder:nav]` prefixes; tree-shaken from production bundles ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/) — reactive signals; sync router state to a signal for framework-agnostic reactivity - [Ward](/ward/) — permission guards; use inside Wayfinder middleware to protect routes - [Herald](/herald/) — event bus; dispatch route-change events to decouple navigation side effects ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --------------------------------------- | ---------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | | `createRouter(options)` | Create a router from a route table | Sync | Initial navigation starts asynchronously in the constructor | | `createBrowserHistory()` | Create the default browser history driver | Sync | — | | `createMemoryHistory(initialPath?)` | Create an in-memory history driver | Sync | — | | `redirectTo(target, options?)` | Build redirect middleware | Sync (returns fn) | Does not call `next()` — always short-circuits the chain | | `router.navigate(target, options?)` | Navigate to a named route, raw path object, or string path | Async | No-op when destination equals current URL unless `force: true` | | `router.getSnapshot()` | Return the current immutable route state | Sync | Does not subscribe — call `subscribe()` to react to changes | | `router.subscribe(listener)` | Register a listener for state changes | Sync (returns unsub) | Listener is **not** called immediately with current state | | `router.url(name, params?, query?)` | Build a URL for a named route | Sync | Throws if the route name is unknown | | `router.isActive(name, options?)` | Check if a named route matches the current URL | Sync | Compares against the current snapshot pathname, not `history.location` directly | | `router.resolve(pathname)` | Resolve a pathname to a branch without side effects | Sync | Returns `null` for redirect routes | | `router.match(url, options?)` | Resolve a URL to a full state including data loaders | Async | Lazy modules are resolved as a side effect | | `router.preload(name, params?, query?)` | Eagerly run data loaders without navigating | Async | Pass `query` to match the navigation cache key; rejects with `WayfinderDisposedError` if the router is disposed | | `router.waitFor(name)` | Wait for the router to settle on a named route | Async | Rejects immediately if `status === 'error'`; rejects with `WayfinderDisposedError` if disposed while pending | | `router.beforeLeave(blocker, options?)` | Register a global leave guard | Sync (returns unsub) | Scoped to specific routes via `options.routes` | | `router.dispose()` | Remove listeners and shut down the router | Sync | Idempotent — safe to call multiple times | ## Package Entry Points | Import | Purpose | | ------------------------------ | -------------------------------------------- | | `@vielzeug/wayfinder` | Main exports and types | | `@vielzeug/wayfinder/devtools` | `debugRouter` — navigation logger (dev only) | ## `createRouter(options)` ```ts import { createRouter } from '@vielzeug/wayfinder'; const router = createRouter({ base: '/app', routes: { home: { path: '/' }, dashboard: { path: '/dashboard', children: { index: { index: true }, settings: { path: 'settings', data: () => fetchSettings() }, }, }, }, notFound: { component: NotFoundPage }, }); ``` | Option | Type | Default | Description | | ---------------- | ----------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `base` | `string` | `'/'` | Base path prefix for all routes | | `coerceSearch` | `CoerceSearchFn` | — | Global search-param coercion applied to every route that does not define its own `coerceSearch`. Throwing falls back to raw strings and is reported via `onError`. | | `history` | `HistoryDriver` | `createBrowserHistory()` | History source used for reading locations and writing navigations | | `middleware` | `Middleware[]` | `[]` | Global middleware prepended to every route | | `notFound` | `{ component?, data?, meta?, middleware? }` | — | Synthetic route used when no path matches. Global middleware runs first, then `notFound.middleware` and `notFound.data`. `ctx.pathname` is the unmatched path. | | `onError` | `(error, context: RouterErrorContext) => void` | — | Optional sink for non-awaited/background router errors | | `routes` | `RouteTable` | required | Declarative route table. Object key order defines match precedence. | | `scroll` | `(to, from) => ScrollDecision` | — | Called after each navigation. Return `'top'` to scroll to top, `'preserve'` to keep the current position, or `{ x, y }` for a specific position. | | `viewTransition` | `boolean` | `false` | Wrap navigations in the View Transition API when available | **Returns:** `Router` ## Route Table Define routes as a plain object where keys become route names. TypeScript will infer route params from literal `path` strings. ```ts const routes = { home: { path: '/' }, userDetail: { path: '/users/:id' }, files: { path: '/files/:rest*' }, }; ``` Nested routes are declared with `children`, and child names become compound names with dot notation. ## Route Definition ```ts const routes = { home: { path: '/' }, dashboard: { path: '/dashboard', middleware: [requireAuth], children: { index: { index: true }, settings: { path: 'settings', data: async () => fetchSettings(), }, }, }, userDetail: { path: '/users/:id', meta: { section: 'users' }, data: async ({ params }) => fetchUser(params.id), onError: (error) => ({ error, user: null }), }, }; ``` Each route definition supports these fields: | Field | Type | Description | | -------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `path` | `string` | Wayfinder pattern. Supports static paths, `:param`, `:param*`, and `*`. Child paths are relative unless they start with `/`. | | `children` | `Record` | Nested child routes. Child names are appended to the parent route name. | | `index` | `boolean` | Default child route that inherits the parent path. | | `component` | `unknown` | Optional framework view payload exposed on the leaf `RouteMatch`. | | `data` | `DataFn` | Data loader. Runs after middleware; result available as `match.data`. Supports streaming via `AsyncGenerator`. | | `lazy` | `() => Promise` | Lazy-load the route module. Called once on first navigation; result overrides static fields in the hydration cache. | | `meta` | `unknown` | Static metadata exposed on each `RouteMatch` in the branch. | | `middleware` | `Middleware[]` | Optional route-specific middleware | | `onError` | `(error, context: DataContext) => MaybePromise` | Per-route error boundary for data loader failures. Return value becomes `match.data` for degraded rendering. | | `redirect` | `NavigationTarget` | Declarative redirect. Resolved before middleware runs; uses `replaceState` so the original URL is never added to history. | | `coerceSearch` | `(raw: QueryParams) => ResolvedQueryParams` | Coerce raw URL string values into typed values. Return value replaces `ctx.query`. Throwing leaves the parsed query unchanged. | ## `createBrowserHistory()` ```ts import { createBrowserHistory } from '@vielzeug/wayfinder'; const history = createBrowserHistory(); ``` Create the default `HistoryDriver` backed by the browser History API. ## `createMemoryHistory(initialPath?)` ```ts import { createMemoryHistory } from '@vielzeug/wayfinder'; // Tests const router = createRouter({ history: createMemoryHistory('/dashboard'), routes, }); // Controlled non-browser runtime const router = createRouter({ history: createMemoryHistory('/request-path'), routes, }); ``` Create an in-memory `HistoryDriver`. No browser history globals required — suitable for unit tests and controlled non-browser runtimes. The optional `initialPath` defaults to `'/'`. ## `Router` ### Lifecycle #### `router.dispose()` Remove listeners, clear subscribers, and reject future router interaction. Idempotent — safe to call multiple times. **Returns:** `void` **Throws:** Never. --- #### `router.disposed` `boolean` — `true` after `dispose()` has been called. --- #### `router.disposalSignal` `AbortSignal` that is aborted (with a `WayfinderDisposedError` reason) when the router is disposed. Use this to tie external resource lifetimes to the router's lifetime. ```ts source.on('update', syncRouteParams, { signal: router.disposalSignal }); ``` --- ### Navigation #### `router.navigate(target, options?)` ```ts await router.navigate({ name: 'userDetail', params: { id: '42' } }); await router.navigate({ name: 'userDetail', params: { id: '42' } }, { replace: true }); await router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' }); ``` | Option | Type | Default | Description | | ---------------- | --------- | ------- | ------------------------------------------------------- | | `replace` | `boolean` | `false` | Use `replaceState` instead of `pushState` | | `state` | `unknown` | — | History state payload | | `viewTransition` | `boolean` | — | Override the router-level setting for this navigation | | `force` | `boolean` | `false` | Re-run even when the destination URL is already current | **Returns:** `Promise` Named routes stay the primary API, but `navigate()` also accepts raw path objects or a plain string: ```ts await router.navigate({ path: '/marketing?utm_source=campaign' }); await router.navigate({ path: '/checkout#payment' }, { replace: true }); // Plain string — most concise for direct paths await router.navigate('/about'); await router.navigate('/search?q=hello'); ``` --- ### Route Helpers #### `router.url(name, params?, query?)` ```ts router.url('userDetail', { id: '42' }); router.url('userDetail', { id: '42' }, { tab: 'profile' }); ``` Build a base-aware URL for a named route. **Returns:** `string` #### `router.isActive(name, options?)` ```ts router.isActive('userDetail'); router.isActive('users'); router.isActive('users', { exact: true }); ``` Check whether the current pathname matches a named route exactly or by prefix. **Returns:** `boolean` #### `router.resolve(pathname)` ```ts router.resolve('/app/dashboard/settings'); // => [ // { name: 'dashboard', ... }, // { name: 'dashboard.settings', ... }, // ] ``` Resolve a pathname without running middleware, handlers, data loaders, or subscribers. Strips the configured `base` automatically. Returns the matched branch from root to leaf, or `null` for redirect routes and no-match. **Returns:** `RouteMatchBranch | null` --- #### `router.match(url, options?)` ```ts // SSR data prefetch const state = await router.match('/users/42'); // With cancellation const controller = new AbortController(); const state = await router.match('/dashboard', { signal: controller.signal }); ``` Resolve a full URL to a `RouteState` including data loader results, without modifying router state or history. Follows declarative redirects (up to five hops) and resolves lazy modules as a side effect. Returns `null` for unmatched URLs. When a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value. **Returns:** `Promise` --- #### `router.waitFor(name)` ```ts // Navigate and wait for data to settle await router.navigate({ name: 'userDetail', params: { id: '42' } }); const state = await router.waitFor('userDetail'); const user = state.matches.at(-1)?.data; // Useful in tests with memory history: const history = createMemoryHistory('/dashboard'); const router = createRouter({ history, routes }); const state = await router.waitFor('dashboard'); ``` Waits for the router to reach `status: 'idle'` with the named route active in the matched branch. Rejects immediately if `status === 'error'`. Resolves immediately if the router is already idle on the target route. Also rejects if `router.dispose()` is called while the promise is pending. > **Note:** `waitFor` skips intermediate `'streaming'` states — it only resolves once the status reaches `'idle'`. It does not resolve while the route is still streaming partial data. **Returns:** `Promise` --- #### `router.preload(name, params?, query?)` ```ts // Hover-prefetch without query anchor.addEventListener('mouseenter', () => { router.preload('userDetail', { id: '42' }); }); // Hover-prefetch with matching query to avoid a cache miss anchor.addEventListener('mouseenter', () => { router.preload('search', undefined, { q: 'hello' }); }); ``` Eagerly runs the data loaders for a named route without navigating. Useful for hover-prefetch. Concurrent calls for the same `name + params + query` combination are deduplicated. Results are consumed on the next navigation to the same route with the same cache key. Pass the same `query` you intend to navigate with to ensure the preloaded result hits the cache. Without `query`, the key is the bare path — a navigation with a query string will produce a cache miss and re-run the loader. In-flight preloads are aborted automatically via the router's disposal signal when `router.dispose()` is called. Calling `preload()` on an already-disposed router throws `WayfinderDisposedError` immediately, without running the data loader — consistent with `navigate()`, `subscribe()`, `beforeLeave()`, and `waitFor()`. **Returns:** `Promise` --- #### `router.beforeLeave(blocker, options?)` ```ts // Guard unsaved-changes forms const remove = router.beforeLeave(async (destination) => { if (!form.isDirty) return true; return confirm(`Leave without saving? (going to ${destination.pathname})`); }); // Remove the guard when the form unmounts remove(); ``` Register a global leave guard called before user-triggered navigation attempts. Return `true` to allow, `false` to cancel. Multiple guards can be registered; navigation is blocked if any guard returns `false`. Scope a guard to fire only when leaving specific routes using the `routes` option: ```ts router.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] }); ``` The guard fires when the router is leaving any route whose name appears in the `routes` array (any node in the active branch, not just the leaf). Declarative `redirect` routes bypass all leave guards. **Returns:** `() => void` ## `redirectTo(target, options?)` ```ts import { redirectTo } from '@vielzeug/wayfinder'; const requireAuth = redirectTo({ name: 'login' }, { replace: true }); ``` Creates middleware that navigates to `target` and short-circuits the middleware chain (does not call `next()`). Useful for auth guards and route aliases in middleware. For permanent declarative redirects (URL aliases), use the `redirect` field on the route definition instead. > **Note:** `redirectTo()` internally calls `ctx.navigate()`, which runs `beforeLeave` guards. If a guard blocks navigation, the redirect will not complete. Declarative `redirect` on a route definition bypasses guards entirely. **Returns:** `Middleware` --- ### State #### `router.getSnapshot()` Returns the current immutable route state snapshot. Use this to read state synchronously. Compatible with React's `useSyncExternalStore`: ```ts const state = useSyncExternalStore( (cb) => router.subscribe(cb), () => router.getSnapshot(), ); ``` ```ts const { location, matches, status, error } = router.getSnapshot(); location.pathname; location.query; // raw parsed query (QueryParams) — always string values location.hash; location.historyState; // value passed to navigate({ ... }, { state: ... }) // When status === 'error': console.error(error); ``` `error` is only set when `status === 'error'`. It holds the exact value thrown by the failing `data()` function. **Returns:** `RouteState` #### `router.subscribe(listener)` ```ts const unsubscribe = router.subscribe((state) => { const leaf = state.matches.at(-1); document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'App'; }); ``` Register a listener that is called after each subsequent state change. The listener is **not** called immediately — use `router.getSnapshot()` to read the current state synchronously. **Returns:** `() => void` ## Types ### `RouteContext` Context passed to middleware and data loader functions. ```ts type RouteContext = { readonly hash: string; /** State stored on the history entry that triggered this navigation. */ readonly historyState: unknown; locals: Record; readonly matches: RouteMatchBranch; readonly navigate: ( target: NamedNavigationTarget | RawNavigationTarget, options?: NavigateOptions, ) => Promise; readonly params: Params; readonly pathname: string; readonly query: ResolvedQueryParams; }; ``` Read route metadata from the leaf match: `ctx.matches.at(-1)?.meta`. `ctx.locals` is mutable and shared across the entire middleware chain for one navigation. Use it to pass values from middleware to data loaders. `ctx.query` is the coerced query (after `coerceSearch`). `router.getSnapshot().location.query` always contains raw string values from URL parsing. ### `DataFn` ```ts type DataFn = ( context: DataContext, ) => DataStream | MaybePromise; ``` Return an `AsyncGenerator` to stream partial results (see `DataStream`). ### `DataContext` ```ts type DataContext = RouteContext & { readonly signal: AbortSignal; }; ``` ### `DataStream` ```ts type DataStream = AsyncGenerator; ``` Return a `DataStream` from a `data()` function to stream partial results. Each `yield` updates `match.data` immediately with `match.status: 'streaming'`. The `return` value is the final settled data with `match.status: 'idle'`. ```ts data: async function* ({ signal }) { const items: Item[] = []; for await (const batch of streamBatches({ signal })) { items.push(...batch); yield items; // partial — status: 'streaming' } return items; // final — status: 'idle' }, ``` ### `Middleware` ```ts type Middleware = ( context: RouteContext, next: () => Promise, ) => void | Promise; ``` Middleware ordering is simple: global middleware first, then route middleware, then `data()`. ### `UntypedNamedNavigationTarget` ```ts type UntypedNamedNavigationTarget = { hash?: string; name: string; params?: RouteParams; query?: ResolvedQueryParams; }; ``` ### `NavigationTarget` ```ts type NavigationTarget = | { path: string; } | { hash?: string; name: string; params?: RouteParams; query?: ResolvedQueryParams; }; ``` ### `NavigateOptions` ```ts type NavigateOptions = { force?: boolean; replace?: boolean; state?: unknown; viewTransition?: boolean; }; ``` ### `RouteState` ```ts type RouteState = { /** The value thrown by a `data()` function. Only set when `status === 'error'`. */ readonly error?: unknown; readonly location: RouteLocation; readonly matches: readonly RouteMatch[]; readonly status: NavigationStatus; }; type RouteLocation = { readonly hash: string; /** State stored on the history entry that triggered this navigation. */ readonly historyState: unknown; readonly pathname: string; /** Raw parsed query params — always string values from URL parsing. * For coerced values (numbers, booleans), read `ctx.query` inside middleware or data loaders. */ readonly query: QueryParams; }; ``` ### `RouteMatch` ```ts type RouteMatch = { readonly component: unknown; readonly data: unknown; readonly meta: unknown; readonly name: string; readonly params: RouteParams; readonly pathname: string; /** Per-node loading status. Reflects individual loader state in nested layouts. */ readonly status: MatchStatus; }; ``` ### `RouteMatchBranch` ```ts type RouteMatchBranch = readonly RouteMatch[]; ``` ### `PathParams` ```ts type UserParams = PathParams; // => { readonly id: string } type FileParams = PathParams; // => { readonly rest: string } ``` ### `QueryParams` ```ts type QueryParams = Record; ``` Represents parsed URL query values before route-level coercion. ### `ResolvedQueryParams` ```ts type ResolvedQueryValue = string | number | boolean; type ResolvedQueryParams = Record; ``` Represents the query object after optional `coerceSearch` normalization. ### `NavigationStatus` ```ts type NavigationStatus = 'idle' | 'loading' | 'streaming' | 'error'; ``` Top-level status of the router. `'streaming'` means at least one active data loader is an async generator and has yielded at least one value but has not yet returned. ### `MatchStatus` ```ts type MatchStatus = NavigationStatus; ``` Per-node status on each `RouteMatch`. Alias of `NavigationStatus`; useful for nested layouts that want to show per-slot loading indicators. ### `RouteMiddleware` ```ts type RouteMiddleware = ( context: RouteContext, TRoutes>, next: () => Promise, ) => void | Promise; ``` Typed variant of `Middleware` scoped to a route path. Provides typed `ctx.params` matching the path pattern. ```ts const guard: RouteMiddleware = (ctx, next) => { console.log(ctx.params.id); // string return next(); }; ``` ### `CoerceSearchFn` ```ts type CoerceSearchFn = ( raw: QueryParams, ) => Q; ``` Function signature for both the per-route `coerceSearch` field and the global `RouterOptions.coerceSearch` option. Receives raw URL strings and returns typed values. Throwing inside the function falls back to the original raw query. ### `BeforeLeaveOptions` ```ts type BeforeLeaveOptions = { /** Route names that trigger this guard. Omit for a global guard. */ routes?: RouteName[]; }; ``` Passed as the second argument to `router.beforeLeave()`. When `routes` is provided, the guard only fires when the router leaves a route whose name is in the array. ### `BeforeLeaveBlocker` ```ts // Return true to allow navigation, false to cancel. type BeforeLeaveBlocker = (destination: NavigationDestination) => MaybePromise; ``` ### `NavigationDestination` ```ts type NavigationDestination = { readonly name?: string; // route name if navigating to a named route readonly params: RouteParams; readonly pathname: string; readonly query: QueryParams; }; ``` Passed to every `beforeLeave` blocker. Use `destination.pathname` and `destination.query` to make context-aware allow/block decisions. ### `IsActiveOptions` ```ts type IsActiveOptions = { /** Require an exact pathname match. Defaults to prefix matching. */ exact?: boolean; }; ``` ### `ScrollDecision` ```ts type ScrollPosition = { x: number; y: number }; type ScrollDecision = ScrollPosition | 'preserve' | 'top'; ``` ### `RouterErrorContext` ```ts type RouterErrorContext = | { routeName: string; source: 'data-loader' } // data() threw | { routeName: string; source: 'middleware' } // middleware threw | { source: 'coerce-search' | 'history-listener' | 'initial-navigation' | 'preload' }; type RouterErrorSource = RouterErrorContext['source']; ``` Passed to the `onError` callback in `createRouter` options. The `routeName` is present when the error originates from a named route's `data()` or `middleware`. ### `HistoryDriver` ```ts interface HistoryDriver { readonly location: { readonly hash: string; readonly pathname: string; readonly search: string; readonly state: unknown; }; /** Navigate one entry back in history, equivalent to the browser back button. */ back(): void; push(url: string, state?: unknown): void; replace(url: string, state?: unknown): void; /** * Subscribe to backwards/forwards navigation (popstate-equivalent). * `push()` and `replace()` are silent — they do not notify subscribers. * Only `back()` (and browser popstate events) trigger notifications. * Returns an unsubscribe function. */ onPopstate(listener: () => void): () => void; } ``` ### `RouteDefinition` ```ts type RouteDefinition = | ContentRouteDefinition // path + data/component/meta/middleware/coerceSearch/lazy/onError | RedirectRouteDefinition; // path + redirect ``` The union type for a single entry in the route table. Use this to type externally-defined route objects: ```ts import type { RouteDefinition } from '@vielzeug/wayfinder'; const userDetail: RouteDefinition = { path: '/users/:id', data: async ({ params }) => fetchUser(params.id), }; ``` ### `RouterOptions` The options object accepted by `createRouter()`. See the [`createRouter(options)`](#createrouter-options) options table above for the full field reference. ```ts import type { RouterOptions } from '@vielzeug/wayfinder'; const options: RouterOptions = { routes, base: '/app', }; ``` ### `Unsubscribe` ```ts type Unsubscribe = () => void; ``` ## Errors ### `WayfinderError` Base class for every error Wayfinder throws. Catch this to handle any router-originated error without enumerating subclasses. `WayfinderError.is(err)` is equivalent to `err instanceof WayfinderError`. ```ts import { WayfinderError } from '@vielzeug/wayfinder'; try { await router.navigate({ name: 'home' }); } catch (e) { if (WayfinderError.is(e)) { // any router-originated error — check e.name or `instanceof` a subclass for detail } } ``` ### `WayfinderDisposedError` Thrown when `navigate()`, `subscribe()`, `beforeLeave()`, `waitFor()`, or `preload()` is called after `dispose()`. Also used as the `AbortSignal.reason` on `disposalSignal`. ```ts import { WayfinderDisposedError } from '@vielzeug/wayfinder'; try { await router.navigate({ name: 'home' }); } catch (e) { if (e instanceof WayfinderDisposedError) { // router was disposed } } ``` ### `WayfinderRouteError` Thrown for malformed route definitions — at `createRouter()` time for config errors, or when a `url()`/`navigate()` call references an unknown route name or a missing path param. ### `WayfinderRedirectLoopError` Thrown when a chain of declarative `redirect`s (or a mix of declarative redirects and `ctx.navigate()` calls inside route middleware) exceeds 5 hops. ### `WayfinderApiError` Thrown on middleware misuse — currently only when a middleware function calls its `next()` more than once. ### Runtime error messages | Message | Class | When | | ----------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------- | | `Router is disposed` | `WayfinderDisposedError` | Calling a guarded method (see above) after `dispose()` | | `Unknown route name: X. Available routes: Y` | `WayfinderRouteError` | Navigating to, resolving, or building a URL for an unregistered route | | `Route "X" cannot define both index and path` | `WayfinderRouteError` | A route sets `index: true` and `path` at the same time | | `Route "X" must define path or set index: true` | `WayfinderRouteError` | A route defines neither `index: true` nor `path` | | `Duplicate route name: "X"` | `WayfinderRouteError` | Two routes resolve to the same compound name during `createRouter()` | | `Missing path param: X` | `WayfinderRouteError` | `url()`/`navigate()`/`preload()` omits a param the path pattern requires | | `Invalid param name ":X" in path "Y"` | `WayfinderRouteError` | A param name contains non-word characters (e.g., `:user-id`) | | `Wildcard "*" must be the final segment in path: X` | `WayfinderRouteError` | A `*` segment appears before the last segment | | `Wildcard param must be final segment in path: X` | `WayfinderRouteError` | A `:param*` greedy param appears before the last segment | | `Redirect loop detected` | `WayfinderRedirectLoopError` | A declarative `redirect` chain (or mixed redirect + `navigate()`) exceeds 5 hops | | `next() called multiple times` | `WayfinderApiError` | Middleware calls its `next()` callback more than once | ## Pattern Rules | Pattern | Example | Meaning | | ------------------------------ | ------------------- | ------------------------------------------- | | `/about` | `/about` | Exact static path | | `/users/:id` | `/users/42` | Single named param | | `/users/:userId/posts/:postId` | `/users/1/posts/2` | Multiple named params | | `/docs/*` | `/docs/guide/intro` | Wildcard suffix without a named capture | | `/files/:rest*` | `/files/a/b/c` | Wildcard suffix captured as one named param | | `*` | anything | Global catch-all | ## `debugRouter(options)` ```ts import { debugRouter } from '@vielzeug/wayfinder/devtools'; const router = debugRouter({ routes }); // [wayfinder:nav] idle / [home] ← logged when initial navigation settles // [wayfinder:nav] loading /dashboard // [wayfinder:nav] idle /dashboard [dashboard.index] ``` Wraps `createRouter()` and attaches a `subscribe` listener that logs every navigation state change to `console.debug`. Returns the same `Router` instance — all methods are identical to `createRouter()`. The first logged entry appears when the initial navigation completes (not synchronously at construction). Import from the dedicated sub-path so the `console.debug` reference is tree-shaken from production bundles when not imported. ### `DebugRouterOptions` Extends `RouterOptions` with one additional field: | Option | Type | Default | Description | | ------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------- | | `label` | `string` | `'nav'` | Label used in log prefixes. Produces `[wayfinder:]`. Useful when running multiple routers simultaneously. | ```ts // Multi-router setup — distinguish logs by label: const main = debugRouter({ routes, label: 'main' }); const modal = debugRouter({ routes: modalRoutes, label: 'modal' }); // [wayfinder:main] idle /dashboard // [wayfinder:modal] loading /confirm ``` | Log format | When | | ------------------------------------------------------- | -------------------------------------- | | `[wayfinder:nav] idle /path [routeName]` | Navigation settled | | `[wayfinder:nav] loading /path` | Data loaders in flight | | `[wayfinder:nav] streaming /path [routeName]` | Streaming loader emitting partial data | | `[wayfinder:nav] error /path [routeName] ` | Navigation error | ## Design Notes - Wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`. - Wayfinder names come from the route-table object keys. - `data()` is the terminal action. Its return value becomes `match.data`. There is no separate `handler` step. - For unmatched URLs, use the `notFound` router option rather than `path: '*'` in the route table. - Error handling is middleware that wraps `await next()`. The thrown error is also stored on `router.getSnapshot().error`. - Declarative `redirect` on a route definition is for permanent alias redirects. The `redirectTo()` middleware helper is for conditional guards. - `lazy` factories are called at most once per `RouteRecord`. The loaded `data`/`component`/`meta` are stored in the router's internal hydration cache. `handler` is not accepted in the lazy-resolved module. - `onError` in a route definition is a per-route data-loader boundary. If `onError` itself throws, the router falls through to `status: 'error'` as usual. ### Usage Guide Start with the [Overview](./index.md), then use this page for the day-to-day API. ## Basic Usage ```ts import { createRouter, redirectTo } from '@vielzeug/wayfinder'; const routes = { home: { path: '/', }, login: { path: '/login', }, dashboard: { path: '/dashboard', middleware: [requireAuth], children: { index: { index: true, }, settings: { path: 'settings', data: async () => fetchSettings(), }, }, }, userDetail: { path: '/users/:id', data: async ({ params }) => fetchUser(params.id), meta: { title: 'User' }, }, }; const router = createRouter({ base: '/app', middleware: [logger], notFound: { component: NotFoundPage, }, onError: (error, context) => reportError(error, context), routes, viewTransition: true, }); ``` `routes` is required. Wayfinder names come from the object keys, and object key order controls match precedence. ## Define Routes Each route can provide these fields: | Field | Purpose | | -------------- | --------------------------------------------------------------------------------------------------------------------------- | | `path` | Match pattern | | `children` | Nested child routes | | `index` | Default child route that inherits the parent path | | `component` | Optional view payload exposed on `match.component` | | `data` | Abortable route data function. Result available as `match.data`. Supports streaming via `AsyncGenerator`. | | `lazy` | Lazy-load the module. Called once; result fills `data`, `component`, and `meta`. | | `meta` | Static metadata exposed on `match.meta` | | `middleware` | Route-specific middleware | | `onError` | Per-route error boundary. Called when this route's `data()` throws; its return value becomes `match.data`. | | `redirect` | Declarative permanent redirect. Resolved before middleware runs. | | `coerceSearch` | Coerce raw URL search strings into typed values. Return value replaces `ctx.query`. Throw to leave the raw query unchanged. | Use wildcard routes for fallback behavior: ```ts const routes = { docs: { path: '/docs/*' }, }; ``` For a catch-all not-found page, use the `notFound` option in router options instead of a `path: '*'` route: ```ts const router = createRouter({ routes, notFound: { component: NotFoundPage, data: async ({ pathname }) => ({ requestedPath: pathname }), }, }); ``` Alternatively, `path: '*'` still works as a named route when you need to navigate to it explicitly. Nested routes compose naturally and create compound route names: ```ts const routes = { dashboard: { path: '/dashboard', children: { index: { index: true }, settings: { path: 'settings' }, }, }, }; await router.navigate({ name: 'dashboard.settings' }); ``` ## Route Context Middleware and data loaders receive a `RouteContext`: ```ts userDetail: { path: '/users/:id', middleware: [ (ctx, next) => { ctx.params.id; // typed to path params ctx.query.tab; // resolved query (after coerceSearch) ctx.pathname; ctx.hash; ctx.historyState; // value from navigate({ ... }, { state: ... }) ctx.locals; // mutable bag shared across the middleware chain ctx.navigate; // programmatic navigation return next(); }, ], data: async (ctx) => { ctx.signal; // AbortSignal — cancelled when navigation is superseded return fetchUser(ctx.params.id, { signal: ctx.signal }); }, } ``` `ctx.locals` is mutable and shared through the entire middleware chain for one navigation. Use it to pass values from middleware to data loaders. ## Middleware Middleware wraps the navigation using the familiar `async (ctx, next) => { ... }` shape. ```ts const requireAuth = redirectTo({ name: 'login' }, { replace: true }); const loadCurrentUser = async (ctx, next) => { ctx.locals.user = await fetchCurrentUser(); await next(); }; ``` Order is fixed and simple: ```text global middleware ↓ route middleware ↓ data() ``` ### Guards Use middleware for auth checks, redirects, analytics, and boundaries. ```ts const requireAuth = async (ctx, next) => { if (!session.currentUser) { await ctx.navigate({ name: 'login' }, { replace: true }); return; // do not call next() } ctx.locals.user = session.currentUser; await next(); }; ``` For unconditional redirects, use the `redirectTo()` helper: ```ts import { redirectTo } from '@vielzeug/wayfinder'; const requireAuth = redirectTo({ name: 'login' }, { replace: true }); ``` For permanent URL aliases, use the declarative `redirect` field instead of middleware: ```ts const routes = { profile: { path: '/profile', redirect: { name: 'userDetail' } }, userDetail: { path: '/users/:id' }, }; ``` > **Note:** `redirectTo()` calls `ctx.navigate()` internally, so `beforeLeave` guards will run and can block it. Declarative `redirect` on a route definition bypasses all leave guards. ### Leave Guards Register a global leave guard with `router.beforeLeave()`. Return `false` to cancel navigation. ```ts const removeGuard = router.beforeLeave(async (destination) => { if (!form.isDirty) return true; return confirm(`Discard changes? (navigating to ${destination.pathname})`); }); // Remove when no longer needed: removeGuard(); ``` Scope a guard to fire only when leaving specific routes: ```ts router.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] }); ``` Declarative `redirect` routes bypass all leave guards. ### Data Loading Use `data()` for route-local data acquisition. It receives the same route context plus an `AbortSignal`. ```ts const routes = { userDetail: { path: '/users/:id', data: async ({ params, signal }) => fetchUser(params.id, { signal }), }, }; ``` Access the result via the matched branch: ```ts router.subscribe((state) => { const user = state.matches.at(-1)?.data; renderUser(user); }); ``` #### Per-route Error Boundaries Use `onError` to handle data loader failures per-route. The returned value becomes `match.data`, allowing the route to render a degraded state: ```ts const routes = { userDetail: { path: '/users/:id', data: async ({ params, signal }) => fetchUser(params.id, { signal }), onError: (error) => ({ error, user: null }), }, }; ``` If `onError` itself throws, the router falls through to `status: 'error'` as usual. #### Streaming Data Loaders Return an `AsyncGenerator` from `data()` to stream partial results. Each `yield` updates `match.status` to `'streaming'` and `match.data` to the yielded value. The `return` value is the final settled data. ```ts const routes = { feed: { path: '/feed', data: async function* ({ signal }) { const items: FeedItem[] = []; for await (const batch of streamFeedBatches({ signal })) { items.push(...batch); yield items; // stream partial results } return items; // final settled value }, }, }; ``` During streaming, `state.status` is `'streaming'` and each `match.status` reflects the loading state of that individual branch node. ### Lazy Routes Defer loading a route module until first navigation. The factory is called at most once. ```ts const routes = { settings: { path: '/settings', lazy: () => import('./pages/Settings'), }, }; ``` The resolved object may contain `data`, `component`, and/or `meta`. Any present field overwrites the static definition. ### Search Param Validation Validate and coerce `ctx.query` per route. The function receives raw URL strings (`QueryParams`). Throw to leave the parsed query unchanged. ```ts const routes = { search: { path: '/search', coerceSearch: (raw) => ({ q: String(raw.q ?? ''), page: Math.max(1, Number(raw.page ?? 1)), }), data: async ({ query }) => searchPosts(query.q, query.page), }, }; ``` To apply the same coercion to every route, set `coerceSearch` on the router options instead. Per-route `coerceSearch` takes precedence over the global one. ```ts const router = createRouter({ coerceSearch: (raw) => ({ page: Number(raw.page ?? 1) }), routes, }); ``` ### Error Boundaries Wrap `await next()` in middleware for route-wide error handling. The thrown error is also stored on `router.getSnapshot().error`. ```ts const boundary = async (ctx, next) => { try { await next(); } catch (error) { reportRouteError(ctx.pathname, error); await ctx.navigate({ path: '/error' }, { replace: true }); } }; const router = createRouter({ middleware: [boundary], routes, }); // Check after navigation: const { status, error } = router.getSnapshot(); if (status === 'error') { console.error(error); } ``` ## Navigation ### Named Navigation ```ts await router.navigate({ name: 'userDetail', params: { id: '42' } }); await router.navigate({ name: 'userDetail', params: { id: '42' } }, { replace: true }); await router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' }); await router.navigate({ name: 'dashboard.settings' }); ``` ### Raw Path Targets ```ts await router.navigate({ path: '/marketing?utm_source=campaign' }); await router.navigate({ path: '/checkout#payment' }, { replace: true }); ``` Use these when a destination does not belong in the route table. The same `navigate()` method covers named routes and raw path targets. ### History State Attach arbitrary state to a history entry and read it back via `ctx.historyState` or `router.getSnapshot().location.historyState`. ```ts await router.navigate({ name: 'userDetail', params: { id: '42' } }, { state: { from: 'search' } }); // In data(): data: async (ctx) => { console.log(ctx.historyState); // { from: 'search' } return fetchUser(ctx.params.id); }, ``` ### Same-URL Deduplication ```ts await router.navigate({ name: 'dashboard' }); await router.navigate({ name: 'dashboard' }); // no-op await router.navigate({ name: 'dashboard' }, { force: true }); // re-runs ``` ### Prefetching Eagerly run data loaders without navigating — useful for hover-prefetch: ```ts // Preload a parameterised route anchor.addEventListener('mouseenter', () => { router.preload('userDetail', { id: '42' }); }); // Preload with a query string to avoid a cache miss on navigation searchInput.addEventListener('focus', () => { router.preload('search', undefined, { q: searchInput.value }); }); ``` Concurrent calls for the same `name + params + query` combination are deduplicated. Results are consumed on the next navigation to the same route with the same cache key. Pass the same `query` you intend to navigate with — without it, the preload key is the bare path and any navigation with a query string will re-run the loaders. In-flight preloads are aborted automatically when `router.dispose()` is called. ### Leave Guards Guard navigation until the user confirms — useful for unsaved-changes forms: ```ts const removeGuard = router.beforeLeave(async (destination) => { if (!form.isDirty) return true; return confirm('Discard changes?'); }); // Remove when the component unmounts: removeGuard(); ``` Scope a guard to a specific route so it only fires when leaving that route: ```ts router.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] }); ``` ## URLs and Active State ```ts router.url('userDetail', { id: '42' }); router.url('userDetail', { id: '42' }, { tab: 'profile' }); router.isActive('userDetail'); router.isActive('users'); router.isActive('users', { exact: true }); ``` `isActive(name)` is useful for parent navigation items. ## Resolve Without Navigating ```ts const branch = router.resolve('/app/dashboard/settings'); if (branch?.at(-1)?.name === 'dashboard.settings') { warmSettingsPanel(); } ``` `resolve()` strips the configured base automatically and returns the full matched branch (root to leaf). Data loaders are not executed. ## SSR Data Prefetch Use `router.match(url)` to resolve a full route state including data loader results without modifying router state or history. Ideal for server-side data prefetching. ```ts const state = await router.match('/users/42'); if (state) { const data = state.matches.at(-1)?.data; // serialize and send to the client } ``` Pass an `AbortSignal` via the options object to cancel in-flight loaders: ```ts const controller = new AbortController(); const state = await router.match('/users/42', { signal: controller.signal }); ``` `match()` follows declarative redirects (up to five hops) and resolves lazy modules as a side effect. ## State and Subscriptions ```ts router.subscribe((state) => { const leaf = state.matches.at(-1); document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'App'; }); ``` Use `router.getSnapshot()` to read the current state synchronously: ```ts const { location, matches, status, error } = router.getSnapshot(); location.pathname; location.query; // raw parsed query strings (QueryParams) location.hash; location.historyState; // state from the current history entry matches; // matched branch from root to leaf status; // 'idle' | 'loading' | 'streaming' | 'error' error; // only set when status === 'error' ``` Each match node also carries its own `status`: ```ts matches.at(-1)?.status; // 'idle' | 'loading' | 'streaming' | 'error' ``` This lets nested layouts show per-slot loading indicators without polling the top-level status. The state object is immutable. A successful navigation replaces it with a new snapshot. ### `waitFor(name)` Wait for the router to reach `status: 'idle'` with a specific route active. Useful in tests and lifecycle coordination: ```ts // Navigate and wait for data to settle await router.navigate({ name: 'userDetail', params: { id: '42' } }); const state = await router.waitFor('userDetail'); const user = state.matches.at(-1)?.data; ``` `waitFor` rejects immediately if the router is already in `status: 'error'`, and also rejects if `router.dispose()` is called while the promise is pending. Resolves immediately if the named route is already active and idle. ## Scroll Restoration Provide a `scroll` callback to control scroll position after each navigation: ```ts const router = createRouter({ routes, scroll: (to, from) => { // Return 'top' to scroll to top // Return { x, y } for a specific position // Return 'preserve' to do nothing return 'top'; }, }); ``` The callback receives the incoming state and the previous state, making it possible to implement saved-position restore: ```ts const scrollPositions = new Map(); router.subscribe((state) => { scrollPositions.set(state.location.pathname, { x: window.scrollX, y: window.scrollY }); }); const router = createRouter({ routes, scroll: (to, _from) => scrollPositions.get(to.location.pathname) ?? 'top', }); ``` ## Testing Use `createMemoryHistory` to test routers without a browser: ```ts import { createMemoryHistory, createRouter } from '@vielzeug/wayfinder'; const history = createMemoryHistory('/dashboard'); const router = createRouter({ history, routes }); // Use waitFor to avoid manual timing: const state = await router.waitFor('dashboard'); assert(state.location.pathname === '/dashboard'); router.dispose(); ``` ## Cleanup ```ts router.dispose(); ``` Remove listeners, clear subscribers, and prevent future router usage. ## Framework Integration Route exposes `getSnapshot()` and `subscribe()`, which map directly to each framework's external-store primitives. Create the router once at module scope and bind actions outside the component lifecycle so references stay stable. ```tsx [React] import { createRouter } from '@vielzeug/wayfinder'; import { useSyncExternalStore } from 'react'; const router = createRouter({ routes: { home: { component: HomePage, path: '/' }, settings: { component: SettingsPage, path: '/settings' }, }, notFound: { component: NotFoundPage }, }); // Stable references outside the hook — do not recreate on every render. const getSnapshot = () => router.getSnapshot(); const subscribe = (cb: () => void) => router.subscribe(cb); const navigate = router.navigate.bind(router); const url = router.url.bind(router); const isActive = router.isActive.bind(router); export function useRouter() { const state = useSyncExternalStore(subscribe, getSnapshot); return { isActive, navigate, state, url }; } // RouterView.tsx export function RouterView() { const { state } = useRouter(); const Component = state.matches.at(-1)?.component as React.ComponentType | undefined; return Component ? : null; } ``` ```ts [Vue 3] import { createRouter } from '@vielzeug/wayfinder'; import { readonly, shallowRef } from 'vue'; const router = createRouter({ routes: { home: { component: HomePage, path: '/' }, settings: { component: SettingsPage, path: '/settings' }, }, notFound: { component: NotFoundPage }, }); // shallowRef — no need to deep-track immutable route state. const state = shallowRef(router.getSnapshot()); router.subscribe((next) => { state.value = next; }); export function useRouter() { return { isActive: router.isActive.bind(router), navigate: router.navigate.bind(router), state: readonly(state), url: router.url.bind(router), }; } ``` ```svelte [Svelte] import { createRouter } from '@vielzeug/wayfinder'; import { readable } from 'svelte/store'; const router = createRouter({ routes: { home: { component: HomePage, path: '/' }, settings: { component: SettingsPage, path: '/settings' }, }, notFound: { component: NotFoundPage }, }); // readable injects the initial value; subscribe() drives updates. export const routerState = readable(router.getSnapshot(), (set) => router.subscribe(set)); export const navigate = router.navigate.bind(router); export const url = router.url.bind(router); export const isActive = router.isActive.bind(router); ``` For full RouterView and RouterLink patterns, see [React Integration](./examples/react-integration.md), [Vue Integration](./examples/vue-integration.md), and [Svelte Integration](./examples/svelte-integration.md). ## Debug Mode Import `debugRouter` from the dedicated sub-path to create a router with navigation logging pre-enabled. The sub-path is tree-shaken from production bundles when not imported. ```ts import { debugRouter } from '@vielzeug/wayfinder/devtools'; const router = debugRouter({ routes: { home: { path: '/' }, dashboard: { path: '/dashboard', data: () => fetchDashboard() }, }, }); // Logged once the initial navigation completes: // [wayfinder:nav] idle / [home] // On navigate({ name: 'dashboard' }): // [wayfinder:nav] loading /dashboard // [wayfinder:nav] idle /dashboard [dashboard] ``` The router returned is identical to `createRouter()` — all methods (`navigate`, `subscribe`, `waitFor`, etc.) work the same way. Errors are logged with the error object appended: ```ts // [wayfinder:nav] error /dashboard [dashboard] Error: fetch failed ``` Use the `label` option when running multiple routers to distinguish their log output: ```ts const main = debugRouter({ routes, label: 'main' }); const modal = debugRouter({ routes: modalRoutes, label: 'modal' }); // [wayfinder:main] loading /products // [wayfinder:modal] loading /confirm ``` Debug logging has no effect on behavior and should not be enabled in production. If a route's data loader throws and no `onError` callback is set on the router, the error is surfaced via `console.error` in development and silenced in production (`__WAYFINDER_PROD__` set). Always provide an `onError` callback in production to handle errors explicitly. ## Working with Other Vielzeug Libraries ### With Ward Use Ward inside Wayfinder middleware to guard protected routes. ```ts import { createRouter } from '@vielzeug/wayfinder'; import { createWard } from '@vielzeug/ward'; type User = { id: string; roles: string[] }; const ward = createWard([{ role: 'admin', resource: 'settings', action: 'view', effect: 'allow' }]); const router = createRouter({ middleware: [ (ctx, next) => { const user: User = getSessionUser(); if (!ward.can(user, 'settings', 'view')) return ctx.navigate({ path: '/login' }, { replace: true }); return next(); }, ], routes: { settings: { path: '/settings' }, }, }); ``` ### With Ripple Sync router state to a Ripple signal for reactive UI. ```ts import { createRouter } from '@vielzeug/wayfinder'; import { signal } from '@vielzeug/ripple'; const router = createRouter({ /* ... */ }); const currentRoute = signal(router.getSnapshot().matches.at(-1)?.name ?? ''); router.subscribe((state) => { currentRoute.value = state.matches.at(-1)?.name ?? ''; }); ``` ## Best Practices - Define the route table once at app startup and import it where needed. - Prefer named navigation (`router.navigate({ name: 'settings' })`) over raw paths. - Put auth and permission checks in middleware, not in data loaders. - Use `data()` loaders for route data and honor the provided `AbortSignal`. - Use `onError` on a route for degraded-state rendering rather than a full redirect to an error page. - Use `notFound` in router options for the not-found page rather than `path: '*'` in the route table. - Call `router.dispose()` when tearing down apps/tests to release listeners. - Use `createMemoryHistory()` for tests and non-browser runtimes; avoid touching `window.history` directly. - Use `router.preload()` on hover for routes likely to be visited next. ### Examples ## Examples - [Route Table Basics](./examples/route-table-basics.md) - [Not Found and Error Boundary](./examples/not-found-and-error-boundary.md) - [Auth and Guards](./examples/auth-and-guards.md) - [Page Titles from Meta](./examples/page-titles-from-meta.md) - [Same-URL Deduplication](./examples/same-url-deduplication.md) - [Base Path Deployment](./examples/base-path-deployment.md) - [Raw Path Targets](./examples/raw-path-targets.md) - [View Transitions](./examples/view-transitions.md) - [React Integration](./examples/react-integration.md) - [Vue Integration](./examples/vue-integration.md) - [Svelte Integration](./examples/svelte-integration.md) ### REPL Examples - Basic Routing — Route State and Navigation (id: `basic-routing`) - Debug Router — Navigation Logging (id: `debug-router`) - Guards and Redirects — Auth Flows (id: `middleware-auth`) - Middleware Chain — Execution Flow (id: `middleware-chain`) - Named Routes — Type-Safe Navigation (id: `named-routes`) - Nested Routes — Children and Index Routes (id: `nested-routes`) - Preload, waitFor, and Dispose (id: `preload-and-dispose`) - Query Parameters — Coercion and URL State (id: `query-params`) - Route Context — Full Context Access (id: `route-context`) - URL Building — Resolve and Active State (id: `url-building`)