# Vielzeug: Full Documentation > Complete documentation for 41 packages. Version: 26.10.0 --- ## @vielzeug/arsenal **Category:** utilities ### Overview ## Why Arsenal? Arsenal provides typed, zero-dependency utilities that reduce repeated application code: collection transforms, numeric operations, retry and cancellation, caching, safe-path traversal, and deterministic serialization. The root stays curated while category entry points expose the broader toolkit. Exact platform aliases remain excluded. ```ts // Before const users = JSON.parse(raw).filter((user) => user.name.includes(query)); // After import { fuzzyFilter } from '@vielzeug/arsenal/array'; import { tryParseJson } from '@vielzeug/arsenal/object'; const parsed = tryParseJson(raw); const users = parsed.ok ? fuzzyFilter(parsed.value as User[], query, { select: (user) => user.name }) : []; ``` | Feature | Arsenal | lodash-es | Remeda | | --- | --- | --- | --- | | Bundle size | | ~72 kB | ~18 kB | | Typed root utilities | | Partial | | | Category entry points | | Partial | Partial | | Async task pool and cache | | | | | Zero dependencies | | | | **Use Arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior. **Consider narrower alternatives when** you need only platform APIs or a small functional subset. ## Installation ```sh [pnpm] pnpm add @vielzeug/arsenal ``` ```sh [npm] npm install @vielzeug/arsenal ``` ```sh [yarn] yarn add @vielzeug/arsenal ``` ## Quick Start ```ts import { groupBy, retry } from '@vielzeug/arsenal'; import { cache } from '@vielzeug/arsenal/cache'; import { taskPool } from '@vielzeug/arsenal/async'; const byRole = groupBy([{ role: 'admin' }, { role: 'user' }], (user) => user.role); const pool = taskPool({ concurrency: 2 }); const health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json()))); const responses = cache({ ttlMs: 60_000 }); const profile = await responses.getOrLoad('/profile', () => fetch('/profile').then((response) => response.json())); pool.dispose(); console.log(byRole, health, profile); ``` ## Features - **`retry`**: retry async work with cancellation support from package root - **`taskPool`**: bounded, disposable concurrent work from `/async` - **`cache`**: identity-keyed TTL cache with async load deduplication from `/cache` - **`fuzzyFilter`**: explicit-field fuzzy filtering from `/array` - **`tryParseJson`**: preserve JSON syntax failures from `/object` - **`allocate`**: lossless proportional distribution from `/math` - **`chunk`, `zip`, `compact`**: typed collection transforms from `/array` - **`range`, `average`, `median`**: common numeric operations from `/math` - **`assert`, `tap`**: typed control-flow helpers from `/function` - **`isEqual`**: structural equality from package root - **`hash`**: deterministic serialization for stable cache keys from `/object` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Spell](/spell/): validate `unknown` JSON data after `tryParseJson`. - [Vault](/vault/): persistent storage; Arsenal cache is in-memory only. - [Tempo](/tempo/): date/time utilities kept outside Arsenal. - [Coins](/coins/): money formatting and currency conversion. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export | | `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` | | `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` | | `assert` | Assert and narrow a condition | Sync | Available from `/function` | | `retry` | Retry async work | Async | Rethrows final error | | `taskPool` | Bound concurrent tasks | Async | Available from `/async` | | `cache` | In-memory identity-keyed cache | Async | Available from `/cache` | | `fuzzyFilter` | Filter string or selected object fields | Sync | Object collections require `select` | | `fuzzyScore` | Rank string or selected object fields | Sync | Object collections require `select` | | `tryParseJson` | Preserve JSON syntax result | Sync | Returns `unknown` on success | | `getPath` | Optional object lookup (safe-path guarded) | Sync | Available from `/object` | | `allocate` | Lossless proportional distribution | Sync | Available from `/math` | | `isEqual` | Structural equality | Sync | Root export | | `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/arsenal` | Curated common utilities | | `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search | | `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing | | `@vielzeug/arsenal/cache` | In-memory cache and memoization | | `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown | | `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards | | `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation | | `@vielzeug/arsenal/object` | Paths, transforms, hash, JSON parse result | | `@vielzeug/arsenal/random` | Cryptographic-entropy selection and shuffle | | `@vielzeug/arsenal/string` | Unicode-aware case transforms and similarity | ## Array ### Typed transforms ```ts chunk(input: readonly T[], size?: number): T[][] compact(array: readonly T[]): Array> first(array: readonly T[], fallback?: T): T | undefined last(array: readonly T[], fallback?: T): T | undefined replace(array: readonly T[], predicate: (value: T) => boolean, value: T): T[] rotate(array: readonly T[], positions: number, options?: { wrap?: boolean }): T[] zip(...arrays): Array unzip(rows): tupleOfArrays ``` `take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity. ### fuzzyFilter / fuzzyScore ```ts fuzzyFilter(strings: readonly string[], query: string, options?: FuzzyOptions): string[] fuzzyFilter(items: readonly T[], query: string, options: FuzzySelection): T[] fuzzyScore(strings: readonly string[], query: string, options?: FuzzyOptions): ScoredResult[] fuzzyScore(items: readonly T[], query: string, options: FuzzySelection): ScoredResult[] ``` `fuzzyFilter` preserves input order. `fuzzyScore` orders results by descending score. ```ts import { fuzzyFilter } from '@vielzeug/arsenal/array'; const users = [{ email: 'alice@example.com', name: 'Alice' }]; const matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] }); ``` --- ## Async ### taskPool ```ts interface TaskPool { run(task: (signal: AbortSignal) => Promise): Promise; idle(): Promise; dispose(reason?: unknown): void; readonly active: number; readonly pending: number; readonly disposed: boolean; readonly disposalSignal: AbortSignal; } taskPool(options?: { concurrency?: number }): TaskPool ``` `dispose()` aborts running cooperative tasks and rejects pending tasks. ```ts import { taskPool } from '@vielzeug/arsenal/async'; const pool = taskPool({ concurrency: 2 }); const user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json())); pool.dispose(); ``` --- ## Cache ### cache ```ts interface Cache { entries(): ReadonlyArray; get(key: K): T | undefined; set(key: K, value: T, options?: { ttlMs?: number }): void; getOrLoad(key: K, load: () => Promise): Promise; delete(key: K): boolean; clear(): void; readonly size: number; } cache(options?: CacheOptions): Cache ``` Keys use native `Map` identity. Expiry is lazy, evaluated by `get`, `getOrLoad`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion-ordered snapshot without exposing internal storage. The `size` getter does not evict expired entries. ```ts import { cache } from '@vielzeug/arsenal/cache'; const profiles = cache({ ttlMs: 60_000 }); const profile = await profiles.getOrLoad('me', loadProfile); ``` --- ## Encoding URL-safe base64 (`base64url`) codecs without `btoa`/`Buffer`, so the same code runs in browsers, workers, and Node. ```ts import { base64UrlToBytes, base64UrlToText, bytesToBase64Url, textToBase64Url, utf8Bytes } from '@vielzeug/arsenal'; textToBase64Url('{"build":"hero"}'); // never contains '+', '/', or '=' base64UrlToText(textToBase64Url('League of Legends')); // round-trips any UTF-8 text bytesToBase64Url(Uint8Array.from([0, 1, 250])); // byte-level codec for binary payloads base64UrlToBytes(code); // throws on characters outside the alphabet utf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string ``` ## Object ### tryParseJson ```ts type JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false }; tryParseJson(text: string): JsonParseResult ``` Use a schema validator after success to refine `unknown` data. ```ts import { tryParseJson } from '@vielzeug/arsenal/object'; const result = tryParseJson(raw); if (!result.ok) throw result.error; ``` ### getPath ```ts getPath, P extends string>(item: T, path: P): PathValue | undefined getPathOr, P extends string, F>(item: T, path: P, fallback: F): PathValue | F requirePath, P extends string>(item: T, path: P): Exclude, undefined> ``` ### hash ```ts hash(value: unknown, options?: HashOptions): string ``` Produces a deterministic, order-independent serialization. Handles `Date`, `RegExp`, `Set`, `Map`, and `bigint`. Circular references produce a sentinel. Useful for stable cache keys. --- ## Math ### Numeric toolkit ```ts range(stop: number): number[] range(start: number, stop: number, step?: number): number[] clamp(value: number, min?: number, max?: number): number sum(values: readonly T[], select?: (value: T) => number): number average(values: readonly T[], select?: (value: T) => number): number | undefined median(values: readonly T[], select?: (value: T) => number): number | undefined variance(values: readonly T[], select?: (value: T) => number): number standardDeviation(values: readonly T[], select?: (value: T) => number): number ``` `sum` and `average` reject non-finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standardDeviation` compute population statistics. ### allocate ```ts allocate(amount: number, ratios: number[] | number): number[] allocate(amount: bigint, ratios: number[] | number): bigint[] ``` Distributes an amount proportionally across ratios. The indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations. ## Types ```ts type FuzzyOptions = { normalize?: boolean; threshold?: number; }; type FuzzySelection = FuzzyOptions & { select: (item: T) => string | readonly string[]; }; type ScoredResult = { item: T; score: number }; type CacheOptions = { capacity?: number; now?: () => number; ttlMs?: number; }; ``` ## Errors - `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count. - `TypeError`: invalid value types, unsupported comparison, or required path missing. - `ArsenalSerializationError`: memo or hash cannot serialize supplied input. ### Usage Guide ## Basic Usage Start at package root for common utilities. Move to a category entry point for the broader typed toolkit. Arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge-case consistency; exact platform aliases stay out. ```ts import { groupBy, retry } from '@vielzeug/arsenal'; const users = [ { id: 'a1', role: 'admin' }, { id: 'u1', role: 'user' }, { id: 'u2', role: 'user' }, ]; const byRole = groupBy(users, (user) => user.role); const health = await retry(() => fetch('/health').then((response) => response.json())); console.log(byRole, health); ``` Use category imports for APIs absent from root: ```ts import { fuzzyFilter } from '@vielzeug/arsenal/array'; import { taskPool } from '@vielzeug/arsenal/async'; import { cache } from '@vielzeug/arsenal/cache'; import { tryParseJson } from '@vielzeug/arsenal/object'; ``` ## Transform Collections Use `/array` for transforms that preserve input immutability. `filterMap` combines mapping and omission; `indexBy` and `groupBy` build lookup structures without mutation. ```ts import { filterMap, indexBy, sort } from '@vielzeug/arsenal/array'; const products = [ { id: 'p1', price: 20, published: true }, { id: 'p2', price: 10, published: false }, { id: 'p3', price: 15, published: true }, ]; const publishedLabels = filterMap(products, (product) => product.published ? `${product.id}: ${product.price}` : undefined, ); const byId = indexBy(products, (product) => product.id); const byPrice = sort(products, (product) => product.price); console.log(publishedLabels, byId, byPrice); ``` ## Search Explicit Fields Search string arrays directly. Object collections require `select`, so callers define exactly what can match. ```ts import { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array'; const users = [ { email: 'alice@example.com', name: 'Alice' }, { email: 'bob@example.com', name: 'Bob' }, ]; const matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] }); const ranked = fuzzyScore(users, 'ali', { select: (user) => user.name }); ``` ## Work with Object Data Use `/object` for paths, key selection, stable cache keys, and object transforms. ```ts import { getPathOr, hash, omit, pick } from '@vielzeug/arsenal/object'; const config = { api: { host: 'localhost', port: 3000 }, debug: true }; const port = getPathOr(config, 'api.port', 8080); const publicConfig = pick(config, ['api']); const productionConfig = omit(config, ['debug']); const key = hash({ port, productionConfig }); console.log(publicConfig, key); ``` ## Parse and Validate JSON `tryParseJson` distinguishes syntax failure from schema failure. Treat successful values as `unknown`, then validate with Spell or application code. ```ts import { tryParseJson } from '@vielzeug/arsenal/object'; import { s } from '@vielzeug/spell'; const User = s.object({ id: s.string(), name: s.string() }); const parsed = tryParseJson(raw); if (!parsed.ok) throw parsed.error; const user = User.parse(parsed.value); ``` ## Bound Concurrent Work Use `parallel` for one finite collection. Use `taskPool` when tasks arrive over time or need disposal. ```ts import { parallel, taskPool } from '@vielzeug/arsenal/async'; const metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 }); const pool = taskPool({ concurrency: 2 }); const profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json())); await pool.idle(); pool.dispose(); console.log(metadata, profile); ``` ## Cache Loaded Values Use `cache` for process-local values. Keys retain native `Map` identity. `getOrLoad` deduplicates concurrent loads for one key. ```ts import { cache } from '@vielzeug/arsenal/cache'; type Profile = { id: string; name: string }; const profiles = cache({ capacity: 100, ttlMs: 60_000 }); const profile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json())); console.log(profiles.entries()); profiles.delete('me'); const freshProfile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json())); console.log(profile, freshProfile); ``` ## Test Deterministic Randomness Random helpers use cryptographic entropy by default. Pass `RandomSource` in tests when output must be deterministic. ```ts import { random, randomFloat, type RandomSource } from '@vielzeug/arsenal/random'; const source: RandomSource = { next: () => 0.5 }; randomFloat(source); // 0.5 random(1, 4, source); // 3 ``` ## Encode Text for URLs and QR Codes Share codes ride in URLs and QR payloads, where `+`, `/`, and padding `=` break consumers. The base64url codecs are environment-independent: no `btoa`, no `Buffer`, so the same encode/decode pair works in the browser, in workers, and in Node. ```ts import { base64UrlToText, textToBase64Url } from '@vielzeug/arsenal'; const code = textToBase64Url(JSON.stringify({ seat: 4, version: 2 })); // eyJzZWF0Ijo0LCJ2ZXJzaW9uIjoyfQ: safe in a query string or QR const payload = JSON.parse(base64UrlToText(code)); ``` Decoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage. ## Working with Other Vielzeug Libraries Use Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart. ```ts import { tryParseJson } from '@vielzeug/arsenal/object'; import { s } from '@vielzeug/spell'; const Settings = s.object({ theme: s.string() }); const parsed = tryParseJson(rawSettings); const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' }; ``` ## Best Practices - Import common utilities from package root. - Import the broader toolkit from category entry points. - Prefer Arsenal when a helper improves typing, intent, immutable updates, or edge-case behavior. - Use the platform directly for exact aliases such as `crypto.randomUUID()` and `Array.prototype.flat()`. - Pass `select` for every fuzzy search over objects. - Validate parsed JSON before using it as application data. - Use `parallel` for finite batches and `taskPool` for ongoing work. - Dispose task pools when their owner ends. - Use `cache` only for in-memory data. - Inject `RandomSource` in deterministic tests. ### 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`, `tryParseJson`, `hash`, `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`) - fuzzyFilter - Explicit searchable fields (id: `array-search`) - fuzzyFilter - Unicode normalization (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`) - taskPool - Parallel execution with concurrency limit (id: `async-pool`) - taskPool - Bounded concurrent work (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`) - cache - Async load deduplication (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 (id: `object-getPath`) - hash - Deterministic cache key from any value (id: `object-hash`) - deepMerge - Merge objects (id: `object-merge`) - tryParseJson - Preserve JSON syntax errors (id: `object-parseJSON`) - prune - Remove empty values (id: `object-prune`) - cache - Identity keys, TTL, and load deduplication (id: `object-stash`) - camelCase - Convert to camelCase (id: `string-camelcase`) - Guards, equality, and predicate combinators (id: `typed-is`) --- ## @vielzeug/assay **Category:** testing ### Overview ## Why Assay? Assay provides focused DOM test primitives that work with vanilla elements, custom elements, and framework-rendered output. It scopes queries, dispatches exact browser event classes, and waits on explicit conditions without imposing a renderer or browser automation stack. ```ts // Before button.dispatchEvent(new MouseEvent('click', { bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 100)); // After fireClick(view.get('button.submit')); await waitUntil(() => view.queryByText('Saved') !== null); ``` | Feature | Assay | Testing Library DOM | Browser automation | | ------------------- | -------------------------------------------- | ---------------------------------------- | ---------------------------------------- | | Bundle size | | Larger query layer | Browser runtime required | | Zero dependencies | | | | | Scoped DOM queries | `within()` and shadow helpers | Renderer-oriented queries | Manual selectors | | Deterministic waits | `waitUntil()` and `waitForEvent()` | Framework-dependent | Full browser timing | **Use Assay when** a DOM unit test needs readable queries, dispatched events, or a bounded async wait without adopting a rendering framework. **Consider browser integration tests when** correctness depends on browser default actions, focus behavior, pointer capture, layout, or accessibility-tree behavior. ## Installation ```sh [pnpm] pnpm add -D @vielzeug/assay ``` ```sh [npm] npm install -D @vielzeug/assay ``` ```sh [yarn] yarn add -D @vielzeug/assay ``` ## Quick Start Scope a test fixture, dispatch an event, and wait for resulting DOM state. ```ts import { fireClick, waitUntil, within } from '@vielzeug/assay'; const panel = document.createElement('section'); panel.innerHTML = 'Save'; panel.querySelector('button')!.addEventListener('click', () => { panel.querySelector('output')!.textContent = 'Saved'; }); const view = within(panel); fireClick(view.get('button')); await waitUntil(() => view.queryByText('Saved') !== null); ``` ## Features - `within(root)` scopes nullable and required DOM queries. - `queryInShadow`, `queryPart`, and `getSlotted` cross custom-element boundaries explicitly. - `fireClick`, `fireInput`, `fireKeyDown`, and peers dispatch exact synchronous events. - `waitUntil`, `eventually`, and `waitForEvent` provide bounded, abortable async waiting. - `delay` models explicit cancellable timer scheduling. - `AssayError`, `AssayQueryError`, and `AssayTimeoutError` provide typed failures. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ore](/ore/): component authoring and test fixtures that pair with Assay DOM helpers. - [Refine](/refine/): accessible components with component-specific test assertions. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------------------------------- | ------------------------------------------- | -------------- | ------------------------------------------------ | | `within` | Creates scoped query API | Sync | Required `get*` methods throw `AssayQueryError` | | `queryInShadow` / `queryPart` / `getSlotted` | Crosses custom-element boundaries | Sync | Open shadow roots are required | | `fire*` / `dispatch` | Dispatches platform event instances | Sync | Does not reproduce browser default behavior | | `waitUntil` / `eventually` / `waitForEvent` | Waits for conditions, assertions, or events | Async | Use a signal or timeout for bounded waits | | `delay` | Schedules a cancellable timer | Async | Pass a signal to cancel a pending wait | ## Package Entry Point | Import | Purpose | | ----------------- | ---------------------------------------------------- | | `@vielzeug/assay` | DOM queries, events, wait helpers, errors, and types | ## Queries ### `within(root)` Creates a `QueryScope` for an `Element`, `ShadowRoot`, `Document`, or `DocumentFragment`. | Method | Returns | Use | | --------------------------------- | ----------------- | -------------------------------------------- | | `get(selector)` | `Element` | Required CSS match; throws `AssayQueryError` | | `query(selector)` | `Element \| null` | Optional CSS match | | `queryAll(selector)` | `Element[]` | All CSS matches | | `getByText(text, selector?)` | `Element` | Required exact trimmed-text match | | `queryByText(text, selector?)` | `Element \| null` | Optional exact trimmed-text match | | `queryAllByText(text, selector?)` | `Element[]` | All exact trimmed-text matches | | `getByTestId(id)` | `Element` | Required `data-testid` match | | `queryByTestId(id)` | `Element \| null` | Optional `data-testid` match | | `queryAllByTestId(id)` | `Element[]` | All `data-testid` matches | Text selectors default to `'*'`. Required-query failures include the lookup and a bounded view of the scoped DOM. ### Shadow and slot helpers | Function | Returns | Description | | ---------------------------------- | ----------------- | ---------------------------------------------------- | | `queryInShadow(host, selector)` | `Element \| null` | First match in an open shadow root | | `queryAllInShadow(host, selector)` | `Element[]` | All matches in an open shadow root | | `queryPart(host, part)` | `Element \| null` | First shadow element whose `part` token matches | | `getSlotted(host, slotName?)` | `Element[]` | Direct light-DOM children in a named or default slot | These helpers return `null` or `[]` when there is no shadow root. Dynamic test IDs, parts, and slot names are matched as attribute values rather than interpolated into CSS selectors. ## Event dispatch All event helpers synchronously return `dispatchEvent()`'s boolean result. ```ts import { dispatch, fireBlur, fireChange, fireClick, fireCustom, fireFocus, fireInput, fireKeyDown, fireKeyUp, fireSubmit, } from '@vielzeug/assay'; fireClick(button, { clientX: 20 }); fireInput(input); fireKeyDown(input, { key: 'Enter' }); fireCustom(element, 'item-added', { detail: { id: '42' } }); dispatch(element, new Event('ready')); ``` | Function | Event class | Defaults | | --------------------------- | --------------- | ------------------------------------------------------ | | `fireBlur` / `fireFocus` | `FocusEvent` | Platform defaults (`bubbles: false`) | | `fireChange` | `Event` | `bubbles: true` | | `fireInput` | `InputEvent` | `bubbles: true` | | `fireClick` | `MouseEvent` | `bubbles: true`, `cancelable: true` | | `fireKeyDown` / `fireKeyUp` | `KeyboardEvent` | `bubbles: true`, `cancelable: true` | | `fireSubmit` | `SubmitEvent` | `bubbles: true`, `cancelable: true` | | `fireCustom` | `CustomEvent` | `bubbles: true`, `cancelable: true`, `composed: false` | `fireCustom(target, type, init?)` dispatches a `CustomEvent` with the given type. Assay intentionally does not provide browser-default or fallback pointer/touch simulation. ## Async waiting ```ts await waitUntil(() => ready, { interval: 20, signal, timeout: 1000 }); await eventually(() => expect(spy).toHaveBeenCalled(), { signal, timeout: 1000 }); await waitForEvent(target, 'ready', { signal, timeout: 1000 }); await delay(100, { signal }); ``` | Function | Success condition | Options | | -------------------------------------- | ------------------------ | ------------------------------------------ | | `waitUntil(predicate, options?)` | Predicate returns `true` | `timeout`, `interval`, `signal` | | `eventually(assertion, options?)` | Assertion stops throwing | `timeout`, `interval`, `signal`, `message` | | `waitForEvent(target, type, options?)` | Target emits `type` | `timeout`, `signal` | | `delay(ms?, options?)` | Timer elapses | `signal` | `waitUntil()`, `eventually()`, and `waitForEvent()` reject with `AssayTimeoutError` when their shared deadline expires. `waitUntil()` and `eventually()` remain bounded while an asynchronous callback is pending. Abort rejects with the signal reason and removes Assay-owned timers and listeners. Callback work itself must cooperate with application cancellation. `timeout` and delay milliseconds must be finite, non-negative, and no greater than 2,147,483,647 ms; `interval` must be finite, positive, and within the same timer limit. Invalid durations throw `RangeError`. `eventually()` also accepts `message` for diagnostic timeout context. ## Types ```ts export interface QueryScope { get(selector: string): E; getByTestId(testId: string): E; getByText(text: string, selector?: string): E; query(selector: string): E | null; queryAll(selector: string): E[]; queryAllByTestId(testId: string): E[]; queryAllByText(text: string, selector?: string): E[]; queryByTestId(testId: string): E | null; queryByText(text: string, selector?: string): E | null; } ``` Scoped query helpers returned by `within(root)`. ```ts export interface WaitOptions { /** Polling interval in ms (default: 50). */ interval?: number; /** Cancel the pending wait. */ signal?: AbortSignal; /** Maximum wait time in ms (default: 1000). */ timeout?: number; } export interface EventuallyOptions extends WaitOptions { /** Context included in the timeout error. */ message?: string; } export interface DelayOptions { /** Cancel the pending delay. */ signal?: AbortSignal; } ``` `WaitOptions` configures `waitUntil()`. `EventuallyOptions` configures `eventually()`. `DelayOptions` configures `delay()`. ## Errors | Error | Meaning | | ------------------- | -------------------------------------- | | `AssayError` | Base class for Assay-originated errors | | `AssayQueryError` | A required `get*` query had no match | | `AssayTimeoutError` | A wait operation reached its timeout | Use `instanceof AssayError` to narrow any value to the Assay error hierarchy. ### Usage Guide ## Basic Usage `within(root)` is Assay's query API. It accepts an element, document fragment, or shadow root and keeps every lookup in that scope. Use `get*` when a match is required and nullable `query*` methods when absence is part of the assertion. ```ts import { within } from '@vielzeug/assay'; const view = within(panel.shadowRoot!); const save = view.get('button.save'); const status = view.queryByText('Saved'); expect(view.query('.error')).toBeNull(); expect(view.getByTestId('summary').textContent).toContain('Complete'); ``` `get()`, `getByText()`, and `getByTestId()` throw `AssayQueryError` with the lookup and a bounded rendering of the scoped DOM. This keeps a failed required lookup diagnosable without non-null assertions. Use the cross-boundary helpers for custom elements: ```ts import { getSlotted, queryAllInShadow, queryPart } from '@vielzeug/assay'; const trigger = queryPart(menu, 'trigger'); const options = queryAllInShadow(menu, '[role="option"]'); const footerActions = getSlotted(dialog, 'footer'); ``` ## Exact event dispatch Assay dispatches the platform event class named by each helper. It does not model browser activation, focus management, or form defaults. `fireFocus()` and `fireBlur()` use their non-bubbling platform defaults; use `focusin`/`focusout` events when testing delegated focus listeners. Use `element.click()` when native activation is the behavior under test; use Assay when testing an event listener or a controlled state transition. ```ts import { fireClick, fireCustom, fireInput, fireKeyDown } from '@vielzeug/assay'; input.value = 'Ada'; fireInput(input); fireKeyDown(input, { key: 'Enter' }); fireClick(saveButton); fireCustom(panel, 'value-change', { detail: { value: 42 } }); ``` Every helper returns `dispatchEvent()`'s boolean result. `dispatch(target, event)` is available when an existing event instance is the clearest expression of the test. ## Waiting Choose the waiting primitive by the test's assertion shape: ```ts import { delay, eventually, waitForEvent, waitUntil } from '@vielzeug/assay'; await waitUntil(() => panel.querySelector('.status')?.textContent === 'Ready'); await eventually(() => { expect(onSave).toHaveBeenCalledOnce(); }, { message: 'save callback did not run' }); const completed = waitForEvent>(panel, 'save-complete', { signal: AbortSignal.timeout(1000), }); fireClick(saveButton); expect((await completed).detail.id).toBeDefined(); await delay(100); // real timer dependency, such as a debounce ``` `waitUntil()` polls a boolean predicate. `eventually()` retries a throwing assertion and can include a diagnostic `message`. Both enforce one hard deadline while asynchronous callbacks are pending. Abort rejects immediately with the signal reason, but cannot stop callback-owned work; forward an application signal into that work when needed. Timeouts reject with `AssayTimeoutError`. Timeout and delay values must be finite and non-negative; polling intervals must be finite and positive. All durations must fit the platform timer limit of 2,147,483,647 ms. ## Testing custom elements Use Ore for component mounting and Assay for generic DOM concerns: ```ts import { fireClick } from '@vielzeug/assay'; import { html } from '@vielzeug/ore'; import { mount } from '@vielzeug/ore/testing'; const fixture = await mount( () => html` Save `, ); fireClick(fixture.get('button')); await fixture.flush(); expect(onSave).toHaveBeenCalledOnce(); ``` Refine's testing entry point contains only Refine-specific assertions and typed mount wrappers. Import Assay helpers directly instead of routing generic DOM operations through another package. ## Best Practices - Scope multiple assertions with `within()` rather than repeatedly querying the document. - Prefer `get*` for required controls and `query*` for intentional absence checks. - Make form state and event boundaries explicit: assign `.value`, then call `fireInput()` or `fireChange()`. - Use browser integration tests for focus, disabled activation, pointer capture, and other browser-default behavior. - Use `waitForEvent()` for an emitted event, `waitUntil()` for a condition, and `eventually()` for assertions. - Add a diagnostic `message` to waits whose final assertion does not identify the tested workflow. ### Examples ## Examples - [Custom Element Interaction](./examples/custom-element-interaction.md) - [Waiting for Async Updates](./examples/waiting-for-async-updates.md) --- ## @vielzeug/clockwork **Category:** state ### Overview ## Why Clockwork? Application workflows often mix state changes with timers, requests, rendering, and cleanup. Clockwork keeps transition logic pure while each disposable actor owns runtime work. You can test state decisions without starting effects or invokes. ```ts import { defineMachine } from '@vielzeug/clockwork'; // Before if (status === 'idle') status = 'loading'; fetchItems().then((items) => { status = 'ready'; data = items; }); // After type Event = { type: 'FETCH' } | { items: string[]; type: 'DONE' }; const machine = defineMachine()({ context: { items: [] }, initial: 'idle', states: { idle: { on: { FETCH: { target: 'loading' } } }, loading: { invoke: [{ src: ({ signal }) => fetch('/api/items', { signal }).then((response) => response.json() as Promise), onDone: ({ result }) => ({ items: result, type: 'DONE' }), }], on: { DONE: { reduce: ({ event }) => ({ items: event.items }), target: 'ready' } }, }, ready: {}, }, }); ``` | Feature | Clockwork | XState | Zustand | | --- | --- | --- | --- | | Bundle size | | Larger actor/statechart runtime | Smaller store runtime | | Zero dependencies | | | | | Pure transition API | | Statechart-focused | | | Owned cancellation | Actor disposal | | | | Framework coupling | None | None | None | **Use Clockwork when** your feature has explicit workflow states, cancellable work, or effects that must run after a state commit. **Consider XState when** you need statecharts, visual tooling, or its broader actor ecosystem. ## Installation ```sh [pnpm] pnpm add @vielzeug/clockwork ``` ```sh [npm] npm install @vielzeug/clockwork ``` ```sh [yarn] yarn add @vielzeug/clockwork ``` ## Quick Start Define the context and event union, create an actor, observe its snapshot, then dispose it when its owner ends. ```ts import { defineMachine } from '@vielzeug/clockwork'; type Event = { type: 'DEC' } | { type: 'INC' }; const counter = defineMachine()({ context: { count: 0 }, initial: 'idle', states: { idle: { on: { DEC: { reduce: ({ context }) => ({ count: context.count - 1 }), target: 'idle' }, INC: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' }, }, }, }, }); using actor = counter.createActor(); actor.subscribe((snapshot) => console.log(snapshot)); actor.send({ type: 'INC' }); // { context: { count: 1 }, state: 'idle' } ``` ## Features - **`defineMachine()`**: defines one flat, typed machine and rejects the footguns types cannot express. - **`machine.transition()`**: evaluates a transition without actor runtime work. - **`machine.createActor()`**: creates isolated, disposable runtime ownership. - **`reduce`**: returns a replacement context from a transition. - **`effects`**: run after commit and stable subscriber notification. - **`invoke`**: runs cancellable asynchronous work on state entry. - **`after`**: schedules cancellable delayed transitions. - **`actor.snapshot`**: exposes the current shallow-frozen state/context value. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Herald](/herald/): publish events between independent actors without coupling machine definitions. - [Ripple](/ripple/): bridge actor snapshots into a reactive graph when you need fine-grained rendering. - [Ward](/ward/): call authorization predicates from transition guards. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `defineMachine()` | Define a typed flat machine | Sync | Call the generic factory before supplying the definition | | `Machine.transition()` | Resolve a pure next snapshot | Sync | Does not run effects, invokes, or timers | | `Machine.createActor()` | Create a runtime owner | Sync | Fresh and restored actors have different entry behavior | | `Actor.send()` | Dispatch an event | Sync | Returns `void`; re-entrant events queue internally | | `Actor.subscribe()` | Observe committed snapshots | Sync | Observes only; it does not trace sends or errors | | `Actor.tap()` | Observe transitions, ignores, errors, disposal | Sync | Handler errors are swallowed; it cannot control disposition | | `ClockworkError` | Report definition, snapshot, and transition-limit failures | Sync | Narrow with the error subtypes, not message text | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/clockwork` | Machine factory, actor runtime, errors, and types | ## Exported Surface Callback parameter types are inferred for inline definitions. Clockwork also exports reusable callback, transition, state-node, actor-option, result, and error-context types for shared helpers. | Primary export | Kind | | --- | --- | | `defineMachine` | Function | | `Machine` | Type | | `Actor` | Type | | `MachineSnapshot` | Type | | `MachineConfig` | Type | | `ClockworkError` | Class | | `ClockworkDefinitionError` | Class | | `ClockworkSnapshotError` | Class | | `ClockworkTransitionLimitError` | Class | Additional exported types: `ActorErrorContext`, `ActorOptions`, `ActorTapEvent`, `After`, `Effect`, `EffectArgs`, `EventByType`, `EventType`, `Guard`, `Invoke`, `InvokeArgs`, `MachineEvent`, `Reducer`, `StateNode`, `Transition`, `TransitionInput`, and `TransitionResult`. ## Core Functions ### `defineMachine()` ```ts function defineMachine = Record, Event extends MachineEvent = MachineEvent, >(): (definition: MachineConfig) => Machine; ``` Returns a factory that accepts a typed flat machine definition. The definition is trusted as typed: targets, callback shapes, and state keys are compiler-checked. `defineMachine()` rejects only what types cannot express: timer delays outside `0..2,147,483,647` ms, empty transition arrays, and a context that is not a plain record (arrays, `null`, and class instances fail). Omit `context` only when the context type has no keys. The curried signature is required so TypeScript can infer `State` from the `states` object while you explicitly provide `Context` and `Event`. TypeScript does not support partial type argument inference, so the state-key union cannot be inferred in a single non-curried call when the context and event generics are explicit. **Returns:** A definition function that returns `Machine`. **Example:** ```ts import { defineMachine } from '@vielzeug/clockwork'; type Event = { type: 'START' }; const machine = defineMachine, Event>()({ initial: 'idle', states: { idle: { on: { START: { target: 'running' } } }, running: {} }, }); ``` Throws `ClockworkDefinitionError` when a definition carries a timer delay outside the platform range, an empty transition array, or a context that is not a plain record. --- ### `Actor.subscribe()` ```ts subscribe(listener: (snapshot: MachineSnapshot) => void): () => void; ``` Subscribes to future committed actor snapshots. It does not run immediately; read `actor.snapshot` for the initial value. Returns an unsubscribe function. Subscriber failures are reported through `tap()` as `error` events with `phase: 'subscriber'` without stopping remaining subscribers, declared effects, or the actor. **Example:** ```ts import { defineMachine } from '@vielzeug/clockwork'; const machine = defineMachine, { type: 'NEXT' }>()({ initial: 'idle', states: { idle: { on: { NEXT: { target: 'idle' } } } }, }); const actor = machine.createActor(); const stop = actor.subscribe((snapshot) => console.debug(snapshot)); actor.send({ type: 'NEXT' }); stop(); actor.dispose(); ``` ## Machine Methods ### `machine.transition()` ```ts transition( snapshot: MachineSnapshot, event: Event, ): TransitionResult; ``` Resolves a snapshot for one user event without actor runtime work. | Parameter | Type | Description | | --- | --- | --- | | `snapshot` | `MachineSnapshot` | Input state and context | | `event` | `Event` | User event to evaluate | **Returns:** A `TransitionResult` with `transition` or `ignored` type. **Example:** ```ts const result = machine.transition(machine.initialSnapshot, { type: 'START' }); ``` --- ### `machine.can()` ```ts can(snapshot: MachineSnapshot, event: Event): boolean; ``` Returns whether a transition exists and its guard passes. **Returns:** `true` when the supplied snapshot accepts the event. --- ### `machine.createActor()` ```ts createActor(options?: ActorOptions): Actor; ``` Creates an independent actor for event dispatch, timers, invokes, effects, subscriptions, and disposal. A fresh actor starts the initial state's entry effects and resources. An actor restored with `options.snapshot` starts only the restored state's resources: invokes and timers, not entry effects. | Parameter | Type | Description | | --- | --- | --- | | `options.snapshot` | `MachineSnapshot` | Optional restored actor snapshot | **Returns:** Disposable `Actor`. **Example:** ```ts const actor = machine.createActor({ snapshot: { context: {}, state: 'idle' } }); ``` ## Actor Methods ### `actor.send()` ```ts send(event: Event): void; ``` Dispatches a user event to the current actor state. Events sent while the actor is processing queue and flush synchronously; sends to a disposed actor are ignored. For active actors, malformed events without a string `type` log a development warning and are ignored. Valid but unhandled event types are ignored without a warning. Use `actor.snapshot` after sending to read the current snapshot. **Returns:** Nothing. --- ### `actor.can()` ```ts can(event: Event): boolean; ``` Returns whether the current actor snapshot accepts an event. A guard failure is reported as a fatal transition error, disposes the actor, and returns `false`. Returns `false` after disposal. **Returns:** Boolean transition availability. --- ### `actor.subscribe()` ```ts subscribe(listener: (snapshot: MachineSnapshot) => void): () => void; ``` Registers a listener for committed snapshots. The listener does not run immediately. **Returns:** An unsubscribe function. --- ### `actor.dispose()` ```ts dispose(): void; [Symbol.dispose](): void; ``` Cancels timers and invokes, clears queued events, tappers and listeners, emits a final `dispose` tap event, and aborts `disposalSignal`. **Returns:** Nothing. Idempotent. ### `actor.tap()` ```ts tap( handler: (event: ActorTapEvent) => void, options?: { signal?: AbortSignal }, ): () => void; ``` Observes actor lifecycle without affecting machine behavior: `{ type: 'transition' | 'ignored' }` carries the event and resulting snapshot, `{ type: 'error' }` carries the error with its `phase`/`state`/`event` context, and `{ type: 'dispose' }` is the final event before tappers are cleared. Handler errors are swallowed. Returns an unsubscribe function. ```ts actor.tap((event) => { if (event.type === 'error') console.error(event.phase, event.state, event.error); }); ``` ## Error Handling Policy Machine-execution failures are fail-stop: guard, reducer, effect, invoke, timer, and queued-transition-limit failures dispose the actor. The queued-transition limit is a fixed internal guard against runaway loops, not a configurable option. Subscriber failures are observational: Clockwork reports them through `tap()`, continues the stable subscriber snapshot and declared effects, and keeps the actor active. Tap handlers cannot control disposition. Subscriber iteration uses a stable snapshot: listeners added during notification begin with the next transition. ## Types ### `MachineSnapshot` ```ts type MachineSnapshot> = { readonly context: Readonly; readonly state: State; }; ``` Clockwork shallow-clones and freezes each snapshot and its context record. Nested context values remain caller-owned and are not deep-frozen. ### `MachineConfig` ```ts type MachineConfig, Event extends MachineEvent> = (keyof Context extends never ? { readonly context?: Context } : { readonly context: Context }) & { readonly initial: State; readonly states: Record>; }; ``` A flat machine definition. State nodes cannot contain child states. The `StateNode`, `Transition`, `After`, `Invoke`, `Effect`, `Guard`, and `Reducer` shapes are inferred inline and exported for reusable helpers. `After.delay` must be finite and between 0 and 2,147,483,647 milliseconds; delayed guards and reducers receive `event: undefined`. ### `Actor` ```ts type Actor, Event extends MachineEvent> = { [Symbol.dispose](): void; can(event: Event): boolean; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; send(event: Event): void; readonly snapshot: MachineSnapshot; subscribe(listener: (snapshot: MachineSnapshot) => void): () => void; tap( handler: (event: ActorTapEvent) => void, options?: { readonly signal?: AbortSignal }, ): () => void; }; ``` An actor's `snapshot` is the current plain readonly snapshot. ### `ActorTapEvent` ```ts type ActorTapEvent = | (ActorErrorContext & { readonly error: unknown; readonly type: 'error' }) | { readonly event: Event | undefined; readonly snapshot: MachineSnapshot; readonly type: 'transition' | 'ignored'; } | { readonly type: 'dispose' }; type ActorErrorContext = { readonly event?: Event; readonly phase: 'effect' | 'invoke' | 'subscriber' | 'transition'; readonly state: State; }; ``` ### `Machine` ```ts type Machine, Event extends MachineEvent> = { can(snapshot: MachineSnapshot, event: Event): boolean; createActor(options?: ActorOptions): Actor; readonly initialSnapshot: MachineSnapshot; transition(snapshot: MachineSnapshot, event: Event): TransitionResult; }; ``` A reusable machine. Its state and event lookups are own-property guarded, so unknown or prototype-colliding names such as `__proto__` or `constructor` are safely ignored when no transition exists. ## Errors ### `ClockworkError` `ClockworkError` is the base class for every Clockwork failure, with `details` and standard `Error` fields. Use `instanceof ClockworkError` to narrow an unknown error. Three subtypes carry the failure category: | Subtype | Raised when | | --- | --- | | `ClockworkDefinitionError` | `defineMachine()` rejects a definition footgun types cannot express: a timer delay outside `0..2,147,483,647` ms, an empty transition array, or a context that is not a plain record | | `ClockworkSnapshotError` | A snapshot, its context, or a reducer result is not a plain object record, or names an undeclared state | | `ClockworkTransitionLimitError` | An actor exceeds the fixed queued-transition limit and disposes itself | ```ts if (error instanceof ClockworkDefinitionError) { console.error(error.message, error.details); } ``` ### Usage Guide ## Basic Usage Call `defineMachine()` first to bind context and event types; the returned definition function infers state labels from `states`. Context is optional only when its type has no keys. Create one actor for each independently owned workflow. ```ts import { defineMachine } from '@vielzeug/clockwork'; type Event = { type: 'TOGGLE' }; const machine = defineMachine, Event>()({ initial: 'on', states: { off: { on: { TOGGLE: { target: 'on' } } }, on: { on: { TOGGLE: { target: 'off' } } }, }, }); const actor = machine.createActor(); actor.send({ type: 'TOGGLE' }); console.log(actor.snapshot.state); // 'off' actor.dispose(); ``` Dispose actors when a feature, request, or test ends. You can use `using` when the surrounding runtime supports `Symbol.dispose`. ```ts using actor = machine.createActor(); actor.send({ type: 'TOGGLE' }); ``` ## Context reducers A reducer receives readonly context and returns the next context. Clockwork shallow-clones and freezes each snapshot and context record; nested values are not deep-cloned, so make explicit nested replacements. ```ts type Event = { type: 'DEC' } | { type: 'INC' } | { type: 'RESET' }; const counter = defineMachine()({ context: { count: 0 }, initial: 'idle', states: { idle: { on: { DEC: { reduce: ({ context }) => ({ count: context.count - 1 }), target: 'idle' }, INC: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' }, RESET: { reduce: () => ({ count: 0 }), target: 'idle' }, }, }, }, }); ``` Keep reducers pure. Make nested copies yourself when nested data changes. ```ts SAVE: { reduce: ({ context, event }) => ({ ...context, profile: { ...context.profile, name: event.name }, }), target: 'editing', } ``` ## Guards Guards decide whether a transition can run. They receive readonly context and the matching event. For several choices, use an ordered array; the first passing guard wins. ```ts PAY: [ { guard: ({ context }) => context.balance >= context.total, reduce: ({ context }) => ({ ...context, balance: context.balance - context.total }), target: 'success', }, { target: 'insufficientFunds' }, ] ``` Call `actor.can(event)` for the current actor snapshot or `machine.can(snapshot, event)` for an arbitrary snapshot. ## Pure transitions `machine.transition()` enables isolated unit tests and decision UIs. It returns the unchanged snapshot with `type: 'ignored'` when no transition matches; it does not expose or run effects. ```ts const result = counter.transition( { context: { count: 3 }, state: 'idle' }, { type: 'INC' }, ); if (result.type === 'transition') { console.log(result.snapshot.context.count); // 4 } ``` ## Effects Entry, exit, and transition effects run only through an actor. The actor commits, establishes target-state timers and invokes, notifies a stable subscriber snapshot, then runs exit, transition, and entry effects. Subscriber failures are reported but cannot skip declared effects. Effects cannot change context; send a regular event for another state change. ```ts type WorkflowEvent = { type: 'SUBMIT' }; const workflow = defineMachine()({ context: { orderId: '' }, initial: 'draft', states: { draft: { on: { SUBMIT: { effects: [({ context }) => console.debug('submitted', context)], target: 'submitted', }, }, }, submitted: { entry: [({ context }) => console.log(`Submitted ${context.orderId}`)] }, }, }); ``` Effects receive `context`, the triggering `event` (or `undefined` for initial entry), actor `send`, and the actor lifetime `signal`. ## Async invokes Invokes start on state entry. `src` gets readonly entry context, the triggering event or `undefined`, and an `AbortSignal`. `onDone` or `onError` map settlement to ordinary events. All invokes are cancelled when the actor exits the state or disposes. ```ts type LoadEvent = | { type: 'FETCH' } | { items: string[]; type: 'SUCCESS' } | { message: string; type: 'FAILURE' } | { type: 'RETRY' }; const loader = defineMachine()({ context: { error: '', items: [] }, initial: 'idle', states: { idle: { on: { FETCH: { target: 'loading' } } }, loading: { invoke: [{ src: async ({ signal }) => { const response = await fetch('/api/items', { signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; }, onDone: ({ result }) => ({ items: result, type: 'SUCCESS' }), onError: ({ error }) => ({ message: String(error), type: 'FAILURE' }), }], on: { FAILURE: { reduce: ({ event }) => ({ error: event.message, items: [] }), target: 'error' }, SUCCESS: { reduce: ({ event }) => ({ error: '', items: event.items }), target: 'ready' }, }, }, ready: {}, error: { on: { RETRY: { target: 'loading' } } }, }, }); ``` ## Delayed transitions `after` starts timers on state entry and cancels them on exit or disposal. Delay must be finite and between 0 and 2,147,483,647 milliseconds. Guards and reducers receive `event: undefined`; a user event with `type: '$after'` remains a normal user event. ```ts type NotificationEvent = { type: 'DISMISS' } | { message: string; type: 'SHOW' }; const notification = defineMachine()({ context: { message: '' }, initial: 'hidden', states: { hidden: { on: { SHOW: { reduce: ({ event }) => ({ message: event.message }), target: 'visible' } } }, visible: { after: [{ delay: 5_000, target: 'hidden' }], on: { DISMISS: { target: 'hidden' } }, }, }, }); ``` ## Snapshot observation and persistence `actor.snapshot` is the current shallow-frozen `MachineSnapshot`; read it directly rather than calling a snapshot method. Use `subscribe()` to integrate a state library or persist future committed snapshots. Fresh actors run their initial entry effects and resources; restored actors start only the restored state's invokes and timers, not its entry effects. ```ts const stored = sessionStorage.getItem('wizard'); const actor = machine.createActor({ snapshot: stored ? JSON.parse(stored) : undefined, }); const stopSaving = actor.subscribe((snapshot) => { sessionStorage.setItem('wizard', JSON.stringify(snapshot)); }); console.log(actor.snapshot); stopSaving(); actor.dispose(); ``` Validate untrusted persisted data before passing it to `createActor()`. Clockwork validates the restored state name but cannot validate application-specific context fields. ## Error handling Guard, reducer, effect, invoke, timer, and transition-limit failures dispose the actor. The transition limit is a fixed internal guard against runaway loops. Subscriber failures are reported but do not dispose the actor or interrupt remaining subscribers and declared effects. Use `tap()` to observe either class without controlling disposition. The `error` event carries the error and its `phase` and `state`; handler failures are swallowed so observation cannot alter actor behavior. ```ts const actor = machine.createActor(); actor.tap((event) => { if (event.type === 'error') console.error({ error: event.error, event: event.event, phase: event.phase, state: event.state }); }); ``` For fatal machine failures, the `error` event is emitted before disposal. Without a tap handler, fatal failures dispose silently. ## Debugging Use `actor.subscribe()` to observe committed snapshots during development. It observes snapshots only; it does not trace dispatched events or runtime errors. ```ts const actor = machine.createActor(); const stop = actor.subscribe((snapshot) => console.debug(snapshot)); actor.send({ type: 'NEXT' }); stop(); actor.dispose(); ``` For richer inspection, route snapshots to application devtools. Clockwork intentionally has no internal trace buffer. ## Flat state maps Clockwork has flat state IDs. Prefer explicit states such as `editingDraft` and `editingSaving`, or compose several actors when domains have independent lifecycles. ## SSR Reuse one machine definition, but create and dispose an actor per request. Never share an actor across concurrent requests. ## Testing Test deterministic state behavior through `machine.transition()`. Create actors only for timers, invokes, effects, queueing, subscriptions, or disposal behavior. ```ts import { expect, test } from 'vitest'; test('increments without an actor', () => { const result = counter.transition( { context: { count: 2 }, state: 'idle' }, { type: 'INC' }, ); expect(result).toMatchObject({ snapshot: { context: { count: 3 }, state: 'idle' }, type: 'transition', }); }); ``` ## Framework Integration Bridge the current actor snapshot into renderer state through one subscription. Dispose that subscription with component lifecycle. ```ts [React] import { useSyncExternalStore } from 'react'; function useActor(actor: { readonly snapshot: Value; subscribe(listener: (snapshot: Value) => void): () => void }) { return useSyncExternalStore( (notify) => actor.subscribe(() => notify()), () => actor.snapshot, () => actor.snapshot, ); } ``` ```ts [Vue 3] import { onUnmounted, shallowRef } from 'vue'; const snapshot = shallowRef(actor.snapshot); const stop = actor.subscribe((next) => (snapshot.value = next)); onUnmounted(stop); ``` ```ts [Svelte] import { onDestroy } from 'svelte'; let snapshot = actor.snapshot; const stop = actor.subscribe((next) => (snapshot = next)); onDestroy(stop); ``` ## Working with Other Vielzeug Libraries Use Herald when separate actors exchange application events. Bridge Clockwork snapshots into Ripple only at a UI or application boundary. ```ts import { createBus } from '@vielzeug/herald'; const bus = createBus(); bus.on('REFRESH', () => actor.send({ type: 'FETCH' })); ``` ## Best Practices - Define context and event unions with `defineMachine()`. - Return replacement context from reducers; do not rely on runtime copying or freezing. - Keep guards and reducers pure. - Use actors for effects, timers, invokes, subscriptions, and cancellation. - Read the current snapshot from `actor.snapshot`, not a wrapper value. - Validate persisted context before restoring a snapshot. - Dispose every actor at its ownership boundary. - Route runtime failures through the `tap()` observer for logging and diagnostics. ### Examples - [Counter with Reset](./examples/counter-with-reset.md) - [Form Validation](./examples/form-validation.md) - [Auto-Dismiss Notification](./examples/auto-dismiss-notification.md) - [Model Nested Workflows with Flat States](./examples/hierarchical-states.md) - [Pure Transition Testing](./examples/unit-testing.md) - [Auth Flow with Guards](./examples/auth-flow.md) - [Data Fetching with Error Recovery](./examples/data-fetching.md) - [Fetch with Retry](./examples/fetch-retry.md) - [Paginated Data Loading](./examples/paginated-data-loading.md) - [Media Player](./examples/media-player.md) - [Persisted Wizard](./examples/persisted-wizard.md) - [Multi-Step Wizard with Routing](./examples/wizard-with-routing.md) - [Shopping Cart Checkout](./examples/checkout.md) - [Permission-Based Access Control](./examples/permission-based-access.md) - [Event Boundaries](./examples/middleware-pipeline.md) - [Multi-Machine Coordination](./examples/multi-machine-coordination.md) - [Debugging Transitions](./examples/debugging-transitions.md) ### REPL Examples - Delayed Transitions (id: `after-transitions`) - Async Invokes (id: `async-invokes`) - Basic State Machine (id: `basic-machine`) - Post-commit Effects (id: `entry-exit-actions`) - Guards & Reducers (id: `guards-and-reducers`) - Pure Transitions & Errors (id: `pure-transitions-and-errors`) --- ## @vielzeug/codex **Category:** AI ### Overview ## Why Codex? Codex exposes current Vielzeug catalog data through MCP without scanning source at request time. ## Installation ```sh pnpm add @vielzeug/codex ``` ## Quick Start ```sh npx -y @vielzeug/codex ``` ## Features - `loadSnapshot` validates chunked snapshot metadata. - `SnapshotCatalog` loads package content only when requested. - `createMcpServer` registers generic package tools; `/refine` adds component tools explicitly. - Root and `/refine` entry points publish TypeScript declarations. - `startHttpHost` enforces loopback binding plus localhost Host and Origin validation. ## Documentation - [Usage](./usage.md) - [API](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Refine](../refine/) provides component metadata bundled by Codex. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `loadSnapshot` | Read validated snapshot metadata | Sync | Content chunks load lazily | | `SnapshotCatalog` | Query package corpus | Sync | Construct from loaded snapshot | | `createMcpServer` | MCP adapter factory (generic tools) | Sync | Requires catalog and version | | `startHttpHost` | Loopback Streamable HTTP host | Async | HTTP remains local-only | | `registerRefineTools` | Opt-in Refine tool registration | Sync | Import from `@vielzeug/codex/refine` | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/codex` | `Catalog`, `SnapshotCatalog`, `loadSnapshot`, MCP server, and HTTP host | | `@vielzeug/codex/refine` | Opt-in Refine component tools (`registerRefineTools`) | ## Snapshot ### `loadSnapshot` ```ts loadSnapshot(snapshotRoot?: string, options?: { validateContents?: boolean }): LoadedSnapshot; ``` Loads catalog/search metadata and Refine metadata. `snapshotRoot` defaults to bundled `data/`. Package content chunks stay lazy unless `validateContents: true`, which validates every package content chunk during loading. ### `SnapshotCatalog` ```ts new SnapshotCatalog(snapshot: LoadedSnapshot) ``` Provides generic package lookup, docs/source/example/signature access, and deterministic search. Use `SnapshotRefineCatalog` from `/refine` when component methods are needed. --- ## MCP ### `createMcpServer` ```ts createMcpServer(catalog: Catalog, options: { version: string; debug?: boolean }): Server; ``` Registers generic package tools over `Catalog`. Refine tools are opt-in through `/refine`. The root also re-exports `StdioServerTransport` for connecting the server without a second package import. ## Refine tools (`@vielzeug/codex/refine`) ### `SnapshotRefineCatalog` ```ts new SnapshotRefineCatalog(snapshot: LoadedSnapshot) ``` Extends `SnapshotCatalog` with `getComponent()` and `listComponents()`. ### `registerRefineTools` ```ts registerRefineTools(server: Server, catalog: RefineCatalog, debug?: boolean): void; ``` Upgrades a server created by `createMcpServer()` with the combined generic and `refine-*` tool set. `refineTools` is exported as a readonly registry. ## HTTP ### `startHttpHost` ```ts startHttpHost(options: HttpHostOptions): Promise; ``` Starts Streamable HTTP on `127.0.0.1` by default. Runtime validation accepts only `127.0.0.1` or `::1`; every request must also pass localhost Host and Origin checks. `configureServer` runs once for each lazily created MCP request server and is not called during host startup. ## Types ```ts interface SnapshotPointer { directory: string; } interface SnapshotManifest { catalog: 'catalog.json'; contentDirectory: 'packages'; refine: 'refine.json' | null; schemaVersion: typeof SNAPSHOT_SCHEMA_VERSION; search: 'search.json'; version: string; } ``` Dev snapshots use `SnapshotPointer` (via `current.json`); published snapshots are static directories. ```ts interface LoadedSnapshot { catalog: CatalogFile; contentDirectory: string; manifest: SnapshotManifest; refineComponents: CemDeclaration[]; search: SearchRecord[]; } ``` ```ts interface CatalogFile { packages: PackageMeta[]; version: string; } interface SearchRecord { category: string; description: string; docs: Partial>; examples: Array; exports: string; keywords: string; name: string; related: string; slug: string; source: string | null; } interface PackageMeta { availableDocPages: DocPage[]; category: string; description: string; exampleIds: string[]; exports: string[]; hasSource: boolean; keywords: string[]; name: string; related: string[]; slug: string; version: string; } interface PackageContent { apiSource: string | null; docs: Partial>; examples: Example[]; typeSignatures: Record; } interface Example { code: string; id: string; name: string; } ``` ```ts interface Catalog { getContent(slug: string): PackageContent; getDocs(slug: string, page: DocPage): string; getExample(slug: string, exampleId: string): Example; getPackage(slug: string): PackageMeta; getSource(slug: string): string; getTypeSignature(slug: string, symbol: string): string; listExamples(slug: string): Array>; listPackages(): PackageMeta[]; search(query: string): SearchHit[]; } interface RefineCatalog extends Catalog { getComponent(tagName: string): CemDeclaration; listComponents(): CemDeclaration[]; } interface SearchHit { matchedExamples?: string[]; matchedIn: Array; matchedPages?: DocPage[]; name: string; slug: string; } ``` ```ts interface HttpHost { readonly disposalSignal: AbortSignal; dispose(): Promise; readonly disposed: boolean; readonly host: string; readonly port: number; [Symbol.asyncDispose](): Promise; } interface HttpHostOptions { catalog: Catalog; configureServer?: (server: Server) => void; debug?: boolean; host?: '127.0.0.1' | '::1'; port: number; version: string; } ``` ```ts const DOC_PAGES = ['index', 'api', 'usage', 'examples'] as const; type DocPage = (typeof DOC_PAGES)[number]; const SNAPSHOT_SCHEMA_VERSION = 1 as const; ``` CEM types and `RefineCatalog` are exported from `/refine`. ```ts interface CemDeclaration { attributes?: CemAttribute[]; cssParts?: CemCssPart[]; cssProperties?: CemCssProperty[]; description?: string; events?: CemEvent[]; members?: CemMember[]; name?: string; slots?: CemSlot[]; superclass?: { name: string; package?: string }; tagName?: string; [key: string]: unknown; } interface CemAttribute { default?: string; description?: string; fieldName?: string; name: string; type?: { text: string }; } interface CemCssPart { description?: string; name: string; } interface CemCssProperty { default?: string; description?: string; name: string; } interface CemEvent { description?: string; name: string; type?: { text: string }; } interface CemMember { description?: string; kind?: 'field' | 'method'; name: string; type?: { text: string }; } interface CemSlot { description?: string; name: string; } ``` ## Errors `CodexError` signals malformed snapshots or host failures. `CatalogError` adds `INVALID_ARG`, `NOT_FOUND`, or `UNAVAILABLE` for expected tool failures. ### Usage Guide ## Basic Usage Run local stdio server: ```sh npx -y @vielzeug/codex ``` Use shipped `mcp-setup.json` for machine-readable generic configuration. Client-specific configuration must use its documented MCP format. ## HTTP Mode HTTP uses Streamable HTTP and binds loopback only: ```sh npx -y @vielzeug/codex --port=3100 curl http://127.0.0.1:3100/health ``` Response includes snapshot version. Runtime bind validation and Host/Origin allowlists restrict access to localhost; no remote host mode exists. Programmatic `configureServer` hooks run once per MCP request server, not at host startup. Dispose the host asynchronously and observe `disposed` or `disposalSignal` when coordinating shutdown. ## Agent Skill Codex ships a `vielzeug` agent skill: a `SKILL.md` that tells an AI agent working in *your* project to discover packages through the MCP tools instead of guessing, to use the owning `@vielzeug/*` package instead of hand-rolling, and to build UI from Refine components, tokens, and theme whenever `@vielzeug/refine` is installed. Install it into the project: ```sh npx -y @vielzeug/codex skills install npx -y @vielzeug/codex skills install --target=.claude/skills npx -y @vielzeug/codex skills install --force # overwrite an existing copy ``` Without `--target`, the first existing directory among `.agents/skills`, `.github/skills`, and `.claude/skills` is used; when none exists, `.agents/skills` is created. The command refuses to overwrite an installed skill unless `--force` is passed. Rerun after upgrading codex to pick up the current package list, which is generated from the published manifests. The skill pairs with the MCP server: its lookups (`get-docs`, `refine-get-component`, `refine-validate-usage`, …) assume codex is connected, and it documents `node_modules` fallbacks for when it is not. ## Local Development Requires Node 22+ and root setup: ```sh pnpm setup cd packages/codex pnpm test:unit pnpm test:integration pnpm dev ``` `test:unit` uses fixtures only. `test:integration` regenerates a current snapshot then checks real monorepo inputs. `pnpm dev` watches documentation and package inputs, atomically publishes snapshots, then restarts server when snapshot changes. ## Debugging ```sh pnpm dev node src/cli.ts --port=3100 --debug curl http://127.0.0.1:3100/health ``` `--debug` logs tool durations and expected catalog errors to stderr. Build `@vielzeug/refine` before generating snapshot when component metadata changes. ## Programmatic Usage ```ts import { SnapshotCatalog, StdioServerTransport, createMcpServer, loadSnapshot } from '@vielzeug/codex'; const snapshot = loadSnapshot(); const catalog = new SnapshotCatalog(snapshot); await createMcpServer(catalog, { version: snapshot.manifest.version }).connect(new StdioServerTransport()); ``` To include Refine component tools, upgrade the server with the `@vielzeug/codex/refine` subpath: ```ts import { StdioServerTransport, createMcpServer, loadSnapshot } from '@vielzeug/codex'; import { SnapshotRefineCatalog, registerRefineTools } from '@vielzeug/codex/refine'; const snapshot = loadSnapshot(); const catalog = new SnapshotRefineCatalog(snapshot); const server = createMcpServer(catalog, { version: snapshot.manifest.version }); registerRefineTools(server, catalog); await server.connect(new StdioServerTransport()); ``` ## Best Practices - Use `search-packages` for capability discovery before loading broad source. - Use `get-type-signature` before loading full source. - Published package snapshots are static directories; local dev snapshots are immutable generations selected by `.dev/current.json`. - Pass `validateContents: true` to `loadSnapshot()` for artifact verification; normal startup keeps package chunks lazy. - Treat `configureServer` as a per-request factory hook and avoid process-global side effects. - Keep HTTP local. Use stdio for normal client integration. - Run `pnpm test:unit` before `pnpm test:integration`. ### 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:** finance ### Overview ## Why Coins? Coins keeps monetary values in bigint minor units, but makes units explicit at construction. Currency scale comes from deterministic definitions; `Intl` formats a known value without deciding its arithmetic representation. ```ts // Before const total = (19.99 + 7.25) * 1.08; // After import { USD, add, money, multiply } from '@vielzeug/coins'; const total = multiply(add(money('19.99', USD), money('7.25', USD)), '1.08'); ``` | Feature | Coins | decimal.js | Dinero.js | | --- | --- | --- | --- | | Bundle size | | External dependency | External dependency | | Bigint minor units | | | | | Explicit currency scale | | App-defined | Partial | | Exact allocation | | Manual | | | Zero dependencies | | | | **Use Coins when** application values represent real money and every rounding boundary must be visible. **Consider native numbers when** values are estimates, analytics, or display-only approximations. ## Installation ```sh [pnpm] pnpm add @vielzeug/coins ``` ```sh [npm] npm install @vielzeug/coins ``` ```sh [yarn] yarn add @vielzeug/coins ``` ## Quick Start ```ts import { USD, add, format, money, multiply } from '@vielzeug/coins'; const subtotal = add(money('12.50', USD), money('7.25', USD)); const total = multiply(subtotal, '1.08', { rounding: 'halfEven' }); console.log(format(total)); ``` ## Features - **`money`**: one constructor for decimal and explicit minor-unit values - **`currency`**: resolve seven canonical built-ins, or create a local custom definition - **`add`**: exact same-currency arithmetic - **`allocate`**: split every minor unit without loss - **`exchange`**: typed source and target currency conversion - **`format`**: locale presentation for bigint values - **`decodeMoney`**: validate and re-canonicalize persisted, transported, or cross-realm values ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Vault](/vault/): persist validated money JSON. - [Courier](/courier/): retrieve exchange-rate data. - [Spell](/spell/): validate external monetary payloads. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `money` | Construct validated money | Sync | Bigint requires `{ unit: 'minor' }` | | `currency` | Resolve built-in or construct custom | Sync | Unknown codes throw | | `add` / `subtract` | Combine matching currencies | Sync | Mismatches throw | | `multiply` / `divide` | Exact decimal scaling | Sync | Use decimal strings | | `sum` | Aggregate with inferred currency | Sync | Empty iterable requires `{ currency }` | | `allocate` | Split without losing minor units | Sync | Weights must be non-negative | | `clamp` | Bound to min/max range | Sync | Min must not exceed max | | `exchange` | Convert through an exact rate | Sync | Rate source must match value currency | | `format` | Present money with `Intl` | Sync | Formatting does not define currency scale | | `toJSON` / `decodeMoney` | Cross serialization or realm boundary | Sync | Persisted amount uses minor units | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/coins` | Complete public Coins API | ## Construction ### `currency(code)` / `currency({ code, minorUnit })` ```ts function currency(code: C): Currency; function currency(definition: { code: C; minorUnit: number }): Currency; ``` Resolves one of the seven built-ins (`USD`, `EUR`, `GBP`, `JPY`, `KRW`, `BHD`, or `KWD`) or constructs an immutable custom currency. Custom currencies are local values with no global registry. Code must be exactly three uppercase letters; `minorUnit` must be an integer from 0 to 6. Arithmetic compares currency definitions by reference. ```ts currency('USD'); currency({ code: 'PTS', minorUnit: 0 }); ``` ### `isCurrency(value)` ```ts function isCurrency(value: unknown): value is Currency; ``` Type guard for canonical currency values (built-in or constructed via `currency()`). ### `money(amount, currency, options?)` ```ts function money(amount: string, currency: C, options?: { rounding?: RoundingMode }): Money; function money(amount: bigint, currency: C, options: { unit: 'minor' }): Money; ``` ```ts money('19.99', USD); money(1999n, USD, { unit: 'minor' }); ``` Decimal strings that exceed the currency's precision require a `rounding` mode. Inputs use plain base-10 syntax without exponent, leading plus, or whitespace; input length is capped at 1,000 characters and normalized fractional precision at 100 places. Bigint amounts require `{ unit: 'minor' }`. ### `decodeMoney(value, options?)` ```ts function decodeMoney(value: unknown, options?: { currency?: (code: string) => Currency }): Money; ``` Validates untrusted or cross-realm data and returns canonical money. Plain bigint input must have exactly `amount` and canonical `currency`; JSON must have exactly `amount`, currency code, and `unit: 'minor'`. Serialized amounts are bounded to 1,000 characters. A custom resolver must return a canonical currency whose code matches the envelope. ### `isMoney(value)` ```ts function isMoney(value: unknown): value is Money; ``` Type guard for canonical Coins money values. Checks identity against the internal canonical set: forged frozen objects do not pass. ## Arithmetic ```ts function add(left: Money, right: Money>): Money; function subtract(left: Money, right: Money>): Money; function multiply(value: Money, factor: string, options?: { rounding?: RoundingMode }): Money; function divide(value: Money, divisor: string, options?: { rounding?: RoundingMode }): Money; function compare(left: Money, right: Money>): -1 | 0 | 1; function clamp( value: Money, options: { max: Money>; min: Money> }, ): Money; function abs(value: Money): Money; function negate(value: Money): Money; function round( value: Money, options: { fractionDigits: number; rounding?: RoundingMode }, ): Money; function toDecimal(value: Money): string; ``` `factor` and `divisor` are plain decimal strings; exponent notation, whitespace, and a leading plus are invalid. Matching currency identity is required for binary operations. `round()` requires integer `fractionDigits` from 0 through the currency's `minorUnit`. Scaling and rounding default to `halfAwayFromZero`. ## Aggregation ```ts function sum(values: readonly Money[]): Money; function sum(values: Iterable>, options: { currency: C }): Money; function allocate(value: Money, count: number): Money[]; function allocate(value: Money, weights: readonly string[]): Money[]; ``` `sum()` infers currency from non-empty values. An empty iterable requires a canonical `{ currency }` and returns canonical zero. `allocate()` accepts a positive integer count or dense, non-empty, non-negative decimal-string weights with a positive total. Either form is limited to 100,000 output parts. It distributes remainders by largest fractional share, breaks ties by input order, and preserves the exact signed minor-unit total. ## Exchange ```ts function exchangeRate({ from, to, value, }: { from: From; to: To; value: string; }): ExchangeRate; function exchange( value: Money, rate: ExchangeRate, options?: { rounding?: RoundingMode }, ): Money; ``` ```ts const rate = exchangeRate({ from: USD, to: EUR, value: '0.9234' }); exchange(money('100.00', USD), rate); ``` Rates are exact positive decimal strings. The money currency must be the same canonical instance as `rate.from`. Conversion defaults to `halfAwayFromZero` rounding. ### `isExchangeRate(value)` ```ts function isExchangeRate(value: unknown): value is ExchangeRate; ``` Type guard for canonical exchange rates created by `exchangeRate()`. Checks identity against the internal canonical set: forged frozen objects do not pass. ## Formatting ```ts function format(value: Money, options?: FormatOptions): string; function formatParts(value: Money, options?: FormatOptions): MoneyFormatPart[]; ``` Defaults are locale `en-US`, style `symbol`, and the currency's minor-unit digits. Either fraction bound may be supplied independently; the omitted bound adjusts to remain compatible. Explicit bounds must satisfy `0 ≤ minimum ≤ maximum ≤ 20`. Exact decimal strings and mapped rounding modes are passed to `Intl.NumberFormat`, preserving locale digits, currency-name pluralization, and rounded negative signs. `formatParts()` keeps locale grouping inside its `integer` part. ## Serialization ```ts function toJSON(value: Money): MoneyJSON; function decodeMoney(value: unknown, options?: { currency?: (code: string) => Currency }): Money; ``` `toJSON()` produces exactly `{ amount, currency, unit: 'minor' }`. `amount` is a canonical integer string of at most 1,000 characters: no decimal point, exponent, leading plus, leading zero, whitespace, or negative zero. `decodeMoney()` resolves built-ins with `currency()` by default; custom codes require a resolver returning the same encoded code. ## Types ```ts type CurrencyCode = C & { readonly [currencyBrand]: C }; type Currency = Readonly; minorUnit: number; }>; type Decimal = Readonly; type Money = Readonly; type ExchangeRate = Readonly; type FormatOptions = Readonly; type MoneyFormatPart = Readonly; type MoneyJSON = Readonly; type RoundingMode = 'awayFromZero' | 'ceil' | 'floor' | 'halfAwayFromZero' | 'halfEven' | 'towardZero'; ``` `CurrencyCode`, `Decimal`, and `Money` carry phantom brand symbols that prevent unbranded values from being assigned where canonical values are required. `ExchangeRate.value` exposes a normalized exact `Decimal` numerator and denominator. | Rounding mode | Behavior | | --- | --- | | `awayFromZero` | Always increase a non-exact magnitude | | `ceil` | Toward positive infinity | | `floor` | Toward negative infinity | | `halfAwayFromZero` | Nearest; midpoint away from zero | | `halfEven` | Nearest; midpoint to an even retained unit | | `towardZero` | Truncate toward zero | ## Errors ```ts type CoinsErrorCode = | 'CURRENCY_MISMATCH' | 'DIVISION_BY_ZERO' | 'FORMAT_ERROR' | 'INVALID_ALLOCATION' | 'INVALID_CURRENCY' | 'INVALID_DECIMAL' | 'INVALID_EXCHANGE_RATE' | 'INVALID_MONEY' | 'INVALID_RANGE' | 'INVALID_ROUNDING'; ``` Every Coins failure extends `CoinsError` and exposes `code`. ```ts class CoinsError extends Error { readonly code: CoinsErrorCode; } class CurrencyMismatchError extends CoinsError { readonly expected: string; readonly received: string; } class InvalidCurrencyError extends CoinsError { readonly value: unknown; } ``` `CurrencyMismatchError` and `InvalidCurrencyError` are specialized `CoinsError` subclasses. Use `instanceof CoinsError` to narrow any value to the Coins error hierarchy. ### Usage Guide ## Basic Usage Construct decimal values with a currency definition. Coins stores minor units internally and never accepts implicit floating-point input. ```ts import { USD, add, money, toDecimal } from '@vielzeug/coins'; const subtotal = add(money('12.50', USD), money('7.25', USD)); console.log(toDecimal(subtotal)); // '19.75' ``` Use bigint only when data is already in minor units: ```ts import { USD, money } from '@vielzeug/coins'; const cents = money(1999n, USD, { unit: 'minor' }); ``` ## Define Currencies Coins exports seven built-in definitions: `USD`, `EUR`, `GBP`, `JPY`, `KRW`, `BHD`, and `KWD`. Construct other ISO or business currencies explicitly. Custom definitions are local canonical values; share one instance because arithmetic compares currencies by reference. ```ts import { EUR, USD, currency, money } from '@vielzeug/coins'; const rewards = currency({ code: 'PTS', minorUnit: 0 }); money('10.00', USD); money('10.00', EUR); money('500', rewards); ``` ## Apply Exact Arithmetic Pass plain decimal strings to scaling operations; exponent notation, signs such as `+1`, whitespace, and numeric inputs are rejected. `multiply()`, `divide()`, `exchange()`, `round()`, and visible formatting default to `halfAwayFromZero`; pass a named mode when accounting policy requires another rule. ```ts import { USD, divide, money, multiply, round, toDecimal } from '@vielzeug/coins'; const subtotal = money('19.99', USD); const taxed = multiply(subtotal, '1.08', { rounding: 'halfEven' }); // Extra currency precision must name its rounding policy. const roundedInput = money('19.999', USD, { rounding: 'halfAwayFromZero' }); const split = divide(taxed, '3', { rounding: 'floor' }); const displayed = round(taxed, { fractionDigits: 0, rounding: 'halfAwayFromZero' }); console.log(toDecimal(split), toDecimal(displayed)); ``` ## Aggregate and Allocate `sum()` infers currency from non-empty values. Pass a canonical `{ currency }` for possibly empty iterables. `allocate()` preserves every minor unit, distributes remainders by largest fractional share with stable index ties, and applies the same allocation symmetrically to negative values. Counts and dense weight arrays are limited to 100,000 shares. ```ts import { USD, allocate, money, sum, toDecimal } from '@vielzeug/coins'; const total = sum([money('10.00', USD), money('5.00', USD)]); const zero = sum([], { currency: USD }); const weighted = allocate(money('10.00', USD), ['1', '2', '1']); const even = allocate(money(5n, USD, { unit: 'minor' }), 2); console.log(toDecimal(total)); console.log(weighted.map(toDecimal)); console.log(even.map((value) => value.amount)); // [3n, 2n] ``` ## Convert Currency Create a typed rate from currency definitions and a strictly positive exact decimal string. ```ts import { EUR, USD, exchange, exchangeRate, format, money } from '@vielzeug/coins'; const usdToEur = exchangeRate({ from: USD, to: EUR, value: '0.9234' }); const euros = exchange(money('100.00', USD), usdToEur, { rounding: 'halfEven' }); console.log(format(euros, { locale: 'de-DE' })); ``` ## Format Money Format only at presentation boundaries. Coins keeps bigint arithmetic exact and uses `Intl.NumberFormat` only to obtain locale and currency layout. ```ts import { EUR, format, formatParts, money } from '@vielzeug/coins'; const value = money('1234.56', EUR); console.log(format(value, { locale: 'de-DE', style: 'code' })); console.log(formatParts(value, { maximumFractionDigits: 1, rounding: 'halfEven' })); ``` Either fraction bound may be supplied independently; the omitted bound adjusts to remain compatible. Explicit values must satisfy `0 ≤ minimumFractionDigits ≤ maximumFractionDigits ≤ 20`. Formatting passes the exact decimal string and rounding mode to `Intl`, including locale digits, currency-name pluralization, and rounded negative signs. It never mutates money. ## Serialize Money Use `toJSON()` and `decodeMoney()` at storage, transport, worker, and realm boundaries. Decoding validates the envelope and returns a new canonical `Money` value. ```ts import { USD, currency, decodeMoney, money, toJSON } from '@vielzeug/coins'; const encoded = toJSON(money('19.99', USD)); const restored = decodeMoney(encoded); const TOK = currency({ code: 'TOK', minorUnit: 2 }); const customEncoded = toJSON(money('1.00', TOK)); const custom = decodeMoney(customEncoded, { currency: (code) => (code === 'TOK' ? TOK : currency(code)), }); ``` `MoneyJSON.amount` is a canonical integer string of at most 1,000 characters in minor units: no decimal point, exponent, leading plus, leading zero, whitespace, or negative zero. Built-in codes resolve automatically; custom codes require a resolver. `decodeMoney()` also accepts exactly `{ amount: bigint, currency: Currency }` when the currency is canonical. Custom resolvers must return the same code encoded in JSON. Use `isMoney()` only to test values already created or decoded in the current realm. ## Handle Errors Use `CoinsError.code` for stable recovery branches. ```ts import { CoinsError, USD, money } from '@vielzeug/coins'; try { money('19.999', USD); } catch (error) { if (error instanceof CoinsError && error.code === 'INVALID_MONEY') { console.log('Over-precise decimal requires a rounding mode.'); } } ``` ## Best Practices - Use decimal strings for exact external inputs. - Use bigint only with `{ unit: 'minor' }`. - Pass named rounding options for division, scaling, and exchange. - Export each custom currency from one module and reuse that canonical instance. - Use `sum(values)` for non-empty collections; `sum(values, { currency })` for possibly empty ones. - Serialize with `toJSON()` and validate or re-canonicalize with `decodeMoney()`. - Format only at presentation boundaries. ### Examples ## Examples - [Formatting](./examples/formatting.md) - [Exchange Rate Conversion](./examples/exchange.md) - [Allocation](./examples/allocation.md) - [Serialization](./examples/serialization.md) ### REPL Examples - allocate - Preserve every minor unit (id: `allocation-basic`) - Arithmetic - Exact decimal scaling (id: `arithmetic-basic`) - Errors - Stable codes (id: `errors-basic`) - exchange - Exact currency conversion (id: `exchange-basic`) - format - Locale presentation (id: `format-basic`) - money - Decimal and minor-unit construction (id: `money-basic`) - round - Explicit rounding policy (id: `rounding-basic`) - Serialization - Validate JSON boundaries (id: `serialization-basic`) - Currency definitions and validation (id: `utilities-basic`) --- ## @vielzeug/conduit **Category:** infrastructure ### Overview ## Why Conduit? Conduit makes service wiring explicit. Typed builders create immutable providers, static graphs are validated at construction, and dependency tuples drive creation and disposal. Resolve one root token or a typed composition map. ```ts // Before const service = createService(createApi(config), logger); // After const container = createContainer([ valueProvider(Config, config), factoryProvider(Api, [Config], (config) => createApi(config)), factoryProvider(Service, [Api, Logger], (api, logger) => createService(api, logger)), ]); const services = await container.resolve({ service: Service }); ``` | Feature | Conduit | Inversify | tsyringe | | --- | --- | --- | --- | | Dependencies | Explicit token tuples | Decorators/runtime metadata | Decorators/runtime metadata | | Async factories | | Partial | Partial | | Lifecycle scopes | | | | | Runtime dependencies | 0 | | | **Use Conduit when** application services need explicit wiring and owned lifecycle cleanup. **Consider direct imports when** dependencies are static, small, and need no replacement or disposal boundary. ## Installation ```sh [pnpm] pnpm add @vielzeug/conduit ``` ```sh [npm] npm install @vielzeug/conduit ``` ```sh [yarn] yarn add @vielzeug/conduit ``` ## Quick Start ```ts import { createContainer, factoryProvider, token, valueProvider } from '@vielzeug/conduit'; const Config = token('Config'); const Client = token('Client'); const container = createContainer([ valueProvider(Config, { baseUrl: '/api' }), factoryProvider(Client, [Config], (config) => ({ url: `${config.baseUrl}/users` })), ]); const services = await container.resolve({ client: Client }); console.log(services.client); await container.dispose(); ``` ## Features - **`token`**: typed dependency identity - **`valueProvider` / `factoryProvider`**: token-safe immutable registrations - **`createContainer`**: validated provider graph - **`resolve`**: direct service or typed composition map - **`createScope`**: child containers for request and job ownership - **`dispose`**: in-flight-safe resource cleanup ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Courier](/courier/): inject HTTP clients into application services. - [Vault](/vault/): inject persistence adapters with scoped ownership. - [Rune](/rune/): provide application logging services. ### API Reference ## API Overview | Symbol | Purpose | Mode | Common gotcha | | --- | --- | --- | --- | | `token` | Create typed dependency identity | Sync | Same description does not mean same token | | `createContainer` | Build root container from an immutable provider array | Sync | Validates the full graph at construction | | `valueProvider` / `factoryProvider` | Build type-safe immutable registrations | Sync | Prefer over unannotated object literals | | `Provider` | Value or factory registration type | Sync | One local registration per token | | `has` | Check registration visibility | Sync | Walks parent containers | | `resolve` | Resolve one token or typed composition-root map | Async | Undeclared map tokens fail at resolution | | `createScope` | Create child container | Sync | Child providers shadow parent registrations | | `dispose` | Release owned resources | Async | May throw `ConduitDisposeError` after cleanup attempts | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/conduit` | Complete Conduit API | ## Tokens ```ts token(description: string): Token ``` Tokens are unique symbols. Descriptions exist only for diagnostics. `disposalSignalToken` is a built-in `Token` that resolves to the owning container's cancellation signal. ## Container ```ts createContainer(providers: readonly Provider[], options?: { name?: string }): Container ``` `providers` is an immutable array of value and factory providers. Construction validates the entire graph: duplicate tokens, missing dependencies, circular factory tuples, and singletons depending on transient factories all fail fast. ### Providers ```ts type Provider = ValueProvider | FactoryProvider; valueProvider(token: Token, value: T, options?: ValueOptions): ValueProvider; factoryProvider( token: Token, dependencies: Dependencies, factory: (...values: InferTokens) => Promise | T, options?: FactoryOptions, ): FactoryProvider; ``` Use builders for token-driven value and dependency inference: ```ts valueProvider(Config, { baseUrl: '/api' }, { dispose: (value) => value.close() }); factoryProvider(Service, [Api, Logger], (api, logger) => createService(api, logger), { lifetime: 'singleton', dispose: (value) => value.dispose(), }); ``` Explicit `ValueProvider` and `FactoryProvider` annotations remain available for reusable provider declarations. Avoid unannotated provider object literals because TypeScript cannot contextually infer one factory parameter from another object property. `dependencies` is copied at construction and drives creation, validation, cycle detection, and teardown order. Factories may return a value or promise. `lifetime` accepts `'singleton'` (default) or `'transient'`. Singletons cannot capture transient factories. ```ts type FactoryProvider = { token: Token; dependencies: Dependencies; factory: (...values: InferTokens) => Promise | T; dispose?: (value: T) => void | Promise; lifetime?: 'singleton' | 'transient'; }; ``` ### has ```ts container.has(token): boolean ``` Checks local and parent registrations without creating a factory result. ### resolve ```ts container.resolve(token: Token): Promise container.resolve(map: ServiceMap): Promise> ``` Resolves one typed token or a service object at a composition root. Map keys are defined as own properties, including `__proto__`, without changing the result prototype. Missing top-level tokens fail during resolution. ```ts const services = await container.resolve({ client: Client, config: Config }); ``` Singleton resolutions deduplicate concurrent callers and cache successful values. A rejected creation attempt is evicted so a later resolution can retry. ### createScope ```ts container.createScope(options?: { name?: string; providers?: readonly AnyProvider[] }): Container ``` A child container inherits parent registrations. Immutable local providers may shadow parent registrations for request or test overrides: this is how request-scoped values are spelled: one child per request, disposed when the request ends. Disposing a parent also disposes active children. ### dispose ```ts container.dispose(): Promise container.disposalSignal: AbortSignal container.disposed: boolean ``` Disposal blocks new work, aborts `disposalSignal`, disposes active child containers, waits for complete in-flight creation and late-result cleanup, then disposes owned resources in reverse creation order. Cleanup failures are aggregated in the readonly `ConduitDisposeError.errors` array. ## Types ```ts type Token = symbol; type Lifetime = 'singleton' | 'transient'; type InferTokens[]> = { [K in keyof T]: T[K] extends Token ? Value : never; }; type ServiceMap = { readonly [key: string]: Token }; type InferServices = { readonly [K in keyof M]: M[K] extends Token ? Value : never; }; interface Container { createScope(options?: { name?: string; providers?: readonly AnyProvider[] }): Container; readonly disposalSignal: AbortSignal; dispose(): Promise; readonly disposed: boolean; has(token: Token): boolean; readonly name: string; resolve(token: Token): Promise; resolve(map: M): Promise>; [Symbol.asyncDispose](): Promise; } ``` ## Errors - `ConduitError`: base class; use `instanceof ConduitError` to narrow package errors. - `ConduitProviderNotFoundError`: dependency has no registration. - `ConduitCircularDependencyError`: static factory tuple graph contains a cycle. - `ConduitDuplicateRegistrationError`: token registered twice in one container. - `ConduitDisposedError`: operation attempted after disposal began. - `ConduitDisposeError`: one or more cleanup hooks failed. ### Usage Guide ## Basic Usage Create tokens once, build a container from an immutable provider array, then resolve a typed service object at your composition root. ```ts import { createContainer, factoryProvider, token, valueProvider } from '@vielzeug/conduit'; const Config = token('Config'); const Client = token('Client'); const container = createContainer([ valueProvider(Config, { baseUrl: '/api' }), factoryProvider(Client, [Config], (config) => ({ url: `${config.baseUrl}/users` })), ]); const services = await container.resolve({ config: Config, client: Client }); console.log(services.client); await container.dispose(); ``` ## Define Dependencies Factory `dependencies` tuples are authoritative. Conduit resolves tuple values in order, validates every edge at construction, and disposes created services in reverse dependency order. ```ts const Logger = token('Logger'); const Api = token }>('Api'); const Service = token }>('Service'); const container = createContainer([ valueProvider(Logger, { info: (message) => console.log(message) }), factoryProvider(Api, [], () => ({ get: async (path: string) => fetch(path) })), factoryProvider(Service, [Api, Logger], (api, logger) => ({ async load() { logger.info('Loading data'); return api.get('/data'); }, })), ]); ``` ## Choose Lifetimes Factories are singletons by default and cached on the registering container. Concurrent singleton resolutions share one in-flight attempt; a rejected attempt is evicted for retry. Use `'transient'` for one value per resolution. Singletons cannot depend on transient factories. ```ts import { createContainer, factoryProvider, token } from '@vielzeug/conduit'; const Clock = token('Clock'); const root = createContainer([ factoryProvider(Clock, [], () => ({ now: () => Date.now() }), { lifetime: 'transient' }), ]); ``` Factory dependency tuples are copied at construction, so later caller mutation cannot change Conduit's graph. Add `disposalSignalToken` to a factory tuple when work must observe its owning container's cancellation. ## Scope Work to Child Containers Use a child container when a resource belongs to a request, job, or test lifecycle. Pass immutable local providers through `createScope()` options to shadow parent registrations; the child owns and disposes them. ```ts const Session = token('Session'); const TraceId = token('TraceId'); const request = root.createScope({ providers: [ factoryProvider(Session, [], () => ({ id: crypto.randomUUID() })), valueProvider(TraceId, 'request-trace'), ], }); const services = await request.resolve({ session: Session, traceId: TraceId }); await request.dispose(); await root.dispose(); ``` Each child caches its own singleton, so two request children resolve distinct `Session` values while the root never sees request-owned work unless it registers it. ## Resolve at Composition Roots Resolve a map at composition roots to receive an explicitly typed service object. Direct `resolve(token)` remains available for one root service and focused tests; avoid scattering service-locator calls through application code. ```ts const client = await container.resolve(Client); const services = await container.resolve({ client: Client, config: Config }); // Both results are fully typed. ``` ## Dispose Resources `dispose()` rejects new work, aborts `disposalSignal`, disposes child containers, waits for in-flight creation, then releases services in reverse creation order. A factory that finishes after disposal starts is immediately cleaned up and its resolver receives `ConduitDisposedError`. ```ts await container.dispose(); ``` `ConduitDisposeError.errors` contains every cleanup failure after Conduit attempts all hooks, including cleanup from in-flight factories and child containers. ## Testing Build a container per test with explicit values for external dependencies. Because the provider array is immutable and validated at construction, a misconfigured test container fails immediately. ```ts const Clock = token('Clock'); const Service = token('Service'); const container = createContainer([ valueProvider(Clock, { now: () => 123 }), factoryProvider(Service, [Clock], (clock) => ({ timestamp: clock.now() })), ]); const services = await container.resolve({ service: Service }); expect(services.service).toEqual({ timestamp: 123 }); await container.dispose(); ``` ## Best Practices - Create tokens at module scope. - Use `valueProvider()` and `factoryProvider()` so tokens, values, dependencies, and disposers remain type-safe. - Declare every factory dependency in its tuple. - Keep factories focused on one service. - Use child containers for request/job-owned resources. - Resolve once at a composition root; pass the typed service object downstream. - Dispose every child container and root container. - Keep optional application fallback policy outside Conduit. - Use `await using container = createContainer([...])` when lexical async disposal fits application lifetime. ### Examples ## Examples - [Basic setup](./examples/basic-setup.md) - [Static async providers](./examples/async-providers.md) - [Singleton lifetime](./examples/lifetimes.md) - [Child containers](./examples/child-containers.md) - [Disposal lifecycle](./examples/dispose-lifecycle.md) - [Startup validation](./examples/startup-hardening.md) ### REPL Examples - Immutable provider array (id: `basic-container`) - Child container ownership (id: `child-containers`) - Reverse dependency disposal (id: `dispose-lifecycle`) - Singleton lifetime caching (id: `lifetimes`) - Replace dependencies in tests (id: `testing`) - Fail-fast startup validation (id: `validate`) --- ## @vielzeug/courier **Category:** http ### Overview ## Why Courier? Native `fetch` leaves request policy, error classification, middleware composition, and repeated-read coordination to each application. Courier keeps those concerns in one client, including explicit cached GETs and warm-cache prefetching, while observable query and mutation state remain consumer-owned. ```ts // Before const response = await fetch(`/api/users/${userId}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const user = await response.json(); // After const user = await courier.get('/users/{id}', { params: { id: userId } }); ``` | Feature | Courier | ky | ofetch | | --- | --- | --- | --- | | Bundle size | | Separate package | Separate package | | Zero runtime dependencies | | | | | Native fetch transport | | | | | Explicit parsed-value cache and prefetch | | | | | Immutable middleware at construction | | | | | Structured error taxonomy | | | | **Use Courier when** one client should own typed HTTP, explicit cached GETs and prefetching, immutable middleware, and structured errors, while a separate state layer owns observable queries and mutations. **Consider ky when** you only need a compact fetch wrapper. **Consider ofetch when** you want a bundled fetch utility with its own retry and caching conventions. ## Installation ```sh [pnpm] pnpm add @vielzeug/courier ``` ```sh [npm] npm install @vielzeug/courier ``` ```sh [yarn] yarn add @vielzeug/courier ``` ## Quick Start Create one transport client for an application or request scope, then use method conveniences for standard HTTP methods and `request()` for custom methods. ```ts import { CourierHttpError, createCourier } from '@vielzeug/courier'; type User = { id: number; name: string }; const courier = createCourier({ baseUrl: 'https://api.example.com' }); try { const user = await courier.get('/users/{id}', { params: { id: 42 } }); console.log(user.name); } catch (error) { if (CourierHttpError.is(error, 404)) console.log('User not found'); else throw error; } finally { courier.dispose(); } ``` ## Features - **`createCourier()`**: one lifecycle, immutable middleware pipeline, header defaults, and cancellation boundary. - **`request()`**: one contract for every HTTP method; pass `method` in the config. - **Cached `get()`**: opt in with a structured key and optional per-read TTL; successful parsed values share bounded storage and in-flight work. - **`prefetch()`**: await cache warming while request failures remain observable through `tap()`. - **`invalidateCache()` / `clearCache()`**: invalidate structured key prefixes without query state. - **`get()` / `post()` / `put()` / `patch()` / `delete()`**: conveniences for standard methods. - **`withBearerAuth()` / `withRequestId()` / `withLogging()`**: composable middleware configured at construction. - **`tap()`**: signal-owned structured transport observation. - **Error taxonomy**: HTTP, network, timeout, abort, parse, and schema failures are distinct, actionable classes. - **`cancelAll()` / `dispose()`**: abort active requests and tear down the transport. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Flux](/flux/): composes async streams; pair with Courier transport calls in the owning state layer. - [Spell](/spell/): validates parsed HTTP payloads through Courier's `schema` option. - [Postmaster](/postmaster/): coordinates durable delivery of Courier requests through a job outbox. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createCourier()` | Creates transport client | Sync | Dispose only when whole scope ends | | `request()` | Sends one HTTP request of any method | Async | Direct calls never deduplicate | | `get()` | Sends a GET, optionally through the explicit cache | Async | One key must identify one representation | | `prefetch()` | Warms a cached GET | Async | Request failures resolve after surfacing through `tap()` | | `invalidateCache()` / `clearCache()` | Removes cached values | Sync | Invalidation matches structured key prefixes | | `post()` / `put()` / `patch()` / `delete()` | Common write method conveniences | Async | Use `request()` for custom methods | | `withBearerAuth()` | Adds authorization middleware | Sync | Token provider runs per request | | `withRequestId()` | Adds request identifier middleware | Sync | Default generator uses `crypto.randomUUID()` | | `withLogging()` | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values | | `tap()` | Observe structured transport events | Sync | Observation only; optional signal owns lifetime | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/courier` | Client factory, errors, middleware helpers, and public types | ## Client ### `createCourier()` ```ts createCourier(options?: CourierOptions): Courier; ``` Returns an HTTP client sharing base URL, default headers, immutable middleware, optional parsed-value cache, timeout, and cancellation lifecycle. | `CourierOptions` field | Type | Default | Description | | --- | --- | --- | --- | | `baseUrl` | `string` | `''` | Prefix for relative request paths | | `cache.capacity` | `number` | `100` | Maximum cached entries; positive integer or `Infinity` | | `cache.ttlMs` | `number` | `30_000` | Default cache freshness; non-negative milliseconds or `Infinity` | | `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Fetch implementation | | `headers` | `HeadersInit` | `{}` | Default request headers (immutable; merged lowercase) | | `middleware` | `readonly Middleware[]` | `[]` | Immutable middleware chain configured at construction | | `timeout` | `number` | `30_000` | Integer milliseconds from 1 to 2,147,483,647, or `Infinity` | **Returns:** `Courier`. ```ts import { createCourier, withBearerAuth } from '@vielzeug/courier'; const courier = createCourier({ baseUrl: 'https://api.example.com', middleware: [withBearerAuth('token')], }); ``` | `Courier` member | Signature | Description | | --- | --- | --- | | `request` | `(path, config?) => Promise` | Sends one HTTP request; `config.method` defaults to `GET` | | `get` | `(path, config?) => Promise` | Sends GET; `cache` opts parsed responses into bounded caching | | `prefetch` | `(path, config) => Promise` | Warms one required structured cache key; failures are tap-observed | | `invalidateCache` | `(prefix: CourierCacheKey) => void` | Invalidates matching settled and pending key prefixes | | `clearCache` | `() => void` | Removes every cached and pending entry | | `post` | `(path, config?) => Promise` | Sends POST | | `put` | `(path, config?) => Promise` | Sends PUT | | `patch` | `(path, config?) => Promise` | Sends PATCH | | `delete` | `(path, config?) => Promise` | Sends DELETE | | `tap` | `(handler, options?: { signal?: AbortSignal }) => () => void` | Observes structured request and disposal events | | `cancelAll` | `() => void` | Aborts active requests; client remains usable | | `dispose` | `() => void` | Final teardown; aborts work and marks client unusable | | `disposed` | `boolean` | Whether final disposal occurred | | `disposalSignal` | `AbortSignal` | Aborts on final disposal | --- ## Requests ### `request()` ```ts request(path: P, config?: RequestConfig & { method?: string }): Promise; ``` Sends one HTTP request. `config.method` defaults to `GET`; pass any method (including custom verbs) to support it consistently. | `RequestConfig` field | Type | Description | | --- | --- | --- | | `method` | `string` | HTTP method; defaults to `GET` | | `params` | `Record` | Path parameters for `{param}` placeholders | | `query` | `Params` | Query string parameters | | `body` | `unknown` | BodyInit values pass through; streams use Node duplex; GET/HEAD reject bodies; other values encode as JSON | | `headers` | `HeadersInit` | Per-request headers merged with (and overriding) defaults | | `responseType` | `ResponseType` | Response parsing strategy | | `schema` | `{ parse(data: unknown): T }` | Optional parsed-response validator; incompatible with `responseType: 'raw'` | | `signal` | `AbortSignal` | External abort signal merged with internal cancellation | | `timeout` | `number` | Integer milliseconds from 1 to 2,147,483,647, or `Infinity`; overrides default | | `fetchInit` | `Omit` | Raw fetch options for advanced use | **Returns:** Parsed response body (or `undefined` for empty bodies). ```ts const created = await courier.request('/users', { method: 'POST', body: { name: 'Ada' }, }); ``` ### `get()` ```ts get(path: P, config?: GetRequestConfig): Promise; ``` Convenience for `request(path, { ...config, method: 'GET' })`. A `cache` descriptor opts a parsed response into the bounded cache. It requires a structured key and may override the default TTL. Raw responses cannot be cached. ```ts const user = await courier.get('/users/{id}', { cache: { key: ['users', 1], ttlMs: 10_000 }, params: { id: 1 }, }); ``` ## Cache ### `prefetch()` ```ts prefetch(path: P, config: PrefetchConfig): Promise; ``` Starts the same cached GET used by `get()`. `config.cache` is required; `signal`, per-call `timeout`, and `responseType: 'raw'` are unavailable. Fresh values and pending loads are reused. The promise settles when the attempt finishes. Request failures emit `request-error` through `tap()` and resolve without caching data. ### `invalidateCache()` and `clearCache()` ```ts invalidateCache(prefix: CourierCacheKey): void; clearCache(): void; ``` `invalidateCache()` removes every settled entry whose structured key starts with `prefix` and prevents matching pending loads from being stored. `clearCache()` applies that behavior to all entries. Existing waiters may still receive an invalidated in-flight result; `cancelAll()` or `dispose()` aborts physical work. --- ## Middleware Middleware is an immutable chain configured at construction. Each middleware receives an immutable `FetchContext` and a `next` function. ```ts type Middleware = (ctx: FetchContext, next: (ctx: FetchContext) => Promise) => Promise; ``` ### Middleware helpers ```ts withBearerAuth(token: string | (() => string | null | undefined | Promise)): Middleware; withRequestId(options?: { generate?: () => string; header?: string }): Middleware; withLogging(options: { logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void; }): Middleware; ``` Each helper returns a `Middleware` passed in the `middleware` option. `withLogging` requires an explicit `logger` function, isolates logger failures from requests, and has no default console output. ```ts const courier = createCourier({ middleware: [ withBearerAuth(() => tokenStore.getAccessToken()), withRequestId(), withLogging({ logger: (msg) => console.log(msg) }), ], }); ``` ## Types ```ts type TransportOptions = { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; middleware?: readonly Middleware[]; timeout?: number; }; type CourierCacheKeyAtom = string | number | boolean | null; type CourierCacheKey = readonly [CourierCacheKeyAtom, ...CourierCacheKeyAtom[]]; type CourierCacheOptions = { capacity?: number; ttlMs?: number; }; type CourierReadCache = { key: CourierCacheKey; ttlMs?: number; }; type CourierOptions = TransportOptions & { cache?: CourierCacheOptions; }; type FetchContext = { readonly headers: Readonly>; readonly init: Readonly>; readonly url: string; withHeaders(updates: Record): FetchContext; withInit(updates: Partial>): FetchContext; }; type Middleware = (ctx: FetchContext, next: (ctx: FetchContext) => Promise) => Promise; type CourierEvent = | { type: 'request-start'; method: string; url: string } | { type: 'request-success'; method: string; url: string; status: number; duration: number } | { type: 'request-error'; method: string; url: string; error: unknown } | { type: 'dispose' }; ``` ```ts type ParamValue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined; type Params = Record; type ResponseType = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'raw'; type RequestConfig = { body?: unknown; method?: string; fetchInit?: Omit; headers?: HeadersInit; params?: Record; query?: Params; responseType?: ResponseType; schema?: { parse(data: unknown): T }; signal?: AbortSignal; timeout?: number; }; type GetRequestConfig = Omit, 'body' | 'method' | 'responseType' > & ({ cache?: CourierReadCache; responseType?: Exclude } | { cache?: never; responseType: 'raw' }) & { body?: never; }; type PrefetchConfig = Omit, 'cache' | 'responseType' | 'signal' | 'timeout' > & { cache: CourierReadCache; responseType?: Exclude; signal?: never; timeout?: never; }; ``` ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `CourierError` | Base class for all Courier errors | Use `instanceof` to narrow | | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`; `CourierHttpError.is(e, status?)` narrows by status | | `CourierNetworkError` | Request failure without response | `method`, `url`, `cause` | | `CourierTimeoutError` | Timeout signal aborts request | `method`, `url`, `cause` | | `CourierAbortError` | Caller or client cancellation | `method`, `url`, `cause` | | `CourierSchemaValidationError` | Response schema fails | `data`, `cause` | | `CourierParseError` | Response body cannot parse | N/A | | `CourierConfigError` | Invalid request config (GET/HEAD body, raw + schema) | N/A | | `CourierDisposedError` | Work starts after disposal | N/A | Errors thrown directly by custom middleware are preserved rather than reclassified. ### Usage Guide ## Basic Usage Create one Courier client for an application or request scope. Its transport policy, middleware, and disposal lifecycle apply to every request. ```ts import { createCourier } from '@vielzeug/courier'; type User = { id: number; name: string }; const courier = createCourier({ baseUrl: 'https://api.example.com' }); const user = await courier.get('/users/{id}', { params: { id: 1 } }); console.log(user.name); ``` ## HTTP Requests Use `get()`, `post()`, `put()`, `patch()`, and `delete()` for standard methods. Use `request()` when the method is custom or selected dynamically. Courier preserves root-relative paths, encodes path parameters, serializes JSON-compatible bodies, and parses successful responses. ```ts const posts = await courier.get('/users/{id}/posts', { params: { id: 1 }, query: { limit: 20, status: 'published' }, }); await courier.patch('/posts/{id}', { body: { title: 'Updated title' }, params: { id: posts[0].id }, }); ``` `get()`, `post()`, `put()`, `patch()`, and `delete()` cover common methods. Use `request()` for custom verbs or when a method is selected dynamically. ## Default Headers Set default headers at construction. They are merged (lowercased) into every request and cannot be mutated at runtime: middleware handles dynamic headers. ```ts const courier = createCourier({ baseUrl: 'https://api.example.com', headers: { 'x-client': 'web' }, }); ``` ## Middleware Middleware is an immutable chain configured once at construction. Each middleware receives an immutable `FetchContext` and a `next` function. Use `ctx.withHeaders()` or `ctx.withInit()` to derive a new context: never mutate `ctx` directly. ```ts import { withBearerAuth, withRequestId, createCourier } from '@vielzeug/courier'; const courier = createCourier({ baseUrl: 'https://api.example.com', middleware: [ withBearerAuth(() => sessionStorage.getItem('access-token') ?? ''), withRequestId(), ], }); ``` A bearer token provider may return `null`, `undefined`, or an empty string to omit a stale authorization header. Use `withLogging()` with an explicit `logger` function to log requests during local development. `withLogging()` includes full URLs, so sanitize query values before persistent logging. ```ts import { withLogging, createCourier } from '@vielzeug/courier'; const courier = createCourier({ baseUrl: 'https://api.example.com', middleware: [withLogging({ logger: (msg) => console.log(msg) })], }); ``` Use `tap()` for multiple structured transport observers. It emits request-start, request-success, request-error, and dispose events; observer failures cannot alter requests. Pass `{ signal }` to own listener lifetime. ```ts const stop = courier.tap((event) => metrics.record(event), { signal: routeSignal }); ``` Because middleware is immutable, a client's behavior is locally understandable from its construction call. To change policy, create a new client. ## Cached Reads and Prefetching Opt parsed GETs into caching with a structured key. Courier uses a finite capacity of 100 entries and a 30-second TTL by default; client options change those defaults, and each read may override TTL. Successful parsed and schema-validated values are cached, while errors are not. ```ts const courier = createCourier({ baseUrl: 'https://api.example.com', cache: { capacity: 200, ttlMs: 60_000 }, }); const cache = { key: ['accounts', accountId, 'users', userId] as const, ttlMs: 10_000 }; await courier.prefetch('/users/{id}', { cache, params: { id: userId } }); const user = await courier.get('/users/{id}', { cache, params: { id: userId } }); ``` `prefetch()` returns a promise that settles when warming finishes. Request failures are not cached or rejected from that promise; the physical request emits `request-error` through `tap()`. Cache, key, and URL configuration errors still throw before prefetch starts. Invalidate a structured prefix after writes. Every matching settled or pending key becomes ineligible for caching, while unrelated entries remain available. ```ts await courier.patch('/users/{id}', { body: update, params: { id: userId } }); courier.invalidateCache(['accounts', accountId, 'users']); ``` A cache key must identify one response representation, including a stable non-secret principal or tenant identifier, schema, and relevant request variants. Never place credentials in a key; call `clearCache()` when identity changes. Cached objects are shared by reference. Concurrent callers share physical work but own their own signal and timeout. One cancelled caller does not affect the others. When every caller cancels and no prefetch owns the load, Courier aborts the physical request. `cancelAll()` always aborts physical work; `dispose()` also clears settled cache data. ## Response Parsing Courier parses successful 2xx response bodies automatically based on `content-type`, unless you set `responseType` explicitly (`'json'`, `'text'`, `'blob'`, `'arrayBuffer'`, or `'raw'` for the `Response` itself). Empty JSON bodies return `undefined` instead of throwing. Raw response bodies remain owned by Courier until consumed or cancelled, so `cancelAll()` and `dispose()` still abort them. Raw responses cannot be combined with `schema`. Always consume or cancel the body to release request ownership, and set `timeout: Infinity` for intentionally long-lived streams. Courier preserves URL, redirect, and type metadata on tracked responses and their clones. ```ts const buffer = await courier.request('/files/{id}', { method: 'GET', params: { id: 1 }, responseType: 'arrayBuffer', }); ``` ## Schema Validation Pass a `schema` with a `parse(data)` method to validate the parsed body. Any validator works: a `@vielzeug/spell` schema or a plain function wrapper. A failed parse throws `CourierSchemaValidationError`. ```ts import { s } from '@vielzeug/spell'; const user = await courier.get('/users/1', { schema: s.object({ id: s.number(), name: s.string() }), }); ``` ## Cancellation and Disposal Pass a `signal` to cancel one request. `cancelAll()` aborts every active request without disposing the client. `dispose()` aborts active work and marks the client unusable: call it only at the final application or request boundary. ```ts const controller = new AbortController(); void courier.get('/slow', { signal: controller.signal }); controller.abort(); // cancels that request only courier.cancelAll(); // cancels all active requests courier.dispose(); // final teardown ``` ## Timeouts Set a client-wide `timeout` (default 30,000 ms) or a per-request timeout. Finite values must be integer milliseconds from 1 through 2,147,483,647. A timed-out request rejects with `CourierTimeoutError`; use `Infinity` for intentionally long-lived work. ```ts const courier = createCourier({ timeout: 10_000 }); await courier.get('/health', { timeout: 2_000 }); ``` ## Error Handling Courier classifies URL, request-construction, fetch, cancellation, HTTP, response-parsing, and schema failures into distinct error classes. Errors thrown directly by custom middleware are preserved. ```ts import { CourierAbortError, CourierHttpError, CourierNetworkError, CourierParseError, CourierTimeoutError, createCourier, } from '@vielzeug/courier'; const courier = createCourier({ baseUrl: 'https://api.example.com' }); async function loadUser(): Promise { try { return (await courier.get('/users/1', { timeout: 2_000 })).name; } catch (error) { if (error instanceof CourierAbortError) return 'Cancelled'; if (error instanceof CourierTimeoutError) return 'Timed out; retry.'; if (CourierHttpError.is(error, 404)) return 'User not found.'; if (error instanceof CourierNetworkError) return 'Check your connection.'; if (error instanceof CourierParseError) return 'The server returned an unreadable response.'; throw error; } } ``` - A `CourierHttpError` has a response (`status`, `data`, `headers`); a `CourierNetworkError` does not. - Do not display cancellation as an application error during navigation. - `CourierSchemaValidationError` carries the raw pre-validation `data`. ## Composing with a State Layer Courier owns HTTP transport and optional parsed GET caching. Observable query state, optimistic mutations, collection navigation, and stream state belong to the owning state layer (e.g. Sourcerer, TanStack Query, or consumer code). Compose Courier calls into that layer instead of nesting state machines. ```ts import { createPageSource } from '@vielzeug/sourcerer'; const users = createPageSource({ load: async ({ page, pageSize, signal }) => { const result = await courier.get('/users', { query: { page, pageSize }, signal, }); return { items: result.data, totalItems: result.total }; }, }); await users.reload(); ``` ## Framework Integration Create Courier at an application or route boundary. Inject the client where requests are issued; let the framework own rendering and cache state. ```tsx [React] import { createContext, useContext } from 'react'; import { createCourier } from '@vielzeug/courier'; type User = { id: number; name: string }; type CourierClient = ReturnType; const CourierContext = createContext(null); export function CourierProvider({ children, courier }: { children: React.ReactNode; courier: CourierClient }) { return {children}; } export function useCourier() { const courier = useContext(CourierContext); if (!courier) throw new Error('CourierProvider missing'); return courier; } // In a component: // const user = await useCourier().get('/users/1'); ``` ```ts [Vue 3] import { createCourier } from '@vielzeug/courier'; const courier = createCourier({ baseUrl: '/api' }); // Provide via app.provide('courier', courier) and inject in setup(). ``` ```svelte [Svelte] import { createCourier } from '@vielzeug/courier'; export let courier: ReturnType; ``` Courier exposes no loading, error, or cache store. Construct the client at the application or SSR-request boundary, pass it into framework providers, and dispose it at that same boundary. Render state in the framework that owns the view. ## Gotchas ### Root-relative paths stay root-relative With no `baseUrl`, `'/api/users'` is passed to fetch unchanged while `'api/users'` remains document-relative. With a `baseUrl`, Courier appends either spelling to that base. ### `baseUrl` should not include query parameters `buildUrl` joins `baseUrl` and path with `/`. A base URL like `https://api.example.com?token=abc` produces broken URLs (`https://api.example.com?token=abc/users`). Pass query parameters per-request via `config.query` instead. ### Query parameters precede fragments When a path already contains `#fragment`, Courier inserts `config.query` before the fragment so values remain part of the HTTP query. ### GET and HEAD bodies are rejected Fetch does not support bodies for GET or HEAD. Courier rejects them as request parse errors before dispatch. ### Middleware is immutable There is no `use()` method. To change middleware, create a new client. This keeps each client's behavior locally understandable and avoids runtime mutation of the request chain. ### Keep credentials out of URLs when using logging middleware `withLogging()` logs the full URL, including query parameters. If URLs may contain sensitive data, provide a custom `logger` that sanitizes the URL first. ## Best Practices - Create one Courier client per application or SSR request scope. - Configure middleware once at construction; never mutate it at runtime. - Use method conveniences for GET/POST/PUT/PATCH/DELETE and `request()` for custom verbs. - Use explicit cache keys only for parsed GETs that should share data and in-flight work. - Clear cached data when the authenticated principal or tenant changes. - Let the owning state layer own observable queries and mutations. - Dispose only at the final application or request boundary. - Keep credentials out of URLs when using logging middleware. ### Examples ## Examples - [Authentication](./examples/authentication.md) - [CRUD Operations](./examples/crud-operations.md) - [Disposal](./examples/disposal.md) - [Error Handling Patterns](./examples/error-handling-patterns.md) - [File Uploads](./examples/file-uploads.md) - [Optimistic Updates](./examples/optimistic-updates.md) - [SSE Events](./examples/sse-events.md) ### REPL Examples - Cached Read and Prefetch (id: `cached-read`) - createCourier - Transport Client (id: `create-courier`) - middleware - Immutable Chain (id: `middleware`) --- ## @vielzeug/dnd **Category:** ui-interaction ### 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/drop'; 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 | | | | | Touch support | Scoped opt-in | | | | Zero third-party dependencies | | | | **Use Dnd when** an item is picked up and dropped: file drop zones, sortable collections, keyboard reordering, or connected lists. **Use Gesture instead** when a surface follows free or axis-locked pointer movement and application code owns rendering and completion, such as direct manipulation, swipe reveal, carousel navigation, or drawer dismissal. **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 } from '@vielzeug/dnd/drop'; import { createSortable } from '@vielzeug/dnd/sortable'; // File drop zone: with async validation and paste support const dropzone = document.getElementById('dropzone')!; using zone = createDropZone({ element: dropzone, accept: ['image/*', '.pdf'], paste: true, onValidate: (files) => files.every((file) => file.size console.log('Upload', files), onDropRejected: (files) => { console.warn(`${files.length} file(s) rejected`); }, onHoverChange: (hovered) => { dropzone.classList.toggle('drag-over', hovered); }, }); // Sortable list: with application-owned rollback for optimistic updates let currentOrder = ['a', 'b', 'c']; const history: Array = []; 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: ({ before, after }) => { history.push({ before }); currentOrder = after; }, }); ``` ## 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 cancellable async step after type filtering; `zone.validating` remains `true` until every pending validation settles - **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` - **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; pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) for lifecycle-owned FLIP animation - **Application-owned rollback**: `onReorder` emits `{ before, after, item }` so application history can own undo/rollback instead of the drag controller - **Boundary-safe keyboard reordering**: arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally - **Transactional connected scopes**: one `onMove` callback receives each cross-list transfer with both final orders - **Scoped touch support**: `createSortableScope({ touch: true })` handles only items registered to that scope and uses an inert outline preview - **Explicit DOM refresh**: call `sortable.refresh()` after DOM mutations, or provide an `items()` provider for explicit item ownership - **`[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` is normalized and snapshotted at construction; recreate the zone to change accepted types - **Shared pointer recognition**: touch sorting uses `@vielzeug/gesture`; Dnd retains previews, hit-testing, drag events, and sortable transactions ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Orbit](/orbit/): floating element positioning; use alongside Dnd to anchor drag previews and drop-zone indicators to precise positions - [Gesture](/gesture/): pointer recognition used by Dnd touch sorting and directly by swipe, reveal, carousel, and drawer interactions - [Ore](/ore/): web-component authoring framework for application-owned draggable and sortable surfaces - [Refine](/refine/): accessible web components; Dnd powers Refine's file-drop input behavior ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- | | `createDropZone()` | Create a typed drop-zone controller | Sync | Dispose 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 | | `DndError` | Base class for Dnd errors | Sync | Use `instanceof DndError` to narrow unknown errors | ## Package Entry Point | Import | Purpose | | ------------------- | -------------------------------- | | `@vielzeug/dnd` | Backward-compat root barrel | | `@vielzeug/dnd/drop` | Drop-zone APIs plus `DndError` and `Disposable` | | `@vielzeug/dnd/sortable` | Sortable APIs plus `DndError`, `DndScopeError`, and `Disposable` | Dnd has no third-party runtime dependencies. Its sortable touch adapter uses `@vielzeug/gesture` for shared pointer recognition. ## Types ### `Disposable` ```ts interface Disposable { readonly disposed: boolean; readonly disposalSignal: AbortSignal; dispose(): void; [Symbol.dispose](): void; } ``` ### `DropZoneOptions` ```ts interface DropZoneOptions { element: HTMLElement; accept?: readonly string[]; maxFiles?: number; onValidate?: (files: File[], context: DropValidationContext) => 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; } ``` ### `DropValidationContext` ```ts interface DropValidationContext { readonly signal: AbortSignal; } ``` ### `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; onInteraction?: (event: SortableInteractionEvent) => void; onBeforeReorder?: (from: readonly string[], to: readonly string[]) => void; onReorder?: (event: ReorderEvent) => void; items?: () => readonly HTMLElement[]; } ``` ### `AutoScrollOptions` ```ts interface AutoScrollOptions { edgeThreshold?: number; speed?: number; container?: boolean; viewport?: boolean; } ``` ### `ReorderEvent` ```ts interface ReorderEvent { before: readonly string[]; after: readonly string[]; item: string; } ``` ### `Sortable` ```ts interface Sortable extends Disposable { readonly isDragging: boolean; refresh(): void; } ``` ### `SortableScope` ```ts interface SortableScope extends Disposable { readonly isDragging: boolean; } ``` ### `SortableScopeOptions` ```ts interface SortableScopeOptions { onMove?: (event: SortableMoveEvent) => void; touch?: boolean | SortableTouchOptions; } ``` ### `SortableMoveEvent` ```ts interface SortableMoveEvent { readonly itemId: string; readonly source: HTMLElement; readonly sourceBeforeIds: readonly string[]; readonly sourceIds: readonly string[]; readonly target: HTMLElement; readonly targetBeforeIds: readonly string[]; readonly targetIds: readonly string[]; } ``` ### `SortableInteractionEvent` Structured accessibility/observability event for sortable interactions. Drag emits `pickup` on dragstart, `drop` on commit, `cancel` on cancel. Keyboard emits `move` on each successful arrow/Home/End reorder (direct-commit model). ```ts type SortableInteractionEvent = | { readonly type: 'pickup'; readonly itemId: string; readonly index: number; readonly total: number } | { readonly type: 'move'; readonly itemId: string; readonly previousIndex: number; readonly index: number; readonly total: number; } | { readonly type: 'drop'; readonly itemId: string; readonly index: number; readonly total: number } | { readonly type: 'cancel'; readonly itemId: string; readonly index: number; readonly total: number }; ``` Wire a consumer-side announcer to provide screen-reader feedback: ```ts import { createSortable } from '@vielzeug/dnd/sortable'; createSortable({ element: listEl, getKey: (el) => el.dataset.id!, onInteraction(event) { switch (event.type) { case 'pickup': announce(`Picked up ${event.itemId}, position ${event.index + 1} of ${event.total}`); break; case 'move': announce(`Moved ${event.itemId} to position ${event.index + 1} of ${event.total}`); break; case 'drop': announce(`Dropped ${event.itemId} at position ${event.index + 1} of ${event.total}`); break; case 'cancel': announce(`Cancelled, ${event.itemId} returned to position ${event.index + 1} of ${event.total}`); break; } }, }); ``` ### `SortableTouchOptions` ```ts type SortableTouchOptions = Readonly HTMLElement | null); }> ``` `activationDistance` is the non-negative finite movement threshold in pixels before touch sorting starts (default `6`). Invalid values throw `DndError`. `preview` returns a template that Dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller-owned DOM. Return `false` to disable the preview. Touch sorting delegates one-pointer activation, movement, identity, and cancellation to Gesture's `createDragGesture()` with pointer capture disabled for document hit-testing. Dnd retains preview rendering, hit-testing, synthetic drag events, and sortable transactions. Non-primary pointers are ignored, and cancellation restores the pre-drag order without firing `onReorder`. ## `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` | N/A | **Required.** The element to attach drag listeners to. | | `accept` | `string[]` | `[]` | Accepted file types, normalized and snapshotted at construction. Empty array accepts everything; invalid MIME, wildcard, or extension patterns throw `DndError`. | | `maxFiles` | `number` | N/A | Non-negative safe-integer file limit. Excess files are passed to `onDropRejected`; invalid values throw `DndError` during construction. | | `onValidate` | `(files, { signal }) => boolean \| Promise` | N/A | Optional async gating step. Return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. | | `disabled` | `boolean` | N/A | 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` | N/A | 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` | N/A | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. | | `onHoverChange` | `(hovered: boolean) => void` | N/A | Called when hover state toggles. Use this callback for drag-over styling. | | `onValidatingChange` | `(validating: boolean) => void` | N/A | Called whenever the aggregate async validation state changes. | | `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `element`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. | | `onPaste` | `(files: File[]) => void` | N/A | 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. - Disposal aborts validation and transitions `validating` to `false` before teardown completes. - Consumer callback failures are reported through Dnd's development warning channel and never reclassify accepted files as rejected. ```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, aborts pending validation, and resets `hovered` and `validating`. 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` adds drag and keyboard defaults only when callers have not already supplied semantics. Every changed attribute and inline style is restored to its prior value on disposal. - `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`. Valid CSS selector for a drag handle inside each item. Invalid selectors throw `DndError`; an empty selector warns and disables dragging. - `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`, matched through `matchKey` from `@vielzeug/keymap`: modifier chords (e.g. Ctrl+ArrowDown, browser back/forward) are ignored. Events from interactive descendants are ignored unless they match the configured handle. - `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion. - `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls near edges; threshold must be finite and non-negative and speed finite and positive. - `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. - `onInteraction`: `(event: SortableInteractionEvent) => void`. Structured accessibility event for pickup/move/drop/cancel: wire to a consumer-side announcer for screen-reader feedback. See [`SortableInteractionEvent`](#sortableinteractionevent). - `onBeforeReorder`: `(from: readonly string[], to: readonly 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 [`captureLayout()`](/necromancer/api.md#capturelayout) setup. - `items`: `() => readonly HTMLElement[]`. Returns direct children owned by this sortable. `refresh()` restores elements no longer returned before marking the current set. - `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. The event carries `before`, `after`, and `item` so application history can own rollback. Order arrays and interaction events are frozen snapshots. Callback failures are reported through Dnd's development warning channel and cannot interrupt session completion or corrupt later events. Native touch scrolling is preserved unless the shared scope enables touch input. **Returns:** `Sortable` ```ts const boardScope = createSortableScope({ onMove: ({ itemId, sourceIds, targetIds }) => saveMove(itemId, sourceIds, targetIds), touch: true, }); 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: ({ before, after }) => { history.push({ revert: () => saveOrder(before) }); saveOrder(after); }, scope: boardScope, }); ``` ### `createSortableScope()` ```ts declare function createSortableScope(options?: SortableScopeOptions): SortableScope; ``` Use one scope per connected set of containers. `onMove` fires once for cross-list moves with both final orders; local reorders continue to call the sortable's `onReorder`. | Parameter | Type | Description | | --- | --- | --- | | `options` | `SortableScopeOptions` | Optional cross-list move callback and scope-owned touch configuration | **Returns:** `SortableScope`. ```ts import { createSortableScope } from '@vielzeug/dnd/sortable'; const scope = createSortableScope({ onMove: ({ itemId, sourceIds, targetIds }) => { persistMove(itemId, sourceIds, targetIds); }, touch: true, }); ``` ## `Sortable` Interface ### `sortable.isDragging` `readonly isDragging: boolean` `true` while an item drag is in progress. ### `sortable.refresh()` `refresh(): void` Re-reads items from the `items` provider (or the container's direct children) and reapplies `draggable`, `role`, and handle attributes. 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()`. ## `SortableScope` Interface ### `scope.isDragging` `readonly isDragging: boolean` `true` while any sortable registered to the scope is dragging. ### `scope.dispose()` `dispose(): void` Disposes scope-owned touch input and all registered sortables. Registering a new sortable with a disposed scope throws `DndScopeError`. ## 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`. Restored on `dispose()`. - `draggable`, roles, tabindex, and `touchAction`: managed only as needed and restored to their exact prior values on `dispose()`. - `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.refresh()`, 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. - `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.refresh()` on the item (or the handle, when `handle` is set), then restored on `dispose()`. ## 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: readonly 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. **Returns:** `true` when the file matches at least one pattern, or when `accept` is empty. ```ts import { matchesAccept } from '@vielzeug/dnd/drop'; matchesAccept(file, ['image/*', '.pdf']); // true or false ``` ## `applyReorder()` ```ts declare function applyReorder(items: readonly T[], ids: readonly 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. - Duplicate keys in `items` throw `DndError`; data is never silently discarded. **Returns:** A new array ordered by `ids`, with omitted items appended in their original order. ```ts const next = applyReorder(items, orderedIds, (item) => item.id); ``` ## Errors | Error | Trigger | Notable property | | --- | --- | --- | | `DndError` | Invalid configuration, duplicate item data, or duplicate sortable registration in one scope | Base class for package errors | | `DndScopeError` | A sortable receives an invalid or disposed scope | Extends `DndError` | ### 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/drop'; const dropzone = document.getElementById('dropzone')!; const zone = createDropZone({ element: dropzone, onDrop: (files) => { console.log('Accepted files:', 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 normalized and snapshotted at construction. MIME matching is case-insensitive; invalid patterns throw `DndError`. Recreate the zone to change accepted types. ### 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 }); ``` `maxFiles` must be a non-negative safe integer. ### Async validation Gate drops behind an async check with `onValidate`. The zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal. ```ts const zone = createDropZone({ element: dropEl, accept: ['image/*'], onValidate: async (files, { signal }) => { const ok = await checkServerQuota(files, { signal }); 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: ({ after }) => { saveTaskOrder(after); }, }); ``` 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: ({ after }) => saveOrder(after), }); ``` ### 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`. Arrow keys from buttons, links, form fields, and editable descendants remain native unless that descendant matches the configured drag handle. Wire `onInteraction` to an application live region for screen-reader announcements. ### Connected lists Create a shared scope when items should move between containers: ```ts const boardScope = createSortableScope({ onMove: ({ itemId, sourceIds, targetIds }) => { persistMove(itemId, sourceIds, targetIds); }, touch: true, }); createSortable({ element: todoEl, getKey: (el) => el.dataset.sortId!, scope: boardScope, }); createSortable({ element: doneEl, getKey: (el) => el.dataset.sortId!, 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: ({ after }) => saveOrder(after), }); ``` ### Custom identity function ```ts createSortable({ element: listEl, getKey: (el) => el.getAttribute('data-id')!, onReorder: ({ after }) => saveOrder(after), }); ``` ### Dynamic lists Call `sortable.refresh()` after adding, removing, or replacing sortable items. It restores Dnd-managed semantics on elements no longer returned by `items()` before marking the current direct children. ```ts const item = document.createElement('li'); item.dataset.sortId = 'task-4'; item.textContent = 'Deploy'; listEl.appendChild(item); sortable.refresh(); ``` ### Disabled state ```ts import { createSortable, type SortableOptions } from '@vielzeug/dnd/sortable'; const options: SortableOptions = { disabled: false, element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ after }) => saveOrder(after), }; 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/sortable'; 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: ({ after }) => { items = applyReorder(items, after, (item) => item.id); }, }); ``` ### Cleanup ```ts sortable.dispose(); // or: using sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ after }) => saveOrder(after), }); ``` ### FLIP animation hook `onBeforeReorder` fires just before the DOM reorder commits, for both drag and keyboard moves. Pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) to animate the resulting layout without managing rectangles, transforms, or animation frames yourself. ```ts import { captureLayout, type LayoutTransition } from '@vielzeug/necromancer'; let layout: LayoutTransition | undefined; const sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onBeforeReorder: () => { layout = captureLayout(listEl.querySelectorAll('[data-sort-id]'), { getKey: (el) => el.dataset.sortId!, }); }, onReorder: ({ after }) => { saveOrder(after); // Commit a framework render here when needed. layout?.animate({ duration: 200, easing: 'ease-out', elements: listEl.querySelectorAll('[data-sort-id]'), }); layout = undefined; }, }); ``` If `saveOrder()` triggers a render that replaces list items, call `layout?.animate({ elements: committedItems })` after that render commits. When DnD's own reordered elements remain in the DOM, call `layout?.animate()` directly. Dnd does not depend on Necromancer: the application installs it only when FLIP animation is required. Dnd's sole runtime dependency is Gesture for touch pointer recognition. ### Optimistic updates and rollback The `onReorder` event carries `before`, `after`, and `item`. Application history owns rollback: use `before` to record an undo entry. ```ts const history: Array = []; const sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.sortId!, onReorder: ({ before, after }) => { history.push({ before }); setOrder(after); // optimistic update }, }); // On server error: try { await api.saveOrder(currentOrder); } catch { const entry = history.pop(); if (entry) setOrder(entry.before); } ``` ## Touch Support The HTML Drag and Drop API has no reliable native touch path. Enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements. ```ts import { createSortable, createSortableScope } from '@vielzeug/dnd/sortable'; using scope = createSortableScope({ touch: true }); using sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.id!, scope }); ``` Gesture tracks the primary touch pointer for the active drag. Additional contacts cannot move, finish, or replace it. Pointer cancellation restores the original item order and removes the transient preview. ### Touch preview Touch uses an inert outline by default, avoiding cloned application DOM. Provide a preview factory or opt out when your item styling supplies its own feedback. ```ts const scope = createSortableScope({ touch: { activationDistance: 8, // The returned element is cloned before Dnd mounts it as a transient preview. preview: (item) => item.querySelector('.drag-preview'), }, }); ``` ### Why touch-enabled items get `touch-action: none` When a sortable scope enables touch input, `createSortable` sets `touch-action: none` on the managed item or handle. This prevents a mobile browser from claiming the gesture as page scrolling before the scope controller starts the drag. Sortables without touch input preserve native touch scrolling. ## Testing Test observable callbacks and controller state with your DOM test runner. Construct the zone in each test, dispatch a real `drop` event, then dispose it during teardown. ```ts import { afterEach, expect, it, vi } from 'vitest'; import { createDropZone } from '@vielzeug/dnd/drop'; const zones: Array = []; afterEach(() => zones.splice(0).forEach((zone) => zone.dispose())); it('forwards accepted files', async () => { const element = document.createElement('div'); const onDrop = vi.fn(); const zone = createDropZone({ element, onDrop }); zones.push(zone); const file = new File(['content'], 'readme.txt', { type: 'text/plain' }); const event = new Event('drop') as DragEvent; Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } }); element.dispatchEvent(event); await Promise.resolve(); expect(onDrop).toHaveBeenCalledWith([file]); expect(zone.disposed).toBe(false); }); ``` ## Framework Integration ```tsx [React] import { useEffect, useRef } from 'react'; import { createSortable, applyReorder } from '@vielzeug/dnd/sortable'; 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: ({ after }) => { items.current = applyReorder(items.current, after, (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/sortable'; 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: ({ after }) => { orderedItems.value = applyReorder(orderedItems.value, after, (i) => i.id); }, }); }); onUnmounted(() => sortable?.dispose()); return { listRef, orderedItems }; } ``` ```svelte [Svelte] import { onMount } from 'svelte'; import { createSortable, applyReorder } from '@vielzeug/dnd/sortable'; 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: ({ after }) => { items = applyReorder(items, after, (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/sortable'; import { define, getHost, html, onMounted } from '@vielzeug/ore'; define('task-list', { setup(_props) { const el = getHost(); onMounted(() => { const sortable = createSortable({ element: el, getKey: (el) => el.dataset.sortId!, onReorder: ({ after }) => save(after), }); return () => sortable.dispose(); }); return html``; }, }); ``` ### With Gesture Keep direct manipulation separate from drag-and-drop semantics. Use Gesture for one-axis surfaces that follow the pointer and let application code decide the outcome; use Dnd when an item enters a drag lifecycle and is dropped into a zone or reordered collection. Dnd touch sorting uses Gesture's two-dimensional pointer recognizer, then adds previews, hit-testing, synthetic drag events, and sortable transactions. Direct-manipulation consumers use Gesture without Dnd. ## 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, and guarantee unique backing keys; duplicates throw `DndError` rather than dropping data. - 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. - Enable `touch: true` only on scopes that own touch-sortable lists. ### Examples ## Examples - [Sortable List](./examples/sortable-list.md) - [Touch-Enabled Sortable List](./examples/touch-enabled-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-craft.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 ### Overview ## Why Familiar? Raw workers force every application to maintain its own message contract, lifecycle, cancellation, and pool scheduler. Familiar provides those boundaries while keeping worker code in normal typed ES modules. ```ts // Before const worker = new Worker(new URL('./sum.worker.ts', import.meta.url), { type: 'module' }); worker.postMessage([1, 2, 3]); // After const pool = createWorker(new URL('./sum.worker.ts', import.meta.url)); await pool.run([1, 2, 3]); ``` | Feature | Familiar | Raw Worker | Comlink | | --- | --- | --- | --- | | Bundle size | | built-in | ~2 kB | | Module-worker contract | | manual | | | Pool scheduling | | | | | AbortSignal cancellation | | manual | manual | | Versioned protocol | | | implementation-specific | | Zero dependencies | | | | **Use Familiar when** worker jobs need bounded concurrency, typed errors, cancellation, or queue policy. **Consider raw Worker when** one isolated worker and custom messaging are enough. ## Installation ```sh [pnpm] pnpm add @vielzeug/familiar ``` ```sh [npm] npm install @vielzeug/familiar ``` ```sh [yarn] yarn add @vielzeug/familiar ``` ## Quick Start Register task logic inside a worker module. ```ts // double.worker.ts import { exposeTask } from '@vielzeug/familiar/protocol'; exposeTask((value: number) => value * 2); ``` Create pool from module URL and dispose it after use. ```ts import { createWorker } from '@vielzeug/familiar'; const worker = createWorker(new URL('./double.worker.ts', import.meta.url)); try { console.log(await worker.run(21)); } finally { worker.dispose(); } ``` ## Features - `createWorker()`: versioned task protocol over ES module workers - `createStreamWorker()`: stream-only worker capability - `run()`: validated priority scheduling, transferables, timeout, and cancellation - `runBatch()`: ordered progressive results with shared fail-fast cancellation - `stats`: active, queued, completed, and failed counters - `createTestWorker()`: faithful in-process task-pool testing - `dispose()` and `drain()`: immediate or draining teardown, with `using` support - Application-owned promise composition: domain task groups without pool-owned orchestration state ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Ripple](/ripple/): expose worker results through reactive state. - [Herald](/herald/): publish application events after worker jobs settle. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createWorker()` | Create single-result module-worker pool | Sync | Worker must call `exposeTask()` | | `createStreamWorker()` | Create stream-only module-worker pool | Sync | Worker must call `exposeStream()` | | `runBatch()` | Yield ordered results with shared cancellation | Async iterable | Transferables need a per-input selector | | `createTestWorker()` | Create an in-process task-pool test double | Sync | Task modules are not executed | | `exposeTask()` | Register worker task handler | Sync | Worker-only import | | `exposeStream()` | Register worker stream handler | Sync | Worker-only import | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/familiar` | Pool factories, types, and errors | | `@vielzeug/familiar/protocol` | Versioned worker protocol and registration helpers | | `@vielzeug/familiar/testing` | Task-pool testing adapter | ## Pool Factories ### `createWorker()` ```ts function createWorker(url: URL | string, options?: WorkerOptions): WorkerPool; ``` Creates a task pool for a worker module registered with `exposeTask()`. | Parameter | Type | Description | | --- | --- | --- | | `url` | `URL \| string` | Module-worker URL, usually `new URL('./task.worker.ts', import.meta.url)` | | `options` | `WorkerOptions` | Pool concurrency, queue, timeout, and worker-error policy | **Returns:** `WorkerPool`. **Example:** ```ts import { createWorker } from '@vielzeug/familiar'; const pool = createWorker(new URL('./double.worker.ts', import.meta.url)); try { console.log(await pool.run(21)); } finally { pool.dispose(); } ``` ### `createStreamWorker()` ```ts function createStreamWorker(url: URL | string, options?: WorkerOptions): StreamWorkerPool; ``` Creates a stream-only pool for a worker module registered with `exposeStream()`. **Returns:** `StreamWorkerPool`. ### `runBatch()` ```ts function runBatch( pool: WorkerPool, inputs: readonly TInput[], options?: BatchOptions, ): AsyncIterable; ``` Starts related pool tasks concurrently and yields results in input order. The first failure aborts siblings, early iterator exit cancels unfinished work, and cleanup waits for every task to settle. The iterable is one-shot once consumption begins; an unused iterator may be discarded. Use `getTransferables(input, index)` to return a fresh transfer list for each task. A shared transfer list is intentionally unsupported because transferred values can be detached only once. **Returns:** `AsyncIterable`. ## Testing ### `createTestWorker()` ```ts function createTestWorker( handler: (input: TInput) => TOutput | Promise, options?: TestWorkerOptions, ): TestWorkerHandle; ``` Creates an in-process task-pool double. It structured-clones values, records settlement, and matches pool timeout and cancellation results without loading a worker module. Cancellation rejects the test task but cannot stop side effects inside an already-running in-process handler. **Returns:** `TestWorkerHandle`. ## Worker Protocol ### `exposeTask()` ```ts function exposeTask(handler: TaskHandler): void; ``` Registers one single-result handler in a module worker. ### `exposeStream()` ```ts function exposeStream(handler: StreamHandler): void; ``` Registers one chunk-producing handler in a module worker. ### `PROTOCOL_VERSION` ```ts const PROTOCOL_VERSION: 1; ``` Version included in every host request and worker response. Identifiers must be non-negative safe integers. Task/stream capability mismatches return a protocol-category error that maps to `FamiliarRuntimeError`; malformed host responses reject active work with the same class. ## Types ### `BatchOptions` ```ts type BatchOptions = Pick & { getTransferables?: (input: TInput, index: number) => readonly Transferable[]; }; ``` ### `WorkerOptions` ```ts type WorkerOptions = { concurrency?: number | 'auto'; maxQueue?: number; onFull?: 'reject' | 'wait'; timeout?: number; }; ``` `concurrency` is limited to 512; `"auto"` clamps the reported hardware concurrency to that limit. `maxQueue` must be a positive integer and `onFull` must be `"reject"` or `"wait"`. Timeout values must be integer milliseconds from 1 through 2,147,483,647. Unhandled worker runtime errors are reported through `tap()`; a throwing tap handler cannot interrupt slot replacement. ### `RunOptions` ```ts type RunOptions = { priority?: number; signal?: AbortSignal; timeout?: number; transferables?: readonly Transferable[]; }; ``` `signal` cancels capacity waits, queued work, and executing work. Executing cancellation terminates and replaces its worker slot without incrementing `failed`. `priority` must be finite and timeout values use the same bounds as pool defaults. The transfer list is copied at submission. Queued inputs remain caller-owned until a slot dispatches them, so do not mutate input objects while work is queued. ### `WorkerPool` ```ts interface WorkerPool { [Symbol.asyncDispose](): Promise; [Symbol.dispose](): void; run(input: TInput, options?: RunOptions): Promise; drain(options?: DrainOptions): Promise; dispose(): void; tap(handler: (event: FamiliarTapEvent) => void, options?: { signal?: AbortSignal }): () => void; readonly stats: WorkerStats; readonly status: WorkerStatus; readonly disposed: boolean; readonly disposalSignal: AbortSignal; } ``` ### `StreamWorkerPool` ```ts interface StreamWorkerPool { [Symbol.asyncDispose](): Promise; [Symbol.dispose](): void; runStream(input: TInput, options?: RunOptions): AsyncIterable; drain(options?: DrainOptions): Promise; dispose(): void; tap(handler: (event: FamiliarTapEvent) => void, options?: { signal?: AbortSignal }): () => void; readonly disposed: boolean; readonly disposalSignal: AbortSignal; readonly stats: WorkerStats; readonly status: WorkerStatus; } ``` `runStream()` returns a one-shot iterable. Consumption begins on the first iterator operation, so an unused iterator can be discarded. A second active iterator rejects with `FamiliarRuntimeError`, and `return()` immediately cancels a pending chunk request. Caller abort listeners are owned by the active iterator rather than an unconsumed iterable. Incoming chunks are buffered without protocol backpressure. ### `WorkerStats` ```ts type WorkerStats = { readonly active: number; readonly completed: number; readonly failed: number; readonly queued: number; }; ``` `queued` includes admitted queue entries and calls waiting for queue capacity. Cancellation is not counted as failure even when a caller supplies a custom abort reason. ### `FamiliarTapEvent` ```ts type FamiliarTapEvent = | { error: FamiliarRuntimeError; type: 'worker-error' } | { type: 'dispose' }; ``` `tap()` observes the pool: `worker-error` fires when a slot fails with an unhandled runtime error (the slot is replaced regardless), and `dispose` fires once when the pool terminates. Handler errors are swallowed; `tap()` returns an unsubscribe function and honors an optional `signal`. ### `WorkerStatus` ```ts type WorkerStatus = 'idle' | 'running' | 'terminated'; ``` ### `DrainOptions` ```ts type DrainOptions = { timeout?: number; }; ``` `drain()` accepts no argument for an unbounded graceful drain. A supplied timeout must be an integer from 1 through 2,147,483,647; expiry disposes the pool and rejects with `FamiliarTimeoutError`. ### `TestWorkerOptions` ```ts type TestWorkerOptions = Omit & { concurrency?: number; }; ``` ### `TestWorkerCall` ```ts type TestWorkerCall = | { input: TInput; status: 'fulfilled'; value: TOutput } | { input: TInput; reason: unknown; status: 'rejected' }; ``` ### `TestWorkerHandle` ```ts type TestWorkerHandle = WorkerPool & { readonly calls: ReadonlyArray>; }; ``` ### `SerializedError` ```ts type SerializedError = { category?: 'protocol'; message: string; name: string; stack?: string; }; ``` ### `WorkerRequest` ```ts type WorkerRequest = | { id: number; input: TInput; kind: 'run'; version: 1 } | { id: number; input: TInput; kind: 'stream'; version: 1 }; ``` ### `WorkerResponse` ```ts type WorkerResponse = | { id: number; kind: 'chunk'; value: TOutput; version: 1 } | { error: SerializedError; id: number; kind: 'error'; version: 1 } | { id: number; kind: 'result'; value: TOutput; version: 1 }; ``` ### `TaskHandler` and `StreamHandler` ```ts type TaskHandler = (input: TInput) => TOutput | Promise; type StreamHandler = (input: TInput) => AsyncIterable | Promise>; ``` ## Errors | Error | Trigger | Notable property | | --- | --- | --- | | `FamiliarError` | Base class for all Familiar errors | Use `instanceof FamiliarError` to narrow | | `FamiliarInvalidOptionsError` | Invalid factory or test options | N/A | | `FamiliarQueueFullError` | Queue limit reached with `onFull: 'reject'` | `maxQueue` | | `FamiliarTaskError` | Worker handler throws or payload cannot clone | `cause` | | `FamiliarTimeoutError` | Task or drain deadline expires | `timeoutMs` | | `FamiliarTerminatedError` | Pool is disposed or draining | N/A | | `FamiliarRuntimeError` | Worker API or worker process fails | `cause` | ### Usage Guide ## Basic Usage Put task logic in a worker module. Imports and helpers stay normal module code. ```ts // normalize.worker.ts import { exposeTask } from '@vielzeug/familiar/protocol'; import { normalize } from './normalize'; exposeTask((text: string) => normalize(text)); ``` Create one long-lived pool at its owner boundary. ```ts import { createWorker } from '@vielzeug/familiar'; const pool = createWorker(new URL('./normalize.worker.ts', import.meta.url), { concurrency: 2, timeout: 2_000, }); try { const normalized = await pool.run(' Familiar '); console.log(normalized); } finally { pool.dispose(); } ``` ## Cancellation and Timeouts Pass one signal to stop capacity waits, queued work, or active work. Cancelling active work terminates and lazily replaces its slot without incrementing `stats.failed`, regardless of the abort reason. Timeouts must be integer milliseconds from 1 through 2,147,483,647. ```ts const controller = new AbortController(); const result = pool.run('input', { signal: controller.signal, timeout: 500 }); controller.abort(); await result.catch((error) => console.log(error.name)); // AbortError ``` ## Queue Policy and Priority Use `maxQueue` to bound the admitted queue. Higher priorities dispatch first once a slot opens. ```ts const pool = createWorker(new URL('./job.worker.ts', import.meta.url), { concurrency: 2, maxQueue: 100, onFull: 'wait', }); await pool.run(criticalJob, { priority: 10 }); ``` With `onFull: 'wait'`, overflow calls wait for admission outside the bounded queue. `stats.queued` includes both admitted tasks and capacity waiters, and priority applies to both. ## Batch Composition Use `runBatch()` for ordered progressive results with one shared cancellation lifetime. It starts pool work concurrently, cancels siblings on the first failure, and cancels unfinished work when iteration stops early. ```ts import { runBatch } from '@vielzeug/familiar'; for await (const result of runBatch(pool, inputs, { signal, timeout: 5_000 })) { consume(result); } ``` Use `getTransferables(input, index)` when each task needs its own transfer list. See [Cancellable Batch](./examples/cancellable-batch.md). ## Streaming Stream workers have their own capability and registration helper. ```ts // tokenize.worker.ts import { exposeStream } from '@vielzeug/familiar/protocol'; exposeStream(async function* (text: string) { for (const token of text.split(/\s+/)) yield token; }); ``` ```ts import { createStreamWorker } from '@vielzeug/familiar'; const pool = createStreamWorker(new URL('./tokenize.worker.ts', import.meta.url)); for await (const token of pool.runStream('typed module workers')) { console.log(token); } pool.dispose(); ``` Each value returned by `runStream()` is one-shot once an iterator operation begins. Unused iterators can be discarded, and `return()` immediately cancels a pending chunk request. Abort listeners are attached when iteration begins and removed when it settles. Worker messages do not provide backpressure, so keep chunks and production rates bounded when consumers may be slow. ## Testing Use `createTestWorker()` when testing consumer code that depends on a task pool. It clones input/output, wraps task failures, and honors cancellation and timeout behavior. ```ts import { createTestWorker } from '@vielzeug/familiar/testing'; const pool = createTestWorker((value: number) => value * 2); await expect(pool.run(21)).resolves.toBe(42); expect(pool.calls).toEqual([{ input: 21, status: 'fulfilled', value: 42 }]); pool.dispose(); ``` Test worker-module business logic directly when possible. `createTestWorker()` does not run module files or support stream pools. Cancellation rejects an active test task but cannot stop side effects in an already-running in-process handler. ## Framework Integration Create a pool at a stable owner boundary. Abort obsolete requests during component cleanup and dispose the pool at the same boundary that created it. ```tsx [React] import { useEffect } from 'react'; import type { WorkerPool } from '@vielzeug/familiar'; function SortedView({ pool }: { pool: WorkerPool }) { useEffect(() => { const controller = new AbortController(); void pool.run(input, { signal: controller.signal }).then(renderOutput, (error: unknown) => { if (!(error instanceof DOMException && error.name === 'AbortError')) reportError(error); }); return () => controller.abort(); }, [input, pool]); return null; } ``` Create and dispose the injected React pool at the application boundary, outside Strict Mode effect replay. ```ts [Vue] import { onUnmounted } from 'vue'; import { createWorker } from '@vielzeug/familiar'; const pool = createWorker(new URL('./sort.worker.ts', import.meta.url)); onUnmounted(() => pool.dispose()); ``` ```ts [Svelte] import { onDestroy } from 'svelte'; import { createWorker } from '@vielzeug/familiar'; const pool = createWorker(new URL('./sort.worker.ts', import.meta.url)); onDestroy(() => pool.dispose()); ``` ## Working with Other Vielzeug Libraries Keep worker module protocol registration in `@vielzeug/familiar/protocol`. ## Best Practices - Put every task handler in its own module-worker boundary. - Reuse pools for repeated work; dispose owner-scoped pools. - Abort work made obsolete by navigation or newer input. - Transfer large binary buffers instead of cloning them. - Treat submitted inputs as immutable until dispatch; queued inputs are structured-cloned only when a worker slot becomes available. - Set explicit timeouts for work with a bounded latency budget. - Keep worker handlers deterministic and data-only. - Test module logic directly; test pool consumers with `createTestWorker()`. ### 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 Stream Worker](./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) ### REPL Examples - Familiar Error Contracts (id: `error-contracts`) --- ## @vielzeug/flux **Category:** reactive ### Overview ## Why Flux? Use Flux when an API pushes many values over time and consumers need independent cancellation. Streams describe reusable work; subscriptions own cleanup. Explicit queue capacity keeps async iteration from silently growing memory. ```ts // Before const controller = new AbortController(); const render = (value: string) => console.log(value); const handler = (event: Event) => render((event.target as HTMLInputElement).value); input.addEventListener('input', handler); setTimeout(() => controller.abort(), 5_000); // After import { fromEvent, map, pipe, takeUntil } from '@vielzeug/flux'; const updates = pipe( fromEvent(input, 'input'), map((event) => (event.target as HTMLInputElement).value), takeUntil(controller.signal), ); updates.subscribe({ error: console.error, next: render }); ``` | Feature | Flux | RxJS | TC39 Observable | | --- | --- | --- | --- | | Bundle size | | Varies by imported operators | Native proposal / polyfill | | Runtime dependencies | | | | | Subscription-owned cancellation | | | | | Explicit async queue policy | | Operator-dependent | No standard policy | | Vielzeug adapters | Ripple, Courier, Herald, Pulse | Manual adapters | Manual adapters | **Use Flux when** you need a small TypeScript stream primitive, explicit cancellation, and first-party Vielzeug adapters. **Consider RxJS when** you need its larger operator catalog or third-party Observable integrations. ## Installation ```sh [pnpm] pnpm add @vielzeug/flux ``` ```sh [npm] npm install @vielzeug/flux ``` ```sh [yarn] yarn add @vielzeug/flux ``` ## Quick Start ```ts import { toArray, interval, map, pipe, take } from '@vielzeug/flux'; const firstThree = pipe( interval(100), map((value) => value * 2), take(3), ); try { console.log(await toArray(firstThree, { maxItems: 3 })); // [0, 2, 4] } catch (reason) { console.error('Stream failed', reason); } ``` ## Features - `stream()`: define cold reusable work with one teardown function - `pipe()`: compose any number of typed operators - `Subscription`: own cancellation through `unsubscribe()` or `AbortSignal` - `createChannel()`: mutable multicast state with bounded replay - `toAsyncIterable()`: explicit capacity and overflow policy for pull consumers - `retry()`: retry failures with optional backoff - `fromStore()`: bridge snapshot-based state such as Ripple signals or Sourcerer queries - `fromSubscribe()`: bridge callback-delivered Herald or Pulse events ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ripple](/ripple/): bridge snapshot state with `fromStore()`. - [Courier](/courier/): compose transport promises with `from()` or custom producers. - [Herald](/herald/): bridge explicit subscriptions with `stream()`. - [Pulse](/pulse/): bridge events with `fromSubscribe()` and presence snapshots with `fromStore()`. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `stream()` | Create cold stream | Lazy | Return one teardown function | | `pipe()` | Compose operators | Lazy | Source is first argument | | `of()` / `from()` | Convert known values | Sync / mixed | `from()` promise cannot be aborted | | `fromEvent()` | Adapt event target | Async | Unsubscribe removes listener | | `interval()` / `timer()` | Create timed values | Async | Use `take()` or unsubscribe for intervals | | `map()` / `filter()` / `scan()` | Transform values | Sync | Callback throws terminate stream | | `switchMap()` / `mergeMap()` / `concatMap()` | Flatten streams | Mixed | `mergeMap()` concurrency and queue are bounded; `concatMap()` queue is bounded | | `take()` / `takeUntil()` | Stop values | Mixed | Notifier emission completes output | | `debounce()` / `timeout()` / `retry()` | Control time and failures | Async | `timeout()` measures inactivity | | `merge()` / `concat()` / `combineLatest()` | Combine streams | Mixed | `combineLatest()` waits for every source | | `toArray()` / `first()` / `last()` | Consume finite values | Async | Bound `toArray()` with `maxItems` | | `toAsyncIterable()` | Use `for await` | Async | Capacity and overflow required | | `createChannel()` | Imperative multicast boundary | Sync | Dispose to complete subscribers | | `fromStore()` | Bridge snapshot-based state | Lazy | Snapshot and subscribe are both required | | `fromSubscribe()` | Bridge callback-delivered values | Lazy | Registration must return teardown | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/flux` | Core streams, operators, consumers, errors, types, and `createChannel()` | | `@vielzeug/flux/async` | `toAsyncIterable()` only | ## Core ### `stream()` ```ts stream(producer: Producer): Stream ``` Creates cold reusable work. Producer runs once for every subscription. | Parameter | Type | Description | | --- | --- | --- | | `producer` | `Producer` | Emits through sink and returns optional teardown | **Returns:** `Stream`. ```ts import { stream } from '@vielzeug/flux'; const ticks = stream((sink) => { const id = setInterval(() => sink.next(Date.now()), 1_000); return () => clearInterval(id); }); ``` --- ### `pipe()` ```ts pipe(source: Stream, ...operators: Operators): Stream ``` Applies operators left to right while inferring output value type. | Parameter | Type | Description | | --- | --- | --- | | `source` | `Stream` | Source stream | | `operators` | `Operator[]` | Operators applied in order | **Returns:** transformed `Stream`. ```ts import { map, of, pipe } from '@vielzeug/flux'; const labels = pipe(of(1, 2), map((value) => `#${value}`)); ``` ## Creation ### `of()` ```ts of(...values: T[]): Stream ``` Emits every value synchronously, then completes. ```ts import { of } from '@vielzeug/flux'; of(1, 2, 3).subscribe(console.log); ``` --- ### `from()` ```ts from(source: Iterable | AsyncIterable | Promise): Stream ``` Converts iterable, async iterable, or promise into a stream. Cancellation stops iterable consumption and calls `return()` when available. ```ts import { from } from '@vielzeug/flux'; from(Promise.resolve('ready')).subscribe({ error: console.error, next: console.log }); ``` --- ### `fromEvent()` ```ts fromEvent( target: { addEventListener(type: string, listener: (event: T) => void): void; removeEventListener(type: string, listener: (event: T) => void): void; }, type: string, ): Stream ``` Emits target events until subscription ends. ```ts import { fromEvent } from '@vielzeug/flux'; fromEvent(document, 'click').subscribe(console.log); ``` --- ### `interval()` ```ts interval(every: number): Stream ``` Emits incrementing values starting at zero. | Parameter | Type | Description | | --- | --- | --- | | `every` | `number` | Interval duration from 0 through 2,147,483,647 milliseconds | --- ### `timer()` ```ts timer(options: TimerOptions): Stream ``` Emits zero after `delay`; optionally continues at `interval`. | Option | Type | Description | | --- | --- | --- | | `delay` | `number` | Initial delay from 0 through 2,147,483,647 milliseconds | | `interval` | `number` | Optional repeat duration from 0 through 2,147,483,647 milliseconds | ## Transformation Operators ### `map()` ```ts map(project: (value: A) => B): Operator ``` Maps every value. A thrown callback error terminates output. --- ### `filter()` ```ts filter(predicate: (value: T) => boolean): Operator ``` Forwards values matching predicate. --- ### `scan()` ```ts scan(reducer: (state: A, value: T) => A, initial: A): Operator ``` Emits accumulated state after every source value. --- ### `switchMap()` ```ts switchMap(project: (value: A) => Stream): Operator ``` Cancels previous inner stream when source emits. --- ### `mergeMap()` ```ts mergeMap(project: (value: A) => Stream, options: FlattenOptions): Operator ``` Runs inner streams with bounded concurrency and queue capacity. Exceeding capacity errors output with `FluxCapacityError`. | Option | Type | Description | | --- | --- | --- | | `concurrency` | `number \| Infinity` | Maximum concurrently active inner subscriptions | | `capacity` | `number` | Positive maximum queued source values (required when `concurrency` is finite) | --- ### `concatMap()` ```ts concatMap(project: (value: A) => Stream, options: { capacity: number }): Operator ``` Runs inner streams in order (`concurrency: 1`). Exceeding capacity errors output with `FluxCapacityError`. | Option | Type | Description | | --- | --- | --- | | `capacity` | `number` | Positive maximum queued source values | ## Control Operators ### `take()` ```ts take(count: number): Operator ``` Forwards `count` values, cancels upstream, then completes. Count must be non-negative integer. --- ### `takeUntil()` ```ts takeUntil(notifier: AbortSignal | Stream): Operator ``` Completes when notifier aborts or emits. --- ### `debounce()` ```ts debounce(duration: number): Operator ``` Emits latest value after configured silence. Pending value flushes on source completion. | Parameter | Type | Description | | --- | --- | --- | | `duration` | `number` | Silence duration from 0 through 2,147,483,647 milliseconds | --- ### `timeout()` ```ts timeout(duration: number): Operator ``` Errors with `FluxTimeoutError` when source is silent too long. | Parameter | Type | Description | | --- | --- | --- | | `duration` | `number` | Inactivity duration from 0 through 2,147,483,647 milliseconds | --- ### `retry()` ```ts retry(options: RetryOptions): Operator ``` Resubscribes after source errors until attempts are exhausted. | Option | Type | Description | | --- | --- | --- | | `times` | `number` | Non-negative retry count | | `delay` | `number \| (attempt: number) => number` | Optional delay/backoff returning 0 through 2,147,483,647 milliseconds | ## Combination ### `merge()` ```ts merge(...sources: Stream[]): Stream ``` Forwards values from all sources and completes after every source completes. --- ### `concat()` ```ts concat(...sources: Stream[]): Stream ``` Subscribes to each source only after previous source completes. --- ### `combineLatest()` ```ts combineLatest[]>(...sources: T): Stream ? V : never }> ``` Emits latest tuple after every source emits once. Completes without emission when a source completes before first value. ## Value Consumers ### `toArray()` ```ts toArray(source: Stream, options: ToArrayOptions): Promise ``` Collects finite output. Rejects on source error, the supplied signal's abort reason, or `maxItems` overflow. | Option | Type | Description | | --- | --- | --- | | `maxItems` | `number` | Non-negative maximum toArrayed values | | `signal` | `AbortSignal` | Optional cancellation signal | --- ### `first()` ```ts first(source: Stream, options?: ValueOptions): Promise ``` Resolves first value and cancels source. Rejects on source error, the supplied signal's abort reason, or empty completion (`FluxEmptyError`); pass `defaultValue` to resolve instead. --- ### `last()` ```ts last(source: Stream, options?: ValueOptions): Promise ``` Resolves last value on completion. Rejects on source error, the supplied signal's abort reason, or empty completion (`FluxEmptyError`); pass `defaultValue` to resolve instead. ## Async Conversion ### `toAsyncIterable()` ```ts toAsyncIterable(source: Stream, options: AsyncIterableOptions): AsyncIterable ``` Converts a push stream to an async iterable with a bounded queue. External cancellation rejects pending iteration with `signal.reason`; explicit iterator `return()` completes normally. Overflow `"error"` rejects with `FluxCapacityError`; unsupported policies throw `RangeError` before subscription. | Option | Type | Description | | --- | --- | --- | | `capacity` | `number` | Positive queue capacity | | `overflow` | `OverflowPolicy` | `error`, `drop-oldest`, or `drop-newest` | | `signal` | `AbortSignal` | Optional cancellation signal | ## Channels ### `createChannel()` ```ts createChannel(options?: ChannelOptions): Channel ``` Creates an imperative multicast boundary. Disposal completes subscribers and releases replay and pending values. > **`initial` + `replay` interaction:** When `initial` is set and `replay` is omitted, `replay` defaults to `1` so the initial value is retained. Setting `replay: 0` with `initial` throws `RangeError`: the initial value would be immediately dropped. | Option | Type | Description | | --- | --- | --- | | `initial` | `T` | Optional initial replay value | | `replay` | `number` | Non-negative retained value count | ## Structural adapters ### `fromStore()` ```ts fromStore(source: { getSnapshot(): T; subscribe(listener: () => void): () => void; }): Stream ``` Bridges snapshot-based state without package-specific dependencies. It emits the current snapshot, subscribes, rechecks for a subscription-time change, and reads the latest snapshot after each notification. Snapshot failure terminates without subscribing. ### `fromSubscribe()` ```ts fromSubscribe(subscribe: (listener: (value: T) => void) => () => void): Stream ``` Bridges callback-delivered event values. Registration runs once per Flux subscription and its teardown runs when that subscription closes. ## Types ```ts type Teardown = () => void; type Subscription = { [Symbol.dispose](): void; readonly closed: boolean; unsubscribe(): void; }; type Observer = { complete?: () => void; error?: (reason: unknown) => void; next: (value: T) => void; }; type SubscribeOptions = { signal?: AbortSignal }; type Sink = { complete(): void; error(reason: unknown): void; next(value: T): void; }; type Producer = (sink: Sink, signal: AbortSignal) => Teardown | undefined; type Operator = (source: Stream) => Stream; interface Stream { subscribe(observer: Observer | ((value: T) => void), options?: SubscribeOptions): Subscription; } type OverflowPolicy = 'drop-newest' | 'drop-oldest' | 'error'; type AsyncIterableOptions = { capacity: number; overflow: OverflowPolicy; signal?: AbortSignal }; type TimerOptions = { delay: number; interval?: number }; type FlattenOptions = { concurrency: number; capacity: number }; type RetryOptions = { times: number; delay?: number | ((attempt: number) => number) }; type ToArrayOptions = { maxItems: number; signal?: AbortSignal }; type ValueOptions = { signal?: AbortSignal; defaultValue?: T }; type ChannelOptions = { initial?: T; replay?: number }; type Channel = { [Symbol.dispose](): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; send(value: T): void; readonly stream: Stream; }; ``` ## Errors ### `FluxError` Base Flux error. Use `instanceof FluxError` to narrow unknown values. ### `FluxTimeoutError` Raised by `timeout()`. `ms` contains configured inactivity duration. ### `FluxEmptyError` Raised by `first()` and `last()` when the source completes without emitting any value. ### `FluxCapacityError` Raised when a bounded buffer (`mergeMap`, `concatMap`, `toArray`, async iteration) overflows. `capacity` contains the configured limit. ### Usage Guide ## Basic Usage Define one cold stream. Return teardown work from producer. Every subscription runs producer independently. ```ts import { stream } from '@vielzeug/flux'; const clock = stream((sink) => { let value = 0; const id = setInterval(() => sink.next(value++), 1_000); return () => clearInterval(id); }); const subscription = clock.subscribe({ error: console.error, next: console.log, }); subscription.unsubscribe(); ``` Pass `AbortSignal` when another owner controls lifetime. ```ts const controller = new AbortController(); clock.subscribe(console.log, { signal: controller.signal }); controller.abort(); ``` ## Compose Streams Pass source first to `pipe()`. Operators retain inferred value types across chains. ```ts import { filter, fromEvent, map, pipe, take } from '@vielzeug/flux'; const clicks = pipe( fromEvent(document, 'click'), filter((event) => event.button === 0), map((event) => ({ x: event.clientX, y: event.clientY })), take(10), ); clicks.subscribe({ complete: () => console.log('done'), error: console.error, next: console.log, }); ``` Use `switchMap()` for latest-only work, `mergeMap()` for concurrent work with bounded concurrency and queue capacity, and `concatMap()` for ordered work with bounded queue capacity. ```ts import { from, pipe, retry, switchMap } from '@vielzeug/flux'; const results = pipe( queries, switchMap((query) => from(fetch(`/api/search?q=${encodeURIComponent(query)}`).then((response) => response.json()))), retry({ times: 2, delay: (attempt) => 250 * (attempt + 1) }), ); ``` ## Consume Values Use bounded array conversion for finite streams. `toArray()` rejects once source exceeds `maxItems`. ```ts import { toArray, of } from '@vielzeug/flux'; try { const values = await toArray(of(1, 2, 3), { maxItems: 3 }); console.log(values); } catch (reason) { console.error('Collection failed', reason); } ``` Use `first()` for first emission and `last()` for last value before completion. Both reject with `FluxEmptyError` when the source completes without emitting; pass `{ defaultValue }` to resolve instead. Pass `{ signal }` to cancel waiting; cancellation rejects with `signal.reason` (the default reason is `AbortError`). ## Channels Use channels only at imperative boundaries. Expose `channel.stream` to consumers; keep `send()` near event producer. ```ts import { createChannel } from '@vielzeug/flux'; const status = createChannel({ initial: 'starting', replay: 1 }); status.stream.subscribe({ error: console.error, next: console.log }); status.send('ready'); status.dispose(); ``` Disposal completes active and future subscribers. Replay retains only configured latest values. ## Async Iteration and Bounds Convert push stream only when pull syntax is required. Capacity and overflow policy are mandatory. ```ts import { interval, toAsyncIterable } from '@vielzeug/flux'; const values = toAsyncIterable(interval(100), { capacity: 32, overflow: 'error', }); for await (const value of values) { console.log(value); if (value === 2) break; } ``` `return()` from a loop permanently completes its iterator; external signal cancellation rejects with `signal.reason`. Use `drop-oldest` or `drop-newest` only when loss is acceptable. ## Testing Use fake timers for time operators. Test producer cleanup through returned subscription. ```ts import { expect, it, vi } from 'vitest'; import { first, pipe, stream, timeout } from '@vielzeug/flux'; it('fails after inactivity', async () => { vi.useFakeTimers(); const result = first(pipe(stream(() => {}), timeout(500))); const expectation = expect(result).rejects.toThrow('Timeout after 500ms'); await vi.advanceTimersByTimeAsync(500); await expectation; vi.useRealTimers(); }); ``` ## Framework Integration ```tsx [React] import { useEffect, useState } from 'react'; import type { Stream } from '@vielzeug/flux'; export function useStream(source: Stream, initial: T): T { const [value, setValue] = useState(initial); useEffect(() => { const subscription = source.subscribe({ error: console.error, next: setValue }); return () => subscription.unsubscribe(); }, [source]); return value; } ``` ```ts [Vue 3] import { onUnmounted, ref } from 'vue'; import type { Stream } from '@vielzeug/flux'; export function useStream(source: Stream, initial: T) { const value = ref(initial); const subscription = source.subscribe({ error: console.error, next: (next) => (value.value = next) }); onUnmounted(() => subscription.unsubscribe()); return value; } ``` ```ts [Svelte] import type { Stream } from '@vielzeug/flux'; export function streamStore(source: Stream, initial: T) { return { subscribe(run: (value: T) => void) { run(initial); const subscription = source.subscribe({ error: console.error, next: run }); return () => subscription.unsubscribe(); }, }; } ``` ## Working with Other Vielzeug Libraries Use `fromStore()` for snapshot state and `fromSubscribe()` for callback-delivered event values. Write stream output into the owning state or event layer explicitly. ```ts import { fromStore, fromSubscribe } from '@vielzeug/flux'; const state = fromStore({ getSnapshot: () => store.value, subscribe: (listener) => store.subscribe(listener), }); const events = fromSubscribe((listener) => bus.on('message', listener)); ``` ## Best Practices - Return one idempotent producer teardown function. - Pass `{ signal }` from component, request, or task owner. - Provide `error` when subscription can recover locally. - Use `pipe(source, ...)`; never mutate stream definitions. - Bound `mergeMap()` concurrency and queue capacity. - Bound `concatMap()` queue capacity. - Bound `toArray()` with realistic `maxItems`. - Choose async iterator overflow policy deliberately. - Keep timer-backed durations between 0 and 2,147,483,647 milliseconds. - Keep `Channel.send()` at integration boundaries. ### Examples ## Examples - [Debounced Search Input](./examples/debounce-search.md) - [Combining Streams with combineLatest](./examples/combine-streams.md) - [Structural State Integration](./examples/signal-integration.md) ### REPL Examples - Async Conversion (id: `async-conversion`) - Creating a Stream (id: `basic-flux`) - Cancelling an Iterable (id: `cancellation`) - Combining Streams (id: `combination`) - Error Handling (id: `error-handling`) - Operators (id: `operators`) - Channels (id: `subjects`) --- ## @vielzeug/focus **Category:** input ### Overview ## Why Focus? Composite widgets need consistent keyboard navigation and predictable return focus behavior. Focus centralizes those primitives without coupling to component rendering or framework state. ```ts // Before list.addEventListener('keydown', (event) => { // arrow/home/end bookkeeping, disabled filtering, wrapping }); // After const nav = createListNavigation({ getItems }); list.addEventListener('keydown', (event) => { nav.handleKeydown(event)?.change?.item.focus(); }); ``` | Feature | Per-component navigation | Focus | | --- | --- | --- | | Bundle size | n/a | | | Zero dependencies | n/a | | | RTL mirroring | Manual | Built in | | Grid navigation | Flat only | `createGridNavigation()` | | Typeahead | Manual | Optional via `typeahead` | | Focus restoration | Manual capture | `captureFocus()` / `restoreFocus()` | **Use Focus when** a widget needs arrow-key navigation, Home/End, and controlled focus restoration. **Consider direct focus calls when** interaction is a single isolated element with no composite navigation. ## Installation ```sh [pnpm] pnpm add @vielzeug/focus ``` ```sh [npm] npm install @vielzeug/focus ``` ```sh [yarn] yarn add @vielzeug/focus ``` ## Quick Start ```ts import { captureFocus, createListNavigation } from '@vielzeug/focus'; const restore = captureFocus(); const nav = createListNavigation({ getItems: () => items, loop: true, }); const onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus(); container.addEventListener('keydown', onKeydown); restore(); container.removeEventListener('keydown', onKeydown); ``` ## Features - `createListNavigation()`: pure composite navigation with explicit handled/change results - `createGridNavigation()`: two-dimensional arrow-key navigation with fixed or measured columns - Orientation and direction support: vertical/horizontal/both with LTR/RTL defaults - Dynamic item queries: disabled filtering and loop control - Optional typeahead: label-based navigation in key-driven lists - `captureFocus()`, `restoreFocus()`, and `rescueFocus()`: explicit return-focus and unmount-rescue helpers ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Refine](/refine/): component primitives integrating list navigation. - [Keymap](/keymap/): global and scoped keyboard shortcuts. - [Ore](/ore/): lifecycle ownership used by consumer components. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createListNavigation()` | Build keyboard navigation for composite widgets | Sync | Apply returned changes to DOM focus | | `createGridNavigation()` | Build two-dimensional arrow-key navigation for grids | Sync | Columns resolve per navigation: responsive grids need a getter | | `restoreFocus()` | Restore focus to a target or fallback | Sync | Returns `false` when neither target can receive focus | | `rescueFocus()` | Re-home focus after the focused element unmounts | Sync | Returns `false` when focus is already on a real element | | `captureFocus()` | Capture active focus for one later restoration | Sync | The returned function is one-shot | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/focus` | List navigation and focus restoration primitives. | ## Core Functions ### `createListNavigation()` ```ts function createListNavigation(options: ListNavigationOptions): ListNavigation; ``` Creates a keyboard navigation controller with an internal active index. | Parameter | Type | Description | | --- | --- | --- | | `options` | `ListNavigationOptions` | Item lookup, key mapping, navigation, dynamic direction/orientation, and typeahead options. | **Returns:** `ListNavigation`. **Example** ```ts import { createListNavigation } from '@vielzeug/focus'; const nav = createListNavigation({ getItems: () => rows, isItemDisabled: (item) => item.matches('[aria-disabled="true"]'), }); const result = nav.handleKeydown(event); result?.change?.item.focus(); ``` | Member | Return | Contract | | --- | --- | --- | | `handleKeydown(event)` | `ListKeyResult \| null` | Returns explicit handled/change state for navigation and typeahead keys. | | `navigate(action)` | `ListNavigationChange \| null` | Moves programmatically and returns the committed change. | | `set(index)` | `number` | Sets an integer usable index, or resets to `-1`. | | `reset()` | `void` | Clears the active index and typeahead sequence. | | `getIndex()` | `number` | Returns the current usable index, or `-1`. | | `getActiveItem()` | `T \| undefined` | Returns the item at the current usable index. | Boundary navigation keys return `{ handled: true, change: null }` and remain consumed. Unrecognized, already-prevented, composing, and disabled events return `null`. Successful typeahead returns a change and follows `typeahead.preventDefault`. Keys are matched through `matchKey` from `@vielzeug/keymap`, so `keys` overrides accept shortcut patterns with aliases (`esc`, `space`, `up`) and modifiers (`shift+Home`), and modifier state must match exactly: a plain `ArrowDown` binding does not fire on Ctrl+ArrowDown. --- ### `createGridNavigation()` ```ts function createGridNavigation(options: GridNavigationOptions): GridNavigation; ``` Creates a two-dimensional keyboard navigation controller: horizontal arrows step one item, vertical arrows step one row (the column count), Home/End jump to the grid's ends. | Parameter | Type | Description | | --- | --- | --- | | `options` | `GridNavigationOptions` | Item lookup, column resolution, active-index source, key mapping, and wrapping options. | **Returns:** `GridNavigation`. **Example** ```ts import { createGridNavigation } from '@vielzeug/focus'; const grid = createGridNavigation({ columns: 4, getActiveIndex: () => tiles().indexOf(document.activeElement as HTMLElement), getItems: tiles, }); const result = grid.handleKeydown(event); if (result?.change) result.change.item.focus(); ``` | Member | Return | Contract | | --- | --- | --- | | `handleKeydown(event)` | `GridKeyResult \| null` | Returns handled state for navigation keys; recognized keys are always consumed, even at clamped edges. | | `navigate(action)` | `GridNavigationChange \| null` | Moves programmatically and returns the committed change. | | `set(index)` | `void` | Sets the tracked index for grids without `getActiveIndex`. | | `reset()` | `void` | Clears the tracked index. | | `getIndex()` | `number` | Returns the active index: derived from `getActiveIndex` when provided, or `-1`. | | `getActiveItem()` | `T \| undefined` | Returns the item at the active index. | Unlike `createListNavigation`, items are not skipped when disabled: skipping in two dimensions would break row alignment. With no active index, forward moves start at the first item and backward moves at the last. `columns` may be a getter resolved on every navigation, so responsive grids can read a media query and measured grids can read the rendered row length. `FocusConfigError` is thrown when `columns` resolves below `1`. --- ### `restoreFocus()` ```ts function restoreFocus(target: FocusTarget, options?: RestoreFocusOptions): boolean; ``` Attempts to focus a connected target that is neither disabled nor inert. Throwing target getters or `focus()` implementations count as failed attempts and fall through to the configured fallback. | Parameter | Type | Description | | --- | --- | --- | | `target` | `FocusTarget` | Element or getter resolved when `restoreFocus()` is called. | | `options` | `RestoreFocusOptions` | Optional lazy fallback and `preventScroll` flag. | **Returns:** `boolean`: `true` when focus moved to the target or fallback. **Example** ```ts import { restoreFocus } from '@vielzeug/focus'; restoreFocus(() => triggerElement, { fallback: () => document.body, preventScroll: true, }); ``` --- ### `rescueFocus()` ```ts function rescueFocus(target: FocusTarget, options?: RestoreFocusOptions): boolean; ``` Hands focus to `target` when focus has been lost to the document body: the state left behind when the focused element unmounts mid-swap, where keydown never reaches a handler. | Parameter | Type | Description | | --- | --- | --- | | `target` | `FocusTarget` | Element or getter resolved when the rescue runs. | | `options` | `RestoreFocusOptions` | Optional lazy fallback and `preventScroll` flag, as in `restoreFocus()`. | **Returns:** `boolean`: `true` when focus was rescued; `false` when focus is already on a real element or neither target can receive focus. **Example** ```ts import { rescueFocus } from '@vielzeug/focus'; // After a swap that may have unmounted the focused element. rescueFocus(() => dialog.querySelector('footer button')); ``` --- ### `captureFocus()` ```ts function captureFocus(options?: CaptureFocusOptions): FocusRestorer; ``` Captures the deepest active element immediately and returns a one-shot restoration function. | Parameter | Type | Description | | --- | --- | --- | | `options` | `CaptureFocusOptions` | Optional lazy fallback, `preventScroll`, and cancellation signal. | **Returns:** `FocusRestorer`. Its first call attempts restoration; later calls return `false`. **Example** ```ts import { captureFocus } from '@vielzeug/focus'; const restore = captureFocus({ fallback: () => document.body }); dialog.showModal(); dialog.addEventListener('close', restore, { once: true }); ``` ## Types ```ts type MaybeGetter = T | (() => T); type ListNavigationAction = 'first' | 'last' | 'next' | 'prev'; type ListKeyAction = ListNavigationAction | 'typeahead'; type ListNavigationChange = { readonly action: ListKeyAction; readonly event?: KeyboardEvent; readonly index: number; readonly item: T; }; type ListKeyResult = { readonly change: ListNavigationChange | null; readonly handled: true; }; type ListNavigationTypeaheadOptions = { delayMs?: number; getLabel: (item: T, index: number) => string; preventDefault?: boolean; }; type ListNavigationOptions = { direction?: MaybeGetter; disabled?: MaybeGetter; getItems: () => readonly T[]; isItemDisabled?: (item: T, index: number) => boolean; keys?: Partial>; // shortcut patterns matched via @vielzeug/keymap loop?: boolean; orientation?: MaybeGetter; typeahead?: ListNavigationTypeaheadOptions; }; type ListNavigation = { getActiveItem(): T | undefined; getIndex(): number; handleKeydown(event: KeyboardEvent): ListKeyResult | null; navigate(action: ListNavigationAction): ListNavigationChange | null; reset(): void; set(index: number): number; }; type GridNavigationAction = 'first' | 'last' | 'next' | 'nextRow' | 'prev' | 'prevRow'; type GridNavigationChange = { readonly action: GridNavigationAction; readonly event?: KeyboardEvent; readonly index: number; readonly item: T; }; type GridKeyResult = { readonly change: GridNavigationChange | null; readonly handled: true; }; type GridColumns = number | (() => number); type GridNavigationOptions = { columns: GridColumns; direction?: MaybeGetter; disabled?: MaybeGetter; getActiveIndex?: () => number; getItems: () => readonly T[]; keys?: Partial>; loop?: boolean; }; type GridNavigation = { getActiveItem(): T | undefined; getIndex(): number; handleKeydown(event: KeyboardEvent): GridKeyResult | null; navigate(action: GridNavigationAction): GridNavigationChange | null; reset(): void; set(index: number): void; }; type FocusTarget = HTMLElement | SVGElement | null | undefined | (() => HTMLElement | SVGElement | null | undefined); type RestoreFocusOptions = { fallback?: FocusTarget; preventScroll?: boolean; }; type CaptureFocusOptions = RestoreFocusOptions & { signal?: AbortSignal; }; type FocusRestorer = () => boolean; ``` `typeahead.delayMs` defaults to `500`; supplied values must be positive and finite. `preventDefault` defaults to `false`, which preserves editable combobox input. Set it to `true` for menu-style typeahead. ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `FocusError` | Base class for every focus-originated error | `instanceof FocusError` catches any focus error | | `FocusConfigError` | Invalid navigation configuration | Extends `FocusError`. Thrown for a key assigned to two actions, a non-positive `typeahead.delayMs`, or grid `columns` resolving below `1` | ### Usage Guide ## Basic Usage Create one navigation model for the items in a composite widget. Focus remains an application side effect: apply the returned change after each key operation. ```ts import { createListNavigation } from '@vielzeug/focus'; const nav = createListNavigation({ getItems: () => items, isItemDisabled: (item) => item.matches('[aria-disabled="true"]'), loop: true, }); const onKeydown = (event: KeyboardEvent) => { const result = nav.handleKeydown(event); result?.change?.item.focus(); }; list.addEventListener('keydown', onKeydown); ``` `createListNavigation()` owns only index and typeahead state. The layer that attaches the event listener owns its cleanup. ## Inspect Keyboard Results `handleKeydown()` returns `null` when Focus did not handle the event. Recognized keys return explicit handling state: ```ts const result = nav.handleKeydown(event); if (result?.handled && result.change) { console.log(result.change.action, result.change.index, result.change.item); } ``` A boundary arrow may return `{ handled: true, change: null }`: the composite consumed the key but its active item did not move. Events that are already prevented, disabled, or part of IME composition return `null`. Programmatic navigation returns only committed changes: ```ts nav.navigate('first')?.item.focus(); nav.navigate('next')?.item.focus(); nav.navigate('prev')?.item.focus(); nav.navigate('last')?.item.focus(); ``` `set(index)` accepts integer indexes. Invalid or disabled targets reset navigation to `-1`. ## Configure Orientation and Direction Direction, orientation, and disabled state may be live getters: ```ts const nav = createListNavigation({ direction: () => document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr', disabled: () => panel.hidden, getItems: () => items, orientation: () => 'horizontal', }); ``` Horizontal arrows mirror in RTL. Vertical arrows do not. Custom key tables override defaults and are matched through `matchKey` from `@vielzeug/keymap`, so entries accept aliases and modifiers and require exact modifier state; assigning one key to multiple actions throws `FocusConfigError`. ## Add Typeahead Provide stable labels for menu, listbox, or command navigation: ```ts const nav = createListNavigation({ getItems: () => menuItems, typeahead: { delayMs: 300, getLabel: (item) => item.textContent ?? '', preventDefault: true, }, }); ``` `delayMs` defaults to `500` and must be positive and finite. Repeated characters cycle matching items. Set `preventDefault: true` for menu-style typeahead; leave it false when an editable combobox input must receive the printable key. ## Navigate Grids Use `createGridNavigation()` when items lay out in rows: vertical arrows step one row, horizontal arrows one item, and Home/End jump to the ends. Point `getActiveIndex` at the DOM so focus moved by pointer or Tab stays in sync: ```ts import { createGridNavigation } from '@vielzeug/focus'; const tiles = () => [...grid.querySelectorAll('.tile')]; const nav = createGridNavigation({ columns: 4, getActiveIndex: () => tiles().indexOf(document.activeElement as HTMLElement), getItems: tiles, }); grid.addEventListener('keydown', (event) => { nav.handleKeydown(event)?.change?.item.focus(); }); ``` `columns` resolves on every navigation, so responsive grids can read a media query and measured grids can read the rendered row length. Clamping is the default; set `loop: true` to wrap moves around the grid's edges by flat index. Items are never skipped when disabled: skipping in two dimensions would break row alignment. ## Restore Focus Capture focus before opening a transient surface, then invoke the one-shot restorer when it closes: ```ts import { captureFocus } from '@vielzeug/focus'; const restore = captureFocus({ fallback: () => document.querySelector('#main'), preventScroll: true, }); dialog.showModal(); dialog.addEventListener('close', restore, { once: true }); ``` A supplied signal can cancel pending restoration: ```ts const controller = new AbortController(); const restore = captureFocus({ signal: controller.signal }); controller.abort(); restore(); // false ``` Use `restoreFocus()` directly when the target is already known. Disconnected, disabled, inert, throwing, or non-focusable targets fall through to the lazy fallback. When the focused element unmounts: browsing a dialog whose swap removes the item that held focus: the browser drops focus to ``, where keydown never reaches a handler. Call `rescueFocus()` after the swap to hand focus to a control that survives it: ```ts import { rescueFocus } from '@vielzeug/focus'; const onAfterSwap = () => { rescueFocus(() => footer.querySelector('button')); }; ``` `rescueFocus()` returns `false` without touching anything when focus is already on a real element, so it is safe to call from both a watcher and a transition's after-leave hook. ## Framework Integration Create navigation once per mounted widget. Framework cleanup removes only the event listener because the navigation model owns no external resources. ```tsx [React] import { useEffect, useRef } from 'react'; import { createListNavigation } from '@vielzeug/focus'; function Tabs({ tabs }: { tabs: string[] }) { const listRef = useRef(null); const tabRefs = useRef>([]); useEffect(() => { const list = listRef.current; if (!list) return; const nav = createListNavigation({ getItems: () => tabRefs.current.filter((item): item is HTMLButtonElement => item !== null), loop: true, orientation: 'horizontal', }); const onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus(); list.addEventListener('keydown', onKeydown); return () => list.removeEventListener('keydown', onKeydown); }, []); return ( {tabs.map((tab, index) => ( { tabRefs.current[index] = element; }}>{tab} ))} ); } ``` ```ts [Vue] import { onMounted, onUnmounted, ref } from 'vue'; import { createListNavigation } from '@vielzeug/focus'; const list = ref(); let onKeydown: ((event: KeyboardEvent) => void) | undefined; onMounted(() => { const nav = createListNavigation({ getItems: () => items, orientation: 'horizontal' }); onKeydown = (event) => nav.handleKeydown(event)?.change?.item.focus(); list.value?.addEventListener('keydown', onKeydown); }); onUnmounted(() => { if (onKeydown) list.value?.removeEventListener('keydown', onKeydown); }); ``` ```ts [Svelte] import { onMount } from 'svelte'; import { createListNavigation } from '@vielzeug/focus'; onMount(() => { const nav = createListNavigation({ getItems: () => items, orientation: 'horizontal' }); const onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus(); list.addEventListener('keydown', onKeydown); return () => list.removeEventListener('keydown', onKeydown); }); ``` ## Integrate with Keymap Use Focus for local composite navigation and Keymap for application shortcuts: ```ts import { createListNavigation } from '@vielzeug/focus'; import { createKeymap } from '@vielzeug/keymap'; const nav = createListNavigation({ getItems: () => items }); const onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus(); const map = createKeymap([ { id: 'palette', shortcut: 'mod+k', handler: () => openPalette() }, { id: 'reset', shortcut: 'escape', handler: () => nav.reset() }, ]); list.addEventListener('keydown', onKeydown); map.mount(document); ``` ## Best Practices - Keep item discovery in one live `getItems()` function. - Apply focus from returned changes rather than hiding effects in navigation state. - Treat `{ handled: true, change: null }` as a consumed boundary key. - Ignore events before Focus when an inner widget already called `preventDefault()`. - Reset navigation when an overlay closes or its item context changes. - Use typeahead only with stable labels, and set `preventDefault` according to whether the keyboard surface is editable. - Capture return focus before opening transient surfaces. - Rescue focus after swaps that can unmount the focused element. - Remove the owning event listener when a widget unmounts. ### Examples ## Examples - [Roving Tabs Keyboard Navigation](./examples/roving-tabs-keyboard-navigation.md) - [Dialog Return Focus Restoration](./examples/dialog-return-focus-restoration.md) ### REPL Examples - List Navigation (id: `list-navigation`) - Restore Captured Focus (id: `restore-focus`) --- ## @vielzeug/forge **Category:** state ### Overview ## Why Forge? Forge owns form state without owning rendering or validation timing. Nested field handles keep access typed, flat validation issues compose across arrays and unions, and Forge-owned validation and persistence operations settle on cancellation. ## Quick Start ```ts import { createForm } from '@vielzeug/forge'; const form = createForm({ initialValues: { email: '', profile: { name: '' } }, validate: (values) => values.email.includes('@') ? undefined : [{ path: ['email'], message: 'Invalid email' }], }); form.field('profile').field('name').set('Ada'); const result = await form.validate(); ``` | Feature | Forge | Local component state | | --- | --- | --- | | Bundle size | | n/a | | Nested typed fields | | Manual | | Flat validation issues | | Manual | | Abortable validation | | Manual | | Framework-neutral | | Varies | | Zero third-party dependencies | | | **Use Forge when** several fields need coordinated validation, submission, dirty/touched state, nested updates, or explicit persistence. **Use local state when** a form has only one or two fields and no shared validation lifecycle. ## Installation ```sh pnpm add @vielzeug/forge ``` ## Features - `createForm()`: deeply readonly form state with structural sharing and cloned dates - `field().field()`: typed nested field handles - `patch()`: shallow top-level merge without spread casts - `normalize` + `toPlainValues()`: hold domain-model classes in form state - Flat `{ path, message }` validation issues - Explicit `validate()` and `submit()` timing - `/dom`: optional element binding - `/schema`: validator-agnostic Standard Schema adapter - `/persist`: explicit structural store integration - `/form-data`: browser submission serialization ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Spell](/spell/): Standard Schema-compatible validation. - [Vault](/vault/): durable typed storage for drafts. - [Assay](/assay/): DOM test helpers for form interaction. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createForm()` | Create immutable form and field state | Sync | Dispose with its owner | | `toPlainValues()` | Flatten class instances to the plain value shape | Sync | Drops circular branches | | `validate()` | Run the configured full-form validator | Async | Superseded work returns `aborted` | | `submit()` | Validate and submit one captured snapshot | Async | Concurrent submission rejects | | `bindField()` | Bind a field to an HTML element | Sync | Caller owns returned cleanup | | `schemaValidator()` | Adapt Standard Schema validation | Async | Transform output is not applied | | `loadForm()` / `saveForm()` | Explicit structural draft persistence | Async | Loading will not overwrite newer edits | | `toFormData()` | Serialize supported values | Sync | Dotted keys and object arrays reject | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/forge` | Form and field state | | `@vielzeug/forge/dom` | `bindField()` DOM binding | | `@vielzeug/forge/form-data` | `toFormData()` serialization | | `@vielzeug/forge/schema` | Standard Schema validation adapter | | `@vielzeug/forge/persist` | Explicit draft save/load helpers | ## createForm ```ts function createForm>( options: FormOptions, ): Form ``` `initialValues` is required. Validation is explicit and returns flat issues. ```ts const form = createForm({ initialValues: { email: '' }, validate: (values) => values.email.includes('@') ? undefined : [{ path: ['email'], message: 'Invalid email' }], }); ``` `normalize` runs before value validation at every write boundary (`initialValues`, `set`, `patch`, `reset`, `field().set`), so a caller that normalizes once at `createForm` stays correct everywhere. Pair it with `toPlainValues()` to hold domain-model classes in form state. ```ts import { createForm, toPlainValues } from '@vielzeug/forge'; const form = createForm({ initialValues: { user: new UserModel() }, normalize: toPlainValues }); form.reset({ user: new UserModel() }); // same normalization as init ``` ## toPlainValues ```ts function toPlainValues(value: T): T; ``` Deeply converts a value into the plain shape Forge accepts: primitives pass through, `Date` is cloned, `File`/`Blob` keep identity, arrays map, and class instances flatten to their own enumerable entries. Unsafe keys (`__proto__`, `constructor`, `prototype`) are dropped, so the output always passes value validation. The returned type parameter is a convenience cast: the flattened shape differs from the input type whenever class instances are present. Lossy conversions replace the error an un-normalized write would throw: `Map`, `Set`, and other keyless built-ins flatten to `{}`; sparse array slots are dropped, shifting later indexes; `NaN`, `Infinity`, functions, and circular branches become `undefined`. Normalize model classes, not arbitrary graphs. ```ts import { createForm, toPlainValues } from '@vielzeug/forge'; class UserModel { email = ''; name = ''; } toPlainValues(new UserModel()); // { email: '', name: '' } const form = createForm({ initialValues: { user: new UserModel() }, normalize: toPlainValues }); ``` ## Form ```ts interface Form> { readonly value: ReadonlyDeep; readonly state: FormState; readonly disposed: boolean; readonly disposalSignal: AbortSignal; field(key: K): Field; set(next: TValues | ((previous: ReadonlyDeep) => TValues)): void; patch(next: Partial): void; patch(updater: (previous: ReadonlyDeep) => Partial): void; reset(next?: TValues): void; validate(signal?: AbortSignal): Promise; submit(handler, signal?: AbortSignal): Promise>; subscribe(listener, options?: { immediate?: boolean }): () => void; dispose(): void; [Symbol.dispose](): void; } ``` `validate()` and `submit()` return `{ status: 'valid' }`, `{ status: 'invalid', issues }`, or `{ status: 'aborted' }`. Successful submission returns `{ status: 'ok', value }`. Submission handlers receive the exact snapshot that passed validation. `form.state` retains one stable reference until the next transition for framework external-store adapters. `patch()` shallow-merges top-level keys: `form.patch({ count: 1 })` equals `form.set((prev) => ({ ...prev, count: 1 }))` without the `as T` cast a spread requires. Nested values replace by identity; there is no deep merge. ```ts type FormState = Readonly; ``` ## Field ```ts interface Field { readonly value: ReadonlyDeep; readonly state: FieldState; readonly dirty: boolean; readonly touched: boolean; readonly error: string | undefined; field(keyOrIndex): Field; set(next: Value | ((previous: Value) => Value)): void; reset(): void; touch(): void; subscribe(listener, options?: { immediate?: boolean }): () => void; } ``` Nested `field().field()` handles avoid string paths while preserving type inference. ## Value types ```ts type Atomic = Date | File | Blob; type ReadonlyDate = Omit>; type ReadonlyDeep = T extends Date ? ReadonlyDate : T extends File | Blob ? T : T extends readonly (infer Item)[] ? readonly ReadonlyDeep[] : T extends Record ? { readonly [K in keyof T]: ReadonlyDeep } : T; ``` Forge clones valid dates, preserves binary identity, freezes plain containers, and rejects unsupported, circular, sparse, or non-finite values. ## ValidationIssue ```ts type ValidationIssue = Readonly; ``` `path: []` denotes a form-level issue. A field derives its first matching error from the flat issue list. ## bindField ```ts import { bindField } from '@vielzeug/forge/dom'; const stop = bindField(input, form.field('email'), { read: (element) => element.value, write: (element, value) => { element.value = value; }, }); ``` The binding reads the configured event (`input` by default), marks the field touched on blur, writes external state changes, and returns cleanup. ## schemaValidator ```ts import { schemaValidator } from '@vielzeug/forge/schema'; const form = createForm({ initialValues: { email: '' }, validate: schemaValidator(UserSchema), }); ``` Accepts any Standard Schema-compatible validator. Portable issues are copied into Forge-owned immutable `{ path, message }` values. Cancellation settles promptly, although the underlying schema operation may continue. Successful transformed output is intentionally not written into the form. ## Draft persistence ```ts import { loadForm, saveForm } from '@vielzeug/forge/persist'; await saveForm(form, store, 'drafts', codec); await loadForm(form, store, 'drafts', 'profile', codec, { signal }); ``` The store is structural: it only needs typed `get(table, key)` and `put(table, value)` methods. The codec owns record conversion. The optional signal and form disposal cancel pending work; a load returns `false` when values changed before the record arrived. ## FormData ```ts import { toFormData } from '@vielzeug/forge/form-data'; const body = toFormData(form.value); ``` Flattens nested plain objects to dotted keys and repeats scalar or binary array entries. Dots in object keys and nested objects or arrays inside arrays throw `ForgeConfigError` to prevent ambiguous or lossy output. ## Errors - `ForgeError`: base package error. - `ForgeConfigError`: invalid value, path, issue, or serialization configuration. - `ForgeDisposedError`: operation attempted after disposal. - `ForgeSubmitError`: concurrent submission attempt. - `ForgeValidationError`: unexpected validator execution failure, with the original `cause`. ### Usage Guide ## Basic Usage ```ts import { createForm } from '@vielzeug/forge'; const form = createForm({ initialValues: { email: '', profile: { name: '' }, }, }); ``` Read `form.value` for the current deeply readonly snapshot. Use `set()` for whole-form replacement and field handles for focused updates. ```ts form.field('email').set('ada@example.com'); form.field('profile').field('name').set('Ada'); ``` `patch()` shallow-merges top-level keys without the cast a spread `set()` needs: ```ts form.patch({ email: 'ada@example.com' }); // siblings keep their values ``` ## Hold domain-model classes Forge values must be plain JSON-safe data. When your app stores class instances (models, DTOs with behavior), pass `normalize: toPlainValues` once at `createForm`; it runs before validation at every write boundary: init, `set`, `patch`, `reset`, and nested `field().set`, so a reset with fresh models behaves like init. ```ts import { createForm, toPlainValues } from '@vielzeug/forge'; class ProviderUserModel { id: number; email = ''; constructor(id: number) { this.id = id; } } const form = createForm({ initialValues: { users: [new ProviderUserModel(1)] }, normalize: toPlainValues, }); form.field('users').field(0).set(new ProviderUserModel(2)); // flattened like init form.reset({ users: [new ProviderUserModel(3)] }); // flattened like init // Rebuild models at submit: await form.submit((values) => send(values.users.map((u) => new ProviderUserModel(u.id)))); ``` Without `normalize`, a rejected write throws `ForgeConfigError` naming the offending path (for example `list[0]`). ## Validate explicitly Validators return a flat issue list. Forge does not choose blur/change/submit policy for the application. ```ts const form = createForm({ initialValues: { email: '', password: '' }, validate: (values) => { const issues = []; if (!values.email.includes('@')) issues.push({ path: ['email'], message: 'Invalid email' }); if (values.password.length { const response = await fetch('/profile', { body: JSON.stringify(values), method: 'POST', signal, }); return response.json(); }); if (result.status === 'ok') console.log(result.value); ``` `submit()` validates first, passes the exact validated snapshot to the handler, prevents duplicate active submissions, and reports aborts explicitly. ## Bind an element ```ts import { bindField } from '@vielzeug/forge/dom'; const stop = bindField(input, form.field('email'), { read: (element) => element.value, write: (element, value) => { element.value = value; }, }); ``` Call `stop()` when the element owner unmounts. ## Use a Standard Schema validator ```ts import { schemaValidator } from '@vielzeug/forge/schema'; const form = createForm({ initialValues: { email: '' }, validate: schemaValidator(UserSchema), }); ``` Spell schemas implement Standard Schema directly. Forge uses the schema result only for success and issues; transformations do not replace form values. Cancellation settles Forge promptly even when underlying validator work cannot be stopped. ## Persist a draft ```ts import { loadForm, saveForm } from '@vielzeug/forge/persist'; const codec = { fromRecord: (record) => record.value, toRecord: (values) => ({ id: 'profile', value: values }), }; await saveForm(form, store, 'drafts', codec); await loadForm(form, store, 'drafts', 'profile', codec, { signal }); ``` Persistence remains explicit. The store owns durability and validation; the codec owns record shape. Loading returns `false` rather than overwriting edits made while storage was pending. ## Serialize FormData ```ts import { toFormData } from '@vielzeug/forge/form-data'; await fetch('/profile', { body: toFormData(form.value), method: 'POST' }); ``` Object keys containing dots and arrays containing nested objects reject instead of producing ambiguous or lossy entries. ## Dispose ```ts form.dispose(); ``` Disposal settles active Forge validation and signals active submission/persistence work and clears subscribers. It does not dispose borrowed validators, stores, or DOM elements. ## Framework Integration Inject an owner-scoped form and subscribe to its stable `FormState` snapshot. ```tsx [React] import { useSyncExternalStore } from 'react'; import type { Form } from '@vielzeug/forge'; export function useFormState>(form: Form) { return useSyncExternalStore(form.subscribe, () => form.state, () => form.state); } ``` When the component is the form's sole owner, create it in a ref and skip explicit disposal: writes on a disposed form throw `ForgeDisposedError`, which is what a StrictMode double-mount hits when effect cleanup disposes a ref-held form that survives the remount. Garbage collection reclaims a sole-subscriber form; dispose explicitly only when external subscribers or in-flight work need teardown. ```ts [Vue] import { onUnmounted, shallowRef } from 'vue'; import type { Form } from '@vielzeug/forge'; export function useFormState>(form: Form) { const state = shallowRef(form.state); const stop = form.subscribe((next) => { state.value = next; }, { immediate: true }); onUnmounted(stop); return state; } ``` ```ts [Svelte] import type { Form } from '@vielzeug/forge'; export const formState = >(form: Form) => ({ subscribe(run: (state: Form['state']) => void) { return form.subscribe(run, { immediate: true }); }, }); ``` The owner that creates the form disposes it. Framework subscriptions own only their unsubscribe callbacks. ## Working with Other Vielzeug Libraries - Pass Spell schemas to `schemaValidator()` through Standard Schema. - Pass Vault stores directly to `loadForm()` and `saveForm()`. - Bind Ore or native controls with `bindField()` and owner cleanup. ## Best Practices - Keep validation timing in the UI layer. - Return flat issues rather than shape-coupled error trees. - Use field handles instead of string paths. - Decode persisted/network data at its boundary. - Dispose forms with their owner. ### Examples ## Examples - [Login form](./examples/login-form.md) - [Conditional values](./examples/form-with-conditional-fields.md) - [Dynamic arrays](./examples/dynamic-form-fields.md) - [Contact form with file upload](./examples/contact-form-with-file-upload.md) - [Registration form](./examples/registration-form.md) - [Multi-step wizard](./examples/multi-step-wizard.md) - [Search form with debounce](./examples/search-form-with-debounce.md) ### REPL Examples - Immutable Array Updates (id: `array-fields`) - Create Immutable Form (id: `create-form`) - Dynamic Values (id: `dynamic-fields`) - Focused Field Operations (id: `field-operations`) - Form Submission (id: `form-submission`) - Form and Field Subscriptions (id: `form-subscriptions`) - Whole-Value Validation (id: `form-validation`) - Nested Field Handles (id: `scoped-sub-forms`) --- ## @vielzeug/gesture **Category:** input ### Overview ## Why Gesture? Pointer-driven interfaces need reliable one-pointer movement tracking without coupling recognition to rendering, drag-and-drop semantics, or product-specific completion policy. ```ts // Before element.addEventListener('pointermove', (event) => { // Coordinate tracking, pointer identity, direction locking, and cleanup }); // After import { createPanGesture } from '@vielzeug/gesture'; const pan = createPanGesture(element, { axis: 'x', onMove: ({ distance }) => console.log('distance', distance), onEnd: ({ reason }) => console.log('ended', reason), }); ``` | Feature | Gesture | Ad-hoc pointer handling | | --- | --- | --- | | Bundle size | | n/a | | Zero dependencies | | n/a | | Two-dimensional drag tracking | Built in | Manual | | Axis intent recognition | Built in | Manual | | Pointer ownership | Tracked across the document | Manual | | Lifecycle cleanup | `dispose()` + `disposalSignal` | Manual | **Use Gesture when** UI surfaces need consistent free drag or axis-locked pan tracking while retaining their own rendering and completion rules. **Consider direct pointer handling when** the interaction is isolated and does not need reusable lifecycle or direction-lock behavior. ## Installation ```sh [pnpm] pnpm add @vielzeug/gesture ``` ```sh [npm] npm install @vielzeug/gesture ``` ```sh [yarn] yarn add @vielzeug/gesture ``` ## Quick Start ```ts import { createPanGesture } from '@vielzeug/gesture'; const element = document.querySelector('[data-swipe]'); if (!element) throw new Error('Missing [data-swipe] element'); const pan = createPanGesture(element, { axis: 'x', onMove: ({ distance }) => { element.style.transform = `translateX(${distance}px)`; }, onEnd: ({ distance, reason }) => { element.style.transform = ''; if (reason === 'release' && Math.abs(distance) >= 48) { pan.dispose(); element.remove(); } }, }); window.addEventListener('pagehide', () => pan.dispose(), { once: true }); ``` ## Features - `createDragGesture()`: unrestricted two-dimensional pointer movement tracking - `createPanGesture()`: one-axis pointer movement with direction intent recognition - `createLongPress()`: hold recognition with slop cancellation and click swallowing - Direction locking: activates only when movement favors the configured axis - Configurable pointer capture: own the pointer by default or preserve native targeting - Configurable `activationDistance`: tune slop for touch density and component needs - Consumer-owned policy: completion thresholds, snapping, and outcomes stay in application code - Stable completion: one `onEnd` callback for release and cancellation - Lifecycle ownership: `dispose()`, `disposed`, `disposalSignal`, optional owner `signal`, and `[Symbol.dispose]()` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Refine](/refine/): components that use pan recognition for carousel, drawer, toast, and list interactions. - [Dnd](/dnd/): builds touch sorting on `createDragGesture()` and adds files, previews, drop targets, keyboard reordering, and connected-list transactions. - [Keymap](/keymap/): keyboard interaction primitives for complementary input paths. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createDragGesture()` | Track unrestricted two-dimensional pointer movement | Sync | Does not provide DOM movement or drop semantics | | `createPanGesture()` | Track pointer movement projected onto one axis | Sync | Cross-axis intent ends a pending interaction | | `createLongPress()` | Recognize a primary pointer held in place | Sync | The click that follows a fired hold is swallowed | | `DragGesture` / `PanGesture` / `LongPress` | Control recognition lifecycle | Sync | `dispose()` does not invoke `onEnd` | | `DragGestureOptions` | Configure free-drag recognition | Sync | `activationDistance` uses Euclidean distance | | `PanGestureOptions` | Configure axis-locked recognition | Sync | Completion thresholds belong in `onEnd` | | `LongPressOptions` | Configure hold recognition | Sync | `durationMs` and `slopPx` are validated at construction | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/gesture` | Drag and pan factories, handles, callback details, options, axes, points, and end reasons | ## Core Functions ### `createDragGesture()` ```ts function createDragGesture(target: Element, options?: DragGestureOptions): DragGesture; ``` Tracks one primary pointer without restricting movement to an axis. | Parameter | Type | Description | | --- | --- | --- | | `target` | `Element` | Element that owns pointer admission and optional capture | | `options` | `DragGestureOptions` | Activation, admission, capture, callback, and lifecycle configuration | **Returns:** A lifecycle-owned `DragGesture` handle. **Example** ```ts import { createDragGesture } from '@vielzeug/gesture'; const drag = createDragGesture(element, { onMove: ({ delta }) => { element.style.translate = `${delta.x}px ${delta.y}px`; }, onEnd: () => { element.style.translate = ''; }, }); ``` --- ### `createPanGesture()` ```ts function createPanGesture(target: Element, options?: PanGestureOptions): PanGesture; ``` Tracks one primary pointer after movement establishes intent on the configured axis. | Parameter | Type | Description | | --- | --- | --- | | `target` | `Element` | Element that owns pointer admission and optional capture | | `options` | `PanGestureOptions` | Axis, activation, admission, capture, callback, and lifecycle configuration | **Returns:** A lifecycle-owned `PanGesture` handle. **Example** ```ts import { createPanGesture } from '@vielzeug/gesture'; const pan = createPanGesture(element, { axis: 'x', onEnd: ({ distance, reason }) => { if (reason === 'release' && Math.abs(distance) >= 48) dismiss(); }, }); ``` Both handles expose the same members: | Member | Type | Contract | | --- | --- | --- | | `active` | `boolean` | `true` after activation and before release or cancellation | | `cancel()` | `() => boolean` | Clears pending recognition or ends active recognition with `reason: 'cancel'`; returns whether a session existed | | `disposalSignal` | `AbortSignal` | Aborts when the handle is disposed | | `dispose()` | `() => void` | Aborts `disposalSignal`, clears recognition, releases capture, and detaches listeners; idempotent | | `disposed` | `boolean` | `true` after disposal | | `[Symbol.dispose]()` | `() => void` | Calls `dispose()` | --- ### `createLongPress()` ```ts function createLongPress(target: Element, options?: LongPressOptions): LongPress; ``` Recognizes a primary pointer held in place: `onLongPress` fires with the originating pointerdown once the hold outlasts `durationMs`. | Parameter | Type | Description | | --- | --- | --- | | `target` | `Element` | Element that owns pointer admission, document tracking, and click swallowing | | `options` | `LongPressOptions` | Hold time, slop distance, admission, callback, and lifecycle configuration | **Returns:** A lifecycle-owned `LongPress` handle with the same members as the other gestures; `active` is `true` while a hold is pending. **Example** ```ts import { createLongPress } from '@vielzeug/gesture'; const hold = createLongPress(list, { shouldStart: (event) => event.pointerType !== 'mouse', onLongPress: (detail) => { const tile = (detail.event.target as HTMLElement).closest('.tile'); openDetail(tile?.dataset.id); }, }); ``` Movement beyond `slopPx` cancels back to a normal press, and window blur or a hidden document cancels a pending hold. The click that follows a fired hold is swallowed through a capture-phase listener on `target`, so releasing never commits what the hold opened; bind `target` to a stable ancestor of the pressed controls so the swallow precedes their click handlers. ## Types ### Drag types ```ts type DragEndReason = 'cancel' | 'release'; type DragPoint = Readonly; type DragGestureDetail = Readonly; type DragGestureEndDetail = DragGestureDetail & Readonly; type DragGestureOptions = Readonly boolean | undefined); onEnd?: (detail: DragGestureEndDetail) => void; onMove?: (detail: DragGestureDetail) => void; onStart?: (detail: DragGestureDetail) => void; pointerCapture?: boolean; shouldStart?: (event: PointerEvent) => boolean; signal?: AbortSignal; }>; ``` `start`, `current`, and `delta` use viewport `clientX` and `clientY` coordinates. Activation uses `Math.hypot(delta.x, delta.y)`. ### Pan types ```ts type PanAxis = 'x' | 'y'; type PanEndReason = 'cancel' | 'release'; type PanGestureDetail = Readonly; type PanGestureEndDetail = PanGestureDetail & Readonly; type PanGestureOptions = Readonly PanAxis); disabled?: boolean | (() => boolean | undefined); onEnd?: (detail: PanGestureEndDetail) => void; onMove?: (detail: PanGestureDetail) => void; onStart?: (detail: PanGestureDetail) => void; pointerCapture?: boolean; shouldStart?: (event: PointerEvent) => boolean; signal?: AbortSignal; }>; ``` Pan details project `start`, `current`, and `distance` onto `axis`. The default axis is `'x'`. ### Long-press types ```ts type LongPressDetail = Readonly; type LongPressOptions = Readonly boolean | undefined); onLongPress?: (detail: LongPressDetail) => void; shouldStart?: (event: PointerEvent) => boolean; slopPx?: number; signal?: AbortSignal; }>; ``` `durationMs` defaults to `500` and `slopPx` to `8`. `event` is the pointerdown that started the hold, so `event.target` is the pressed element. ### Handle types ```ts type DragGesture = { readonly active: boolean; [Symbol.dispose](): void; cancel(): boolean; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; }; type PanGesture = DragGesture; ``` ### Shared option behavior | Option | Default | Contract | | --- | --- | --- | | `activationDistance` | `6` | Non-negative finite CSS pixels; drag uses Euclidean distance, pan waits for dominant-axis intent | | `axis` | `'x'` | Pan only; fixed value or getter sampled when each interaction starts | | `disabled` | `false` | Fixed value or getter; blocks starts and cancels active recognition on the next matching pointer event | | `pointerCapture` | `true` | Best-effort capture after activation; document tracking continues when capture is disabled or fails | | `shouldStart` | N/A | Admission predicate evaluated for the primary pointer before tracking starts | | `signal` | N/A | Disposes the handle when the external owner aborts | | `onStart` | N/A | Runs once on the activating movement | | `onMove` | N/A | Runs on the activating movement and each later matching movement | | `onEnd` | N/A | Runs once for active release or cancellation; pending interactions end without callbacks | Options and callback details are readonly. Fixed callbacks and capture policy are snapshotted at construction. Function-valued `axis` and `disabled` options remain dynamic. Gesture tracks accepted movement with capture-phase listeners on the target's current owner document. Window blur, hidden-document transition, `pointercancel`, and `lostpointercapture` cancel active recognition. ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `GestureError` | Base class for every gesture-originated error | `instanceof GestureError` catches any gesture error | | `GestureConfigError` | Invalid option value | Extends `GestureError` | - Invalid `activationDistance` values throw `GestureConfigError` during construction. - Invalid fixed `axis` values throw `GestureConfigError` during construction. - Invalid values returned by a dynamic `axis` getter throw `GestureConfigError` when a pointer interaction starts. ### Usage Guide ## Basic Usage Create one handle for the element that owns the interaction. This example creates its surface, applies the required touch policy, and disposes the handle when the page unloads. ```ts import { createPanGesture } from '@vielzeug/gesture'; const row = document.body.appendChild(document.createElement('div')); row.textContent = 'Swipe to archive'; row.style.touchAction = 'pan-y'; const pan = createPanGesture(row, { axis: 'x', onMove: ({ distance }) => { row.style.transform = `translateX(${distance}px)`; }, onEnd: ({ distance, reason }) => { row.style.transform = ''; if (reason === 'release' && Math.abs(distance) >= 64) { pan.dispose(); row.remove(); } }, }); window.addEventListener('pagehide', () => pan.dispose(), { once: true }); ``` ## Track Two-Dimensional Dragging Use `createDragGesture()` when movement must remain unrestricted in both dimensions: ```ts import { createDragGesture } from '@vielzeug/gesture'; const card = document.querySelector('[data-draggable]'); if (!card) throw new Error('Missing [data-draggable] element'); const drag = createDragGesture(card, { activationDistance: 6, onMove: ({ delta }) => { card.style.translate = `${delta.x}px ${delta.y}px`; }, onEnd: () => { card.style.translate = ''; }, }); window.addEventListener('pagehide', () => drag.dispose(), { once: true }); ``` `start`, `current`, and `delta` are readonly `{ x, y }` points. Set `touch-action: none` when the surface owns movement in both dimensions. Drag recognition does not provide previews, drop targets, DOM movement, or `DataTransfer`; use Dnd for those semantics. ## Recognize Long Presses Use `createLongPress()` for hold interactions: the touch counterpart of a keyboard preview key. `onLongPress` receives the originating pointerdown once the hold outlasts `durationMs`: ```ts import { createLongPress } from '@vielzeug/gesture'; const hold = createLongPress(grid, { durationMs: 500, shouldStart: (event) => event.pointerType !== 'mouse' && (event.target as HTMLElement).closest('.tile') !== null, onLongPress: (detail) => { const tile = (detail.event.target as HTMLElement).closest('.tile'); openPreview(tile?.dataset.id); }, }); ``` Movement beyond `slopPx` cancels back to a normal press, so scrolling never triggers the hold. The click that follows a fired hold is swallowed through a capture-phase listener on `target`: bind `target` to a stable ancestor of the pressed controls so the swallow precedes their click handlers, and gate `shouldStart` to the elements the hold belongs to. ## Completion Rules Gesture reports movement and terminal state but does not decide what constitutes a swipe. Apply thresholds and allowed directions in `onEnd`. ```ts const pan = createPanGesture(panel, { axis: 'x', onEnd: ({ distance, reason }) => { if (reason === 'release' && distance !event .composedPath() .some((node) => node instanceof Element && node.matches('button, a, input, select, textarea')), onMove, onEnd, }); ``` `shouldStart` protects controls under the initial pointer. `pointerCapture: false` additionally protects controls that appear beneath the pointer during a reveal interaction. ## Disabled State A boolean disables the recognizer permanently. A getter supports state that changes while the handle is alive. ```ts const pan = createPanGesture(row, { disabled: () => isLocked, onEnd: ({ reason }) => { if (reason === 'cancel') resetRow(); }, }); ``` When the getter becomes `true`, the next pointer event cancels an active pan. Window blur or a hidden owner document also cancels active tracking so a lost release cannot block later gestures. Gesture snapshots fixed callbacks and capture policy at construction. Use the `axis` and `disabled` functions for behavior that must remain dynamic instead of mutating the options object. ## Lifecycle Dispose the target-bound handle when its owning UI scope unmounts. ```ts const element = document.querySelector('[data-pan]'); if (!element) throw new Error('Missing [data-pan] element'); const owner = new AbortController(); const request = new AbortController(); const pan = createPanGesture(element, { axis: 'x', signal: owner.signal, }); const childSignal = AbortSignal.any([pan.disposalSignal, request.signal]); window.addEventListener('pagehide', () => owner.abort(), { once: true }); ``` Pass `{ signal }` to dispose with an existing owner signal. `disposalSignal` always aborts when Gesture finishes teardown, so child work can compose with the handle regardless of how disposal starts. Use `cancel()` to stop a pending or active interaction without disposing the handle. An active interaction emits `onEnd` with `reason: 'cancel'`. Call `cancel()` before `dispose()` when a connected surface needs its `onEnd` reset path; disposal itself does not invoke callbacks. ## Framework Integration Create the handle after the target element exists and dispose it on unmount. ```tsx [React] import { useEffect, useRef } from 'react'; import { createPanGesture } from '@vielzeug/gesture'; function SwipeRow({ onDismiss }: { onDismiss: () => void }) { const rowRef = useRef(null); useEffect(() => { const row = rowRef.current; if (!row) return; const pan = createPanGesture(row, { axis: 'x', onMove: ({ distance }) => { row.style.transform = `translateX(${distance}px)`; }, onEnd: ({ distance, reason }) => { row.style.transform = ''; if (reason === 'release' && Math.abs(distance) >= 64) onDismiss(); }, }); return () => pan.dispose(); }, [onDismiss]); return Swipe me; } ``` ```vue [Vue 3] import { onMounted, onUnmounted, ref } from 'vue'; import { createPanGesture, type PanGesture } from '@vielzeug/gesture'; const emit = defineEmits(); const rowEl = ref(null); let pan: PanGesture | undefined; onMounted(() => { const row = rowEl.value; if (!row) return; pan = createPanGesture(row, { axis: 'x', onEnd: ({ distance, reason }) => { if (reason === 'release' && Math.abs(distance) >= 64) emit('dismiss'); }, }); }); onUnmounted(() => pan?.dispose()); Swipe me ``` ```svelte [Svelte] import { onMount } from 'svelte'; import { createPanGesture } from '@vielzeug/gesture'; let { ondismiss = () => {} }: { ondismiss: () => void } = $props(); let rowEl: HTMLDivElement; onMount(() => { const pan = createPanGesture(rowEl, { axis: 'x', onEnd: ({ distance, reason }) => { if (reason === 'release' && Math.abs(distance) >= 64) ondismiss(); }, }); return () => pan.dispose(); }); Swipe me ``` ## Working with Other Vielzeug Libraries ### Gesture + Refine Refine uses Gesture internally for carousel, drawer, toast, and list-item pointer interactions. Custom surfaces can use the same pan lifecycle while keeping visual state local. ```ts import { createPanGesture } from '@vielzeug/gesture'; const pan = createPanGesture(panel, { axis: 'x', onMove: ({ distance }) => { panel.style.transform = `translateX(${distance}px)`; }, onEnd: ({ distance, reason }) => { panel.style.transform = ''; if (reason === 'release' && Math.abs(distance) >= 80) revealActions(); }, }); ``` ### Gesture + Dnd Gesture owns pointer recognition: unrestricted two-dimensional drag and one-axis pan. Dnd builds touch sorting on `createDragGesture()`, then owns `DataTransfer`, previews, hit-testing, file drop zones, keyboard sorting, DOM reordering, and connected-list transactions. Use Dnd when an item is picked up and dropped; use Gesture directly when application code owns rendering and completion. ## Best Practices - **Set** `touch-action` for the axis the browser should continue scrolling. - **Use** `shouldStart` to exclude nested interactive controls. - **Disable** pointer capture when nested or newly revealed controls must keep native release targeting. - **Apply** completion thresholds and direction rules in `onEnd`; use `activationDistance` only for recognition slop. - **Treat** `reason: 'cancel'` as a reset path, never a commit path. - **Keep** `onMove` rendering lightweight. - **Dispose** the handle when its target leaves the UI. ### Examples ## Examples - [Carousel Pan Navigation](./examples/carousel-swipe-navigation.md) - [Swipe-to-Dismiss Notifications](./examples/swipe-dismiss-notifications.md) ### REPL Examples - createDragGesture - Basic (id: `drag-basic`) - createPanGesture - Basic (id: `pan-basic`) --- ## @vielzeug/herald **Category:** events ### Overview ## Why Herald? Raw event emitters lose payload inference and leave one-shot waiting, cancellation, diagnostics, and teardown to every caller. Herald keeps events temporal: use [Ripple](/ripple/) when you need retained state. ```ts // Before const listeners = new Set void>(); listeners.add((payload) => loadProfile((payload as { id: string }).id)); // After import { createBus } from '@vielzeug/herald'; interface AppEvents { 'user:login': { id: string }; } function loadProfile(id: string): void { console.log(id); } const bus = createBus(); bus.on('user:login', ({ id }) => loadProfile(id)); ``` Delivery is synchronous and temporal: events are not retained or replayed. Listener failures do not stop later listeners; `emit()` rethrows the first failure after dispatch, while `tap()` exposes every failure for diagnostics. | Feature | Herald | mitt | EventEmitter3 | | --- | --- | --- | --- | | Bundle size | | ~200 B | ~1.5 kB | | Typed payloads | | | | | One-shot async wait | | | | | AbortSignal lifecycle | | | | | Runtime activity tracing | | | | | Zero dependencies | | | | **Use Herald when** modules need typed temporal event delivery with owned lifecycle. **Consider Ripple when** consumers need current state and replayed values. ## Installation ```sh [pnpm] pnpm add @vielzeug/herald ``` ```sh [npm] npm install @vielzeug/herald ``` ```sh [yarn] yarn add @vielzeug/herald ``` ## Quick Start ```ts import { createBus } from '@vielzeug/herald'; interface AppEvents { 'user:login': { id: string }; 'user:logout': void; } const bus = createBus(); const stop = bus.on('user:login', ({ id }) => console.log(id)); bus.emit('user:login', { id: '42' }); stop(); bus.dispose(); ``` ## Features - `on()` / `once()`: typed subscriptions with explicit teardown - `onAny()`: cross-cutting event observation - `tap()`: observe bus activity for logging and diagnostics - `wait()` / `waitAny()`: one-shot async coordination - `listenerCount()` / `wildcardCount()` / `eventNames()`: listener inspection - `error` tap events: isolate listener failures without interrupting delivery - `AbortSignal`: subscription, wait, tap, and disposal ownership - `createTestBus()`: emitted-payload recording for tests ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ripple](/ripple/): retained reactive state. - [Wayfinder](/wayfinder/): route lifecycle events. - [Familiar](/familiar/): worker completion events. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `createBus()` | Create a typed temporal event bus | Sync | Events are not retained or replayed | | `emit()` | Deliver one event | Sync | Returns `void`; rethrows the first listener failure after dispatch | | `on()` / `once()` | Subscribe to one event | Sync | Keep the unsubscribe function or pass a signal | | `onAny()` | Subscribe to every event | Sync | Payload is `unknown` | | `wait()` / `waitAny()` | Await future one-shot events | Async | Disposal and external abort reject the promise | | `tap()` | Observe bus runtime activity | Sync | Tap-handler failures are swallowed | | `createTestBus()` | Record emitted payloads | Sync | Import from `/testing` | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/herald` | Bus factory, errors, and public bus types | | `@vielzeug/herald/testing` | `createTestBus()` and `TestBus` | ## `createBus()` ```ts function createBus>( options?: BusOptions, ): Bus ``` Creates a synchronous bus for future event delivery. Construction has no external side effects. ```ts import { createBus } from '@vielzeug/herald'; interface Events { count: number; ready: void; } const bus = createBus({ maxListeners: 20, name: 'worker' }); bus.emit('count', 1); bus.emit('ready'); bus.dispose(); ``` ### `BusOptions` ```ts type BusOptions = { maxListeners?: number; name?: string; }; ``` | Field | Description | | --- | --- | | `maxListeners` | Development warning threshold applied separately to one event's listeners and wildcard listeners. Omit to disable the check. | | `name` | Display name included in disposal errors and listener-threshold warnings. | ## `Bus` ```ts type Bus = { [Symbol.dispose](): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; emit>(event: K, ...args: T[K] extends void ? [] : [payload: T[K]]): void; eventNames(): EventKey[]; listenerCount(event?: EventKey): number; on>(event: K, listener: Listener, options?: SubscribeOptions): Unsubscribe; onAny(listener: (event: EventKey, payload: unknown) => void, options?: SubscribeOptions): Unsubscribe; once>(event: K, listener: Listener, options?: { signal?: AbortSignal }): Unsubscribe; tap(handler: (event: HeraldEvent) => void, options?: { signal?: AbortSignal }): Unsubscribe; wait>(event: K, options?: { signal?: AbortSignal }): Promise; waitAny[]>( events: K, options?: { signal?: AbortSignal }, ): Promise>; wildcardCount(): number; }; ``` ### `emit()` Calls a snapshot of event-specific listeners synchronously, then wildcard listeners, then emits an `emit` trace event. It always returns `void`. Synchronous listener failures produce `error` trace events without stopping delivery; after dispatch, the first failure is rethrown. After disposal, emission is a no-op. ### `on()` and `once()` ```ts type Listener = (payload: T) => void; type SubscribeOptions = { signal?: AbortSignal }; type Unsubscribe = () => void; ``` `on()` returns an idempotent unsubscribe function. `once()` is the one-shot variant: it unsubscribes before invoking the listener. An already-aborted signal creates no subscription. ### `onAny()` Receives `(event, payload)` after event-specific listeners and supports `signal`. Wildcard listeners are excluded from `listenerCount()` and reported by `wildcardCount()`. ### `wait()` Resolves with the next payload for one event. It rejects with the external signal's reason when aborted, or `BusDisposedError` when bus disposal wins. Earlier emissions are not replayed. ### `waitAny()` Accepts one or more event keys. It resolves once with a typed `{ event, payload }` discriminated union and removes every losing subscription. ```ts const result = await bus.waitAny(['count', 'ready']); if (result.event === 'count') console.log(result.payload); ``` ### Listener inspection - `listenerCount(event)` returns specific listeners for one key. - `listenerCount()` returns all specific listeners. - `wildcardCount()` returns `onAny()` listeners. - `eventNames()` returns keys with active specific listeners. ### `tap()` Observes runtime activity without affecting delivery. It returns an idempotent unsubscribe function and accepts an ownership signal. Tap-handler errors are swallowed. ```ts const stop = bus.tap((event) => { if (event.type === 'error') recordListenerFailure(event.error); }); ``` ### Disposal `dispose()` is permanent and idempotent. It emits a final `dispose` trace event, aborts `disposalSignal` with `BusDisposedError`, rejects pending waits, removes subscriptions, and clears taps. `[Symbol.dispose]()` delegates to it. ## Runtime Events ```ts type HeraldEvent = | { readonly event: EventKey; readonly listeners: number; readonly payload: unknown; readonly type: 'emit' } | { readonly event: EventKey; readonly type: 'subscribe' } | { readonly event: EventKey; readonly type: 'unsubscribe' } | { readonly type: 'subscribe-any' } | { readonly type: 'unsubscribe-any' } | { readonly error: unknown; readonly event: EventKey; readonly type: 'error' } | { readonly type: 'dispose' }; ``` `listeners` on an `emit` event counts every specific and wildcard listener invoked, including listeners that threw. Runtime events intentionally contain no timestamp; observers can add one using their own clock. ## Shared Types ```ts type EventMap = object; type EventKey = Extract; type WaitAnyResult[]> = { [I in keyof K]: K[I] extends EventKey ? { event: K[I]; payload: T[K[I]] } : never; }[number]; ``` Interfaces and object type aliases are valid event maps. Only string keys participate in the bus API. ## Testing ### `createTestBus()` ```ts import { createTestBus } from '@vielzeug/herald/testing'; function createTestBus>( options?: BusOptions, ): TestBus ``` Creates a regular bus that records every dispatched payload through a `tap()` observer, including dispatches whose listeners throw. ### `TestBus` ```ts type TestBus = Bus & { allEmitted(): { [K in EventKey]?: T[K][] }; emitted>(event: K): T[K][]; emittedCount>(event: K): number; reset(): void; }; ``` `emitted()` and `allEmitted()` return array snapshots. `reset()` clears records without affecting subscriptions. Disposal clears records and the underlying bus. To prevent prototype mutation in the returned plain object, `allEmitted()` omits records named `__proto__`, `constructor`, or `prototype`; `emitted(event)` remains available for those keys. ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `BusDisposedError` | Bus disposal aborts `disposalSignal` or interrupts `wait()` / `waitAny()` | Configured bus name appears in the message | | `HeraldError` | Base class for Herald-originated errors | Use `instanceof HeraldError` | ### Usage Guide ## Basic Usage Use an interface or object type whose string keys are event names and whose values are payloads. Use `void` or `undefined` for payloadless events. ```ts import { createBus } from '@vielzeug/herald'; interface AppEvents { 'cart:updated': { count: number }; 'user:logout': void; } using bus = createBus({ name: 'app' }); const stop = bus.on('cart:updated', ({ count }) => console.log(count)); bus.emit('cart:updated', { count: 1 }); bus.emit('user:logout'); stop(); ``` `emit()` calls listeners synchronously and returns `void`. Event-specific listeners run before wildcard listeners. Events emitted without listeners are discarded. ## Own Subscription Lifetimes Keep the returned unsubscribe function or bind a subscription to an `AbortSignal`. `once()` unsubscribes before invoking the listener. ```ts const controller = new AbortController(); bus.on('cart:updated', renderCart, { signal: controller.signal }); bus.once('user:logout', clearSession); controller.abort(); ``` An already-aborted signal creates no subscription and returns an idempotent no-op unsubscribe function. ## Observe Every Event Use `onAny()` for cross-cutting application behavior. Wildcard listeners receive the event key and untyped payload after event-specific listeners. ```ts const stop = bus.onAny((event, payload) => { audit(event, payload); }); stop(); ``` `wildcardCount()` reports wildcard listeners separately from `listenerCount()`. ## Await One Event `wait()` resolves on the next matching event. `waitAny()` accepts one or more keys and returns a discriminated union. ```ts const cart = await bus.wait('cart:updated', { signal: AbortSignal.timeout(5_000) }); const result = await bus.waitAny(['cart:updated', 'user:logout'], { signal: AbortSignal.timeout(5_000), }); if (result.event === 'cart:updated') console.log(result.payload.count); ``` These methods do not replay earlier events. External cancellation rejects with the signal reason. Bus disposal rejects pending waits with `BusDisposedError`. ## Handle Listener Failures Listener exceptions do not prevent remaining specific or wildcard listeners from running. Each failure produces a `error` tap event, then `emit()` rethrows the first error after dispatch. ```ts const stopTrace = bus.tap((event) => { if (event.type === 'error') { recordListenerFailure(event.error); } }); stopTrace(); ``` Herald captures synchronous listener throws only, and `tap()` does not suppress the error rethrown after dispatch. A listener that starts asynchronous work must handle its own rejected promises. ## Trace Bus Activity `tap()` observes subscriptions, unsubscriptions, wildcard subscriptions, emissions, listener failures, and disposal without changing bus behavior. Tap-handler failures are swallowed. ```ts const stopTrace = bus.tap((event) => { recordHeraldEvent(event); }, { signal: owner.signal }); ``` An `emit` trace includes the event key, payload, and number of specific plus wildcard listeners invoked. Trace events do not include timestamps; add them in the observer if needed. ## Inspect Listeners ```ts bus.listenerCount('cart:updated'); // specific listeners for this key bus.listenerCount(); // all specific listeners bus.wildcardCount(); // onAny listeners bus.eventNames(); // keys with active specific listeners ``` `eventNames()` does not include events that only have wildcard listeners. ## Dispose the Bus `dispose()` is permanent and idempotent. It emits a final tap event, aborts `disposalSignal` with `BusDisposedError`, clears all listeners and taps, and rejects pending waits. Later subscriptions and emissions are no-ops. ```ts using scopedBus = createBus({ name: 'checkout' }); scopedBus.disposalSignal.addEventListener('abort', () => releaseOwnedWork()); ``` The configured name appears in disposal errors and listener-threshold warnings. ## Test Emissions Import test helpers from the focused testing entry point. ```ts import { createTestBus } from '@vielzeug/herald/testing'; using testBus = createTestBus(); testBus.emit('cart:updated', { count: 2 }); expect(testBus.emitted('cart:updated')).toEqual([{ count: 2 }]); expect(testBus.emittedCount('cart:updated')).toBe(1); expect(testBus.allEmitted()).toEqual({ 'cart:updated': [{ count: 2 }] }); testBus.reset(); ``` Returned recordings are snapshots. `reset()` clears recording history without removing listeners; disposal clears both. ## Working with Other Vielzeug Libraries Use Herald for temporal facts, Ripple for retained state, Rune for structured logging inside `tap()`, and Postmaster when work must survive reloads or retry durably. ## Best Practices - Define one explicit event map per module or application boundary. - Emit facts, not mutable retained state. - Keep synchronous listeners short. - Handle rejected promises inside asynchronous listener work. - Keep unsubscribe functions or pass ownership signals. - Use `wait()` and `waitAny()` only for future one-shot events. - Install a `tap()` observer when listener failures must be visible. - Dispose owner-scoped buses. ### Examples ## Examples - [Standalone Bus](./examples/standalone-entry.md) - [Module-Level Bus](./examples/module-level-bus.md) - [Await a One-Time Event](./examples/awaiting-a-one-time-event.md) - [Inspect Listener Counts](./examples/inspecting-listener-counts.md) - [Observe Listener Failures](./examples/custom-error-boundary.md) - [Handle Disposal in Async Code](./examples/handling-disposal-in-async-code.md) - [Scope a Bus to a Request](./examples/request-scoping.md) - [Consume Ongoing Events](./examples/streaming-with-events.md) - [Bridge Buses Explicitly](./examples/bus-bridging-with-pipeevents.md) - [Test with `createTestBus()`](./examples/testing-with-createtestbus.md) ### REPL Examples - AbortSignal & BusDisposedError (id: `abort-signal`) - Async wait() (id: `async-wait`) - Basic Bus (id: `basic-bus`) - createBus - Basics (id: `bus-basics`) - disposalSignal (id: `disposal-signal`) - Error Handling (id: `error-handling`) - Named Bus (id: `named-bus`) - once() and wait() (id: `once-and-wait`) - createTestBus() (id: `test-bus`) - waitAny() (id: `wait-any`) - onAny + wildcardCount() (id: `wildcard-listeners`) --- ## @vielzeug/illusionist **Category:** data ### Overview ## Why Illusionist? Illusionist generates realistic fake data from a single seeded random source. The same seed always produces the same output, so test fixtures and snapshots stay reproducible across runs, machines, and CI. Every category shares one bound instance with one locale, so a person, their email, and their address stay internally consistent. ```ts // Before const user = { name: 'Test User', email: 'test@example.com', address: '123 Main St', }; // After import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 12345, locale: en }); const user = { name: illusion.person.fullName(), email: illusion.internet.email(), address: illusion.location.streetAddress(), }; ``` | Feature | Illusionist | Faker.js | @faker-js/faker | | --- | --- | --- | --- | | Bundle size | | External dependency | External dependency | | Zero external dependencies | | | | | Seeded determinism | | Partial | | | Locale-aware datasets | | | | | TypeScript-native types | | | | **Use Illusionist when** test fixtures, mock APIs, or database seeds must be realistic and reproducible from a single seed value. **Consider @faker-js/faker when** you need a large catalog of locale datasets beyond `en` and `de` or a community plugin ecosystem. ## Installation ```sh [pnpm] pnpm add @vielzeug/illusionist ``` ```sh [npm] npm install @vielzeug/illusionist ``` ```sh [yarn] yarn add @vielzeug/illusionist ``` ## Quick Start Create a bound instance with a seed and locale. All categories share that seed, so output is deterministic. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 12345, locale: en }); illusion.person.fullName(); // 'Ashley Harris' illusion.internet.email(); // 'samantha.sanchez@mail.com' illusion.commerce.price(); // Money { amount: 76640n, currency: USD } illusion.date.past({ years: 2 }); // Temporal.ZonedDateTime ``` ## Features - **`person`**: names, gender, prefixes, suffixes, job titles - **`internet`**: emails, usernames, passwords, URLs, IPs, MACs, HTTP metadata - **`commerce`**: product names, departments, prices as coins `Money` - **`date`**: past, future, recent, between, birthday as tempo `Temporal` objects - **`finance`**: amounts, IBANs, BICs, credit cards, crypto addresses - **`location`**: cities, streets, states, countries, GPS coordinates - **`lorem`**: words, sentences, paragraphs, slugs - **`system`**: file paths, semver, UUIDs, ports, cron expressions ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Arsenal](/arsenal/): random primitives (`RandomSource`, `uuid`) that Illusionist builds on. - [Coins](/coins/): exact money type returned by `commerce.price()` and `finance.amount()`. - [Tempo](/tempo/): `Temporal` date utilities returned by every `date` function. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `createIllusion` | Create a bound, seeded instance | Sync | Locale is fixed for the instance lifetime | | `person.*` | Names, gender, job titles | Sync | Locale-specific datasets (`en`, `de`) | | `internet.*` | Emails, URLs, IPs, HTTP metadata | Sync | `ip()` defaults to IPv4 | | `commerce.*` | Product names, prices | Sync | `price()` returns coins `Money` | | `date.*` | Past, future, recent, birthday | Sync | Returns tempo `Temporal` objects | | `finance.*` | IBANs, cards, crypto addresses | Sync | IBANs pass mod-97; cards pass Luhn | | `location.*` | Cities, streets, GPS | Sync | Locale-specific datasets | | `lorem.*` | Words, sentences, paragraphs | Sync | Word pool is fixed | | `system.*` | Files, semver, UUIDs, ports | Sync | `port()` avoids well-known ports by default | | `createSeed` | Build a `RandomSource` from a seed | Sync | Non-finite numeric seeds throw | | `mulberry32` | Low-level 32-bit PRNG | Sync | Not cryptographically secure | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/illusionist` | `createIllusion`, `createSeed`, `mulberry32`, `Illusionist`, `IllusionistOptions`, `IllusionistLocale`, error classes | | `@vielzeug/illusionist/locales` | Tree-shakeable barrel: `en`, `de` locale objects | | `@vielzeug/illusionist/locales/en` | English locale object only | | `@vielzeug/illusionist/locales/de` | German locale object only | ## createIllusion ```ts function createIllusion(options: IllusionistOptions): Illusionist; ``` Creates a bound instance. All categories share one seeded random source and one locale. Locale data is included only when its dedicated subpath is imported; the root entry does not statically import a default locale. For dynamic switching, use `await import('@vielzeug/illusionist/locales')` before calling this synchronous factory. | Option | Type | Default | Description | | --- | --- | --- | --- | | `seed` | `number \| string` | `undefined` | Seed for deterministic output. Omit for cryptographic randomness. | | `locale` | `IllusionistLocale` | Required | Explicit locale object for locale-aware categories. | **Returns:** `Illusionist`: an object with `person`, `internet`, `commerce`, `date`, `finance`, `location`, `lorem`, and `system` categories, plus readonly `seed` and `locale` values. It owns no external resources and requires no cleanup. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 12345, locale: en }); illusion.person.fullName(); ``` --- ## Person ### `person.firstName()` ```ts function firstName(): string; ``` Returns a random first name from the locale dataset. ### `person.lastName()` ```ts function lastName(): string; ``` Returns a random last name from the locale dataset. ### `person.fullName()` ```ts function fullName(): string; ``` Returns a first and last name separated by a space. ### `person.gender()` ```ts function gender(): string; ``` Returns a random gender label from the locale dataset. ### `person.prefix()` ```ts function prefix(): string; ``` Returns a random name prefix (e.g. `Mr.`, `Dr.`). ### `person.suffix()` ```ts function suffix(): string; ``` Returns a random name suffix. Returns an empty string when the locale dataset has no suffixes. ### `person.jobTitle()` ```ts function jobTitle(): string; ``` Returns a job area and job type joined by a space. --- ## Internet ### `internet.email()` ```ts function email(): string; ``` Returns an email of the form `firstname.lastname@domain.tld`. ### `internet.username()` ```ts function username(): string; ``` Returns either a random alphanumeric string or a `firstname.lastname` pattern. ### `internet.password(options?)` ```ts function password(options?: PasswordOptions): string; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `length` | `number` | `12` | Password length. | | `memorable` | `boolean` | `false` | Build from name fragments and digits. | Returns a password mixing upper/lowercase letters, digits, and special characters. ### `internet.url()` ```ts function url(): string; ``` Returns a URL of the form `protocol://sub.domain.tld/path/...`. ### `internet.domainName()` ```ts function domainName(): string; ``` Returns a domain of the form `domain.tld`. ### `internet.ip(version?)` ```ts function ip(version?: 4 | 6): string; ``` Returns an IPv4 or IPv6 address. Defaults to IPv4. ### `internet.mac()` ```ts function mac(): string; ``` Returns a MAC address of the form `XX:XX:XX:XX:XX:XX`. ### `internet.userAgent()` ```ts function userAgent(): string; ``` Returns a random user agent string. ### `internet.httpMethod()` ```ts function httpMethod(): string; ``` Returns a random HTTP method. ### `internet.statusCode()` ```ts function statusCode(): number; ``` Returns a random HTTP status code. ### `internet.mimeType()` ```ts function mimeType(): string; ``` Returns a random MIME type. --- ## Commerce ### `commerce.productAdjective()` ```ts function productAdjective(): string; ``` Returns a random product adjective. ### `commerce.productMaterial()` ```ts function productMaterial(): string; ``` Returns a random product material. ### `commerce.productNoun()` ```ts function productNoun(): string; ``` Returns a random product noun. ### `commerce.productName()` ```ts function productName(): string; ``` Returns an adjective, material, and noun joined by spaces. ### `commerce.department()` ```ts function department(): string; ``` Returns a random department name. ### `commerce.price(options?)` ```ts function price(options?: PriceOptions): Money; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `min` | `number` | `0.01` | Minimum price. | | `max` | `number` | `1000` | Maximum price. | | `currency` | `'USD' \| 'EUR' \| 'GBP'` | `'USD'` | Currency code. | Returns a coins `Money` value with two decimal places. ### `commerce.productDescription()` ```ts function productDescription(): string; ``` Returns one or two sentences describing a product. --- ## Date All date functions return tempo `Temporal` objects. ### `date.past(options?)` ```ts function past(options?: { years?: number; ref?: Temporal.ZonedDateTime }): Temporal.ZonedDateTime; ``` Returns a date in the past within `years` (default `1`) from `ref` (default now). ### `date.future(options?)` ```ts function future(options?: { years?: number; ref?: Temporal.ZonedDateTime }): Temporal.ZonedDateTime; ``` Returns a date in the future within `years` (default `1`) from `ref` (default now). ### `date.recent(options?)` ```ts function recent(options?: { days?: number; ref?: Temporal.ZonedDateTime }): Temporal.ZonedDateTime; ``` Returns a date within `days` (default `1`) in the past from `ref` (default now). ### `date.between(from, to)` ```ts function between(from: Temporal.ZonedDateTime, to: Temporal.ZonedDateTime): Temporal.ZonedDateTime; ``` Returns a date between `from` and `to`. Returns `from` if `from` is after `to`. ### `date.birthday(options?)` ```ts function birthday(options?: { minAge?: number; maxAge?: number; ref?: Temporal.ZonedDateTime }): Temporal.PlainDate; ``` Returns a `PlainDate` with a random age between `minAge` (default `18`) and `maxAge` (default `80`). ### `date.weekday(locale?)` ```ts function weekday(locale?: string): string; ``` Returns a random weekday name. Uses the instance locale unless overridden. ### `date.month(locale?)` ```ts function month(locale?: string): string; ``` Returns a random month name. Uses the instance locale unless overridden. --- ## Finance ### `finance.amount(options?)` ```ts function amount(options?: AmountOptions): Money; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `min` | `number` | `100` | Minimum amount. | | `max` | `number` | `10000` | Maximum amount. | | `currency` | `'USD' \| 'EUR' \| 'GBP'` | `'USD'` | Currency code. | Returns a coins `Money` value with two decimal places. ### `finance.iban(countryCode?)` ```ts function iban(countryCode?: string): string; ``` Returns an IBAN. Pass a country code to fix the country; otherwise a random supported country is chosen. The check digits are computed so the IBAN passes mod-97 validation. ### `finance.bic()` ```ts function bic(): string; ``` Returns a BIC/SWIFT code of 8 or 11 characters. ### `finance.creditCardNumber(type?)` ```ts function creditCardNumber(type?: 'visa' | 'mastercard' | 'amex'): string; ``` Returns a card number with a valid Luhn check digit. Amex returns 15 digits; others return 16. ### `finance.creditCardCVV(type?)` ```ts function creditCardCVV(type?: 'visa' | 'mastercard' | 'amex'): string; ``` Returns a CVV. Amex returns 4 digits; others return 3. ### `finance.bitcoinAddress()` ```ts function bitcoinAddress(): string; ``` Returns a Bitcoin address with a `1`, `3`, or `bc1` prefix. ### `finance.ethereumAddress()` ```ts function ethereumAddress(): string; ``` Returns a 42-character Ethereum address prefixed with `0x`. ### `finance.transactionType()` ```ts function transactionType(): string; ``` Returns a random transaction type label. ### `finance.bank()` ```ts function bank(): string; ``` Returns a random bank name. --- ## Location ### `location.city()` ```ts function city(): string; ``` Returns a random city from the locale dataset. ### `location.street()` ```ts function street(): string; ``` Returns a random street from the locale dataset. ### `location.streetAddress()` ```ts function streetAddress(): string; ``` Returns a house number (1–999) followed by a street name. ### `location.zipCode()` ```ts function zipCode(): string; ``` Returns a ZIP code matching the locale's pattern. ### `location.state()` ```ts function state(): string; ``` Returns a random state or region from the locale dataset. ### `location.country()` ```ts function country(): string; ``` Returns a random country from the locale dataset. ### `location.latitude()` ```ts function latitude(): number; ``` Returns a latitude in the range `[-90, 90]`. ### `location.longitude()` ```ts function longitude(): number; ``` Returns a longitude in the range `[-180, 180]`. ### `location.nearbyGPSCoordinate(ref?)` ```ts function nearbyGPSCoordinate(ref?: Coordinate): Coordinate; ``` Returns a coordinate within ~1 degree of `ref`. When `ref` is omitted, a random coordinate is used as the base. --- ## Lorem ### `lorem.word()` ```ts function word(): string; ``` Returns a single random word. ### `lorem.words(count?)` ```ts function words(count?: number): string; ``` Returns `count` (default `3`) space-joined words. ### `lorem.sentence(wordCount?)` ```ts function sentence(wordCount?: number): string; ``` Returns a sentence of `wordCount` words (default 6–12) with a capital first letter and trailing period. ### `lorem.sentences(count?)` ```ts function sentences(count?: number): string; ``` Returns `count` (default `3`) space-joined sentences. ### `lorem.paragraph(sentenceCount?)` ```ts function paragraph(sentenceCount?: number): string; ``` Returns a paragraph of `sentenceCount` sentences (default 3–7). ### `lorem.paragraphs(count?)` ```ts function paragraphs(count?: number): string; ``` Returns `count` (default `3`) newline-joined paragraphs. ### `lorem.slug(wordCount?)` ```ts function slug(wordCount?: number): string; ``` Returns a hyphen-joined slug of `wordCount` (default `3`) words. ### `lorem.lines(count?)` ```ts function lines(count?: number): string; ``` Returns `count` (default `5`) newline-joined lines, each a sentence. --- ## System ### `system.fileExtension()` ```ts function fileExtension(): string; ``` Returns a random file extension. ### `system.fileName()` ```ts function fileName(): string; ``` Returns a random file name with extension. ### `system.filePath()` ```ts function filePath(): string; ``` Returns a path with 1–4 directory segments and a file name. ### `system.mimeType()` ```ts function mimeType(): string; ``` Returns a random MIME type. ### `system.semver(options?)` ```ts function semver(options?: { maxMajor?: number; includePrerelease?: boolean }): string; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `maxMajor` | `number` | `20` | Maximum major version. | | `includePrerelease` | `boolean` | `false` | Occasionally append a prerelease label. | Returns a semver string. ### `system.uuid()` ```ts function uuid(): string; ``` Returns a random UUID via `crypto.randomUUID()`. **Not deterministic**: ignores the seeded `RandomSource`. Use only when uniqueness matters more than reproducibility. ### `system.port(options?)` ```ts function port(options?: { min?: number; max?: number }): number; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `min` | `number` | `1024` | Minimum port. | | `max` | `number` | `65535` | Maximum port. | Returns a random port number. ### `system.cron()` ```ts function cron(): string; ``` Returns a random cron expression from common patterns. ### `system.process()` ```ts function process(): string; ``` Returns a random process name of the form `prefix_suffix`. --- ## Seed ```ts import { createSeed, mulberry32 } from '@vielzeug/illusionist'; ``` ### `createSeed(seed?)` ```ts function createSeed(seed?: number | string): RandomSource; ``` Creates a `RandomSource` from a seed. Number seeds are used directly as mulberry32 state. String seeds are hashed to a 32-bit integer. Omit the seed for cryptographic randomness via `crypto.getRandomValues`. Throws `IllusionistSeedError` for non-finite numeric seeds. ```ts const a = createSeed(12345); // deterministic const b = createSeed('hello'); // deterministic (hashed) const c = createSeed(); // cryptographic ``` ### `mulberry32(seed)` ```ts function mulberry32(seed: number): RandomSource; ``` Low-level 32-bit PRNG. Not cryptographically secure. Returns a `RandomSource` producing floats in `[0, 1)`. --- ## Types ```ts type PersonLocaleData = { readonly firstNameFemale: readonly string[]; readonly firstNameMale: readonly string[]; readonly gender: readonly string[]; readonly jobAreas: readonly string[]; readonly jobTypes: readonly string[]; readonly lastName: readonly string[]; readonly prefix: readonly string[]; readonly suffix: readonly string[]; }; type LocationLocaleData = { readonly cities: readonly string[]; readonly countries: readonly string[]; readonly states: readonly string[]; readonly streets: readonly string[]; readonly zipPattern: string; }; type IllusionistLocale = { readonly code: string; readonly person: PersonLocaleData; readonly location: LocationLocaleData; }; type IllusionistOptions = { seed?: number | string; locale: IllusionistLocale; }; type Illusionist = { readonly person: typeof person; readonly internet: typeof internet; readonly commerce: typeof commerce; readonly date: typeof date; readonly finance: typeof finance; readonly location: typeof location; readonly lorem: typeof lorem; readonly system: typeof system; readonly seed: number | string | undefined; readonly locale: IllusionistLocale; }; type Coordinate = { lat: number; lng: number; }; type PasswordOptions = { length?: number; memorable?: boolean; }; type PriceOptions = { readonly min?: number; readonly max?: number; readonly currency?: 'USD' | 'EUR' | 'GBP'; }; type AmountOptions = { readonly min?: number; readonly max?: number; readonly currency?: 'USD' | 'EUR' | 'GBP'; }; // Re-exported from @vielzeug/arsenal type RandomSource = { next(): number; // finite float in [0, 1) }; ``` Category helpers throw `RangeError` when a custom source returns a non-finite value or a value outside `[0, 1)`. ## Errors All errors extend `IllusionistError`, which extends `Error`. Use `instanceof IllusionistError` to catch any illusionist-originated error. | Error | Trigger | Notable properties | | --- | --- | --- | | `IllusionistError` | Base class for all illusionist errors | `name`, `message` | | `IllusionistSeedError` | Non-finite numeric seed passed to `createSeed` (`NaN`, `Infinity`, `-Infinity`) | `name`, `message` | ```ts import { IllusionistError, IllusionistSeedError, createSeed } from '@vielzeug/illusionist'; try { createSeed(Number.NaN); } catch (error) { if (error instanceof IllusionistSeedError) { console.log(error.message); } } ``` ### Usage Guide ## Basic Usage Create an illusionist instance with `createIllusion`. Access data through the eight bound categories. Each call consumes from the shared random source, so output is deterministic for a given seed. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 12345, locale: en }); illusion.person.firstName(); // 'Ashley' illusion.internet.username(); // 'fVEv9zc638m' illusion.commerce.productName(); // 'Intelligent Granite Table' illusion.date.recent({ days: 7 }); // Temporal.ZonedDateTime within the last week illusion.lorem.sentence(); // 'Enim ex non ea minim amet sint laborum proident nisi anim officia.' ``` ## Seeded Determinism Pass a number or string seed to make output reproducible. The same seed always produces the same sequence across runs, machines, and Node versions. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const a = createIllusion({ seed: 12345, locale: en }); const b = createIllusion({ seed: 12345, locale: en }); a.person.fullName() === b.person.fullName(); // true const c = createIllusion({ seed: 'my-test-suite', locale: en }); const d = createIllusion({ seed: 'my-test-suite', locale: en }); c.internet.email() === d.internet.email(); // true: string seeds are hashed ``` Omit the seed for cryptographic randomness backed by `crypto.getRandomValues`. Output is then non-deterministic and unsuitable for snapshots. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const random = createIllusion({ locale: en }); random.person.fullName(); // different every run ``` ## Locale Support Import a locale object and pass it at creation time. The `person` and `location` categories draw from that object's datasets. The `date.weekday` and `date.month` functions use its locale code. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { de, en } from '@vielzeug/illusionist/locales'; const english = createIllusion({ seed: 1, locale: en }); const german = createIllusion({ seed: 1, locale: de }); english.person.firstName(); // 'Mary' german.person.firstName(); // 'Mia' english.location.city(); // 'Austin' german.location.city(); // 'Bremen' english.date.month(); // 'December' german.date.month(); // 'Dezember' ``` The locale is fixed for the lifetime of an instance. Each locale is a separate subpath, so locale data ships only when that subpath is imported; the root package does not statically include English or German data. Create a new instance to switch locales. For dynamic app switching, load the desired locale before calling the synchronous factory: ```ts const { de } = await import('@vielzeug/illusionist/locales/de'); const german = createIllusion({ locale: de }); ``` ### Custom Locales The shipped `en` and `de` objects are just plain data that `satisfies IllusionistLocale`. Build your own the same way: import the type, assemble the `person` and `location` datasets, and pass the result to `createIllusion`. No registration step; the factory accepts any object that matches the shape. ```ts import { createIllusion, type IllusionistLocale } from '@vielzeug/illusionist'; const fr: IllusionistLocale = { code: 'fr', person: { firstNameFemale: ['Marie', 'Camille', 'Sophie'], firstNameMale: ['Louis', 'Hugo', 'Léo'], lastName: ['Martin', 'Bernard', 'Dubois'], gender: ['féminin', 'masculin', 'non-binaire'], jobAreas: ['marketing', 'ingénierie', 'ventes'], jobTypes: ['directeur', 'ingénieur', 'analyste'], prefix: ['M.', 'Mme', 'Dr.'], suffix: ['PhD', 'Jr.'], }, location: { cities: ['Paris', 'Lyon', 'Marseille'], countries: ['France', 'Belgique', 'Suisse'], states: ['Île-de-France', 'Auvergne-Rhône-Alpes', 'Provence-Alpes-Côte d\'Azur'], streets: ['rue de la Paix', 'avenue des Champs-Élysées', 'boulevard Saint-Germain'], zipPattern: '#####', }, }; const illusion = createIllusion({ seed: 42, locale: fr }); illusion.person.fullName(); // 'Camille Dubois' illusion.location.city(); // 'Marseille' illusion.person.jobTitle(); // 'marketing ingénieur' ``` `date.weekday()` and `date.month()` currently ship English and German name arrays only; a custom locale code falls through to the English set. For other languages, format a generated `Temporal` date with `@vielzeug/tempo`'s `format()` and your own `Intl.DateTimeFormat` options. Use `satisfies IllusionistLocale` instead of a bare type annotation to get error locality: TypeScript points at the offending field rather than the whole object. ## Category Overview | Category | Example call | Returns | | --- |-------------------------------------| --- | | `person` | `illusion.person.fullName()` | `string` | | `internet` | `illusion.internet.email()` | `string` | | `commerce` | `illusion.commerce.price()` | `Money` (coins) | | `date` | `illusion.date.past({ years: 1 })` | `Temporal.ZonedDateTime` (tempo) | | `finance` | `illusion.finance.iban()` | `string` | | `location` | `illusion.location.streetAddress()` | `string` | | `lorem` | `illusion.lorem.paragraph()` | `string` | | `system` | `illusion.system.uuid()` | `string` | ## Working with Other Vielzeug Libraries Illusionist integrates with other Vielzeug packages at the return-type level. `commerce.price()` and `finance.amount()` return coins `Money`, so you can format, add, or allocate them directly. `date` functions return tempo `Temporal` objects, so you can shift, compare, or format them. ```ts import { format, add, money } from '@vielzeug/coins'; import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; import { formatZonedDateTimeISO } from '@vielzeug/tempo'; const illusion = createIllusion({ seed: 42, locale: en }); const price = illusion.commerce.price({ min: 10, max: 50, currency: 'EUR' }); const tax = money('5.00', price.currency); const total = add(price, tax); console.log(format(total, { locale: 'de-DE' })); const orderDate = illusion.date.recent({ days: 30 }); console.log(formatZonedDateTimeISO(orderDate)); ``` ## Best Practices - Pass a seed in tests and CI; omit it only for one-off non-reproducible mocks. - Create one instance per test case so each test starts from a known random state. - Treat instances as ordinary stateful values; they own no resources and require no cleanup. - Fix the locale at creation time; create a new instance to switch locales rather than mixing. - Use string seeds for named test suites: they are self-documenting and hash to a stable number. - Combine `person`, `internet`, and `location` to build internally consistent mock entities. - Treat `Money` and `Temporal` return values as first-class: pass them to coins and tempo functions directly. - Avoid sharing a single instance across concurrent async tasks; each call advances the shared random source. - `system.uuid()` uses `crypto.randomUUID()`, not the seeded source. Do not use it in deterministic fixtures or snapshot tests. ### Examples ## Generating Test Fixtures Build a batch of realistic records from a fixed seed. The same seed reproduces the same fixtures in every run. For a full Vitest setup, see the [Test Fixtures recipe](./examples/test-fixtures.md). ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 'fixtures-v1', locale: en }); const users = Array.from({ length: 10 }, () => ({ name: illusion.person.fullName(), email: illusion.internet.email(), address: illusion.location.streetAddress(), city: illusion.location.city(), zip: illusion.location.zipCode(), })); ``` ## Seeded Test Data for Snapshot Testing Use a named string seed so snapshot output is stable across CI runs. Each test creates its own instance to avoid cross-test random-state drift. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; test('order receipt snapshot', () => { const illusion = createIllusion({ seed: 'order-receipt', locale: en }); const order = { customer: illusion.person.fullName(), product: illusion.commerce.productName(), price: illusion.commerce.price({ min: 5, max: 50 }), date: illusion.date.recent({ days: 7 }), }; expect(order).toMatchInlineSnapshot(); }); ``` ## Locale-Specific Data (German) Import the German locale object to draw names, cities, and weekday labels from its dataset. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { de } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 42, locale: de }); illusion.person.fullName(); // 'Mathilda Scholz' illusion.location.city(); // 'Nürnberg' illusion.location.zipCode(); // '15268' illusion.date.weekday(); // 'Donnerstag' illusion.date.month(); // 'März' ``` ## E-commerce Mock Data Combine `person`, `commerce`, and `location` to build a consistent customer-order-shipping record. `commerce.price()` returns coins `Money`, so you can format it directly. ```ts import { format } from '@vielzeug/coins'; import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 'ecommerce-mock', locale: en }); const order = { customer: { name: illusion.person.fullName(), email: illusion.internet.email(), }, item: illusion.commerce.productName(), price: illusion.commerce.price({ min: 20, max: 200, currency: 'EUR' }), shipping: { address: illusion.location.streetAddress(), city: illusion.location.city(), zip: illusion.location.zipCode(), country: illusion.location.country(), }, }; console.log(format(order.price, { locale: 'de-DE' })); ``` ## Database Seeding Pattern Generate rows for a database seed script. Use a stable seed so the seed file is reproducible and reviewable. ```ts import { createIllusion } from '@vielzeug/illusionist'; import { en } from '@vielzeug/illusionist/locales'; const illusion = createIllusion({ seed: 'db-seed-2024', locale: en }); const products = Array.from({ length: 50 }, () => ({ name: illusion.commerce.productName(), department: illusion.commerce.department(), price: illusion.commerce.price({ min: 1, max: 500 }), description: illusion.commerce.productDescription(), })); const customers = Array.from({ length: 100 }, () => ({ firstName: illusion.person.firstName(), lastName: illusion.person.lastName(), email: illusion.internet.email(), createdAt: illusion.date.past({ years: 2 }), })); ``` ### REPL Examples - commerce - Products, departments, and prices (id: `commerce-basic`) - date - Past, future, birthdays, and locale labels (id: `date-basic`) - seed - Deterministic output from the same seed (id: `determinism-basic`) - finance - IBANs, cards, BICs, and crypto addresses (id: `finance-basic`) - internet - Emails, URLs, and network addresses (id: `internet-basic`) - locales - English and German side-by-side (id: `locale-basic`) - location - Addresses, regions, and coordinates (id: `location-basic`) - person - Names, gender, and job titles (id: `person-basic`) - system - Files, semver, UUIDs, ports, and cron (id: `system-basic`) --- ## @vielzeug/keymap **Category:** app-infrastructure ### Overview ## Why Keymap? Browser keyboard handling needs modifier normalization, chord state, context policy, and listener ownership. Keymap keeps those concerns in one headless, zero-dependency handle. ```ts // Before window.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.key === 's') event.preventDefault(); }); // After import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap([ { id: 'save', shortcut: 'mod+s', handler: () => console.log('save') }, ]); const unmount = map.mount(document); unmount(); map.dispose(); ``` | 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 | Event-aware `when(event)` predicate | | Chord ownership | Application-managed state | Per mounted target | | Per-binding control | Manual `preventDefault`/`stopPropagation` | Per-binding `preventDefault`/`stopPropagation` with safe defaults | | Disposable | Manual `removeEventListener` | Terminal `dispose()` + `[Symbol.dispose]()` | **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. **Consider 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 Create, mount, then dispose one map owned by your UI scope. ```ts import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap([ { id: 'save', shortcut: 'mod+k mod+s', handler: () => console.log('save') }, { id: 'palette', shortcut: 'mod+shift+p', handler: () => console.log('open palette') }, { id: 'top', shortcut: 'g g', handler: () => window.scrollTo({ top: 0 }) }, { id: 'close', shortcut: 'escape', handler: () => console.log('close panel') }, ]); const unmount = map.mount(document); unmount(); map.dispose(); ``` ## Features - `createKeymap()`: Create a keymap from an ordered binding array; 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 - `Binding` objects: each binding has an explicit `id`, `shortcut`, `handler`, and optional `trigger`, `when`, `preventDefault`, `stopPropagation` - Per-binding `preventDefault`/`stopPropagation`: safe defaults (`true`/`false`) applied to completed shortcuts and intermediate chord steps - `tap()`: observe chord progress, timeout, matches, and disposal without changing behavior - `modKey` option: explicit platform override for SSR and cross-platform tests - `formatShortcut()`: platform-aware display (`⇧⌘P` on Mac, `Ctrl+Shift+P` elsewhere) - `@vielzeug/keymap/parse` subpath: `parseShortcut()`, `parseStep()`, `matchStep()`, `matchKey()`, `canonicalizeShortcut()`, `detectModKey()` for custom tooling - `listBindings()`: snapshot all active bindings (id, shortcut, trigger, preventDefault, stopPropagation) 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) - [Migration Guide](./migration.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 ### Core API (Most Users) | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createKeymap()` | Create shortcut manager from an ordered binding array | Sync | `dispose()` is terminal | | `findShortcutConflicts()` | Find duplicate and prefix paths | Sync | Invalid non-empty input throws | | `formatShortcut()` | Format shortcut labels | Sync | Invalid input returns `''` | | `matchKey()` | Match an event against one shortcut pattern | Sync | Unparseable pattern returns `false`; chord patterns never match | | `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` | | `tap()` | Observe chord lifecycle, matches, and disposal | Sync | Observer failures are swallowed | | `Binding` | Per-binding config type (id, shortcut, handler, trigger, when, preventDefault, stopPropagation) | N/A | Reusing an `id` replaces its binding | ### Parser Subpath (`@vielzeug/keymap/parse`) Use the parser subpath if you're building keyboard-aware config validators, custom UI, or framework integrations. | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `parseShortcut()` | Strictly parse full shortcut | Sync | Empty input throws | | `parseStep()` | Parse one step without throwing | Sync | Invalid input returns `null` | | `canonicalizeShortcut()` | Create stable shortcut key | Sync | Input must already be parsed | | `matchStep()` | Test event against parsed step | Sync | Extra modifiers prevent a match | | `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` | ### Errors | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `KeymapError` | Base Keymap error | Sync | Includes parse, config, and lifecycle errors | | `KeymapConfigError` | Invalid `createKeymap` option | Sync | Thrown for a non-positive `chordTimeout` | | `KeymapParseError` | Strict parser error | Sync | `parseStep()` never throws it | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/keymap` | Root entry point for `createKeymap`, `formatShortcut`, `findShortcutConflicts`, `matchKey`, `detectModKey`, errors, and public types. | | `@vielzeug/keymap/parse` | Parser internals subpath for custom tooling: `parseShortcut`, `parseStep`, `matchStep`, `matchKey`, `canonicalizeShortcut`, `detectModKey`. | ## Core Manager ### `createKeymap()` ```ts function createKeymap( bindings?: readonly Binding[], options?: KeymapOptions, ): Keymap; ``` Creates shortcut manager with independent chord state for each mounted target. | Parameter | Type | Description | | --- | --- | --- | | `bindings` | `readonly Binding[]` | Ordered array of bindings. Each binding has an explicit `id`, `shortcut`, and `handler`. | | `options` | `KeymapOptions` | Chord, modifier, and global-guard configuration. | **Returns:** `Keymap`. ```ts import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap([ { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save') }, ]); const unmount = map.mount(document); unmount(); map.dispose(); ``` | `Keymap` member | Return | Contract | | --- | --- | --- | | `bind(binding)` | `() => void` | Adds or replaces a binding with the same `id`. Returned callback removes that binding while active. | | `mount(target)` | `() => void` | Adds target listener. Repeat mounts of same target are reference-counted. | | `unbind(id)` | `void` | Removes binding by id. Warns in development when unknown. | | `listBindings()` | `readonly BindingEntry[]` | Returns a detached binding snapshot. | | `tap(handler, options?)` | `() => void` | Observes chord lifecycle, matches, and disposal. Supports signal-owned teardown. | | `dispose()` | `void` | Removes all listeners, aborts signal, and permanently disposes map. Idempotent. | | `disposed` | `boolean` | `true` after first `dispose()`. | | `disposalSignal` | `AbortSignal` | Aborts when map is disposed. | | `[Symbol.dispose]()` | `void` | Calls `dispose()`. | After disposal, `bind()`, `unbind()`, and `mount()` throw `KeymapError`. ## Runtime Observation ### `tap()` ```ts function tap( handler: (event: KeymapEvent) => void, options?: { signal?: AbortSignal }, ): () => void; ``` Observes chord progress, cancellation, completed matches, timeouts, and disposal without affecting shortcut behavior. | Parameter | Type | Description | | --- | --- | --- | | `handler` | `(event: KeymapEvent) => void` | Receives each runtime event synchronously. Errors are swallowed. | | `options.signal` | `AbortSignal` | Detaches the handler when aborted. | **Returns:** An idempotent detach function. ```ts const stop = map.tap((event) => { if (event.type === 'chord-start') showChordHint(event.step); if (event.type === 'chord-cancel' || event.type === 'chord-timeout' || event.type === 'match') hideChordHint(); }); ``` ## Conflict Analysis ### `findShortcutConflicts()` ```ts function findShortcutConflicts( shortcut: string, entries: readonly BindingEntry[], options?: ConflictOptions, ): BindingEntry[]; ``` Returns entries with same-trigger exact or prefix-conflicting shortcut paths. | Parameter | Type | Description | | --- | --- | --- | | `shortcut` | `string` | Proposed shortcut. Empty or whitespace-only input returns no conflicts. | | `entries` | `readonly BindingEntry[]` | Bindings to compare, commonly `map.listBindings()`. | | `options` | `ConflictOptions` | Optional modifier resolution and trigger filter. | **Returns:** Matching entries. Returns `[]` when no conflict exists. ```ts import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'; const map = createKeymap([{ id: 'top', shortcut: 'g', handler: () => console.log('top') }]); const conflicts = findShortcutConflicts('g g', map.listBindings()); console.log(conflicts.length); // 1 ``` ## Formatting ### `formatShortcut()` ```ts function formatShortcut(shortcut: string, modKey?: 'ctrl' | 'meta'): string; ``` Formats parsed shortcut into Mac symbols for `meta` or word labels for `ctrl`. | Parameter | Type | Description | | --- | --- | --- | | `shortcut` | `string` | Shortcut string to format. | | `modKey` | `'ctrl' \| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. | **Returns:** Display label, or `''` for invalid input. ```ts import { formatShortcut } from '@vielzeug/keymap'; formatShortcut('mod+shift+p', 'meta'); // ⇧⌘P formatShortcut('mod+shift+p', 'ctrl'); // Ctrl+Shift+P ``` ## Parser Subpath (`@vielzeug/keymap/parse`) ### `parseShortcut()` ```ts function parseShortcut(raw: string, modKey?: 'ctrl' | 'meta'): Shortcut; ``` Strictly parses one or more space-separated shortcut steps. | Parameter | Type | Description | | --- | --- | --- | | `raw` | `string` | Full shortcut string. | | `modKey` | `'ctrl' \| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. | **Returns:** Parsed `Shortcut`. ```ts import { parseShortcut } from '@vielzeug/keymap/parse'; const shortcut = parseShortcut('ctrl+k ctrl+s', 'ctrl'); console.log(shortcut.length); // 2 ``` Throws `KeymapParseError` for empty, modifier-only, or ambiguous steps. --- ### `parseStep()` ```ts function parseStep(raw: string, modKey?: 'ctrl' | 'meta'): ShortcutStep | null; ``` Parses one shortcut step without throwing. | Parameter | Type | Description | | --- | --- | --- | | `raw` | `string` | One shortcut step. | | `modKey` | `'ctrl' \| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. | **Returns:** Parsed `ShortcutStep`, or `null` for empty, modifier-only, or ambiguous input. ```ts import { parseStep } from '@vielzeug/keymap/parse'; parseStep('ctrl+k', 'ctrl'); // { key: 'k', modifiers: Set(['ctrl']) } parseStep('ctrl+k+j', 'ctrl'); // null ``` --- ### `canonicalizeShortcut()` ```ts function canonicalizeShortcut(steps: readonly ShortcutStep[]): string; ``` Converts parsed steps into stable canonical string with sorted modifier order. | Parameter | Type | Description | | --- | --- | --- | | `steps` | `readonly ShortcutStep[]` | Parsed shortcut steps. | **Returns:** Canonical shortcut string. ```ts import { canonicalizeShortcut, parseShortcut } from '@vielzeug/keymap/parse'; canonicalizeShortcut(parseShortcut('shift+ctrl+k', 'ctrl')); // ctrl+shift+k ``` --- ### `matchStep()` ```ts function matchStep(event: KeyboardEvent, step: ShortcutStep): boolean; ``` Tests exact key and modifier equality for one parsed step. | Parameter | Type | Description | | --- | --- | --- | | `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. | | `step` | `ShortcutStep` | Parsed step. | **Returns:** `true` only when key and all modifier states match. ```ts import { matchStep, parseStep } from '@vielzeug/keymap/parse'; const step = parseStep('ctrl+k', 'ctrl')!; matchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), step); // true ``` --- ### `matchKey()` ```ts function matchKey(event: KeyboardEvent, pattern: string, modKey?: 'ctrl' | 'meta'): boolean; ``` Matches a keyboard event against a single shortcut pattern such as `'ArrowDown'`, `'esc'`, `'mod+k'`, or `'shift+Home'`. Aliases and `mod` resolve through the same parser `createKeymap` uses, and modifier state must match exactly: `'ArrowDown'` does not match Ctrl+ArrowDown. Exported from both the root entry point and `@vielzeug/keymap/parse`. | Parameter | Type | Description | | --- | --- | --- | | `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. | | `pattern` | `string` | One shortcut step. Unparseable patterns and multi-step chords return `false` (never throws). | | `modKey` | `'ctrl' \| 'meta'` | Optional. Resolution for `mod`; defaults to `detectModKey()`. | **Returns:** `true` only when key and all modifier states match. ```ts import { matchKey } from '@vielzeug/keymap'; if (matchKey(event, 'mod+k')) openPalette(); if (matchKey(event, 'shift+Home')) selectToStart(event); ``` --- ### `detectModKey()` ```ts function detectModKey(): 'ctrl' | 'meta'; ``` Detects Mac platform from `navigator` and otherwise returns `ctrl`. **Returns:** `'meta'` on Mac platforms; `'ctrl'` elsewhere or without `navigator`. ```ts import { detectModKey } from '@vielzeug/keymap/parse'; const modKey = detectModKey(); ``` ## Types ### `Keymap` Stateful shortcut manager returned by `createKeymap()`. ```ts interface Keymap { [Symbol.dispose](): void; bind(binding: Binding): () => void; dispose(): void; readonly disposalSignal: AbortSignal; readonly disposed: boolean; listBindings(): readonly BindingEntry[]; mount(target: EventTarget): () => void; tap(handler: (event: KeymapEvent) => void, options?: { signal?: AbortSignal }): () => void; unbind(id: string): void; } ``` ### `KeymapOptions` Options applied to every binding owned by one manager. ```ts interface KeymapOptions { chordTimeout?: number; modKey?: 'ctrl' | 'meta'; when?: When; } ``` - `when`: Guard function called for all bindings. When combined with per-binding `when` guards, both must return `true` for the handler to fire (AND composition). Global guard is checked first. - `chordTimeout`: Must be a positive finite number; `createKeymap()` throws `KeymapConfigError` otherwise. Defaults to `1000`. ### `KeymapEvent` Runtime event observed through `tap()`. ```ts type KeymapEvent = | { type: 'chord-cancel'; target: EventTarget; trigger: 'keydown' | 'keyup' } | { type: 'chord-start'; step: ShortcutStep; target: EventTarget; trigger: 'keydown' | 'keyup' } | { type: 'chord-progress'; steps: readonly ShortcutStep[]; target: EventTarget; trigger: 'keydown' | 'keyup' } | { type: 'chord-timeout'; target: EventTarget; trigger: 'keydown' | 'keyup' } | { type: 'match'; binding: BindingEntry; target: EventTarget; trigger: 'keydown' | 'keyup' } | { type: 'dispose' }; ``` `step`, `steps`, and `binding` are detached snapshots. Mutating them does not affect matching. ### `Binding` Per-binding configuration. Each binding has an explicit `id` so duplicate shortcuts can coexist. Reusing an ID replaces its binding; among duplicate shortcuts, the first binding whose guard passes wins. ```ts interface Binding { id: string; shortcut: string; handler: Handler; trigger?: 'keydown' | 'keyup'; when?: When; preventDefault?: boolean; stopPropagation?: boolean; } ``` - `trigger`: Defaults to `'keydown'`. - `preventDefault`: Defaults to `true`. Set to `false` for shortcuts that must retain browser behavior. - `stopPropagation`: Defaults to `false`. ### `Handler` and `When` ```ts type Handler = (event: KeyboardEvent) => void; type When = (event: KeyboardEvent) => boolean; ``` ### `BindingEntry` Detached binding metadata returned by `listBindings()`. ```ts type BindingEntry = { readonly id: string; readonly shortcut: readonly ShortcutStep[]; readonly trigger: 'keydown' | 'keyup'; readonly preventDefault: boolean; readonly stopPropagation: boolean; }; ``` ### `ModifierKey`, `Shortcut`, and `ShortcutStep` Parser types used by `parseShortcut()`, `parseStep()`, `matchStep()`, and `canonicalizeShortcut()`. Available from `@vielzeug/keymap/parse`. ```ts type ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift'; type ShortcutStep = { key: string; modifiers: Set; }; type Shortcut = ShortcutStep[]; ``` ### `ConflictOptions` Comparison options for `findShortcutConflicts()`. ```ts interface ConflictOptions { modKey?: 'ctrl' | 'meta'; trigger?: 'keydown' | 'keyup'; } ``` ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `KeymapError` | Lifecycle operation after disposal | Use `instanceof KeymapError` to narrow Keymap errors. | | `KeymapConfigError` | `createKeymap()` receives an invalid option | Extends `KeymapError`. Thrown for a non-positive or non-finite `chordTimeout`. | | `KeymapParseError` | Strict shortcut parser receives invalid input | Extends `KeymapError`. | ### Usage Guide ## Basic Usage Mount one keymap, then release its target listener and dispose its owner during teardown. ```ts import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap([ { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save') }, { id: 'undo', shortcut: 'ctrl+z', handler: () => console.log('undo') }, { id: 'close', shortcut: 'escape', handler: () => console.log('close') }, ]); const unmount = map.mount(document); // Call this when the owning UI scope ends. unmount(); map.dispose(); ``` `unmount()` only releases that target. `dispose()` releases every target, aborts `disposalSignal`, and makes `bind()`, `unbind()`, and `mount()` unavailable. ## Modifier Aliases Use aliases to accept platform terminology while Keymap stores one canonical shortcut. | Input | Canonical modifier | | --- | --- | | `cmd`, `command`, `win` | `meta` | | `opt`, `option` | `alt` | | `ctrl`, `control` | `ctrl` | | `mod` | `meta` on Mac; `ctrl` elsewhere | Pass `modKey` when rendering or testing a specific platform. ```ts import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap( [{ id: 'palette', shortcut: 'mod+k', handler: () => console.log('open palette') }], { modKey: 'ctrl' }, ); map.mount(document); ``` ## Chord Sequences Separate chord steps with spaces. Keymap resets an incomplete sequence after `chordTimeout` milliseconds. ```ts const map = createKeymap( [ { id: 'save', shortcut: 'ctrl+k ctrl+s', handler: () => console.log('save') }, { id: 'top', shortcut: 'g g', handler: () => window.scrollTo({ top: 0 }) }, { id: 'end', shortcut: 'g e', handler: () => window.scrollTo({ top: document.body.scrollHeight }) }, ], { chordTimeout: 800 }, ); ``` Do not bind a complete shortcut and a longer chord beginning with that shortcut. `g` fires immediately, so `g g` cannot complete. Check proposed user bindings with `findShortcutConflicts()`. ## Binding Options Each binding is an object with an explicit `id`, `shortcut`, and `handler`. Add a guard, choose `keyup`, or control event behavior with optional fields. ```ts const map = createKeymap([ { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() }, { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel }, { id: 'play', shortcut: 'space', handler: togglePlayback, trigger: 'keyup' }, { id: 'inspect', shortcut: 'f12', handler: inspectElement, preventDefault: false }, ]); ``` ### Per-Binding preventDefault and stopPropagation Each binding controls `preventDefault` and `stopPropagation` independently: - `preventDefault` defaults to `true`: completed shortcuts and matched chord prefixes suppress browser defaults. - `stopPropagation` defaults to `false`: events continue bubbling unless explicitly stopped. Set `preventDefault: false` for shortcuts that must coexist with native controls (e.g. developer tools, accessibility features). ## Context Guards Use global `when(event)` for policy shared by every binding. Use per-binding `when(event)` when one shortcut needs a narrower policy. ```ts const map = createKeymap( [ { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel }, { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() }, ], { when: (event) => !modalIsOpen() && event.isTrusted }, ); ``` Accept `KeyboardEvent` when guard logic needs target, modifier, composition, or shadow-DOM context. ### Guard Composition: Global + Per-Binding When you provide both a global `when` (in `KeymapOptions`) and per-binding `when` guards, both must return `true` for the handler to fire. This is AND composition. **Guard evaluation and chord tracking order:** 1. **Global guard runs first.** If it returns `false`, the target's pending chord state resets. 2. **Per-binding guards select candidates.** A binding participates in each chord step only while its guard passes. 3. **Chord state advances for eligible candidates.** The first completed candidate in binding order runs. A chord cannot begin while its map is disabled and finish after the global guard becomes active. ```ts const map = createKeymap( [ { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel }, { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() }, ], { when: (event) => !isModalOpen() && event.isTrusted }, ); // Global guard runs first; if false, both bindings are skipped (handler doesn't fire). // If global passes: // - 'save' handler fires immediately. // - 'close' handler fires only if event.target is the panel. ``` ### Preserve Native Text Editing Use `event.composedPath()` to keep browser undo and redo inside inputs, textareas, and `contenteditable` elements. CRM app shell uses this policy for its global undo and redo shortcuts. ```ts const isTypingInField = (event: KeyboardEvent): boolean => event.composedPath().some( (target) => target instanceof HTMLElement && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable), ); const map = createKeymap( [ { id: 'undo', shortcut: 'mod+z', handler: () => undo() }, { id: 'redo', shortcut: 'mod+shift+z', handler: () => redo() }, ], { when: (event) => !isTypingInField(event) }, ); ``` Do not make editable-field suppression a hidden package default. Applications may intentionally bind shortcuts inside editable controls. ## Trigger Control Bind on `keyup` when an action must run after key release. ```ts const map = createKeymap([ { id: 'confirm', shortcut: 'space', handler: confirmAction, trigger: 'keyup' }, ]); ``` `keydown` and `keyup` maintain independent chord state. ## Replace Bindings at Runtime `bind(binding)` adds or replaces a binding with the same `id` and returns a targeted removal callback. ```ts const map = createKeymap([{ id: 'palette', shortcut: 'ctrl+k', handler: defaultAction }]); const removePluginBinding = map.bind({ id: 'palette', shortcut: 'ctrl+k', handler: pluginAction }); removePluginBinding(); map.bind({ id: 'palette', shortcut: 'ctrl+k', handler: defaultAction }); ``` `unbind(id)` removes the binding with that id and warns in development when no binding exists. ### Duplicate Shortcuts with Different IDs Each binding has an explicit `id`, so duplicate shortcuts can coexist. Keymap evaluates them in insertion order and fires the first binding whose guard passes. ```ts const map = createKeymap([ { id: 'primary', shortcut: 'ctrl+k', handler: primaryAction }, { id: 'fallback', shortcut: 'ctrl+k', handler: fallbackAction }, ]); // 'primary' fires while eligible; use mutually exclusive guards for contextual fallback. ``` ## Format Shortcut Labels Format labels with explicit platform behavior when your UI is cross-platform. ```ts import { formatShortcut } from '@vielzeug/keymap'; console.log(formatShortcut('mod+shift+p', 'meta')); // ⇧⌘P console.log(formatShortcut('mod+shift+p', 'ctrl')); // Ctrl+Shift+P ``` `formatShortcut()` returns `''` and emits a development warning for invalid input. ## Detect Conflicts Check a custom shortcut before binding it to prevent duplicate or unreachable chord paths. ```ts import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'; const map = createKeymap([{ id: 'top', shortcut: 'g', handler: () => scrollToTop() }]); const conflicts = findShortcutConflicts('g g', map.listBindings()); if (conflicts.length === 0) map.bind({ id: 'bottom', shortcut: 'g g', handler: () => scrollToBottom() }); ``` Conflict detection compares only bindings with same trigger. An empty proposal returns no conflicts; other invalid proposals throw `KeymapParseError`. ## Parser Subpath For custom tooling, validators, or framework integrations that need direct access to the shortcut parser, import from `@vielzeug/keymap/parse`: ```ts import { parseShortcut, parseStep, matchStep, canonicalizeShortcut } from '@vielzeug/keymap/parse'; const steps = parseShortcut('ctrl+k ctrl+s', 'ctrl'); const step = parseStep('ctrl+k', 'ctrl'); const isMatch = matchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), steps[0]); ``` `matchKey()` and `detectModKey()` are also exported from the root entry point. For a one-off check against a pattern string, prefer `matchKey()`: it parses and matches in one call and returns `false` instead of throwing: ```ts import { matchKey } from '@vielzeug/keymap'; if (matchKey(event, 'mod+k')) openPalette(); ``` ## Mount Targets Mount one keymap on multiple independent targets when each target should own its own chord progression. ```ts const map = createKeymap([{ id: 'top', shortcut: 'g g', handler: () => console.log('go to top') }]); const unmountEditor = map.mount(editor); const unmountPreview = map.mount(preview); ``` A chord started on `editor` cannot complete on `preview`. Repeated `mount(editor)` calls share one listener and require one unmount call each. For nested targets, Keymap handles one bubbled event at its innermost mounted target. ## Scoped Maps Create separate keymaps for separate UI owners. If maps share a target and shortcut, guards must be mutually exclusive because Keymap has no implicit layer precedence. ```ts const baseMap = createKeymap( [{ id: 'close-sidebar', shortcut: 'escape', handler: () => closeSidebar() }], { when: () => !modalIsOpen() }, ); const modalMap = createKeymap( [{ id: 'close-modal', shortcut: 'escape', handler: () => closeModal() }], { when: () => modalIsOpen() }, ); const unmountBase = baseMap.mount(document); const unmountModal = modalMap.mount(document); ``` ## Observe Chord Activity Use `tap()` for chord hints, diagnostics, and matched-binding telemetry. Observers cannot alter shortcut behavior. ```ts const stopTrace = map.tap((event) => { if (event.type === 'chord-start') showChordHint(event.step); if (event.type === 'chord-progress') updateChordHint(event.steps); if (event.type === 'chord-cancel' || event.type === 'chord-timeout' || event.type === 'match') hideChordHint(); }, { signal: owner.signal }); ``` Tap handlers receive detached shortcut and binding snapshots. Handler errors are swallowed, and disposal emits a final `dispose` event before observers are cleared. ## Testing Dispatch `KeyboardEvent` instances against a mounted DOM target to test handlers and default prevention. ```ts import { expect, it, vi } from 'vitest'; import { createKeymap } from '@vielzeug/keymap'; it('handles save', () => { const save = vi.fn(); const target = document.createElement('button'); const map = createKeymap([{ id: 'save', shortcut: 'ctrl+s', handler: save }]); const unmount = map.mount(target); target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 's' })); expect(save).toHaveBeenCalledOnce(); unmount(); map.dispose(); }); ``` Mount nested DOM targets in tests when your application uses both a container and a descendant listener. This verifies one bubbled event cannot complete a chord twice. ## Framework Integration Create map during framework lifecycle, then dispose it during teardown. ```tsx [React] import { useEffect } from 'react'; import { createKeymap } from '@vielzeug/keymap'; export function App() { useEffect(() => { const map = createKeymap([{ id: 'palette', shortcut: 'ctrl+k', handler: () => console.log('open palette') }]); const unmount = map.mount(document); return () => { unmount(); map.dispose(); }; }, []); return null; } ``` ```vue [Vue 3] import { onMounted, onUnmounted } from 'vue'; import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap([{ id: 'close', shortcut: 'escape', handler: () => console.log('close palette') }]); let unmount: (() => void) | undefined; onMounted(() => { unmount = map.mount(document); }); onUnmounted(() => { unmount?.(); map.dispose(); }); ``` ```ts [Svelte] import { onMount } from 'svelte'; import { createKeymap } from '@vielzeug/keymap'; const map = createKeymap([{ id: 'close', shortcut: 'escape', handler: () => console.log('close palette') }]); onMount(() => { const unmount = map.mount(document); return () => { unmount(); map.dispose(); }; }); ``` ## Working with Other Vielzeug Libraries ### Keymap + Ledger Connect undo and redo handlers to a Ledger owner. ```ts import { createKeymap } from '@vielzeug/keymap'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const reportHistoryError = (error: unknown): void => console.error(error); const map = createKeymap([ { id: 'undo', shortcut: 'mod+z', handler: () => void ledger.undo().catch(reportHistoryError) }, { id: 'redo', shortcut: 'mod+shift+z', handler: () => void ledger.redo().catch(reportHistoryError) }, ]); map.mount(document); ``` ### Keymap + Herald Emit domain events instead of calling application actions from shortcut handlers. ```ts import { createBus } from '@vielzeug/herald'; import { createKeymap } from '@vielzeug/keymap'; const bus = createBus(); const map = createKeymap([ { id: 'save', shortcut: 'ctrl+s', handler: () => bus.emit('shortcut:save') }, ]); map.mount(document); ``` ## Best Practices - **Dispose** every map when its owner ends. - **Unmount** temporary target listeners instead of disposing reusable maps. - **Guard** global text-editing shortcuts with `event.composedPath()`. - **Check** conflicts before accepting customized shortcuts. - **Keep** shared-target guards mutually exclusive. - **Use** `mod` for primary cross-platform shortcuts. - **Avoid** prefix pairs such as `g` and `g g`. - **Use** unique `id`s for each binding and mutually exclusive guards for intentional duplicate shortcuts. ### Examples ## Examples - [Global Shortcuts](./examples/global-shortcuts.md) - [Vim-style Navigation](./examples/vim-navigation.md) ### REPL Examples - Basic Shortcuts (id: `basic-shortcuts`) - Chord Sequences (id: `chord-sequences`) - Conflict Detection (id: `conflict-detection`) - Format & Conflicts (id: `format-and-conflicts`) - Shortcut Utilities (id: `shortcut-utilities`) --- ## @vielzeug/ledger **Category:** utilities ### Overview ## Why Ledger? Undo and redo require more than array manipulation when operations are asynchronous, cancellable, and visible in a UI. Ledger serializes only reversible commands, owns queue lifecycle, and publishes one atomic framework-neutral state snapshot without a reactive runtime dependency. ```ts // Before const undo = () => changes.pop()?.revert(); // After import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); await ledger.do({ apply: saveNext, revert: restorePrevious }); await ledger.undo(); ``` | Feature | Roll your own | Ledger | | --- | --- | --- | | Bundle size | 0 B | | | Reversible history | Manual arrays | | | Serialized async work | Manual queue | | | Queue cancellation | Manual ownership | Abort-aware lifecycle | | Framework-neutral state | Manual events | Structural `Subscribable` | | Composition | Custom transaction code | `compose()` | **Use Ledger when** you own reversible asynchronous state transitions and need undo, redo, or history UI. **Consider direct application code when** work is irreversible, fire-and-forget, or does not need history. ## Installation ```sh [pnpm] pnpm add @vielzeug/ledger ``` ```sh [npm] npm install @vielzeug/ledger ``` ```sh [yarn] yarn add @vielzeug/ledger ``` ## Quick Start Submit a reversible command, read state, then dispose its owner. ```ts import { createLedger } from '@vielzeug/ledger'; let value = 'before'; const ledger = createLedger(); await ledger.do({ apply: () => { value = 'after'; }, label: 'Rename value', revert: () => { value = 'before'; }, }); await ledger.undo(); console.log(ledger.state.getSnapshot().undo.length); // 0 ledger.dispose(); ``` ## Features - `createLedger()`: Create serialized reversible command history - `state`: Read atomic queue, undo, redo, and acceptance state - `compose()`: Combine reversible commands into one reversible command - `whenIdle()`: Await queued and active operation settlement - `LedgerCancelledError`: Distinguish cancellation from execution failure - `maxHistory`: Keep a non-negative safe-integer undo depth - `[Symbol.dispose]()`: Seal, abort, and clear a ledger owner ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ripple](/ripple/): Project Ledger's structural state readable into a signal when reactive derivations are needed. - [Keymap](/keymap/): Route undo and redo shortcuts to a Ledger error boundary. - [Forge](/forge/): Record reversible form transitions. - [Vault](/vault/): Persist application snapshots outside transient undo history. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `createLedger()` | Create serialized reversible history | Sync construction | Every operation method is queued | | `compose()` | Combine commands into one history entry | Sync construction | Child metadata is not aggregated | | `ledger.do()` | Apply and record a command | Async | Failed or cancelled apply is not recorded | | `ledger.undo()` / `redo()` | Move the latest history entry | Async | Failures leave history on its original side | | `ledger.clear()` | Remove history without reverting | Async | Serialized behind earlier work | | `ledger.whenIdle()` | Await an empty operation queue | Async | Cannot force non-cooperative work to settle | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/ledger` | Complete Ledger runtime, error hierarchy, state-readable contract, unsubscribe type, and command/history types | ## `createLedger()` ```ts function createLedger(options?: LedgerOptions): Ledger ``` Creates a ledger with one submission-order operation queue and one atomic state readable. ```ts import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger({ maxHistory: 50 }); ``` ### `LedgerOptions` ```ts interface LedgerOptions { maxHistory?: number; } ``` `maxHistory` defaults to `100`. It must be a non-negative safe integer. `0` executes commands without retaining history. ## `compose()` ```ts function compose( commands: readonly ReversibleCommand[], label?: string, ): ReversibleCommand ``` Snapshots child command callbacks, applies children in order, and reverts them in reverse order. The result creates one history entry with the supplied label and `meta: undefined`. If child application fails, completed children are compensated in reverse order. Compensation attempts continue after failures. Combined apply and compensation failures are preserved in an `AggregateError`, which Ledger exposes as `LedgerExecutionError.cause`. Normal composed reversion also attempts every child and reports failures through `LedgerRollbackError.cause` as an `AggregateError`. ## `Ledger` ```ts interface Ledger { clear(): Promise; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; do(command: ReversibleCommand, options?: LedgerCallOptions): Promise; redo(options?: LedgerCallOptions): Promise; readonly state: Subscribable>; undo(options?: LedgerCallOptions): Promise; whenIdle(): Promise; [Symbol.dispose](): void; } ``` All operation methods are serialized in call order. ### `do()` Snapshots the command's `apply`, `revert`, `label`, and metadata reference when submitted. On successful apply it appends one undo entry, evicts the oldest entry beyond `maxHistory`, and clears redo history. Apply failure rejects with `LedgerExecutionError` and records nothing. Ledger does not automatically revert a failed individual command. ### `undo()` Reverts the final undo entry. Success moves it to the end of redo history. If no undo entry exists, the queued operation resolves without changing history. Revert failure rejects with `LedgerRollbackError` and leaves the entry in undo history. ### `redo()` Applies the final redo entry. Success moves it to the end of undo history. If no redo entry exists, it resolves without changing history. Apply failure rejects with `LedgerExecutionError` and leaves the entry in redo history. ### `record()` Appends an already-executed command to undo history synchronously, without running it through the queue. For stores that execute their own writes: typically synchronous, embedded state stores: `undo()` calls the recorded `revert`, and `redo()` calls the recorded `apply`, so `apply` must be able to re-apply the effect. Recording shares `do()`'s bookkeeping: it clears redo history and evicts the oldest entry beyond `maxHistory`. It throws `LedgerDisposedError` on a disposed ledger. ### `clear()` Queues removal of all undo and redo entries. It does not call command revert functions and does not cancel earlier work. When history is already empty it resolves without queueing; after disposal it rejects with `LedgerDisposedError`. ### `whenIdle()` Resolves immediately when `queued` and `running` are zero, otherwise resolves after both reach zero. Multiple waiters are supported. It rejects with `LedgerDisposedError` when the ledger is disposed, immediately or while waiting. ### Disposal `dispose()` is permanent and idempotent. It clears undo and redo history, aborts active command contexts, rejects queued operations that have not started with `LedgerDisposedError`, and rejects pending `whenIdle()` waiters. Active commands must observe their signal and settle before `running` reaches zero. Read `disposed` to detect permanent disposal. `[Symbol.dispose]()` delegates to `dispose()`. ## Command Types ### `ReversibleCommand` ```ts interface ReversibleCommand { readonly apply: (context: CommandContext) => Promise | void; readonly label?: string; readonly meta?: TMeta; readonly revert: (context: CommandContext) => Promise | void; } ``` `apply` and `revert` may be synchronous or asynchronous. Metadata is retained by reference; Ledger does not deep-clone or deep-freeze it. ### `CommandContext` ```ts interface CommandContext { readonly signal: AbortSignal; } ``` The signal combines ledger disposal with the operation signal. Queued cancellation prevents user code from running. Active cancellation is cooperative. If user code ignores abort and completes, the operation still rejects with `LedgerCancelledError` and its history transition is not committed. ### `LedgerCallOptions` ```ts interface LedgerCallOptions { signal?: AbortSignal; } ``` Supported by `do()`, `undo()`, and `redo()`. `clear()` has no call options. ## State Types ### `LedgerState` ```ts interface LedgerState { readonly queued: number; readonly redo: readonly HistoryEntry[]; readonly running: number; readonly undo: readonly HistoryEntry[]; } ``` | Field | Meaning | | --- | --- | | `queued` | Submitted operations that have not started | | `running` | Currently executing operation; serialization keeps this at 0 or 1 | | `undo` | Chronological successfully applied history; final entry is next to undo | | `redo` | Chronological reverted history; final entry is next to redo | State objects and history arrays are frozen replacements. ### `HistoryEntry` ```ts interface HistoryEntry { readonly label: string | undefined; readonly meta: TMeta | undefined; } ``` History entries are frozen and intentionally omit command callbacks. ### `Subscribable` and `Unsubscribe` `Ledger.state` implements this framework-neutral structural contract, which matches `Subscribable` in `@vielzeug/arsenal`. Both names are root type exports. ```ts type Unsubscribe = () => void; interface Subscribable { getSnapshot(): T; subscribe(listener: () => void): Unsubscribe; } ``` Subscriptions are not immediate and receive no value argument. Listeners run synchronously from a snapshot after state replacement. A listener failure does not interrupt Ledger bookkeeping or later listeners; it is rethrown in a microtask. ## Errors | Error | Trigger | History result | | --- | --- | --- | | `LedgerCancelledError` | Queued signal cancellation or active cooperative cancellation | No transition committed | | `LedgerDisposedError` | Submission after disposal or queued work rejected by disposal | History already cleared by disposal | | `LedgerExecutionError` | `apply()` fails during `do()` or `redo()` | New command absent, or redo entry retained | | `LedgerRollbackError` | `revert()` fails during `undo()` | Undo entry retained | | `LedgerConfigError` | Invalid `maxHistory` | Construction fails | | `LedgerError` | Base class and internal history corruption | Depends on operation | Wrapped operation failures preserve the original value in `.cause`. Use `instanceof LedgerError` to catch the hierarchy, then narrow to a specific subtype. ### Usage Guide ## Basic Usage Capture both sides of a reversible transition before submitting it. ```ts import { createLedger } from '@vielzeug/ledger'; using ledger = createLedger(); const item = { name: 'Old name' }; const previous = item.name; const next = 'New name'; await ledger.do({ apply: () => { item.name = next; }, label: 'Rename item', revert: () => { item.name = previous; }, }); await ledger.undo(); await ledger.redo(); ``` All `do()`, `undo()`, `redo()`, and `clear()` calls join one submission-order queue. Keep irreversible effects outside commands unless they have a real compensating operation. ## Read Atomic State `state` is a framework-neutral `Subscribable`, not a Ripple signal. Read `getSnapshot()`, and subscribe to invalidation callbacks. ```ts function renderHistory(): void { const { queued, redo, running, undo } = ledger.state.getSnapshot(); undoButton.disabled = undo.length === 0 || ledger.disposed; redoButton.disabled = redo.length === 0 || ledger.disposed; spinner.hidden = queued + running === 0; } renderHistory(); const stop = ledger.state.subscribe(renderHistory); ``` Subscriptions are not immediate and receive no value argument. They run synchronously from a listener snapshot after every state replacement. A failure is rethrown in a microtask without interrupting Ledger bookkeeping or later subscribers. State objects, history arrays, and history entries are frozen snapshots. `undo` and `redo` are chronological; the next command to undo or redo is the final item. Metadata values are retained by reference rather than deep-cloned. ## Attach History Metadata Set `label` for presentation and use the ledger's metadata type for application context. ```ts const ledger = createLedger(); await ledger.do({ apply: () => { form.email = next; }, label: 'Edit email', meta: { field: 'email' }, revert: () => { form.email = previous; }, }); console.log(ledger.state.getSnapshot().undo.at(-1)); ``` Ledger snapshots command callbacks, label, and metadata references when `do()` is submitted. Later mutation of the command object does not replace the captured callbacks. ## Compose Reversible Commands `compose()` produces one command and one history entry. Children apply in order and revert in reverse order. ```ts import { compose } from '@vielzeug/ledger'; await ledger.do( compose([ { apply: () => { node.x = nextX; }, revert: () => { node.x = previousX; } }, { apply: () => { node.y = nextY; }, revert: () => { node.y = previousY; } }, ], 'Move node'), ); ``` If a child apply fails, already-applied children are compensated in reverse order. Compensation continues after failures. If application and compensation both fail, `LedgerExecutionError.cause` is an `AggregateError` containing all failures. A composed command records the supplied label but does not aggregate child metadata. ## Handle Operation Failures ```ts import { LedgerCancelledError, LedgerExecutionError, LedgerRollbackError, } from '@vielzeug/ledger'; try { await ledger.undo(); } catch (error) { if (error instanceof LedgerCancelledError) { // Expected cancellation. } else if (error instanceof LedgerRollbackError) { showUndoError(error.cause); } else if (error instanceof LedgerExecutionError) { showApplyError(error.cause); } else { throw error; } } ``` A failed `do()` is not recorded and is not automatically reverted. A failed `undo()` remains in undo history; a failed `redo()` remains in redo history, allowing retry after the underlying problem is corrected. ## Cancel Work Pass a signal per operation. Queued cancellation prevents user code from starting. Active work receives a combined signal and must stop cooperatively. ```ts const controller = new AbortController(); const operation = ledger.do( { apply: ({ signal }) => saveDraft(nextDraft, { signal }), revert: ({ signal }) => saveDraft(previousDraft, { signal }), }, { signal: controller.signal }, ); controller.abort(); await operation.catch(handleHistoryFailure); ``` If active user code ignores cancellation and finishes, Ledger rejects with `LedgerCancelledError` and does not record or move history. It does not compensate partial effects automatically, so command implementations must check or forward `context.signal` before committing changes. ## Record Externally Executed Work When a synchronous store owns its writes, execute first and record the reversible entry afterwards: `undo()` calls the recorded `revert`, `redo()` calls the recorded `apply`, so `apply` must be able to re-apply the effect. ```ts const previous = item.name; item.name = next; // the store's own synchronous write ledger.record({ apply: () => { item.name = next; }, label: 'rename item', revert: () => { item.name = previous; }, }); ``` Recording is synchronous and shares `do()`'s bookkeeping: it clears redo history and evicts the oldest entry beyond `maxHistory`. ## Limit and Clear History `maxHistory` defaults to `100` and accepts non-negative safe integers. Successful commands beyond the cap evict the oldest undo entries. `0` executes commands without retaining undo or redo history. ```ts const ledger = createLedger({ maxHistory: 30 }); await ledger.clear(); ``` `clear()` is serialized behind earlier work and clears both history arrays without reverting commands. ## Drain and Dispose `whenIdle()` resolves when both `queued` and `running` reach zero. It does not cancel work. ```ts const operation = ledger.do(command); const idle = ledger.whenIdle(); ledger.dispose(); await operation.catch(handleHistoryFailure); await idle; ``` Disposal is permanent and idempotent. It clears history, aborts active contexts, rejects queued work with `LedgerDisposedError`, and rejects pending `whenIdle()` waiters. Read `disposed` to detect it. Active operations settle cooperatively; `whenIdle()` waits for them. Work that never settles can keep `whenIdle()` pending. ## Framework Integration Keep one ledger per owning scope and adapt its structural readable through the framework's subscription API. ```tsx [React] import { useEffect, useMemo, useSyncExternalStore } from 'react'; import { createLedger } from '@vielzeug/ledger'; export function UndoStatus() { const ledger = useMemo(() => createLedger(), []); const state = useSyncExternalStore( (notify) => ledger.state.subscribe(notify), () => ledger.state.getSnapshot(), () => ledger.state.getSnapshot(), ); useEffect(() => () => ledger.dispose(), [ledger]); return {state.undo.length} undo / {state.redo.length} redo; } ``` ```ts [Vue 3] import { onUnmounted, shallowRef } from 'vue'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const state = shallowRef(ledger.state.getSnapshot()); const stop = ledger.state.subscribe(() => { state.value = ledger.state.getSnapshot(); }); onUnmounted(() => { stop(); ledger.dispose(); }); ``` ```ts [Ripple] import { signal } from '@vielzeug/ripple'; import { createLedger } from '@vielzeug/ledger'; const ledger = createLedger(); const state = signal(ledger.state.getSnapshot()); const stop = ledger.state.subscribe(() => { state.value = ledger.state.getSnapshot(); }); function dispose(): void { stop(); ledger.dispose(); } ``` ## Working with Other Vielzeug Libraries Use Keymap to route keyboard shortcuts into `undo()` and `redo()`. Project `ledger.state` into Ripple when reactive derivations are needed. Persist application snapshots with Vault; Ledger history itself is transient. ## Best Practices - Capture previous and next values before submitting a command. - Keep irreversible effects outside Ledger commands. - Forward or check `context.signal` in asynchronous command work. - Catch every operation promise at the application boundary. - Read one `state` snapshot per render. - Keep state subscribers synchronous and handle failures through the application's global error boundary. - Use `whenIdle()` before releasing owners that require a drain boundary. - Dispose each owner-scoped ledger. ### Examples ## Examples - [Text Editor History](./examples/text-editor.md) - [Form History](./examples/form-history.md) ### REPL Examples - Cancellation (id: `cancellation`) - History Metadata & State (id: `command-data`) - Compose Reversible Commands (id: `compose-commands`) - do / undo / redo (id: `do-undo-redo`) - Structural State Subscription (id: `reactive-signals`) - Rollback Error (id: `rollback-error`) --- ## @vielzeug/lingua **Category:** i18n ### Overview ## Why Lingua? Lingua separates immutable translation from mutable locale state. Use `createTranslator` for one fixed-locale catalog, or `createI18n` when locale changes at runtime. ```ts // Before const message = catalogs[locale]?.inbox?.[count === 1 ? 'one' : 'other'] ?? 'inbox'; // After const output = i18n.translate('inbox', { count }); ``` | Feature | Lingua | i18next | FormatJS | | --- | --- | --- | --- | | Bundle size | | Varies by selected modules | Varies by selected modules | | Zero runtime dependencies | | | | | Explicit plural catalog nodes | | Convention/config dependent | ICU-message dependent | | Static and lazy locale catalogs | | Plugin/config dependent | Application-defined | | Immutable locale snapshots | | Application-defined | Application-defined | **Use Lingua when** you need a compact TypeScript runtime with explicit catalog structure, deterministic fallback, and framework-neutral subscriptions. **Consider i18next or FormatJS when** you need their plugin ecosystems, message extraction pipelines, or framework-specific integrations. ## Installation ```sh [pnpm] pnpm add @vielzeug/lingua ``` ```sh [npm] npm install @vielzeug/lingua ``` ```sh [yarn] yarn add @vielzeug/lingua ``` ## Quick Start Create an i18n instance with an eager default catalog and a loader for other locales. ```ts import { createI18n } from '@vielzeug/lingua'; const en = { inbox: { plural: { one: 'One message', other: '{count} messages' } } }; const i18n = createI18n({ catalogs: { en }, locale: 'en', loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default), }); try { console.log(i18n.translate('inbox', { count: 3 })); await i18n.setLocale('de'); console.log(i18n.translate('inbox', { count: 1 })); } finally { i18n.dispose(); } ``` ## Features - `createTranslator()` compiles one immutable fixed-locale catalog. - `createI18n()` manages eager catalogs, locale changes, and optional lazy loading. - `translate()` renders text and plural messages through explicit catalog nodes. - `translateDynamic()` makes runtime-key lookup explicit. - `parts()` returns typed discriminated parts for framework content. - `load()` deduplicates loading and resolves the active locale plus configured fallbacks. - `getSnapshot()` and `subscribe()` expose immutable translator revisions. - `serialize()` and `state` option transfer resolved SSR catalogs. - `missing: 'throw' | 'key' | handler` controls failure behaviour. - `validateCatalog()` remains an isolated subpath tool. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ripple](../ripple/index.md) adapts Lingua snapshots into reactive application state. - [Courier](../courier/index.md) can fetch locale catalogs before passing them to Lingua loaders. - [Wayfinder](../wayfinder/index.md) can drive locale selection from route state. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createTranslator()` | Compile one immutable locale catalog | Sync | No fallback locales | | `createI18n()` | Create mutable locale and catalog store | Sync | Provide catalogs, serialized state, or a loader | | `createFormatter()` | Create a cached Intl facade from `/format` | Sync | Pass a locale getter for dynamic locale changes | | `createReactiveI18n()` | Mirror an i18n instance into ripple readables from `/ripple` | Sync | Optional `@vielzeug/ripple` peer; import from subpath | | `catalogKeys()` | Enumerate message keys as dotted paths | Sync | Takes a catalog (or subtree) only; for an i18n instance pass `state.catalogs[state.locale]` | | `validateCatalog()` | Check explicit plural forms from `/validate` | Sync | Import from subpath | | `compareCatalogs()` | Compare key parity across locales from `/validate` | Sync | First locale is the base; import from subpath | | `LinguaError` | Base class for Lingua errors | Sync | Use `instanceof LinguaError` for broad narrowing | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/lingua` | Translation factories, state types, and Lingua errors | | `@vielzeug/lingua/format` | Standalone cached Intl formatter factory | | `@vielzeug/lingua/ripple` | `createReactiveI18n()`: reactive locale/translate bridge (optional `@vielzeug/ripple` peer) | | `@vielzeug/lingua/validate` | `validateCatalog()`, `compareCatalogs()`, and `ValidationIssue` | ## Translation Factories ### createTranslator ```ts function createTranslator(catalog: C, options?: TranslatorOptions): Translator; ``` Compiles one catalog and returns an immutable fixed-locale translator. Locale defaults to `en` and controls plural selection and diagnostics. | Parameter | Type | Description | | --- | --- | --- | | `catalog` | `C` | One catalog containing only messages and grouping objects | | `options` | `TranslatorOptions` | Locale and missing-message strategy | **Returns:** `Translator`. **Example:** ```ts import { createTranslator } from '@vielzeug/lingua'; const translator = createTranslator( { save: 'Enregistrer' }, { locale: 'fr' }, ); translator.translate('save'); ``` | Method | Signature | Returns | | --- | --- | --- | | `translate` | `(textKey, options?)` or `(pluralKey, { count, ordinal?, values? })` | Rendered string | | `translateDynamic` | `(key, options?)` | Rendered string for runtime key | | `parts` | `(textKey, { values })` or `(pluralKey, { count, ordinal?, values? })` | Typed discriminated parts | | `partsDynamic` | `(key, options)` | Parts for runtime key | | `locale` | `Locale` | Resolved active locale | --- ### createI18n ```ts function createI18n(options: I18nOptions): I18n; ``` Creates catalog store, current locale state, and immutable translator snapshots. | Parameter | Type | Description | | --- | --- | --- | | `options.catalogs` | `Catalogs` | Eager locale catalogs; mutually exclusive with `state` | | `options.locale` | `Locale` | Initial locale; defaults to serialized locale, then `en` | | `options.loadCatalog` | `(locale) => Promise \| C` | Optional loader for catalogs not supplied eagerly | | `options.fallback` | `Locale \| readonly Locale[]` | Fallback locales loaded with the selected locale | | `options.missing` | `MissingStrategy` | Missing-key/value strategy | | `options.state` | `TranslationState` | Eager serialized state; mutually exclusive with `catalogs` | Provide `catalogs`, `state`, or `loadCatalog`. You may combine eager `catalogs` or hydrated `state` with `loadCatalog` for later locales. **Returns:** `I18n`, with every `Translator` method plus lifecycle methods. **Example:** ```ts import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ catalogs: { en: { title: 'Home' } }, locale: 'en', }); i18n.translate('title'); ``` | Method or property | Signature | Returns | | --- | --- | --- | | `translate` | Translator method | Rendered string | | `parts` | Translator method | Typed discriminated parts | | `load` | `({ locale? })` | `Promise` after the locale and configured fallbacks load | | `setLocale` | `(locale)` | `Promise` after an atomic loaded-locale commit | | `isLoaded` | `({ locale? })` | `boolean` | | `getSnapshot` | `()` | `I18nSnapshot` | | `subscribe` | `(listener, { immediate?, signal? })` | Unsubscribe function | | `serialize` | `()` | Loader-free `TranslationState` | | `dispose` | `()` | `void` | | `locale` | `Locale` | Current canonical locale | | `disposed` | `boolean` | Disposal state | | `disposalSignal` | `AbortSignal` | Aborts on disposal | | `[Symbol.dispose]` | `()` | Delegates to `dispose()` | --- ## Reactive bridge (`@vielzeug/lingua/ripple`) ### createReactiveI18n ```ts function createReactiveI18n(i18n: I18n): ReactiveI18n; ``` Bridges a lingua instance into `@vielzeug/ripple` so translated strings are reactive. `i18n.translate()` is not ripple-reactive on its own: reading it inside a `computed()` computes once and never re-runs, since it registers no tracked dependency. Both translate methods here first read the reactive `locale` readable, so any binding built on them re-evaluates the instant `setLocale()` resolves. `@vielzeug/ripple` is an optional peer dependency: install it only when you use this subpath. ```ts import { createI18n } from '@vielzeug/lingua'; import { createReactiveI18n } from '@vielzeug/lingua/ripple'; import { computed } from '@vielzeug/ripple'; const reactive = createReactiveI18n(createI18n({ catalogs, locale: 'en' })); const heading = computed(() => reactive.translate('title')); // re-computes on setLocale() await reactive.i18n.setLocale('fr'); ``` | Property or method | Signature | Returns | | --- | --- | --- | | `i18n` | `I18n` | The underlying instance for imperative work (`setLocale`, `load`, disposal) | | `locale` | `Readable` | Reactive locale: reading it inside a computed/template registers the dependency | | `translate` | `(key, options?)` | Rendered string; re-evaluates whenever the locale changes | | `translateDynamic` | `(key, options?)` | Same, for runtime-assembled keys | --- ## Catalog Utilities ### catalogKeys ```ts function catalogKeys(catalog: C): ReadonlyArray>; ``` Enumerates every message key as a dotted path. Traverses nested grouping objects and explicit `{ plural: ... }` messages, producing the same paths that `TextKey` represents at the type level. Pass any catalog object or subtree; for an `I18n` instance, read its current locale catalog first. | Parameter | Type | Description | | --------- | ------------------- | ----------------------------------------- | | `catalog` | `C extends Catalog` | Raw catalog object or any catalog subtree | **Returns:** `ReadonlyArray>`: dotted paths to every text and plural message. ```ts import { catalogKeys, createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ catalogs: { en: { nav: { home: 'Home', settings: 'Settings' } } }, locale: 'en', }); const state = i18n.serialize(); const allKeys = catalogKeys(state.catalogs[state.locale]); // ['nav.home', 'nav.settings'] const navKeys = catalogKeys(state.catalogs.en.nav); // ['home', 'settings'] ``` --- ## Intl formatting ### createFormatter ```ts import { createFormatter } from '@vielzeug/lingua/format'; const format = createFormatter(() => i18n.locale); format.number(1_234.5); format.currency(19.99, 'USD'); format.date(new Date(), { dateStyle: 'medium' }); ``` The formatter caches bounded `Intl` instances and resolves a locale getter for every call. ## Validation ### validateCatalog ```ts function validateCatalog(catalog: Catalog, locale: Locale): ValidationIssue[]; ``` Validates explicit plural messages against locale plural categories after catalog structural validation. | Parameter | Type | Description | | --- | --- | --- | | `catalog` | `Catalog` | Explicit catalog to validate | | `locale` | `Locale` | BCP 47 locale tag | **Returns:** `ValidationIssue[]`. **Example:** ```ts import { validateCatalog } from '@vielzeug/lingua/validate'; validateCatalog({ inbox: { plural: { one: 'One message' } } }, 'en'); ``` ### compareCatalogs ```ts function compareCatalogs(catalogs: Catalogs): CatalogComparison; ``` Compares key sets across locales. First locale is the base: reports keys missing in each target and keys present in targets but absent from base. Validates each catalog structurally. | Parameter | Type | Description | | --- | --- | --- | | `catalogs` | `Catalogs` | Locale-keyed catalogs to compare | **Returns:** `CatalogComparison` with `missing` and `extra` arrays. ```ts import { compareCatalogs } from '@vielzeug/lingua/validate'; const result = compareCatalogs({ en: { greeting: 'Hello', farewell: 'Goodbye' }, de: { greeting: 'Hallo' }, }); // { missing: [{ key: 'farewell', locale: 'de' }], extra: [] } ``` ## Types ```ts type Locale = string; type PluralCategory = Intl.LDMLPluralRule; type PluralMessage = { readonly plural: Partial> }; type CatalogNode = Catalog | PluralMessage | string; type Catalog = { readonly [key: string]: CatalogNode }; type Catalogs = Record; type TextPart = { readonly type: 'text'; readonly value: string }; type ValuePart = { readonly type: 'value'; readonly value: V }; type Part = TextPart | ValuePart; type MissingInfo = { readonly key: string; readonly locale: Locale; readonly name?: string }; type MissingHandler = (info: MissingInfo) => string; type MissingStrategy = 'throw' | 'key' | MissingHandler; type TranslatorOptions = { readonly locale?: Locale; readonly missing?: MissingStrategy }; type I18nOptions = { readonly fallback?: Locale | readonly Locale[]; readonly locale?: Locale; readonly missing?: MissingStrategy; } & ( | { readonly catalogs: Catalogs; readonly loadCatalog?: CatalogLoader; readonly state?: never } | { readonly catalogs?: never; readonly loadCatalog?: CatalogLoader; readonly state: TranslationState } | { readonly catalogs?: never; readonly loadCatalog: CatalogLoader; readonly state?: never } ); type CatalogLoader = (locale: Locale) => Promise | C; type TranslationState = { readonly catalogs: Catalogs; readonly locale: Locale; readonly version: 1; }; type I18nSnapshot = { readonly locale: Locale; readonly revision: number; readonly translator: Translator; }; type I18n = Translator & { readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; getSnapshot(): I18nSnapshot; isLoaded(options?: { locale?: Locale }): boolean; load(options?: { locale?: Locale }): Promise; serialize(): TranslationState; setLocale(locale: Locale): Promise; subscribe(listener: (snapshot: I18nSnapshot) => void, options?: SubscribeOptions): () => void; [Symbol.dispose](): void; }; type Translator = { readonly locale: Locale; parts(key: TextKey, options: TranslateOptions & { values: Record }): Array>; parts(key: PluralKey, options: PluralOptions & { values?: Record }): Array>; partsDynamic( key: string, options: (TranslateOptions | PluralOptions) & { values?: Record }, ): Array>; translate(key: TextKey, options?: TranslateOptions): string; translate(key: PluralKey, options: PluralOptions): string; translateDynamic(key: string, options?: TranslateOptions | PluralOptions): string; }; ``` ```ts type Values = Record; type TranslateOptions = { values?: Values }; type PluralOptions = TranslateOptions & { count: number; ordinal?: boolean }; type SubscribeOptions = { immediate?: boolean; signal?: AbortSignal }; type MessageKey = Depth extends readonly [unknown, ...infer Rest] ? C extends string | PluralMessage ? Prefix : C extends Catalog ? { [K in string & keyof C]: MessageKey; }[string & keyof C] : never : never; type TextKey = Depth extends readonly [unknown, ...infer Rest] ? C extends string ? Prefix : C extends Catalog ? { [K in string & keyof C]: TextKey; }[string & keyof C] : never : never; type PluralKey = Depth extends readonly [unknown, ...infer Rest] ? C extends PluralMessage ? Prefix : C extends Catalog ? { [K in string & keyof C]: PluralKey; }[string & keyof C] : never : never; type ValidationIssue = { key: string; locale: Locale; missing: Intl.LDMLPluralRule }; type CatalogComparison = { readonly missing: ReadonlyArray; readonly extra: ReadonlyArray; }; ``` ## Errors | Error | Trigger | | --- | --- | | `LinguaDisposedError` | State mutation or subscription after `dispose()` | | `LinguaInvalidCatalogError` | Invalid catalog node or reserved key | | `LinguaInvalidLocaleError` | Invalid BCP 47 locale tag | | `LinguaInvalidPluralCountError` | Non-finite plural count | | `LinguaInvalidStateError` | Unsupported serialized state version | | `LinguaMissingCatalogError` | No `loadCatalog` function or catalog source unavailable | | `LinguaMissingKeyError` | Missing translation key when `missing: 'throw'` | | `LinguaMissingValueError` | Missing interpolation value when `missing: 'throw'` | ### Usage Guide ## Basic Usage Create an i18n instance with eager locale catalogs. Strings are text messages; plural messages use `{ plural: ... }`. ```ts import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ catalogs: { en: { greeting: 'Hello, {name}!', inbox: { plural: { one: 'One message', other: '{count} messages' } }, }, }, locale: 'en', }); console.log(i18n.translate('greeting', { values: { name: 'Ada' } })); console.log(i18n.translate('inbox', { count: 3 })); ``` Call `dispose()` when the instance belongs to a temporary request, test, or route owner. ## Define Explicit Catalogs Use nested objects only to group keys. A plural message always has `plural`, so regular objects containing `one` or `other` remain groups. ```ts const catalog = { account: { greeting: 'Hello, {name}!', unread: { plural: { one: 'One unread message', other: '{count} unread messages' } }, }, }; ``` Use `{ values }` for text replacements. Pass `count` at top level for plural selection; Lingua injects it into selected template. Catalogs contain strings, grouping objects, and explicit `{ plural: ... }` messages only. Keep application data outside catalog, then translate display labels while constructing it. ```ts import { createTranslator } from '@vielzeug/lingua'; const messages = { status: { blocked: 'Blocked', done: 'Done', inProgress: 'In progress' }, }; const statusDefinitions = [ { labelKey: 'status.inProgress', value: 'in-progress' }, { labelKey: 'status.blocked', value: 'blocked' }, { labelKey: 'status.done', value: 'done' }, ] as const; const translator = createTranslator(messages, { locale: 'en' }); const statusOptions = statusDefinitions.map(({ labelKey, value }) => ({ label: translator.translate(labelKey), value })); ``` ## Missing Keys and Values Missing keys and values do not silently echo placeholders by default: Lingua warns in development and returns the key or `{name}`. Control failure behaviour with the `missing` option: | `missing` | Behaviour | | --- | --- | | `undefined` (default) | Warn in development; return key / `{name}` | | `'key'` | Return key / `{name}` silently | | `'throw'` | Throw `LinguaMissingKeyError` / `LinguaMissingValueError` | | `handler` | Call `({ key, locale, name? }) => string` | ```ts const translator = createTranslator(messages, { locale: 'en', missing: 'throw' }); ``` ## Enumerate Catalog Keys Use `catalogKeys()` to derive key arrays from the catalog itself instead of maintaining a parallel list that can go stale. It traverses nested grouping objects and explicit `{ plural: ... }` messages, returning the same dotted paths that `MessageKey` represents at the type level. For an `I18n` instance, read its current locale catalog first. ```ts import { catalogKeys, createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ catalogs: { en: { greeting: 'Hello, {name}!', inbox: { plural: { one: 'One message', other: '{count} messages' } }, nav: { home: 'Home', settings: 'Settings' }, }, }, locale: 'en', }); const state = i18n.serialize(); const allKeys = catalogKeys(state.catalogs[state.locale]); // ['greeting', 'inbox', 'nav.home', 'nav.settings'] ``` Pass a raw catalog object to enumerate keys directly. Call `catalogKeys()` on a nested subtree to get exactly the keys in that group: no filtering, no casts. ```ts import { catalogKeys } from '@vielzeug/lingua'; const messages = { nav: { home: 'Home', settings: 'Settings' }, } as const; const allKeys = catalogKeys(messages); // ['nav.home', 'nav.settings'] const navKeys = catalogKeys(messages.nav); // ['home', 'settings'] ``` ## Render Framework Content Use `parts()` when replacements are framework nodes, links, or other values that must not be stringified. `parts()` returns a discriminated union: `{ type: 'text', value: string }` for literal text and `{ type: 'value', value: V }` for interpolated values. ```ts import { createTranslator } from '@vielzeug/lingua'; const translator = createTranslator({ error: 'Try {retry} or {support}.' }, { locale: 'en' }); const retry = { href: '/retry', label: 'retry' }; const support = { href: '/support', label: 'support' }; const result = translator.parts('error', { values: { retry, support } }); // [ // { type: 'text', value: 'Try ' }, // { type: 'value', value: { href: '/retry', label: 'retry' } }, // { type: 'text', value: ' or ' }, // { type: 'value', value: { href: '/support', label: 'support' } }, // { type: 'text', value: '.' }, // ] ``` Render returned array with framework fragment or list primitive. Give UI values consumer-owned keys before passing them to `parts()`; Lingua preserves value identity and never clones or mutates them. ## Use a Fixed-Locale Translator Use `createTranslator()` when one catalog and locale stay fixed for the translator's lifetime. It defaults locale to `en`; pass `locale` when plural rules or diagnostics need another locale. Lingua snapshots catalog messages during construction. Do not mutate source catalog objects afterward. ```ts import { createTranslator } from '@vielzeug/lingua'; const translator = createTranslator( { save: 'Enregistrer' }, { locale: 'fr' }, ); console.log(translator.translate('save')); ``` ## Load Catalogs and Switch Locales Provide eager catalogs for translations needed immediately and a `loadCatalog` function for other locales. `setLocale()` loads the selected locale and configured fallbacks before it commits the change. ```ts import { createI18n } from '@vielzeug/lingua'; const i18n = createI18n({ catalogs: { en: { navigation: { settings: 'Settings' } } }, fallback: 'en', locale: 'en', loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default), }); await i18n.setLocale('fr'); console.log(i18n.translate('navigation.settings')); ``` Use loader-only configuration when every locale is lazy, and call `load()` before the first translation. Concurrent loads for the same locale share work. A failed or superseded locale selection never changes the current snapshot. ## Subscribe to Immutable Snapshots Subscribe when UI state must change with locale or loaded active/fallback catalog. Every callback receives a snapshot containing the translator for that revision. ```ts const unsubscribe = i18n.subscribe( ({ locale, translator }) => { console.log(locale, translator.translate('navigation.settings')); }, { immediate: true }, ); unsubscribe(); ``` Pass `{ signal }` when an `AbortController` owns subscription lifetime. ## SSR State Serialize resolved catalogs on the server, then hydrate a client instance from the same payload via the `state` option. `getSnapshot()` stays referentially stable until the store revision changes, so use the same hydrated instance throughout initial client render. ```ts import { createI18n } from '@vielzeug/lingua'; const serverI18n = createI18n({ catalogs: { en: { title: 'Server title' } }, locale: 'en', }); const state = serverI18n.serialize(); const clientI18n = createI18n({ state }); console.log(clientI18n.translate('title')); serverI18n.dispose(); clientI18n.dispose(); ``` State contains raw loaded catalogs and the active locale. It never contains loader functions. Pass an explicit `locale` only when the client should override the serialized locale. ## Validation Import catalog validation from the dedicated subpath to keep translation state focused. ```ts import { compareCatalogs, validateCatalog } from '@vielzeug/lingua/validate'; const catalog = { inbox: { plural: { one: 'One message', other: '{count} messages' } } }; console.log(validateCatalog(catalog, 'en')); ``` Use `compareCatalogs()` to catch missing or extra keys across locales: the most common i18n defect. First locale is the base. ```ts import { compareCatalogs } from '@vielzeug/lingua/validate'; const result = compareCatalogs({ en: { greeting: 'Hello', farewell: 'Goodbye' }, de: { greeting: 'Hallo' }, }); // { missing: [{ key: 'farewell', locale: 'de' }], extra: [] } ``` ## Framework Integration Pass stable `getSnapshot()` and `subscribe()` methods to framework state primitives. For SSR, create client instance from the same serialized state used by the server before calling `useSyncExternalStore`. ```ts [React] import { useSyncExternalStore } from 'react'; import type { I18n } from '@vielzeug/lingua'; export function useTranslator(i18n: I18n) { const snapshot = useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot); return snapshot.translator; } ``` ```ts [Vue 3] import { onUnmounted, shallowRef } from 'vue'; import type { I18n } from '@vielzeug/lingua'; export function useTranslator(i18n: I18n) { const snapshot = shallowRef(i18n.getSnapshot()); const unsubscribe = i18n.subscribe((next) => { snapshot.value = next; }); onUnmounted(unsubscribe); return snapshot; } ``` ```ts [Svelte] import { readable } from 'svelte/store'; import type { I18n } from '@vielzeug/lingua'; export function translatorStore(i18n: I18n) { return readable(i18n.getSnapshot().translator, (set) => i18n.subscribe(({ translator }) => set(translator))); } ``` ## Working with Other Vielzeug Libraries Bridge Lingua's structural snapshot directly when templates need reactive locale reads. ```ts import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const i18nState = ripple.fromSubscribable(i18n); export const locale = ripple.computed(() => i18nState.value.locale); export const disposeLocaleBridge = () => ripple.dispose(); ``` Use Courier loaders when locale catalogs come from HTTP rather than bundled modules; pass each loader to `loadCatalog`. ## Best Practices - Define plural messages with `{ plural: ... }` and no sibling metadata. - Keep arrays and application metadata outside catalogs. - Treat source catalog objects as immutable after construction. - Use `translateDynamic()` only for runtime-generated keys. - Provide eager catalogs for translations required during initial render. - Give UI values keys before passing them to `parts()`. - Keep loader functions out of SSR payloads. - Dispose temporary instances after requests, tests, and route lifetimes. - Use `missing: 'throw'` in development to catch missing keys and values early. ### Examples - [Static Translator](./examples/static-translator.md) - [Lazy Locale Catalog](./examples/feature-resources.md) - [SSR Hydration](./examples/ssr-hydration.md) ### REPL Examples - Lazy Locale Catalog (id: `feature-resources`) - Rich Parts (id: `rich-segments`) - Static Translator (id: `static-translator`) - Reactive Locale Store (id: `store`) --- ## @vielzeug/mesh **Category:** webrtc ### Overview ## Why Mesh? Browsers cannot discover peers on a LAN, so sharing live state between nearby devices has always meant running a server. Mesh pairs devices out-of-band: copy/paste, `navigator.share`, or QR via `meshQrCodec`, and then moves typed messages directly over a WebRTC data channel with no backend involved. ```ts // Before const pc = new RTCPeerConnection(); const dc = pc.createDataChannel('sync'); await pc.setLocalDescription(await pc.createOffer()); // …wait for ICE, hand the SDP to the other device, wire expiry, timeouts, // message framing, and dedupe yourself: per guest // After const host = createMeshHost(); const invitation = await host.createInvitation(); shareText(meshCodec.encode(invitation)); // copy/paste or navigator.share const peer = await host.acceptAnswer(meshCodec.decode(answerText)); host.send(peer.id, 'snapshot', { rev: 1 }); ``` | Feature | Mesh | `pulse` (WebSocket) | Raw `RTCPeerConnection` | | --- | --- | --- | --- | | Bundle size | | | 0 B | | Runtime dependencies | `@vielzeug/arsenal` | none | none | | Works without a server | | | | | Typed message protocol | | | | | Multi-peer sessions | Star, host-authoritative | Server rooms | Manual per-peer wiring | | Pairing safety | Proof + TTL + approval hook | Server auth | | **Use Mesh when** a small group of devices on one network must exchange live state and you cannot, or do not want to: run a server. **Consider `pulse` when** you have a backend anyway, need internet-wide reach, or want server-managed rooms and presence. ## Limitations Mesh is deliberately small. It does **not** provide: - Automatic LAN discovery: every pairing is an explicit out-of-band exchange. - Guest-to-guest relay: guests only ever talk to the host. - Reconnection across page reloads: a reload destroys the `RTCPeerConnection`; the consumer re-pairs and re-associates the stable `peerId`. - CRDTs or conflict resolution: payload semantics are yours. - TURN: there is no guarantee peers connect off-LAN. - Persistence, or any UI. Two operational caveats matter in practice: the host page must stay open for the session to live, and client-isolated Wi-Fi, VPNs, or strict firewalls can block even same-LAN pairing. ## Installation ```sh [pnpm] pnpm add @vielzeug/mesh ``` ```sh [npm] npm install @vielzeug/mesh ``` ```sh [yarn] yarn add @vielzeug/mesh ``` ## Quick Start Pair one host and one guest, then exchange typed messages. Pairing requires a secure context (`https:` or `localhost`) for `RTCPeerConnection`. ```ts import { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh'; // The consumer declares both directions of the conversation. interface AppProtocol extends MeshProtocol { toHost: { note: string }; toGuest: { ack: { ok: boolean } }; } // Host side const host = createMeshHost(); host.on('note', (message) => host.send(message.peerId, 'ack', { ok: true })); const invitation = await host.createInvitation(); const invitationText = meshCodec.encode(invitation); // hand to the guest // Guest side (other device) const guest = createMeshGuest(); const answer = await guest.acceptInvitation(meshCodec.decode(invitationText)); const answerText = meshCodec.encode(answer); // hand back to the host // Host accepts the answer; resolves once the channel is open try { await host.acceptAnswer(meshCodec.decode(answerText)); guest.send('note', 'hello over WebRTC'); } finally { host.dispose(); guest.dispose(); } ``` ## Features - **`createMeshHost`**: host-authoritative star sessions; one `RTCPeerConnection` per guest. - **`createMeshGuest`**: single-host guest node with its own lifecycle. - **`meshCodec`**: compact base64url encoding for pairing payloads; pure and replaceable. - **Typed protocol**: declare `toHost`/`toGuest` maps once; `send`, `on`, and `broadcast` stay type-checked. - **Manual pairing**: invitation/answer exchange over copy/paste or `navigator.share`; proof of possession, TTL, and an `approvePeer` hook. - **`tap` observability**: status transitions, ICE state, byte counts, and rejections without affecting behavior. - **`peers` + `kick`**: host-side peer inventory and removal; join/leave arrive as `tap` events carrying the live peer. - **Injectable `rtc`**: every WebRTC object comes from a factory, so tests run fully in memory; `@vielzeug/mesh/testing` ships the in-memory fake. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Pulse](/pulse/): typed WebSocket sessions when a server is available. - [Arsenal](/arsenal/): the utilities Mesh builds on for ids, randomness, and hashing. - [Herald](/herald/): in-process event bus for wiring mesh messages into your app. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `createMeshHost` | Host-authoritative session node; pairs one `RTCPeerConnection` per guest | Sync | `createInvitation`/`acceptAnswer` are async; calls after `dispose()` throw `MeshDisposedError` | | `createMeshGuest` | Guest node that pairs with one host | Sync | Second `acceptInvitation` throws `MeshPairingError` | | `meshCodec` | base64url encode/decode for pairing payloads | Sync | `decode` throws `MeshPairingError` on malformed text or wrong `v` | | `meshQrCodec` | deflate-raw + base45 codec for QR-sized payloads | Async | Falls back to plain `meshCodec` output where `CompressionStream` is missing | | `MeshProtocol` | Declares the `toHost`/`toGuest` message maps | N/A | Extend it; the maps themselves stay plain records | | `MeshError` | Base class for all mesh errors | N/A | `instanceof MeshError` catches every mesh-originated error | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/mesh` | Complete public Mesh API | ## Factory Functions ### createMeshHost() ```ts function createMeshHost(options?: MeshHostOptions): MeshHost; ``` Returns a host node in status `'idle'`. Creating it never touches WebRTC: the first `createInvitation()` does. #### Options (`MeshHostOptions`) | Option | Type | Default | Purpose | | --- | --- | --- | --- | | `iceServers` | `readonly RTCIceServer[]` | `[]` | Passed to the peer connection; mesh ships no STUN/TURN. | | `channel` | `MeshChannelOptions` | `{ ordered: true, label: 'mesh' }` | Data-channel init mapped to `RTCDataChannelInit`. | | `maxMessageBytes` | `number` | `65_536` | Serialized message cap, both directions. | | `invitationTtlMs` | `number` | `300_000` | Invitation validity window. | | `iceGatheringTimeoutMs` | `number` | `5_000` | ICE gathering cap; pairing proceeds with gathered candidates on timeout. | | `channelOpenTimeoutMs` | `number` | `60_000` | Channel-open wait in `acceptAnswer` and on the guest: generous to cover the human carry-back of the answer. | | `rtc` | `MeshRtcFactory` | `globalThis.RTCPeerConnection` | Injection point for tests and non-browser runtimes. | | `clock` | `() => number` | `Date.now` | TTLs and message timestamps. | | `random` | `RandomSource` | `crypto.getRandomValues` | Ids and secrets. | | `signal` | `AbortSignal` | N/A | Disposes the node on abort. | | `approvePeer` | `(peer: MeshPeerInfo) => boolean \| Promise` | approve | Runs after proof verification; `false` rejects with `MeshPairingError`. | #### Example ```ts import { createMeshHost, meshCodec } from '@vielzeug/mesh'; const host = createMeshHost({ approvePeer: (peer) => peer.name !== undefined }); const invitation = await host.createInvitation(); ``` --- ### createMeshGuest() ```ts function createMeshGuest(options?: MeshGuestOptions): MeshGuest; ``` Returns a guest node in status `'idle'`. `MeshGuestOptions` is `MeshOptions`: the shared option table above without `approvePeer`. #### Example ```ts import { createMeshGuest, meshCodec } from '@vielzeug/mesh'; const guest = createMeshGuest(); const answer = await guest.acceptInvitation(meshCodec.decode(invitationText), { name: 'Sam' }); ``` ## Host Surface `MeshHost` extends `MeshNode`. | Member | Signature | Purpose | | --- | --- | --- | | `createInvitation` | `(meta?: { name?: string }) => Promise` | Single-use invitation for one guest; repeatable: one per guest. `meta.name` becomes this host's peer name on the guest side. | | `acceptAnswer` | `(answer: MeshAnswer) => Promise` | Verifies proof + approval, then resolves once the channel opens. Rejects `MeshPairingError` (unknown/expired/duplicate session, duplicate peer id, proof mismatch, refusal) or `MeshTimeoutError`. | | `peers` | `ReadonlyMap` | Live peer inventory keyed by peer id. | | `send` | `(peerId: string, type: K, payload: P['toGuest'][K]) => void` | Typed unicast; throws `MeshConnectionError` for unknown/unconnected peers, `MeshPayloadError` over the cap. | | `broadcast` | `(type: K, payload: P['toGuest'][K], options?: { except?: readonly string[] }) => void` | Sends to every connected peer not in `except`. | | `on` | `(type: K, handler: (message: MeshInbound) => void) => Unsubscribe` | Typed inbound subscription from any guest. | | `kick` | `(peerId: string, reason?: string) => void` | Disconnects a peer; the guest observes `'disconnected'`. | ## Guest Surface `MeshGuest` extends `MeshNode`. | Member | Signature | Purpose | | --- | --- | --- | | `acceptInvitation` | `(invitation: MeshInvitation, meta?: { name?: string }) => Promise` | Consumes an invitation; the returned answer goes back to the host out-of-band. Throws `MeshPairingError` on expired/malformed input or while a live host peer exists; after the host peer fails or disconnects, a fresh invitation re-pairs on the same node with listeners intact. | | `host` | `MeshPeer \| null` | The host peer once pairing started; its `id` is the invitation's `sessionId`. | | `send` | `(type: K, payload: P['toHost'][K]) => void` | Typed send to the host; throws `MeshConnectionError` when unpaired. | | `on` | `(type: K, handler: (message: MeshInbound) => void) => Unsubscribe` | Typed inbound subscription from the host. | ## Shared Surface `MeshNode` members common to host and guest. | Member | Signature | Purpose | | --- | --- | --- | | `status` | `MeshStatus` | Current lifecycle state. | | `disposed` | `boolean` | Whether the node is permanently disposed. | | `disposalSignal` | `AbortSignal` | Aborted by `dispose()`. | | `dispose` | `() => void` | Idempotent teardown: closes every connection, aborts `disposalSignal`, emits `'dispose'`, detaches tappers. | | `[Symbol.dispose]` | `() => void` | Enables `using` declarations. | | `tap` | `(handler: (event: MeshEvent) => void, options?: { signal?: AbortSignal }) => Unsubscribe` | Observability stream; handler errors are swallowed. | ## Codec ```ts const meshCodec: { encode(payload: MeshInvitation | MeshAnswer): string; decode(text: string): MeshInvitation | MeshAnswer; }; ``` Compact JSON → base64url for pairing payloads, kept pure so other encodings can slot in later. `decode` validates shape and `v`, throwing `MeshPairingError` on malformed text, wrong version, or unrecognized payload. ```ts const text = meshCodec.encode(await host.createInvitation()); const answer = await guest.acceptInvitation(meshCodec.decode(text)); await host.acceptAnswer(meshCodec.decode(meshCodec.encode(answer))); ``` ### `meshQrCodec` ```ts const meshQrCodec: { encode(payload: MeshInvitation | MeshAnswer): Promise; decode(text: string): Promise; }; ``` QR-oriented variant: deflate-raw compresses the JSON payload, then base45-encodes it with an `mq2.` prefix. `encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability. `decode` accepts both `mq2.*` and plain `meshCodec` output, so camera scans and paste fallbacks share one path. ```ts const invitationText = await meshQrCodec.encode(await host.createInvitation()); // "mq2.…" const answer = await host.acceptAnswer(await meshQrCodec.decode(scannedText)); ``` `encode` never throws for a missing capability: it falls back to plain `meshCodec` output where `CompressionStream` is unavailable. `decode` throws `MeshPairingError` on corrupt base45/deflate/JSON and `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing. ## Types ### Protocol ```ts interface MeshProtocol { readonly toHost: MeshMessageMap; // messages a guest may send readonly toGuest: MeshMessageMap; // messages the host may send } type MeshMessageMap = Record; ``` ### Pairing payloads ```ts interface MeshInvitation { readonly v: 1; readonly sessionId: string; // 128-bit, base64url readonly secret: string; // 256-bit, base64url readonly expiresAt: number; // epoch ms readonly hostName?: string; // host display name for the guest's peer list readonly sdp: string; // host offer, candidates inlined } interface MeshAnswer { readonly v: 1; readonly sessionId: string; readonly proof: string; // HMAC-SHA-256 of the answer SDP keyed by secret readonly peer: { readonly id: string; readonly name?: string }; readonly sdp: string; } ``` ### Peers, status, messages ```ts type MeshStatus = 'idle' | 'pairing' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'disposed'; type MeshPeerStatus = 'connecting' | 'connected' | 'disconnected' | 'failed'; interface MeshPeerInfo { readonly id: string; readonly name?: string; } interface MeshPeer extends MeshPeerInfo { readonly status: MeshPeerStatus; readonly role: 'host' | 'guest'; } interface MeshInbound { readonly peerId: string; readonly messageId: string; readonly sentAt: number; readonly payload: T; } type Unsubscribe = () => void; ``` ### Events ```ts type MeshEvent = | { readonly type: 'status-change'; readonly status: MeshStatus } | { readonly type: 'peer-status-change'; readonly peerId: string; readonly status: MeshPeerStatus } | { readonly type: 'invitation-created' | 'invitation-expired'; readonly sessionId: string } | { readonly type: 'peer-approved' | 'peer-rejected'; readonly peerId: string } | { readonly type: 'peer-joined'; readonly peer: MeshPeer } | { readonly type: 'peer-left'; readonly peer: MeshPeer; readonly reason?: string } | { readonly type: 'message-sent' | 'message-received'; readonly peerId: string; readonly messageType: string; readonly bytes: number } | { readonly type: 'message-rejected'; readonly peerId: string; readonly reason: 'too-large' | 'duplicate' | 'malformed' | 'unknown-type' } | { readonly type: 'ice-state'; readonly peerId: string; readonly state: string } | { readonly type: 'error'; readonly error: MeshError } | { readonly type: 'dispose' }; ``` `status-change` reports node-level transitions; `peer-status-change` reports one peer's transition and always names a peer id that exists in `peers` (or `guest.host`). ### Options ```ts interface MeshChannelOptions { readonly ordered?: boolean; readonly maxRetransmits?: number; readonly label?: string; } interface MeshOptions { readonly iceServers?: readonly RTCIceServer[]; readonly channel?: MeshChannelOptions; readonly maxMessageBytes?: number; readonly invitationTtlMs?: number; readonly iceGatheringTimeoutMs?: number; readonly rtc?: MeshRtcFactory; readonly clock?: () => number; readonly random?: RandomSource; readonly signal?: AbortSignal; } interface MeshHostOptions extends MeshOptions { readonly approvePeer?: (peer: MeshPeerInfo) => boolean | Promise; } type MeshGuestOptions = MeshOptions; ``` ### WebRTC injection Minimal structural subsets: the surface mesh actually calls, no more. Implement them over `wrtc` or a test double to run outside a browser. ```ts interface MeshRtcFactory { createPeerConnection(config: RTCConfiguration): RTCPeerConnectionLike; } interface RTCPeerConnectionLike { readonly connectionState: string; readonly iceConnectionState: string; readonly iceGatheringState: string; readonly localDescription: RTCSessionDescriptionLike | null; createDataChannel(label: string, init?: { ordered?: boolean; maxRetransmits?: number }): RTCDataChannelLike; createOffer(): Promise; createAnswer(): Promise; setLocalDescription(description: RTCSessionDescriptionLike): Promise; setRemoteDescription(description: RTCSessionDescriptionLike): Promise; close(): void; addEventListener(type: 'datachannel', listener: (event: { readonly channel: RTCDataChannelLike }) => void): void; addEventListener(type: 'icegatheringstatechange' | 'iceconnectionstatechange' | 'connectionstatechange', listener: () => void): void; addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void; removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void; } interface RTCDataChannelLike { readonly readyState: string; send(data: string): void; close(): void; addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void; addEventListener(type: 'message', listener: (event: { readonly data: string }) => void): void; addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void; removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void; } interface RTCSessionDescriptionLike { readonly type?: 'offer' | 'answer' | 'pranswer' | 'rollback'; readonly sdp?: string; } interface MeshRtcEvent { readonly data?: unknown; readonly channel?: RTCDataChannelLike; } ``` ## Errors | Class | Thrown when | Notable properties | | --- | --- | --- | | `MeshError` | Base class: never thrown directly | `instanceof MeshError` catches all mesh errors | | `MeshPairingError` | Malformed/expired/unknown invitation or answer, proof mismatch, duplicate answer, `approvePeer` refusal, codec `decode` failure | N/A | | `MeshConnectionError` | ICE/DTLS failure, channel closed, send to unknown/unconnected peer, offer/answer creation failure | `peerId: string \| null` | | `MeshPayloadError` | Outbound message over `maxMessageBytes` or unserializable | N/A | | `MeshTimeoutError` | Channel-open wait exceeded | N/A | | `MeshDisposedError` | Any method called after `dispose()` | N/A | | `MeshUnsupportedError` | No `RTCPeerConnection` in the environment and no `rtc` injected (raised at first use, never at import), or `meshQrCodec.decode` given `mq2.` text without `DecompressionStream` | N/A | ### Usage Guide ## Basic Usage Declare the protocol once, create a host and a guest, and exchange the pairing payloads out-of-band. `RTCPeerConnection` requires a secure context (`https:` or `localhost`). ```ts import { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh'; interface AppProtocol extends MeshProtocol { toHost: { note: string }; toGuest: { ack: { ok: boolean } }; } const host = createMeshHost(); const guest = createMeshGuest(); // Host produces an invitation; the guest turns it into an answer. const invitationText = meshCodec.encode(await host.createInvitation()); const answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText))); // The host resolves once the data channel is open. await host.acceptAnswer(meshCodec.decode(answerText)); guest.send('note', 'hello'); host.on('note', (m) => host.send(m.peerId, 'ack', { ok: true })); ``` ## Pairing Flow Pairing is a two-leg manual exchange. Each leg is a base64url string produced by `meshCodec.encode` and consumed by `meshCodec.decode`. ```ts // Host device: one invitation per prospective guest const invitation = await host.createInvitation(); const text = meshCodec.encode(invitation); ``` Deliver `text` any way you can: a textarea the user copies, or `navigator.share` on mobile: ```ts if (navigator.share) { await navigator.share({ text, title: 'Join my session' }); } ``` The guest decodes it and returns an answer the same way: ```ts const answer = await guest.acceptInvitation(meshCodec.decode(text), { name: 'Sam' }); const answerText = meshCodec.encode(answer); // back to the host const peer = await host.acceptAnswer(meshCodec.decode(answerText)); ``` Rules that shape the flow: - An invitation is **single-use**: call `createInvitation()` once per guest. - It **expires** at `expiresAt` (default `invitationTtlMs: 300_000`). Expired invitations are refused on both sides and cleaned up on the host. - The answer carries a **proof** derived from the invitation's secret, so a swapped or forged answer is rejected before any connection attempt. - `approvePeer` runs after proof verification: return `false` to refuse a guest with `MeshPairingError` and a `'peer-rejected'` tap. ```ts const host = createMeshHost({ approvePeer: (peer) => allowlist.has(peer.id), }); ``` On the guest, the host's `peerId` is the invitation's `sessionId`: the invitation carries no other host identity. On the host, a guest's `peerId` is the id the guest generated for its answer. ### Pairing over QR `meshQrCodec` is an async codec that compresses pairing payloads (deflate-raw + base45, `mq2.` prefix) so they fit a QR code comfortably. `decode` accepts both the compressed `mq2.*` form and plain `meshCodec` output: paste fallback and camera scanning work through one code path. ```ts import { meshQrCodec } from '@vielzeug/mesh'; import { encodeQr, toSvg } from '@vielzeug/sigil'; // Async: deflate + base45 through CompressionStream, with a plain-codec // fallback where the stream API is missing. const invitationText = await meshQrCodec.encode(await host.createInvitation()); document.querySelector('#qr').innerHTML = toSvg(encodeQr(invitationText)); const answer = await host.acceptAnswer(await meshQrCodec.decode(scannedAnswerText)); ``` `encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability: check for the `mq2.` prefix if you need to know which form you got. `decode` accepts both forms interchangeably and throws `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing. The [sigil pairing recipe](../sigil/examples/pair-two-devices.md) shows the full two-device flow, and `ore-qr-code`/`ore-qr-scanner` (Refine) wrap the rendering and camera sides. ## Sending and Receiving `send`/`on`/`broadcast` are typed from the protocol. Outbound messages serialize through `JSON.stringify` into a versioned envelope with id, type, timestamp, and payload. ```ts host.send(peerId, 'ack', { ok: true }); // one guest host.broadcast('ack', { ok: true }, { except: [peerId] }); // all but one guest.send('note', 'from the guest'); ``` Inbound messages arrive as `MeshInbound`: `{ peerId, messageId, sentAt, payload }`. Rules: - Payloads over `maxMessageBytes` (default 65 536) throw `MeshPayloadError` outbound; oversized inbound frames are dropped and tapped as `'message-rejected'` with reason `'too-large'`. - Duplicate message ids (e.g. after a retry) are dropped and tapped as `'duplicate'`. - A type with no registered `on` handler is tapped as `'unknown-type'`: never thrown. - `send` to an unknown or unconnected peer throws `MeshConnectionError`. ## Peer Lifecycle Node `status` moves through `idle → pairing → connecting → connected`, then `disconnected`, `failed`, or `disposed`. Watch transitions, and everything else: through `tap`: ```ts host.tap((event) => { if (event.type === 'status-change') console.log('node:', event.status); if (event.type === 'peer-status-change') console.log('peer:', event.peerId, event.status); if (event.type === 'peer-left') console.log('left:', event.peer.id, event.reason); }); ``` `peer-joined` and `peer-left` carry the live `MeshPeer`; the inventory itself lives on `peers`: ```ts host.tap((event) => { if (event.type === 'peer-joined') console.log('peers now:', [...host.peers.keys()]); }); host.kick(peerId, 'bye'); // guest observes 'disconnected' ``` `status-change` reports node-level transitions; `peer-status-change` reports a single peer moving between `'connecting' | 'connected' | 'disconnected' | 'failed'`. A guest whose host peer failed or disconnected can re-pair on the same node: call `acceptInvitation` again with a fresh invitation and existing `on`/`tap` listeners keep working. Dispose nodes deterministically: `dispose()` is idempotent, aborts `disposalSignal`, emits a final `'dispose'` event, and detaches all tappers. Passing an `AbortSignal` option disposes on abort; `using` works too. ## Timeouts and Limits | Option | Default | Meaning | | --- | --- | --- | | `invitationTtlMs` | `300_000` | How long an unanswered invitation stays valid. | | `iceGatheringTimeoutMs` | `5_000` | Caps ICE gathering: on timeout pairing proceeds with the candidates gathered so far. | | `channelOpenTimeoutMs` | `60_000` | How long `acceptAnswer` and the guest wait for the data channel to open: deliberately generous because it covers the human carry-back of the answer. | | `maxMessageBytes` | `65_536` | Serialized message cap, enforced in both directions. | `acceptAnswer` rejects with `MeshTimeoutError` when the channel never opens; the guest independently marks itself `'failed'` and taps an `'error'` event. Inject `clock` to drive TTLs deterministically in tests. ## Security Model Mesh reduces the manual-pairing attack surface but does not remove it: - Every invitation carries a random 256-bit **secret**; the answer must present a proof derived from it (HMAC-SHA-256 via `SubtleCrypto`). Pairing requires a secure context: without `SubtleCrypto` both ends throw `MeshUnsupportedError` rather than fall back to a forgeable hash. - Invitations are **single-use and time-boxed**, so a leaked invite is only useful briefly. - `approvePeer` is the host's admission hook: use it for a name check, a shared passphrase confirmed out-of-band, or an allowlist. - `maxMessageBytes` caps every inbound frame; malformed frames are dropped, never dispatched. - **Roles are app-level.** A guest can send any declared `toHost` type with any payload: validate guest state on the host as if it came from a form. Never trust `peer.name` as an identity; it is self-declared. ## Testing Every WebRTC object comes from the injectable `rtc` factory: tests pair nodes fully in memory, with no browser. `@vielzeug/mesh/testing` ships the in-memory fake the package's own tests use: two peer connections wired through token SDP, with knobs for latency, drops, ICE failure, stalled gathering, and a never-opening channel. ```ts import { createFakeRtc } from '@vielzeug/mesh/testing'; const fx = createFakeRtc(); const host = createMeshHost({ rtc: fx.rtc, clock: () => now }); const guest = createMeshGuest({ rtc: fx.rtc }); ``` The [REPL examples](/repl) include a runnable in-memory pairing you can copy as a starting point. ## Framework Integration A node is a plain object: own it in a composable/store and dispose on teardown. ```ts [React] import { useEffect, useRef } from 'react'; import { createMeshHost, type MeshProtocol } from '@vielzeug/mesh'; export function useMeshHost() { const ref = useRef>>(null); if (!ref.current) ref.current = createMeshHost(); useEffect(() => () => ref.current?.dispose(), []); return ref.current; } ``` ```ts [Vue] import { onUnmounted } from 'vue'; import { createMeshHost, type MeshProtocol } from '@vielzeug/mesh'; export function useMeshHost() { const host = createMeshHost(); onUnmounted(() => host.dispose()); return host; } ``` ```ts [Svelte] import { onDestroy } from 'svelte'; import { createMeshHost, type MeshProtocol } from '@vielzeug/mesh'; export function createHost() { const host = createMeshHost(); onDestroy(() => host.dispose()); return host; } ``` ## Working with Other Vielzeug Libraries Mesh builds on `@vielzeug/arsenal` for random ids, secrets, and base64url encoding: no extra wiring needed. Pair it with `herald` when you want mesh messages to feed an in-process event bus: ```ts import { createBus } from '@vielzeug/herald'; const bus = createBus(); host.on('note', (m) => bus.emit('guest-note', m.payload)); ``` ## Best Practices - Pair over `meshCodec` strings, never raw SDP: the transport restores the CRLF line endings that copy/paste mangles. - Create **one invitation per guest**; never reuse or forward an invitation. - Keep `invitationTtlMs` short for physical-proximity pairing and always gate unknown guests through `approvePeer`. - Subscribe `on` handlers **before** `acceptAnswer` resolves so early messages are not tapped as `'unknown-type'`. - Validate every guest payload on the host: treat `toHost` messages as untrusted input. - `dispose()` both nodes on teardown; pass `signal` to bind disposal to a controller you already own. - Inject `rtc` and `clock` in tests instead of mocking globals. - Plan re-pairing, not reconnection: after a reload, create a fresh invitation and let the app re-associate the stable `peerId`. ### Examples ## Examples - [Pair Two Devices](./examples/pair-two-devices.md) - [Host-Authoritative Command Loop](./examples/host-authoritative-command-loop.md) ### REPL Examples - Pairing & Messaging (id: `pairing`) --- ## @vielzeug/necromancer **Category:** ui ### Overview ## Why Necromancer? Native Web Animations API calls do not provide lifecycle ownership, reduced-motion policy, grouped playback, or layout transitions. Necromancer retains native keyframes and timing semantics while making ownership explicit for a component or DOM feature. ```ts // Before const animation = element.animate(keyframes, { duration: 180 }); animation.addEventListener('cancel', removeListeners); // After const animation = animate(element, keyframes, { duration: 180 }); animation.dispose(); ``` | Feature | Native WAAPI | Necromancer | Motion One | | --- | --- | --- | --- | | Bundle size | 0 B | | ~18 kB | | Root dependencies | | | | | Lifecycle handle | Manual | `dispose()` | Library-specific controls | | Reduced motion | Manual | `motion: 'system'` default | Configuration required | | Layout transitions | Manual FLIP math | `captureLayout().animate()` | Separate API | **Use Necromancer when** you need native browser animations with explicit cancellation, reduced-motion behavior, staggered groups, or positional FLIP transitions. **Consider CSS transitions when** a static style change needs no playback control, cleanup, or layout measurement. ## Installation ```sh [pnpm] pnpm add @vielzeug/necromancer ``` ```sh [npm] npm install @vielzeug/necromancer ``` ```sh [yarn] yarn add @vielzeug/necromancer ``` ## Quick Start Start the animation after its DOM element mounts and release it when its UI owner is removed. ```ts import { animate } from '@vielzeug/necromancer'; const notice = document.createElement('p'); notice.textContent = 'Saved'; document.body.append(notice); const animation = animate( notice, [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 180, easing: 'ease-out' }, ); await animation.result; animation.dispose(); ``` ## Features - `animate()`: Native element animation with lifecycle ownership and direct native access - `animateEach()`: Group ownership with stable keyframe factories and `stagger` - `captureLayout()`: One-shot FLIP transition with additive `translate` (position) and `scale` (size) - `motion`: `'system'` reduced-motion support with explicit reduced outcomes - Native timing: Omitted timing fields retain Web Animations API behavior - `signal`: Abort a handle from its parent lifecycle - `dispose()`: Idempotent cleanup with `[Symbol.dispose]()` ## Deliberate Scope Necromancer owns explicit WAAPI keyframes. It does not generate CSS keyframes, observe CSS transitions, watch mutations, simulate springs, interpolate SVG paths, or run a JavaScript tween loop. Use CSS for declarative style changes and choose a dedicated tool when those capabilities are required. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [3.0 Migration](./migration.md) ## See Also - [Orbit](/orbit/): Position floating UI before animating its appearance. - [Ore](/ore/): Own Necromancer handles in a custom element's mount and disposal lifecycle. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `animate()` | Animate one element | Sync | Omitted timing fields use native WAAPI behavior | | `animateEach()` | Animate a unique element group | Sync | Non-zero `stagger` needs numeric `delay` | | `captureLayout()` | Capture positions and create a one-shot FLIP transition | Sync | Capture before changing layout | | `shouldReduceMotion()` | Report whether a motion mode reduces visible movement | Sync | `'system'` reads `prefers-reduced-motion`; false without `matchMedia` | | `NecromancerError` | Base package error | Sync | Use `instanceof NecromancerError` to narrow unknown errors | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/necromancer` | Animation functions, types, and errors | | `@vielzeug/necromancer/testing` | jsdom test fakes for `Element.animate()` and `getBoundingClientRect()` | ## Animation Functions ### `animate()` ```ts function animate(element: Element, keyframes: Keyframes, options?: AnimateOptions): AnimationHandle; ``` Starts a lifecycle-owned native Web Animation. Native timing options, including an omitted or zero `duration`, are preserved. | Parameter | Type | Description | | --- | --- | --- | | `element` | `Element` | Element animated through its native `animate()` method. | | `keyframes` | `Keyframes` | Native keyframe array or property-indexed keyframes. | | `options` | `AnimateOptions` | Native timing plus motion and cancellation ownership. | **Returns:** An `AnimationHandle` with native playback access and explicit disposal. **Example:** ```ts import { animate } from '@vielzeug/necromancer'; const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 }); handle.animation.pause(); const result = await handle.result; handle.dispose(); ``` --- ### `animateEach()` ```ts function animateEach( elements: Iterable, keyframes: Keyframes | KeyframeFactory, options?: AnimateEachOptions, ): AnimationGroup; ``` Starts animations for unique elements in first-seen order. Necromancer resolves every keyframe factory before starting the first native animation. | Parameter | Type | Description | | --- | --- | --- | | `elements` | `Iterable` | Elements animated once in first-seen order. | | `keyframes` | `Keyframes \| KeyframeFactory` | Shared keyframes or a subtype-preserving factory. | | `options` | `AnimateEachOptions` | Animation options plus a stagger interval. | **Returns:** An `AnimationGroup` containing child handles and ordered results. **Example:** ```ts import { animateEach } from '@vielzeug/necromancer'; const group = animateEach(document.querySelectorAll('.item'), [{ opacity: 0 }, { opacity: 1 }], { duration: 180, stagger: 30, }); await group.results; ``` ### `shouldReduceMotion()` ```ts function shouldReduceMotion(mode: MotionMode): boolean; ``` Reports whether the requested motion preference reduces visible movement: the same check `animate()` applies internally, exposed so hand-rolled effects (scroll behavior, tweens) stay consistent with it. `'full'` is always `false`, `'reduced'` is always `true`, and `'system'` reads the `prefers-reduced-motion` media query (`false` where `matchMedia` is unavailable). ```ts import { shouldReduceMotion } from '@vielzeug/necromancer'; element.scrollIntoView({ behavior: shouldReduceMotion('system') ? 'auto' : 'smooth' }); ``` ## Layout Functions ### `captureLayout()` ```ts function captureLayout( elements: Iterable, options?: LayoutCaptureOptions, ): LayoutTransition; ``` Captures unique elements' positions and sizes and returns a one-shot transition. Rotation and other transforms are not captured or compensated. | Parameter | Type | Description | | --- | --- | --- | | `elements` | `Iterable` | Elements measured before a layout change. | | `options` | `LayoutCaptureOptions` | Optional stable key mapping for replacement nodes. | **Returns:** A `LayoutTransition` that measures committed layout once and creates an `AnimationGroup`. **Example:** ```ts import { captureLayout } from '@vielzeug/necromancer'; const transition = captureLayout(beforeItems, { getKey: (element) => element.getAttribute('data-id')!, }); renderReorderedItems(); const group = transition.animate({ duration: 220, easing: 'ease-out', elements: afterItems, }); ``` Calling `animate()` twice on the same transition throws `NecromancerConfigError`. ## Types ### `MotionMode` ```ts type MotionMode = 'full' | 'reduced' | 'system'; ``` `'system'` is the default. Reduced motion preserves the supplied keyframes while normalizing delay, duration, and end delay to `0`, and iterations to `1`. ### `AnimationResult` ```ts type AnimationResult = | { readonly status: 'finished' } | { readonly status: 'reduced' } | { readonly reason?: unknown; readonly status: 'cancelled' }; ``` `cancelled` describes native cancellation and includes its native rejection reason. A reason passed to `dispose()` or an abort signal takes precedence. The independent `disposed` property becomes `true` only when the lifecycle owner is explicitly disposed. ### `AnimateOptions` ```ts type AnimateOptions = KeyframeAnimationOptions & { readonly motion?: MotionMode; readonly signal?: AbortSignal; }; ``` Native timing fields pass through unchanged unless reduced motion normalizes timing. `signal` disposes the returned handle when aborted. ### `AnimateEachOptions` ```ts type AnimateEachOptions = AnimateOptions & { readonly stagger?: number; }; ``` `stagger` is a finite, non-negative millisecond offset. ### `LayoutCaptureOptions` ```ts interface LayoutCaptureOptions { readonly getKey?: (element: ElementType) => string; } ``` `getKey` maps a captured element and its committed replacement to the same stable, non-empty string. Duplicate or empty keys throw `NecromancerConfigError`. ### `LayoutAnimationOptions` ```ts type LayoutAnimationOptions = AnimateEachOptions & { readonly elements?: Iterable; }; ``` `elements` is the collection in its committed layout. Omit it to animate the same captured elements. With `getKey`, replacement elements animate from the positions of their captured predecessors. Unmatched, removed, and newly entered elements are ignored. ### `Keyframes` and `KeyframeFactory` ```ts type Keyframes = readonly Keyframe[] | PropertyIndexedKeyframes; type KeyframeFactory = ( element: ElementType, index: number, total: number, ) => Keyframes; ``` Accepts a `readonly` array so a reusable `as const` keyframe list can be passed without a cast. ### `AnimationHandle` ```ts interface AnimationHandle { readonly animation: Animation; readonly result: Promise; readonly disposed: boolean; dispose(reason?: unknown): void; [Symbol.dispose](): void; } ``` | Member | Description | | --- | --- | | `animation` | Owned native animation used for playback control. | | `result` | Resolves with the first terminal outcome. | | `disposed` | Reports explicit owner disposal. | | `dispose(reason?)` | Cancels the animation and releases its abort listener. | | `[Symbol.dispose]()` | Delegates to `dispose()`. | ### `AnimationGroup` ```ts interface AnimationGroup { readonly handles: readonly AnimationHandle[]; readonly results: Promise; readonly disposed: boolean; dispose(reason?: unknown): void; [Symbol.dispose](): void; } ``` | Member | Description | | --- | --- | | `handles` | Child handles in input order. | | `results` | Resolves with every child outcome in handle order. | | `disposed` | Reports explicit group disposal. | | `dispose(reason?)` | Disposes every child. | | `[Symbol.dispose]()` | Delegates to `dispose()`. | ### `LayoutTransition` ```ts interface LayoutTransition { animate(options?: LayoutAnimationOptions): AnimationGroup; } ``` | Method | Description | | --- | --- | | `animate(options?)` | Measures committed elements and creates the one allowed layout animation group. | ## Errors | Error | Trigger | | --- | --- | | `NecromancerError` | Base class for package errors | | `NecromancerConfigError` | Invalid stagger, incompatible delay, or reused layout transition | | `NecromancerUnsupportedError` | `Element.animate()` is unavailable | ## Testing (`@vielzeug/necromancer/testing`) jsdom (and most non-browser DOM environments) do not implement `Element.animate()`. Import these from the `/testing` sub-path, not the root entry point. ### `AnimationCall` ```ts type AnimationCall = { readonly animation: FakeAnimation; readonly keyframes: Keyframe[] | PropertyIndexedKeyframes; readonly options?: KeyframeAnimationOptions; }; ``` One recorded invocation of `Element.prototype.animate` from `installFakeAnimations()`. ### `installFakeAnimations()` ```ts function installFakeAnimations(): { readonly calls: readonly AnimationCall[]; restore(): void; [Symbol.dispose](): void; }; ``` Replaces `Element.prototype.animate` with a deterministic lifecycle fake for the duration of a test. `calls` records every invocation in order. Call `restore()` or use an explicit resource-management `using` declaration to put the original implementation back. The fake does not implement native playback controls such as `pause()` or `reverse()`. ```ts import { installFakeAnimations } from '@vielzeug/necromancer/testing'; using animations = installFakeAnimations(); const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]); animations.calls[0]?.animation.finish(); await handle.result; // { status: 'finished' } ``` ### `FakeAnimation` ```ts class FakeAnimation { cancelCallCount: number; finishCallCount: number; finished: Promise; cancel(): void; finish(): void; } ``` A minimal `Animation` stand-in. `cancel()` rejects `finished` with an `AbortError`; `finish()` resolves it. `cancelCallCount`/`finishCallCount` track how many times each was called, in place of a test-runner-specific spy. ### `createRect()` ```ts function createRect(x: number, y: number, width?: number, height?: number): DOMRect; ``` Builds a `DOMRect` for mocking `Element.getBoundingClientRect()` in `captureLayout()` tests. `width`/`height` default to `20`. ### Usage Guide ## Basic Usage Create an animation after its element mounts, control playback through the native `Animation`, and dispose its owner with the UI lifecycle. ```ts import { animate } from '@vielzeug/necromancer'; const handle = animate( element, [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 180, easing: 'ease-out', fill: 'both' }, ); handle.animation.reverse(); const result = await handle.result; handle.dispose(); ``` `result` distinguishes natural completion, reduced timing, and cancellation. `disposed` reports only whether the owner was explicitly disposed. Omitted timing fields retain native Web Animations API behavior. Pass an explicit `duration` when the animation should run over time. ## Motion Preferences Use `motion` to select how the animation responds to the operating system preference. ```ts const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 200, motion: 'system', }); const result = await handle.result; ``` `'system'` is the default and reduces movement when `prefers-reduced-motion: reduce` matches. `'full'` preserves the requested timing, while `'reduced'` always reduces it. Reduced motion keeps the supplied keyframes but normalizes delay, duration, and end delay to zero and iterations to one. The result is `{ status: 'reduced' }`, and `handle.animation` still represents the requested visual transition. ## Parent Cancellation Pass a parent `AbortSignal` to release an animation when its owning work is cancelled. ```ts const controller = new AbortController(); const handle = animate(element, [{ scale: 0.96 }, { scale: 1 }], { duration: 160, signal: controller.signal, }); controller.abort('route changed'); const result = await handle.result; // { status: 'cancelled', reason: 'route changed' } ``` An already-aborted signal throws its reason before an animation starts. ## Staggering a Group Pass an iterable of elements to `animateEach()`. Duplicate elements are animated once in first-seen order. ```ts import { animateEach } from '@vielzeug/necromancer'; const group = animateEach( document.querySelectorAll('.card'), (_card, index) => [ { opacity: 0, transform: `translateY(${12 + index * 2}px)` }, { opacity: 1, transform: 'translateY(0)' }, ], { duration: 220, easing: 'ease-out', stagger: 45 }, ); const results = await group.results; group.dispose(); ``` A group owns child lifecycles only. `results` preserves every child result in handle order; use `group.handles` when native playback control is required. ## Serial Application Flow JavaScript control flow is the clearest way to express serial, conditional, or branching animations: ```ts for (const step of steps) { const handle = animate(step.element, step.keyframes, { ...step.options, signal: controller.signal, }); const result = await handle.result; if (result.status === 'cancelled') break; } ``` One parent `AbortSignal` cancels the active step without introducing a separate timeline abstraction. ## Animating a Reorder with FLIP Capture positions before changing layout, then animate through the returned one-shot transition. ```ts import { captureLayout } from '@vielzeug/necromancer'; const transition = captureLayout(items); list.prepend(items[2]!); const group = transition.animate({ duration: 220, easing: 'ease-out' }); await group.results; group.dispose(); ``` The transition only animates changed, connected elements and can be animated once. It additively composes the individual CSS `translate` and `scale` properties, preserving authored `transform`, `translate`, and `scale`. A resized element (for example a list item whose content changed) animates from its captured size as well as its captured position. ### Replacing rendered elements When a framework replaces list nodes rather than reorders the captured elements, give `captureLayout()` a stable key and pass the committed nodes to `animate()`. Capture before updating state, then call `animate()` only after the renderer has committed the new DOM. ```ts const transition = captureLayout(beforeItems, { getKey: (element) => element.getAttribute('data-id')!, }); renderReorderedItems(); transition.animate({ duration: 220, easing: 'ease-out', elements: afterItems, }); ``` Keys must be unique, non-empty strings in both collections. Items with no matching predecessor are not enter animations; animate those explicitly with `animate()` or `animateEach()`. For sortable lists, DnD exposes its pre-commit layout seam through `onBeforeReorder`; see the [DnD optimistic-reorder recipe](/dnd/examples/optimistic-reorder-with-revert.md). ## Scope Necromancer creates and owns explicit Web Animations API work. It does not observe CSS-authored transitions or animations, inject `@keyframes`, watch DOM mutations, generate springs, interpolate SVG geometry, or provide a JavaScript tween fallback. Keep CSS as the owner of declarative component styling and use a dedicated charting or tweening tool when the animation needs capabilities beyond WAAPI keyframes. ## Testing jsdom does not implement `Element.animate()`, so lifecycle tests need a fake. `@vielzeug/necromancer/testing` has no test-runner import and supports explicit resource management. ```ts import { animate } from '@vielzeug/necromancer'; import { installFakeAnimations } from '@vielzeug/necromancer/testing'; using animations = installFakeAnimations(); const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]); animations.calls[0]?.animation.finish(); await handle.result; // { status: 'finished' } ``` Use `restore()` when `using` is unavailable. The lifecycle fake supports `finish()`, `cancel()`, and `finished`; use browser tests for native playback controls. Use `createRect()` to mock `Element.getBoundingClientRect()` when testing code that calls `captureLayout()`. ## Framework Integration Create handles in a client mount lifecycle and dispose them during unmount. The same composition works with reactive effect systems: start the animation in the effect and return `handle.dispose()` as its cleanup. ```tsx import { useEffect, useRef } from 'react'; import { animate } from '@vielzeug/necromancer'; export function Notice() { const elementRef = useRef(null); useEffect(() => { const element = elementRef.current; if (!element) return; const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 }); return () => handle.dispose(); }, []); return Saved; } ``` ## Working with Other Vielzeug Libraries Use Dnd's `onBeforeReorder` hook to capture item layout before a sortable commits its DOM update. Animate the captured transition after `onReorder`; see the [optimistic reorder recipe](/dnd/examples/optimistic-reorder-with-revert.md). ## Best Practices - Start animations only after their elements mount in the browser. - Set animation timing explicitly when native zero-duration behavior is not intended. - Dispose each handle or group with its UI owner. - Use native `Animation` objects for playback control. - Respect the default `'system'` motion setting unless movement is essential. - Use a parent `AbortSignal` for cancellable application flow. - Keep `delay` numeric when combining it with non-zero `stagger`. - Capture layout before mutation and animate each transition exactly once. ### Examples ## Examples - [Animate on Mount](./examples/animate-on-mount.md) - [Stagger a List](./examples/stagger-a-list.md) - [Animate a Reorder](./examples/animate-a-reorder.md) ### REPL Examples - captureLayout() - FLIP Reorder (id: `flip`) - animate() - Lifecycle Handle (id: `lifecycle`) - motion - Reduced Motion (id: `reduced-motion`) - animateEach() - Stagger a Group (id: `stagger`) --- ## @vielzeug/orbit **Category:** ui ### Overview ## Why Orbit? Floating UI needs one owner for CSS coordinates, clipping boundaries, updates, and cleanup. Orbit provides a lifecycle positioner for normal UI and a pure computation API for advanced integrations. ```ts // Before const { x, y } = computeSomehow(trigger, panel); panel.style.left = `${x}px`; panel.style.top = `${y}px`; // After const positioner = createPositioner(trigger, panel); ``` | Feature | Manual DOM positioning | Orbit | | --- | --- | --- | | Bundle size | 0 B | | | Root dependencies | Application-defined | `@vielzeug/arsenal` | | Clipping boundary | Manual geometry | `clippingAncestors` default | | Coordinate strategy | Consumer logic | `fixed` / `absolute` | | Cleanup | Manual listeners | `dispose()` | **Use Orbit when** floating UI needs robust placement, collision handling, or reactive updates. **Consider direct CSS when** placement is static and never depends on element geometry. ## Installation ```sh [pnpm] pnpm add @vielzeug/orbit ``` ```sh [npm] npm install @vielzeug/orbit ``` ```sh [yarn] yarn add @vielzeug/orbit ``` ## Quick Start Create a positioner only after its reference and floating elements mount; positioning starts immediately. ```ts import { createPositioner, flip, offset, shift } from '@vielzeug/orbit'; const positioner = createPositioner(trigger, tooltip, { middleware: [offset(8), flip(), shift({ padding: 6 })], placement: 'top', }); positioner.dispose(); ``` ## Features - `createPositioner()`: Lifecycle-owned floating positioning - `computePosition()`: Low-level calculation for advanced integrations - `autoUpdate()`: Scroll, viewport, resize, and animation-frame updates - Middleware: Offset, flip, shift, size, hide, arrow, inline, auto-placement - `strategy`: Explicit `fixed` or `absolute` coordinate behavior - `/reactive`: Optional Ripple position readable ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Refine](/refine/): Accessible components using floating UI behavior. - [Ore](/ore/): Lifecycle ownership for custom-element positioning. - [Prism](/prism/): Chart tooltips positioned from virtual references. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createPositioner()` | Lifecycle-owned floating positioning | Sync | Starts immediately; create after mount | | `computePosition()` | Low-level geometry computation | Sync | Caller owns CSS application | | `autoUpdate()` | Listen for geometry changes | Sync | Call returned cleanup | | `createReactivePositioner()` | Optional Ripple position readable | Sync | Requires `@vielzeug/ripple` | | Middleware factories | Adjust placement and size | Sync | Order is explicit | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/orbit` | Positioner, computation, updates, middleware, and types. | | `@vielzeug/orbit/reactive` | Optional Ripple position adapter. | | `@vielzeug/orbit/presets` | Preset placement and middleware options. | | `@vielzeug/orbit/devtools` | Development overlay. | ## Core Functions ### `createPositioner()` ```ts function createPositioner( reference: ReferenceElement, floating: HTMLElement, options?: PositionerOptions, ): Positioner; ``` Creates an active positioner and applies its initial position before returning. | Parameter | Type | Description | | --- | --- | --- | | `reference` | `ReferenceElement` | DOM or virtual anchor. | | `floating` | `HTMLElement` | Positioned element. | | `options` | `PositionerOptions` | Strategy, clipping, middleware, updates, and application callback. | **Returns:** `Positioner`. ```ts import { createPositioner } from '@vielzeug/orbit'; const positioner = createPositioner(trigger, tooltip); positioner.dispose(); ``` | Member | Return | Contract | | --- | --- | --- | | `update()` | `void` | Recomputes and applies position. | | `getPosition()` | `ComputePositionResult` | Latest applied result. | | `dispose()` | `void` | Stops updates and aborts disposal signal. | ### `computePosition()` ```ts function computePosition( reference: ReferenceElement, floating: HTMLElement, options?: ComputePositionOptions, ): ComputePositionResult; ``` Calculates position without applying DOM styles or creating listeners. **Returns:** `ComputePositionResult`. ### `autoUpdate()` ```ts function autoUpdate( reference: ReferenceElement, floating: HTMLElement, update: () => void, options?: AutoUpdateOptions, ): () => void; ``` Calls `update` immediately, then on relevant scroll, viewport, resize, and optional animation-frame changes. **Returns:** cleanup callback. ## Middleware ```ts type Middleware = (state: MiddlewareState) => MiddlewareResult | undefined; ``` Built-in factories: `arrow`, `autoPlacement`, `flip`, `hide`, `inline`, `offset`, `shift`, `limitShift`, and `size`. ```ts const middleware = [offset(8), flip(), shift({ padding: 6 }), size()]; ``` `middlewareData` is `MiddlewareData`; narrow custom data at the consuming boundary. ## Reactive Adapter ```ts function createReactivePositioner( reference: ReferenceElement, floating: HTMLElement, options?: Omit, ): ReactivePositioner; ``` `ReactivePositioner.position` is `Readable`. Imported from `@vielzeug/orbit/reactive`. ## Types ```ts type Side = 'top' | 'bottom' | 'left' | 'right'; type Alignment = 'start' | 'end'; type Placement = Side | `${Side}-${Alignment}`; interface Rect { height: number; width: number; x: number; y: number; } interface VirtualReference { getBoundingClientRect: () => DOMRect | Rect; getClientRects?: () => DOMRectList | DOMRect[]; } type ReferenceElement = Element | VirtualReference; interface SideObject { bottom: number; left: number; right: number; top: number; } type Padding = number | Partial; interface ArrowData { centerOffset: number; constrained: boolean; x?: number; y?: number; } interface FlipData { skippedPlacements: Placement[]; } interface ShiftData { x: number; y: number; } interface HideData { escaped?: boolean; escapedOffsets?: SideObject; referenceHidden?: boolean; referenceHiddenOffsets?: SideObject; } interface SizeData { availableHeight: number; availableWidth: number; } interface MiddlewareData { arrow?: ArrowData; flip?: FlipData; hide?: HideData; shift?: ShiftData; size?: SizeData; [key: string]: unknown; } interface MiddlewareState { boundary?: Element | Rect; elements: { floating: HTMLElement; reference: ReferenceElement }; initialPlacement: Placement; middlewareData: MiddlewareData; padding?: Padding; placement: Placement; rects: { floating: Rect; reference: Rect }; x: number; y: number; } type MiddlewareReset = { placement?: Placement; rects?: MiddlewareState['rects']; remeasure?: boolean; }; interface MiddlewareResult { data?: MiddlewareData; placement?: Placement; reset?: MiddlewareReset; x?: number; y?: number; } type Middleware = (state: MiddlewareState) => MiddlewareResult | undefined; interface ComputePositionResult { middlewareData: MiddlewareData; placement: Placement; x: number; y: number; } interface ComputePositionOptions { boundary?: Element | Rect; containingBlock?: Element | null; middleware?: readonly Middleware[]; padding?: Padding; placement?: Placement; } interface DetectOverflowOptions { boundary?: Element | Rect; padding?: Padding; } type PositionStrategy = 'absolute' | 'fixed'; interface PositionerOptions extends Omit { apply?: (result: ComputePositionResult) => void; autoUpdate?: AutoUpdateOptions | false; boundary?: ComputePositionOptions['boundary'] | 'clippingAncestors'; strategy?: PositionStrategy; } interface Positioner { readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; getPosition(): ComputePositionResult; update(): void; [Symbol.dispose](): void; } interface AutoUpdateOptions { animationFrame?: boolean; observeAncestors?: boolean; observeFloating?: boolean; observeVisualViewport?: boolean; pauseWhenHidden?: boolean; throttle?: number; } interface ReactivePositioner extends Positioner { readonly position: Readable; } interface ArrowOptions { element: HTMLElement; padding?: Padding; } interface AutoPlacementOptions extends DetectOverflowOptions { alignment?: Alignment | null; allowedPlacements?: Placement[]; } interface FlipOptions extends DetectOverflowOptions { fallbackPlacements?: Placement[]; } interface HideOptions extends DetectOverflowOptions { strategy?: 'referenceHidden' | 'escaped' | 'both'; } type OffsetConfig = { crossAxis?: number; mainAxis?: number; }; type OffsetValue = number | OffsetConfig | ((state: MiddlewareState) => number | OffsetConfig); type ShiftLimiter = ( state: MiddlewareState, correction: { crossAxis: number; mainAxis: number }, ) => { crossAxis: number; mainAxis: number }; interface LimitShiftOptions { offset?: number | ((state: MiddlewareState) => number); } interface ShiftOptions extends DetectOverflowOptions { crossAxis?: boolean; limiter?: ShiftLimiter; } interface InlineOptions { padding?: Padding; x?: number; y?: number; } type SizeOptions = DetectOverflowOptions; interface PositioningPreset { middleware: Middleware[]; placement: Placement; } interface PresetOptions { offset?: number; padding?: number; placement?: Placement; } ``` ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `OrbitConfigError` | Invalid middleware reset configuration | Extends `OrbitError` | | `OrbitError` | Base Orbit error | `instanceof OrbitError` narrows Orbit errors | ### Usage Guide ## Basic Usage Create a positioner after both elements mount, then dispose it with their owner. ```ts import { createPositioner, flip, offset, shift } from '@vielzeug/orbit'; const positioner = createPositioner(trigger, tooltip, { middleware: [offset(8), flip(), shift({ padding: 6 })], placement: 'top', }); positioner.dispose(); ``` `createPositioner()` applies the initial position before returning and owns clipping-boundary resolution, updates, CSS strategy, and cleanup. ## Coordinate Strategy Use `fixed` for viewport-positioned overlays. Use `absolute` when the floating element should position within its offset parent. ```ts const positioner = createPositioner(trigger, dropdown, { placement: 'bottom-start', strategy: 'absolute', }); ``` Orbit resolves clipping ancestors by default. Pass an explicit `boundary` when your application owns a different visible region. ## Middleware Pass middleware in the exact order it should execute. ```ts const positioner = createPositioner(trigger, panel, { middleware: [ offset(8), flip(), shift({ padding: 8 }), size(), arrow({ element: arrowElement }), ], }); ``` Use either `flip()` or `autoPlacement()` for one positioner. Custom middleware writes data into `result.middlewareData`. ## Virtual References Use a virtual reference for cursor-anchored UI. ```ts const reference = { getBoundingClientRect: () => ({ height: 0, width: 0, x: event.clientX, y: event.clientY }), }; const positioner = createPositioner(reference, menu, { placement: 'bottom-start' }); ``` ## Manual Positioning Use `computePosition()` only when your application owns CSS application and lifecycle itself. ```ts import { computePosition, offset } from '@vielzeug/orbit'; const result = computePosition(reference, floating, { middleware: [offset(8)] }); floating.style.left = `${result.x}px`; floating.style.top = `${result.y}px`; ``` ## Reactive Adapter Install Ripple and import the optional adapter only when your UI needs a reactive position value. ```ts import { createReactivePositioner } from '@vielzeug/orbit/reactive'; import { effect } from '@vielzeug/ripple'; const positioner = createReactivePositioner(trigger, tooltip); effect(() => { const position = positioner.position.value; tooltip.style.left = `${position.x}px`; tooltip.style.top = `${position.y}px`; }); ``` ## Client Lifecycle Orbit root imports are server-safe. Invoke geometry APIs only from a client mount lifecycle, where DOM elements exist. ```ts onMounted(() => { const positioner = createPositioner(trigger, panel); onCleanup(() => positioner.dispose()); }); ``` ## Framework Integration Create and dispose positioners with component lifecycle. ```tsx [React] useEffect(() => { const positioner = createPositioner(trigger, panel); return () => positioner.dispose(); }, [trigger, panel]); ``` ```vue [Vue 3] onMounted(() => { const positioner = createPositioner(trigger.value!, panel.value!); onUnmounted(() => positioner.dispose()); }); ``` ```ts [Svelte] onMount(() => { const positioner = createPositioner(trigger, panel); return () => positioner.dispose(); }); ``` ## Working with Other Vielzeug Libraries ### Orbit + Prism Use `strategy: 'absolute'` for a tooltip rendered inside a chart container. ```ts const positioner = createPositioner(cursorReference, tooltip, { autoUpdate: false, strategy: 'absolute', }); positioner.dispose(); ``` ## Best Practices - **Create** a positioner after both DOM elements mount; it starts immediately. - **Dispose** it with its UI owner. - **Choose** `fixed` or `absolute` intentionally. - **Keep** middleware order explicit. - **Use** `computePosition()` only for advanced platform-managed paths. - **Install** Ripple only when importing `/reactive`. - **Invoke** geometry APIs only on the client. ### Examples ## Examples - [Context Menu](./examples/context-menu.md) - [Custom Middleware](./examples/custom-middleware.md) - [Dropdown Select](./examples/dropdown-select.md) - [Popover with Arrow](./examples/popover-with-arrow.md) - [Reactive Adapter](./examples/reactive-adapter.md) - [Tooltip](./examples/tooltip.md) - [Using Presets](./examples/using-presets.md) - [With Ore Component](./examples/with-ore-component.md) ### REPL Examples - autoUpdate - Track on Scroll/Resize (id: `auto-update`) - inline - Multi-line Inline Reference (id: `inline-middleware`) - computePosition - Basic (id: `position-basic`) - createPositioner - With Middleware (id: `position-float`) - presets - Ready-made Middleware Stacks (id: `presets`) - size() - Constrain Height (id: `size-middleware`) --- ## @vielzeug/ore **Category:** ui-primitives ### 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 @vielzeug/ripple ``` ```sh [npm] npm install @vielzeug/ore @vielzeug/ripple ``` ```sh [yarn] yarn add @vielzeug/ore @vielzeug/ripple ``` ## Quick Start ```ts import { computed, signal } from '@vielzeug/ripple'; import { bind, css, define, html, onMounted, 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) { 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(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\`` - Lifecycle hooks: `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`: plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls - Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `live`, `unsafeHtml` - Host bindings via `bind({ attr, class, style, on })`: pass `{ target: el }` to bind any off-host element - Reactive ARIA sync via explicit `aria-*` keys in `bind({ attr }, { target })`, with automatic cleanup on disconnect - Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()` - Form-associated `useField()` and observer helpers are root exports - Testing utilities (`@vielzeug/ore/testing`): `mount`, `renderHook`, `flush`, `cleanup` - Generic testing utilities (scoped queries, named event dispatchers, and async waits) are exported by `@vielzeug/assay` ## Package Entry Points | Import | Purpose | | --------------------------- | ----------------------------------------------------------------------------- | | `@vielzeug/ore` | All browser runtime APIs: components, directives, `useField`, and observers | | `@vielzeug/ore/testing` | Ore-specific mounting, lifecycle flushing, hooks, cleanup, and form internals | | `@vielzeug/assay` | Generic DOM events, scoped queries, and async waiting | ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.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 All browser-runtime symbols below are imported from `@vielzeug/ore`. Lifecycle/context/binding functions (`onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`, `bind`, `provide`, `useEmit`, `useSlots`, `getHost`) resolve the active component through an implicit "current component" context: they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window. > `watchEffect` is not named `watch`: `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file. | Symbol | Purpose | Execution mode | Common gotcha | | ---------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------------------- | | `define()` | Create and register a custom element | 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` | | `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 unique typed injection key | Sync | Context is scoped to the component tree | | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys report `ore:error`; plain `T[]` is a one-time static render | | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription | | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler | | `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` | | `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect | | `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 | | `onFormReset(fn)` | Run work when the ancestor `` resets | Setup only | Fires every reset (not one-shot); only for `formAssociated: true` components | | `useEmit()` | Typed `emit()` bound to the current host | Setup only | Call once per component; returns `dispatchEvent`'s boolean (`false` if a listener called `preventDefault()`) | | `useSlots()`| Reactive slot presence/element signals | Setup only | Safe to call more than once: the underlying registry is created once | | `getHost()` | The current component's host element | Setup only | Prefer a higher-level helper (`bind`, …) when one exists | ## Package Entry Points | Import | Purpose | | ------------------------- | ------------------------------------------------------------------ | | `@vielzeug/ore` | All browser runtime APIs, including directives, fields, and lifecycle helpers | | `@vielzeug/ore/testing` | Ore-specific mounting, lifecycle, hook, cleanup, and form test support | | `@vielzeug/assay` | Generic DOM events, scoped queries, and async waiting | ## Core Component API ### `define(tag, definition)` ```ts define(tag: string, definition: ComponentDefinition): CustomElementConstructor; ``` Returns the registered class (for `instanceof` or subclassing); registration itself is the only global side effect. The `setup()` function receives only typed prop signals: ```ts setup(props) { return html`${props.label}`; } ``` Everything else: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls): ```ts import { define, html, onMounted, useEmit, useSlots } from '@vielzeug/ore'; define('my-card', { setup(_props) { const emit = useEmit(); const slots = useSlots(); onMounted(() => console.log('mounted')); // emit() returns dispatchEvent's boolean: false if a listener called preventDefault() const notCancelled = emit('close'); return html`${when(slots.has('header'), () => html``)}`; }, }); ``` `useEmit()` and `useSlots()` are factory hooks: call them once per setup run to get a typed `emit`/`slots` bound to the current host. `useSlots()` is safe to call more than once within that setup run. ### ComponentDefinition ```ts type ComponentDefinition = { formAssociated?: boolean; props?: PropsDef; setup: (props: InferProps>) => HTMLResult | null; shadow?: Partial | false; // false = light DOM (no shadow root) styles?: (string | CSSStyleSheet | CSSResult)[]; }; ``` ## Runtime Helpers `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watchEffect` are plain functions imported from `@vielzeug/ore`. Call them directly during `setup()`. ```ts import { html, onCleanup, onEvent, onMounted } from '@vielzeug/ore'; setup(props) { 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`...`; } ``` Because these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too: no need to pass hooks in as options: ```ts import { onCleanup } from '@vielzeug/ore'; function useMyHelper() { onCleanup(() => { /* teardown */ }); } // In setup: setup(_props) { useMyHelper(); 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, ordinary 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 | | `unsafeHtml(value)` | HTML rendering sink; sanitize untrusted values before calling | `each()` treats number and string keys as distinct. A duplicate-key update reports `ore:error` and preserves the last valid list. ### `unsafeHtml` `unsafeHtml()` is an explicit HTML injection sink. It has no global sanitizer: sanitize untrusted content before passing it to the directive, so the trust boundary remains at the call site. ```ts import { unsafeHtml } from '@vielzeug/ore'; const safeArticle = sanitize(userSuppliedArticle); return html`${unsafeHtml(safeArticle)}`; ``` ## Host Bindings `bind(config, options?)` is a plain function imported from `@vielzeug/ore`: ```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. ### Reactive ARIA attributes Use explicit `aria-*` keys in `bind({ attr }, { target })`: ```ts const stopAria = bind( { attr: { 'aria-controls': panelId, 'aria-expanded': () => isOpen.value, 'aria-haspopup': 'listbox', }, }, { target: triggerEl }, ); ``` Static values are applied once. Getters and signals create reactive effects. Calling `stopAria()` stops updates. Values of `null`, `undefined`, or `false` remove 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 unique typed injection key; the description is diagnostic only - `provide(key, value)`: Provide a value to descendants - `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 `provide()` and `inject()` must be called synchronously during `setup()`. Calling them outside a setup context throws `'Lifecycle hooks must be called during component setup'`. Context resolution walks the ancestor chain including shadow DOM boundaries. `inject()` resolves and caches its result once per consumer: provide a `Readable` (signal/computed) rather than a raw value if descendants need to observe later changes; re-calling `provide()` with a new raw value afterward is not seen by consumers that already resolved it (a dev-mode warning fires when a key is provided twice on the same element). `provide()` registers cleanup automatically: context keys are removed from the registry when the providing component disconnects, so reconnecting the same element runs `setup()` fresh without spurious "overwriting" warnings or stale keys leaking to descendants. ## Utilities - `ref()`: Create a `Signal` element reference. Set to the element via `ref=` in templates. - `createId(prefix = 'id')`: Generate a collision-resistant ID with a semantic prefix, a per-runtime tag, and a monotonic counter (for example, `'field-a3k21'`). ## Form-Associated API Import from `@vielzeug/ore`. ### `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; /** Called when the ancestor resets (see onFormReset): restore local field state here. */ onReset?: () => void; toFormValue?: (value: T) => File | FormData | string | null; /** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */ validationMessage?: Readable; validity?: Readable; value: Signal | Readable; }; type FormFieldHandle = { checkValidity(): boolean; readonly internals: ElementInternals; reportValidity(): boolean; /** Set (non-empty message) or clear (empty string) a custom validity error. */ setCustomValidity(message: string): void; }; ``` Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation through `checkValidity()` and `reportValidity()`: ```ts const isBlank = (v: string) => v.trim() === ''; useField({ validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')), validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)), value, }); ``` ## 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, options?)` | Register auto-cleanup; pass `{ formInternals: true }` to also install the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below) | | `installFormInternalsPolyfill()` | Installs the form-internals polyfill directly (returns an `uninstall()` that restores every patched global). Usually called via `install(afterEach, { formInternals: true })` | | `flush(options?)` | Drain reactive updates and animation frames | | `renderHook(setup)` | Run lifecycle hooks in isolation; overload accepts `propDefs` as first arg for typed props | | `OreTimeoutError` | Error thrown when `flush()` cannot settle tracked Ore work | > **Test isolation:** `cleanup()` removes mounted elements and resets Ore's cross-test caches and counters. Call it in `afterEach`, or use `install(afterEach)`. Import `within`, named dispatchers such as `fireClick`, and waits such as `waitUntil` or `waitForEvent` from `@vielzeug/assay`. > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API: `install(afterEach, { formInternals: true })` polyfills `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`validity`/`states`, mixes `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already exists, and `installFormInternalsPolyfill()` returns an `uninstall()` that restores every patched global. The polyfill is opt-in (`{ formInternals: true }`) because the patches are global: suites without form-associated components shouldn't carry them. A downstream package (e.g. a component library built on `ore`) should rely on this instead of hand-rolling its own copy. #### `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; get(selector: string): E; query(selector: string): E | null; queryAll(selector: string): E[]; getByText(text: string, selector?: string): E; queryByText(text: string, selector?: string): E | null; queryAllByText(text: string, selector?: string): E[]; getByTestId(testId: 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`, `watchEffect`, `inject`, etc.) without a template. `onMounted`/`onCleanup`/`watchEffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current-component context: ```ts // Without props const { result, flush, dispose } = await renderHook(() => { 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:error` | When a lifecycle callback fails: bubbles, composed; detail is `OreLifecycleError` | ## Types ```ts type PropDef = { readonly default: T; readonly parse: (value: string | null) => T; reflect?: boolean; }; type PropsDef> = { [K in keyof Required]: PropDef; }; type PropInputDefs = Record>; /** * Infer reactive props type from a PropInputDefs map. * Each entry becomes Readable keyed by prop name. */ type InferProps = { readonly [K in keyof D]-?: Readable>; }; // Runtime hooks: all plain functions imported from '@vielzeug/ore', not fields on an object. type OnMountedCallback = () => Cleanup | undefined; type OnFormResetCallback = () => void; declare function onMounted(fn: OnMountedCallback): void; // DOM-ready callback; runs after each connection's render declare function onCleanup(fn: Cleanup): void; // Register teardown; called on disconnect declare function onElement( ref: Readable, callback: (el: T) => Cleanup | undefined, ): () => void; declare function onEvent( target: EventTarget | null | undefined, event: K, listener: (e: HTMLElementEventMap[K]) => void, options?: AddEventListenerOptions, ): void; declare function onEvent( target: EventTarget | null | undefined, event: string, listener: EventListener, options?: AddEventListenerOptions, ): void; declare function onFormReset(fn: OnFormResetCallback): void; // Runs on every ancestor reset; formAssociated only declare function watchEffect(fn: () => Cleanup | undefined): () => void; // Scoped reactive effect; auto-cleaned on disconnect declare function bind(config: HostBindConfig, options?: BindOptions): () => void; // Bindings for host or any target element declare function provide(key: InjectionKey, value: T): void; // Register a context value on the host element declare function inject(key: InjectionKey): T | undefined; declare function inject(key: InjectionKey, fallback: T): T; declare function getHost(): HTMLElement; // The current component's host element declare function useEmit = Record>(): EmitFn; declare function useSlots(): ComponentSlots; type ComponentDefinition = Record> = { formAssociated?: boolean; props?: PropsDef; setup: (props: InferProps>) => HTMLResult | null; shadow?: Partial | false; // false = light DOM styles?: (string | CSSStyleSheet | CSSResult)[]; }; type MaybeReactive = T | Readable | (() => T); // Plain value, signal, or getter: accepted by when(), each(), classMap(), and bind() type HostBindingValue = MaybeReactive; type AttributeBindings = Record; type HostBindConfig = { attr?: AttributeBindings; class?: (() => Record) | Record | (() => boolean) | boolean>; on?: Record void) | undefined>; style?: Record; }; type BindOptions = AddEventListenerOptions & { target?: Element; }; 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 }; interface HTMLResult { mount( parent: ParentNode, anchor: Node | null, registerCleanup: (fn: () => void) => void, ): Node[]; } type CSSResult = { content: string; toString(): string; }; type LiveBinding = { readonly source: Readable }; type EmitFn> = { >(event: K): boolean; >>(event: K, detail: T[K]): boolean; }; // KeysWithoutDetail is an internal helper type, not exported. type FormFieldOptions = { disabled?: Readable; emptyStringForNull?: boolean; onReset?: () => void; toFormValue?: (value: T) => File | FormData | string | null; validationMessage?: Readable; validity?: Readable; value: Signal | Readable; }; type FormFieldHandle = { checkValidity: () => boolean; readonly internals: ElementInternals; reportValidity: () => boolean; setCustomValidity: (message: string) => void; }; /** Phase in which a OreError occurred. */ type OreErrorPhase = 'each-reconcile' | 'form-reset' | 'mounted' | 'setup'; ``` ## Errors `OreError` is the base class for every Ore error class: `err instanceof OreError` catches all of them. - **`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()`. - **`OreInternalError`**: thrown when an Ore invariant fails, indicating a package bug rather than invalid application code. - **`OreLifecycleError`**: reported in the `ore:error` event when component `setup()`, a mounted callback, a form-reset callback, or `each()` reconciliation fails. Extends `OreError` with: - `component: string`: the element's local name - `phase: OreErrorPhase`: `'setup'` | `'mounted'` | `'form-reset'` | `'each-reconcile'` - `cause: Error`: the original error thrown by `setup()` - **`OreTimeoutError`**: thrown by `flush()` (from `@vielzeug/ore/testing`) when pending Ore work does not settle before its timeout. Lifecycle failures dispatch a bubbling, composed `ore:error` event whose `detail` is the `OreLifecycleError`. Setup failures still rethrow their original error; mounted and form-reset callback failures are reported through the same event so their remaining callbacks can continue. ### Usage Guide ## Basic Usage `define(tag, definition)` creates and registers a custom element. Call it from the module or browser bootstrap responsible for registration. Your `setup()` function receives typed prop signals and returns an `HTMLResult` directly. Its state belongs to the current connection: disconnect disposes it, and reconnecting the same element runs setup again. ```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')} `; }, }); ``` Everything besides `props`: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls): ```ts import { define, getHost, html, bind, useEmit, useSlots } from '@vielzeug/ore'; define('my-widget', { setup(_props) { const el = getHost(); // the host HTMLElement const emit = useEmit(); // typed event emitter const slots = useSlots(); // reactive slot observation bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on) 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 `onMounted()` for DOM-dependent initialization that must run after the template is mounted. Use `onElement(ref, cb)` for work tied to a specific DOM node. `onEvent()` attaches a listener that is automatically removed on disconnect. ```ts import { signal } from '@vielzeug/ripple'; import { define, html, onElement, onEvent, onMounted, ref, useSlots } from '@vielzeug/ore'; define('deferred-init', { setup(_props) { const tabIndex = signal(0); const inputRef = ref(); const slots = useSlots(); 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 exports `each`, `classMap`, `styleMap`, `when`, `live`, and `unsafeHtml` from `@vielzeug/ore`. Use ordinary attribute bindings plus native event handlers for two-way input state; no special model directive is required. ```ts import { signal } from '@vielzeug/ripple'; import { classMap, define, each, html, styleMap, when } 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 string or number per item; number and string keys remain distinct - **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 { define, html, live } from '@vielzeug/ore'; define('live-search', { setup() { const query = signal(''); return html` (query.value = (e.target as HTMLInputElement).value)} /> `; }, }); ``` ## host bindings `bind()` wires reactive attrs, classes, styles, and events to the host element. ```ts import { signal } from '@vielzeug/ripple'; import { bind, define, html } from '@vielzeug/ore'; define('x-toggle', { setup(_props) { 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 explicit `aria-*` keys in `bind({ attr: config }, { target })` to reactively sync ARIA attributes to any element. ```ts import { signal } from '@vielzeug/ripple'; import { bind, define, html, onMounted } from '@vielzeug/ore'; define('x-disclosure', { setup(_props) { 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) { // bind() registers cleanup automatically when called inside setup bind( { attr: { 'aria-controls': panelId, 'aria-expanded': () => String(open.value), 'aria-haspopup': 'region', }, }, { target: trigger }, ); } }); return html``; }, }); ``` Static values are applied once. Getter functions create reactive effects. Setting a value to `null`, `undefined`, or `false` removes the attribute. `bind()` 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 = bind({ attr: { 'aria-expanded': () => String(open.value) } }, { target: trigger }); // 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 { bind, define, html, onMounted, ref } from '@vielzeug/ore'; define('button-wrapper', { setup(_props) { 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 { define, html, useEmit, useSlots, when } from '@vielzeug/ore'; define('card-with-footer', { setup(_props) { const slots = useSlots(); const emit = useEmit(); return html` ${when(slots.has('footer'), () => html``)} emit('action')}>Go `; }, }); ``` Pass a `SlotNames` type parameter to `useSlots()` to get typed `slots.has()` and `slots.elements()` calls. ## context provide/inject ```ts import { signal } from '@vielzeug/ripple'; import { createContext, define, html, injectStrict, provide } from '@vielzeug/ore'; const COUNT_CTX = createContext>>('count'); define('count-provider', { setup(_props) { 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}`; }, }); ``` `provide()` registers cleanup automatically: context keys are removed from the registry when the providing component disconnects. On reconnect, `setup()` runs fresh and `provide()` re-registers without spurious "overwriting" warnings. Provide a `Readable` (signal/computed) rather than a raw value if descendants need to observe later changes: `inject()` resolves and caches the value once per consumer connection. ## form-associated elements ```ts import { signal } from '@vielzeug/ripple'; import { define, html, prop } from '@vielzeug/ore'; import { 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} `; }, }); ``` ## Sentinel Observers Use `@vielzeug/sentinel` for subscribable browser and DOM observations. Create element-dependent Sentinels inside `onMounted()` and dispose them with the component. ```ts import { define, html, onCleanup, onMounted, ref, watchEffect } from '@vielzeug/ore'; import { fromSubscribable } from '@vielzeug/ripple'; import { createElementSize, SentinelUnavailableError } from '@vielzeug/sentinel'; define('x-observed', { setup(_props) { const boxRef = ref(); onMounted(() => { const element = boxRef.value; if (!element) return; try { const size = createElementSize(element); const sizeState = fromSubscribable(size, { signal: size.disposalSignal }); watchEffect(() => { console.log(sizeState.value?.width); }); onCleanup(() => size.dispose()); } catch (error) { if (!(error instanceof SentinelUnavailableError)) throw error; } }); 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 { fireClick } from '@vielzeug/assay'; import { html } from '@vielzeug/ore'; import { cleanup, 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(() => fireClick(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. `useField()` remains intentionally narrow: it connects a form-associated custom element to native `ElementInternals` without imposing submission, validation, or dirty-state policy. ```ts import { createForm } from '@vielzeug/forge'; import { define, html } from '@vielzeug/ore'; define('signup-form', { setup(_props) { const form = createForm({ initialValues: { email: '' } }); return html` { event.preventDefault(); void form.submit(async (values) => { console.log(values); }); }}> `; }, }); ``` ## Best Practices - Setup returns `html\`...\`` directly: not a function wrapping the template. - Use `watchEffect()` for reactive subscriptions tied to component lifetime: it auto-registers cleanup on disconnect. - Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node. - Bind host attributes and classes via `bind()` rather than mutating the element directly. - Provide context at the nearest ancestor: avoid global context singletons. - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions). - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits. - Extract composable helper functions freely: `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters. - Test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic DOM events, queries, and waits from `@vielzeug/assay`. ### 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/postmaster **Category:** Async ### Overview ## Why Postmaster? Application jobs that touch a remote service: posting a form, syncing state, sending analytics: must survive page reloads, resume later, retry according to an explicit policy, and retain terminal failures for recovery. Postmaster coordinates that delivery with typed job definitions, leased processing, and a dead-letter queue, all backed by IndexedDB. ```ts // Before async function createTodo(payload: { id: string; title: string }) { // Lost on reload. No retry. No recovery. Silent failure. await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload) }); } // After import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; import { s } from '@vielzeug/spell'; const jobs = defineJobs({ createTodo: { version: 1, validate: s.object({ id: s.string(), title: s.string() }), key: (p) => p.id, execute: async (payload, { key, signal }) => { await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload), headers: { 'Idempotency-Key': key }, signal, }); }, }, }); const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' }); const postmaster = createPostmaster({ jobs, store }); await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' }); postmaster.start(); ``` | Feature | Postmaster | Ad hoc outbox | Familiar | | --- | --- | --- | --- | | Bundle size | | Application-defined | | | Zero dependencies | | | | | Survives page reload | | | | | Leased cross-tab processing | | | | | Dead-letter recovery | | | | | Typed job payloads | | | | **Use Postmaster when** application jobs must survive reloads, retry explicitly, and remain recoverable after terminal failure. **Consider Familiar when** jobs are CPU-bound, in-memory only, and never need to survive a page reload. ## Installation ```sh [pnpm] pnpm add @vielzeug/postmaster ``` ```sh [npm] npm install @vielzeug/postmaster ``` ```sh [yarn] yarn add @vielzeug/postmaster ``` For browser persistence, also install `@vielzeug/vault` (a workspace peer of the IndexedDB adapter): ```sh [pnpm] pnpm add @vielzeug/postmaster @vielzeug/vault ``` ```sh [npm] npm install @vielzeug/postmaster @vielzeug/vault ``` ```sh [yarn] yarn add @vielzeug/postmaster @vielzeug/vault ``` ## Quick Start Define typed jobs, create a durable store, enqueue work, and start the processor. Dispose both the processor and the store when the page lifetime ends. ```ts import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; const jobs = defineJobs({ createTodo: { version: 1, validate: (v: unknown) => v as { id: string; title: string }, key: (p) => p.id, execute: async (payload, { key, signal }) => { await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload), headers: { 'Idempotency-Key': key }, signal, }); }, retry: { maxAttempts: 5, shouldRetry: () => true }, }, }); const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' }); const postmaster = createPostmaster({ jobs, store }); await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' }); postmaster.start(); // On page unload: await postmaster.dispose(); await store.dispose(); ``` ## Features - `defineJobs()`: Typed job registry with payload inference and validation. - `createPostmaster()`: Processor with leased claims, heartbeat renewal, and crash recovery. - `enqueue()`: Persist a job and wake the processor, with optional delayed eligibility via `availableAt`. - `flush()`: Process every available job until the queue is empty. - `retry()` / `remove()`: Recover or discard dead-letter jobs. - `tap()`: Typed runtime events for enqueued, started, completed, retry-scheduled, dead-lettered, removed, lease-lost, and processor-error. - `createIndexedDbPostmasterStore()`: Durable browser store backed by Vault IndexedDB. - `createMemoryPostmasterStore()`: Deterministic in-memory store for tests. ## Documentation - [**Usage Guide**](./usage.md) - [**API Reference**](./api.md) - [**Examples**](./examples.md) - [**3.0 Migration Guide**](./migration.md) ## See Also - [@vielzeug/courier](../courier/): Perform the HTTP requests Postmaster jobs coordinate. - [@vielzeug/sourcerer](../sourcerer/): Reload paginated collection state after durable jobs complete. - [@vielzeug/vault](../vault/): IndexedDB storage primitive backing the durable store. - [@vielzeug/sentinel](../sentinel/): Flush the outbox when the network returns. - [@vielzeug/familiar](../familiar/): In-memory Web Worker pool for CPU-bound tasks. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `defineJobs()` | Typed job registry with validation | Sync | Throws on invalid version, missing fields, or bad retry config | | `createPostmaster()` | Processor with leased claims and retry | Sync | Store is borrowed, not disposed with the processor | | `createIndexedDbPostmasterStore()` | Durable browser store | Sync | Skips corrupt records and reports each issue through `onCorruptRecord` | | `createMemoryPostmasterStore()` | Deterministic in-memory store | Sync | Use for tests only | | `PostmasterError` | Base class for package errors | Sync | Catch a subtype when recovery is specific | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/postmaster` | Job definitions, processor, events, errors | | `@vielzeug/postmaster/store` | Store-author interfaces (`StoredJob`, `StoreTx`, `PostmasterStore`) | | `@vielzeug/postmaster/indexeddb` | Durable browser store backed by Vault IndexedDB | | `@vielzeug/postmaster/testing` | Deterministic in-memory store and test helpers | ## Factories ### `defineJobs()` ```ts function defineJobs(jobs: J): J; ``` Returns the job registry after validating each definition. Rejects invalid versions, missing `execute`/`key`, retry configurations with non-positive `maxAttempts`, and migration ranges that contain gaps or do not lead into the current version. | Parameter | Type | Description | | --- | --- | --- | | `jobs` | `J extends JobDefinitions` | Map of job name to definition | **Returns:** `J`: the same registry, typed for payload inference. **Example** ```ts import { defineJobs } from '@vielzeug/postmaster'; const jobs = defineJobs({ createTodo: { version: 1, validate: (v) => v as { id: string; title: string }, key: (p) => p.id, execute: async (payload, { key, signal }) => { await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload), headers: { 'Idempotency-Key': key }, signal, }); }, }, }); ``` --- ### `createPostmaster()` ```ts function createPostmaster(options: CreatePostmasterOptions): Postmaster; ``` Returns a Postmaster processor that claims, executes, retries, and dead-letters jobs from the borrowed store. | Parameter | Type | Description | | --- | --- | --- | | `options.jobs` | `J` | Job registry from `defineJobs()` | | `options.store` | `PostmasterStore` | Borrowed store; not disposed with the processor | | `options.leaseDuration` | `number` | Lease duration in ms (default 30000, minimum 1000) | | `options.clock` | `() => number` | Deterministic clock for tests (default `Date.now`) | | `options.signal` | `AbortSignal` | External signal that disposes the processor | **Returns:** `Postmaster`. **Example** ```ts import { createPostmaster } from '@vielzeug/postmaster'; import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; const store = createIndexedDbPostmasterStore({ name: 'outbox' }); const postmaster = createPostmaster({ jobs, store }); postmaster.start(); await postmaster.dispose(); await store.dispose(); ``` --- ### `createIndexedDbPostmasterStore()` ```ts function createIndexedDbPostmasterStore(options: IndexedDbPostmasterStoreOptions): PostmasterStore; ``` Returns a durable Postmaster store backed by Vault IndexedDB. Uses one internal table indexed by `status`, `availableAt`, and `leaseExpiresAt`. All operations run inside Vault transactions. Persisted records are validated before use; invalid records are skipped so they cannot block valid work. | Parameter | Type | Description | | --- | --- | --- | | `options.name` | `string` | IndexedDB database name | | `options.onCorruptRecord` | `(record: CorruptStoredJob) => void` | Optional diagnostic called once for each distinct invalid-record issue; handler errors are ignored | **Returns:** `PostmasterStore`. **Example** ```ts import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox', onCorruptRecord: ({ id, reason }) => console.error('corrupt outbox record', id, reason), }); await store.dispose(); ``` --- ### `createMemoryPostmasterStore()` ```ts function createMemoryPostmasterStore(entries?: readonly StoredJob[]): PostmasterStore; ``` Returns a deterministic in-memory store for tests. Serializes all operations through a promise chain. | Parameter | Type | Description | | --- | --- | --- | | `entries` | `readonly StoredJob[]` | Initial records (default empty) | **Returns:** `PostmasterStore`. **Example** ```ts import { createMemoryPostmasterStore } from '@vielzeug/postmaster/testing'; const store = createMemoryPostmasterStore(); await store.dispose(); ``` ## Postmaster Methods ### `enqueue()` ```ts enqueue( name: K, payload: InferJobPayload, options?: EnqueueOptions, ): Promise; ``` Validates the payload (if `validate` is defined), derives the key, persists the job, and wakes the processor. Throws `PostmasterError` for an empty key, non-JSON-serializable payload, or invalid `availableAt`. | Parameter | Type | Description | | --- | --- | --- | | `name` | `K` | Registered job name | | `payload` | `InferJobPayload` | Job payload (validated if `validate` is defined) | | `options.availableAt` | `number` | Earliest epoch timestamp (ms) the job may be claimed. Defaults to the Postmaster clock. Must be a finite non-negative safe integer. | **Delayed eligibility.** The job persists immediately but cannot be claimed before `availableAt`. Postmaster does not guarantee execution at that time: only that the job will not be claimed earlier. A live processor (`start()` or `flush()`) is required for execution. Past timestamps remain immediately eligible. **Example** ```ts await postmaster.enqueue('sendDigest', { userId }, { availableAt: Date.now() + 60_000 }); ``` --- ### `start()` ```ts start(): void; ``` Begins background processing. Idempotent: returns immediately and kicks off the pump in the background. Use `flush()` when you need to await completion of all available work. --- ### `flush()` ```ts flush(options?: { signal?: AbortSignal }): Promise; ``` Processes every available job until the queue is empty or the signal aborts. Concurrent `flush()` calls join the same drain. Returns counts of processed, completed, dead-lettered, and retry-scheduled jobs. --- ### `list()` ```ts list(filter?: EntryFilter): Promise; ``` Returns entries ordered by `createdAt`. Filter by `status` optionally. --- ### `stats()` ```ts stats(): Promise; ``` Returns counts of queued, running, and dead-letter jobs. --- ### `retry()` ```ts requeue(id: string): Promise; ``` Moves a dead-letter job back to queued. Returns a discriminated result: `requeued`, `not-found`, `not-dead-letter`, or `running`. --- ### `remove()` ```ts remove(id: string): Promise; ``` Deletes a queued or dead-letter job. Returns a discriminated result: `removed`, `not-found`, or `running`. --- ### `tap()` ```ts tap(handler: (event: PostmasterEvent) => void, options?: { signal?: AbortSignal }): () => void; ``` Observe runtime events (enqueued, started, completed, retry-scheduled, dead-lettered, removed, lease-lost, processor-error, dispose). Handler errors are swallowed: observability never affects processing. Returns an unsubscribe function. Pass `{ signal }` to auto-detach on abort. --- ### `dispose()` ```ts dispose(): Promise; [Symbol.asyncDispose](): Promise; ``` Aborts owned work, releases all active leases, and tears down subscriptions. Idempotent. Does not dispose the borrowed store. ## Types ### `EnqueueOptions` ```ts interface EnqueueOptions { readonly availableAt?: number; } ``` Options for `enqueue()`. `availableAt` is the earliest epoch timestamp (ms) at which the job may be claimed. Defaults to the Postmaster clock at enqueue time. Past timestamps remain immediately eligible. Postmaster does not guarantee execution at the requested time: only that the job will not be claimed before it. A live processor is required for execution. --- ### `JobDefinition` ```ts interface JobDefinition { readonly version: number; readonly validate?: Validate; readonly key: (payload: T) => string; readonly execute: (payload: T, context: JobContext) => Promise; readonly retry?: RetryPolicy; readonly migrate?: VersionMigrations; } ``` `validate` is optional. Accepts a function `(value: unknown) => T` or any structural parser with `parse(value: unknown): T` (Spell schemas, Zod schemas, etc). Called once at enqueue. If omitted, payload trusted as-is. `migrate` declares contiguous version-step migrations keyed by the source version. Key `n` transforms a payload from version `n` to `n + 1`. The lowest key is the earliest supported stored version, and the highest key must lead into the current version. Omit `migrate` when only the current version is supported. Unsupported versions, thrown steps, and `undefined` migration output move the record to dead-letter. --- ### `Validate` ```ts type Validate = ((value: unknown) => T) | { parse(value: unknown): T }; ``` Accepts either a plain validation function or any object with a `parse(value: unknown): T` method. Spell's `Schema` and `s.object(...)` satisfy this contract directly: no adapter needed. --- ### `VersionMigrations` ```ts type VersionMigrations = { readonly [fromVersion: number]: (payload: unknown) => unknown }; ``` Contiguous version-step migrations keyed by the source version. Key `n` transforms a payload from version `n` to `n + 1`. The lowest key declares the earliest supported stored version. The range must have no gaps and must end at the step entering the current job version. --- ### `IndexedDbPostmasterStoreOptions` Available from `@vielzeug/postmaster/indexeddb`. ```ts type IndexedDbPostmasterStoreOptions = { readonly name: string; readonly onCorruptRecord?: (record: CorruptStoredJob) => void; }; ``` --- ### `CorruptStoredJob` Available from `@vielzeug/postmaster/indexeddb`. ```ts interface CorruptStoredJob { readonly id?: string; readonly reason: string; } ``` `id` is omitted when the malformed value contains no usable record identifier. --- ### `JobContext` ```ts interface JobContext { readonly attempt: number; readonly entryId: string; readonly key: string; readonly signal: AbortSignal; } ``` --- ### `RetryPolicy` ```ts interface RetryPolicy { readonly maxAttempts: number; readonly shouldRetry?: (error: unknown, attempt: number) => boolean; readonly delay?: (attempt: number) => number; } ``` `maxAttempts` is total executions including the first. When `shouldRetry` is omitted, any error retries until `maxAttempts` is reached. Default delay uses an exponential backoff cap (`min(1000 × 2ⁿ, 30_000)` ms). --- ### `StoredJob` Available from `@vielzeug/postmaster/store`. ```ts interface StoredJob { readonly id: string; readonly name: string; readonly version: number; readonly payload: JsonValue; readonly key: string; readonly status: 'queued' | 'running' | 'dead-letter'; readonly attempts: number; readonly createdAt: number; readonly updatedAt: number; readonly availableAt: number; readonly ownerId?: string; readonly leaseExpiresAt?: number; readonly failure?: StoredFailure; } ``` --- ### `StoredFailure` ```ts interface StoredFailure { readonly name: string; readonly message: string; readonly occurredAt: number; } ``` Only a bounded error name/message/timestamp is persisted. Never persist arbitrary error objects, response bodies, headers, or stacks. --- ### `PostmasterEntry` ```ts type PostmasterEntry = Pick; ``` The public entry view excludes `payload`, `ownerId`, and `leaseExpiresAt`. --- ### `PostmasterStore` Available from `@vielzeug/postmaster/store`. ```ts interface PostmasterStore { transact(fn: (tx: StoreTx) => Promise): Promise; list(filter?: EntryFilter): Promise; subscribe(listener: () => void): () => void; dispose(): Promise; readonly disposed: boolean; readonly disposalSignal: AbortSignal; [Symbol.asyncDispose](): Promise; } interface StoreTx { get(id: string): Promise; put(entry: StoredJob): Promise; delete(id: string): Promise; findClaimable(now: number): Promise; findNextWake(now: number): Promise; countByStatus(): Promise; } ``` The store exposes transactional primitives. The processor owns all ownership and transition logic: stores implement storage, not the job state machine. `transact` wraps all operations in an atomic transaction. `findClaimable` returns the earliest eligible job (queued with `availableAt <= now`, or running with expired lease). `findNextWake` returns the earliest future wake time across queued and running jobs. --- ### `PostmasterEvent` ```ts type PostmasterEvent = | { readonly type: 'enqueued' | 'started' | 'completed' | 'retry-scheduled' | 'dead-lettered'; readonly entry: PostmasterEntry } | { readonly type: 'removed' | 'lease-lost'; readonly id: string } | { readonly type: 'processor-error'; readonly error: Error } | { readonly type: 'dispose' }; ``` --- ### `FlushResult` ```ts interface FlushResult { readonly processed: number; readonly completed: number; readonly deadLettered: number; readonly retryScheduled: number; } ``` --- ### `RequeueResult` / `RemoveResult` ```ts type RequeueResult = | { readonly status: 'not-found' | 'not-dead-letter' | 'running' } | { readonly status: 'requeued'; readonly entry: PostmasterEntry }; type RemoveResult = | { readonly status: 'not-found' | 'running' } | { readonly status: 'removed'; readonly id: string }; ``` ## Errors ### `PostmasterError` ```ts class PostmasterError extends Error { constructor(message: string, options?: ErrorOptions); } ``` Base class for package-defined errors. Use `instanceof PostmasterError` to narrow to the hierarchy. Covers configuration errors, serialization errors, and store failures. --- ### `PostmasterDisposedError` ```ts class PostmasterDisposedError extends PostmasterError {} ``` Thrown when a public method is called after disposal. --- ### `PostmasterJobError` ```ts class PostmasterJobError extends PostmasterError {} ``` Thrown when a job definition is missing, a version is incompatible, or a migration fails. These errors move the job to dead-letter rather than rejecting the public call. ### Usage Guide ## Basic Usage Define typed jobs, create a durable store, enqueue work, and start the processor. Dispose both handles when the owner ends. ```ts import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; const jobs = defineJobs({ createTodo: { version: 1, validate: (v: unknown) => v as { id: string; title: string }, key: (p) => p.id, execute: async (payload, { key, signal }) => { await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload), headers: { 'Idempotency-Key': key }, signal, }); }, }, }); const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' }); const postmaster = createPostmaster({ jobs, store }); await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' }); postmaster.start(); // On page unload: await postmaster.dispose(); await store.dispose(); ``` The store is borrowed by `createPostmaster()` and is not disposed with the processor. Dispose both explicitly. ## At-least-once delivery and idempotency Postmaster provides **at-least-once delivery**. A crash after the remote write but before local completion can repeat the job. Every job must derive a stable idempotency key, and handlers must send or otherwise enforce that key. ```ts const jobs = defineJobs({ createTodo: { version: 1, validate: (v: unknown) => v as { id: string; title: string }, key: (p) => p.id, execute: async (payload, { key, signal }) => { await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload), headers: { 'Idempotency-Key': key }, signal, }); }, }, }); ``` Never assume exactly-once execution. Design handlers so a repeated delivery is safe. ## Postmaster jobs vs Courier requests Courier performs immediate HTTP transport. Postmaster coordinates durable delivery. Use Courier inside a Postmaster job when the write must survive reloads. ```ts import { createCourier, CourierNetworkError } from '@vielzeug/courier'; import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; const courier = createCourier({ baseUrl: 'https://api.example.com' }); const jobs = defineJobs({ createTodo: { version: 1, validate: (v: unknown) => v as { id: string; title: string }, key: (p) => p.id, execute: async (payload, { key, signal }) => { await courier.request('/todos', { method: 'POST', body: payload, headers: { 'Idempotency-Key': key }, signal, }); }, retry: { maxAttempts: 5, shouldRetry: (error) => error instanceof CourierNetworkError }, }, }); ``` Postmaster does not import Courier. The integration happens in your job definitions. ## Payload and version migration Each job declares a `version` and an optional `validate` function. When a stored job's version is older than the registered version, Postmaster applies contiguous version-step migrations before validating. `migrate` is keyed by the source version: key `n` transforms version `n` into `n + 1`. The lowest key declares the earliest stored version you support, and the range must lead into the current version without gaps. Omit `migrate` when a job has no supported older records. `validate` accepts a plain function `(value: unknown) => T` or any structural parser with `parse(value: unknown): T`: Spell schemas work directly: ```ts import { s } from '@vielzeug/spell'; const jobs = defineJobs({ createTodo: { version: 2, validate: s.object({ id: s.string(), title: s.string(), priority: s.number().optional() }), key: (p) => p.id, migrate: { 1: (payload) => ({ ...(payload as { id: string; title: string }), priority: 0 }), }, execute: async (payload, { key, signal }) => { await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload), headers: { 'Idempotency-Key': key }, signal, }); }, }, }); ``` Unknown job names, unsupported versions, failed migrations, `undefined` migration output, and invalid migrated payloads move to dead-letter rather than being executed. ## Corrupt persisted records The IndexedDB adapter validates durable records before the processor uses them. It skips malformed records so one corrupt value cannot block valid jobs. Supply `onCorruptRecord` to surface each distinct issue to your diagnostics: ```ts const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox', onCorruptRecord: ({ id, reason }) => reportOutboxCorruption({ id, reason }), }); ``` The callback may omit `id` when the persisted value has no usable identifier. Handler errors are ignored. Repair or remove reported records with your application's storage recovery tooling; Postmaster never deletes corrupt durable data automatically. ## Retry semantics Retries are opt-in and explicitly classified. No `retry` block means one attempt followed by dead-letter. ```ts const jobs = defineJobs({ syncTodo: { version: 1, validate: (v: unknown) => v as { id: string }, key: (p) => p.id, execute: async (payload, { signal }) => { await fetch(`/api/todos/${payload.id}/sync`, { signal }); }, retry: { maxAttempts: 5, shouldRetry: (error) => error instanceof TypeError, // network errors only }, }, }); ``` - `maxAttempts` means total executions, including the first. - `shouldRetry` is required when retries are enabled. Postmaster never guesses whether a write is safe to repeat. - Default delay uses an exponential backoff cap (`min(1000 × 2ⁿ, 30_000)` ms). Override with `delay`. - Delay must be finite and non-negative. - Lifecycle aborts caused by disposal are not classified as job failures. ## Delayed eligibility `enqueue()` accepts an optional `availableAt` timestamp. The job persists immediately but cannot be claimed before that time. Use this for scheduled writes, cooldowns, or any work that must survive a reload but should not run yet. ```ts await postmaster.enqueue('sendDigest', { userId }, { availableAt: Date.now() + 60_000 }); ``` Postmaster does not guarantee execution at `availableAt`: only that the job will not be claimed earlier. A live processor (`start()` or `flush()`) is required for execution. In a browser, a closed page or suspended service worker will run the job when the processor next becomes active. Past timestamps remain immediately eligible. The same mechanism already backs retry delays, so delayed eligibility reuses the existing claim, wake, and persistence paths. ## Dead-letter recovery Jobs that exhaust retries or hit a terminal failure move to dead-letter. Inspect, retry, or remove them. ```ts const deadLettered = await postmaster.list({ status: 'dead-letter' }); for (const entry of deadLettered) { console.log(entry.id, entry.name, entry.failure); } // Retry a dead-letter job back into the queue. await postmaster.requeue(entry.id); // Or remove it permanently. await postmaster.remove(entry.id); ``` `retry()` and `remove()` return discriminated results so callers can distinguish `not-found`, `not-dead-letter`, `running`, and successful outcomes without exceptions. ## Lifecycle and disposal `start()` begins background processing and returns immediately (synchronous, idempotent). `dispose()` stops claiming new work, aborts owned work, and is idempotent. `flush()` processes every available job and awaits completion. ```ts postmaster.start(); // ...on unload await postmaster.dispose(); await store.dispose(); ``` Disposal aborts owned work, releases the active lease, and is idempotent. A controlled disposal abort does not consume the attempt: the job returns to queued. ## Events Tap runtime events for observability. Handler errors are swallowed: observability never affects processing. ```ts const unsubscribe = postmaster.tap((event) => { switch (event.type) { case 'enqueued': console.log('enqueued', event.entry.id); break; case 'completed': console.log('completed', event.entry.id); break; case 'dead-lettered': console.error('dead-lettered', event.entry.id, event.entry.failure); break; case 'processor-error': console.error('processor error', event.error); break; } }); ``` Pass an `AbortSignal` to auto-detach: ```ts const controller = new AbortController(); postmaster.tap(handler, { signal: controller.signal }); controller.abort(); // stops tapping ``` ## Testing Use the in-memory store for deterministic tests. ```ts import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; import { createMemoryPostmasterStore } from '@vielzeug/postmaster/testing'; const store = createMemoryPostmasterStore(); const postmaster = createPostmaster({ jobs: defineJobs({ send: { version: 1, validate: (v: unknown) => String(v), key: (p) => p, execute: async () => {}, }, }), store, }); await postmaster.enqueue('send', 'hello'); await postmaster.flush(); await postmaster.dispose(); ``` Inject a deterministic clock to control retry scheduling. ```ts let now = 0; const postmaster = createPostmaster({ clock: () => now, jobs, store }); ``` ## Framework Integration Create the Postmaster after the component mounts, start processing, and dispose on unmount. ```tsx [React] import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; import { createPostmaster, defineJobs, type Postmaster } from '@vielzeug/postmaster'; import { useEffect } from 'react'; const jobs = defineJobs({ sync: { version: 1, validate: (v: unknown) => v as { id: string }, key: (p) => p.id, execute: async (payload, { signal }) => { await fetch(`/api/sync/${payload.id}`, { signal }); }, }, }); export function OutboxProvider() { useEffect(() => { const store = createIndexedDbPostmasterStore({ name: 'outbox' }); const postmaster = createPostmaster({ jobs, store }); postmaster.start(); return () => { void postmaster.dispose(); void store.dispose(); }; }, []); return null; } ``` ```vue [Vue 3] import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; import { onMounted, onUnmounted } from 'vue'; const jobs = defineJobs({ sync: { version: 1, validate: (v: unknown) => v as { id: string }, key: (p) => p.id, execute: async (payload, { signal }) => { await fetch(`/api/sync/${payload.id}`, { signal }); }, }, }); let postmaster: ReturnType | undefined; let store: ReturnType | undefined; onMounted(() => { store = createIndexedDbPostmasterStore({ name: 'outbox' }); postmaster = createPostmaster({ jobs, store }); postmaster.start(); }); onUnmounted(() => { void postmaster?.dispose(); void store?.dispose(); }); ``` ```svelte [Svelte] import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb'; import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; import { onMount } from 'svelte'; const jobs = defineJobs({ sync: { version: 1, validate: (v: unknown) => v as { id: string }, key: (p) => p.id, execute: async (payload, { signal }) => { await fetch(`/api/sync/${payload.id}`, { signal }); }, }, }); onMount(() => { const store = createIndexedDbPostmasterStore({ name: 'outbox' }); const postmaster = createPostmaster({ jobs, store }); postmaster.start(); return () => { void postmaster.dispose(); void store.dispose(); }; }); ``` ## Working with Other Vielzeug Libraries ### Postmaster + Courier Use Courier inside job handlers for HTTP transport. Postmaster coordinates delivery; Courier performs the request. ```ts import { createCourier, CourierNetworkError } from '@vielzeug/courier'; import { createPostmaster, defineJobs } from '@vielzeug/postmaster'; const courier = createCourier({ baseUrl: 'https://api.example.com' }); const jobs = defineJobs({ createTodo: { version: 1, validate: (v: unknown) => v as { id: string; title: string }, key: (p) => p.id, execute: async (payload, { key, signal }) => { await courier.request('/todos', { method: 'POST', body: payload, headers: { 'Idempotency-Key': key }, signal, }); }, retry: { maxAttempts: 5, shouldRetry: (e) => e instanceof CourierNetworkError }, }, }); ``` ### Postmaster + Sentinel Flush the outbox when the network returns. Sentinel reports online state; Postmaster does the rest. ```ts import { createNetwork } from '@vielzeug/sentinel'; import { createPostmaster } from '@vielzeug/postmaster'; const network = createNetwork(); const postmaster = createPostmaster({ jobs, store }); const unsubscribe = network.subscribe(() => { if (network.getSnapshot().online) void postmaster.flush(); }); // On teardown: unsubscribe(); network.dispose(); await postmaster.dispose(); ``` ### Postmaster + Vault The IndexedDB adapter is built on Vault. Use Vault directly for unrelated storage; the Postmaster store owns its own database name. ## Best Practices - **Derive** a stable idempotency key from every job payload and send it with the remote write. - **Dispose** both the processor and the store explicitly; the processor does not own the store. - **Classify** retryable errors explicitly with `shouldRetry`; never let Postmaster guess. - **Migrate** persisted payloads when job versions change; test migrations against stored fixtures. - **Inspect** dead-letter jobs and report `onCorruptRecord` diagnostics to your recovery tooling. - **Avoid** persisting sensitive data in payloads or failure messages; IndexedDB is per-origin but not encrypted. - **Flush** the outbox when Sentinel reports the network returns. - **Test** with the in-memory store and a deterministic clock for reproducible retry timing. ### Examples ## Examples - [Queue Offline Courier Requests](./examples/queue-offline-courier-mutations.md) - [Resume When Network Returns](./examples/resume-when-network-returns.md) - [Delayed Eligibility](./examples/delayed-eligibility.md) - [Recover Dead-Letter Jobs](./examples/recover-dead-letter-jobs.md) - [Service Worker Background Sync](./examples/service-worker-background-sync.md) ### REPL Examples - defineJobs - Basic Outbox (id: `define-jobs`) - delayedEnqueue - Delayed Eligibility (id: `delayed-enqueue`) --- ## @vielzeug/prism **Category:** ui ### 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-create or mutate the Chart.js instance when data changes }); // After: Prism, responsive SVG with an explicit update boundary import { createLineChart } from '@vielzeug/prism'; const chart = createLineChart(document.getElementById('chart')!, { a11y: { ariaLabel: 'Users by day' }, series: [ { data: [ { key: 1, value: 12 }, { key: 2, value: 40 }, { key: 3, value: 28 }, ], name: 'Users', }, ], tooltip: true, }); chart.update([ { data: [ { key: 1, value: 12 }, { key: 2, value: 40 }, { key: 3, value: 28 }, { key: 4, value: 65 }, ], name: 'Users', }, ]); ``` | Feature | Prism | Chart.js | Lightweight Charts | D3 | | ------------------ | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | -------------------------------------------- | | Bundle size | | ~60 kB | ~45 kB | ~30 kB (core) | | Zero dependencies | (Orbit) | | | (modular) | | Renderer | SVG | Canvas | Canvas | SVG/Canvas | | Data updates | Explicit `update()` | Instance mutation | Series mutation | Manual | | CSS themeable | | | Limited | | | Framework-neutral | | | | | | Accessible SVG | | | | Manual | | Keyboard navigation | | | | Manual | | TypeScript-first | | Partial | | Types available | **Use Prism when** you need lightweight, framework-neutral charts with explicit updates and SVG output that can be styled with CSS. It fits dashboards, admin panels, and data-heavy applications. **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 '@vielzeug/prism/theme'; const chart = createLineChart(document.getElementById('chart')!, { a11y: { ariaLabel: 'Revenue by month' }, series: [ { color: '#3b82f6', data: [ { key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }, ], name: 'Revenue', }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, tooltip: true, crosshair: true, onHover: (event) => console.log(event?.datum), }); // Update chart data explicitly chart.update([ { color: '#3b82f6', data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }, { key: 5, value: 28 }], name: 'Revenue', }, ]); // 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 a gradient or solid fill - **`createPieChart(container, config)`**: pie, donut, or semi-circle donut chart; labels that do not fit their slice are hidden - **`createRadarChart(container, config)`**: radar chart for comparing values across 3+ axes, with per-axis ranges - **`createSparkline(container, config)`**: minimal inline line, area, bar, or stack chart - **`linearScale`, `timeScale`, `bandScale`**: standalone scales with nice tick generation - **`ChartHandle.update(data)`**: replace chart data synchronously without coupling to a state library - **`setTheme(theme)` / `resetTheme()`**: apply or clear custom colors, font, and grid tokens at runtime - **Series comparison**: hover or keyboard focus reports every series at the active key in the tooltip, `onHover`, and screen-reader announcements - **Keyboard navigation**: arrow keys, `Enter`, and `Escape` on every labelled chart except sparklines - **CSS custom properties**: full theme control via `--prism-*` tokens, with dark mode built in - **Responsive**: auto-resizes via `ResizeObserver` - **Devtools**: `debugChart()` from `@vielzeug/prism/devtools` logs mount/resize/dispose to `console.debug` - **`Symbol.dispose`**: explicit resource management following the TC39 proposal ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [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 | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createLineChart()` | Render a line chart | Sync | Line keys must be numbers or dates | | `createAreaChart()` | Render an area chart | Sync | Line keys must be numbers or dates | | `createBarChart()` | Render grouped or stacked bars | Sync | Stacked negative values are clamped to zero | | `createPieChart()` | Render pie, donut, or semi-circle slices | Sync | Event callbacks use slice/index arguments | | `createRadarChart()` | Compare values across 3+ axes, such as hero stats | Sync | Events report a whole axis, not one series | | `createSparkline()` | Render an inline line, area, bar, or stack | Sync | Omitted `a11y` makes the chart decorative | | `linearScale()` | Create a numeric scale | Sync | `nice` defaults to `true` | | `timeScale()` | Create a date scale | Sync | Invalid dates produce an invalid domain | | `bandScale()` | Create a categorical scale | Sync | Unknown categories map to `0` and warn in development | | `setTheme()` | Set Prism CSS custom properties | Sync | Values apply to `document.documentElement` | | `resetTheme()` | Remove Prism theme overrides | Sync | Removes only properties managed by `setTheme()` | | `seriesColor()` | Resolve a series palette color | Sync | Palette indexes wrap after eight colors | | `debugChart()` | Log chart lifecycle events | Sync | Import from `@vielzeug/prism/devtools` | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/prism` | Chart factories, scales, theme helpers, errors, and public types | | `@vielzeug/prism/theme` | Default CSS custom properties and dark-mode values | | `@vielzeug/prism/devtools` | Optional `debugChart()` lifecycle logging | ## Chart Factories ### `createLineChart()` ```ts function createLineChart( container: HTMLElement, config: LineChartConfig, ): ChartHandle; ``` Renders a line chart and returns a handle that replaces the complete series array through `update()`. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host that receives the SVG and optional overlays | | `config` | `LineChartConfig` | Initial series, axes, interaction, accessibility, and transition settings | **Returns:** `ChartHandle`. ```ts import { createLineChart } from '@vielzeug/prism'; const chart = createLineChart(container, { a11y: { ariaLabel: 'Revenue by month' }, series: [{ data: [{ key: 1, value: 10 }], name: 'Revenue' }], }); chart.update([{ data: [{ key: 2, value: 20 }], name: 'Revenue' }]); ``` --- ### `createAreaChart()` ```ts function createAreaChart( container: HTMLElement, config: AreaChartConfig, ): ChartHandle; ``` Renders an area chart and returns a handle that replaces the complete series array through `update()`. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host that receives the SVG and optional overlays | | `config` | `AreaChartConfig` | Initial series, axes, interaction, accessibility, and transition settings | **Returns:** `ChartHandle`. ```ts import { createAreaChart } from '@vielzeug/prism'; const chart = createAreaChart(container, { series: [{ data: [{ key: 1, value: 10 }], name: 'Revenue' }], }); ``` --- ### `createBarChart()` ```ts function createBarChart( container: HTMLElement, config: BarChartConfig, ): ChartHandle; ``` Renders grouped, stacked, grouped-horizontal, or stacked-horizontal bars. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host that receives the SVG and optional overlays | | `config` | `BarChartConfig` | Initial series, variant, axes, accessibility, and interaction settings | **Returns:** `ChartHandle`. ```ts import { createBarChart } from '@vielzeug/prism'; const chart = createBarChart(container, { series: [{ data: [{ key: 'Open', value: 12 }], name: 'Tasks' }], variant: 'grouped', }); ``` --- ### `createPieChart()` ```ts function createPieChart( container: HTMLElement, config: PieChartConfig, ): ChartHandle; ``` Renders pie, donut, or semi-circle slices. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host that receives the SVG and optional overlays | | `config` | `PieChartConfig` | Initial slices, variant, geometry, accessibility, and interaction settings | **Returns:** `ChartHandle`. ```ts import { createPieChart } from '@vielzeug/prism'; const chart = createPieChart(container, { data: [{ label: 'Complete', value: 72 }, { label: 'Remaining', value: 28 }], variant: 'donut', }); ``` --- ### `createRadarChart()` ```ts function createRadarChart( container: HTMLElement, config: RadarChartConfig, ): ChartHandle; ``` Renders one closed shape per series across three or more axes. `update()` replaces the series; the axes are fixed at creation. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host that receives the SVG and optional overlays | | `config` | `RadarChartConfig` | Axes, series, scale, grid, appearance, accessibility, and interaction settings | | Field | Type | Default | Description | | --- | --- | --- | --- | | `axes` | `RadarAxisConfig[]` | N/A | Ordered axes, drawn clockwise; needs at least 3 | | `series` | `RadarSeriesConfig[]` | N/A | Values keyed by `axis.key` | | `domain` | `[number, number]` | `[0, nice max]` | Shared value range; `axis.min`/`axis.max` override it per axis | | `grid` | `boolean \| RadarGridConfig` | `true` | Rings, bands, and level labels; `false` hides the grid | | `fill` | `'gradient' \| 'none' \| 'solid'` | `'solid'` | Shape fill; `gradient` deepens toward the rim | | `curve` | `'linear' \| 'rounded'` | `'linear'` | Straight edges or a smooth closed curve | | `showPoints` | `boolean` | `true` | Vertex dots; a missing value shows a hollow dot at the centre | | `showValues` | `boolean` | `false` | Print each value beside its vertex | | `startAngle` | `number` | `0` | Degrees clockwise from 12 o'clock for the first axis | | `onHover` | `(event: RadarEvent \| null) => void` | N/A | Nearest axis under the pointer or keyboard focus | | `onClick` | `(event: RadarEvent) => void` | N/A | Axis clicked, or activated with Enter/Space | **Returns:** `ChartHandle`. ```ts import { createRadarChart } from '@vielzeug/prism'; const chart = createRadarChart(container, { a11y: { ariaLabel: 'Hero stats' }, axes: [ { key: 'str', label: 'Strength' }, { key: 'agi', label: 'Agility' }, { key: 'end', label: 'Endurance' }, ], domain: [0, 10], series: [{ data: [{ key: 'str', value: 8 }, { key: 'agi', value: 6 }, { key: 'end', value: 9 }], name: 'Adam' }], }); ``` Hovering or arrowing onto an axis highlights its spoke and reports every series' value on it. Without a tooltip, the chart announces the values through its own `role="status"` region. A value for an unknown axis, duplicate axis keys, or fewer than 3 axes warns in development. --- ### `createSparkline()` ```ts function createSparkline( container: HTMLElement, config: SparklineConfig, ): ChartHandle; ``` Renders a compact chart without axes, margin, or legend. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host that receives the SVG | | `config` | `SparklineConfig` | Initial values, variant, appearance, accessibility, and callbacks | **Returns:** `ChartHandle`. ```ts import { createSparkline } from '@vielzeug/prism'; const chart = createSparkline(container, { data: [2, 5, 3, 8], variant: 'area' }); chart.update([2, 5, 3, 8, 13]); ``` ## Scale Factories ### `linearScale()` ```ts function linearScale(config: LinearScaleConfig): Scale; ``` | Field | Type | Default | Description | | --- | --- | --- | --- | | `domain` | `[number, number]` | N/A | Input extent | | `range` | `[number, number]` | N/A | Output extent | | `clamp` | `boolean` | `false` | Clamp mapped and inverted values | | `nice` | `boolean` | `true` | Expand the domain to rounded boundaries | **Returns:** `Scale`. ```ts import { linearScale } from '@vielzeug/prism'; const scale = linearScale({ domain: [0, 100], range: [0, 500] }); scale.map(50); // 250 ``` --- ### `timeScale()` ```ts function timeScale(config: TimeScaleConfig): Scale; ``` | Field | Type | Default | Description | | --- | --- | --- | --- | | `domain` | `[Date, Date]` | N/A | Input date extent | | `range` | `[number, number]` | N/A | Output extent | | `nice` | `boolean` | `true` | Expand the domain to rounded interval boundaries | **Returns:** `Scale`. ```ts import { timeScale } from '@vielzeug/prism'; const scale = timeScale({ domain: [new Date('2026-01-01'), new Date('2026-12-31')], range: [0, 500], }); ``` --- ### `bandScale()` ```ts function bandScale(config: BandScaleConfig): BandScale; ``` | Field | Type | Default | Description | | --- | --- | --- | --- | | `domain` | `string[]` | N/A | Ordered categories | | `range` | `[number, number]` | N/A | Output extent | | `padding` | `number` | `0.1` | Inner gap ratio | | `paddingOuter` | `number` | `padding` | Outer gap ratio | **Returns:** `BandScale`. ```ts import { bandScale } from '@vielzeug/prism'; const scale = bandScale({ domain: ['A', 'B'], range: [0, 200] }); ``` ## Theme Utilities ### `setTheme()` ```ts function setTheme(theme: PrismTheme): void; ``` Sets Prism color, font, and grid custom properties on `document.documentElement`. ### `resetTheme()` ```ts function resetTheme(): void; ``` Removes every custom property managed by `setTheme()`. ### `seriesColor()` ```ts function seriesColor(index: number, override?: string): string; ``` Returns `override` when provided; otherwise returns `var(--prism-color-N)` with an eight-color wrap. ## Types ### Core types ```ts interface ChartHandle { readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly el: SVGSVGElement; update(data: TData): void; dispose(): void; [Symbol.dispose](): void; } interface ChartMargin { top: number; right: number; bottom: number; left: number; } interface ChartDimensions { width: number; height: number; margin: ChartMargin; } type ChartA11y = | { readonly decorative: true } | { readonly ariaLabel: string; readonly decorative?: false }; ``` `update()` renders synchronously and throws `PrismRenderError` after disposal. Omitting `a11y` makes the SVG decorative with `aria-hidden="true"`. --- ### Data types ```ts interface Datum { key: TKey; value: number; meta?: Record; } type ContinuousDatum = Datum; interface Series { name: string; data: TDatum[]; color?: string; } interface ChartEvent { datum: Datum; series: Series; originalEvent: Event; values?: SeriesValue[]; // every series at the same key (line, area, bar) } interface SeriesValue { datum: Datum | undefined; series: Series; } ``` Line and area series use `ContinuousDatum`; bar series accept the complete `Datum` key range. On line, area, and bar charts, `datum`/`series` name the series nearest the pointer, and `values` lists every series at that key (a missing value is `undefined`). `originalEvent` is a `MouseEvent`, or a `KeyboardEvent` for keyboard navigation. --- ### Chart configurations ```ts interface BaseChartConfig { a11y?: ChartA11y; legend?: boolean | LegendConfig; margin?: Partial; onClick?: (event: ChartEvent) => void; onHover?: (event: ChartEvent | null) => void; tooltip?: boolean | TooltipConfig; transition?: TransitionConfig; xAxis?: XAxisConfig; yAxis?: YAxisConfig; } interface LineChartConfig extends BaseChartConfig { crosshair?: boolean | CrosshairConfig; series: LineSeriesConfig[]; } interface AreaChartConfig extends BaseChartConfig { crosshair?: boolean | CrosshairConfig; series: AreaSeriesConfig[]; } interface BarChartConfig extends BaseChartConfig { series: BarSeriesConfig[]; variant?: BarVariant; } interface PieChartConfig extends Omit { data: PieSliceConfig[]; variant?: PieVariant; innerRadius?: number; cornerRadius?: number; padPixels?: number; onClick?: (slice: PieSliceConfig, index: number) => void; onHover?: (slice: PieSliceConfig | null, index: number | null) => void; } interface RadarChartConfig extends Omit { axes: RadarAxisConfig[]; series: RadarSeriesConfig[]; curve?: 'linear' | 'rounded'; domain?: [number, number]; fill?: 'gradient' | 'none' | 'solid'; grid?: boolean | RadarGridConfig; showPoints?: boolean; showValues?: boolean; startAngle?: number; onClick?: (event: RadarEvent) => void; onHover?: (event: RadarEvent | null) => void; } interface SparklineConfig { data: number[] | StackSegment[]; variant?: SparklineVariant; a11y?: ChartA11y; color?: string; cornerRadius?: number; curve?: 'linear' | 'monotone' | 'step'; fillOpacity?: number; // overrides --prism-spark-fill-opacity (area variant) onClick?: (index: number, value: number) => void; onHover?: (index: number | null, value: number | null) => void; padPixels?: number; showEndPoint?: boolean; // default true: dot on the latest value (line, area) strokeWidth?: number; transition?: TransitionConfig; } ``` --- ### Series and slice configurations ```ts interface LineSeriesConfig extends Series { curve?: 'linear' | 'monotone' | 'step'; pointRadius?: number; showPoints?: boolean; strokeWidth?: number; // overrides --prism-line-width } interface AreaSeriesConfig extends Series { curve?: 'linear' | 'monotone' | 'step'; fill?: 'gradient' | 'solid'; // default 'gradient', fading toward the baseline fillOpacity?: number; // overrides --prism-area-opacity showLine?: boolean; } interface BarSeriesConfig extends Series { borderRadius?: number; // overrides --prism-bar-radius; stacked bars stay square } interface PieSliceConfig { value: number; color?: string; label?: string; } interface RadarAxisConfig { key: string; label: string; min?: number; max?: number; format?: (value: number) => string; } type RadarSeriesConfig = Series>; interface RadarGridConfig { bands?: boolean; // default true labels?: boolean; // default false; drawn only when every axis shares one domain levels?: number; // default 4 shape?: 'circle' | 'polygon'; // default 'polygon' } interface RadarEvent { axis: RadarAxisConfig; index: number; values: RadarAxisValue[]; originalEvent: Event; } interface RadarAxisValue { datum: Datum | undefined; series: RadarSeriesConfig; } interface StackSegment { value: number; color?: string; label?: string; } ``` A custom radar `tooltip.render` receives `datum.meta` with `axis`, `values`, and the default `text`. --- ### Axis, interaction, and transition configurations ```ts type HorizontalAxisPosition = 'bottom' | 'top'; type VerticalAxisPosition = 'left' | 'right'; type AxisPosition = HorizontalAxisPosition | VerticalAxisPosition; interface AxisConfig { grid?: boolean | GridConfig; label?: string; position?: TPosition; tickCount?: number; tickFormat?: (value: Date | number | string) => string; } type XAxisConfig = AxisConfig; type YAxisConfig = AxisConfig; interface GridConfig { color?: string; dash?: string; } interface TooltipConfig { offset?: number; render?: (datum: Datum, series: Series) => Node | string; } interface CrosshairConfig { horizontal?: boolean; snap?: boolean; vertical?: boolean; } interface LegendConfig { position?: LegendPosition; } type LegendPosition = 'bottom' | 'left' | 'right' | 'top'; interface TransitionConfig { duration?: number; easing?: EasingFn | 'ease-in' | 'ease-in-out' | 'ease-out' | 'linear'; preference?: 'always' | 'never' | 'system'; stagger?: number; } type EasingFn = (t: number) => number; ``` Tooltip strings are assigned through `textContent`. Return a `Node` for structured content. --- ### Scale types ```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[]; } interface BandScale { readonly domain: readonly string[]; readonly range: readonly [number, number]; map(value: string): number; ticks(count?: number): string[]; bandwidth(): number; gap(): number; } interface LinearScaleConfig { domain: [number, number]; range: [number, number]; clamp?: boolean; nice?: boolean; } interface TimeScaleConfig { domain: [Date, Date]; range: [number, number]; nice?: boolean; } interface BandScaleConfig { domain: string[]; range: [number, number]; padding?: number; paddingOuter?: number; } ``` --- ### Remaining unions and theme type ```ts type BarVariant = 'grouped' | 'grouped-horizontal' | 'stacked' | 'stacked-horizontal'; type PieVariant = 'donut' | 'pie' | 'semi'; type SparklineVariant = 'area' | 'bar' | 'line' | 'stack'; interface PrismTheme { colors?: string[]; fontFamily?: string; gridColor?: string; gridOpacity?: number; } ``` ## Devtools ### `debugChart()` ```ts function debugChart(handle: T, options?: DebugChartOptions): T; interface DebugChartOptions { label?: string; // log prefix; defaults to 'chart', producing [prism:chart] } ``` Logs mount, resize, and disposal events to `console.debug` and returns the same handle. Import it and `DebugChartOptions` from `@vielzeug/prism/devtools`. ## Errors ### `PrismError` Base class for every Prism-originated error. It supports standard `ErrorOptions` cause chaining. ### `PrismRenderError` Extends `PrismError`. Thrown for invalid containers, failed initial renders, and attempts to update disposed charts. ### Usage Guide ## Basic Usage Every chart needs a container element with defined dimensions and the theme CSS: ```ts import { createLineChart } from '@vielzeug/prism'; import '@vielzeug/prism/theme'; const container = document.querySelector('#chart')!; const chart = createLineChart(container, { series: [ { name: 'Revenue', data: [ { key: 1, value: 10 }, { key: 2, value: 16 }, ], }, ], }); chart.dispose(); ``` ```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. ## Updating Data Chart factories render synchronously and return a typed handle. Call `update()` with the same data shape used by the factory when application state changes. ```ts import { createLineChart } from '@vielzeug/prism'; const chart = createLineChart(container, { series: [{ data: [{ key: 1, value: 10 }], name: 'Live' }], }); chart.update([ { data: [ { key: 1, value: 10 }, { key: 2, value: 20 }, ], name: 'Live', }, ]); ``` `update()` is state-library neutral. Call it from a framework effect, store subscription, event handler, or request callback. It throws after the chart is disposed instead of silently retaining detached state. ## 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. The domain spans exactly the first to the last date, so the series fills the plot width: ```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 | ```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 Area fills use a vertical gradient that fades toward the baseline. Set `fill: 'solid'` for a flat fill: ```ts import { createAreaChart } from '@vielzeug/prism'; const chart = createAreaChart(container, { series: [ { name: 'Users', data: userData, curve: 'monotone', fill: 'gradient', // 'gradient' (default) | 'solid' showLine: true, }, ], xAxis: { position: 'bottom' }, yAxis: { position: 'left', grid: true }, crosshair: true, }); ``` `fillOpacity` overrides the theme opacity for one series. ## 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. A label that does not fit inside its slice is not drawn; the value stays available through the tooltip, legend, and keyboard announcements: ```ts { value: 42, label: 'Direct' } ``` Style labels via CSS: ```css :root { --prism-pie-label-color: #fff; --prism-pie-label-size: 11px; } ``` ### Updating Data ```ts const chart = createPieChart(container, { data: [ { label: 'A', value: 40 }, { label: 'B', value: 60 }, ], variant: 'donut', }); chart.update([ { 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); }, }); ``` ## Radar Charts `createRadarChart` compares several values on one shape, such as a hero's stats. Each series supplies values keyed by `axis.key`: ```ts import { createRadarChart } from '@vielzeug/prism'; const axes = [ { key: 'str', label: 'Strength' }, { key: 'agi', label: 'Agility' }, { key: 'int', label: 'Intellect' }, { key: 'end', label: 'Endurance' }, { key: 'spd', label: 'Speed', max: 5 }, ]; const chart = createRadarChart(container, { a11y: { ariaLabel: 'Hero stats' }, axes, domain: [0, 10], fill: 'gradient', showValues: true, series: [ { name: 'Adam', data: [ { key: 'str', value: 8 }, { key: 'agi', value: 6 }, { key: 'int', value: 4 }, { key: 'end', value: 9 }, { key: 'spd', value: 3 }, ], }, ], }); ``` ### Scales Every axis shares `domain` (or a nice range around the data when omitted). Give an axis its own `min`/`max` when its stat uses a different range: above, Speed runs 0 to 5 while the rest run 0 to 10, so each still fills the chart. ### Appearance | Option | Values | Use it for | | --- | --- | --- | | `fill` | `'solid'`, `'gradient'`, `'none'` | `gradient` for a single hero; `solid` or `none` when overlaying several | | `curve` | `'linear'`, `'rounded'` | Rounded reads softer; linear keeps exact vertices | | `grid.shape` | `'polygon'`, `'circle'` | Polygon matches the axes; circle suits many axes | | `grid.levels` / `grid.bands` | number / boolean | Ring count and alternate shading | | `grid.labels` | boolean | Level values; drawn only when every axis shares one domain | | `showValues` | boolean | Value beside each vertex, formatted by `axis.format` | ### Comparing Series Pass several series with `legend: true`. Hovering one shape dims the others, and hovering an axis reports every series' value on it: ```ts createRadarChart(container, { axes, legend: true, series: [adam, eve], tooltip: true, // "Agility: Adam 6, Eve 9" onHover: (event) => event && console.log(event.axis.label, event.values), }); ``` ## 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' | 'stack' (default: 'line') color: '#3b82f6', curve: 'monotone', strokeWidth: 1.5, }); spark.dispose(); ``` ### Variants - **`line`**: simple polyline path (default) - **`area`**: gradient-filled area + line overlay - **`bar`**: vertical bar for each data point - **`stack`**: horizontal proportional segments; use `StackSegment[]` for `data` with per-segment colors Line and area sparklines mark the latest value with a dot. Set `showEndPoint: false` to hide it. ### Updating Data ```ts const spark = createSparkline(container, { data: [12, 18, 14, 22], variant: 'area', }); spark.update([12, 18, 14, 22, 30]); ``` ### Event Hooks Sparklines use simplified hooks: index-based rather than full `ChartEvent`. With a hook set, a marker follows the hovered value: ```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:** Sparklines have no keyboard navigation. Label one with `a11y.ariaLabel` only when the trend carries meaning, and state the key value in surrounding text. ## 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 ($)', }, } ``` Category axes show every label when the labels fit and thin them out only when they would overlap. Set `tickCount` to choose the density yourself. ## Tooltips Enable with `tooltip: true`. On line, area, bar, and radar charts the default tooltip compares every series at the active key: a title, then a colour swatch, name, and value per series. Provide a custom `render` function to replace it. Strings are rendered as text. Return a DOM node for structured content: ```ts { tooltip: { offset: 12, render: (datum, series) => { const content = document.createElement('strong'); content.textContent = `${series.name}: ${datum.value.toLocaleString()}`; return content; }, }, } ``` Prism never injects tooltip strings as HTML, so custom rendering does not require a sanitizer. 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 datum of the series nearest the pointer - `series`: the corresponding `Series` config - `values`: every series at the same key (line, area, and bar charts) - `originalEvent`: the raw `MouseEvent` or, for keyboard navigation, `KeyboardEvent` > **Pie and radar events differ**: pie hooks receive `(slice: PieSliceConfig, index: number)`, and radar hooks receive a `RadarEvent` describing an axis. See [`PieChartConfig`](./api.md#chart-configurations) and [`createRadarChart()`](./api.md#createradarchart). ## Animations Pass a `transition` config to animate enter and update transitions: ```ts { transition: { duration: 400, easing: 'ease-out', stagger: 30, // ms delay between bars (bar charts) or series (radar charts) }, } ``` All chart types use `requestAnimationFrame`-based interpolation and skip animation when the user prefers reduced motion (`preference: 'system'`, the default). Bar and radar charts apply `stagger` on entry; line, area, and pie charts ignore it. ## 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}` | Colorblind-safe palette | Series color palette | | `--prism-bg` | `transparent` | Chart background | | `--prism-axis-color` | `hsl(215deg 16% 47%)` | Axis lines and ticks | | `--prism-grid-color` | `hsl(220deg 10% 75%)` | Grid lines | | `--prism-text-color` | `hsl(215deg 25% 27%)` | Axis labels and text | | `--prism-font-family` | `system-ui` | Chart font | | `--prism-font-size` | `0.75rem` | Label font size | | `--prism-tooltip-bg` | `hsl(222deg 47% 11%)` | Tooltip background | | `--prism-tooltip-color` | `hsl(210deg 40% 98%)` | Tooltip text | | `--prism-tooltip-radius` | `0.375rem` | Tooltip border radius | | `--prism-crosshair-color` | `hsl(215deg 16% 47%)` | Crosshair line | | `--prism-crosshair-dash` | `4 2` | Crosshair dash pattern | The palette follows Wong (2011) so adjacent series stay distinguishable under common color-vision deficiencies. Dark mode applies automatically through `prefers-color-scheme` or an `html.dark` class. Radar charts add their own tokens, each defaulting to a shared one: | Token | Default | Description | | --- | --- | --- | | `--prism-radar-grid-color` | `--prism-grid-color` | Rings | | `--prism-radar-grid-opacity` | `0.7` | Ring and spoke opacity | | `--prism-radar-band-fill` | `--prism-grid-color` | Alternate ring shading | | `--prism-radar-band-opacity` | `0.14` | Shading opacity | | `--prism-radar-spoke-color` | `--prism-radar-grid-color` | Spokes | | `--prism-radar-fill-opacity` | `--prism-area-opacity` | Solid shape fill | | `--prism-radar-stroke-width` | `--prism-line-width` | Shape outline | | `--prism-radar-point-radius` | `--prism-point-radius` | Vertex dots | | `--prism-radar-point-radius-active` | `--prism-point-radius-hover` | Dots on the active axis | | `--prism-radar-label-color` / `-size` | Secondary text tokens | Axis labels | | `--prism-radar-value-color` / `-size` | Text tokens | Vertex values | | `--prism-radar-dim-opacity` | `--prism-dim-opacity` | Other shapes while one is hovered | Shared interaction and chart tokens: | Token | Default | Description | | --- | --- | --- | | `--prism-dim-opacity` | `0.35` | Series or slices outside the active one | | `--prism-active-point-radius` | `--prism-point-radius-hover` | Points marked at the active key | | `--prism-point-ring` | `Canvas` | Ring separating active and end points from the line | | `--prism-focus-color` | `--prism-color-1` | Focus outline of a keyboard-focused chart | | `--prism-bar-radius` | `4px` | Bar corner radius (stacked bars stay square) | | `--prism-bar-band-fill` / `-opacity` | Grid colour / `0.18` | Shaded active category | | `--prism-bar-dim-opacity` | `0.55` | Bars outside the active category | | `--prism-area-gradient-start` / `-end` | `0.45` / `0.02` | Gradient fill opacity at the line and the baseline | | `--prism-spark-gradient-start` / `-end` | `0.35` / `0` | Sparkline gradient fill | Explicit config such as `strokeWidth`, `fillOpacity`, or `borderRadius` always wins over these tokens. ## 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 in-flight transitions - Disconnects the `ResizeObserver` - Removes the SVG element, tooltip, and legend from the DOM - Restores container styles changed for tooltip positioning - Is idempotent: safe to call multiple times Call `update()` only while the handle is active. Updating a disposed chart throws `PrismRenderError`. ## 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. ## Accessibility Label informative charts with `a11y: { ariaLabel }`. A labelled chart renders with `role="img"`, is focusable, and supports keyboard navigation. Charts without `a11y` are decorative and render with `aria-hidden="true"`. ```ts createLineChart(container, { a11y: { ariaLabel: 'Revenue by month' }, series: [...], }); ``` Mark a chart decorative explicitly when surrounding text already states its meaning: ```ts createSparkline(container, { a11y: { decorative: true }, data: [...], }); ``` ### Keyboard and announcements Every labelled chart except sparklines shares one keyboard model: | Chart | Arrow keys move between | `Home` / `End` | Announced | | --- | --- | --- | --- | | Line, area | x positions | First / last position | `1: Adam 3, Eve 6` | | Bar | categories | First / last category | `Str: Adam 8, Eve 5` | | Pie | slices | First / last slice | `Wins: 15 (75%)` | | Radar | axes (wrapping) | Not supported | `Agility: Adam 6, Eve 9` | The first arrow press focuses the first item. `Enter` or `Space` fires `onClick` for the focused item, and `Escape` clears it. The tooltip speaks the text when one is enabled; otherwise the chart's own `role="status"` region (`.prism-live`) does, so each value is announced once. ### Hover feedback The active key is shown the same way on every chart. Line and area charts mark each series' point and dim the series farther from the pointer. Bar charts shade the category band and dim the other categories. Pie charts dim the other slices. Radar charts highlight the active spoke and its points, and dim the other shapes while one is hovered. ## Framework Integration Prism has no framework adapter. Connect the same three operations to your framework's lifecycle: create after mount, update when state changes, and dispose before unmount. ```ts import { createLineChart, type ChartHandle, type ContinuousDatum, type LineSeriesConfig } from '@vielzeug/prism'; let chart: ChartHandle | undefined; export function mountChart(container: HTMLElement, data: ContinuousDatum[]): void { chart = createLineChart(container, { series: [{ data, name: 'Series' }] }); } export function updateChart(data: ContinuousDatum[]): void { chart?.update([{ data, name: 'Series' }]); } export function unmountChart(): void { chart?.dispose(); chart = undefined; } ``` ## Working with Other Vielzeug Libraries ### With Ripple Keep state ownership in Ripple and connect it to Prism through an explicit subscription. ```ts import { createLineChart } from '@vielzeug/prism'; import { signal } from '@vielzeug/ripple'; const data = signal([{ key: 1, value: 10 }]); const toSeries = () => [{ data: data.value, name: 'Series' }]; const chart = createLineChart(container, { series: toSeries() }); const unsubscribe = data.subscribe(() => chart.update(toSeries())); // Cleanup both owners together. unsubscribe(); chart.dispose(); ``` ### With Sourcerer Bind chart data to a Sourcerer remote source so charts update whenever the list refreshes. ```ts import { createBarChart } from '@vielzeug/prism'; import { createPageSource } from '@vielzeug/sourcerer'; const source = createPageSource({ load: async ({ page, pageSize, signal }) => { const result = await api.stats.list({ page, pageSize }, { signal }); return { items: result.data, totalItems: result.total }; }, }); const toSeries = (state) => [ { data: state.items.map((item) => ({ key: item.label, value: item.count })), name: 'Series', }, ]; const chart = createBarChart(container, { series: toSeries(source.state) }); const unsubscribe = source.subscribe((state) => chart.update(toSeries(state))); void source.reload().catch(() => undefined); ``` ## 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 transitions, disconnect resize observation, and remove DOM nodes. - Call `chart.update(data)` from your application state boundary; Prism does not require or own a state library. - Set `a11y: { ariaLabel: '…' }` on every chart that conveys meaningful data: unlabelled charts are hidden from assistive technology and receive no keyboard navigation. - 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 - [Create a line chart](./examples/line-chart.md) - [Plot a time series](./examples/time-series.md) - [Compare categories with a bar chart](./examples/bar-chart.md) - [Render compact charts](./examples/compact-charts.md) - [Compare hero stats with a radar chart](./examples/radar-chart.md) - [Update chart data](./examples/update-data.md) - [Sync a details panel with hover](./examples/hover-details.md) - [Render a custom tooltip](./examples/custom-tooltip.md) - [Match charts to your design system](./examples/theming.md) --- ## @vielzeug/pulse **Category:** websockets ### Overview ## Why Pulse? Native WebSocket leaves connection ownership, event routing, reconnect restoration, and cleanup to each application. Pulse provides those boundaries while making readiness explicit: applications connect before sending, and disconnected messages never disappear silently. ```ts // Before const socket = new WebSocket('wss://api.example.com/ws'); socket.addEventListener('message', (event) => route(JSON.parse(event.data))); socket.addEventListener('close', () => setTimeout(() => reconnect(), 1_000)); // After const pulse = createPulse( 'wss://api.example.com/ws', { reconnect: true }, ); try { await pulse.connect(); pulse.on('chat:message', (message) => console.log(message.text)); pulse.send('chat:send', { text: 'Hello!' }); } catch (error) { console.error('Pulse connection failed:', error); } ``` | Feature | Pulse | Native WebSocket | socket.io-client | | --- | --- | --- | --- | | Bundle size | | 0 B | ~44 kB gzip | | Explicit readiness | | Manual | | | Session restoration | | | Protocol-specific | | Typed scoped channels | | | Basic | | Typed rooms with presence | | | | | Zero runtime dependencies | | | | **Use Pulse when** you need a typed WebSocket session whose reconnect and cleanup behavior must be deterministic. **Consider native WebSocket when** a single untyped connection does not need retry, routing, or session restoration. ## Installation ```sh [pnpm] pnpm add @vielzeug/pulse ``` ```sh [npm] npm install @vielzeug/pulse ``` ```sh [yarn] yarn add @vielzeug/pulse ``` ## Quick Start Define the protocol schema at construction time, create scopes, then connect before sending. ```ts import { createPulse } from '@vielzeug/pulse'; type Schema = { server: { 'chat:message': { text: string } }; client: { 'chat:send': { text: string } }; channels: { chat: { client: { send: { text: string } }; server: { message: { text: string } }; }; }; rooms: { lobby: { presence: { name: string } }; }; }; const pulse = createPulse('wss://api.example.com/ws', { reconnect: true, }); pulse.tap((event) => { if (event.type === 'error') console.error(event.error); if (event.type === 'status-change') console.log('status:', event.status); }); const chat = pulse.channel('chat'); const lobby = pulse.room('lobby'); try { await pulse.connect(); chat.send('send', { text: 'Hello!' }); await lobby.joined; lobby.updatePresence({ name: 'Ada' }); } catch (error) { console.error('Pulse connection failed:', error); } pulse.dispose(); ``` ## Features - **`connect()`**: explicit readiness; application messages throw while disconnected. - **`channel()`**: named, schema-bound scopes with independent disposal and reference-counted server subscriptions. - **`room()`**: named, schema-bound ref-counted room scopes with optional reactive presence. The first scope sends `join`; the last disposal sends `leave`. - **`reconnect`**: ordered restoration of channel subscriptions, room memberships, and local presence state. - **`transform`**: one synchronous transform or filter for application messages. - **`tap()`**: subscribe to lifecycle events (status changes, errors, disposal) via a typed `PulseEvent` stream. - **`heartbeat`**: ping/pong liveness detection that uses the same reconnect controller. - **`status` and `rooms`**: framework-neutral external stores for transport and confirmed membership state. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ripple](/ripple/): bridges Pulse external stores into computed values and effects. - [Herald](/herald/): receives routed Pulse events in an in-process application bus. - [Courier](/courier/): handles request/response traffic alongside a Pulse session. - [Clockwork](/clockwork/): models application-level authentication or session workflows. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createPulse()` | Create a typed WebSocket session instance. | Sync (returns `Pulse`) | Does not open the connection: call `connect()`. | | `Pulse` | Main instance: channels, rooms, messaging, lifecycle. | Sync methods, async `connect()`/`wait()` | `send()` throws while disconnected. | | `PulseChannel` | Scoped channel namespace with independent disposal. | Sync methods, async `wait()` | Each call returns a new scope; ref-counted subscription. | | `RoomScope` | Ref-counted room membership with optional presence. | Sync methods, async `joined` | `joined` rejects on transport close or timeout. | | `PulseSchema` | Declares server/client events, channels, and rooms. | Type-only | Infer all named scope types from this schema. | | `PulseOptions` | Configuration: heartbeat, reconnect, transform. | Type-only | `reconnect` and `heartbeat` default to `false`. | | `Subscribable` | Framework-neutral snapshot and change subscription. | Sync | `subscribe()` does not emit the initial snapshot. | | `PulseError` | Base class for all Pulse errors. | Runtime | Check `instanceof` against subclasses. | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/pulse` | All public exports: `createPulse`, types, and error classes. | | `@vielzeug/pulse/testing` | Wire-protocol-accurate `MockWebSocket` for tests and demos. | ## `createPulse()` ```ts function createPulse(url: string, options?: PulseOptions): Pulse ``` Creates a Pulse instance. The WebSocket is not opened until `connect()` is called. ### Type parameters | Parameter | Constraint | Description | | --- | --- | --- | | `S` | `PulseSchema` | Schema declaring server events, client events, channels, and rooms. | ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `url` | `string` | WebSocket URL. | | `options` | `PulseOptions` | Optional configuration. | ### Returns `Pulse`: the Pulse instance. --- ## `PulseSchema` ```ts type PulseSchema = { server?: MessageMap; client?: MessageMap; channels?: ChannelDefinitions; rooms?: RoomDefinitions; }; ``` Declare all protocol surfaces once at construction. Named scopes infer their types from this schema. | Field | Type | Description | | --- | --- | --- | | `server` | `MessageMap` | Root events the server sends. | | `client` | `MessageMap` | Root events the client sends. | | `channels` | `ChannelDefinitions` | Named channel schemas. | | `rooms` | `RoomDefinitions` | Named room schemas with optional presence. | --- ## `PulseOptions` ```ts type PulseOptions = { heartbeat?: boolean | HeartbeatOptions; protocols?: string | string[]; reconnect?: boolean | ReconnectOptions; transform?: OutgoingTransform; }; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `heartbeat` | `boolean \| HeartbeatOptions` | `false` | Ping/pong keep-alive. | | `protocols` | `string \| string[]` | N/A | Sub-protocols passed to the WebSocket constructor. | | `reconnect` | `boolean \| ReconnectOptions` | `false` | Auto-reconnect on unexpected close. | | `transform` | `OutgoingTransform` | N/A | Transform or filter outgoing application messages. | --- ## `HeartbeatOptions` ```ts type HeartbeatOptions = { interval?: number; timeout?: number; }; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `interval` | `number` | `30_000` | Interval between pings in ms. | | `timeout` | `number` | `5_000` | How long to wait for a pong before treating the connection as dead. | --- ## `ReconnectOptions` ```ts type ReconnectOptions = { delay?: number | ((attempt: number) => number); maxAttempts?: number; }; ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `delay` | `number \| ((attempt: number) => number)` | Full-jitter exponential backoff capped at 30 s | Delay between reconnect attempts in ms. `attempt` is zero-based. | | `maxAttempts` | `number` | `5` | Maximum number of reconnect attempts after initial failure. | --- ## `OutgoingMessage` ```ts type OutgoingMessage = { channel?: string; event: string; payload: unknown }; ``` An outgoing application message before it is serialized. --- ## `OutgoingTransform` ```ts type OutgoingTransform = (message: Readonly) => OutgoingMessage | null; ``` Transform or filter outgoing application messages. Internal protocol frames (subscribe, join, leave, presence, ping) bypass this hook. Return `null` to drop the message. --- ## `Pulse` ```ts type Pulse = { // Channels channel & string>( name: K, ): PulseChannel[K]['server'], ChannelMap[K]['client']>; // Connection connect(): Promise; disconnect(code?: number, reason?: string): void; // Lifecycle readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; // Messaging on>>(event: K, handler: (payload: ServerEvents[K]) => void): Unsubscribe; once>>(event: K, handler: (payload: ServerEvents[K]) => void): Unsubscribe; send>>(event: K, payload: ClientEvents[K]): void; wait>>(event: K, opts?: { signal?: AbortSignal; timeout?: number }): Promise[K]>; // Rooms room & string>(name: K, opts?: RoomOptions): RoomScope[K]>; readonly rooms: Subscribable>; // Status readonly status: Subscribable; // Tap tap(handler: (event: PulseEvent) => void, options?: { signal?: AbortSignal }): () => void; [Symbol.dispose](): void; }; ``` ### `channel(name)` Creates an isolated message namespace over the shared connection. Each call returns an independently disposable scope. The server subscription is reference-counted. ### `connect()` Explicitly opens the connection. Resolves after session restoration completes. Rejects if the connection closes before opening. ### `disconnect(code?, reason?)` Closes the connection without triggering reconnection. Default code is `1000`. ### `dispose()` Permanently closes the connection and releases all resources. Idempotent. ### `on(event, handler)` Subscribes to a typed server event. Returns an unsubscribe function. ### `once(event, handler)` Subscribes once: auto-removes after first invocation. ### `send(event, payload)` Sends a typed event to the server. Throws `PulseConnectionError` unless the connection is open. ### `wait(event, opts?)` Resolves on the next emission of the given server event. Rejects when `opts.signal` aborts, the timeout elapses, or the instance is disposed. ### `room(name, opts?)` Creates a ref-counted room scope. The first scope sends `join`; the last disposal sends `leave`. When the room definition includes `presence`, the scope exposes reactive presence state. ### `rooms` Reactive set of rooms the client is currently a confirmed member of. ### `status` Reactive connection status: `'connecting' | 'open' | 'reconnecting' | 'closed'`. ### `tap(handler, options?)` Subscribes to lifecycle events emitted by the Pulse instance. The handler receives a discriminated-union `PulseEvent`. Returns an unsubscribe function. | Parameter | Type | Description | | --- | --- | --- | | `handler` | `(event: PulseEvent) => void` | Called for each lifecycle event. | | `options.signal` | `AbortSignal` | Optional signal to stop the subscription. | ```ts const pulse = createPulse(url, { reconnect: true }); pulse.tap((event) => { if (event.type === 'error') console.error(event.error); if (event.type === 'status-change') console.log('status:', event.status); }); ``` --- ## `Subscribable` ```ts interface Subscribable { getSnapshot(): T; subscribe(listener: () => void): Unsubscribe; } ``` A framework-neutral state source used by `pulse.status`, `pulse.rooms`, and room `presence`. `getSnapshot()` returns the current immutable snapshot. `subscribe()` notifies once for each distinct snapshot and returns an unsubscribe function; listener errors are reported through `pulse.tap()` without interrupting Pulse state transitions. --- ## `PulseEvent` ```ts type PulseEvent = | { type: 'status-change'; status: PulseStatus } | { type: 'error'; error: PulseError } | { type: 'dispose' }; ``` A discriminated union of lifecycle events emitted by a `Pulse` instance. Inspect `event.type` to narrow the payload. | `type` | Payload | When | | --- | --- | --- | | `status-change` | `status: PulseStatus` | The connection status transitions. | | `error` | `error: PulseError` | A typed transport or protocol error occurs. | | `dispose` | N/A | The instance is disposed. | --- ## `PulseChannel` ```ts type PulseChannel = { readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly name: string; dispose(): void; on>(event: K, handler: (payload: TServer[K]) => void): Unsubscribe; once>(event: K, handler: (payload: TServer[K]) => void): Unsubscribe; send>(event: K, payload: TClient[K]): void; wait>(event: K, opts?: { signal?: AbortSignal; timeout?: number }): Promise; [Symbol.dispose](): void; }; ``` --- ## `RoomScope` ```ts type RoomScope = R extends { presence: infer P } ? P extends undefined ? RoomScopeBase : PresenceRoomScope : RoomScopeBase; ``` A room scope. When the room definition includes `presence`, the scope is a `PresenceRoomScope`; otherwise it is a `RoomScopeBase`. ### `RoomScopeBase` ```ts type RoomScopeBase = { readonly disposalSignal: AbortSignal; readonly disposed: boolean; readonly name: string; readonly joined: Promise; dispose(): void; [Symbol.dispose](): void; }; ``` ### `PresenceRoomScope` ```ts type PresenceRoomScope = RoomScopeBase & { readonly presence: Subscribable>; updatePresence(state: T): void; onJoin(handler: (memberId: string, state: T) => void): Unsubscribe; onLeave(handler: (memberId: string) => void): Unsubscribe; }; ``` | Member | Type | Description | | --- | --- | --- | | `presence` | `Subscribable>` | Reactive map of `memberId → state`. | | `updatePresence(state)` | `(state: T) => void` | Broadcast this client's presence state. Throws `PulseConnectionError` unless open. | | `onJoin(handler)` | `(handler) => Unsubscribe` | Called whenever a new member joins with their initial state. | | `onLeave(handler)` | `(handler) => Unsubscribe` | Called whenever a member leaves. | ### `RoomOptions` ```ts type RoomOptions = { signal?: AbortSignal; timeout?: number; }; ``` | Option | Type | Description | | --- | --- | --- | | `signal` | `AbortSignal` | Aborts the join, rejecting `joined` with `PulseAbortError`. | | `timeout` | `number` | Join timeout in ms. Rejects `joined` with `PulseRoomTimeoutError`. | --- ## Errors All errors extend `PulseError`. ### `PulseError` Base class for all Pulse errors. ### `PulseConnectionError` Transport failure, send while disconnected, or room join rejected on close. ### `PulseProtocolError` Malformed frame or server error frame. ### `PulseTimeoutError` `wait()` timed out before the server event arrived. ### `PulseRoomTimeoutError` Room scope `joined` timed out before the server confirmed membership. ### `PulseAbortError` `wait()` or room `joined` aborted via AbortSignal. ### `PulseDisposedError` Operation attempted after disposal. --- ## Channel and room definitions ### `ChannelDefinition` ```ts type ChannelDefinition = { client: MessageMap; server: MessageMap }; ``` ### `ChannelDefinitions` ```ts type ChannelDefinitions = Record; ``` ### `RoomDefinition` ```ts type RoomDefinition = { presence?: unknown }; ``` ### `RoomDefinitions` ```ts type RoomDefinitions = Record; ``` --- ## Utility types ### `MessageMap` ```ts type MessageMap = Record; ``` ### `EventKey` ```ts type EventKey = keyof T & string; ``` ### `ServerEvents` ```ts type ServerEvents = S extends { server: infer M extends MessageMap } ? M : MessageMap; ``` Extract server events from a schema, defaulting to an empty map. ### `ClientEvents` ```ts type ClientEvents = S extends { client: infer M extends MessageMap } ? M : MessageMap; ``` Extract client events from a schema, defaulting to an empty map. ### `RoomMap` ```ts type RoomMap = S extends { rooms: infer R extends RoomDefinitions } ? R : RoomDefinitions; ``` Extract room definitions from a schema, defaulting to an empty map. ### `Unsubscribe` ```ts type Unsubscribe = () => void; ``` ### `PulseStatus` ```ts type PulseStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'; ``` --- ## Testing APIs Import from `@vielzeug/pulse/testing`. ### `MockWebSocket` ```ts import { MockWebSocket } from '@vielzeug/pulse/testing'; // Tests: drive the socket pulse created const socket = MockWebSocket.instances.at(-1)!; socket.open(); socket.receive({ type: 'joined', room: 'crm' }); // Demos: subclass and install as the global class ScriptedWebSocket extends MockWebSocket { constructor(url: string, protocols?: string | string[]) { super(url, protocols, { autoOpen: true }); setTimeout(() => this.receive({ room: 'crm', type: 'joined' }), 60); } } (globalThis as Record).WebSocket = ScriptedWebSocket; ``` A wire-protocol-accurate `WebSocket` stand-in: pulse dials it through `new WebSocket(url, protocols)`, tests drive it through `open()`, `receive()`, `drop()`, `error()`, and `close()`, and every outbound frame lands in `sentMessages` for `frames()` to decode. Instances register on the static `MockWebSocket.instances` array in construction order, so a test can grab the socket pulse just created. `MockWebSocket.deferClose = true` parks closes in `CLOSING` until `finishClose()` runs, for testing reconnect timing. Requires a browser-like environment for the `CloseEvent`/`MessageEvent` globals. ### `frames(socket)` Decodes every frame the socket sent, in order. ```ts import { frames, MockWebSocket } from '@vielzeug/pulse/testing'; expect(frames(MockWebSocket.instances[0]!)).toContainEqual({ room: 'lobby', type: 'join' }); ``` ### Usage Guide ## Basic Usage Declare server events, client events, channel schemas, and room schemas once at construction. Named scopes infer their types from this schema. ```ts import { createPulse } from '@vielzeug/pulse'; type Schema = { // Root events the server sends server: { 'chat:message': { text: string }; notice: string }; // Root events the client sends client: { 'chat:send': { text: string } }; // Named channel scopes channels: { chat: { client: { send: { text: string } }; server: { message: { text: string } }; }; alerts: { client: { subscribe: { topic: string } }; server: { alert: { topic: string; severity: 'info' | 'warn' | 'error' } }; }; }; // Named room scopes with optional presence state rooms: { lobby: { presence: { name: string; color: string } }; announcements: {}; }; }; ``` ## Create and connect ```ts const pulse = createPulse('wss://api.example.com/ws', { reconnect: { delay: 1_000, maxAttempts: 5 }, heartbeat: { interval: 30_000, timeout: 5_000 }, }); pulse.tap((event) => { if (event.type === 'error') console.error(event.error); if (event.type === 'status-change') console.log('status:', event.status); }); try { await pulse.connect(); } catch (error) { console.error('Connection failed:', error); } ``` `connect()` opens the WebSocket and resolves after session restoration completes. `send()` throws `PulseConnectionError` while disconnected: Pulse never silently drops or buffers application messages. ## Send and receive root events ```ts pulse.on('chat:message', (message) => console.log(message.text)); pulse.send('chat:send', { text: 'Hello!' }); ``` ## Channels Each `channel()` call returns an independently disposable scope. The server subscription is reference-counted: the first scope sends `subscribe`, the last disposal sends `unsubscribe`. ```ts const chat = pulse.channel('chat'); chat.on('message', (message) => console.log(message.text)); chat.send('send', { text: 'Hello!' }); // Later chat.dispose(); ``` Use `using` for automatic cleanup: ```ts { using chat = pulse.channel('chat'); chat.on('message', (message) => console.log(message.text)); } // chat.dispose() called automatically ``` ## Rooms and presence Each `room()` call returns a ref-counted room scope. The first scope sends `join`; the last disposal sends `leave`. When the room definition includes `presence`, the scope exposes reactive presence state. ```ts const lobby = pulse.room('lobby'); // joined resolves when the server confirms membership await lobby.joined; // Reactive presence map: memberId → state lobby.onJoin((memberId, state) => console.log(`${memberId} joined: ${state.name}`)); lobby.onLeave((memberId) => console.log(`${memberId} left`)); // Broadcast your presence lobby.updatePresence({ name: 'Ada', color: 'blue' }); // Read current presence for (const [memberId, state] of lobby.presence.getSnapshot()) { console.log(`${memberId}: ${state.name}`); } // Leave lobby.dispose(); ``` Plain rooms (without presence) work the same way but don't expose presence members: ```ts const announcements = pulse.room('announcements'); await announcements.joined; announcements.dispose(); ``` ### Room scope options ```ts // Timeout if the server doesn't confirm in time const lobby = pulse.room('lobby', { timeout: 5_000 }); try { await lobby.joined; } catch (error) { console.error('Join failed:', error); } // Abort via AbortSignal const ctrl = new AbortController(); const lobby = pulse.room('lobby', { signal: ctrl.signal }); ctrl.abort(); // joined rejects with PulseAbortError, scope auto-disposes ``` ### Observe the rooms set `pulse.rooms` is an external store that tracks confirmed room memberships: ```ts const printRooms = () => console.log('Joined rooms:', [...pulse.rooms.getSnapshot()]); const unsubscribe = pulse.rooms.subscribe(printRooms); printRooms(); // Later unsubscribe(); ``` ## Reconnect When the connection drops unexpectedly, Pulse reconnects using the configured strategy. On reconnect, it restores: 1. Channel subscriptions (sends `subscribe` for each active channel). 2. Room memberships (sends `join` for each active room scope). 3. Local presence state (sends `presence` with the last successfully published state). ```ts const pulse = createPulse('wss://api.example.com/ws', { reconnect: { delay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000), maxAttempts: 5, }, }); ``` `joined` rejects on transport close. For post-reconnect membership, read `pulse.rooms` instead. ## Heartbeat ```ts const pulse = createPulse('wss://api.example.com/ws', { heartbeat: { interval: 30_000, timeout: 5_000 }, }); ``` Pulse sends periodic pings. If a pong doesn't arrive before the timeout, it forces a reconnect using the same reconnect controller. ## Transform outgoing messages ```ts const pulse = createPulse('wss://api.example.com/ws', { transform: (message) => { // Add a timestamp to all messages return { ...message, payload: { ...message.payload, ts: Date.now() } }; }, }); ``` Return `null` to drop a message: ```ts const pulse = createPulse('wss://api.example.com/ws', { transform: (message) => (message.event === 'debug' ? null : message), }); ``` ## Wait for a specific event ```ts const notice = await pulse.wait('notice', { timeout: 10_000 }); console.log(notice); ``` ## Dispose ```ts pulse.dispose(); ``` Disposal is idempotent. It closes the connection, rejects pending room joins, clears all listeners, and aborts all scope disposal signals. ## Error handling ```ts const pulse = createPulse('wss://api.example.com/ws', { reconnect: true, }); pulse.tap((event) => { if (event.type === 'error') { if (event.error instanceof PulseConnectionError) { console.error('Connection error:', event.error); } else if (event.error instanceof PulseProtocolError) { console.error('Protocol error:', event.error); } } }); ``` | Error | When | | --- | --- | | `PulseConnectionError` | Transport failure, send while disconnected, room join rejected on close. | | `PulseProtocolError` | Malformed frame or server error frame. | | `PulseTimeoutError` | `wait()` times out. | | `PulseRoomTimeoutError` | Room scope `joined` times out. | | `PulseAbortError` | `wait()` or room `joined` aborted via AbortSignal. | | `PulseDisposedError` | Operation attempted after disposal. | ## Working with Other Vielzeug Libraries Pulse external stores plug directly into Flux and Ripple bridges: ```ts import { fromStore } from '@vielzeug/flux'; import { effect, fromSubscribable } from '@vielzeug/ripple'; const statusStream = fromStore(pulse.status); const presence = fromSubscribable(lobby.presence, { signal: lobby.disposalSignal }); const presenceEffect = effect(() => console.log([...presence.value])); const statusSubscription = statusStream.subscribe((status) => console.log(status)); // Later statusSubscription.unsubscribe(); presenceEffect.dispose(); ``` ## Best Practices - Await `connect()` before sending; never assume construction opens the transport. - Define the full schema at `createPulse()` so named scopes are type-safe without per-call generics. - Use `using` declarations for channel and room scopes so disposal is automatic at block exit. - Always call `dispose()` when done: it closes the connection, rejects pending joins, and clears listeners. - Call `tap()` to observe lifecycle events; Pulse reports transport and protocol errors there rather than throwing asynchronously. - Read `pulse.rooms` for post-reconnect membership; `joined` rejects on transport close. - Set a `timeout` on room scopes when the server may never confirm membership. - Keep `transform` synchronous; resolve async policy decisions before calling `send()`. ### Examples - [Basic Connection](./examples/basic-connection.md) - [Channel Multiplexing](./examples/channels.md) - [Outgoing Transform](./examples/middleware.md) - [Reconnect and Heartbeat](./examples/reconnect-and-heartbeat.md) - [Rooms and Presence](./examples/rooms-and-presence.md) ### REPL Examples - Typed Channels (id: `channels`) - Connect & Send (id: `connect-and-send`) - Lifecycle & Disposal (id: `lifecycle`) - Reconnect & Restoration (id: `reconnect`) - Rooms & Presence (id: `rooms-presence`) --- ## @vielzeug/refine **Category:** ui-components ### 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 | | Runtime dependencies | Vielzeug UI primitives + Lucide | Lit + utilities | Lit + Material foundations | | 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/fouc.css'; // Hide unupgraded custom elements until first paint import '@vielzeug/refine/tokens.css'; // Tokens, animations, cascade layers // 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 ``` ### 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/fouc.css` | FOUC suppression for unupgraded custom elements | | `@vielzeug/refine/tokens.css` | Global design tokens and cascade layers | | `@vielzeug/refine/styles/preflight.css` | Optional browser-default reset (includes FOUC suppression) | Component registration happens through side-effect imports such as `@vielzeug/refine/button` and `@vielzeug/refine/dialog`. ### Components **Content:** `ore-avatar`, `ore-avatar-group`, `ore-breadcrumb`, `ore-card`, `ore-carousel`, `ore-carousel-slide`, `ore-chat-message`, `ore-icon`, `ore-keyboard-key`, `ore-keyboard-shortcut`, `ore-list`, `ore-list-item`, `ore-marquee`, `ore-pagination`, `ore-qr-code`, `ore-separator`, `ore-stats`, `ore-step`, `ore-stepper`, `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-cookie-banner`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator` **Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-counter`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-input`, `ore-message-composer`, `ore-number-input`, `ore-otp-input`, `ore-qr-scanner`, `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-command-palette`, `ore-command-palette-item`, `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip` ## Component portfolio Refine keeps one public package and classifies components by composition level rather than stability. All listed components remain supported and tested. - **Foundations**: buttons, inputs, selection controls, disclosure, dialogs, menus, tooltips, layout primitives, and feedback indicators. Start here when building an application-specific interface. - **Composed patterns**: data grids, command palettes, navigation menus, steppers, calendars, date/time pickers, file inputs, and message composers. These package recurring accessibility and interaction behavior that is costly to rebuild correctly. - **Presentation patterns**: cards, chat messages, code windows, stats, marquees, avatars, badges, and skeletons. Use these when their product vocabulary matches the application; prefer foundations when it does not. Classification guides discovery and bundle selection. It is not a reason to remove working components or move them into unstable packages; per-component subpath imports remain the tree-shaking boundary. ## 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 - **Typed framework declarations**: DOM, React, and Vue tags derive from the component property contracts ### Prerequisites - Browser runtime with Custom Elements support. - Import `@vielzeug/refine/fouc.css` and `@vielzeug/refine/tokens.css` 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) - [Migration Guide](./migration.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 - [Keymap](/keymap/): Keyboard shortcut manager that powers the command palette's global trigger ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `@vielzeug/refine/` | Register one custom element and export its public types | Sync | Importing a type alone does not register the element | | `@vielzeug/refine/tokens.css` | Load required theme tokens, animations, and cascade layers | CSS | Import once before components render | | `@vielzeug/refine/fouc.css` | Hide unregistered `ore-*` elements until upgrade | CSS | Load before first paint | | `@vielzeug/refine/styles/preflight.css` | Apply the optional browser reset | CSS | The reset affects global elements | | `@vielzeug/refine/frameworks/elements` | Register typed DOM tag mappings | Types only | Import for side effects in TypeScript | | `@vielzeug/refine/frameworks/react` | Register typed React JSX elements | Types only | Does not provide runtime wrappers | | `@vielzeug/refine/frameworks/vue` | Register typed Vue global components | Types only | Does not install a Vue plugin | | `@vielzeug/refine/theme` | `createThemeController()`: light/dark/system document theme | Sync | Does not persist the preference or set an accent color | | `eventFieldValue()` / `eventFieldChecked()` | Read a form control's value/checked from `event.currentTarget` | Sync | Exported from the package root; guards non-field targets | | `RefineError` | Base class for package-defined public errors | Sync | Component configuration warnings do not throw this error | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/refine` | Export `RefineError` and the `eventFieldValue`/`eventFieldChecked` field readers without registering components | | `@vielzeug/refine/` | Register one component and export its tag constant, props, events, and related types | | `@vielzeug/refine/theme` | `createThemeController()` and its theme types | | `@vielzeug/refine/tokens.css` | Required global design contract | | `@vielzeug/refine/fouc.css` | Optional pre-upgrade visibility rule | | `@vielzeug/refine/styles/*` | Focused theme, animation, layer, and preflight stylesheets | | `@vielzeug/refine/frameworks/*` | TypeScript augmentations for DOM, React, and Vue | ## Styles ```ts import '@vielzeug/refine/fouc.css'; import '@vielzeug/refine/tokens.css'; import '@vielzeug/refine/styles/preflight.css'; ``` `tokens.css` is required. It defines tokens, animations, and cascade-layer order without resetting global elements. `preflight.css` is optional and includes FOUC suppression. | Import path | Purpose | | --- | --- | | `@vielzeug/refine/fouc.css` | FOUC suppression for unupgraded custom elements | | `@vielzeug/refine/tokens.css` | Tokens, animation helpers, and cascade layers | | `@vielzeug/refine/styles/theme.css` | Theme token declarations | | `@vielzeug/refine/styles/animation.css` | Animation helpers | | `@vielzeug/refine/styles/layers.css` | Cascade layer declarations | | `@vielzeug/refine/styles/preflight.css` | Optional browser reset and FOUC suppression | ## Components Each component uses one registration and type entry point: ```ts import '@vielzeug/refine/button'; import type { OreButtonProps } from '@vielzeug/refine/button'; ``` | Area | Components | | --- | --- | | Content | `avatar`, `avatar-group`, `breadcrumb`, `card`, `carousel`, `chat-message`, `code-window`, `copy-command`, `icon`, `keyboard-key`, `list`, `list-item`, `marquee`, `pagination`, `separator`, `speech-player`, `stats`, `step`, `stepper`, `table`, `text` | | Disclosure | `accordion`, `accordion-item`, `tabs`, `tab-item`, `tab-panel` | | Feedback | `alert`, `async`, `badge`, `chip`, `cookie-banner`, `password-strength`, `progress`, `skeleton`, `toast`, `typing-indicator` | | Inputs | `button`, `button-group`, `calendar`, `checkbox`, `checkbox-group`, `combobox`, `counter`, `datagrid`, `date-picker`, `file-input`, `input`, `message-composer`, `number-input`, `otp-input`, `radio`, `radio-group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time-picker` | | Layout | `box`, `grid`, `grid-item`, `navbar`, `sidebar` | | Overlays | `command-palette`, `dialog`, `drawer`, `menu`, `navigation-menu`, `popover`, `tooltip` | Each component page lists its attributes, JavaScript properties, events, slots, parts, and CSS custom properties. ## Framework Types ```ts import type {} from '@vielzeug/refine/frameworks/elements'; import type {} from '@vielzeug/refine/frameworks/react'; import type {} from '@vielzeug/refine/frameworks/vue'; ``` The declarations cover every supported tag and derive component properties from Refine's authoritative `HTMLElementTagNameMap`. They provide types only; component registration still uses component subpaths. ## Events and Form Controls Form controls expose `.value` or `.checked` and dispatch standard `input` and `change` events. Read the property from `event.currentTarget`. Stateful overlays expose controlled `open`, optional `default-open`, and an `open-change` custom event with `{ open, reason }` detail. Component pages define additional detail fields and reasons. ## Utilities ### `createThemeController(options?)` ```ts import { createThemeController } from '@vielzeug/refine/theme'; const theme = createThemeController(); // starts in 'system' mode theme.setPreference('dark'); theme.dispose(); ``` Owns the light/dark/system theme for the document: a reactive `preference` signal, the effective `resolved` mode (`'dark' | 'light'` after resolving `system` against the OS), and the DOM application `styles/theme.css` expects: the `.dark` class plus the `color-scheme` property on the root element, so every `light-dark()` token resolves to the right branch. While in `system` mode the controller tracks the OS `prefers-color-scheme` via sentinel's `createMediaQuery`. The controller does **not** persist the preference or expose an accent color: those are consumer concerns (wire `watch(controller.preference, save)` for persistence; set `--color-primary-hue` yourself). Options: `initial` (starting preference, default `'system'`), `root` (element carrying the classes, default `document.documentElement`), `target` (window whose `matchMedia` backs `system`). The handle is disposable: `dispose()`, `disposed`, `disposalSignal`, `[Symbol.dispose]`. ### `eventFieldValue(event)` / `eventFieldChecked(event)` ```ts import { eventFieldChecked, eventFieldValue } from '@vielzeug/refine'; input.addEventListener('input', (event) => { const value = eventFieldValue(event); // string | undefined: never a blind cast }); ``` Guarded readers for the standard `input`/`change` event pattern above: they read `value`/`checked` from `event.currentTarget` only when the target actually carries it, so a handler wired to the wrong element yields `undefined`/`false` instead of a crash or a lie. Exported from the package root, which registers no components. ## Types Component props follow the `OreProps` naming pattern. Event maps follow `OreEvents`. Import both from the component subpath that owns them. `RefineElementMap`, `RefineReactIntrinsicElements`, and `RefineVueGlobalComponents` are exported from their respective `frameworks/*` type entry points. ## Errors ### `RefineError` ```ts class RefineError extends Error { constructor(message: string, opts?: ErrorOptions); } ``` Base class for public Refine errors. It preserves `cause` through `ErrorOptions`. ### Usage Guide ## Basic Usage Load the global design contract once, then register only the components used by the page. ```ts import '@vielzeug/refine/tokens.css'; import '@vielzeug/refine/button'; import '@vielzeug/refine/input'; import '@vielzeug/refine/dialog'; const input = document.querySelector('ore-input'); const save = document.querySelector('ore-button'); if (!(input instanceof HTMLElement) || !(save instanceof HTMLElement)) { throw new Error('Expected Refine controls'); } save.addEventListener('click', () => { console.log((input as HTMLElement & { value: string }).value); }); ``` Add the browser reset only when the application owns global element styling: ```ts import '@vielzeug/refine/styles/preflight.css'; ``` ## Set Attributes and Properties Use attributes for serializable HTML state. ```html Save changes ``` Use JavaScript properties for arrays, objects, callbacks, and controlled state. ```ts import '@vielzeug/refine/datagrid'; import type { OreDataGridProps } from '@vielzeug/refine/datagrid'; const grid = document.querySelector('ore-datagrid') as HTMLElement & OreDataGridProps; grid.columns = [ { key: 'name', label: 'Name', sortable: true }, { key: 'role', label: 'Role' }, ]; grid.rows = [ { id: '1', name: 'Ada', role: 'Admin' }, { id: '2', name: 'Linus', role: 'Editor' }, ]; ``` ## Handle Events Form controls dispatch standard `input` and `change` events. Read their current property from `event.currentTarget`. ```ts const input = document.querySelector('ore-input'); input?.addEventListener('input', (event) => { const target = event.currentTarget as HTMLElement & { value: string }; console.log(target.value); }); ``` Custom events use kebab-case names and typed detail objects. ```ts import type { OreDialogEvents } from '@vielzeug/refine/dialog'; const dialog = document.querySelector('ore-dialog'); dialog?.addEventListener('open-change', (event: CustomEvent) => { console.log(event.detail.open, event.detail.reason); }); ``` ## Compose Slots Default slots hold primary content. Named slots identify component-owned regions. ```html Account Profile settings Cancel Save ``` Input components commonly expose `prefix`, `suffix`, `label`, `helper`, and `error` slots. Each component reference lists its supported slots. ## Load TypeScript Framework Declarations Import the declaration matching the framework once in an application type entry point. ```ts [DOM] import type {} from '@vielzeug/refine/frameworks/elements'; ``` ```ts [React] import type {} from '@vielzeug/refine/frameworks/react'; ``` ```ts [Vue] import type {} from '@vielzeug/refine/frameworks/vue'; ``` These entry points provide typed tags and component properties. They do not register components or install runtime adapters. ## Framework Integration Refine elements use native DOM properties, events, and slots. Frameworks differ only in how they forward custom-element properties and events. ```tsx [React 19] import '@vielzeug/refine/button'; export function SaveButton() { return Save; } ``` ```vue [Vue 3] import '@vielzeug/refine/button'; Save ``` ```svelte [Svelte] import '@vielzeug/refine/button'; Save ``` See [Framework Integration](./frameworks.md) for custom events, controlled properties, SSR guards, and framework-specific configuration. ## Working with Other Vielzeug Libraries ### Ore Use Ore when authoring a custom element that composes Refine components. ```ts import '@vielzeug/refine/button'; import { define, html } from '@vielzeug/ore'; define('save-panel', { setup() { return html` console.log('save')}>Save`; }, }); ``` ### Ripple Use Ripple to drive component properties from application state. ```ts import '@vielzeug/refine/button'; import { effect, signal } from '@vielzeug/ripple'; const loading = signal(false); const button = document.querySelector('ore-button') as HTMLElement & { loading?: boolean }; const loadingEffect = effect(() => { button.loading = loading.value; }); loadingEffect.dispose(); ``` ## Best Practices - **Import** `tokens.css` once before rendering components. - **Register** components through explicit subpaths. - **Set** structured data and callbacks as JavaScript properties. - **Read** form state from the event target instead of custom event payloads. - **Provide** accessible labels for icon-only controls. - **Use** component reference pages for supported slots, parts, and CSS properties. - **Dispose** application-owned Ripple effects and service handles. - **Guard** DOM-dependent component registration during SSR. - **Working with AI agents?** Install the `vielzeug` skill (`npx -y @vielzeug/codex skills install`) so agents reach for Refine components and tokens instead of custom widgets: see the [Codex usage guide](../codex/usage.md#agent-skill). --- ## @vielzeug/ripple **Category:** state ### Overview ## Why Ripple? Hand-rolled reactive state spreads subscription, cleanup, and derived-value rules across application code. Ripple gives you one graph boundary with explicit disposal and fine-grained dependencies while keeping rendering and routing outside the runtime. ```ts // Before let count = 0; const listeners = new Set void>(); function setCount(next: number) { count = next; for (const listener of listeners) listener(); } // After import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const count = ripple.signal(0); const doubled = ripple.computed(() => count.value * 2); const stop = ripple.effect(() => console.log(doubled.value)); count.value = 1; stop.dispose(); ripple.dispose(); ``` | Feature | Ripple | Zustand | Jotai | | --- | --- | --- | --- | | Bundle size | | ~3.5 kB | ~7 kB | | Zero dependencies | | | | | Framework-agnostic | | | React-first | | Explicit graph lifetime | | | | | Fine-grained derived values | | Selectors | Atoms | **Use Ripple when** you need framework-independent state with explicit graph lifetime and small composable primitives. **Consider a framework store when** component bindings, server cache, or framework-specific tooling matter more than portable reactive state. ## Installation ```sh [pnpm] pnpm add @vielzeug/ripple ``` ```sh [npm] npm install @vielzeug/ripple ``` ```sh [yarn] yarn add @vielzeug/ripple ``` ## Quick Start Create one graph, derive a value, observe it, then dispose when the graph lifetime ends. ```ts import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const count = ripple.signal(0); const doubled = ripple.computed(() => count.value * 2); const stop = ripple.effect(() => console.log(doubled.value)); ripple.batch(() => { count.value = 1; count.value = 2; }); stop.dispose(); ripple.dispose(); ``` ## Features - `createRipple()` creates an isolated graph and lifetime boundary. - `signal()` stores writable values with configurable equality. - `computed()` derives lazy read-only values. - `effect()` reacts to dependency changes with cleanup support. - `batch()` coalesces synchronous writes and notifications. - `createScope()` groups owned reactive work. - `watch()` observes one selected source transition. - `tap()` provides runtime observability for all graph events. - `resource()` owns focused latest-request async state. - `fromSubscribable()` creates a disposable bridge for external `{ getSnapshot, subscribe }` sources. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Ore](/ore/): uses Ripple signals and effects for web-component reactivity. - [Clockwork](/clockwork/): exposes machine state through reactive Ripple values. - [Ledger](/ledger/): adds command-based undo and redo beside Ripple state. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createRipple()` | Create isolated graph | Sync | Disposal is terminal; create a new graph instead of reusing it | | `signal()` | Create writable value | Sync | Default graph is process-wide | | `computed()` | Create lazy derived value | Sync | Keep derivation pure | | `effect()` | React to dependency reads | Sync | Dispose handle or return cleanup | | `batch()` | Coalesce synchronous writes | Sync | Does not roll back writes | | `createScope()` | Group owned reactive work | Sync | Call `run()` to activate it | | `untrack()` | Read without tracking | Sync | Read still happens immediately | | `watch()` | Observe selected output | Sync | Use `effect()` for broad reads | | `tap()` | Observe runtime events | Sync | Handler errors are swallowed | | `fromSubscribable()` | Bridge external source | Sync | Returned bridge must be disposed | | `isReactive()` | Test Ripple's marked readable contract | Sync | Not a security or provenance check | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, tap, and the isolated graph factory | ## Graph Creation ### `createRipple(options?)` ```ts function createRipple(options?: RippleOptions): Ripple; ``` Creates one isolated reactive graph. Factories on the returned object share scheduling, ownership, and error boundaries. `dispose()` is terminal: `ripple.disposed` becomes `true`, existing owned work is disposed, and creating more graph work throws `RippleDisposedRuntimeError`. Create a new graph for a new lifetime. | Parameter | Type | Description | | --- | --- | --- | | `options.errorPolicy` | `'throw' \| 'swallow'` | Controls whether computed refresh, effect, cleanup, and listener failures rethrow. Default `'throw'`. Error events are always emitted through `tap()`. | **Returns:** `Ripple`. **Example:** ```ts import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const count = ripple.signal(0); const stop = ripple.effect(() => console.log(count.value)); stop.dispose(); ripple.dispose(); ``` --- ### `isReactive(value)` ```ts function isReactive(value: T | Readable): value is Readable; ``` Tests Ripple's shared-symbol marker and complete readable surface (`value`, `peek()`, and `subscribe()`). Recognition works across duplicated Ripple module graphs. The marker is intentionally structural and forgeable; do not use this guard for trust or provenance decisions. **Returns:** `true` for marked signals, computeds, resources, and external-source bridges with the complete readable contract. **Example:** ```ts import { isReactive, signal } from '@vielzeug/ripple'; console.log(isReactive(signal(0))); ``` ## Default Graph Functions Root helpers (`signal`, `computed`, `effect`, `batch`, `createScope`, `untrack`, `watch`, `resource`, `fromSubscribable`) operate on a process-lifetime default graph. Use them only when the application has one graph for its entire lifetime: they are never disposed. For isolated, disposable graphs (tests, SSR, embedded features) use `createRipple()`. ### `signal(initial, options?)` ```ts function signal(initial: T, options?: SignalOptions): Signal; ``` Creates writable state on the default graph. Use `update()` for immutable replacement patterns. **Returns:** `Signal`. **Example:** ```ts import { signal } from '@vielzeug/ripple'; const count = signal(0); count.value += 1; const cart = signal({ items: 0 }); cart.update((state) => ({ ...state, items: state.items + 1 })); ``` --- ### `computed(derive, options?)` ```ts function computed(derive: () => T, options?: ComputedOptions): Readable; ``` Creates a lazy read-only value from reactive reads in `derive`. `peek()` may trigger derivation and throw directly. During dependency propagation, a computed refresh failure is reported through `tap()` with context kind `computed` and does not prevent sibling dependents from updating. A disposed computed returns its last cached value from `peek()` without recomputing. **Returns:** `Readable`. **Example:** ```ts import { computed, signal } from '@vielzeug/ripple'; const count = signal(2); const doubled = computed(() => count.value * 2); console.log(doubled.value); ``` --- ### `effect(callback, options?)` ```ts function effect(callback: () => Cleanup | undefined, options?: EffectOptions): EffectHandle; ``` Runs immediately and reruns when its tracked reads change. A returned cleanup runs before the next callback or disposal. **Returns:** `EffectHandle`. **Example:** ```ts import { effect, signal } from '@vielzeug/ripple'; const connected = signal(false); const stop = effect(() => { if (!connected.value) return; return () => console.log('disconnect'); }); stop.dispose(); ``` --- ### `batch(fn)` and `untrack(fn)` ```ts function batch(fn: () => T): T; function untrack(fn: () => T): T; ``` `batch()` defers effects and listeners until its callback returns. `untrack()` reads current state without adding dependencies to an enclosing effect. **Returns:** the callback result. **Example:** ```ts import { batch, signal, untrack } from '@vielzeug/ripple'; const first = signal('Ada'); const last = signal('Lovelace'); const locale = signal('en-US'); batch(() => { first.value = 'Grace'; last.value = 'Hopper'; }); console.log(untrack(() => locale.value)); ``` --- ### `createScope(name?)` ```ts function createScope(name?: string): Scope; ``` Creates a disposable ownership boundary. Work created inside `scope.run()` belongs to that scope. Explicit scopes created during an effect run attach to the enclosing scope (not the transient effect-run owner) and therefore survive reruns until explicitly or parent-disposed. Disposed children detach from parent ownership immediately. **Returns:** `Scope`. **Example:** ```ts import { createScope, effect, signal } from '@vielzeug/ripple'; const scope = createScope('panel'); const count = signal(0); scope.run(() => effect(() => console.log(count.value))); scope.dispose(); ``` ### `resource(source, loader, options?)` ```ts function resource( source: () => Source, loader: (source: Source, context: { readonly signal: AbortSignal }) => Promise, options?: ResourceOptions, ): Resource; ``` Creates a disposable readable async state and starts loading immediately. Source changes and `reload()` abort stale work before starting the next loader. The last successful value is preserved as `previous`, including when that value is `undefined`. ```ts const user = resource( () => userId.value, (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()), ); ``` The state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. Reads remain available after disposal; `reload()` and new subscriptions throw `RippleDisposedResourceError`. ## Watch and Observability ### `watch(source, callback, options?)` ```ts function watch( source: Readable | (() => T), callback: (value: T, previous: T | undefined) => void, options?: WatchOptions, ): EffectHandle; ``` Observes selected output changes using the default graph or a `Ripple.watch()` method. Callback-only reactive reads are untracked; without `immediate`, initial evaluation does not count as an invocation. For one-shot observation, dispose the returned handle from inside the callback: with `immediate: true`, do the first invocation at the call site instead, because the callback runs before the handle exists. **Returns:** `EffectHandle`. **Example:** ```ts import { signal, watch } from '@vielzeug/ripple'; const count = signal(0); const stop = watch(count, (value, previous) => console.log(previous, value), { immediate: true }); stop.dispose(); ``` --- ### `ripple.tap(handler, options?)` ```ts function tap( handler: (event: RippleEvent) => void, options?: { signal?: AbortSignal }, ): Unsubscribe; ``` Runtime observability for writes, computes, effects, disposals, and reported errors. Computed refresh errors are reported with context kind `computed` without interrupting sibling propagation. During graph disposal, taps remain active for owned cleanup errors and node disposal events; `dispose:graph` is emitted last. Handler errors are swallowed. The returned function unsubscribes, and an optional signal auto-detaches it. The `Ripple.tap()` method is available on instances created with `createRipple()`. After runtime disposal it returns a no-op unsubscribe. **Returns:** `Unsubscribe`. **Example:** ```ts import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple({ errorPolicy: 'swallow' }); const stop = ripple.tap((event) => { if (event.type === 'error') console.error(event.context.kind, event.error); if (event.type === 'write') console.log(`${event.name}: ${event.previous} → ${event.next}`); }); const count = ripple.signal(0); count.value = 1; stop(); ripple.dispose(); ``` --- ### `fromSubscribable(source, options?)` ```ts function fromSubscribable(source: Subscribable, options?: FromSubscribableOptions): BridgedReadable; ``` Bridges an external `{ getSnapshot, subscribe }` source into a disposable reactive readable. The bridge subscribes and then rereads the snapshot to avoid missing a change during registration. The root helper uses the default graph; `ripple.fromSubscribable()` binds ownership to an isolated graph. An optional signal disposes the bridge. **Returns:** `BridgedReadable`. Disposal invokes the source unsubscribe function. Reads remain available afterward, while new subscriptions throw. **Example:** ```ts import { fromSubscribable } from '@vielzeug/ripple'; const routerState = fromSubscribable({ getSnapshot: () => router.getSnapshot(), subscribe: (cb) => router.subscribe(cb), }); routerState.dispose(); ``` ## Types ```ts type Cleanup = () => void; type Equality = (previous: T, next: T) => boolean; type Unsubscribe = () => void; type SignalOptions = { equals?: Equality; name?: string }; type ComputedOptions = { equals?: Equality; name?: string }; type EffectOptions = { name?: string }; type WatchOptions = { equals?: Equality; immediate?: boolean; name?: string }; type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'listener'; readonly name?: string }; type RippleErrorPolicy = 'throw' | 'swallow'; type RippleOptions = { errorPolicy?: RippleErrorPolicy }; type RippleEvent = | { readonly type: 'compute'; readonly name?: string } | { readonly type: 'effect'; readonly name?: string } | { readonly type: 'write'; readonly name?: string; readonly next: unknown; readonly previous: unknown } | { readonly type: 'dispose'; readonly name?: string; readonly node: 'effect' | 'graph' | 'scope' } | { readonly type: 'error'; readonly error: unknown; readonly context: RippleErrorContext }; interface Readable { readonly name?: string; peek(): T; subscribe(listener: () => void): Unsubscribe; readonly value: T; } interface Signal extends Readable { update(updater: (prev: T) => T): void; value: T } interface Disposable { dispose(): void; readonly disposed: boolean; readonly disposalSignal: AbortSignal; [Symbol.dispose](): void } type EffectHandle = Disposable; interface Scope extends Disposable { run(fn: () => T): T } interface Subscribable { getSnapshot(): T; subscribe(listener: () => void): Unsubscribe; } type FromSubscribableOptions = { name?: string; signal?: AbortSignal }; type BridgedReadable = Readable & Disposable; type AsyncState = | { readonly previous?: T; readonly status: 'pending' } | { readonly status: 'success'; readonly value: T } | { readonly error: unknown; readonly previous?: T; readonly status: 'error' }; interface Resource extends Readable>, Disposable { reload(): void; } type ResourceOptions = { name?: string }; interface Ripple extends Disposable { batch(fn: () => T): T; computed(derive: () => T, options?: ComputedOptions): Readable; createScope(name?: string): Scope; effect(callback: () => Cleanup | undefined, options?: EffectOptions): EffectHandle; fromSubscribable(source: Subscribable, options?: FromSubscribableOptions): BridgedReadable; resource( source: () => Source, loader: (source: Source, context: { readonly signal: AbortSignal }) => Promise, options?: ResourceOptions, ): Resource; signal(initial: T, options?: SignalOptions): Signal; tap(handler: (event: RippleEvent) => void, options?: { signal?: AbortSignal }): Unsubscribe; untrack(fn: () => T): T; watch(source: Readable | (() => T), callback: (value: T, previous: T | undefined) => void, options?: WatchOptions): EffectHandle; } ``` ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `RippleError` | Base Ripple error | Use `instanceof RippleError` to narrow unknown values. | | `RippleComputedCycleError` | Computed dependency reads itself through a cycle | Extends `RippleError`. | | `RippleDisposedResourceError` | `reload()` or `subscribe()` used after resource disposal | Extends `RippleError`. | | `RippleDisposedRuntimeError` | Factory or execution API used after `ripple.dispose()` | Extends `RippleError`. | | `RippleDisposedScopeError` | `scope.run()` after scope disposal | Extends `RippleError`. | | `RippleInfiniteLoopError` | Effect flush exceeds graph iteration limit | Extends `RippleError`. | ### Usage Guide ## Basic Usage Use top-level functions when one application-lifetime graph is sufficient. Read a signal inside an effect to make that read reactive. ```ts import { computed, effect, signal } from '@vielzeug/ripple'; const count = signal(0); const label = computed(() => `Count: ${count.value}`); const stop = effect(() => console.log(label.value)); count.value = 1; stop.dispose(); ``` ## Isolated Graphs Use `createRipple()` for tests, SSR requests, embedded applications, or independently disposable features. Never mix reactive values from separate graphs. ```ts import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple({ errorPolicy: 'swallow' }); ripple.tap((event) => { if (event.type === 'error') console.log(event.context.kind, event.error); }); const count = ripple.signal(0); const stop = ripple.effect(() => console.log(count.value)); stop.dispose(); ripple.dispose(); ``` ## Derived Values and Batches Use `computed()` for pure derivation. Use `untrack()` when a current read must not become an effect dependency. Use `batch()` for related synchronous writes. ```ts const first = ripple.signal('Ada'); const last = ripple.signal('Lovelace'); const locale = ripple.signal('en-US'); const name = ripple.computed(() => `${first.value} ${last.value}`); ripple.effect(() => { console.log({ locale: ripple.untrack(() => locale.value), name: name.value }); }); ripple.batch(() => { first.value = 'Grace'; last.value = 'Hopper'; }); ``` ## Scheduling and Subscriptions Ripple propagates every synchronous write before flushing effects. Each flush pass runs effects queued at its start before direct `subscribe()` listeners queued at its start. Work queued by either runs in a later pass. Effects coalesce writes made inside the same synchronous flush: a `batch()` (or any writes during a flush) produces a single effect run. Each `subscribe()` registration fires once per flush, even when several subscriptions share one callback function. ```ts const count = ripple.signal(0); const log: string[] = []; ripple.effect(() => log.push(`effect: ${count.value}`)); count.subscribe(() => log.push(`listener: ${count.value}`)); log.length = 0; // Ignore synchronous creation runs. ripple.batch(() => { count.value = 1; count.value = 2; }); console.log(log); // ['effect: 2', 'listener: 2'] ``` ## Ownership with Scopes Create a scope when a group of effects or derived values shares one lifetime. Dispose the scope when its feature ends. ```ts const scope = ripple.createScope('panel'); const count = ripple.signal(0); scope.run(() => { ripple.effect(() => console.log(`Panel count: ${count.value}`)); }); count.value = 1; scope.dispose(); ``` Effects and computeds created directly during an effect run belong to that run and are disposed before rerun. Explicit scopes intentionally attach to the enclosing scope and survive effect reruns; create them outside rerunning effects or retain and dispose each scope yourself. Disposed owned nodes detach from their parent immediately, so long-lived graphs do not retain released effects or scopes. ## Watch Selected Values Use `watch()` for one selected output. Reactive reads made only inside its callback are untracked. For one-shot observation, dispose the returned handle from inside the callback; with `immediate: true` the callback runs before the handle exists, so do the first invocation at the call site. Use `effect()` when every callback read should be a dependency. ```ts const stopWatch = ripple.watch( () => `${first.value} ${last.value}`, (value, previous) => console.log({ previous, value }), { immediate: true }, ); stopWatch.dispose(); ``` ## Observability `tap()` receives writes, computes, effects, disposals, and reported errors. Computed refresh failures are isolated and reported without interrupting sibling propagation. During runtime disposal, owned cleanup errors and node disposal events are emitted before the final graph disposal event; handlers remain active until that sequence completes. Tap-handler errors are swallowed. ```ts const ripple = createRipple({ errorPolicy: 'swallow' }); ripple.tap((event) => { switch (event.type) { case 'write': console.log(`${event.name}: ${event.previous} → ${event.next}`); break; case 'error': console.error(`${event.context.kind}:`, event.error); break; } }); ``` `errorPolicy` controls whether computed refresh, effect, cleanup, and listener failures rethrow (`'throw'`, default) or are silenced (`'swallow'`). Error events are emitted through `tap()` regardless of policy. ## Bridging External Sources `fromSubscribable()` bridges external `{ getSnapshot, subscribe }` sources into a disposable reactive readable. Use `ripple.fromSubscribable()` inside isolated graphs; the root helper uses the process-lifetime default graph. The bridge rereads after subscription to close registration races, and disposal invokes the external unsubscribe function. ```ts import { fromSubscribable, effect } from '@vielzeug/ripple'; const routerState = fromSubscribable({ getSnapshot: () => router.getSnapshot(), subscribe: (cb) => router.subscribe(cb), }); const stop = effect(() => { console.log('Current route:', routerState.value); }); stop.dispose(); routerState.dispose(); ``` ## Load Async Resources `resource()` starts immediately, tracks its source reads, and aborts the previous loader whenever the source changes or `reload()` is called. It preserves the last successful value (including `undefined`) as `previous` while newer work is pending or fails. ```ts const userId = ripple.signal('42'); const user = ripple.resource( () => userId.value, (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()), { name: 'user' }, ); const stop = ripple.effect(() => { const state = user.value; if (state.status === 'success') renderUser(state.value); if (state.status === 'error') renderError(state.error); }); stop.dispose(); user.dispose(); ``` Disposed resources retain readable state but reject `reload()` and new subscriptions with `RippleDisposedResourceError`. Use Sourcerer for paginated collection state and a dedicated server-state cache for shared keyed reads, retries, and invalidation. ## Object State `signal()` with `update()` holds one value and supports immutable replacement patterns. Return replacement objects from `update()` when object consumers depend on immutable updates. ```ts const cart = ripple.signal({ items: 0, label: 'empty' }); const items = ripple.computed(() => cart.value.items); cart.update((state) => ({ ...state, items: state.items + 1 })); cart.value = { items: 3, label: 'ready' }; console.log(items.value); ``` ## Testing Create an isolated graph per test. Disposal prevents effects from leaking into later tests. ```ts import { expect, test } from 'vitest'; import { createRipple } from '@vielzeug/ripple'; test('derives a doubled count', () => { const ripple = createRipple(); const count = ripple.signal(2); const doubled = ripple.computed(() => count.value * 2); expect(doubled.value).toBe(4); ripple.dispose(); }); ``` ## Framework Integration Use signals and effects with any renderer. Dispose component-owned effects when the component unmounts. ```ts [React] import { useEffect, useState } from 'react'; import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const count = ripple.signal(0); export function Counter() { const [, rerender] = useState(0); useEffect(() => { const stop = ripple.effect(() => { void count.value; rerender((revision) => revision + 1); }); return () => stop.dispose(); }, []); return (count.value += 1)}>{count.value}; } ``` ```ts [Vue 3] import { onUnmounted, ref } from 'vue'; import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const count = ripple.signal(0); const revision = ref(0); const stop = ripple.effect(() => { void count.value; revision.value++; }); onUnmounted(() => stop.dispose()); ``` ```ts [Svelte] import { onDestroy } from 'svelte'; import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const count = ripple.signal(0); let revision = 0; const stop = ripple.effect(() => { void count.value; revision++; }); onDestroy(() => stop.dispose()); (count.value += 1)}>{count.value} ``` ## Working with Other Vielzeug Libraries Ore uses Ripple for component reactivity. Clockwork actors expose framework-neutral snapshots; bridge actor subscriptions into a Ripple signal. Ledger adds undo/redo commands around state changes without replacing graph. ```ts import { createRipple } from '@vielzeug/ripple'; import { defineMachine } from '@vielzeug/clockwork'; const ripple = createRipple(); const actor = defineMachine, { type: 'START' }>()({ initial: 'idle', states: { active: {}, idle: { on: { START: { target: 'active' } } } }, }).createActor(); const snapshot = ripple.signal(actor.snapshot); const stop = actor.subscribe((next) => (snapshot.value = next)); const status = ripple.computed(() => snapshot.value.state); console.log(status.value); stop(); actor.dispose(); ripple.dispose(); ``` ## Gotchas ### `subscribe()` forces computed evaluation `Readable.subscribe()` calls `peek()` before registering the listener. For signals this is a no-op, but for computeds it forces `refresh()`: the derivation runs immediately even if no one reads `.value`. This ensures `equals` comparison works on the first dependency change. Avoid subscribing to expensive computeds unless you need their value. ### Computed first-run failure is recoverable If a computed's `derive` throws on its first run (e.g., a source is `null`), the computed commits the partial dependencies it tracked before the throw. When a dependency changes and the derivation can succeed, the computed refreshes and notifies its dependents. Effects that read a failing computed report the error through `tap()` and re-run when the computed recovers. ## Best Practices - Create one graph per ownership boundary. - Keep computed callbacks pure. - Return cleanup from effects. - Dispose test and feature graphs. - Batch related synchronous writes. - Use `watch()` only for selected source transitions; callback-only reads are untracked. - Dispose external-source bridges when their owner ends. - Use `tap()` for runtime observability; set `errorPolicy: 'swallow'` to silence rethrow. - Use `resource()` for focused async state and Sourcerer for paginated collection workflows. ### Examples ## Examples - [Reactive Counter](./examples/reactive-counter.md) - [Batch and Untrack](./examples/batch-and-untrack.md) - [Scope Ownership](./examples/scope-ownership.md) - [Watch Selected Value](./examples/watch-selected-value.md) - [Async Resource](./examples/async-resource.md) - [Immutable State](./examples/immutable-store.md) - [Isolated Graph](./examples/isolated-runtime.md) ### REPL Examples - Async Resource (id: `async-resource`) - Create Graph, Signal, Computed & Effect (id: `basic-signal`) - Batch & Untrack (id: `batch-untrack`) - Microtask Effect (id: `effect-options`) - Nested Effect Ownership (id: `scope-ownership`) - Immutable State (id: `store-basics`) - Watch Selected Value (id: `watch-selected-value`) --- ## @vielzeug/rune **Category:** logging ### 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 const path = '/users'; console.log(`[api] GET ${path}`); // After: Rune import { consoleTransport, createLogger, jsonTransport } from '@vielzeug/rune'; const api = createLogger({ namespace: 'api', transports: [ consoleTransport({ level: 'debug' }), jsonTransport({ level: 'error' }), ], }); api.info('request', { method: 'GET', path }); ``` | 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 { consoleTransport, createLogger, jsonTransport, lazy } from '@vielzeug/rune'; const log = createLogger({ logLevel: 'debug', namespace: 'server', transports: [ consoleTransport({ timestamp: true }), jsonTransport({ level: 'error' }), ], }); const requestLog = log.withBindings({ diagnostics: lazy(() => ({ queueDepth: 0 })), requestId: 'abc-123', }); requestLog.info('request', { method: 'GET', path: '/users' }); const users = await requestLog.time('load users', () => Promise.resolve(['user-1'])); console.log(users); ``` ## Features - Level filtering (`debug` to `off`) with `enabled()` checks, including `fatal` above `error` - Immutable config after construction: use `child()` or `withBindings()` to scope - Message-first, context-first, and Error-first log calls for application code and adapters - `Error` values are 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 })` - Pluggable transport pipeline: console, JSON, remote, batching, sampling, and fail-closed redaction - Immutable middleware transforms and filters applied before every transport - 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) - [Migration Guide](./migration.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()` | | `lazy(fn)` | Defer a binding value past the level check | Sync | Factory runs on every emit, not once | | `isLevelEnabled()` | Utility: test whether a level passes a threshold | Sync | `'off'` always returns `false` | | `PRIORITY` | Numeric priority table backing `isLevelEnabled()`| N/A | Lower number = more verbose | | `resolveTheme()` | Merge a partial theme onto the default | Sync | Returns a fully-populated `ResolvedTheme` | | `consoleTransport()` | Styled console output | Sync | Theme is resolved once at factory call, not per entry | | `jsonTransport()` | NDJSON to stdout or a custom sink | Sync | `process.stdout` is unavailable in browsers | | `remoteTransport()` | Asynchronous remote delivery | Async | Pass `onError` for production failure observability | | `batchTransport()` | Serialized buffered delivery | Async | Await `dispose()` during shutdown | | `sampleTransport()` | Probabilistic forwarding | Sync | Validate the downstream delivery path independently | | `redactTransport()` | Recursive sensitive-field masking | Sync | Deeper subtrees are replaced when `maxDepth` is exceeded | ## 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: fault isolation:** if middleware throws, that entry is dropped; if a transport throws synchronously, sibling transports still run. Asynchronous delivery failures belong to the transport and should be observed through options such as `remoteTransport({ onError })`. **Returns:** `Logger` **Example:** ```ts import { consoleTransport, createLogger, jsonTransport } from '@vielzeug/rune'; const log = createLogger({ logLevel: 'warn', namespace: 'app' }); const serverLog = createLogger({ namespace: 'server', transports: [ consoleTransport(), jsonTransport({ level: 'error' }), ], }); ``` ## 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 use `LogMethod`: ```ts (message: string, context?: Bindings): void (context: Bindings, message?: string): void (error: Error, message?: string): void (error: Error, context: Bindings, message?: string): void ``` Message-first is preferred for ordinary logs. Context-first supports structured events and adapters. Error-first stores the serialized error under `err`. ```ts log.info('request started', { requestId: 'abc' }); log.debug({ type: 'dispatch' }, 'bus:dispatch'); log.error(new Error('timeout'), { requestId: 'abc' }, 'request failed'); ``` ### Composition | Method | Returns | What it does | | ---------------------- | -------- | ---------------------------------------------------------------- | | `child(overrides?)` | `Logger` | Clones config, applies overrides, inherits bindings | | `use(middleware)` | `Logger` | Adds an immutable pre-dispatch transform or filter | | `withBindings(fields)` | `Logger` | Pins fields to every subsequent call, returns a new child 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. Idempotent. | ### Properties | Property | Type | Description | | ------------------ | -------------------------- | ------------------------------------------------------------------ | | `logLevel` | `LogLevel` | Active log level threshold | | `namespace` | `string` | Effective namespace string | | `transports` | `readonly Transport[]` | Transport pipeline snapshot | | `middleware` | `readonly LogMiddleware[]` | Middleware 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` | N/A | Custom object formatter (e.g. `util.inspect`) | | `theme` | `ConsoleTheme` | N/A | 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 })], }); ``` ### 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? }` | N/A | 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('request', { path: '/users', status: 200 }); // {"path":"/users","status":200,"level":"info","time":"2026-05-30T...","ns":"api","msg":"request"} ``` ### remoteTransport(options) ```ts remoteTransport(options: RemoteTransportOptions): Transport ``` | Option | Type | Default | Description | | --------- | ------------------------------------------------------------- | ------------- | ------------------------------ | | `handler` | `(level, data) => void \| Promise` | required | Remote delivery function | | `level` | `LogLevel` | `'debug'` | Minimum forwarded level | | `env` | `'development' \| 'production'` | auto-detected | Payload environment | | `onError` | `(error: unknown, data: RemoteLogData) => void` | dev warning | Delivery failure observer | **Returns:** A fire-and-forget `Transport`. Pass `onError` whenever production delivery failures require monitoring. ```ts import { remoteTransport } from '@vielzeug/rune'; const remote = remoteTransport({ handler: (_level, data) => fetch('/api/logs', { body: JSON.stringify(data), method: 'POST' }), onError: (error) => console.error('log delivery failed', error), }); ``` ### batchTransport(options) ```ts batchTransport(options: BatchTransportOptions): BatchHandle ``` | Option | Type | Default | Description | | -------------- | --------------------------------------------- | --------- | ----------------------------------- | | `onFlush` | `(entries) => void \| Promise` | required | Serialized batch delivery | | `onFlushError` | `(entries, error) => void` | N/A | Delivery failure observer | | `interval` | `number` | `5000` | Flush interval in milliseconds | | `maxSize` | `number` | `50` | Entries that trigger an early flush | | `maxBuffer` | `number` | unbounded | Hard in-memory limit | | `level` | `LogLevel` | `'debug'` | Minimum buffered level | **Returns:** `BatchHandle` with `transport`, `flush()`, `dispose()`, `disposed`, and `[Symbol.asyncDispose]`. Await `dispose()` during shutdown. ```ts import { batchTransport, createLogger } from '@vielzeug/rune'; const batch = batchTransport({ onFlush: (entries) => fetch('/api/log-batches', { body: JSON.stringify(entries), method: 'POST' }), }); const log = createLogger({ transports: [batch.transport] }); await batch.dispose(); ``` ### sampleTransport(options) ```ts sampleTransport(options: SampleTransportOptions): Transport ``` | Option | Type | Default | Description | | ----------- | ----------- | --------- | ------------------------------ | | `rate` | `number` | required | Forwarding fraction from 0–1 | | `transport` | `Transport` | required | Downstream transport | | `level` | `LogLevel` | `'debug'` | Minimum level before sampling | **Returns:** A sampling `Transport`. ```ts import { remoteTransport, sampleTransport } from '@vielzeug/rune'; const remote = remoteTransport({ handler: (_level, data) => fetch('/api/logs', { body: JSON.stringify(data) }) }); const sampled = sampleTransport({ rate: 0.1, transport: remote }); ``` ### redactTransport(options) ```ts redactTransport(options: RedactTransportOptions): Transport ``` | Option | Type | Default | Description | | ------------- | ------------------- | -------------- | ------------------------------------------------ | | `keys` | `readonly string[]` | required | Exact field names redacted at any inspected depth | | `transport` | `Transport` | required | Downstream transport | | `maxDepth` | `number` | `20` | Maximum nested object depth inspected | | `replacement` | `string` | `'[REDACTED]'` | Replacement for keys and deeper subtrees | **Returns:** A non-mutating redaction `Transport`. When `maxDepth` is exceeded, the whole deeper subtree is replaced; uninspected values are never forwarded. ```ts import { jsonTransport, redactTransport } from '@vielzeug/rune'; const safeJson = redactTransport({ keys: ['password', 'token'], transport: jsonTransport() }); ``` ## 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 authors building custom level-comparison logic; `isLevelEnabled()` is built directly on top of it. ## 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. ### ConsoleThemeEntry ```ts type ConsoleThemeEntry = { badge: string; bg: string; border: string; color: string; }; ``` Per-level style definition for the console transport. All fields are optional when providing a level override: unspecified fields fall back to the default theme. ### ConsoleTheme ```ts type ConsoleTheme = Partial>>; ``` Partial theme overrides merged on top of the default theme. Each level entry is also partial: only specify the fields you want to change. ### 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 | | `middleware` | `LogMiddleware[]?` | `[]` | Pre-dispatch transform/filter pipeline | | `bindings` | `Bindings?` | `{}` | Initial pinned bindings | ### LogMethod ```ts type LogMethod = { (message: string, context?: Bindings): void; (context: Bindings, message?: string): void; (error: Error, message?: string): void; (error: Error, context: Bindings, message?: string): void; }; ``` Message-first, context-first, and Error-first calls are supported. Top-level `Error` values are serialized to `{ message, name, stack }`. ### 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). ### Logger The full interface returned by `createLogger()`: ```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; 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` | N/A | Custom object formatter (e.g. `util.inspect`) | | `theme` | `ConsoleTheme` | N/A | Override default badge colours for this transport | ### 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? }` | N/A | Custom output field names (e.g. `level: 'severity'` for Datadog) | ### LogMiddleware ```ts type LogMiddleware = (entry: LogEntry) => LogEntry | null; ``` Transforms an entry before all transports or returns `null` to drop it. ### RemoteLogData and RemoteTransportOptions ```ts type RemoteLogData = { data?: Readonly; env: 'development' | 'production'; level: LogType; message?: string; namespace?: string; timestamp: string; }; type RemoteTransportOptions = { env?: 'development' | 'production'; handler: (type: LogType, data: RemoteLogData) => void | Promise; level?: LogLevel; onError?: (error: unknown, data: RemoteLogData) => void; }; ``` ### BatchHandle and BatchTransportOptions ```ts type BatchHandle = { [Symbol.asyncDispose]: () => Promise; dispose: () => Promise; readonly disposed: boolean; flush: () => Promise; transport: Transport; }; type BatchTransportOptions = { interval?: number; level?: LogLevel; maxBuffer?: number; maxSize?: number; onFlush: (entries: LogEntry[]) => void | Promise; onFlushError?: (entries: LogEntry[], error: unknown) => void; }; ``` ### SampleTransportOptions ```ts type SampleTransportOptions = { level?: LogLevel; rate: number; transport: Transport; }; ``` ### RedactTransportOptions ```ts type RedactTransportOptions = { keys: readonly string[]; maxDepth?: number; replacement?: string; transport: Transport; }; ``` Depth-limited subtrees are replaced entirely so sensitive values are never forwarded uninspected. ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `RuneError` | Base class for all rune-originated errors | `instanceof RuneError` narrows all Rune errors | | `RuneConfigError` | Logger or transport configured with invalid options | N/A | ### Usage Guide Start with the [Overview](./index.md), then use this page for detailed usage patterns. ## Basic Usage Use `createLogger()` to create an isolated logger instance. ```ts import { createLogger } from '@vielzeug/rune'; const appLog = createLogger(); 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 { consoleTransport, createLogger, jsonTransport } from '@vielzeug/rune'; const log = createLogger({ logLevel: 'debug', transports: [ consoleTransport({ timestamp: true }), jsonTransport({ level: 'error' }), ], }); ``` When `transports` is omitted, `consoleTransport()` is used automatically. ### Built-in Transport Factories | Factory | Use case | | -------------------- | --------------------------------------------- | | `consoleTransport()` | Styled console output (default) | | `jsonTransport()` | NDJSON for server-side log aggregation | | `remoteTransport()` | Asynchronous browser or server delivery | | `batchTransport()` | Serialized buffered delivery with flush | | `sampleTransport()` | Probabilistic volume reduction | | `redactTransport()` | Recursive sensitive-field and subtree masking | ### 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('request', { path: '/users', status: 200 }); // Outputs: {"level":"info","time":"2026-05-30T...","ns":"api","path":"/users","status":200,"msg":"request"} ``` ### Remote Delivery, Batching, and Redaction Compose transport wrappers around the delivery boundary. Handle remote failures explicitly in production and dispose the batch during shutdown. ```ts import { batchTransport, createLogger, redactTransport, remoteTransport } from '@vielzeug/rune'; const remote = remoteTransport({ handler: (_level, payload) => fetch('/api/logs', { body: JSON.stringify(payload), method: 'POST' }).then((response) => { if (!response.ok) throw new Error(`log delivery failed: ${response.status}`); }), level: 'warn', onError: (error) => console.error('log delivery failed', error), }); const batch = batchTransport({ onFlush: async (entries) => { const response = await fetch('/api/log-batches', { body: JSON.stringify(entries), method: 'POST' }); if (!response.ok) throw new Error(`batch delivery failed: ${response.status}`); }, }); const log = createLogger({ transports: [redactTransport({ keys: ['password', 'token'], transport: remote })], }); const batchedLog = createLogger({ transports: [redactTransport({ keys: ['password', 'token'], transport: batch.transport })], }); log.error('direct delivery'); batchedLog.error('buffered delivery'); await batch.dispose(); ``` `redactTransport()` fails closed: when `maxDepth` is exceeded, the entire deeper subtree is replaced rather than forwarded uninspected. ## Configuration Use `child()` to derive immutable logger variants. ```ts const log = createLogger({ namespace: 'app' }); const AppLog = log.child({ logLevel: 'warn', namespace: 'App', // transports inherited from parent 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` ({ ...entry, data: { ...entry.data, environment: 'production' } })], transports: [consoleTransport(), jsonTransport()], }); const errorsOnly = log.use((entry) => (entry.level === 'error' || entry.level === 'fatal' ? entry : null)); ``` `use()` returns a new logger and leaves its parent unchanged. A throwing middleware is isolated and drops only the affected entry. ## Call Signature All log methods support message-first, context-first, and error-first calls: ```ts log.info('message'); log.info('message', { key: 'value' }); log.debug({ type: 'dispatch', id: 42 }, 'bus:dispatch'); log.error(new Error('boom'), { requestId: 'abc' }, 'request failed'); ``` Use message-first for ordinary application logs. Context-first avoids synthetic messages for structured events and matches observation callbacks such as `tap((event) => log.debug(event))`. Error-first automatically stores the serialized error under `err`. Per-call context is shallow-merged with `withBindings()` bindings into `entry.data`. `Error` values are serialized to `{ message, name, stack }`. ## Logging Methods ```ts const log = createLogger(); log.debug('debug details'); log.info('server started', { port: 3000 }); log.warn('cache stale'); log.error('request failed', { err: new Error('timeout') }); // Error auto-serialized in context log.fatal('terminating', { service: 'db' }); // above error, use for unrecoverable state ``` Use `enabled()` to avoid expensive payload construction before the level check: ```ts if (log.enabled('debug')) { log.debug('diagnostics', { diagnostics: buildLargePayload() }); } ``` Or use `lazy()` to let Rune gate it automatically: ```ts const reqLog = log.withBindings({ diagnostics: lazy(() => 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 = createLogger({ namespace: 'api' }); const reqLog = api.withBindings({ requestId: 'abc-123', userId: 42 }); reqLog.info('GET /users'); // always includes requestId and userId reqLog.warn('query took 2s', { slow: true }); // call-site fields merged in ``` The parent logger is not affected. Bindings stack additively through chained `withBindings()` calls: ```ts const base = log.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 = createLogger().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 = createLogger().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 = createLogger({ 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 ``` ## 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 { createCourier, withLogging } from '@vielzeug/courier'; import { createLogger } from '@vielzeug/rune'; const log = createLogger({ namespace: 'courier' }); const courier = createCourier({ baseUrl: 'https://api.example.com', middleware: [withLogging({ logger: (message, meta) => log.debug(message, meta) })], }); ``` ### 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('dispatched', { event, payload }), onError: (err, event) => log.error(`handler error in "${event}"`, { err }), }); ``` ## Best Practices - Create one child logger per module boundary using `createLogger('module.name')` or `log.child({ namespace: '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. - 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 ### 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 state bridge and explicit message trust boundary. The sandbox cannot reach the host page. ```ts // Before container.innerHTML = untrustedHtml; // After const sandbox = createSandbox(container); await sandbox.render(untrustedHtml); ``` 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 | | Managed message protocol | | Typed host state; untrusted inbound details | | 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 iframe isolation, CSP enforcement, and explicit host/sandbox messaging. **Consider 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); sandbox.onMessage((message) => { if (message.type === 'custom') console.log(message.event, message.detail); if (message.type === 'error') console.error(message.message); if (message.type === 'resize') console.log('height:', message.height); }); try { await sandbox.render('Click me'); sandbox.setState({ theme: 'dark' }); } catch (error) { console.error('Sandbox render failed', error); } finally { sandbox.dispose(); } ``` ## Features - `createSandbox()`: Creates an isolated `` with typed outbound state - `render(html)`: Creates or replaces the document and resolves when its bridge reports ready - `setState(update)`: Pushes one or more typed state values in one message - `replaceBody(html)`: Updates streamed body content without resetting head scripts and styles - `updateStyle(id, css)`: Patches a named style without replacing the document - `SandboxMessage`: Keeps sandbox-controlled custom details typed as `unknown` on the host - `SandboxBridge`: Types authored sandbox-side state subscriptions and event emission - `readyTimeout`: Rejects blocked document initialization with `SandboxTimeoutError` - Strict CSP: Uses `default-src 'none'` and blocks network requests by default - Error forwarding: Installs before user scripts and forwards uncaught errors and rejections - Stale-message protection: Rejects messages from superseded render generations - Disposable: Supports `dispose()`, `disposalSignal`, and `[Symbol.dispose]` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.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 a managed sandbox iframe | Sync | The iframe is created lazily by `render()` | | `SandboxHandle.render()` | Replace the document and await readiness | Async | Injected scripts can trigger a timeout | | `SandboxHandle` | Update, observe, and dispose the iframe | Sync methods | State/body updates require a ready document | | `SandboxOptions` | Configure metadata, CSP sources, scripts, styles, and timeout | Sync validation | Invalid values throw during creation | | `SandboxMessage` | Host-side message union | Sync callback | Custom details are always `unknown` | | `SandboxBridge` | Typed sandbox-side `window.__sandbox__` API | Sync | Its generics do not validate host-side input | | `SandboxError` | Base class for package errors | Thrown/rejected | Narrow caught values with `instanceof` | ## Package Entry Point | Import | Exports | | --- | --- | | `@vielzeug/sandbox` | Runtime, errors, and public types | | `@vielzeug/sandbox/testing` | `createSandboxTestHelpers()` | ```ts import { createSandbox, SandboxConfigurationError, SandboxError, SandboxTimeoutError, } from '@vielzeug/sandbox'; import type { SandboxBridge, SandboxHandle, SandboxMessage, SandboxOptions, } from '@vielzeug/sandbox'; ``` ## Runtime ### `createSandbox(container, options?)` ```ts function createSandbox>( container: HTMLElement, options?: SandboxOptions, ): SandboxHandle; ``` Creates a managed `` in `container`. Construction validates options but does not create DOM. The first `render()` creates the iframe with `referrerpolicy="no-referrer"` and a generated CSP. | Parameter | Type | Description | | --- | --- | --- | | `container` | `HTMLElement` | Host element that owns the iframe | | `options` | `SandboxOptions` | Optional document and runtime configuration | **Returns:** `SandboxHandle`. `State` describes values sent from the trusted host to the sandbox through `setState()`. Ordinary interfaces are supported. ```ts interface PreviewState { locale: string; theme: 'dark' | 'light'; } const sandbox = createSandbox(container, { styles: { theme: 'body { color-scheme: light; }' }, title: 'Preview', }); await sandbox.render('Ready'); sandbox.setState({ locale: 'en', theme: 'dark' }); ``` ### `SandboxHandle` ```ts interface SandboxHandle> { readonly disposalSignal: AbortSignal; readonly disposed: boolean; dispose(): void; onMessage(handler: (message: SandboxMessage) => void): () => void; render(html: string): Promise; replaceBody(html: string): void; setState(update: Partial): void; updateStyle(id: string, css: string): void; [Symbol.dispose](): void; } ``` | Method/property | Description | | --- | --- | | `render(html)` | Replace the complete document and await readiness | | `replaceBody(html)` | Replace body descendants without navigation | | `setState(update)` | Push typed host state | | `updateStyle(id, css)` | Patch a named style | | `onMessage(handler)` | Subscribe to untrusted iframe messages | | `disposed` / `disposalSignal` | Observe lifecycle state | | `dispose()` / `[Symbol.dispose]()` | Release the iframe and listeners | #### `render(html)` Replaces the complete `srcdoc` document. The returned Promise resolves when that document emits its internal ready message and rejects with `SandboxTimeoutError` after `readyTimeout`. A newer render supersedes and resolves the previous pending render. Disposal also resolves a pending render. Messages from superseded generations are ignored. The generated bridge is installed in `` before injected scripts and user HTML, so it can capture script errors during document initialization. Its ready message is sent on `DOMContentLoaded`. #### `replaceBody(html)` Replaces `document.body.innerHTML` without navigating. Head scripts, document/window listeners, and named styles survive. Body descendants, descendant listeners, references, form state, and scripts in replacement HTML do not. Call only after `render()` resolves. The method warns in development and does nothing when no ready document exists. #### `setState(update)` Sends one object containing one or more state values. Inside the iframe, each key dispatches its own `sandbox:state-update` `CustomEvent` and reaches matching `SandboxBridge.onState()` subscribers. ```ts sandbox.setState({ locale: 'en', theme: 'dark' }); ``` Calls made before the document is ready are accumulated instead of dropped: the newest value per key is delivered in one message when the bridge reports ready, and again after each later `render()` so a re-rendered document starts with current state. #### `updateStyle(id, css)` Updates a style declared in `options.styles`. Before the first render it updates only the render baseline. After readiness it also patches the live `` element. Unknown IDs emit a development warning. #### `onMessage(handler)` Subscribes to application messages and returns an idempotent unsubscribe function. Internal ready messages are not forwarded. ```ts sandbox.onMessage((message) => { if (message.type === 'resize') container.style.height = `${message.height}px`; }); ``` #### Lifecycle `dispose()` removes the iframe, removes the host `message` listener, clears subscribers, resolves a pending render, and aborts `disposalSignal`. It is idempotent. `[Symbol.dispose]()` provides the same behavior for `using` declarations. ## Types ### `SandboxOptions` ```ts interface SandboxOptions { allowedFontOrigins?: string[]; allowedImageOrigins?: string[]; allowedScriptOrigins?: string[]; allowedStyleOrigins?: string[]; lang?: string; nonce?: string; readyTimeout?: number; scripts?: string[]; styles?: Record; title?: string; } ``` | Option | Default | Description | | --- | --- | --- | | `allowedFontOrigins` | `[]` | HTTP(S) origins added to `font-src`; otherwise `'none'` | | `allowedImageOrigins` | `[]` | HTTP(S) origins added to `img-src`; `data:` is always included | | `allowedScriptOrigins` | `[]` | HTTP(S) origins added to `script-src` | | `allowedStyleOrigins` | `[]` | HTTP(S) origins added to `style-src` | | `lang` | `'en'` | Basic BCP 47 language tag for `` | | `nonce` | `undefined` | Base64/base64url-style nonce for the bridge script and CSP | | `readyTimeout` | `5000` | Positive finite timeout in milliseconds, at most `2^31 - 1` | | `scripts` | `[]` | Absolute HTTP(S) scripts injected before user HTML | | `styles` | `{}` | Named style blocks; IDs start with a letter and contain letters, digits, `_`, or `-` | | `title` | `''` | Escaped document title | Allowed origins reject paths, credentials, query strings, fragments, unsupported schemes, and CSP syntax. Script URLs must be absolute HTTP(S) URLs. ### `SandboxMessage` ```ts type SandboxMessage = | { detail: unknown; event: string; type: 'custom' } | { message: string; stack?: string; type: 'error' } | { height: number; type: 'resize' }; ``` All fields originate in sandboxed code and are untrusted. Validate custom `detail`, and render text through escaping APIs rather than HTML injection. ### `SandboxBridge` The generated document exposes `window.__sandbox__`: ```ts interface SandboxBridge, Events extends object = Record, > { emit( event: K, ...detail: undefined extends Events[K] ? [detail?: Events[K]] : [detail: Events[K]] ): void; onState( key: K, handler: (value: State[K]) => void, ): () => void; } ``` Use an ambient declaration in authored sandbox-side TypeScript: ```ts interface State { theme: 'dark' | 'light'; } interface Events { saved: { id: string }; } declare interface Window { __sandbox__: import('@vielzeug/sandbox').SandboxBridge; } ``` These generics check authored sandbox code only. They do not make host-side messages trustworthy. ## Errors ### `SandboxError` Base class for package errors. Accepts `opts?: ErrorOptions` for cause chaining. ### `SandboxConfigurationError` Thrown synchronously by `createSandbox()` when an option cannot produce a valid document or CSP. ### `SandboxTimeoutError` Rejected by `render()` when no ready message arrives before `readyTimeout`. An injected script may have blocked document initialization. ## Testing ```ts import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing'; ``` `createSandboxTestHelpers(container)` returns `fireReady()`, `fireCustom()`, `fireError()`, and `fireResize()` helpers. Call `render()` first so the helper can read the iframe channel and generation metadata. ### Usage Guide ## Basic Usage Create a sandbox, subscribe before rendering, await readiness, and dispose it when finished. ```ts import { createSandbox } from '@vielzeug/sandbox'; const container = document.getElementById('preview')!; const sandbox = createSandbox(container); sandbox.onMessage((message) => { if (message.type === 'error') console.error(message.message); if (message.type === 'resize') container.style.height = `${message.height}px`; }); try { await sandbox.render('Preview'); } finally { sandbox.dispose(); } ``` The iframe is created by the first `render()` call. Each render creates a fresh document and resolves after its generated bridge reports ready. ## Rendering HTML Use `render()` when scripts, styles, globals, and DOM state should reset together. ```ts await sandbox.render(` body { font-family: system-ui; } Save document.querySelector('#save').addEventListener('click', () => { window.__sandbox__.emit('save'); }); `); ``` A newer render supersedes a pending one. Messages from the old document are ignored through the per-render generation marker. ## Pushing Typed State Pass a state interface to `createSandbox()` and send partial updates with one object. ```ts interface PreviewState { locale: string; theme: 'dark' | 'light'; user: { name: string }; } const sandbox = createSandbox(container); await sandbox.render(''); sandbox.setState({ locale: 'en', theme: 'dark', user: { name: 'Ada' }, }); ``` Subscribe inside the iframe through the typed bridge. ```ts interface PreviewState { locale: string; theme: 'dark' | 'light'; } declare interface Window { __sandbox__: import('@vielzeug/sandbox').SandboxBridge; } window.__sandbox__.onState('theme', (theme) => { document.documentElement.dataset.theme = theme; }); ``` `setState()` is safe to call at any point in the lifecycle. Updates made before `render()` resolves are accumulated and delivered in one message when the bridge reports ready, so an early call never loses state. ## Receiving Sandbox Messages Subscribe with `onMessage()` before rendering. The unsubscribe function detaches only that handler. ```ts const unsubscribe = sandbox.onMessage((message) => { if (message.type === 'custom' && message.event === 'saved') { if (isSavedDetail(message.detail)) persist(message.detail.id); } }); function isSavedDetail(value: unknown): value is { id: string } { return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string'; } ``` Custom event details remain `unknown` on the host. The iframe is isolated, but its code still controls message payloads. Use `SandboxBridge` only to check authored sandbox-side calls. ```ts interface Events { saved: { id: string }; } declare interface Window { __sandbox__: import('@vielzeug/sandbox').SandboxBridge, Events>; } window.__sandbox__.emit('saved', { id: '42' }); ``` ## Updating Live Content ### Replace the body Use `replaceBody()` when streamed HTML should preserve head scripts, styles, and global listeners. ```ts await sandbox.render('Waiting…'); let html = ''; for await (const chunk of stream) { html += chunk; sandbox.replaceBody(html); } ``` Replacement uses `document.body.innerHTML`. Scripts in replacement HTML do not execute, and body descendant state and listeners are discarded. ### Update a named style Declare styles at construction and patch them without replacing the document. ```ts const sandbox = createSandbox(container, { styles: { theme: 'body { color-scheme: light; }' }, }); await sandbox.render('Preview'); sandbox.updateStyle('theme', 'body { color-scheme: dark; }'); ``` Calling `updateStyle()` before the first render updates the render baseline. Unknown IDs emit a development warning. ## Injecting Scripts and Configuring CSP List absolute external scripts in `scripts`. Their origins are added to `script-src` automatically. ```ts const sandbox = createSandbox(container, { allowedFontOrigins: ['https://fonts.gstatic.com'], allowedStyleOrigins: ['https://fonts.googleapis.com'], scripts: ['https://cdn.example.com/widget.js'], }); ``` Network requests remain blocked by `connect-src 'none'`. The package does not expose arbitrary CSP directives. A nonce authorizes the generated bridge while preventing un-nonced inline user scripts in CSP Level 3 browsers. ```ts const sandbox = createSandbox(container, { nonce: crypto.randomUUID(), }); ``` ## Handling Errors and Timeouts `render()` rejects if the document does not become ready before `readyTimeout`. ```ts import { SandboxError, SandboxTimeoutError } from '@vielzeug/sandbox'; try { await sandbox.render(html); } catch (error) { if (error instanceof SandboxTimeoutError) { showError('A script blocked preview initialization.'); } else if (error instanceof SandboxError) { showError(error.message); } } ``` Uncaught iframe errors and unhandled rejections arrive through `onMessage()` as untrusted `error` messages. ## Disposal Use `dispose()` or a `using` declaration to remove the iframe and host listeners. ```ts using sandbox = createSandbox(container); await sandbox.render('Temporary preview'); ``` Tie other asynchronous work to `disposalSignal`. ```ts const response = await fetch('/preview-data', { signal: sandbox.disposalSignal, }); ``` ## Testing Use the testing subpath to complete `render()` and simulate messages in jsdom. ```ts import { createSandbox } from '@vielzeug/sandbox'; import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing'; import { expect, it } from 'vitest'; it('receives resize messages', async () => { const container = document.createElement('div'); const sandbox = createSandbox(container); const helpers = createSandboxTestHelpers(container); const heights: number[] = []; sandbox.onMessage((message) => { if (message.type === 'resize') heights.push(message.height); }); const render = sandbox.render('Preview'); helpers.fireReady(); await render; helpers.fireResize(240); expect(heights).toEqual([240]); sandbox.dispose(); }); ``` ## Framework Integration Own one sandbox per mounted component and dispose it during unmount. ```tsx [React] import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox'; import { useEffect, useRef } from 'react'; export function Preview({ html }: { html: string }) { const container = useRef(null); const sandbox = useRef(); useEffect(() => { sandbox.current = createSandbox(container.current!); return () => sandbox.current?.dispose(); }, []); useEffect(() => { void sandbox.current?.render(html).catch(console.error); }, [html]); return ; } ``` ```vue [Vue] import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox'; import { onMounted, onUnmounted, ref, watch } from 'vue'; const props = defineProps(); const container = ref(); let sandbox: SandboxHandle | undefined; onMounted(() => { sandbox = createSandbox(container.value!); void sandbox.render(props.html).catch(console.error); }); watch(() => props.html, (html) => void sandbox?.render(html).catch(console.error)); onUnmounted(() => sandbox?.dispose()); ``` ```svelte [Svelte] import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox'; import { onMount } from 'svelte'; export let html: string; let container: HTMLElement; let sandbox: SandboxHandle | undefined; onMount(() => { sandbox = createSandbox(container); return () => sandbox?.dispose(); }); $: if (sandbox) void sandbox.render(html).catch(console.error); ``` ## Working with Other Vielzeug Libraries Use Sandbox to isolate generated markup that loads browser bundles of DOM-output packages such as Refine or Ore. Use Rune in the host to record validated message details; do not pass untrusted payloads directly to structured logs. ## Best Practices - Await `render()` before sending state or replacing body content. - Narrow every custom message detail at the host boundary. - Prefer `setState()` for data changes and `updateStyle()` for CSS changes. - Use `render()` when scripts or global state must reset. - Handle `SandboxTimeoutError` at every non-test render boundary. - Dispose one sandbox with each owning component. - Tie related asynchronous work to `disposalSignal`. - Keep network access blocked unless the application requires a different isolation model. ### 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 - Normalize Errors with SandboxError (id: `error-normalize`) --- ## @vielzeug/scout **Category:** utilities ### 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 scores only items sharing a trigram with the query; broad queries can still approach O(n), while selective queries avoid scoring the whole corpus. ```ts // Before const matches = users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase())); // After import { createIndex } from '@vielzeug/scout'; const index = createIndex(users, { fields: ['name', 'email'] }); const matches = index.search(query); ``` | Feature | Arsenal `fuzzy*` | Scout `createIndex` | Fuse.js | | ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- | | Bundle size | ~3 KB | | ~23 KB | | Zero dependencies | | | | | Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap | | Query time | O(n·m) | O(candidates) | O(n·m) | | Stateful index | | | | | Match highlighting | | | | | Reactive layer | | Atomic external-store snapshots + debounce | | | Incremental updates | | | Partial | **Use Scout when** you need search over 500+ items, real-time UI search boxes (combobox, command palette), or framework-neutral subscribable search state. **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 users = [ { email: 'ada@example.com', name: 'Ada Lovelace' }, { email: 'grace@example.com', name: 'Grace Hopper' }, ]; const index = createIndex(users, { fields: [ { field: 'name', weight: 2 }, { field: 'email' }, ], }); const results = index.search('ada'); console.log(results[0]?.item.name); // Ada Lovelace ``` ## Features - `createIndex()`: Trigram inverted index; construction O(corpus × field_length), query O(candidates) - Per-field weights: Promote `name` matches over secondary fields; finite positive weights and custom `stringify` functions supported - `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) - `toSearchMatcher()`: Matcher adapter for filtering in-memory collections with a `ScoutIndex` - `toFilterPredicate()`: Snapshot `(item: T) => boolean` predicate for `Array.filter` or vault queries - `setItems()`: Reconcile a refreshed corpus by reference, preserve incoming order, and notify once - Incremental updates: `add()` / `remove()` / `reindex()` patch individual items in O(field_length) - `onMutate()`: Subscribe to index mutations; powers `createSearch()`'s reactivity and bulk reconciliation - `segmentWords()`: Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.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.setItems()` | Reconcile a refreshed corpus in one mutation | Sync | Uses reference identity; duplicate references collapse | | `ScoutIndex.items` | All indexed items in insertion order | Sync | Returns a new array snapshot each call | | `ScoutIndex.revision` | Monotonic counter incremented after each mutation | Sync | Use as a cache-busting token for external result caches | | `ScoutIndex.onMutate()` | Subscribe to changed index mutations | Sync | A changed `setItems()` reconciliation emits once; no-ops emit nothing | | `createSearch()` | Atomic search store backed by a `ScoutIndex` | Sync | Read one snapshot and dispose the store 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 | | `toSearchMatcher()` | Adapt `ScoutIndex` to Sourcerer's local `filter` callback | Sync | Recomputes cached query matches after index mutation | | `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) | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/scout` | All exports: index/search/highlighting/adapters, `ScoutConfigError`, `ScoutDisposedError`, `ScoutError`, and all types | --- ## `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` | Finite overlap score in `0..1` (default `0.2`). | | `options.limit` | `number` | Finite non-negative integer max results (default `50`). | | `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). | **Example** ```ts import { createIndex } from '@vielzeug/scout'; const products = [ { sku: 'WGT-001', title: 'Widget Pro' }, { sku: 'GAD-002', title: 'Gadget Plus' }, ]; 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); ``` ### `.setItems(items)` ```ts setItems(items: readonly T[]): void ``` Reconciles the index to a refreshed corpus in one mutation. Existing references are reindexed, missing references are removed, added references are indexed, and incoming first-occurrence order becomes index order. Duplicate references collapse to one item. Calls `onMutate()` once when indexed values, membership, or order changes. ```ts index.setItems(latestUsers); ``` ### `.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 changed `add()` / `remove()` / `reindex()` / `setItems()` operation. No-ops, including unchanged bulk reconciliation, do not fire it. A changed `setItems()` reconciliation fires once. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly will not need it. ```ts const unsubscribe = index.onMutate(() => { console.log(`Index changed: now ${index.size} items`); }); index.add(newUser); // logs "Index changed: now 6 items" unsubscribe(); ``` ### `.revision` `number`: monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search results outside the index: `toSearchMatcher()` uses it for this purpose. --- ## `createSearch(index, options?)` Wraps a `ScoutIndex` in a zero-dependency external store. `getSnapshot()` returns the current query, loading flag, and results as one consistent snapshot; `subscribe()` notifies after the complete snapshot is committed. ```ts function createSearch(index: ScoutIndex, options?: CreateSearchOptions): SearchState ``` **Parameters** | Param | Type | Description | | --- | --- | --- | | `options.debounce` | `number` | Finite non-negative integer milliseconds before query commit (default `200`). Pass `0` for immediate updates. | | `options.limit` | `number` | Finite non-negative integer override of index-level limit. | | `options.threshold` | `number` | Finite `0..1` override of index-level threshold. | | `options.minQueryLength` | `number` | Finite positive integer override of index-level minimum query length. | **Returns `SearchState`** | Member | Type | Description | | --- | --- | --- | | `getSnapshot()` | `() => SearchSnapshot` | Returns the stable current snapshot. Identity changes only after a state transition. | | `setQuery(query)` | `(query: string) => void` | Starts or immediately commits a query according to `debounce`. | | `subscribe(listener, options?)` | `(listener, options?) => () => void` | Notifies after an atomic snapshot commit. Supports `{ signal }` cleanup. | | `tap(handler, options?)` | `(handler, options?) => () => void` | Observes typed state and disposal events. Handler errors are swallowed. | | `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie other lifecycles to this search. | | `disposed` | `boolean` | `true` after `dispose()` has been called. | | `clear()` | `() => void` | Resets query, cancels debounce, and restores empty-query results synchronously. | | `dispose()` | `() => void` | Releases timers and subscriptions. | | `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. | **Example** ```ts import { createIndex, createSearch } from '@vielzeug/scout'; const users = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }]; const index = createIndex(users, { fields: ['name'] }); const search = createSearch(index, { debounce: 150 }); search.subscribe(() => { console.log(search.getSnapshot().results.map((result) => result.item.name)); }); search.setQuery('ada'); ``` --- ## `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` | Finite non-negative integer debounce milliseconds (default `200`). | | `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). | | `options.limit` | `number` | Finite non-negative integer max results (default `50`). | | `options.minQueryLength` | `number` | Finite positive integer 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 import { createReactiveSearch } from '@vielzeug/scout'; const users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }]; const search = createReactiveSearch(users, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }); search.subscribe(() => console.log(search.getSnapshot().results.map((result) => result.item.name))); search.index.add({ email: 'grace@example.com', name: 'Grace Hopper' }); search.dispose(); ``` --- ## `findMatchRanges(text, query)` Normalizes raw `query` with Scout's tokenizer, then computes sorted, non-overlapping literal ranges for each normalized token 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 import { findMatchRanges, highlight } from '@vielzeug/scout'; 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 import { highlight } from '@vielzeug/scout'; 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 import { createIndex, highlightField } from '@vielzeug/scout'; const users = [{ name: 'Alice Johnson' }]; const index = createIndex(users, { fields: ['name'] }); for (const result of index.search('alice')) { const parts = highlightField(result, 'name', result.item.name); console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join('')); } ``` When the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part. --- ## `toSearchMatcher(index, options?)` Returns an `(item, query) => boolean` matcher compatible with Sourcerer's local `filter` option. ```ts function toSearchMatcher(index: ScoutIndex, options?: SearchConstraints): (item: T, query: string) => boolean ``` One matching-item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation. ```ts import { createIndex, toSearchMatcher } from '@vielzeug/scout'; import { createLocalSource } from '@vielzeug/sourcerer'; const users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }]; const index = createIndex(users, { fields: ['name', 'email'] }); const source = createLocalSource(users, { filter: toSearchMatcher(index), params: '' }); source.setParams('ada'); ``` --- ## `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 import { createIndex, toFilterPredicate } from '@vielzeug/scout'; const products = [{ title: 'Widget Pro' }, { title: 'Gadget Plus' }]; const index = createIndex(products, { fields: ['title'] }); const results = products.filter(toFilterPredicate(index, 'widget')); 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 import { createIndex, segmentWords } from '@vielzeug/scout'; const documents = [{ title: '日本語を勉強しています' }]; const index = createIndex(documents, { fields: [{ field: 'title', stringify: (value) => segmentWords(String(value)) }], }); ``` --- ## Types ### `SearchConstraints` Shared search-tuning knobs used by `ScoutIndexOptions`, `CreateSearchOptions`, and all search functions. ```ts type SearchConstraints = { limit?: number; // finite non-negative integer; default 50 minQueryLength?: number; // finite positive integer; default 3 threshold?: number; // finite 0..1 value; 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; // finite non-negative integer; default 200 }; ``` ### `SearchResult` ```ts type SearchResult = { item: T; matches: FieldMatch[]; // literal normalized-token ranges; may be empty for fuzzy-only results 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][]; // literal normalized-token [start, end] ranges in original field value }; ``` ### `HighlightPart` ```ts type HighlightPart = { highlighted: boolean; text: string; }; ``` ### `SearchSnapshot` ```ts type SearchSnapshot = Readonly>; }>; ``` Snapshots and their result arrays are frozen at runtime. ### `ScoutEvent` ```ts type ScoutEvent = { readonly snapshot: SearchSnapshot; readonly type: 'state-change' }; ``` ### `SearchState` ```ts type SearchState = { readonly disposalSignal: AbortSignal; readonly disposed: boolean; clear(): void; dispose(): void; getSnapshot(): SearchSnapshot; setQuery(query: string): void; subscribe(listener: () => void, options?: { signal?: AbortSignal }): () => void; tap(handler: (event: ScoutEvent) => void, options?: { signal?: AbortSignal }): () => void; [Symbol.dispose](): void; }; ``` `subscribe()` observers are notified after the full snapshot commits. An observer error is reported asynchronously after the remaining observers run. `tap()` emits `state-change` only: disposal is observable through `disposed` and `disposalSignal`; tapper errors are swallowed. ### `ReactiveSearch` ```ts type ReactiveSearch = SearchState & { readonly index: ScoutIndex; }; ``` See `createReactiveSearch()` above. --- ## Errors ### `ScoutError` Base class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error. ```ts class ScoutError extends Error {} ``` **Named subclasses** | Class | Thrown when | | ------------------- | ---------------------------------------------------------------------- | | `ScoutConfigError` | An index, search, or reactive search receives invalid fields or numeric options | | `ScoutDisposedError` | A method is called on a disposed `SearchState` instance | ## Entry Ranking Reference lists: glossaries, rule compendiums, profile directories: rank and render through the same helpers. ### `rankEntries(entries, query, options?)` Orders `RankableEntry` items (`{ id, label, meta, text }`) by how directly they match the query: exact label, label prefix, label contains, meta contains, text contains, and finally `options.fuzzyIds` hits as last resort. Non-matching entries drop; ties break alphabetically. A blank query returns every entry. ### `dedupeEntries(entries)` Drops entries whose label and body text are identical to an earlier entry (case-insensitive): alias rows in merged catalogs. ### `splitPattern(text, pattern)` Splits text on a global regular expression, marking matched pieces (`SplitSegment`), for rendering keyword links over prose. A `null` pattern keeps the text whole. ### `escapeRegExp(value)` Escapes a literal for embedding in a regular expression: build keyword patterns from display names. ### 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 users = [ { email: 'ada@example.com', name: 'Ada Lovelace' }, { email: 'grace@example.com', name: 'Grace Hopper' }, ]; 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. `limit` must be a finite non-negative integer, `threshold` a finite value in `0..1`, and `minQueryLength` a finite positive integer; invalid values throw `ScoutConfigError`. ```ts // At most 10 results, minimum overlap score 0.3 const results = index.search('widget', { limit: 10, threshold: 0.3 }); ``` Per-call options override the index-level defaults set in `createIndex`. Scores come from the overlap (Szymkiewicz–Simpson) coefficient: the fraction of the *shorter* trigram set (almost always the query) found in the longer one. This is deliberate for the autocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix of a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how much of the query matched, not diluted by how much longer the target field happens to be. ### 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 state together. It returns one external store whose snapshot contains `query`, `isSearching`, and `results`, plus `.index` for incremental mutations: ```ts import { createReactiveSearch } from '@vielzeug/scout'; const search = createReactiveSearch(users, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }); search.subscribe(() => { const { isSearching, results } = search.getSnapshot(); if (isSearching) showLoadingSpinner(); else renderResults(results.map((result) => result.item)); }); input.addEventListener('input', (event) => { search.setQuery((event.currentTarget as HTMLInputElement).value); }); search.index.add(newUser); search.dispose(); ``` Each notification observes a complete committed snapshot. During a debounce window, `query` contains the latest input, `isSearching` is `true`, and `results` remain the last committed results. ### `createSearch()`: separate index and state Use `createSearch` when sharing one index across multiple search stores: ```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` for synchronous commits with no searching transition: ```ts const search = createReactiveSearch(users, { fields: ['name'], debounce: 0 }); search.setQuery('alice'); console.log(search.getSnapshot().results); // Already updated ``` ### Resetting search ```ts search.clear(); // Resets query, results, and isSearching atomically ``` ### Composing with Ripple A complete `SearchState` implements Ripple's structural subscribable contract: ```ts import { createRipple } from '@vielzeug/ripple'; const ripple = createRipple(); const state = ripple.fromSubscribable(search, { signal: search.disposalSignal }); const topResult = ripple.computed(() => state.value.results[0]?.item ?? null); ``` ## Incremental Updates Use `add()`, `remove()`, and `reindex()` for individual reference-based mutations. Use `setItems()` when a refreshed collection replaces the current corpus; Scout reconciles membership, current field values, and source order in one notification. ```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()`, `reindex()`, and `setItems()` use **reference equality** (`===`). Pass retained object references from the current corpus; `setItems()` collapses duplicate references. ### Replacing a refreshed corpus ```ts const latestProducts = await loadProducts(); index.setItems(latestProducts); ``` `setItems()` removes references absent from `latestProducts`, adds new references, reindexes retained references, and adopts the incoming order. It calls `onMutate()` once only when index membership, field values, or order changes. ### 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()`/`setItems()` internally. `toSearchMatcher()` also invalidates its query cache after index mutation. 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 literal normalized-token ranges. A fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text. ### `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 ?? []); ``` ## Searchable Reference Lists A glossary or compendium mixes entry kinds and needs direct-match ranking above fuzzy hits. Rank with `rankEntries`, render keyword links with `splitPattern`, and highlight the typed query with `findMatchRanges` + `highlight`. ```ts import { dedupeEntries, escapeRegExp, findMatchRanges, highlight, rankEntries, splitPattern } from '@vielzeug/scout'; const keywordPattern = new RegExp(`(${keywords.map((k) => escapeRegExp(k.name)).join('|')})`, 'g'); const results = rankEntries(entries, query, { fuzzyIds: fuzzyIndexIds }); for (const entry of results) { for (const segment of splitPattern(entry.text, keywordPattern)) { if (segment.matched) renderKeywordLink(segment.text); else for (const part of highlight(segment.text, findMatchRanges(segment.text, query))) renderMark(part); } } ``` Dedupe merged catalogs with `dedupeEntries` before ranking so alias rows do not shadow their originals. ## Debug Logging Use `tap()` for typed runtime observation without affecting search behavior: ```ts import { createIndex, createSearch } from '@vielzeug/scout'; const search = createSearch(index, { debounce: 150 }); const stop = search.tap((event) => { console.debug(event.snapshot.query, event.snapshot.isSearching, event.snapshot.results.length); }); search.setQuery('alice'); stop(); ``` `tap()` emits only `state-change`; observe disposal through `disposed` or `disposalSignal`. Tapper errors are swallowed. Use `subscribe()` instead when application state must update after each snapshot commit. `query` carries the full, literal search query string: if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log them in production. ## Framework Integration ```tsx [React] import { createReactiveSearch } from '@vielzeug/scout'; import { useEffect, useMemo, useSyncExternalStore } from 'react'; type User = { id: number; name: string; email: string }; function useScoutSearch(items: User[]) { const search = useMemo( () => createReactiveSearch(items, { fields: [{ field: 'name', weight: 2 }, 'email'], debounce: 150, }), [items], ); const snapshot = useSyncExternalStore(search.subscribe, search.getSnapshot, search.getSnapshot); useEffect(() => () => search.dispose(), [search]); return { ...snapshot, setQuery: search.setQuery }; } ``` ```ts [Vue 3] import { createReactiveSearch } from '@vielzeug/scout'; import { onScopeDispose, shallowRef } 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 snapshot = shallowRef(search.getSnapshot()); const stop = search.subscribe(() => { snapshot.value = search.getSnapshot(); }); onScopeDispose(() => { stop(); search.dispose(); }); return { search, snapshot }; } ``` ```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 snapshot = search.getSnapshot(); const stop = search.subscribe(() => { snapshot = search.getSnapshot(); }); onDestroy(() => { stop(); search.dispose(); }); search.setQuery(event.currentTarget.value)} placeholder="Search…" /> {#each snapshot.results as { item }} {item.name} {/each} ``` ## Working with Other Vielzeug Libraries ### With Sourcerer `toSearchMatcher()` adapts a `ScoutIndex` into Sourcerer's local-source filter. ```ts import { createIndex, toSearchMatcher } from '@vielzeug/scout'; import { createLocalSource } from '@vielzeug/sourcerer'; const index = createIndex(users, { fields: [{ field: 'name', weight: 2 }, 'email'], }); const source = createLocalSource(users, { filter: toSearchMatcher(index), params: '' }); source.setParams('alice'); console.log(source.state.items); ``` > Update the index and source from the same collection change. Use `index.setItems(items)` with `source.setItems(items)` for replacement collections. ### 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 ## Examples - [Basic Search](./examples/basic-search) - [Reactive Combobox](./examples/reactive-combobox) - [Sourcerer Integration](./examples/sourcerer-integration) ### 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 ### 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.replaceChildren(); 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 virtualizer = createVirtualizer(scrollEl, { count: items.length, estimateSize: 36, onChange: ({ items: visibleItems, totalSize }) => { list.style.height = `${totalSize}px`; list.replaceChildren(); for (const { index, start } of visibleItems) { 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.replaceChildren(); 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` - **External-store state**: use `getSnapshot()` and `subscribe()` directly or bridge into Ripple and framework stores - **Keyboard navigation**: compose it from `scrollToIndex()` and `@vielzeug/keymap` instead of a built-in key handler - **Auto-measurement**: enable `autoMeasure` to automatically measure visible items via `ResizeObserver` - **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 - `ScrollConfigError`: Rejects malformed static configuration before listeners attach or updates apply ## 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) - [Migration Guide](./migration.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 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 | ## Package Entry Point Everything exports from a single entry: ```ts import { createVirtualizer, createDomVirtualList, createVirtualScroller, createGroupedVirtualizer, createGridVirtualizer, createMeasurementCache, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN, ScrollError, ScrollConfigError, ScrollRangeError, type Virtualizer, type VirtualItem, type VirtualizerState, type VirtualizerOptions, type VirtualizerUpdateOptions, type ScrollToIndexOptions, type Overscan, type VirtualKey, type MeasurementCache, type ScrollStore, type ScrollTarget, type DomVirtualListOptions, type DomVirtualListController, type DomVirtualListRenderArgs, type RecycleFn, type VirtualRenderItem, type StickToBottomOptions, type VirtualScrollerOptions, type GroupSection, type GroupVirtualizer, type GroupVirtualizerOptions, type GroupVirtualizerState, type GroupVirtualizerUpdateOptions, type GroupVirtualHeader, type GroupVirtualItem, type GridVirtualizer, type GridVirtualizerOptions, type GridVirtualizerState, type GridVirtualizerUpdateOptions, type GridRangeChangeEvent, type ScrollToCellOptions, } 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 rows = [{ label: 'Ada Lovelace' }, { label: 'Grace Hopper' }]; const scrollEl = document.querySelector('.scroll-container')!; const listEl = document.querySelector('.list')!; const virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, gap: 8, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.replaceChildren(); 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` | N/A | Initial scroll position; applied once on construction | | `autoMeasure` | `boolean` | `false` | Automatically measure visible items via ResizeObserver | | `measurementCache` | `MeasurementCache` | N/A | Shared external cache for scroll restoration or SSR pre-measurement | | `onChange` | `(state: VirtualizerState) => void` | N/A | Called when the visible window changes; replace through `update()`. | | `onScrollEnd` | `(offset: number) => void` | N/A | Called when scrolling settles; replace through `update()`. | | `onScrollingChange` | `(isScrolling: boolean) => void` | N/A | Called when scroll activity starts or stops; replace through `update()`. | | `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` | N/A | Mark an item as a sticky header (pinned at viewport top) | Callbacks and `scrollEndDelay` can be replaced through `update()`; `horizontal` and `initialOffset` remain construction-only. **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 | | `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called | | `disposed` | `boolean` | `true` after `dispose()` is called | | `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 | | ------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------- | | `getSnapshot` | `() => VirtualizerState` | Return the current coherent state snapshot | | `subscribe` | `(listener: () => void) => () => void` | Observe state changes; returns an unsubscribe function | | `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 | | `remeasure` | `() => void` | Re-read viewport size and scroll offset from the DOM and recompute | | `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 | | `isAtEnd` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end: check before appending items to decide whether to auto-follow (chat "stick to bottom") | | `invalidate` | `() => void` | Clear all measurements and rebuild from estimates | | `dispose` | `() => void` | Detach listeners; idempotent | | `[Symbol.dispose]` | `() => void` | Delegates to `dispose()`: enables `using` declarations | ### `update(next)` Atomically updates one or more live options. Accepts: `autoMeasure`, `count`, `estimateSize`, `gap`, `getItemKey`, `measurementCache`, `onChange`, `onScrollEnd`, `onScrollingChange`, `overscan`, `scrollEndDelay`, and `sticky`. `horizontal` and `initialOffset` remain construction-only. Invalid static numeric values throw `ScrollConfigError` before any update applies. 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. ### `remeasure()` Re-reads the viewport size and scroll offset from the DOM and recomputes the visible window. A `display: none` container reports a zero viewport and the adapter's `ResizeObserver` never fires for the none→visible transition, so callers that toggle visibility (tabs, `v-show`) must call this when showing the container again. ```ts // Re-showing a v-show/hidden scroll container virt.remeasure(); ``` ### `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` | N/A | Stable key; keeps measurements across `setItems()` calls | | `horizontal` | `boolean` | `false` | Virtualize along X axis | | `measurementCache` | `MeasurementCache` | N/A | External measurement cache | | `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric | | `sticky` | `(index: number, item: T) => boolean` | N/A | Mark items as sticky headers | | `clear` | `(listEl: HTMLElement) => void` | N/A | Custom teardown for listEl; defaults to `textContent = ''` | | `stickToBottom` | `boolean \| StickToBottomOptions` | N/A | Auto-scroll to the end after `setItems()` whenever the list was already at (or near) the end: the chat "stick to bottom on new message" pattern | Without `getItemKey`, each `setItems()` call drops cached measurements. ### `StickToBottomOptions` | Option | Type | Default | Description | | ----------- | --------- | ------- | --------------------------------------------------------------------------- | | `enabled` | `boolean` | `true` | Enable/disable at runtime: pass the object form to toggle without removing it | | `threshold` | `number` | `48` | Distance in pixels from the end still considered "at the end" | `stickToBottom` fires on **any** `setItems()` call made while the list is at the end: not just when the item count grows. This also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. It never fires while the user has scrolled away from the end, so reading older messages is never interrupted. ```ts const chat = createDomVirtualList({ estimateSize: 48, getItemKey: (_, m) => m.id, listElement: listEl, render: renderMessages, scrollElement: scrollEl, stickToBottom: true, // or { threshold: 80 } for a larger "still at bottom" tolerance }); chat.setItems(messages); // scrolls to bottom on first load // … later, a new message arrives (or the last one grows while streaming) … chat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom ``` ### `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 | | ------------------ | ------------------------------------------------------------------------------------------- | | `getSnapshot()` | Return the current `VirtualizerState` snapshot | | `subscribe(fn)` | Observe state changes; works before the lazy virtualizer is created | | `setItems(items)` | Set the current item array. Spawns virtualizer on first non-empty call; destroys it on `[]` | | `count` | Current item count (live getter) | | `disposalSignal` | `AbortSignal` aborted on `dispose()` | | `isScrolling` | `true` while the user is scrolling; `false` once settled (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 | | `remeasure` | Re-read viewport size and scroll offset from the DOM and recompute | | `invalidate` | Clear measurements and rebuild from estimates | | `scrollToIndex` | Scroll to an item | | `scrollToOffset` | Scroll to a pixel offset | | `scrollToTop` | Scroll to offset `0` | | `scrollToBottom` | Scroll to the end of the list | | `isAtEnd` | `true` when within `threshold` px of the end | | `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.replaceChildren(); 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` | N/A | Called when the visible window changes; replace through `update()`. | | `onScrollEnd` | `(offset: number) => void` | N/A | Called when scrolling settles; replace through `update()`. | | `onScrollingChange` | `(isScrolling: boolean) => void` | N/A | Called when scroll activity starts or stops; replace through `update()`. | | `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` | N/A | Stable key for measurement cache | | `horizontal` | `boolean` | `false` | Virtualize along X axis | | `measurementCache` | `MeasurementCache` | N/A | 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 | | ---------------------------------- | ------------------------------------------------------------------------ | | `getSnapshot()` | Return the current `GroupVirtualizerState` snapshot | | `subscribe(listener)` | Observe grouped state changes; returns an unsubscribe function | | `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 | | `count` | Total flat item count (live getter) | | `disposalSignal` | `AbortSignal` aborted on `dispose()` | | `isScrolling` | `true` while the user is scrolling; `false` once scroll settles | | `items` | Currently rendered group items (live getter) | | `scrollOffset` | Current scroll position in pixels (live getter) | | `stickyItems` | Sticky items pinned at viewport top (live getter) | | `totalSize` | Total list size 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 | | `onChange` | `(state: GroupVirtualizerState) => void` | Replace the active onChange callback | | `onScrollEnd` | `(offset: number) => void` | Replace the active onScrollEnd callback | | `onScrollingChange` | `(isScrolling: boolean) => void` | Replace the active onScrollingChange callback | | `overscan` | `number \| { start?, end? }` | New overscan count | | `scrollEndDelay` | `number` | New debounce delay (ms) for scroll-end detection | > `horizontal` remains construction-only. ## `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.replaceChildren(); 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` | N/A | Initial vertical scroll position | | `initialScrollLeft` | `number` | N/A | Initial horizontal scroll position | | `onChange` | `(state: GridVirtualizerState) => void` | N/A | Called when the visible window changes | | `onRangeChange` | `(range: GridRangeChangeEvent) => void` | N/A | Zero-allocation range callback | | `rowMeasurementCache` | `Map` | N/A | External row measurement cache | | `colMeasurementCache` | `Map` | N/A | 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`, `disposalSignal`, `disposed` | Method | Description | | ---------------------------------- | --------------------------------------------------------------------------------- | | `getSnapshot()` | Return the current `GridVirtualizerState` snapshot | | `subscribe(listener)` | Observe grid state changes; returns an unsubscribe function | | `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'; } ``` ## Types ### `ScrollStore` ```ts interface ScrollStore { getSnapshot: () => State; subscribe: (listener: () => void) => () => void; } ``` Framework-neutral external-store contract implemented by every Scroll controller. `getSnapshot()` returns the current state. `subscribe()` observes later state changes and returns an unsubscribe function. ### `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 { autoMeasure?: boolean; count?: number; estimateSize?: number | ((index: number) => number); gap?: number; getItemKey?: ((index: number) => VirtualKey) | undefined; /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */ measurementCache?: MeasurementCache; onChange?: ((state: VirtualizerState) => void) | undefined; onScrollEnd?: ((offset: number) => void) | undefined; onScrollingChange?: ((isScrolling: boolean) => void) | undefined; overscan?: Overscan; scrollEndDelay?: number; sticky?: ((index: number) => boolean) | undefined; } ``` ### `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); onChange?: ((state: GridVirtualizerState) => void) | undefined; onRangeChange?: ((range: GridRangeChangeEvent) => void) | undefined; overscanX?: Overscan; overscanY?: Overscan; 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; } ``` ### `VirtualizerOptions` ```ts interface VirtualizerOptions { autoMeasure?: boolean; count: number; estimateSize?: number | ((index: number) => number); gap?: number; getItemKey?: (index: number) => VirtualKey; horizontal?: boolean; initialOffset?: number; measurementCache?: MeasurementCache; onChange?: (state: VirtualizerState) => void; onScrollEnd?: (offset: number) => void; onScrollingChange?: (isScrolling: boolean) => void; overscan?: Overscan; scrollEndDelay?: number; sticky?: (index: number) => boolean; } ``` ### `Virtualizer` ```ts interface Virtualizer extends ScrollStore { readonly count: number; readonly disposalSignal: AbortSignal; dispose: () => void; readonly disposed: boolean; invalidate: () => void; isAtEnd: (threshold?: number) => boolean; readonly isScrolling: boolean; readonly items: VirtualItem[]; measure: (index: number, size: number) => void; measureBatch: (entries: Array) => void; measureEl: (index: number, el: HTMLElement) => () => void; prepend: (additionalCount: number) => void; refresh: () => void; remeasure: () => void; readonly scrollOffset: number; scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void; scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void; scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void; scrollToTop: (options?: { behavior?: ScrollBehavior }) => void; readonly stickyItems: VirtualItem[]; readonly totalSize: number; update: (next: VirtualizerUpdateOptions) => void; [Symbol.dispose]: () => void; } ``` ### `StickToBottomOptions` ```ts type StickToBottomOptions = { enabled?: boolean; threshold?: number; }; ``` ### `DomVirtualListOptions` ```ts type DomVirtualListOptions = { clear?: (listEl: HTMLElement) => void; estimateSize?: number | ((index: number, item: T) => number); gap?: number; getItemKey?: (index: number, item: T) => VirtualKey; horizontal?: boolean; listElement: HTMLElement; measurementCache?: MeasurementCache; overscan?: Overscan; render: (args: DomVirtualListRenderArgs) => void; scrollElement: HTMLElement | Window; stickToBottom?: boolean | StickToBottomOptions; sticky?: (index: number, item: T) => boolean; }; ``` ### `DomVirtualListController` `Virtualizer` minus `prepend` and `update`, plus `setItems()`. ```ts type DomVirtualListController = Omit & { setItems: (items: T[]) => void; }; ``` ### `DomVirtualListRenderArgs` ```ts type DomVirtualListRenderArgs = { items: Array>; listEl: HTMLElement; recycle: RecycleFn; stickyItems: Array>; totalSize: number; }; ``` ### `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; } ``` ### `GroupVirtualItem` ```ts interface GroupVirtualItem extends VirtualItem { data: T; itemIndex: number; sectionIndex: number; } ``` ### `GroupVirtualHeader` ```ts interface GroupVirtualHeader extends VirtualItem { label: string; sectionIndex: number; } ``` ### `GroupVirtualizerOptions` ```ts interface GroupVirtualizerOptions { estimateHeaderSize?: number | ((section: GroupSection, sectionIndex: number) => number); estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number); getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey; horizontal?: boolean; measurementCache?: MeasurementCache; onChange?: (state: GroupVirtualizerState) => void; onScrollEnd?: (offset: number) => void; onScrollingChange?: (isScrolling: boolean) => void; overscan?: Overscan; scrollEndDelay?: number; sections: Array>; } ``` ### `GroupVirtualizerUpdateOptions` ```ts interface GroupVirtualizerUpdateOptions { estimateHeaderSize?: number | ((section: GroupSection, sectionIndex: number) => number); estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number); getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey; measurementCache?: MeasurementCache; onChange?: ((state: GroupVirtualizerState) => void) | undefined; onScrollEnd?: ((offset: number) => void) | undefined; onScrollingChange?: ((isScrolling: boolean) => void) | undefined; overscan?: Overscan; scrollEndDelay?: number; } ``` ### `GroupVirtualizer` ```ts interface GroupVirtualizer extends ScrollStore> { readonly count: number; readonly disposalSignal: AbortSignal; dispose: () => void; readonly disposed: boolean; invalidate: () => void; readonly isScrolling: boolean; readonly items: ReadonlyArray>; measure: (index: number, size: number) => void; measureBatch: (entries: Array) => void; measureEl: (index: number, el: HTMLElement) => () => void; refresh: () => void; readonly scrollOffset: number; scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void; scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void; scrollToItem: (sectionIndex: number, itemIndex: number, options?: ScrollToIndexOptions) => void; scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void; scrollToSection: (sectionIndex: number, options?: ScrollToIndexOptions) => void; scrollToTop: (options?: { behavior?: ScrollBehavior }) => void; readonly stickyItems: VirtualItem[]; readonly totalSize: number; update: (sections: Array>, opts?: GroupVirtualizerUpdateOptions) => void; [Symbol.dispose]: () => void; } ``` ### `GridVirtualizerState` ```ts interface GridVirtualizerState { readonly cols: VirtualItem[]; readonly rows: VirtualItem[]; readonly totalHeight: number; readonly totalWidth: number; } ``` ### `ScrollToCellOptions` ```ts interface ScrollToCellOptions { behavior?: ScrollBehavior; colAlign?: 'auto' | 'center' | 'end' | 'start'; rowAlign?: 'auto' | 'center' | 'end' | 'start'; } ``` ### `GridVirtualizerOptions` ```ts interface GridVirtualizerOptions { colCount: number; colGap?: number; colMeasurementCache?: Map; estimateColSize?: number | ((col: number) => number); estimateRowSize?: number | ((row: number) => number); initialScrollLeft?: number; initialScrollTop?: number; onChange?: (state: GridVirtualizerState) => void; onRangeChange?: (range: GridRangeChangeEvent) => void; overscanX?: Overscan; overscanY?: Overscan; rowCount: number; rowGap?: number; rowMeasurementCache?: Map; } ``` ### `GridVirtualizer` ```ts interface GridVirtualizer extends ScrollStore { readonly cols: VirtualItem[]; readonly disposalSignal: AbortSignal; dispose: () => void; readonly disposed: boolean; invalidate: () => void; measureBatch: (rows: Array, cols: Array) => void; measureColEl: (col: number, el: HTMLElement) => () => void; measureColumn: (col: number, size: number) => void; measureRow: (row: number, size: number) => void; measureRowEl: (row: number, el: HTMLElement) => () => void; prependRows: (additionalRowCount: number) => void; refresh: () => void; readonly rows: VirtualItem[]; readonly scrollLeft: number; scrollToCell: (row: number, col: number, options?: ScrollToCellOptions) => void; scrollToColumn: (col: number, options?: Pick) => void; readonly scrollTop: number; scrollToRow: (row: number, options?: Pick) => void; readonly totalHeight: number; readonly totalWidth: number; update: (next: GridVirtualizerUpdateOptions) => void; [Symbol.dispose]: () => void; } ``` ## Errors | Class | Thrown when | Notable properties | | --- | --- | --- | | `ScrollError` | Base class for every Scroll error. | Use `instanceof ScrollError` to narrow unknown errors narrows errors from this package. | | `ScrollConfigError` | A constructor or `update()` receives invalid static configuration. | Extends `ScrollError`; malformed JavaScript values also use this class. | | `ScrollRangeError` | A DOM virtual-list render detects that a caller mutated its items array without calling `setItems()` again. | Extends `ScrollError`; message includes stale index and current item count. | Runtime estimator failures, stale measurements, and out-of-range navigation remain resilient: they fall back, no-op, or clamp as documented. ### 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.replaceChildren(); 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.replaceChildren(); 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.replaceChildren(); 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.textContent = rows[item.index].body; 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 data or render strategy changes, call `update()` with one or more option fields. Updates apply atomically and trigger re-render when needed. Counts, gaps, and overscan must be finite non-negative integers; numeric size estimates must be finite positive values; offsets and `scrollEndDelay` must be finite non-negative numbers. Invalid constructor or `update()` values throw `ScrollConfigError` before any change applies. Runtime layout data stays resilient: estimator callbacks that throw or return invalid sizes fall back to the default estimate, stale measurements are ignored, and out-of-range navigation clamps or no-ops. ```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' }); ``` ### Chat "stick to bottom on new message" `createDomVirtualList`'s `stickToBottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read. ```ts import { createDomVirtualList } from '@vielzeug/scroll'; const chat = createDomVirtualList({ estimateSize: 48, getItemKey: (_, m) => m.id, listElement: listEl, render: renderMessages, scrollElement: scrollEl, stickToBottom: true, // or { threshold: 80 } to widen the "still at bottom" tolerance }); chat.setItems(messages); // New message arrives: follows only if the user hasn't scrolled up. socket.on('message', (msg) => { messages = [...messages, msg]; chat.setItems(messages); }); ``` It also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged): every `setItems()` call re-checks "was the list at the end before this update?", not just count changes. Build `isAtEnd()` from `createVirtualizer` directly for custom cases (e.g. showing a "jump to latest" button only while scrolled away): ```ts const showJumpButton = !virt.isAtEnd(); ``` ## Infinite Scroll: Loading More at the End Use `isAtEnd(threshold)` to fetch the next page as the user nears the bottom. `isAtEnd()` reports scroll position only: it keeps returning `true` while a fetch is in flight, so guard it with your own `loading` flag to avoid firing the same request twice. ```ts import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll'; let rows = await fetchPage(0); let loading = false; let virt: Virtualizer; virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.replaceChildren(); 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); } if (!loading && virt.isAtEnd(200)) { loading = true; fetchPage(rows.length).then((nextRows) => { rows = [...rows, ...nextRows]; virt.update({ count: rows.length }); loading = false; }); } }, }); ``` `isAtEnd(200)` fires once the viewport is within 200px of the bottom: tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re-triggers `onChange`, which re-checks `isAtEnd()` against the new total on the next scroll. ## 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 ``` ## Keyboard Navigation Scroll ships no built-in key handler: keyboard scrolling is navigation policy, and the virtualizer already exposes everything it needs: `scrollToIndex()`. Compose it with your own focus tracking, or with `matchKey` from `@vielzeug/keymap` for one key vocabulary across the app: ```ts import { createVirtualizer } from '@vielzeug/scroll'; import { matchKey } from '@vielzeug/keymap'; let focusedIndex = 0; scrollEl.addEventListener('keydown', (event) => { if (matchKey(event, 'ArrowDown')) focusedIndex = Math.min(focusedIndex + 1, count - 1); else if (matchKey(event, 'ArrowUp')) focusedIndex = Math.max(focusedIndex - 1, 0); else if (matchKey(event, 'Home')) focusedIndex = 0; else if (matchKey(event, 'End')) focusedIndex = count - 1; else return; event.preventDefault(); virt.scrollToIndex(focusedIndex, { align: 'auto' }); }); ``` **Requirements:** - The scroll container (or a descendant) must have keyboard focus for events to fire: give it `tabindex="0"` - `align: 'auto'` scrolls only when the item is outside the visible area See the [Keyboard Navigation example](./examples/keyboard-navigation.md) for the full pattern with focus highlighting. ## Auto-Measurement Enable automatic item measurement for dynamic or user-generated content that changes size. When `autoMeasure` is enabled, the virtualizer measures visible items via `ResizeObserver` and updates layout in real time. ```ts const virt = createVirtualizer(scrollEl, { count: messages.length, estimateSize: 36, // Initial guess; will be measured autoMeasure: true, // Automatically measure visible items onChange: ({ items, totalSize }) => { listEl.style.height = `${totalSize}px`; listEl.replaceChildren(); for (const item of items) { const el = document.createElement('div'); // IMPORTANT: Set data-vz-key for auto-measure to find the element el.setAttribute('data-vz-key', String(item.index)); el.textContent = messages[item.index]?.text ?? ''; listEl.appendChild(el); } }, }); ``` **Requirements:** - Every rendered item must have a `data-vz-key` attribute with a unique value - Must use a DOM scroll target (not `Window`) - Elements must be in the DOM by the time `ResizeObserver` fires (usually the next microtask) **Use cases:** - Chat lists where messages expand on load - Expandable sections with collapsing text - Lazy-loaded thumbnails that arrive with unknown heights - User-resizable rows or dynamic content (videos, iframes) **Performance notes:** - Auto-measurement queries the DOM every render cycle: avoid with very large visible windows (100+ items) - For finer control, use the manual `measureEl()` method instead - Enable only on lists with truly variable-height items ## Reactive Integration Every Scroll controller is a framework-neutral external store. Read its current state with `getSnapshot()` and observe future changes with `subscribe()`. ```ts const virt = createVirtualizer(scrollEl, { count: 1000, estimateSize: 36, }); const unsubscribe = virt.subscribe(() => { const { items, totalSize } = virt.getSnapshot(); console.log(`Visible: ${items.length} items, total height: ${totalSize}px`); }); unsubscribe(); virt.dispose(); ``` Bridge the same controller into Ripple without adding Ripple as a Scroll dependency: ```ts import { effect, fromSubscribable } from '@vielzeug/ripple'; import { createVirtualizer } from '@vielzeug/scroll'; const virt = createVirtualizer(scrollEl, { count: 1000, estimateSize: 36, }); const scrollState = fromSubscribable(virt, { signal: virt.disposalSignal }); const renderEffect = effect(() => { const { items, totalSize } = scrollState.value; console.log(`Visible: ${items.length} items, total height: ${totalSize}px`); }); renderEffect.dispose(); virt.dispose(); ``` The same contract works with `createDomVirtualList()`, `createVirtualScroller()`, `createGroupedVirtualizer()`, and `createGridVirtualizer()`. ## 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, useLayoutEffect, 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.replaceChildren(); 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 // useLayoutEffect, not useEffect: syncs count before paint. With useEffect, // the DOM (and anything reading `rows`) paints once with the new length before // the virtualizer's internal count catches up, which can render stale/out-of-bounds indices. useLayoutEffect(() => { 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.replaceChildren(); 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.replaceChildren(); 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.replaceChildren(); 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. - **React:** Use `useLayoutEffect`, not `useEffect`, for the `count`-sync effect. `useEffect` fires after paint: a new `count` can reach the DOM (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out-of-bounds indices for one frame. - **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.replaceChildren(); for (const item of items) { const row = document.createElement('div'); row.style.cssText = `position:absolute;top:${item.start}px;height:40px;`; row.textContent = `Row ${item.index}`; listEl.appendChild(row); } }, }, }, }); 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/sentinel **Category:** Environment ### Overview ## Why Sentinel? Browser environment APIs use different events, observer callbacks, initial states, and cleanup methods. Sentinel gives them one explicit external-store shape with snapshot reads, subscriptions, and disposal. ```ts // Before { const panel = document.querySelector('[data-panel]'); if (!panel) throw new Error('Panel not found'); const observer = new ResizeObserver(([entry]) => { console.log(entry?.contentRect.width); }); observer.observe(panel); // Later observer.disconnect(); } // After import { createElementSize } from '@vielzeug/sentinel'; { const panel = document.querySelector('[data-panel]'); if (!panel) throw new Error('Panel not found'); const size = createElementSize(panel); const unsubscribe = size.subscribe(() => { console.log(size.getSnapshot()?.width); }); // Later unsubscribe(); size.dispose(); } ``` | Feature | Sentinel | Native observer APIs | Ad hoc event listeners | | --- | --- | --- | --- | | Bundle size | | Built in | Application-defined | | Zero dependencies | | | | | Subscribable current snapshot | | | | | Consistent disposable handle | | | | | Shared abort ownership | | | | | Ripple composition | | | | **Use Sentinel when** browser or DOM observations need consistent snapshot reads, subscriptions, and ownership. **Consider native APIs when** one isolated observer is sufficient and a shared lifecycle abstraction adds no value. ## Installation ```sh [pnpm] pnpm add @vielzeug/sentinel ``` ```sh [npm] npm install @vielzeug/sentinel ``` ```sh [yarn] yarn add @vielzeug/sentinel ``` ## Quick Start Create a viewport Sentinel, render its initial state, then react to changes until the page lifetime ends. ```ts import { createViewport } from '@vielzeug/sentinel'; function observeViewport(): () => void { const viewport = createViewport(); const render = () => { const { dpr, height, width } = viewport.getSnapshot(); console.log(`${width}×${height} at ${dpr}dpr`); }; render(); const unsubscribe = viewport.subscribe(render); return () => { unsubscribe(); viewport.dispose(); }; } const stopObserving = observeViewport(); // Call stopObserving() when the owning view unmounts. ``` ## Features - `createViewport()`: Observe viewport dimensions and device pixel ratio. - `createNetwork()`: Track online status and optional connection details. - `createMediaQuery()`: Observe one media query. - `createElementSize()`: Read content-box dimensions from `ResizeObserver`. - `createIntersection()`: Track normalized intersection state. - `createWakeLock()`: Prevent the screen from sleeping during active use. - `createFullscreen()`: Track and drive document fullscreen state. - `dispose()`: Release owned browser observers and listeners. - `SentinelOptions.signal`: Abort several Sentinels through one external lifetime. ## Documentation - [**Usage Guide**](./usage.md) - [**API Reference**](./api.md) - [**Examples**](./examples.md) - [**3.0 Migration Guide**](./migration.md) ## See Also - [@vielzeug/ripple](../ripple/): Derive and watch values from Sentinel state. - [@vielzeug/ore](../ore/): Bind Sentinels to web-component mount and cleanup lifecycles. - [@vielzeug/focus](../focus/): Manage keyboard focus alongside observed UI state. - [@vielzeug/gesture](../gesture/): Handle pointer gestures alongside environmental observations. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createViewport()` | Observe layout viewport dimensions and device pixel ratio | Sync | Requires a browser Window | | `createNetwork()` | Observe online status and optional connection information | Sync | `connection` is often `null` | | `createMediaQuery()` | Observe one media query | Sync | Throws when `matchMedia` is unavailable | | `createElementSize()` | Observe element content-box dimensions | Sync | Value is `null` before the first delivery | | `createIntersection()` | Observe element intersection state | Sync | Value is `null` before the first delivery | | `createWakeLock()` | Prevent the screen from sleeping | Async | Degrades silently when the Wake Lock API is unavailable | | `createFullscreen()` | Track and drive document fullscreen state | Sync | Degrades silently when the Fullscreen API is unavailable | | `Sentinel` | Expose an external-store snapshot with explicit browser-resource ownership | Sync | Call `getSnapshot()` inside subscription listeners | | `SentinelError` | Base class for package-defined errors | Sync | Catch a subtype when recovery is specific | | `SentinelUnavailableError` | Report an unavailable browser API | Sync | Invalid observer inputs retain their native errors | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/sentinel` | All factories, state types, option types, and error classes | ## Factories ### `createViewport()` ```ts function createViewport(options?: WindowSentinelOptions): Sentinel; ``` Returns a Sentinel initialized from the layout viewport's `innerWidth`, `innerHeight`, and `devicePixelRatio`. | Parameter | Type | Description | | --- | --- | --- | | `options.target` | `Window` | Window to observe instead of the global browser window | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `Sentinel`. **Example** ```ts import { createViewport } from '@vielzeug/sentinel'; const viewport = createViewport(); console.log(viewport.getSnapshot().width); viewport.dispose(); ``` --- ### `createNetwork()` ```ts function createNetwork(options?: WindowSentinelOptions): Sentinel; ``` Returns a Sentinel initialized from `navigator.onLine` and the optional Network Information API. | Parameter | Type | Description | | --- | --- | --- | | `options.target` | `Window` | Window whose navigator and events are observed | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `Sentinel`. **Example** ```ts import { createNetwork } from '@vielzeug/sentinel'; const network = createNetwork(); console.log(network.getSnapshot().online); network.dispose(); ``` --- ### `createMediaQuery()` ```ts function createMediaQuery(query: string, options?: WindowSentinelOptions): Sentinel; ``` Returns a Sentinel initialized from `matchMedia(query).matches`. | Parameter | Type | Description | | --- | --- | --- | | `query` | `string` | CSS media query to observe | | `options.target` | `Window` | Window whose `matchMedia` method is used | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `Sentinel`. **Example** ```ts import { createMediaQuery } from '@vielzeug/sentinel'; const darkMode = createMediaQuery('(prefers-color-scheme: dark)'); console.log(darkMode.getSnapshot().matches); darkMode.dispose(); ``` --- ### `createElementSize()` ```ts function createElementSize(element: Element, options?: SentinelOptions): Sentinel; ``` Returns a Sentinel containing the latest `ResizeObserverEntry.contentRect` dimensions. | Parameter | Type | Description | | --- | --- | --- | | `element` | `Element` | Element to observe | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `Sentinel`. The initial value is `null`. **Example** ```ts import { createElementSize } from '@vielzeug/sentinel'; const size = createElementSize(document.body); const unsubscribe = size.subscribe(() => { console.log(size.getSnapshot()?.width); }); unsubscribe(); size.dispose(); ``` --- ### `createIntersection()` ```ts function createIntersection( element: Element, options?: CreateIntersectionOptions, ): Sentinel; ``` Returns a Sentinel containing normalized fields from the latest IntersectionObserver entry. | Parameter | Type | Description | | --- | --- | --- | | `element` | `Element` | Element to observe | | `options.root` | `Element \| Document \| null` | Intersection root | | `options.rootMargin` | `string` | Margin applied to the root | | `options.scrollMargin` | `string` | Margin applied to nested scroll containers | | `options.threshold` | `number \| number[]` | Intersection ratio threshold or thresholds | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `Sentinel`. The initial value is `null`. **Example** ```ts import { createIntersection } from '@vielzeug/sentinel'; const intersection = createIntersection(document.body, { threshold: 0.5 }); const unsubscribe = intersection.subscribe(() => { console.log(intersection.getSnapshot()?.isIntersecting); }); unsubscribe(); intersection.dispose(); ``` --- ### `createWakeLock()` ```ts function createWakeLock(options?: SentinelOptions & { readonly target?: Window }): WakeLockSentinel; ``` Returns a Sentinel-backed screen wake lock. `request()` acquires the lock; `release()` frees it. When the tab is hidden the browser auto-releases the lock; the Sentinel re-acquires it automatically on `visibilitychange` if the lock was active. Degrades silently when the Screen Wake Lock API is unavailable. | Parameter | Type | Description | | --- | --- | --- | | `options.target` | `Window` | Window whose document and navigator are used | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `WakeLockSentinel`. **Example** ```ts import { createWakeLock } from '@vielzeug/sentinel'; const wakeLock = createWakeLock(); wakeLock.request(); const unsubscribe = wakeLock.subscribe(() => { console.log('Wake lock active:', wakeLock.getSnapshot().active); }); // Later wakeLock.release(); unsubscribe(); wakeLock.dispose(); ``` --- ### `createFullscreen()` ```ts function createFullscreen(options?: SentinelOptions & { readonly target?: Window }): FullscreenSentinel; ``` Returns a Sentinel-backed fullscreen view. `request()` enters fullscreen for an element (default: the document element), `exit()` leaves it, and `toggle()` flips it. All degrade silently when the Fullscreen API is missing or the browser refuses (user-activation requirements). Disposing the sentinel exits fullscreen and stops tracking `fullscreenchange`. | Parameter | Type | Description | | --- | --- | --- | | `options.target` | `Window` | Window whose document is tracked | | `options.signal` | `AbortSignal` | External signal that disposes the Sentinel | **Returns:** `FullscreenSentinel`. **Example** ```ts import { createFullscreen } from '@vielzeug/sentinel'; const fullscreen = createFullscreen(); fullscreen.request(); const unsubscribe = fullscreen.subscribe(() => { console.log('Fullscreen active:', fullscreen.getSnapshot().active); }); // Later fullscreen.toggle(); unsubscribe(); fullscreen.dispose(); ``` ## Types ### `Sentinel` ```ts interface Sentinel extends Subscribable, Disposable { tap(handler: (event: SentinelEvent) => void, options?: { signal?: AbortSignal }): () => void; } interface Subscribable { getSnapshot(): T; subscribe(listener: () => void): () => void; } ``` `Sentinel` follows the standard external-store snapshot/subscription contract and owns the underlying browser resource. `getSnapshot()`, `subscribe()`, and `dispose()` are stable callback-safe functions. `dispose()` stops observation, clears listeners, and aborts `disposalSignal`. | Member | Type | Description | | --- | --- | --- | | `getSnapshot()` | `() => T` | Return the current immutable snapshot | | `subscribe(listener)` | `(listener: () => void) => () => void` | Subscribe to snapshot changes and return an unsubscribe function | | `disposed` | `boolean` | Whether observation has ended | | `disposalSignal` | `AbortSignal` | Aborts when observation ends | | `dispose()` | `() => void` | Stop observation and release owned browser resources | | `tap(handler, options?)` | `(handler: (event: SentinelEvent) => void, options?: { signal?: AbortSignal }) => () => void` | Observe `{ type: 'error' }` when a listener throws and `{ type: 'dispose' }` on disposal; handler errors are swallowed | | `[Symbol.dispose]()` | `() => void` | Dispose through the explicit resource-management protocol | --- ### `SentinelOptions` ```ts interface SentinelOptions { readonly signal?: AbortSignal; } ``` `options.signal` disposes the Sentinel when it aborts. Passing an already-aborted signal throws `SentinelError` instead of constructing a dead handle. --- ### `createSentinel()` ```ts function createSentinel( options: CreateSentinelOptions, setup: (update: (value: T) => void) => () => void, ): Sentinel ``` The generic primitive every built-in sensor is built on. `setup` starts observing and returns its cleanup; it calls `update(value)` whenever the underlying source changes. Use it for platform sensors the built-ins do not cover (for example `prefers-reduced-motion`). --- ### `WindowSentinelOptions` ```ts interface WindowSentinelOptions extends SentinelOptions { readonly target?: Window; } ``` --- ### `CreateIntersectionOptions` ```ts interface CreateIntersectionOptions extends SentinelOptions { readonly root?: Element | Document | null; readonly rootMargin?: string; readonly scrollMargin?: string; readonly threshold?: number | number[]; } ``` --- ### `ViewportState` ```ts interface ViewportState { readonly dpr: number; readonly height: number; readonly width: number; } ``` --- ### `NetworkConnectionSnapshot` ```ts interface NetworkConnectionSnapshot { readonly downlink?: number; readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; readonly rtt?: number; readonly saveData?: boolean; } ``` --- ### `NetworkState` ```ts interface NetworkState { readonly connection: NetworkConnectionSnapshot | null; readonly online: boolean; } ``` --- ### `MediaQueryState` ```ts interface MediaQueryState { readonly matches: boolean; } ``` --- ### `ElementSizeState` ```ts interface ElementSizeState { readonly height: number; readonly width: number; } ``` --- ### `IntersectionState` ```ts interface IntersectionState { readonly intersectionRatio: number; readonly isIntersecting: boolean; } ``` --- ### `WakeLockState` ```ts interface WakeLockState { readonly active: boolean; readonly supported: boolean; } ``` | Field | Type | Description | | --- | --- | --- | | `active` | `boolean` | Whether a wake lock is currently held | | `supported` | `boolean` | Whether the Screen Wake Lock API is available in this environment | --- ### `WakeLockSentinel` ```ts interface WakeLockSentinel extends Sentinel { request(): void; release(): void; } ``` A `Sentinel` with two additional methods: | Member | Type | Description | | --- | --- | --- | | `request()` | `() => void` | Acquire the screen wake lock. No-op if already active or unsupported | | `release()` | `() => void` | Release the wake lock. No-op if not active | --- ### `FullscreenState` ```ts interface FullscreenState { readonly active: boolean; readonly supported: boolean; } ``` | Property | Type | Description | | --- | --- | --- | | `active` | `boolean` | Whether an element is currently shown fullscreen | | `supported` | `boolean` | Whether the Fullscreen API is available in this environment | --- ### `FullscreenSentinel` ```ts interface FullscreenSentinel extends Sentinel { request(element?: Element): void; exit(): void; toggle(element?: Element): void; } ``` A `Sentinel` with three additional methods: | Member | Type | Description | | --- | --- | --- | | `request(element?)` | `() => void` | Enter fullscreen for `element` (default: the document element). No-op when already active or unsupported | | `exit()` | `() => void` | Leave fullscreen. No-op when not active | | `toggle(element?)` | `() => void` | Flip fullscreen for `element` (default: the document element) | ## Errors ### `SentinelError` ```ts class SentinelError extends Error { constructor(message: string, options?: ErrorOptions); } ``` Base class for package-defined errors. --- ### `SentinelUnavailableError` ```ts class SentinelUnavailableError extends SentinelError {} ``` Thrown when a required browser API or Window is unavailable: - `createViewport()` and `createNetwork()` when no browser Window is available. - `createMediaQuery()` when `matchMedia` is unavailable. - `createElementSize()` when the element has no Window or `ResizeObserver` is unavailable. - `createIntersection()` when the element has no Window or `IntersectionObserver` is unavailable. - `createWakeLock()` when no browser Window is available. - `createFullscreen()` when no browser Window is available. Native setup errors remain unchanged, including invalid observer options or targets. ### Usage Guide ## Basic Usage Create a Sentinel, read its current state, subscribe to invalidations, and release both resources when the owner ends. ```ts import { createViewport } from '@vielzeug/sentinel'; function observeViewport(): () => void { const viewport = createViewport(); const render = () => { const { dpr, height, width } = viewport.getSnapshot(); console.log(`${width}×${height} at ${dpr}dpr`); }; render(); const unsubscribe = viewport.subscribe(render); return () => { unsubscribe(); viewport.dispose(); }; } const stopObserving = observeViewport(); // Call stopObserving() when the owning view unmounts. ``` `subscribe()` notifies you that the snapshot changed; call `getSnapshot()` inside the listener to read it. Disposing a Sentinel stops its browser observer, removes its listeners, and aborts `disposalSignal`. ## Observe Window State Use `createViewport()` for viewport dimensions and device pixel ratio. ```ts import { createViewport } from '@vielzeug/sentinel'; const viewport = createViewport(); console.log(viewport.getSnapshot().width); console.log(viewport.getSnapshot().height); console.log(viewport.getSnapshot().dpr); ``` Use `createNetwork()` for online status and the optional Network Information API snapshot. ```ts import { createNetwork } from '@vielzeug/sentinel'; const network = createNetwork(); console.log(network.getSnapshot().online); console.log(network.getSnapshot().connection); ``` `connection` is `null` when `navigator.connection` is unavailable. ## Observe Media Queries Use `createMediaQuery()` to react to a browser media query. ```ts import { createMediaQuery, SentinelUnavailableError } from '@vielzeug/sentinel'; function observeReducedMotion(): () => void { try { const reducedMotion = createMediaQuery('(prefers-reduced-motion: reduce)'); const applyPreference = () => { document.documentElement.classList.toggle('reduce-motion', reducedMotion.getSnapshot().matches); }; applyPreference(); const unsubscribe = reducedMotion.subscribe(applyPreference); return () => { unsubscribe(); reducedMotion.dispose(); }; } catch (error) { if (!(error instanceof SentinelUnavailableError)) throw error; return () => {}; } } const stopObserving = observeReducedMotion(); // Call stopObserving() when the owning view unmounts. ``` `createMediaQuery()` throws `SentinelUnavailableError` when `matchMedia` is unavailable. ## Observe Elements ### Element Size Use `createElementSize()` after the target element exists. ```ts import { createElementSize } from '@vielzeug/sentinel'; const panel = document.querySelector('[data-panel]'); if (!panel) throw new Error('Panel not found'); const size = createElementSize(panel); const unsubscribe = size.subscribe(() => { const current = size.getSnapshot(); if (current) panel.dataset.width = String(current.width); }); ``` The initial state is `null` until `ResizeObserver` reports its first measurement. ### Intersection Use `createIntersection()` to observe visibility relative to the viewport or a custom root. ```ts import { createIntersection } from '@vielzeug/sentinel'; const target = document.querySelector('[data-lazy-section]'); if (!target) throw new Error('Section not found'); const intersection = createIntersection(target, { rootMargin: '100px', threshold: [0, 0.5, 1], }); const unsubscribe = intersection.subscribe(() => { target.hidden = !intersection.getSnapshot()?.isIntersecting; }); ``` The initial state is `null` until `IntersectionObserver` reports its first entry. ## Control Ownership Call `dispose()` to stop observation. Disposal is idempotent. ```ts const viewport = createViewport(); viewport.dispose(); viewport.dispose(); ``` Pass an `AbortSignal` when several Sentinels share one lifetime. ```ts const controller = new AbortController(); const viewport = createViewport({ signal: controller.signal }); const network = createNetwork({ signal: controller.signal }); controller.abort(); ``` Sentinel follows the standard external-store shape, so reactive libraries can bridge it without a Sentinel-specific adapter. ## Prevent Screen Sleep Use `createWakeLock()` to keep the screen awake during active use: for example, during a game, presentation, or long-running task. ```ts import { createWakeLock } from '@vielzeug/sentinel'; const wakeLock = createWakeLock(); wakeLock.request(); const unsubscribe = wakeLock.subscribe(() => { console.log('Wake lock active:', wakeLock.getSnapshot().active); }); // Later: release the lock and dispose wakeLock.release(); unsubscribe(); wakeLock.dispose(); ``` The lock auto-releases when the tab is hidden (the browser does this). The Sentinel re-acquires it automatically when the tab becomes visible again if `request()` was called and `release()` was not. Check `getSnapshot().supported` before acquiring if you need to show different UI when the Wake Lock API is unavailable. ```ts const wakeLock = createWakeLock(); if (wakeLock.getSnapshot().supported) { wakeLock.request(); } ``` ## Track Fullscreen State Use `createFullscreen()` to observe and drive document fullscreen state: for example, immersing a board or map view: ```ts import { createFullscreen } from '@vielzeug/sentinel'; const fullscreen = createFullscreen(); const unsubscribe = fullscreen.subscribe(() => { fullscreenButton.pressed = fullscreen.getSnapshot().active; }); fullscreenButton.addEventListener('click', () => fullscreen.toggle()); ``` `request()`, `exit()`, and `toggle()` degrade silently when the Fullscreen API is unavailable or the browser refuses (user activation is required in some browsers). Disposing the sentinel exits fullscreen, so leaving the owning view releases it. ## Handle Unavailable APIs `createMediaQuery()`, `createElementSize()`, and `createIntersection()` report unavailable platform APIs with `SentinelUnavailableError`. ```ts import { createElementSize, SentinelUnavailableError } from '@vielzeug/sentinel'; try { const size = createElementSize(document.body); size.dispose(); } catch (error) { if (error instanceof SentinelUnavailableError) { console.warn(error.message); } else { throw error; } } ``` Invoke all factories only in a browser client lifecycle. Package imports are safe during SSR, but factories require browser or DOM APIs. ## Framework Integration Sentinel's callback-safe external-store methods integrate directly with framework subscription APIs. Create browser-bound Sentinels in a client owner and dispose them when that owner ends. ```tsx [React] import { type Sentinel, type ViewportState } from '@vielzeug/sentinel'; import { useSyncExternalStore } from 'react'; type ViewportSizeProps = { readonly viewport: Sentinel; }; export function ViewportSize({ viewport }: ViewportSizeProps) { const viewportState = useSyncExternalStore(viewport.subscribe, viewport.getSnapshot); return {`${viewportState.width}×${viewportState.height}`}; } ``` ```vue [Vue 3] import { createViewport, type Sentinel, type ViewportState } from '@vielzeug/sentinel'; import { onMounted, onUnmounted, ref } from 'vue'; const viewportState = ref(null); let viewport: Sentinel | undefined; let unsubscribe: (() => void) | undefined; onMounted(() => { viewport = createViewport(); const update = () => { viewportState.value = viewport?.getSnapshot() ?? null; }; update(); unsubscribe = viewport.subscribe(update); }); onUnmounted(() => { unsubscribe?.(); viewport?.dispose(); }); {{ viewportState ? `${viewportState.width}×${viewportState.height}` : 'Measuring…' }} ``` ```svelte [Svelte] import { createViewport, type ViewportState } from '@vielzeug/sentinel'; import { onMount } from 'svelte'; let viewportState: ViewportState | null = null; onMount(() => { const viewport = createViewport(); const update = () => { viewportState = viewport.getSnapshot(); }; update(); const unsubscribe = viewport.subscribe(update); return () => { unsubscribe(); viewport.dispose(); }; }); {viewportState ? `${viewportState.width}×${viewportState.height}` : 'Measuring…'} ``` ## Working with Other Vielzeug Libraries ### Sentinel + Ripple Bridge Sentinel stores with `fromSubscribable()` before deriving reactive values. Dispose the watcher separately; disposing each Sentinel also disposes its bridge through `disposalSignal`. ```ts import { computed, fromSubscribable, watch } from '@vielzeug/ripple'; import { createMediaQuery, createViewport } from '@vielzeug/sentinel'; const viewport = createViewport(); const mobileQuery = createMediaQuery('(max-width: 768px)'); const viewportState = fromSubscribable(viewport, { signal: viewport.disposalSignal }); const mobileState = fromSubscribable(mobileQuery, { signal: mobileQuery.disposalSignal }); const compact = computed(() => mobileState.value.matches || viewportState.value.width console.log('Compact layout:', value), { immediate: true }); compactWatcher.dispose(); mobileQuery.dispose(); viewport.dispose(); ``` ### Sentinel + Ore Create DOM-dependent Sentinels in `onMounted()` and register both subscription and Sentinel cleanup with the component. ```ts import { define, html, onCleanup, onMounted, ref } from '@vielzeug/ore'; import { createElementSize } from '@vielzeug/sentinel'; define('measured-panel', { setup() { const panel = ref(); onMounted(() => { const element = panel.value; if (!element) return; const size = createElementSize(element); const update = () => { element.dataset.width = String(size.getSnapshot()?.width ?? 0); }; const unsubscribe = size.subscribe(update); onCleanup(() => { unsubscribe(); size.dispose(); }); }); return html`Measured panel`; }, }); ``` ## Best Practices - **Create** DOM-dependent Sentinels only after their target elements exist. - **Read** the latest snapshot with `getSnapshot()` inside subscription listeners. - **Unsubscribe** Ripple listeners when their owner ends. - **Dispose** every Sentinel to release browser observers and event listeners. - **Share** an `AbortSignal` when multiple Sentinels have the same lifetime. - **Guard** APIs that can throw `SentinelUnavailableError`. - **Treat** `NetworkState.connection` as optional browser enhancement data. - **Invoke** factories only in browser client lifecycles. ### Examples ## Examples - [Responsive Viewport Tracking](./examples/responsive-viewport-tracking.md) - [Monitor Network Condition](./examples/monitor-network-condition.md) - [Respect Reduced Motion Preference](./examples/respect-reduced-motion-preference.md) - [Responsive Column Layout](./examples/responsive-column-layout.md) - [Lazy Load Images on Intersection](./examples/lazy-load-images-on-intersection.md) - [Prevent Screen Sleep](./examples/prevent-screen-sleep.md) ### REPL Examples - createViewport - Basic (id: `viewport-basic`) --- ## @vielzeug/sigil **Category:** utilities ### Overview ## Why Sigil? QR workflows usually pull in a general-purpose barcode library that renders to canvas and guesses at colors, or a from-scratch decoder you do not need. Sigil is a pure, zero-dependency encoder (numeric, alphanumeric, and byte modes; versions 1–40; all four error-correction levels; automatic mask selection) with renderers that stay out of the way: an SVG string that themes with CSS, and a canvas painter for print/bitmap flows. Scanning is a thin lifecycle wrapper over the platform's native `BarcodeDetector`, so there is no decoder weight and no polyfill to ship. ```ts // Before: a QR library per concern, canvas-only output, hardcoded colors import QRCode from 'qrcode'; const dataUrl = await QRCode.toDataURL(text, { width: 256, color: { dark: '#000' } }); img.src = dataUrl; // raster, fixed colors, no theme support // After: encode once, render anywhere import { encodeQr, toSvg } from '@vielzeug/sigil'; const matrix = encodeQr(text); // smallest version that fits, auto mask element.innerHTML = toSvg(matrix); // currentColor modules: themes with CSS ``` | Feature | Sigil | `qrcode` (node-style) | `jsqr` | | --- | --- | --- | --- | | Bundle size | | ~100 kB+ | decoder only | | Runtime dependencies | none | | none | | Output | Matrix, SVG string, canvas | Canvas/DataURL/UTF-8 | N/A | | Themeable output | `currentColor` by default | Hardcoded color strings | N/A | | Scanning | Native `BarcodeDetector` wrapper | | Pure-JS decoder | | SSR-safe import | | | | **Use Sigil when** you need QR output that themes with your UI (SVG/`currentColor`), a minimal encoder with no runtime dependencies, or a scanner that uses the platform decoder instead of shipping one. **Consider `jsqr`-style decoding when** you must scan in browsers without `BarcodeDetector`: sigil reports `SigilUnsupportedError` rather than bundling a fallback decoder. ## Installation ```sh [pnpm] pnpm add @vielzeug/sigil ``` ```sh [npm] npm install @vielzeug/sigil ``` ```sh [yarn] yarn add @vielzeug/sigil ``` ## Quick Start Encode to an SVG string: works in Node and the browser: ```ts import { encodeQr, qrCapacity, toSvg } from '@vielzeug/sigil'; const payload = 'https://vielzeug.dev'; const matrix = encodeQr(payload, { errorCorrection: 'M' }); document.querySelector('#qr').innerHTML = toSvg(matrix, { label: 'Vielzeug link' }); ``` Scan with the camera (browser only: see support notes in the usage guide): ```ts import { createQrScanner } from '@vielzeug/sigil'; const video = document.querySelector('video'); const scanner = createQrScanner({ video }); scanner.onResult((result) => { console.log('scanned:', result.value); // once: true stops after first decode }); await scanner.start(); // throws SigilUnsupportedError / SigilPermissionError ``` ## Features - `encodeQr`: pure matrix encoder: numeric/alphanumeric/byte modes, versions 1–40, EC levels L/M/Q/H, automatic or forced mask - `qrCapacity`: byte capacity per version/mode/level, for pre-flight validation - `toSvg`: compact path-merged SVG string; `currentColor` modules, `` + `role="img"` built in - `drawToCanvas`: bitmap rendering with DPR-aware scaling for print and download flows - `detectQr`: one-shot detection on any `ImageBitmapSource` - `createQrScanner`: camera scan loop with `once` mode, status events, `tap()` observability, and strict disposal - `isQrScanSupported`/`qrScanSupport`: sync and async feature detection that never assumes ## Documentation - [Usage Guide](./usage.md): encoding modes, capacity, rendering, scanning, and testing - [API Reference](./api.md): every export, option, type, and error - [Examples](./examples.md): copy-paste recipes, including pairing two devices with `meshQrCodec` ## See Also - [Mesh](../mesh/): backendless P2P sessions; `meshQrCodec` produces compact QR-friendly pairing payloads - [Refine](../refine/): `ore-qr-code` and `ore-qr-scanner` wrap sigil in ready-made, themeable components ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `encodeQr` | Encode a payload into a `QrMatrix` | Sync | Throws `SigilCapacityError` over capacity | | `qrCapacity` | Payload limit for a version/level/mode | Sync | Returns *characters* for numeric/alphanumeric, *bytes* for byte mode | | `toSvg` | Render a matrix as an SVG string | Sync | Escapes `dark`/`light` as raw CSS: pass values, not markup | | `drawToCanvas` | Paint a matrix onto a canvas | Sync | Browser only; resizes the canvas for DPR | | `detectQr` | One-shot detection on an image source | Async | Throws `SigilUnsupportedError` without `BarcodeDetector` | | `createQrScanner` | Camera scan loop | Async (start) | Must `dispose()`; camera tracks persist otherwise | | `isQrScanSupported` | Sync feature check | Sync | Constructor exists ≠ `qr_code` supported: prefer `qrScanSupport()` | | `qrScanSupport` | Async feature check incl. formats | Async | Never assume support per browser/version | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/sigil` | Complete public Sigil API | ## Encoding ### `encodeQr(data, options?)` ```ts function encodeQr(data: string | Uint8Array, options?: QrEncodeOptions): QrMatrix; ``` Encodes `data` into a QR symbol. Picks the most compact mode covering the whole input, the smallest fitting version, and the lowest-penalty mask. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `data` | `string \| Uint8Array` | N/A | Payload; `Uint8Array` is raw binary and always byte mode (no UTF-8 round-trip) | | `options.errorCorrection` | `'L' \| 'M' \| 'Q' \| 'H'` | `'M'` | Error-correction level | | `options.version` | `1–40` | auto | Pin the version; throws if payload can't fit | | `options.minVersion` | `1–40` | `1` | Smallest acceptable version | | `options.mode` | `QrMode` | auto | Force a segment mode | | `options.mask` | `0–7` | auto | Force the mask (test vectors only) | **Returns** a frozen `QrMatrix` with `size`, `version`, `mode`, `errorCorrection`, `mask`, and `get(x, y)`. **Throws** `SigilCapacityError` (payload exceeds capacity), `SigilOptionError` (invalid mode/version/mask combination). ```ts import { encodeQr } from '@vielzeug/sigil'; const matrix = encodeQr('HELLO WORLD', { errorCorrection: 'Q' }); matrix.size; // 21 (v1) matrix.get(0, 0); // finder pattern → true ``` --- ### `qrCapacity(version, errorCorrection, mode)` ```ts function qrCapacity(version: number, errorCorrection: QrErrorCorrection, mode: QrMode): number; ``` Returns the payload limit: characters for `numeric`/`alphanumeric`, bytes for `byte`. ```ts qrCapacity(1, 'M', 'byte'); // → 14 qrCapacity(40, 'L', 'numeric'); // → 7089 ``` ## Rendering ### `toSvg(matrix, options?)` ```ts function toSvg(matrix: QrMatrix, options?: QrSvgOptions): string; ``` Returns a complete `` string. Runs in any environment: no DOM required. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `options.dark` | `string` | `'currentColor'` | Dark module color | | `options.light` | `string` | `'transparent'` | Background color | | `options.margin` | `number` | `4` | Quiet-zone modules | | `options.scale` | `number` | `1` | User units per module | | `options.optimizePath` | `boolean` | `true` | Merge dark runs into one `` | | `options.label` | `string` | N/A | `` + `aria-label`; enables `role="img"` | ```ts const svg = toSvg(matrix, { label: 'Pairing code', dark: 'var(--qr-dark)' }); ``` --- ### `drawToCanvas(matrix, canvas, options?)`: browser only ```ts function drawToCanvas(matrix: QrMatrix, canvas: HTMLCanvasElement, options?: QrCanvasOptions): number; ``` Paints the matrix and returns the CSS-pixel edge length. Sizes the backing store for `devicePixelRatio` so output stays crisp on retina displays. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `options.scale` | `number` | `1` | CSS px per module | | `options.margin` | `number` | `4` | Quiet-zone modules | | `options.dark` / `options.light` | `string` | `'#000'` / `'#fff'` | Fill colors | ## Scanning (browser only) ### `createQrScanner(options)` ```ts function createQrScanner(options: QrScannerOptions): QrScanner; ``` Creates a camera scan loop around the native `BarcodeDetector`. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `options.video` | `HTMLVideoElement` | N/A | Element the stream attaches to (needs `autoplay playsinline muted`) | | `options.constraints` | `MediaTrackConstraints` | `{ facingMode: 'environment' }` | `getUserMedia` video constraints | | `options.intervalMs` | `number` | `200` | Minimum ms between detect passes | | `options.once` | `boolean` | `true` | Stop after the first result | | `options.signal` | `AbortSignal` | N/A | Abort → `stop()` | | `options.detector` | `QrDetector` | native | Injected detector for tests | | `options.mediaDevices` | `Pick` | `navigator.mediaDevices` | Injected media for tests | **Returns** a `QrScanner`: | Member | Signature | Description | | --- | --- | --- | | `start()` | `() => Promise` | Request camera, attach stream, run the detect loop. Rejects with `SigilUnsupportedError` / `SigilPermissionError`. `stop()`/`dispose()` while the camera prompt is pending win: the late stream is released and the call resolves | | `stop()` | `() => void` | Stop the loop and tracks, return to `'idle'`; instance stays usable | | `onResult(handler)` | `(handler) => () => void` | Decode results; returns unsubscribe | | `tap(handler, options?)` | `(handler, { signal? }) => () => void` | Observe `SigilEvent`s; handler errors are swallowed | | `status` | `QrScannerStatus` | `'idle' \| 'starting' \| 'scanning' \| 'disposed'` | | `dispose()` / `[Symbol.dispose]` | `() => void` | Terminal teardown; `disposalSignal` aborts | | `disposed` | `boolean` | Whether `dispose()` ran | ```ts const scanner = createQrScanner({ video }); const off = scanner.onResult(({ value }) => console.log(value)); await scanner.start(); ``` --- ### `detectQr(source, options?)` ```ts function detectQr( source: ImageBitmapSource, options?: { signal?: AbortSignal; detector?: QrDetector }, ): Promise; ``` Single-pass detection on an image source. Returns the first result's `{ value, cornerPoints }`, or `null` when nothing decodes. Throws `SigilUnsupportedError` when no detector is available. --- ### `isQrScanSupported()` / `qrScanSupport()` ```ts function isQrScanSupported(): boolean; function qrScanSupport(): Promise; ``` `isQrScanSupported` checks the `BarcodeDetector` constructor synchronously. `qrScanSupport` additionally asks `getSupportedFormats()` for `'qr_code'`: use it before showing scan affordances. ## Types ```ts type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H'; type QrMode = 'numeric' | 'alphanumeric' | 'byte'; type QrScannerStatus = 'idle' | 'starting' | 'scanning' | 'disposed'; interface QrMatrix { readonly errorCorrection: QrErrorCorrection; readonly mask: number; readonly mode: QrMode; readonly size: number; // 17 + 4 * version readonly version: number; // 1–40 get(x: number, y: number): boolean; // true = dark; false outside bounds } interface QrScanResult { readonly cornerPoints?: ReadonlyArray; readonly value: string; } interface QrDetector { detect(source: ImageBitmapSource): Promise }> >; } type SigilEvent = | { readonly type: 'status-change'; readonly status: QrScannerStatus } | { readonly type: 'detect'; readonly value: string; readonly elapsedMs: number } | { readonly type: 'frame-skipped'; readonly reason: 'not-ready' | 'busy' } | { readonly type: 'error'; readonly error: SigilError } | { readonly type: 'dispose' }; ``` `QrEncodeOptions`, `QrSvgOptions`, `QrCanvasOptions`, `QrScannerOptions`, and `QrScanner` are documented under their factories above. ## Errors | Class | Trigger | Notable members | | --- | --- | --- | | `SigilError` | Base class for all sigil errors | N/A | | `SigilCapacityError` | Payload exceeds the version/mode limit | `bytes`, `maxBytes`, `version` | | `SigilOptionError` | Invalid option combination (forced mode can't represent input, bad mask/version) | N/A | | `SigilUnsupportedError` | `BarcodeDetector`/`getUserMedia`/canvas 2D context missing | N/A | | `SigilPermissionError` | Camera `NotAllowedError` | N/A | | `SigilDisposedError` | `start()` on a disposed scanner | N/A | All carry `cause` when wrapping a platform error. ### Usage Guide ## Basic Usage `encodeQr` produces a `QrMatrix`: a frozen square of dark/light modules. Render it with `toSvg` (string, works anywhere) or `drawToCanvas` (browser). ```ts import { encodeQr, qrCapacity, toSvg } from '@vielzeug/sigil'; const matrix = encodeQr('https://vielzeug.dev'); // Pick the smallest version automatically, or pin one: const pinned = encodeQr('https://vielzeug.dev', { version: 5, errorCorrection: 'Q' }); const svg = toSvg(matrix); // inline string: inject or serialize ``` The encoder chooses the most compact mode for the whole input and the smallest version that fits. `mask` is chosen by the lowest ISO penalty score: override it only for test vectors. ## Encoding Modes and Capacity Three segment modes cover every payload: | Mode | Alphabet | Max payload (v40-L) | | --- | --- | --- | | `numeric` | `0–9` | 7,089 digits | | `alphanumeric` | `0–9 A-Z space $%*+-./:` | 4,296 chars | | `byte` | UTF-8 (no ECI header) | 2,953 bytes | Mode selection is automatic: `encodeQr('01234567')` uses `numeric`, `'HELLO WORLD'` uses `alphanumeric`, anything else uses `byte`. Force a mode with `mode`, but a forced mode that cannot represent the input throws `SigilOptionError`. `Uint8Array` input is raw binary: it always encodes in byte mode, byte-for-byte, with no UTF-8 round-trip: invalid UTF-8 (tokens, provisioning payloads) survives intact. Forcing a non-byte mode with binary input throws `SigilOptionError`. Check capacity before encoding with `qrCapacity(version, errorCorrection, mode)`: it returns the maximum *bytes* (for `byte` mode) or *characters* the symbol holds: ```ts import { qrCapacity, SigilCapacityError } from '@vielzeug/sigil'; qrCapacity(10, 'M', 'byte'); // → 213 (data codewords available at v10-M) try { encodeQr(hugePayload, { version: 1 }); } catch (error) { if (error instanceof SigilCapacityError) { console.log(`${error.bytes} > ${error.maxBytes} at v${error.version}`); } } ``` Use `minVersion` to keep a stable symbol size while a payload changes: useful when a layout must not reflow between renders. ## Error Correction | Level | Recovery | Trade-off | | --- | --- | --- | | `L` | ~7% | Smallest matrix | | `M` | ~15% | Default: good balance | | `Q` | ~25% | Print, wear | | `H` | ~30% | Logos/overlays, dense codes | Higher levels shrink capacity at the same version. For an overlaid logo, prefer `H` *and* keep the logo inside the quiet zone's margin of safety. ## Rendering ### SVG: `toSvg` ```ts const svg = toSvg(matrix, { dark: 'currentColor', // default: inherits text color light: 'transparent', // default margin: 4, // quiet zone in modules (spec minimum is 4) scale: 1, // user units per module: prefer scaling via CSS optimizePath: true, // merge adjacent modules into one path (default) label: 'Pairing code', // + role="img" }); ``` `optimizePath` merges each row's dark runs into a single ``: orders of magnitude smaller markup. Set `dark`/`light` to CSS variables to theme the code (`dark: 'var(--qr-dark)'`). ### Canvas: `drawToCanvas` (browser only) ```ts const sizePx = drawToCanvas(matrix, canvas, { scale: 8, margin: 4 }); // canvas is resized for devicePixelRatio; returns the CSS-pixel edge length ``` Use canvas for print, download (`canvas.toBlob`), or compositing. SVG is the better default for display. ## Scanning (browser only) Scanning wraps the native `BarcodeDetector`. It is **not** universally available: feature-detect, never assume: | Engine | `BarcodeDetector` | Notes | | --- | --- | --- | | Chrome / Edge / Android WebView | Yes | `qr_code` format supported | | Firefox | No (behind flag in some builds) | `isQrScanSupported()` → `false` | | Safari | Varies by version | Check at runtime; do not rely on version sniffing | ```ts import { createQrScanner, isQrScanSupported, qrScanSupport } from '@vielzeug/sigil'; isQrScanSupported(); // sync: BarcodeDetector constructor exists await qrScanSupport(); // async: also verifies 'qr_code' is a supported format const scanner = createQrScanner({ video, // constraints: { facingMode: 'environment' }, intervalMs: 200, // min ms between detect passes once: true, // stop after first result }); scanner.onResult((result) => showPairing(result.value)); scanner.tap((event) => console.debug(event.type)); // status-change, detect, frame-skipped… await scanner.start(); // requests camera, attaches stream, starts the loop scanner.stop(); // releases the camera, back to 'idle'; start() resumes scanner.dispose(); // terminal: releases everything ``` Statuses are `'idle' | 'starting' | 'scanning' | 'disposed'`. A `stop()` or `dispose()` that lands while the camera permission prompt is pending wins the race: the late stream is released and `start()` resolves without restarting the loop. `start()` rejects with `SigilUnsupportedError` (no detector/getUserMedia) or `SigilPermissionError` (`NotAllowedError`). Detection failures mid-scan surface through `tap` `error` events and stop the scanner. ### One-shot detection `detectQr(source)` runs a single pass over any `ImageBitmapSource`: `ImageData`, `Blob`, `VideoFrame`, an ``, or a ``: ```ts import { detectQr } from '@vielzeug/sigil'; const result = await detectQr(imageBitmap); // → { value, cornerPoints } | null ``` ## Testing Both seams are injectable: never touch globals in tests: ```ts import { createQrScanner } from '@vielzeug/sigil'; const fakeScanner = createQrScanner({ video: document.createElement('video'), detector: { detect: async () => [{ rawValue: 'payload' }] }, // fake BarcodeDetector mediaDevices: { getUserMedia: async () => fakeStream }, // fake camera }); ``` For the web components, `ore-qr-scanner` exposes a `scannerFactory` JS property so tests inject a fake `QrScanner` without stubbing `BarcodeDetector` or `getUserMedia`. ## Framework Integration Encoding is synchronous and SSR-safe: render `toSvg` output as part of server markup: ```tsx [React] function Qr({ value }: { value: string }) { const svg = useMemo(() => toSvg(encodeQr(value)), [value]); return ; } ``` ```vue [Vue] import { computed } from 'vue'; import { encodeQr, toSvg } from '@vielzeug/sigil'; const props = defineProps({ value: String }); const svg = computed(() => toSvg(encodeQr(props.value ?? ''))); ``` ```html [Svelte] import { encodeQr, toSvg } from '@vielzeug/sigil'; export let value; $: svg = toSvg(encodeQr(value)); {@html svg} ``` Scanning needs lifecycle: start in `onMounted`/`onMount`, always `dispose()` in cleanup. ## Working with Other Vielzeug Libraries - **Mesh**: `meshQrCodec` compresses WebRTC pairing payloads (offer/answer JSON) to QR-friendly strings. The [Pair Two Devices with QR](./examples/pair-two-devices.md) recipe shows the full flow. - **Refine**: prefer `ore-qr-code` / `ore-qr-scanner` over hand-rolled wrappers: they compose sigil with themeable markup, a11y labels, and state handling. ## Best Practices - Pre-validate payloads with `qrCapacity` before surfacing an encode control. - Keep the quiet zone: `margin` below 4 risks unreadable codes; never overlay content on it. - Prefer `toSvg` + `currentColor` for display; reserve `drawToCanvas` for print/download. - Feature-detect scanning with `qrScanSupport()` before showing a scan affordance. - Always `dispose()` scanners: camera tracks outlive route changes otherwise. - Use `once: false` only when the UX genuinely expects repeated decodes. - Treat scanned strings as untrusted input: validate before navigation or pairing. - Pin `minVersion` in animated/live payloads so the symbol doesn't resize mid-flow. ### Examples ## Examples - [Themed SVG QR Code](./examples/themed-svg-qr-code.md) - [Pair Two Devices with QR](./examples/pair-two-devices.md) ### REPL Examples - Capacity Check (id: `capacity-check`) - Encode to SVG (id: `encode-svg`) --- ## @vielzeug/sourcerer **Category:** data ### Overview ## Why Sourcerer? Paginated views need collection data, loading state, errors, parameters, and navigation to change coherently. Sourcerer owns that state and request succession without coupling it to a framework or transport client. ```ts // Before let items = []; let loading = false; let page = 1; // After const source = createPageSource({ load: async ({ page, pageSize }) => ({ items: await loadUsers(page, pageSize), totalItems: 100 }), }); source.subscribe((state) => render(state)); ``` | Feature | Sourcerer | Manual store | General query cache | | --- | --- | --- | --- | | Bundle size | | Application-defined | Package-defined | | Zero runtime dependencies | | | | | Page, cursor, and infinite navigation | | Application-defined | Application-defined | | Framework-neutral subscriptions | | Application-defined | | | HTTP transport policy | Loader-defined | Application-defined | Loader-defined | **Use Sourcerer when** a collection view needs explicit pagination, observable state, cancellation, and lifecycle ownership. **Consider a general query cache when** your primary requirement is shared keyed caching, invalidation, mutations, or server-state normalization rather than collection navigation. ## Installation ```sh [pnpm] pnpm add @vielzeug/sourcerer ``` ```sh [npm] npm install @vielzeug/sourcerer ``` ```sh [yarn] yarn add @vielzeug/sourcerer ``` ## Quick Start Create a page source, load it, read its state, and dispose it with its owner. ```ts import { createPageSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string }; const users: User[] = [ { id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }, ]; const source = createPageSource({ load: async ({ page, pageSize }) => { const start = (page - 1) * pageSize; return { items: users.slice(start, start + pageSize), totalItems: users.length }; }, pageSize: 1, }); try { await source.reload(); console.log(source.state.items); } catch (error) { console.error(error); } finally { source.dispose(); } ``` ## Features - `createLocalSource()`: filters and paginates an in-memory collection synchronously - `createPageSource()`: loads numbered pages and exposes direct navigation commands - `createCursorSource()`: follows opaque cursors returned by the loader - `createInfiniteSource()`: appends pages while preserving loaded items - `setParams()`: replaces consumer-owned loader parameters and resets pagination - `subscribe()`: publishes complete immutable state replacements ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Courier](/courier/): provide HTTP transport, middleware, and structured transport errors inside loaders - [Scout](/scout/): index larger in-memory collections before passing matches to a local source - [Ripple](/ripple/): project source state into reactive computations - [Wayfinder](/wayfinder/): validate and synchronize source parameters with route state ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createLocalSource()` | Filter and paginate in-memory items | Sync | `params` replacement uses `Object.is` | | `createPageSource()` | Load numbered pages | Async | Construction is inert | | `createCursorSource()` | Load server-issued cursor pages | Async | `after` and `before` cannot coexist initially | | `createInfiniteSource()` | Append numbered pages | Async | `loadMore()` is a no-op while loading or exhausted | | `SourcererError` | Base package error | Sync | Loader errors retain their original type | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/sourcerer` | All factories, states, pagination types, loader contexts, and errors | ## Source Factories ### `createLocalSource()` ```ts function createLocalSource(items: readonly T[]): LocalSource function createLocalSource( items: readonly T[], config: LocalSourceConfig, ): LocalSource ``` Returns a synchronous in-memory source. | Parameter | Type | Description | | --- | --- | --- | | `items` | `readonly T[]` | Initial collection | | `config.filter` | `(item: T, params: TParams) => boolean` | Optional inclusion predicate | | `config.pageSize` | `number` | Positive page size; defaults to `20` | | `config.params` | `TParams` | Initial params; required when `TParams` excludes `undefined` | **Returns:** `LocalSource`. ```ts import { createLocalSource } from '@vielzeug/sourcerer'; const source = createLocalSource(['Ada', 'Grace'], { filter: (name, search: string) => name.includes(search), params: '', }); source.setParams('Ada'); ``` | Method | Returns | Description | | --- | --- | --- | | `first()` | `void` | Move to page one | | `goTo(page)` | `void` | Move to a positive page, clamped to the known range | | `last()` | `void` | Move to the final page | | `next()` | `void` | Move forward when available | | `previous()` | `void` | Move backward when available | | `setItems(items)` | `void` | Replace the collection and recompute state | | `setPageSize(pageSize)` | `void` | Replace page size and reset to page one | | `setParams(params)` | `void` | Replace params and reset to page one | | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function | | `dispose()` | `void` | Stop notifications and reject later mutations | --- ### `createPageSource()` ```ts function createPageSource( config: PageSourceConfig, ): PageSource ``` Returns an inert numbered-page source. | Parameter | Type | Description | | --- | --- | --- | | `config.load` | `(context: PageLoadContext) => Promise>` | Page loader | | `config.pageSize` | `number` | Positive page size; defaults to `20` | | `config.params` | `TParams` | Initial params; required when `TParams` excludes `undefined` | **Returns:** `PageSource`. ```ts import { createPageSource } from '@vielzeug/sourcerer'; const source = createPageSource({ load: async ({ page }) => ({ items: [page], totalItems: 10 }), }); await source.reload(); ``` | Method | Returns | Description | | --- | --- | --- | | `first()` | `Promise` | Load page one | | `goTo(page)` | `Promise` | Load a positive page | | `last()` | `Promise` | Load the last known page | | `next()` | `Promise` | Load the next known page | | `previous()` | `Promise` | Load the previous page | | `reload()` | `Promise` | Reload the requested page and params | | `setPageSize(pageSize)` | `Promise` | Reset to page one and load with a new size | | `setParams(params)` | `Promise` | Reset to page one and load replacement params | | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function | | `dispose()` | `void` | Abort work and stop notifications | --- ### `createCursorSource()` ```ts function createCursorSource( config: CursorSourceConfig, ): CursorSource ``` Returns an inert cursor source. | Parameter | Type | Description | | --- | --- | --- | | `config.after` | `TCursor` | Optional initial forward cursor | | `config.before` | `TCursor` | Optional initial backward cursor | | `config.load` | `(context: CursorLoadContext) => Promise>` | Cursor loader | | `config.pageSize` | `number` | Positive page size; defaults to `20` | | `config.params` | `TParams` | Initial params; required when `TParams` excludes `undefined` | **Returns:** `CursorSource`. ```ts import { createCursorSource } from '@vielzeug/sourcerer'; const source = createCursorSource({ load: async () => ({ items: ['first'], nextCursor: 'next' }) }); await source.reload(); await source.next(); ``` | Method | Returns | Description | | --- | --- | --- | | `next()` | `Promise` | Load `state.pagination.nextCursor` when available | | `previous()` | `Promise` | Load `state.pagination.previousCursor` when available | | `reload()` | `Promise` | Reload the requested cursor and params | | `setPageSize(pageSize)` | `Promise` | Clear cursors and reload with a new size | | `setParams(params)` | `Promise` | Clear cursors and load replacement params | | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function | | `dispose()` | `void` | Abort work and stop notifications | --- ### `createInfiniteSource()` ```ts function createInfiniteSource( config: InfiniteSourceConfig, ): InfiniteSource ``` Returns an inert source that appends numbered pages. | Parameter | Type | Description | | --- | --- | --- | | `config.load` | `(context: InfiniteLoadContext) => Promise>` | Page loader | | `config.pageSize` | `number` | Positive page size; defaults to `20` | | `config.params` | `TParams` | Initial params; required when `TParams` excludes `undefined` | **Returns:** `InfiniteSource`. ```ts import { createInfiniteSource } from '@vielzeug/sourcerer'; const source = createInfiniteSource({ load: async () => ({ items: ['post'], totalItems: 1 }) }); await source.reload(); await source.loadMore(); ``` | Method | Returns | Description | | --- | --- | --- | | `loadMore()` | `Promise` | Append the next page when available | | `reload()` | `Promise` | Replace items from page one | | `setPageSize(pageSize)` | `Promise` | Replace items from page one with a new size | | `setParams(params)` | `Promise` | Replace items from page one with new params | | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function | | `dispose()` | `void` | Abort work and stop notifications | ## Types ### Page source types ```ts type PagePagination = Readonly; type PageSourceState = Readonly; type PageLoadContext = Readonly; type PageResult = Readonly; type PageSourceConfig = Readonly): Promise>; pageSize?: number; } & (undefined extends TParams ? { params?: TParams } : { params: TParams }) >; type PageSource = { [Symbol.dispose](): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; first(): Promise; goTo(page: number): Promise; last(): Promise; next(): Promise; previous(): Promise; reload(): Promise; setPageSize(pageSize: number): Promise; setParams(params: TParams): Promise; readonly state: PageSourceState; subscribe(listener: (state: PageSourceState) => void): () => void; }; ``` The conditional config field makes `params` optional only when `TParams` includes `undefined`. ### Local source types ```ts type LocalSourceState = Readonly; type LocalSourceConfig = Readonly boolean; pageSize?: number; } & (undefined extends TParams ? { params?: TParams } : { params: TParams }) >; type LocalSource = { [Symbol.dispose](): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; first(): void; goTo(page: number): void; last(): void; next(): void; previous(): void; setItems(items: readonly T[]): void; setPageSize(pageSize: number): void; setParams(params: TParams): void; readonly state: LocalSourceState; subscribe(listener: (state: LocalSourceState) => void): () => void; }; ``` ### Cursor source types ```ts type CursorPagination = Readonly; type CursorLoadContext = Readonly; type CursorResult = Readonly; type CursorSourceState = Readonly; params: TParams; pendingParams?: TParams; }>; type CursorSourceConfig = Readonly): Promise>; pageSize?: number; } & (undefined extends TParams ? { params?: TParams } : { params: TParams }) >; type CursorSource = { [Symbol.dispose](): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; next(): Promise; previous(): Promise; reload(): Promise; setPageSize(pageSize: number): Promise; setParams(params: TParams): Promise; readonly state: CursorSourceState; subscribe(listener: (state: CursorSourceState) => void): () => void; }; ``` ### Infinite source types ```ts type InfinitePagination = Readonly; type InfiniteLoadContext = Readonly; type InfiniteSourceState = Readonly; type InfiniteSourceConfig = Readonly): Promise>; pageSize?: number; } & (undefined extends TParams ? { params?: TParams } : { params: TParams }) >; type InfiniteSource = { [Symbol.dispose](): void; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; loadMore(): Promise; reload(): Promise; setPageSize(pageSize: number): Promise; setParams(params: TParams): Promise; readonly state: InfiniteSourceState; subscribe(listener: (state: InfiniteSourceState) => void): () => void; }; ``` ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `SourcererError` | Base class for package-originated configuration and lifecycle errors | `instanceof SourcererError` narrows the hierarchy | | `SourcererConfigurationError` | Invalid page, page size, total, or initial cursor direction | Extends `SourcererError` | | `SourcererDisposedError` | A source command runs after disposal | Extends `SourcererError` | Loader failures are preserved when they are `Error` instances. Non-Error rejections become `Error` objects whose `cause` contains the original value. ### Usage Guide ## Basic Usage Create a page source and call `reload()` to start the first request. Construction performs no I/O. ```ts import { createPageSource } from '@vielzeug/sourcerer'; const names = ['Ada', 'Grace', 'Linus']; const source = createPageSource({ load: async ({ page, pageSize }) => { const start = (page - 1) * pageSize; return { items: names.slice(start, start + pageSize), totalItems: names.length }; }, pageSize: 2, }); await source.reload(); console.log(source.state.items); source.dispose(); ``` Read one `state` object per render. It contains `items`, `loading`, `error`, `params`, and pagination metadata from one committed result. Commands return `Promise`; read `source.state` after awaiting them. ## Pass Loader Parameters Use `params` for application-owned filters, search terms, sort state, or request options. `setParams()` replaces the entire value and resets pagination. ```ts import { createPageSource } from '@vielzeug/sourcerer'; type UserParams = { role: 'admin' | 'user'; search: string }; const users = ['Ada', 'Grace', 'Linus']; const source = createPageSource({ load: async ({ page, pageSize, params }) => { const matching = users.filter((name) => name.toLowerCase().includes(params.search.toLowerCase())); const start = (page - 1) * pageSize; return { items: matching.slice(start, start + pageSize), totalItems: matching.length }; }, params: { role: 'admin', search: '' }, }); await source.setParams({ role: 'admin', search: 'ada' }); console.log(source.state.params); source.dispose(); ``` A non-optional parameter type requires an initial `params` value. Sourcerer compares parameter values with `Object.is`; reuse a value when setting it again should be a no-op. While replacement parameters load, `state.params` continues to describe the committed items and `state.pendingParams` contains the requested value. Page-only navigation does not set `pendingParams`. ## Navigate Numbered Pages Use direct commands for numbered-page navigation. `setPageSize()` resets to page one. ```ts await source.reload(); await source.next(); await source.goTo(4); await source.previous(); await source.first(); await source.last(); await source.setPageSize(50); ``` After the first successful load, navigation is clamped to the known `pageCount`. Before that load, `goTo()` accepts any positive page because the total is unknown. ## Filter Local Collections Use a local source when data is already in memory. Its commands are synchronous. ```ts import { createLocalSource } from '@vielzeug/sourcerer'; const source = createLocalSource(['Ada', 'Grace', 'Linus'], { filter: (name, search: string) => name.toLowerCase().includes(search.toLowerCase()), pageSize: 2, params: '', }); source.setParams('a'); source.next(); source.setItems(['Ada', 'Grace']); console.log(source.state.items); source.dispose(); ``` Use ordinary arrays for one-off transformations. A local source is useful when pagination, subscriptions, and lifecycle ownership are also required. ## Use Cursor Pagination Return opaque cursors from the loader. `next()` and `previous()` use only cursors from the committed state. ```ts import { createCursorSource } from '@vielzeug/sourcerer'; const rows = ['A', 'B', 'C', 'D']; const source = createCursorSource({ load: async ({ after, pageSize }) => { const start = after ? Number(after) : 0; const items = rows.slice(start, start + pageSize); const next = start + items.length; return { items, nextCursor: next 0 ? String(Math.max(0, start - pageSize)) : undefined, }; }, pageSize: 2, }); await source.reload(); await source.next(); console.log(source.state.items); source.dispose(); ``` Changing params or page size clears current cursor direction. A source cannot start with both `after` and `before`. ## Build an Infinite Feed Use an infinite source when later pages should append to committed items. ```ts import { createInfiniteSource } from '@vielzeug/sourcerer'; const values = [1, 2, 3, 4, 5]; const source = createInfiniteSource({ load: async ({ page, pageSize }) => { const start = (page - 1) * pageSize; return { items: values.slice(start, start + pageSize), totalItems: values.length }; }, pageSize: 2, }); await source.reload(); await source.loadMore(); console.log(source.state.items); source.dispose(); ``` `loadMore()` is a no-op while another request is active or after `pagination.hasMore` becomes false. `setParams()` and `setPageSize()` replace the feed from page one. ## Handle Requests and Errors A new remote command aborts older work. Superseded or disposed work settles without committing. A current loader failure preserves committed items, writes the error to state, and rejects the command. ```ts try { await source.reload(); } catch (error) { console.error(error); console.log(source.state.error); } ``` Loader result arrays are copied before publication. Commands after disposal throw or reject `SourcererDisposedError`. ## Testing Inject deterministic loaders and await commands before asserting final state. ```ts import { expect, it } from 'vitest'; import { createPageSource } from '@vielzeug/sourcerer'; it('loads a page', async () => { const source = createPageSource({ load: async () => ({ items: ['Ada'], totalItems: 1 }) }); await source.reload(); expect(source.state.items).toEqual(['Ada']); source.dispose(); }); ``` ## Framework Integration Create one source per component lifetime and bridge `subscribe()` to the framework lifecycle. ```tsx [React] import { createPageSource } from '@vielzeug/sourcerer'; import { useEffect, useMemo, useSyncExternalStore } from 'react'; export function Users() { const source = useMemo( () => createPageSource({ load: async () => ({ items: [{ id: 1, name: 'Ada' }], totalItems: 1 }) }), [], ); const state = useSyncExternalStore(source.subscribe, () => source.state); useEffect(() => { void source.reload().catch(() => undefined); return () => source.dispose(); }, [source]); return {state.loading ? 'Loading' : state.items.length}; } ``` ```ts [Vue 3] import { onUnmounted, shallowRef } from 'vue'; import { createPageSource } from '@vielzeug/sourcerer'; const source = createPageSource({ load: async () => ({ items: [{ id: 1, name: 'Ada' }], totalItems: 1 }) }); const state = shallowRef(source.state); const stop = source.subscribe((next) => (state.value = next)); void source.reload().catch(() => undefined); onUnmounted(() => { stop(); source.dispose(); }); ``` ```svelte [Svelte] import { onDestroy } from 'svelte'; import { createPageSource } from '@vielzeug/sourcerer'; const source = createPageSource({ load: async () => ({ items: [{ id: 1, name: 'Ada' }], totalItems: 1 }) }); let state = source.state; const stop = source.subscribe((next) => (state = next)); void source.reload().catch(() => undefined); onDestroy(() => { stop(); source.dispose(); }); {#if state.loading}Loading{/if} {#each state.items as user}{user.name}{/each} ``` ## Working with Other Vielzeug Libraries Use Courier for transport policy and pass the source-owned cancellation signal through. ```ts import { createCourier } from '@vielzeug/courier'; import { createPageSource } from '@vielzeug/sourcerer'; type User = { id: number; name: string }; type Response = { data: User[]; total: number }; const courier = createCourier({ baseUrl: '/api' }); const source = createPageSource({ load: async ({ page, pageSize, params: search, signal }) => { const result = await courier.get('/users', { query: { page, pageSize, search }, signal }); return { items: result.data, totalItems: result.total }; }, params: '', }); ``` Use Scout to compute indexed local matches, Ripple to project state into reactive computations, and Wayfinder to validate and synchronize params with the URL. ## Best Practices - Dispose each source with its owning view, request, or service scope. - Read one `state` object per render. - Await direct commands or handle rejected current-request failures. - Keep transport, caching, and persistence policy outside Sourcerer. - Treat `params` as an immutable value and replace it atomically. - Debounce text input before calling `setParams()` on a remote source. - Validate route and form values before constructing params or page numbers. - Keep rendering committed items while `loading` is true. ### Examples ## Examples - [Local Pagination and Filtering](./examples/local-pagination-and-filtering.md) - [Page Params with URL State](./examples/remote-search-with-url-state.md) - [Cursor-Based Pagination](./examples/cursor-based-pagination.md) - [Infinite Scroll](./examples/infinite-scroll.md) - [Framework Integration](./examples/framework-integration.md) - [Remote Data with Courier](./examples/sourcerer-with-courier.md) - [Reactive Controls with Ripple](./examples/sourcerer-with-ripple.md) - [URL-Synced List with Wayfinder](./examples/sourcerer-with-wayfinder.md) ### REPL Examples - Cursor Source (id: `cursor-source`) - Error Handling (id: `error-handling`) - Infinite Source (id: `infinite-source`) - Source Lifecycle (id: `lifecycle`) - Local Source (id: `local-source`) - Page Source (id: `page-source`) --- ## @vielzeug/spell **Category:** validation ### Overview ## Why Spell? Spell keeps runtime validation, static inference, and portable definitions in one API. Use `s` for schema construction; import JSON conversion and predicates from dedicated subpaths. 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 | `.checkAsync()` | | | | Error flattening | `flatten()` + `flattenFirst()` | | Partial | | Zero dependencies | | | | **Use Spell when** you want a fluent schema API with strong TypeScript inference, structured errors, and no third-party runtime 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 result = User.safeParse(payload); if (!result.success) throw result.error; const user = result.data; ``` ## Features - Namespace and tree-shakeable schema builders. - Sync and async parsing with `parse()`, `safeParse()`, `parseAsync()`, and `safeParseAsync()`. - Dependency-free Standard Schema v1 interoperability through `schema['~standard']`. - Explicit `check()` and `checkAsync()` rules; sync parsing never skips an async check. - Wrapper modes for `optional`, `nullable`, `nullish`, `default`, `catch`, and `required`. - Frozen declarative definitions through `definition()` and JSON Schema export via `fromDefinition()` from `@vielzeug/spell/json`. - Request-local message customization through `createParseContext()`. - Ordered union parsing produces the same selected branch in sync and async modes. - Structured errors with direct path lookup and flattened views. - Object parsing is hardened against prototype-pollution-style keys. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.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` | Creates schemas | Sync or async, depending on checks | `checkAsync()` requires async parsing | | `createParseContext()` | Creates request-local messages | Sync | Pass the context to each parse call | | `joinIssuePath()` | Flattens issue paths to UI keys | Sync | Empty or undefined paths join to `''` | | `SpellValidationError` | Provides validation issues | Sync/async parse failures | Use `safeParse()` for expected invalid data | ## Package Entry Point | Import | Purpose | | ---------------------------- | ----------------------------------------------- | | `@vielzeug/spell` | Schema builders, errors, types, and parse contexts | | `@vielzeug/spell/json` | Convert portable definitions to JSON Schema | | `@vielzeug/spell/predicates` | Standalone format and type predicates | ```ts import { createParseContext, s, type Infer } from '@vielzeug/spell'; import { fromDefinition } from '@vielzeug/spell/json'; import { isEmail } from '@vielzeug/spell/predicates'; ``` ## `s` All builders live under `s`. | Builder | Purpose | | ----------------------------------------------------------------- | -------------------------- | | `string`, `number`, `boolean`, `bigint`, `date` | Primitive values | | `literal`, `enum`, `null`, `undefined`, `unknown`, `never` | Exact and universal values | | `array`, `tuple`, `set`, `map`, `record`, `object` | Collections | | `union`, `intersect`, `discriminatedUnion`, `lazy` | Composition | | `coerce.*` | Coercing primitive schemas | ```ts const User = s.object({ email: s.string().email(), id: s.string().uuid(), role: s.enum(['admin', 'member'] as const), }); type User = Infer; ``` Object schemas reject unknown keys. Use `.relaxed()` to retain extras. ## Standard Schema interoperability Every schema implements the dependency-free Standard Schema v1 structural protocol through `schema['~standard']`. Consumers that accept Standard Schema validators can use Spell schemas directly without an adapter. ```ts const User = s.object({ name: s.string() }); const result = await User['~standard'].validate({ name: 'Ada' }); ``` Successful results contain `value`; failures contain portable `{ message, path }` issues. Failed unions remain one stable union issue instead of selecting a branch heuristically. The exported `StandardSchemaV1` interface mirrors the full v1 protocol, including `validate(value, options?)` and the `InferInput` and `InferOutput` namespace helpers. ## Parsing Every schema provides: ```ts schema.parse(value, context?); // Output or SpellValidationError schema.safeParse(value, context?); // ParseResult schema.parseAsync(value, context?); // Promise schema.safeParseAsync(value, context?); // Promise> schema.is(value); // value is Output schema.assert(value, label?); // assertion ``` `parse()`, `safeParse()`, `is()`, and `assert()` run synchronous parsing. When a schema, or any schema nested inside it: carries an async check added with `checkAsync()`, the synchronous methods throw at runtime with "cannot evaluate async checks"; use `parseAsync()` or `safeParseAsync()` instead. Async checks compose at any nesting depth, so `s.object({ tags: s.array(s.string().checkAsync(fn)) })` parses correctly through `parseAsync()`. UI adapters that accept `AnySchema` cannot call `safeParse()` on it: the structural surface type omits it. Type adapter parameters against `SyncParsable` instead: ```ts import type { SyncParsable } from '@vielzeug/spell'; function spellValidator(schema: SyncParsable) { const result = schema.safeParse(value); // types cleanly, no shim or cast } ``` The shape is structural, so a schema with async checks is assignable to `SyncParsable`; calling `safeParse()` on one returns a failed result ("parse() cannot evaluate async checks"). When async checks may be present, use `safeParseAsync()` instead. ## Custom Checks `check()` is synchronous. `checkAsync()` is asynchronous. Do not return a Promise from `check()`. ```ts const Signup = s.object({ confirm: s.string(), password: s.string() }).check((value, context) => { if (value.password !== value.confirm) { context.addIssue({ code: 'custom', message: 'Passwords must match', path: ['confirm'] }); } }); const AvailableEmail = s .string() .email() .checkAsync(async (value) => { return (await emailAvailable(value)) || 'Email is already registered'; }); ``` `CheckContext.addIssue()` takes `{ code, message, params?, path? }`. Paths are relative to current schema. ## Modifiers and Transforms ```ts s.string().optional(); s.string().nullable(); s.string().nullish(); s.string().required(); s.string().default('guest'); s.string().catch('guest'); s.string() .trim() .transform((value) => value.toLowerCase()); s.string().pipe(s.string().slug()); s.string().label('User name'); ``` `default()`, `catch()`, preprocessors, transforms, and checks are runtime behavior. They cannot become portable definitions. ## `tolerate()` Wraps an object schema so records saved by older schema versions still parse: the named keys are accepted and stripped instead of failing `invalid_keys`. The parsed output never contains the tolerated keys. ```ts const Person = tolerate(s.object({ name: s.string() }), 'address'); ``` The implementation is a preprocessor on the wrapped schema: non-object input reaches the wrapped schema untouched, declared fields keep their validation, and unknown keys outside the tolerated set still fail. ## Definitions and JSON Schema `definition()` is only for schemas containing declarative structure. It returns frozen data and throws `SpellDefinitionError` when runtime behavior is present. ```ts import { s } from '@vielzeug/spell'; import { fromDefinition } from '@vielzeug/spell/json'; const Product = s.object({ id: s.string().uuid(), name: s.string().min(1), }); const definition = Product.definition(); const jsonSchema = fromDefinition(definition); ``` No implicit schema-to-JSON conversion exists. Make definition boundary explicit. ## Parse Contexts `createParseContext()` creates immutable request-local validation messages. ```ts function createParseContext(messages?: DeepPartial): ParseContext; ``` **Returns:** A parse context with built-in messages merged with the supplied overrides. ```ts import { createParseContext, s } from '@vielzeug/spell'; const context = createParseContext({ object: { invalidKeys: () => 'Unsupported field' }, }); const result = s.object({ email: s.string().email() }).safeParse({ email: 'ada@example.com', extra: true }, context); if (!result.success) { const messages = result.error.messagesAt('email'); console.log(messages); } ``` ## Types ### Inference types | Type | Resolves to | | --- | --- | | `Infer` | Parsed output; alias of `InferOutput` | | `InferInput` | Input accepted before parsing or transformation | | `InferOutput` | Value returned after parsing and transformation | Composite schemas preserve child input types. Object input types make fields optional when their field schema accepts `undefined`. ### Standard Schema ```ts interface StandardSchemaV1 { readonly '~standard': StandardSchemaV1.Props; } namespace StandardSchemaV1 { interface Props { readonly version: 1; readonly vendor: string; readonly validate: (value: unknown, options?: Options) => Result | Promise>; readonly types?: Types; } type Result = { readonly value: Output; readonly issues?: undefined } | { readonly issues: readonly Issue[] }; interface Options { readonly libraryOptions?: Record } interface Issue { readonly message: string; readonly path?: readonly (PropertyKey | PathSegment)[] } interface PathSegment { readonly key: PropertyKey } interface Types { readonly input: Input; readonly output: Output } type InferInput = NonNullable['input']; type InferOutput = NonNullable['output']; } ``` ### Parse result and issues ```ts type ParseResult = { data: T; success: true } | { error: SpellValidationError; success: false }; type Issue = | { code: 'custom'; message: string; params?: Record; path: (string | number)[] } | { code: 'invalid_base64'; message: string; params: { format: string }; path: (string | number)[] } | { code: 'invalid_date'; message: string; params?: undefined; path: (string | number)[] } | { code: 'invalid_duration'; message: string; params: { format: string }; path: (string | number)[] } | { code: 'invalid_enum'; message: string; params: { values: readonly unknown[] }; path: (string | number)[] } | { code: 'invalid_finite'; message: string; params?: undefined; path: (string | number)[] } | { code: 'invalid_integer'; message: string; params?: undefined; path: (string | number)[] } | { code: 'invalid_keys'; message: string; params: { keys: string[] }; path: (string | number)[] } | { code: 'invalid_length'; message: string; params: { exact: number }; path: (string | number)[] } | { code: 'invalid_literal'; message: string; params: { expected: unknown }; path: (string | number)[] } | { code: 'invalid_multiple_of'; message: string; params: { step: number | bigint }; path: (string | number)[] } | { code: 'invalid_safe'; message: string; params?: undefined; path: (string | number)[] } | { code: 'invalid_string'; message: string; params: { format?: string; includes?: string; pattern?: string; prefix?: string; suffix?: string }; path: (string | number)[]; } | { code: 'invalid_type'; message: string; params?: undefined; path: (string | number)[] } | { code: 'invalid_union'; message: string; params: { errors: Issue[][] }; path: (string | number)[] } | { code: 'invalid_unique'; message: string; params: { unique: true }; path: (string | number)[] } | { code: 'invalid_url'; message: string; params: { format: string }; path: (string | number)[] } | { code: 'invalid_variant'; message: string; params: { discriminator: string; expected: string[] }; path: (string | number)[]; } | { code: 'too_big'; message: string; params: { exclusive?: boolean; max: number | bigint | Date }; path: (string | number)[]; } | { code: 'too_small'; message: string; params: { exclusive?: boolean; min: number | bigint | Date }; path: (string | number)[]; } | { code: string & {}; message: string; params?: Record; path: (string | number)[] }; ``` `ErrorCode` is a const object mapping each issue code to its string literal. ### Validation contracts ```ts type ParseContext = { messages: Messages }; type CheckContext = { addIssue: (issue: { code: string; message: string; params?: Record; path?: (string | number)[]; }) => void; }; type ValidateResult = boolean | null | undefined | string; ``` ### Messages ```ts type MessageFn = Record> = string | ((ctx: Ctx) => string); type Messages = { array: { length: (ctx: { exact: number; value: unknown[] }) => string; max: (ctx: { max: number; value: unknown[] }) => string; min: (ctx: { min: number; value: unknown[] }) => string; nonEmpty: () => string; type: () => string; unique: () => string }; bigint: { max: (ctx: { max: bigint; value: bigint }) => string; min: (ctx: { min: bigint; value: bigint }) => string; multipleOf: (ctx: { step: bigint; value: bigint }) => string; negative: () => string; nonNegative: () => string; nonPositive: () => string; positive: () => string; type: () => string }; boolean: { type: () => string }; check: { default: () => string }; date: { max: (ctx: { max: Date; value: Date }) => string; min: (ctx: { min: Date; value: Date }) => string; type: () => string }; enum: { invalid: (ctx: { values: readonly unknown[] }) => string }; instanceof: { type: (ctx: { className: string }) => string }; literal: { expected: (ctx: { expected: unknown }) => string }; map: { max: (ctx: { max: number; value: Map }) => string; min: (ctx: { min: number; value: Map }) => string; nonEmpty: () => string; size: (ctx: { exact: number; value: Map }) => string; type: () => string }; never: { invalid: () => string }; number: { finite: () => string; int: () => string; max: (ctx: { max: number; value: number }) => string; min: (ctx: { min: number; value: number }) => string; multipleOf: (ctx: { step: number; value: number }) => string; negative: () => string; nonNegative: () => string; nonPositive: () => string; positive: () => string; safe: () => string; type: () => string }; object: { invalidKeys: (ctx: { keys: string[] }) => string; type: () => string }; set: { max: (ctx: { max: number; value: Set }) => string; min: (ctx: { min: number; value: Set }) => string; nonEmpty: () => string; size: (ctx: { exact: number; value: Set }) => string; type: () => string }; string: { base64: () => string; base64url: () => string; cuid: () => string; cuid2: () => string; date: () => string; dateTime: () => string; duration: () => string; email: () => string; emoji: () => string; endsWith: (ctx: { suffix: string; value: string }) => string; hex: () => string; hexColor: () => string; includes: (ctx: { substr: string; value: string }) => string; ip: () => string; jwt: () => string; length: (ctx: { exact: number; value: string }) => string; max: (ctx: { max: number; value: string }) => string; min: (ctx: { min: number; value: string }) => string; nanoid: () => string; nonEmpty: () => string; numeric: () => string; regex: (ctx: { value: string }) => string; semver: () => string; slug: () => string; startsWith: (ctx: { prefix: string; value: string }) => string; time: () => string; type: () => string; ulid: () => string; url: () => string; uuid: () => string }; tuple: { length: (ctx: { exact: number }) => string; min: (ctx: { min: number }) => string; type: () => string }; union: { invalid: () => string }; variant: { invalidDiscriminator: (ctx: { discriminator: string; expected: string[] }) => string; type: () => string }; }; type DeepPartial = { [K in keyof T]?: T[K] extends Record ? DeepPartial : T[K]; }; ``` ### Descriptor and JSON Schema ```ts type SchemaDescriptor = BaseDescriptor & ( | { kind: 'unknown' | 'never' | 'boolean' | 'bigint' | 'date' | 'lazy' } | { className: string; kind: 'instanceof' } | { contentEncoding?: string; format?: string; kind: 'string'; maxLength?: number; minLength?: number; pattern?: string | null } | { exclusiveMaximum?: number; exclusiveMinimum?: number; kind: 'number'; maximum?: number; minimum?: number; multipleOf?: number; typeHint?: 'integer' } | { kind: 'literal'; value: string | number | boolean | null | undefined } | { kind: 'enum'; values: readonly (string | number)[] } | { items: SchemaDescriptor; kind: 'array'; maxItems?: number; minItems?: number } | { items: SchemaDescriptor[]; kind: 'tuple'; rest: SchemaDescriptor | null } | { fields: Record; kind: 'object'; strict: boolean } | { key: SchemaDescriptor; kind: 'record'; value: SchemaDescriptor } | { items: SchemaDescriptor; kind: 'set' } | { key: SchemaDescriptor; kind: 'map'; value: SchemaDescriptor } | { branches: SchemaDescriptor[]; kind: 'union' | 'intersect' } | { branches: Record; discriminator: string; kind: 'variant' } | { from: SchemaDescriptor; kind: 'pipe'; to: SchemaDescriptor } ); type JsonSchema = Record; ``` ### Error helpers ```ts type FlatError = { messages: string[]; path: (string | number)[] }; type FlatErrorFirst = { message: string; path: (string | number)[] }; /** An issue path in either shape: spell-native segments or Standard Schema `{ key }` segments. */ type IssuePathSegment = PropertyKey | { readonly key: PropertyKey }; type IssuePath = readonly IssuePathSegment[]; /** The synchronous parse surface; `AnySchema` omits `safeParse`, adapter parameters use this. */ type SyncParsable = { safeParse(value: unknown): ParseResult }; function joinIssuePath(path: IssuePath | undefined, separator?: string): string; ``` `joinIssuePath()` flattens an issue path into a UI key such as `'permissions.0.slug'`. It accepts spell-native `(string | number)[]` paths and Standard Schema paths with `{ key }` segments, and returns `''` for empty or undefined paths. ## Errors - `SpellError`: base class. Use `instanceof SpellError` for cross-boundary narrowing. - `SpellValidationError`: validation failure with `issues`, `messagesAt()`, `flatten()`, and `flattenFirst()`. - `SpellDefinitionError`: schema cannot create portable definition. ```ts const result = s.object({ email: s.string().email() }).safeParse({ email: 'invalid' }); if (!result.success) { const { fieldErrors, formErrors } = result.error.flatten(); console.log(fieldErrors, formErrors); } ``` ### 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.issues); } 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 ``` ## Tolerating Removed Fields When a persisted record shape evolves, records saved by older versions carry keys the current schema no longer declares. `tolerate` accepts and drops exactly the named keys, so one call documents one removed field: no optional placeholder in the schema and no strip step after parsing. ```ts import { s, tolerate } from '@vielzeug/spell'; const Person = tolerate( s.object({ name: s.string(), email: s.number() }), 'address', ); const parsed = Person.parse({ name: 'John Doe', address: 'p sherman 42 wallaby way sydney', email: 'johndoe@mail.com' }); // { name: 'John Doe', email: 'johndoe@mail.com' }: the tolerated key never reaches the output ``` Fields added to later schema versions do not need `tolerate`: give them `default()` and older records repair on parse. ## Custom Validation Use `check()` for synchronous domain rules and `checkAsync()` for asynchronous rules. Sync parsing rejects schemas with asynchronous checks. ```ts import { s } from '@vielzeug/spell'; // Boolean shorthand: return false to fail with default message const EvenNumber = s.number().check((n) => n % 2 === 0); // String shorthand: return the message as a string const Username = s .string() .min(3) .check((v) => !v.startsWith('_') || 'Cannot start with underscore'); // Multiple issues via ctx.addIssue() const Signup = s.object({ confirm: s.string(), password: s.string() }).check((v, ctx) => { if (v.password !== v.confirm) { ctx.addIssue({ code: 'custom', message: 'Passwords must match', path: ['confirm'] }); } }); ``` `checkAsync()` marks a schema as asynchronous: synchronous parsing throws at runtime instead of accepting an unchecked value, so async checks require `parseAsync()` or `safeParseAsync()`. Async checks compose at any nesting depth: arrays, objects, unions, intersections, tuples, maps, records, sets, lazy schemas, pipelines, and `s.discriminatedUnion(...)` branches all evaluate nested async checks correctly through `parseAsync()`. ```ts import { s } from '@vielzeug/spell'; const takenEmails = new Set(['ada@example.com']); const AccountEmail = s .string() .email() .checkAsync(async (value, ctx) => { if (takenEmails.has(value)) { ctx.addIssue({ code: 'custom', message: 'Email is already taken', path: [] }); } }); // Async checks require parseAsync await AccountEmail.parseAsync('grace@example.com'); ``` Use `check()` for predicate-only rules too. Return `true` on success or message on failure. ```ts import { s } from '@vielzeug/spell'; const PositivePrice = s.number().check((value) => value > 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 declarative definitions when schemas need to cross process boundaries or feed tooling. ```ts import { s } from '@vielzeug/spell'; import { fromDefinition } from '@vielzeug/spell/json'; const Product = s .object({ id: s.string().uuid(), name: s.string().min(1), price: s.number().positive().multipleOf(0.01), }) .label('Product'); const definition = Product.definition(); const jsonSchema = fromDefinition(definition); Product.parse({ id: '550e8400-e29b-41d4-a716-446655440000', name: 'Keyboard', price: 129.99 }); console.log(jsonSchema.title); ``` Definitions are frozen serializable snapshots of declarative schema structure. Use `definition()` and `fromDefinition()` for external tooling. Schemas with runtime checks, transforms, defaults, catches, or preprocessors intentionally have no definition. ## Messages Spell has no mutable process-wide configuration. Build one parse context per request, locale, or form, then pass it explicitly. ```ts import { createParseContext, s } from '@vielzeug/spell'; const User = s.object({ email: s.string().email() }); const german = createParseContext({ object: { invalidKeys: () => 'Keine unbekannten Felder erlaubt' }, }); User.safeParse({ email: 'ada@example.com', extra: true }, german); ``` Internal development warnings always use `console.warn` in development builds. Route application diagnostics in application code instead of mutating library-wide logger state. ## Working with Validation Errors Use `SpellValidationError` helpers when you need UI-ready error structures. ```ts import { s, SpellValidationError } 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 && result.error instanceof SpellValidationError) { const profileErrors = result.error.messagesAt('profile', 'name'); console.log(profileErrors); } ``` Key inline errors by dot-path with `joinIssuePath()`, which also unwraps Standard Schema `{ key }` segments: ```ts import { joinIssuePath } from '@vielzeug/spell'; for (const issue of result.error.issues) { console.log(joinIssuePath(issue.path), issue.message); // 'profile.name: ...' } ``` Union failures produce one stable `invalid_union` issue. Use `s.discriminatedUnion()` when a discriminator can identify the intended object branch and provide field-specific errors. ## Validating synchronously in React Adapters that validate inside a render or `validate` callback need the sync parse surface. `AnySchema` does not expose `safeParse`; type the adapter parameter against `SyncParsable` instead: ```ts import { type SyncParsable } from '@vielzeug/spell'; function spellValidator(schema: SyncParsable) { return (values: unknown) => { const result = schema.safeParse(values); if (result.success) return undefined; return result.error.issues.map((issue) => ({ message: issue.message, path: issue.path, })); }; } spellValidator(s.object({ email: s.string() })); // no shim or cast ``` ## 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 { schemaValidator } from '@vielzeug/forge/schema'; import { createCourier } from '@vielzeug/courier'; import { s } from '@vielzeug/spell'; const Profile = s.object({ displayName: s.string().min(2), newsletter: s.boolean(), }); const form = createForm({ initialValues: { displayName: '', newsletter: false, }, validate: schemaValidator(Profile), }); const courier = createCourier({ baseUrl: '/api' }); const profile = Profile.parse(await courier.get('/profile')); ``` Use Spell definitions 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. - Use `s` consistently for construction; use explicit `/json` and `/predicates` subpaths for tooling. - 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 `check()` with a `ctx` argument when you need `ctx.addIssue()`; return a message for simple predicate failures. - Use `checkAsync()` and `parseAsync()` for every asynchronous domain rule. - Build a parse context per request or test; never rely on mutable process-wide configuration. - Use `definition()` with `fromDefinition()` from `@vielzeug/spell/json` for external tooling. ### 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) ### 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`) - Declarative Definition Export (id: `descriptor-roundtrip`) - Discriminated Union (id: `discriminated-union`) - Format Predicates (id: `format-validators`) - Request-local messages (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`) - String Validation (id: `string-validation`) - Wrappers & Defaults (id: `wrappers-and-defaults`) --- ## @vielzeug/tandem **Category:** sync ### Overview ## Why Tandem? A local-first app keeps its records in IndexedDB but must still reach a server: batch bursts of edits into one upload, propagate deletions that a plain pull would resurrect, and reconcile two devices that edited the same record while offline. Tandem drives that round trip against any backend, tracking a per-record write counter so a push carries only what actually changed. ```ts // Before // Hand-rolled: debounce each save, POST the whole table, hope deletions survive. let timer; function onSave(record) { clearTimeout(timer); timer = setTimeout(() => fetch('/sync', { method: 'POST', body: JSON.stringify(allRecords()) }), 3000); } // After import { createSync } from '@vielzeug/tandem'; const sync = createSync({ gateway, port, idleDelayMs: 3000 }); sync.changed(); // a local edit happened: the engine batches and pushes dirty records ``` | Feature | Tandem | RxSync | PowerSync | | ------------------------------------ | ------------------------------------------ | ------------------- | -------------------- | | Bundle size | | | | | Zero runtime dependencies | | | | | Backend-agnostic (bring your `port`) | | | | | Storage-agnostic (bring your `gateway`) | | | | | Tombstoned deletions + rev conflicts | | | | **Use `tandem` when** you already own a local store and a backend and want the sync scheduler: dirty tracking, batching, hide-flows, and conflict reconcile: without adopting a database or a server. **Consider `PowerSync` when** you want a managed sync service with its own SQLite client and server component rather than a scheduler over your existing storage. ## Installation ```sh [pnpm] pnpm add @vielzeug/tandem ``` ```sh [npm] npm install @vielzeug/tandem ``` ```sh [yarn] yarn add @vielzeug/tandem ``` ## Quick Start Implement the two seams: a `SyncPort` for the server and a `SyncGateway` for local storage: then start the scheduler. Records are opaque to Tandem; it reads only `id` and `rev`, and every rev comparison is the engine's: the gateway upserts, validates, and tombstones. ```ts import { createSync, type SyncGateway, type SyncPort } from '@vielzeug/tandem'; const port: SyncPort = { pull: (since) => fetch(`/sync?since=${since ?? ''}`).then((r) => r.json()), push: async (records, deletions) => { const response = await fetch('/sync', { method: 'POST', body: JSON.stringify({ records, deletions }) }); if (!response.ok) throw new Error(await response.text()); // divergence → the engine pulls to reconcile }, }; const gateway: SyncGateway = { records: () => localRecords.map((record) => ({ entity: 'docs', record })), pendingDeletions: () => readTombstones(), applyRecords: (pulled) => upsert(pulled), // the engine pre-filters; you validate untrusted records applyDeletions: (deletions) => removeLocal(deletions), clearDeletions: (deletions) => dropTombstones(deletions), loadState: () => readSyncState(), saveState: (state) => writeSyncState(state), }; const sync = createSync({ gateway, port }); sync.tap((event) => console.debug('sync', event)); // observe pushes, pulls, warnings myStore.subscribe(() => sync.changed()); // a local write happened // On account switch or teardown: after flushing this account's work: await sync.flush(); sync.dispose(); ``` ## Features - **`createSync()`**: drives a port against a gateway with a serialized push queue. - **`SyncPort`**: the server seam: `pull(since)` and `push(records, deletions)`. - **`SyncGateway`**: the storage seam: upserts, tombstones, and the persisted baseline: the engine owns every rev comparison. - **`rev` baselines**: a record is dirty exactly when its rev is above the last-synced one, across reloads. - **`changed()`**: wire it to your write path; the engine debounces a burst into one push. - **`flush()`**: a full cycle on demand: pull remote changes, then push dirty records; rejects on failure. - **Hide-flows**: flushes with `keepalive` on `pagehide`/`hidden`, runs a full cycle on return to the foreground. - **`tap()`**: typed `TandemEvent`s for pushes, pulls, invalid records, and warnings. - **Lifecycle-owned**: `dispose()`, `disposed`, `disposalSignal`, and `[Symbol.dispose]`. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Vault](/vault/): the typed storage core most gateways wrap; its IndexedDB adapter is a natural `records()`/`applyRecords()` backend. - [Postmaster](/postmaster/): the durable outbox half of offline sync; pair it with Tandem for at-least-once mutations alongside state sync. - [Sentinel](/sentinel/): subscribable browser state; its network signal can gate when you call `flush()`. - [Ripple](/ripple/): signals and effects; `store.subscribe(() => sync.changed())` is the usual wiring. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createSync()` | Scheduler driving a port against a gateway | Sync (queues async work) | Boot runs pull-then-push; dispose before switching accounts | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/tandem` | `createSync`, port/gateway contracts, events, and types | ## Factories ### `createSync()` ```ts function createSync( options: SyncOptions, ): SyncHandle; ``` Starts a sync scheduler: it loads the persisted baseline, pulls remote changes, pushes anything dirty, then flushes on `changed()`, on tab hide, and on foreground return. | Parameter | Type | Description | | --- | --- | --- | | `options.gateway` | `SyncGateway` | The device's storage seam | | `options.idleDelayMs` | `number \| undefined` | Quiet period before a flush (default `3000`) | | `options.port` | `SyncPort` | The server seam | **Returns** a `SyncHandle`. Ambient flushes never reject: they surface failures as `warning` events through `tap()`. An explicit `flush()` rejects when its cycle fails. **Example** ```ts import { createSync } from '@vielzeug/tandem'; const sync = createSync({ gateway, idleDelayMs: 1500, port }); sync.changed(); await sync.flush(); sync.dispose(); ``` #### `SyncHandle` methods | Member | Description | | --- | --- | | `changed()` | Notes a local change and (re)starts the idle flush timer | | `dispose()` | Stops the scheduler, clears the timer, removes document/window listeners | | `disposed` | `true` once disposed | | `disposalSignal` | `AbortSignal` aborted on dispose | | `flush()` | Runs a full cycle: pull, then push; resolves when it completes, rejects when it fails, and is a no-op after `dispose` | | `tap(handler, options?)` | Observes `TandemEvent`s; returns an unsubscribe function | | `[Symbol.dispose]()` | Calls `dispose()` for `using` scopes | ## Contracts ### `SyncPort` The server seam. `pull(since)` returns remote records, remote deletions, and the next cursor. `push(records, deletions, options)` sends local changes; reject it when the server diverged so the engine pulls to reconcile. `options.keepalive` marks a flush that must survive the tab closing. ### `SyncGateway` The storage seam. The engine owns every rev comparison: it calls your gateway with the decisions already made. | Member | Contract | | --- | --- | | `records()` | This device's own records, remote mirrors excluded | | `pendingDeletions()` | Tombstones the server has not acknowledged | | `applyRecords(records)` | Upsert the given records: already filtered to what the server is ahead on; return the ids that failed validation | | `applyDeletions(deletions)` | Remove records deleted remotely | | `clearDeletions(deletions)` | Drop the given tombstones a successful push carried | | `loadState()` | The persisted `SyncState`, or `null` on a fresh device | | `saveState(state)` | Persist the baseline after a completed pull or push | ## Events ### `TandemEvent` ```ts type TandemEvent = | { readonly records: number; readonly type: 'pull' } | { readonly records: number; readonly type: 'push' } | { readonly message: string; readonly skipped: readonly string[]; readonly type: 'invalid' } | { readonly message: string; readonly type: 'warning' } | { readonly type: 'dispose' }; ``` `invalid` reports record ids `applyRecords` refused; `warning` reports a rejected push or transport failure; `pull` and `push` report the record counts applied and pushed. `dispose` fires once, before tappers are cleared. ## Types ### `SyncRecordBase` ```ts interface SyncRecordBase { readonly id: string; readonly rev: number; } ``` The only shape Tandem reads from a record: identity and a write counter. `rev` is required: the dirty model is a rev comparison, so every synced record carries one from birth. ### `SyncEnvelope` ```ts interface SyncEnvelope { readonly entity: string; readonly record: TRecord; } ``` A record paired with the entity (table or kind) it belongs to. ### `SyncDeletion` ```ts interface SyncDeletion { readonly deletedAt: string; readonly entity: string; readonly id: string; } ``` A tombstone: a deletion that travels to the server on the next push. ### `SyncState` ```ts interface SyncState { cursor: string | null; revs: Record; } ``` The persisted baseline. `revs` maps `"${entity}:${id}"` to the newest rev the server has seen. ### `SyncPullResult` ```ts interface SyncPullResult { readonly cursor: string | null; readonly deletions: readonly SyncDeletion[]; readonly records: readonly SyncEnvelope[]; } ``` ### `SyncOptions` ```ts interface SyncOptions { gateway: SyncGateway; idleDelayMs?: number; port: SyncPort; } ``` ## Errors Tandem defines no error classes. A rejected `push` is expected input, not a failure: the engine pulls to reconcile and reports it as a `warning` event. `flush()` additionally rejects with the underlying port or gateway error; dirty records survive the failure and retry on the next cycle. ### Usage Guide ## Basic Usage Tandem sits between your storage and your server. You implement two interfaces and call `createSync`; the engine handles batching, dirty tracking, hide-flows, and conflict reconcile. ```ts import { createSync, type SyncGateway, type SyncPort } from '@vielzeug/tandem'; interface Doc { id: string; rev: number; title: string; } const port: SyncPort = { async pull(since) { return fetch(`/sync/pull?since=${since ?? ''}`).then((response) => response.json()); }, async push(records, deletions, options) { const response = await fetch('/sync/push', { body: JSON.stringify({ records, deletions }), headers: { 'Content-Type': 'application/json' }, keepalive: options?.keepalive, method: 'POST', }); if (!response.ok) throw new Error(`server diverged: ${await response.text()}`); }, }; const gateway: SyncGateway = { records: () => docs.map((record) => ({ entity: 'docs', record })), pendingDeletions: () => readTombstones(), async applyRecords(pulled) { // The engine already filtered these to records the server is ahead on; // validate the untrusted remotes and upsert the rest. const skipped: string[] = []; for (const { record } of pulled) { if (!isValid(record)) skipped.push(record.id); else upsert(record); } return skipped; }, applyDeletions: (deletions) => Promise.all(deletions.map((d) => remove(d.id))), clearDeletions: (deletions) => Promise.all(deletions.map((d) => dropTombstone(d.id))), loadState: () => readRow('syncState'), saveState: (state) => writeRow('syncState', state), }; const sync = createSync({ gateway, port }); ``` ## Track Dirty Records with Revs Tandem never diffs record contents. Each record carries a `rev` counting its committed writes, and the gateway persists a baseline of the newest rev the server has seen. A record is dirty exactly when its current rev is above that baseline. ```ts // Your write path bumps the rev on every committed change. function saveDoc(id: string, patch: Partial): void { const current = docs.find((doc) => doc.id === id)!; upsert({ ...current, ...patch, rev: current.rev + 1 }); sync.changed(); // schedule a flush } ``` Because the baseline is persisted, dirtiness survives a reload: a record edited while offline is still above the baseline when the app restarts, and the boot sequence pushes it. The engine never asks your gateway to compare revs: ahead, dirty, and deletion precedence are all its decisions. ## Propagate Deletions with Tombstones A plain pull cannot carry a deletion: the record is gone. Record a tombstone when you delete locally; Tandem pushes it and clears it only once the server acknowledges. ```ts function deleteDoc(id: string): void { remove(id); writeTombstone({ deletedAt: new Date().toISOString(), entity: 'docs', id }); sync.changed(); } ``` A remote deletion loses to a local record with unpushed changes: if your local rev is above the baseline, Tandem keeps your edit and pushes it instead of applying the deletion. The guard runs both ways: a pulled record whose id is locally tombstoned, or deleted in the same pull, never applies, so a backend without an incremental cursor cannot resurrect your deletions. ## Batch Changes and Flush Call `changed()` on every local write. The engine debounces a burst into one push after `idleDelayMs` (default 3000), and serializes pushes so two never overlap. ```ts const sync = createSync({ gateway, idleDelayMs: 1500, port }); // Force a full cycle now: on a save button, or before navigating away. try { await sync.flush(); } catch { // the cycle failed: edits stay local and dirty; the next cycle retries them } ``` `flush()` pulls remote changes and then pushes dirty records, so it is also the wiring point for a returning network connection. It rejects when either half fails; the records stay dirty and the next cycle retries them. A `flush()` after `dispose()` resolves as a no-op. Tandem also flushes on its own: it pushes with `keepalive: true` when the tab hides or closes, and runs a full cycle when the tab returns to the foreground. Serve the keepalive push with `navigator.sendBeacon` or a `keepalive` fetch so it survives the tab closing. ## Reconcile a Rejected Push When the server is ahead of the lineage you pushed, reject the push. Tandem immediately pulls to reconcile, then the next flush retries with the merged state. Ambient flushes (debounce, hide) report the failure as a `warning` event; `flush()` additionally rejects with the port's error. ```ts async push(records, deletions) { const response = await fetch('/sync/push', { body: JSON.stringify({ records, deletions }), method: 'POST' }); if (response.status === 409) throw new Error('server ahead'); // triggers a reconcile pull } ``` ## Observe with tap() `tap()` is the observation seam: pushes, pulls, refused records, and warnings. Handler errors are swallowed, so an observer never breaks sync. ```ts const unsubscribe = sync.tap((event) => { switch (event.type) { case 'push': case 'pull': console.debug(`sync ${event.type}: ${event.records} records`); break; case 'invalid': console.warn(event.message, event.skipped); break; case 'warning': console.warn(event.message); // push rejected, network failure, etc. break; } }); ``` ## Lifecycle and Disposal Dispose before switching accounts: the baseline is per-account state and a running scheduler would push into the wrong port. ```ts const sync = createSync({ gateway, port }); // ... sync.dispose(); // stops the scheduler, clears the timer, removes document/window listeners ``` `disposed` and `disposalSignal` report the state; `[Symbol.dispose]` lets you scope it with `using`. ```ts { using sync = createSync({ gateway, port }); sync.changed(); } // disposed here ``` ## Testing Drive the engine against in-memory doubles. A `SyncPort` double records pushes and returns scripted pulls; a `SyncGateway` double is a record array plus a tombstone list plus a state row. ```ts import { createSync } from '@vielzeug/tandem'; const pushes: unknown[][] = []; const port: SyncPort = { pull: async () => ({ cursor: null, deletions: [], records: [] }), push: async (records, deletions) => void pushes.push([records, deletions]), }; const sync = createSync({ gateway: fakeGateway, idleDelayMs: 10, port }); await vi.waitFor(() => expect(pushes.length).toBe(1)); // startup push sync.dispose(); ``` ## Framework Integration Tandem is framework-neutral. Wire `changed()` to your reactive layer and `dispose()` to the component or scope lifecycle. ```ts [Vue 3] import { onScopeDispose, watch } from 'vue'; const sync = createSync({ gateway, port }); watch(store.records, () => sync.changed(), { deep: true }); onScopeDispose(() => sync.dispose()); ``` ```tsx [React] import { useEffect, useRef } from 'react'; const syncRef = useRef(createSync({ gateway, port })); useEffect(() => { const sync = syncRef.current; return () => sync.dispose(); }, []); ``` ```svelte [Svelte] import { onDestroy } from 'svelte'; const sync = createSync({ gateway, port }); $effect(() => { $records; sync.changed(); }); onDestroy(() => sync.dispose()); ``` ## Working with Other Vielzeug Libraries - **Vault**: the usual `gateway` backend. Its IndexedDB adapter stores records and the sync-state row; `records()` reads the in-memory mirror, `applyRecords()` writes through the codec. - **Postmaster**: complementary, not overlapping. Postmaster guarantees at-least-once delivery of discrete mutations; Tandem keeps whole-record state in step. Apps that need both run them side by side. - **Sentinel**: subscribe to `createNetwork()` and call `sync.flush()` when the connection returns; the cycle pulls and pushes, so remote changes land without waiting for the next foreground return. - **Ripple**: `store.subscribe(() => sync.changed())` is the idiomatic wiring for a signal-backed store. ## Best Practices - Bump `rev` on every committed write: the whole dirty model depends on it. - Validate remote records in `applyRecords()` and return the ids you refuse; never trust a pulled record. - Keep `records()` free of remote session mirrors: it must return only this device's own records. - Dispose on account switch; a stale scheduler pushes into the wrong port. - Serve `keepalive` pushes with `sendBeacon` or a `keepalive` fetch so they survive tab close. - Reject a push on divergence instead of silently overwriting; let the reconcile pull merge. - Persist the sync-state row per account, alongside the records it describes. - Call `flush()` before actions that assume both sides are current, such as a share or export, and handle its rejection. ### Examples ## Examples - [Sync a Vault Store](./examples/sync-a-vault-store.md) - [Keep Deletions from Resurrecting](./examples/keep-deletions-from-resurrecting.md) - [Switch Accounts Safely](./examples/switch-accounts-safely.md) ### REPL Examples - createSync - Batched Pushes (id: `create-sync`) --- ## @vielzeug/tavern **Category:** utilities ### Overview ## Why Tavern? Peer-to-peer apps share one arrangement problem: the canonical state of something: a document, a dashboard, a record: lives on exactly one device, while every other participant should see it and be able to change it from their own. Tavern turns that arrangement into a session: **one host owns the canonical state, guests mirror it, and every guest command is forwarded through the host's own command table**, so remote actions follow exactly the same code path as local ones. ```ts // Before: hand-rolled session orchestration: pairing, protocol validation, // command routing, snapshot broadcasting, notice relaying, presence tracking. const host = createMeshHost({ /* ... */ }); host.on('command', ({ peerId, payload }) => { const command = mySchema.safeParse(payload); if (!command.success) { host.send(peerId, 'rejected', { /* ... */ }); return; } try { applySubjectCommand(command.data.name, subject, command.data.args); } catch (error) { host.send(peerId, 'rejected', { /* ... */ }); } }); bus.on('subject:updated', () => { host.broadcast('snapshot', snapshotFor(subject)); }); // ... 40+ more lines of plumbing // After: tavern owns the protocol, the validation, the broadcasting. const host = hostTavern({ commands: { apply: (name, args) => store.apply(name, args), has: (name) => name in store }, subjectId: 'doc-1', subjects: { onChanged: (listener) => store.onUpdated('doc-1', listener), onRemoved: (listener) => store.onRemoved('doc-1', listener), snapshot: () => store.read('doc-1'), }, }); ``` | Feature | Tavern | Raw mesh | Socket.io rooms | | --- | --- | --- | --- | | Bundle size | | | N/A | | Zero dependencies | | | | | Host-authoritative commands | : same table as local UI | : wire it yourself | : wire it yourself | | Snapshot replication | : microtask-coalesced | | | | Out-of-band pairing (QR / copy-paste) | | | : needs a server | | Notice relaying | : consumer-defined wire shape | | : server-mediated | **Use Tavern when** one device should own the canonical state of something: a shared document, a live dashboard, a record under review, and every other device should mirror it and send commands back through the host's own command table, which stays the single validation path. **Consider raw `@vielzeug/mesh` when** you need P2P messaging without the host-authoritative replication model: chat, whiteboards, file transfer, or custom topologies. ## Installation ```sh [pnpm] pnpm add @vielzeug/tavern ``` ```sh [npm] npm install @vielzeug/tavern ``` ```sh [yarn] yarn add @vielzeug/tavern ``` ## Quick Start ```ts import { hostTavern, joinTavern } from '@vielzeug/tavern'; // Host: pick a subject, wire the command table and snapshot access. const host = hostTavern({ commands: { apply: (name, args) => myStore.apply(name, args), has: (name) => name in myStore, }, subjectId: 'doc-1', subjects: { onChanged: (listener) => myStore.onUpdated('doc-1', listener), onRemoved: (listener) => myStore.onRemoved('doc-1', listener), snapshot: () => myStore.read('doc-1'), }, }); // Produce a single-use invitation; deliver it out-of-band (QR, copy/paste). const invitation = await host.createInvitationText(); // Guest: consume the invitation, produce an answer for the host. const { answerText, guest } = await joinTavern({ invitationText: invitation, mount: (snapshot) => parseAndMount(snapshot), name: 'Alex', }); await host.acceptAnswerText(answerText); // The guest's commands run through the host's own table. guest.sendCommand('doc-1', 'rename', ['Quarterly report']); // Cleanup: hosting ends for every guest; onEnded fires exactly once on both sides. host.dispose(); guest.dispose(); ``` ## Features - **`hostTavern`**: host one subject: pairing, protocol validation, command application, coalesced snapshot broadcasts - **`joinTavern`**: join as a guest: consume the invitation, produce the answer, mirror the host's snapshots - **`TavernCommands`**: the host's command table: the same object the host's own UI calls, so remote actions cannot bypass validation - **`TavernNotices`**: consumer-defined notice serialization: catalog keys cross the wire, each client translates locally - **Coalesced snapshots**: bursts of local changes ship one snapshot per microtask, not one per change - **`onEnded` fires exactly once**: on both handles: channel drops, subject removal, and explicit disposal share a single idempotent cleanup path - **Typed errors**: `TavernPairingError` covers every unusable pasted code with the mesh failure chained as `cause` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) ## See Also - [Mesh](/mesh/): the P2P transport underneath: WebRTC pairing, data channels, QR-optimized codecs. - [Ledger](/ledger/): undo/redo history for the commands the host applies. - [Herald](/herald/): typed event bus for the local notice stream tavern relays. ### API Reference ## API Overview | Symbol | Purpose | Execution | Common gotcha | | --- | --- | --- | --- | | `hostTavern()` | Host a subject: pairing, command validation, snapshot broadcast | Sync factory | Disposing ends hosting for every guest | | `joinTavern()` | Join a session: consume invitation, produce answer | Async | The session lives only after the host accepts and the first snapshot mounts | | `TavernHost` | Host handle: invitation, answer acceptance, kick, notice relay | N/A | `dispose()` is idempotent | | `TavernGuest` | Guest handle: command forwarding, disposal | N/A | `dispose()` fires `onEnded` exactly once | | `TavernCommands` | The host's command table | N/A | Unknown names and foreign subject ids reject without applying | | `TavernSubjects` | Snapshot reading, change and removal subscriptions | N/A | `snapshot` returning `null` skips the broadcast | | `TavernNotices` | Wire serialization of local notices | N/A | `toWire` returning `null` skips that notice | | `TavernError` | Base error for every Tavern failure | N/A | N/A | | `TavernPairingError` | The consumer pasted the wrong kind of pairing code | N/A | N/A | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/tavern` | Complete public Tavern API | ## `hostTavern(options)` Creates a mesh host for one subject. Guest commands are validated against the wire shape (Tavern's own protocol), checked against `commands.has` and the `subjectId`, then applied through `commands.apply`: throwing rejects the guest with the error's message. Local changes (via `subjects.onChanged`) re-broadcast `subjects.snapshot()`: coalesced on a microtask; `subjects.onRemoved` ends hosting. Peers are reported through `onPeersChanged`, `onPeerJoined`, and `onPeerLeft`; hosting ending: from removal or `dispose()`: fires `onEnded` exactly once. `relayNotice` serializes through `notices.toWire` and broadcasts to every guest. **Returns:** `TavernHost`: the host handle. | Option | Type | Description | | --- | --- | --- | | `commands` | `TavernCommands` | The host's command table: the same object the host's own UI calls | | `notices?` | `TavernNotices` | Notice relay; omit to disable notice broadcasting | | `onEnded?` | `() => void` | Hosting ended: the subject was removed or `dispose()` ran. Fires exactly once | | `onPeersChanged?` | `(peers: MeshPeer[]) => void` | The full peer list, whenever it changes | | `onPeerJoined?` | `(peer: MeshPeer) => void` | A peer joined | | `onPeerLeft?` | `(peer: MeshPeer) => void` | A peer left | | `onWarning?` | `(message: string) => void` | A transport-level warning | | `rtc?` | `MeshRtcFactory` | WebRTC factory: the injection point for tests and non-browser runtimes | | `subjectId` | `string` | The id of the hosted subject: guest commands targeting anything else reject | | `subjects` | `TavernSubjects` | Snapshot reading, change and removal subscriptions | **Example** ```ts import { hostTavern } from '@vielzeug/tavern'; const host = hostTavern({ commands: { apply: (name, args) => myStore.apply(name, args), has: (name) => name in myStore, }, subjectId: 'doc-1', subjects: { onChanged: (listener) => myStore.onUpdated('doc-1', listener), onRemoved: (listener) => myStore.onRemoved('doc-1', listener), snapshot: () => myStore.read('doc-1'), }, }); ``` ### `TavernHost` members | Member | Returns | Description | | --- | --- | --- | | `acceptAnswerText(text)` | `Promise` | Consumes a guest's answer code; resolves with the peer once the channel opens. Throws `TavernPairingError` for any unusable code: wrong kind, malformed, expired: with the underlying error as `cause` | | `createInvitationText()` | `Promise` | Produces a single-use invitation code, QR-compact when the environment allows | | `disposalSignal` | `AbortSignal` | Aborted when hosting ends | | `dispose()` | `void` | Stops hosting: closes every channel and detaches all subscriptions. Idempotent; fires `onEnded` exactly once | | `disposed` | `boolean` | Whether hosting has ended | | `kick(peerId)` | `void` | Disconnects a peer; the guest observes a disconnect | | `relayNotice(notice)` | `void` | Relays one local notice to every guest; the serializer decides what crosses | | `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations | --- ## `joinTavern(options)` Consumes an invitation and returns `{ answerText, guest }`. The guest mounts snapshots through `mount(snapshot)`: the first successful mount fires `onJoined`; wire notices are relayed through `notices.fromWire`; rejections through `onRejected`. Channel failures before the first mount fire `onFailed` (once); afterwards, a disconnect or `guest.dispose()` fires `onEnded` with the mounted subject exactly once. `guest.sendCommand` forwards a command to the host with the subject id the consumer routes by. An unusable invitation: wrong kind, malformed, expired: rejects with `TavernPairingError` and leaves no node or subscriptions behind. **Returns:** `Promise`: the answer code to show back and the guest handle. | Option | Type | Description | | --- | --- | --- | | `invitationText` | `string` | The invitation code the host produced | | `mount` | `(snapshot: unknown) => Mounted \| null` | Mounts a received snapshot; the mounted value, or `null` to ignore | | `name` | `string` | The guest's display name on the channel | | `notices?` | `TavernNotices` | Notice relay; wire notices re-emit locally through `fromWire` | | `onEnded?` | `(subject: Mounted) => void` | The channel ended after joining; the mounted value is passed for unmounting | | `onFailed?` | `(reason: string) => void` | The host never accepted the answer in time | | `onJoined?` | `(subject: Mounted) => void` | The mounted value once the first snapshot arrives | | `onRejected?` | `(message: string) => void` | The host rejected a forwarded command | | `onWarning?` | `(message: string) => void` | A transport-level warning | | `rtc?` | `MeshRtcFactory` | WebRTC factory: the injection point for tests and non-browser runtimes | **Example** ```ts import { joinTavern } from '@vielzeug/tavern'; const { answerText, guest } = await joinTavern({ invitationText: invitation, mount: (snapshot) => sanitizeAndMount(snapshot), name: 'Alex', onEnded: (subject) => unmountMirror(subject.id), onJoined: (subject) => mountMirror(subject), }); guest.sendCommand('doc-1', 'rename', ['New name']); guest.dispose(); ``` ### `TavernGuest` members | Member | Returns | Description | | --- | --- | --- | | `disposalSignal` | `AbortSignal` | Aborted when the session ends | | `dispose()` | `void` | Leaves the session and drops the channel. Fires `onEnded` exactly once | | `disposed` | `boolean` | Whether the session has ended: the channel dropped or `dispose()` ran | | `sendCommand(subjectId, name, args)` | `void` | Forwards a command to the host. Throws `TavernError` if the session has ended | | `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations | --- ## Types ```ts /** What the host can do on behalf of a guest. */ interface TavernCommands { /** Whether a command name is known; unknown names reject without applying. */ has(name: string): boolean; /** Applies the command; throwing rejects the guest with the error's message. */ apply(name: string, args: readonly unknown[]): unknown; } /** * How the host reads and watches the subject it is sharing. All three close over * whatever subject state the consumer owns: the host routes by `subjectId`, * not by a subject object. */ interface TavernSubjects { /** Reads the snapshot to broadcast; null while the subject is missing. */ snapshot(): unknown; /** Subscribes to local changes of the hosted subject: the re-broadcast trigger. */ onChanged(listener: () => void): () => void; /** Subscribes to the hosted subject's removal: hosting ends when it fires. */ onRemoved(listener: () => void): () => void; } /** * Notice relay between host and guests. Notices cross the wire as opaque * values: each client translates locally. */ interface TavernNotices { /** Serializes a local notice for the wire; null skips this one. */ toWire(notice: unknown): unknown | null; /** Re-emits a received wire notice locally. */ fromWire(wire: unknown): void; } /** Options for `hostTavern`. */ interface TavernHostOptions { commands: TavernCommands; notices?: TavernNotices; /** Hosting ended: the subject was removed or `dispose()` ran. Fires exactly once. */ onEnded?(): void; onPeersChanged?(peers: MeshPeer[]): void; onPeerJoined?(peer: MeshPeer): void; onPeerLeft?(peer: MeshPeer): void; onWarning?(message: string): void; rtc?: MeshRtcFactory; subjectId: string; subjects: TavernSubjects; } /** Options for `joinTavern`. */ interface TavernGuestOptions { invitationText: string; /** Mounts a received snapshot; the mounted value, or null to ignore. */ mount(snapshot: unknown): Mounted | null; name: string; notices?: TavernNotices; onEnded?(subject: Mounted): void; onFailed?(reason: string): void; onJoined?(subject: Mounted): void; onRejected?(message: string): void; onWarning?(message: string): void; rtc?: MeshRtcFactory; } /** Host handle returned by `hostTavern`. */ interface TavernHost { acceptAnswerText(text: string): Promise; createInvitationText(): Promise; readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; kick(peerId: string): void; relayNotice(notice: unknown): void; [Symbol.dispose](): void; } /** Guest handle returned by `joinTavern`. */ interface TavernGuest { readonly disposalSignal: AbortSignal; dispose(): void; readonly disposed: boolean; sendCommand(subjectId: string, name: string, args: readonly unknown[]): void; [Symbol.dispose](): void; } ``` ## Errors | Error | Triggered by | Notable properties | | --- | --- | --- | | `TavernError` | Base class for every Tavern failure: catch this to handle all Tavern errors in one branch | N/A | | `TavernPairingError` | A pairing code the consumer pasted could not be used: wrong kind, malformed, expired, or refused by the host. The mesh-level failure is chained as `cause` | `cause` | `TavernPairingError` extends `TavernError`. Every user-input pairing mistake: a garbage code, the wrong code kind, an expired invitation, a refused answer: surfaces as `TavernPairingError` from `acceptAnswerText` and `joinTavern`; transport-level failures (timeouts, connection errors) propagate unchanged. `TavernGuest.sendCommand` throws `TavernError` when the session has already ended. ### Usage Guide ## Basic Usage Host one subject per session; guests join through the invitation/answer pair. Both sides share one command table, so remote actions run the same code as local ones. ```ts import { hostTavern, joinTavern } from '@vielzeug/tavern'; const host = hostTavern({ commands: { apply: (name, args) => myStore.apply(name, args), has: (name) => name in myStore }, subjectId: 'doc-1', subjects: { onChanged: (listener) => myStore.onUpdated('doc-1', listener), onRemoved: (listener) => myStore.onRemoved('doc-1', listener), snapshot: () => myStore.read('doc-1'), }, }); const invitation = await host.createInvitationText(); const { answerText, guest } = await joinTavern({ invitationText: invitation, mount: (snapshot) => parseAndMount(snapshot), name: 'Alex', }); await host.acceptAnswerText(answerText); guest.sendCommand('doc-1', 'rename', ['Quarterly report']); ``` ## Host a Subject The host validates every guest command through the command table and broadcasts the snapshot on every local change: coalesced on a microtask, so bursts of changes ship one snapshot, not many. `onEnded` fires exactly once when hosting ends, whether the subject was removed or the host was disposed: reset your session UI there instead of intercepting your own `onRemoved` seam. ```ts import { hostTavern } from '@vielzeug/tavern'; const host = hostTavern({ commands: { apply: (name, args) => myStore.apply(name, args), has: (name) => name in myStore, }, onEnded: () => updateSessionUi(), // hosting ended: subject removed or disposed onPeersChanged: (peers) => updatePeerList(peers), onPeerJoined: (peer) => toast(`${peer.name ?? 'A guest'} joined`), onPeerLeft: (peer) => toast(`${peer.name ?? 'A guest'} left`), onWarning: (message) => log.warn(message), subjectId: 'doc-1', subjects: { onChanged: (listener) => myStore.onUpdated('doc-1', listener), onRemoved: (listener) => myStore.onRemoved('doc-1', listener), snapshot: () => myStore.read('doc-1'), }, }); const invitation = await host.createInvitationText(); // Show the invitation (QR or copy/paste); the guest returns an answer code. await host.acceptAnswerText(answerCode); // Kick a peer; the guest observes a disconnect and fires onEnded. host.kick(peerId); host.dispose(); // stop hosting: every guest disconnects ``` ## Join as a Guest Joining consumes the invitation and produces the answer code to show back. The session goes live once the host accepts the answer and the first snapshot arrives. ```ts import { joinTavern } from '@vielzeug/tavern'; const { answerText, guest } = await joinTavern({ invitationText: invitation, mount: (snapshot) => sanitizeAndMount(snapshot), name: 'Alex', onEnded: (subject) => unmountMirror(subject.id), onFailed: (reason) => showError(reason), onJoined: (subject) => mountMirror(subject), onRejected: (message) => showToast(message), }); guest.sendCommand('doc-1', 'rename', ['New name']); guest.dispose(); // leave; onEnded fires with the mounted subject exactly once ``` The snapshot arrives as parsed JSON: `mount` receives `unknown` and should validate before trusting it. Returning `null` ignores the snapshot; returning the mounted value fires `onJoined` (first time only) and passes it to `onEnded` when the session ends. ## Relay Notices Give both sides a notice seam: catalog keys rather than prose, so each client translates locally, and the host relays its own notices to every guest. ```ts import type { TavernNotices } from '@vielzeug/tavern'; const notices: TavernNotices = { fromWire: (wire) => bus.emit('notify', wire), toWire: (notice) => (isLocalNotice(notice) ? toWireShape(notice) : null), }; // Host side: forward every local notice through the session. host.relayNotice(localNotice); ``` **Echo guard**: a tab that both hosts and guests must prevent re-broadcasting a wire-originated notice back to its own guests. One boolean set during `fromWire` and checked in `toWire` is sufficient: the consumer owns this because only they know their local event system. ## Handle Pairing Mistakes Every user-input pairing failure surfaces as `TavernPairingError` from both `acceptAnswerText` and `joinTavern`: a garbage code, the wrong code kind, an expired invitation, or an answer the host refuses. Catch it to show a helpful message instead of a raw error; the underlying mesh failure is chained as `cause` when you need it. ```ts import { hostTavern, TavernPairingError } from '@vielzeug/tavern'; try { await host.acceptAnswerText(pastedCode); } catch (error) { if (error instanceof TavernPairingError) { showToast(error.message); // "That code is an invitation, not a guest answer." } else { throw error; } } ``` ## Working with Other Vielzeug Libraries Tavern builds on `@vielzeug/mesh` for the WebRTC transport, pairing codes, and QR-optimized codecs. You do not interact with mesh directly: tavern owns the protocol, but the `rtc` option accepts a `MeshRtcFactory` for testing or non-browser runtimes, and the peer and status types in callbacks are mesh types. ```ts import type { MeshRtcFactory } from '@vielzeug/mesh'; import { hostTavern } from '@vielzeug/tavern'; const host = hostTavern({ // ... commands, subjects rtc: myTestRtcFactory, // inject an in-memory WebRTC fake subjectId: 'doc-1', }); ``` Pair `@vielzeug/ledger` on the host to give the commands tavern applies an undo history: commands applied through the table land in the ledger like local ones. ## Best Practices - Keep the command table the same object the host's own UI calls, so guest actions cannot bypass local validation. - Return `null` from `snapshot` while the subject is missing: the broadcast simply skips. - Reset host-side session UI in `onEnded`: it fires exactly once for subject removal and explicit disposal alike, so you never need to double-wire your own `onRemoved` seam. - Treat notices as catalog keys, not prose: each client translates locally. - Guard against notice echo if a tab can both host and guest: one boolean in `toWire` is sufficient. - Dispose guests and hosts when their screen unmounts; channels also clean up on disconnect through `onEnded`, which fires exactly once on both sides. - Catch `TavernPairingError` separately from other errors: it means the pasted code could not be used, not that something is broken. ### Examples ## Examples - [Share State Across Devices](./examples/share-state-across-devices.md) --- ## @vielzeug/tempo **Category:** time ### Overview ## Why Tempo? Date/time bugs come from treating an instant and a wall-clock value as interchangeable. Tempo requires an explicit parse target and requires `timeZone` whenever a wall-clock value becomes an instant. ```ts // Before const reminder = new Date(meeting.getTime() - 15 * 60_000); // After import { parse, shift } from '@vielzeug/tempo'; const localMeeting = parse('2026-03-21T10:30:00', { as: 'plainDateTime' }); const reminder = shift(localMeeting, { minutes: -15 }, { timeZone: 'America/New_York' }); ``` | Feature | Tempo | date-fns | Native Date | | --- | --- | --- | --- | | Bundle size | | ~10 kB | 0 kB | | Zero dependencies | `@js-temporal/polyfill` | | | | Explicit wall-time conversion | | Manual | | | DST-safe arithmetic | | Manual | Manual | | Localized formatting | `Intl` | | | **Use Tempo when** you need Temporal values, explicit timezone rules, and DST-safe operations. **Consider native `Date` when** your data is only elapsed milliseconds and you do not need calendar or timezone behavior. ## Installation ```sh [pnpm] pnpm add @vielzeug/tempo ``` ```sh [npm] npm install @vielzeug/tempo ``` ```sh [yarn] yarn add @vielzeug/tempo ``` ## Quick Start Parse a wall-clock input explicitly, attach its timezone, then format it for a user. ```ts import { format, parse, shift } from '@vielzeug/tempo'; const localMeeting = parse('2026-03-21T10:30:00', { as: 'plainDateTime' }); const reminder = shift(localMeeting, { minutes: -15 }, { timeZone: 'America/New_York' }); const text = format(reminder, { locale: 'en-US', pattern: 'short', }); ``` ## Features - `parse()`: Requires an explicit ISO target: instant, zoned date-time, plain date-time, or plain date. - `toInstant()` / `inTimeZone()`: Convert wall-clock and absolute values with explicit timezone semantics. - `shift()` / `difference()`: Apply DST-safe arithmetic across instant, zoned, and wall-clock inputs. - `contains()` / `clamp()`: Compare normalized ranges with optional calendar-unit precision. - `startOf()` / `endOf()`: Resolve timezone-aware calendar boundaries, including configurable week starts. - `dateRange()` / `recurrence()`: Generate validated, lazy zoned calendar sequences. - `format()` / `formatRelative()` / `formatDuration()`: Render localized values through `Intl`, including calendar-aware relative months and years. - `formatTimer()`: Deterministic stopwatch clocks (`47:12`, `1:02:35`) for elapsed-time UI. - `classifyExpiry()`: Classify fixed elapsed-time thresholds without month or year approximation. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Rune](/rune/): format stable Temporal timestamps before writing structured log records. - [Vault](/vault/): derive explicit expiry moments before storing records with TTL policies. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `parse()` | Parse ISO text to an explicit Temporal kind | Sync | `as` is required | | `isValid()` | Narrow an unknown runtime value to `TimeInput` | Sync | Does not parse strings | | `toInstant()` | Resolve a value as an absolute instant | Sync | Plain values require `timeZone` | | `inTimeZone()` | Project a value to a zone | Sync | Preserves instant, changes wall-clock fields | | `shift()` / `difference()` | Timezone-aware arithmetic | Sync | Calendar operations require a timezone | | `isBefore()` / `isAfter()` / `isSame()` | Compare absolute values or calendar units | Sync | Plain values require `timeZone` | | `contains()` / `clamp()` | Compare normalized ranges | Sync | Bounds normalize automatically | | `startOf()` / `endOf()` | Resolve calendar boundaries | Sync | Weeks default to Monday | | `dateRange()` / `recurrence()` | Generate lazy zoned sequences | Sync | Steps and intervals must advance | | `classifyExpiry()` | Classify fixed elapsed-time thresholds | Sync | Months and years are rejected | | `format()` family | Localized and machine formatting | Sync | Use `timeZone`, not `tz` | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/tempo` | Tempo utilities, errors, types, and shared `Temporal` namespace | ## Core Functions ### `parse(input, { as })` ```ts parse(input: string, options: { as: 'instant' }): Temporal.Instant; parse(input: string, options: { as: 'zonedDateTime' }): Temporal.ZonedDateTime; parse(input: string, options: { as: 'plainDateTime' }): Temporal.PlainDateTime; parse(input: string, options: { as: 'plainDate' }): Temporal.PlainDate; ``` Parses an ISO 8601 string as the requested temporal kind. **Parameters** | Parameter | Type | Description | | --- | --- | --- | | `input` | `string` | ISO 8601 input | | `options.as` | `ParseAs` | Required result kind | **Returns:** Requested Temporal value. **Example:** ```ts import { parse } from '@vielzeug/tempo'; const instant = parse('2026-03-21T10:15:30Z', { as: 'instant' }); ``` --- ### `isValid(value)` ```ts isValid(value: unknown): value is TimeInput; ``` Returns whether `value` is a Tempo-supported Temporal value. It does not parse ISO strings. **Example:** ```ts import { isValid, parse } from '@vielzeug/tempo'; const value: unknown = parse('2026-03-21T10:15:30Z', { as: 'instant' }); const valid = isValid(value); // true ``` --- ### `now({ timeZone })` / `nowInstant()` ```ts now(options: { timeZone: string }): Temporal.ZonedDateTime; nowInstant(): Temporal.Instant; ``` Returns current zoned or absolute time. **Example:** ```ts import { now, nowInstant } from '@vielzeug/tempo'; now({ timeZone: 'Europe/Berlin' }); nowInstant(); ``` --- ### `toInstant(input, options?)` / `inTimeZone(input, timeZone)` ```ts toInstant(input: AbsoluteTime): Temporal.Instant; toInstant(input: WallTime, options: { timeZone: string } & DisambiguationOptions): Temporal.Instant; inTimeZone(input: TimeInput, timeZone: string): Temporal.ZonedDateTime; ``` `toInstant()` resolves wall-clock values. `inTimeZone()` projects a value into a requested timezone. **Example:** ```ts import { inTimeZone, parse, toInstant } from '@vielzeug/tempo'; const local = parse('2026-11-01T01:30:00', { as: 'plainDateTime' }); const instant = toInstant(local, { disambiguation: 'later', timeZone: 'America/New_York' }); inTimeZone(instant, 'Europe/Berlin'); ``` --- ## Arithmetic and Comparison ### `shift(input, duration, options?)` ```ts shift(input: Temporal.ZonedDateTime, duration: Temporal.DurationLike, options?: ShiftOptions): Temporal.ZonedDateTime; shift(input: Exclude, duration: Temporal.DurationLike, options: ShiftOptions & { timeZone: string }): Temporal.ZonedDateTime; ``` Adds a duration through zoned calendar rules. **Returns:** `Temporal.ZonedDateTime`. --- ### `difference({ start, end, ...options })` ```ts difference(input: DifferenceInput): Temporal.Duration; ``` Returns the signed duration from `start` to `end` with optional Temporal rounding. **Returns:** `Temporal.Duration`. --- ### `isBefore()` / `isAfter()` / `isSame()` ```ts isBefore(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean; isAfter(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean; isSame(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean; ``` Compare absolute instants or their containing calendar units. **Returns:** A boolean comparison result. --- ### `contains({ value, start, end, ...options })` / `clamp({ value, start, end, ...options })` ```ts contains(input: ContainsInput): boolean; clamp(input: ClampInput & { value: Temporal.ZonedDateTime }): Temporal.ZonedDateTime; clamp(input: ClampInput): Temporal.Instant; ``` Normalizes reversed bounds, then checks or constrains the value. **Returns:** `contains()` returns `boolean`; `clamp()` returns the normalized value or nearest bound. --- ### `startOf()` / `endOf()` ```ts startOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime; endOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime; ``` Returns the first or last nanosecond of a timezone-aware calendar unit. **Returns:** `Temporal.ZonedDateTime`. ## Calendar Sequences ### `dateRange(start, end, step, options?)` ```ts dateRange(start: TimeInput, end: TimeInput, step: Temporal.DurationLike, options?: TimeZoneOptions): Generator; ``` Returns an inclusive lazy zoned range and rejects steps that do not advance time. **Returns:** `Generator`. --- ### `recurrence(start, rule, options?)` ```ts recurrence(start: TimeInput, rule: RecurrenceRule, options?: TimeZoneOptions): Generator; ``` Returns a count- or date-limited lazy recurrence. Intervals must be positive safe integers; counts must be non-negative safe integers. **Returns:** `Generator`. ## Formatting ### `format(input, options?)` ```ts format(input: TimeInput, options?: FormatOptions): string; ``` Formats a value through `Intl.DateTimeFormat`. **Example:** ```ts import { format, parse } from '@vielzeug/tempo'; format(parse('2026-03-21T10:15:30Z', { as: 'instant' }), { locale: 'en-GB', pattern: 'short', timeZone: 'UTC', }); ``` ### `formatInstant()` / `formatZoned()` / `formatRelative()` / `formatDuration()` / `formatTimer()` ```ts formatInstant(input: TimeInput, options?: TimeZoneOptions): string; formatZoned(input: TimeInput, options?: TimeZoneOptions): string; formatRelative(input: RelativeTimeInput, options?: RelativeFormatOptions): string; formatDuration(input: string | Temporal.DurationLike, options?: DurationFormatOptions): string; formatTimer(input: string | Temporal.DurationLike, options?: TimerFormatOptions): string; ``` `formatInstant()` produces UTC transport text (`timeZone` needed for wall-time input, ignored for `Instant`). `formatZoned()` produces zoned ISO text (`timeZone` required for non-`ZonedDateTime` input). `formatRelative()` uses fixed units for short spans and complete calendar months or years in the requested or inferred timezone. Zoned inputs with different zones require `options.timeZone`. `formatDuration()` falls back to English when `Intl.DurationFormat` is unavailable. `formatTimer()` renders a stopwatch-style clock: `47:12` under an hour, `1:02:35` from an hour up: deterministic and locale-independent; sub-second parts are truncated, negative durations clamp to zero, `days` fold into hours, and durations above days throw `TempoInvalidInputError`. ### `formatParts()` / `formatRange()` / `formatRangeParts()` ```ts formatParts(input: TimeInput, options?: FormatOptions): Intl.DateTimeFormatPart[]; formatRange(start: TimeInput, end: TimeInput, options?: FormatOptions): string; formatRangeParts( start: TimeInput, end: TimeInput, options?: FormatOptions, ): ReturnType; ``` Return `Intl` parts or localized range strings using `FormatOptions`. ### `parseDuration()` / `humanize()` ```ts parseDuration(input: string | Temporal.DurationLike): Temporal.Duration; humanize(diff: TimeDiffResult, options?: { locale?: Intl.LocalesArgument }): string; ``` `humanize()` localizes numbers only. Unit names remain English. ## Classification ### `classifyExpiry({ value, thresholds, relativeTo?, timeZone? })` ```ts classifyExpiry(input: ClassifyExpiryInput): K | null; ``` Classifies an expiry against fixed elapsed-time thresholds in milliseconds or larger units. Months and years throw `TempoInvalidInputError`. ### `timeDiff(a, b?, options?)` ```ts timeDiff(a: TimeInput, b?: TimeInput, options?: TimeZoneOptions): TimeDiffResult; ``` Returns absolute calendar difference in its largest meaningful unit. Uses Temporal's calendar-aware `.since()` for correct month and year handling. ## Types ```ts type AbsoluteTime = Temporal.Instant | Temporal.ZonedDateTime; type WallTime = Temporal.PlainDate | Temporal.PlainDateTime; type TimeInput = AbsoluteTime | WallTime; type RelativeTimeInput = AbsoluteTime; type ParseAs = 'instant' | 'plainDate' | 'plainDateTime' | 'zonedDateTime'; type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject'; type CalendarUnit = 'day' | 'month' | 'week' | 'year'; type BoundaryUnit = 'day' | 'hour' | 'minute' | 'month' | 'week' | 'year'; type WeekStartDay = 1 | 2 | 3 | 4 | 5 | 6 | 7; type FormatPattern = 'date-only' | 'long' | 'medium' | 'short' | 'time-only'; type TempoUnit = 'day' | 'hour' | 'microsecond' | 'millisecond' | 'minute' | 'month' | 'nanosecond' | 'second' | 'week' | 'year'; type FixedDuration = Pick; type ExpiryThresholds = Record; type TimeDiffUnit = Exclude; type TimeDiffResult = { unit: TimeDiffUnit; value: number }; interface TimeZoneOptions { timeZone?: string } interface DisambiguationOptions { disambiguation?: Disambiguation } interface ShiftOptions extends DisambiguationOptions, TimeZoneOptions {} interface DifferenceInput extends DisambiguationOptions, TimeZoneOptions { end: TimeInput; largestUnit?: Temporal.DateTimeUnit; roundingIncrement?: number; roundingMode?: Temporal.RoundingMode; smallestUnit?: Temporal.DateTimeUnit; start: TimeInput; } interface BoundaryOptions extends TimeZoneOptions { weekStartsOn?: WeekStartDay } interface CompareOptions extends TimeZoneOptions { unit?: BoundaryUnit; weekStartsOn?: WeekStartDay } interface ContainsInput extends CompareOptions { end: TimeInput; start: TimeInput; value: TimeInput } interface ClampInput extends CompareOptions { end: TimeInput; start: TimeInput; value: TimeInput } type FormatOptions = | { intl: Intl.DateTimeFormatOptions; locale?: Intl.LocalesArgument; pattern?: never; timeZone?: string } | { intl?: never; locale?: Intl.LocalesArgument; pattern?: FormatPattern; timeZone?: string }; interface RelativeFormatOptions extends TimeZoneOptions { base?: RelativeTimeInput; locale?: Intl.LocalesArgument; numeric?: Intl.RelativeTimeFormatNumeric; style?: Intl.RelativeTimeFormatStyle; } interface DurationFormatOptions { locale?: Intl.LocalesArgument; style?: 'digital' | 'long' | 'narrow' | 'short'; } interface ClassifyExpiryInput extends TimeZoneOptions { value: TimeInput; thresholds: ExpiryThresholds; relativeTo?: Temporal.Instant; } type RecurrenceRule = { frequency: 'daily' | 'monthly' | 'weekly' | 'yearly'; interval?: number } & ({ count: number; until?: TimeInput } | { count?: number; until: TimeInput }); ``` ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `TempoError` | Base Tempo error | `instanceof TempoError` narrows every subtype | | `TempoInvalidInputError` | Invalid parse, duration, or fixed-threshold input | Extends `TempoError` | | `TempoInvalidTzError` | Invalid IANA zone or offset | Extends `TempoError` | | `TempoMissingTzError` | Wall time without required `timeZone` | Extends `TempoError` | | `TempoUnsupportedInputError` | Non-Temporal input passed to conversion | Extends `TempoError` | Every Tempo error thrown from `parse()`, `parseDuration()`, and timezone validation carries the original Temporal error on its `cause` property (ES2022). Inspect `err.cause` for root-cause details when debugging. ### Usage Guide ## Basic Usage Parse ISO input with a declared target. Convert plain values with `timeZone` before treating them as an instant. ```ts import { format, parse, shift } from '@vielzeug/tempo'; const local = parse('2026-03-21T10:15:30', { as: 'plainDateTime' }); const reminder = shift(local, { minutes: -15 }, { timeZone: 'America/New_York' }); format(reminder, { locale: 'en-US', pattern: 'short' }); ``` ## Parse ISO Values Choose the value your boundary actually represents. Tempo does not auto-detect ISO strings. ```ts import { parse } from '@vielzeug/tempo'; const occurredAt = parse('2026-03-21T10:15:30Z', { as: 'instant' }); const meeting = parse('2026-03-21T10:15:30+01:00[Europe/Berlin]', { as: 'zonedDateTime' }); const localStart = parse('2026-03-21T10:15:30', { as: 'plainDateTime' }); const birthday = parse('2026-03-21', { as: 'plainDate' }); ``` ## Convert Timezones Use `inTimeZone()` to project an absolute value. Use `toInstant()` only when resolving a wall-clock value. ```ts import { inTimeZone, parse, toInstant } from '@vielzeug/tempo'; const local = parse('2026-11-01T01:30:00', { as: 'plainDateTime' }); const firstOccurrence = toInstant(local, { disambiguation: 'earlier', timeZone: 'America/New_York', }); const berlin = inTimeZone(firstOccurrence, 'Europe/Berlin'); ``` ## Calculate and Compare Use Tempo helpers when an operation must resolve timezones, normalize ranges, or compare calendar units. Use Temporal methods directly when values are already the exact kind and timezone you need. ```ts import { clamp, contains, difference, parse, shift } from '@vielzeug/tempo'; const start = parse('2026-03-21T10:00:00Z', { as: 'instant' }); const end = parse('2026-03-21T12:00:00Z', { as: 'instant' }); const value = parse('2026-03-21T13:00:00Z', { as: 'instant' }); const duration = difference({ end, largestUnit: 'hour', start }); const isScheduled = contains({ end, start, value }); const bounded = clamp({ end, start, value }); const local = parse('2026-03-08T01:30:00', { as: 'plainDateTime' }); const tomorrow = shift(local, { days: 1 }, { timeZone: 'America/New_York' }); ``` ## Classify Expiry Use fixed elapsed-time thresholds in milliseconds or larger units. Handle `null` as the unclassified state instead of adding a far-future catch-all. ```ts import { classifyExpiry, parse } from '@vielzeug/tempo'; const status = classifyExpiry({ relativeTo: parse('2026-06-01T00:00:00Z', { as: 'instant' }), thresholds: { expired: { days: 0 }, critical: { days: 3 }, warning: { days: 14 }, }, value: parse('2026-06-04T00:00:00Z', { as: 'instant' }), }); const label = status ?? 'safe'; ``` ## Format Values Use `format()` for UI, `formatInstant()` for transport, and `formatZoned()` for a zoned ISO string. ```ts import { format, formatInstant, formatRelative, formatZoned, parse } from '@vielzeug/tempo'; const instant = parse('2026-03-21T10:15:30Z', { as: 'instant' }); format(instant, { locale: 'en-GB', pattern: 'short', timeZone: 'UTC' }); formatInstant(instant); formatZoned(instant, { timeZone: 'Europe/Berlin' }); formatRelative(instant, { base: parse('2026-03-21T09:15:30Z', { as: 'instant' }) }); ``` `formatRelative()` uses fixed elapsed-time units for short spans and complete calendar months or years for longer spans. Pass `timeZone` when calendar-relative output must use a specific regional calendar boundary. Use `formatTimer()` for stopwatch-style clocks: elapsed timers, recorded durations: where localized prose does not fit: ```ts import { formatTimer } from '@vielzeug/tempo'; formatTimer('PT47M12S'); // '47:12' formatTimer({ milliseconds: 3_753_000 }); // '1:02:33' formatTimer('PT5M', { hours: 'always' }); // '0:05:00' ``` ## Generate Calendar Sequences Use zoned values when the sequence already has a timezone. Plain and instant starts require `timeZone`. ```ts import { dateRange, parse, recurrence } from '@vielzeug/tempo'; const start = parse('2026-03-01T09:00:00[America/New_York]', { as: 'zonedDateTime' }); const end = parse('2026-03-03T09:00:00[America/New_York]', { as: 'zonedDateTime' }); const days = [...dateRange(start, end, { days: 1 })]; const meetings = [...recurrence(start, { count: 4, frequency: 'weekly', interval: 2 })]; ``` Both functions advance with zoned calendar arithmetic. Non-advancing range steps, invalid recurrence intervals, and negative or fractional counts throw `TempoInvalidInputError`. ## Testing Pin the reference instant for deterministic expiry tests. ```ts import { classifyExpiry, parse } from '@vielzeug/tempo'; const relativeTo = parse('2026-06-01T00:00:00Z', { as: 'instant' }); const value = parse('2026-05-31T00:00:00Z', { as: 'instant' }); classifyExpiry({ relativeTo, thresholds: { expired: { days: 0 } }, value }); ``` ## Framework Integration Pass ISO strings through component props. Parse and format at the rendering boundary. ```tsx [React] import { format, parse } from '@vielzeug/tempo'; const label = format(parse(iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' }); ``` ```vue [Vue 3] import { format, parse } from '@vielzeug/tempo'; const props = defineProps(); const label = format(parse(props.iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' }); ``` ```svelte [Svelte] import { format, parse } from '@vielzeug/tempo'; export let iso: string; $: label = format(parse(iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' }); ``` ## Working with Other Vielzeug Libraries ### With Rune Write stable UTC timestamps to structured logs. ```ts import { formatInstant, nowInstant } from '@vielzeug/tempo'; logger.info({ timestamp: formatInstant(nowInstant()) }, 'server started'); ``` ### With Vault Calculate an explicit instant before storing an expiring record. ```ts import { now } from '@vielzeug/tempo'; const expiresAt = now({ timeZone: 'UTC' }).add({ minutes: 30 }).toInstant(); ``` ## Best Practices - Parse strings with their actual temporal meaning and pass `timeZone` when resolving plain values. - Use `disambiguation` for DST overlap and gap handling. - Use Tempo helpers for timezone resolution, normalized ranges, and calendar units; use Temporal directly for already-normalized values. - Use fixed duration units for expiry thresholds. - Reject non-advancing range and recurrence configuration. - Store instants for transport and database values. - Use `formatInstant()` for machine output and `format()` for user-facing text. - Pass `timeZone` to `formatRelative()` when month and year boundaries must use a specific region. ### Examples ## Examples - [DST-Safe Arithmetic](./examples/dst-safe-arithmetic.md) - [Date Ranges and Recurrence](./examples/date-ranges-and-recurrence.md) - [Locale Formatting](./examples/locale-formatting.md) - [Timezone Conversion](./examples/timezone-conversion.md) - [Expiry Classification](./examples/expiry-classification.md) ### REPL Examples - Timezone-Aware Arithmetic and Ranges (id: `meeting-duration`) --- ## @vielzeug/vault **Category:** Storage ### Overview ## Why Vault? Vault gives browser and SQLite persistence one typed schema while keeping backend guarantees explicit. Use `KeyValueVaultStore` for portable CRUD and observation; choose IndexedDB or the opt-in SQLite subpath when you need atomic transactions or lazy iteration. Durable adapters require codecs so invalid stored data never enters typed code. ```ts // Before localStorage.setItem('theme', JSON.stringify({ value: 'dark' })); const theme = JSON.parse(localStorage.getItem('theme') ?? '{}').value; // After await store.put('preferences', { id: 'theme', value: 'dark' }); const theme = await store.get('preferences', 'theme'); ``` | Feature | Vault | Raw Web Storage | Dexie | | --- | --- | --- | --- | | Bundle size | | Browser built-in | Extra dependency | | Runtime dependencies | | | | | Typed schema and keys | | Application-defined | | | Portable key-value store API | | | IndexedDB only | | Explicit atomic transactions | IndexedDB + SQLite capability | | | | Required durable codecs | | | | | Driver-neutral SQLite | Opt-in subpath | | | **Use Vault when** you need typed browser persistence or application-owned SQLite with honest storage capabilities and required durable codecs. **Consider raw Web Storage when** you only persist one or two unstructured values. **Consider Dexie when** you need a broader IndexedDB ecosystem. ## Installation ```sh [pnpm] pnpm add @vielzeug/vault ``` ```sh [npm] npm install @vielzeug/vault ``` ```sh [yarn] yarn add @vielzeug/vault ``` ## Quick Start Define a schema, create a key-value store with codecs, and dispose it with its owner. ```ts import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createLocalStorage } from '@vielzeug/vault/local-storage'; const PreferenceSchema = s.object({ id: s.string(), theme: s.union('dark', 'light') }); const store = createLocalStorage({ name: 'app-v2', schema: { preferences: table('id') }, codecs: { preferences: PreferenceSchema }, }); try { await store.put('preferences', { id: 'theme', theme: 'dark' }); console.log(await store.get('preferences', 'theme')); } finally { await store.dispose(); } ``` ## Features - `table()` defines typed records with portable string or number keys. - Spell and other parser schemas work directly as codecs; `validatorCodec()` creates an explicit identity-encoding codec. - `/memory` returns a portable `DocumentVaultStore` with `batch()` and `iterate()`: codecs optional (in-memory, no trust boundary). - `/local-storage` and `/session-storage` return `KeyValueVaultStore`: codecs required. - `observe()` emits current and changed table snapshots. - `ttl` creates validated expiration durations. - `/indexeddb` and `/sqlite` return `DocumentVaultStore` with `batch()` and `iterate()`: codecs required. `/memory` shares the same contract without codecs. - `/indexeddb` accepts a `migrate` hook for schema upgrades the table schema cannot express. - `pruneExpired()` removes stale TTL entries on demand. - Bound helpers (`has`, `count`, `update`, `upsert`, `getMany`, `keys`, `deleteMany`) are available on every store and transaction context. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Forge](../forge/index.md) saves and restores form drafts through Vault stores. - [Ripple](../ripple/index.md) owns application state that can persist through Vault. - [Courier](../courier/index.md) can populate persistent cache data. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createMemory()` | In-memory document store | Async API | Import from `/memory`; codecs optional | | `createLocalStorage()` / `createSessionStorage()` | Web Storage-backed key-value stores | Async API | Codecs required; available only where the corresponding Web API exists | | `createIndexedDB()` | Browser document store with transactions and cursor iteration | Async API | Import from `/indexeddb`; codecs required | | `createSQLite()` | Driver-neutral SQLite document store | Async API over a synchronous driver | Import from `/sqlite`; codecs required | | `table()` | Typed record schema | Sync | The key field must be a string or finite number | | `validatorCodec()` | Create an identity-encoding codec from a parser | Sync | Spell schemas can also be passed directly | | `ttl` | Valid expiration durations | Sync | Durations must be positive | ## Package Entry Points | Import | Purpose | | --- | --- | | `@vielzeug/vault` | Adapter-free schemas, TTL, errors, codecs, derived helpers, and shared types | | `@vielzeug/vault/memory` | `createMemory` | | `@vielzeug/vault/local-storage` | `createLocalStorage` | | `@vielzeug/vault/session-storage` | `createSessionStorage` | | `@vielzeug/vault/indexeddb` | `createIndexedDB`, the `migrate` upgrade-hook types, and IndexedDB-only types | | `@vielzeug/vault/sqlite` | `createSQLite`, the SQLite driver protocol types, and `TransactionContext` | ## Schemas, Codecs, and TTL ### `table()` ```ts function table( key: Key & (T[Key] extends VaultKey ? unknown : never), options?: { defaultTtl?: number; indexes?: readonly (keyof T & string)[] }, ): SchemaEntry; ``` Defines a typed table and its primary-key field. | Parameter | Description | | --- | --- | | `key` | A record field whose values are `string` or finite `number` keys | | `options.defaultTtl` | Per-table default TTL in milliseconds | | `options.indexes` | IndexedDB secondary index fields | **Returns:** A `SchemaEntry` describing the table. ```ts import { table, ttl } from '@vielzeug/vault'; const users = table('id', { indexes: ['email'], defaultTtl: ttl.days(7), }); ``` --- ### `validatorCodec()` ```ts function validatorCodec(validator: { parse(value: unknown): T }): RecordCodec; ``` Wraps any object with a `parse(value): T` method as an identity-encoding `RecordCodec`. Parser schemas can also be passed directly in `codecs`; a Spell schema requires no adapter. **Returns:** A `RecordCodec`. ```ts import { s } from '@vielzeug/spell'; const UserSchema = s.object({ id: s.number(), name: s.string() }); const store = createLocalStorage({ name: 'app', schema: { users: table('id') }, codecs: { users: UserSchema }, }); ``` Custom `RecordCodec` values validate writes by round-tripping `decode(encode(value))`. IndexedDB codecs must preserve declared indexed field names and values in their encoded object. --- ### `ttl` ```ts const ttl: { days(n: number): number; hours(n: number): number; minutes(n: number): number; ms(n: number): number; seconds(n: number): number; }; ``` Creates a finite, positive duration in milliseconds for writes and table defaults. **Returns:** `number`. ```ts import { ttl } from '@vielzeug/vault'; const cacheLifetime = ttl.minutes(5); ``` ## Factories Durable factories (LocalStorage, SessionStorage, IndexedDB, SQLite) require `codecs`. Memory may omit them. ### `createMemory()` ```ts function createMemory(options: MemoryStoreOptions): DocumentVaultStore; ``` Creates an in-memory document store with `batch()` and `iterate()`. `batch()` snapshots the declared tables and restores them when the callback throws. Codecs are optional because memory values never cross a trust boundary. | Parameter | Description | | --- | --- | | `schema` | Tables created by `table()` | | `codecs` | Optional per-table codecs | **Returns:** `DocumentVaultStore`. ```ts import { table } from '@vielzeug/vault'; import { createMemory } from '@vielzeug/vault/memory'; const store = createMemory({ schema: { users: table('id') } }); ``` --- ### `createLocalStorage()` ```ts function createLocalStorage(options: DurableStoreOptions & { name: string; }): KeyValueVaultStore; ``` Creates a namespaced `localStorage` store. Codecs are required. | Parameter | Description | | --- | --- | | `schema` | Tables created by `table()` | | `codecs` | Required per-table codecs | | `name` | Required storage namespace | A write that exceeds the browser quota rejects with `VaultQuotaError`. **Returns:** `KeyValueVaultStore`. ```ts import { table } from '@vielzeug/vault'; import { createLocalStorage } from '@vielzeug/vault/local-storage'; const store = createLocalStorage({ name: 'app', schema: { settings: table('id') }, codecs: { settings: SettingsSchema }, }); ``` --- ### `createSessionStorage()` ```ts function createSessionStorage(options: DurableStoreOptions & { name: string; }): KeyValueVaultStore; ``` Creates a namespaced `sessionStorage` store. Its options and return type match `createLocalStorage()`. **Returns:** `KeyValueVaultStore`. ```ts import { table } from '@vielzeug/vault'; import { createSessionStorage } from '@vielzeug/vault/session-storage'; const store = createSessionStorage({ name: 'checkout', schema: { cart: table('id') }, codecs: { cart: CartSchema }, }); ``` --- ### `createIndexedDB()` ```ts function createIndexedDB(options: DurableStoreOptions & { migrate?: MigrationFn; name: string; version?: number; }): IndexedDbVaultStore; ``` Creates an IndexedDB document store with atomic batches, lazy cursor iteration, and optional schema migrations. Codecs are required. | Parameter | Description | | --- | --- | | `schema` | Tables and IndexedDB secondary indexes | | `codecs` | Required per-table codecs | | `name` | Required database name | | `version` | Positive schema version; defaults to `1` | | `migrate` | Synchronous `onupgradeneeded` callback. Object stores and schema-declared indexes are created automatically; use the hook only for deletes or record transforms | **Returns:** `IndexedDbVaultStore`. Its `getAllByIndex(table, field, value)` method queries a declared IndexedDB index without scanning the full table. ```ts import { table } from '@vielzeug/vault'; import { createIndexedDB } from '@vielzeug/vault/indexeddb'; const store = createIndexedDB({ name: 'app', schema: { users: table('id', { indexes: ['role'] }) }, codecs: { users: UserSchema }, }); const admins = await store.getAllByIndex('users', 'role', 'admin'); ``` --- ### `createSQLite()` ```ts function createSQLite(options: SQLiteVaultOptions): DocumentVaultStore; ``` Creates a namespaced SQLite document store with atomic batches and keyset-paginated iteration. Codecs are required. It accepts an application-provided positional-parameter driver and never opens or imports a runtime driver. | Parameter | Description | | --- | --- | | `schema` | Tables created by `table()` | | `codecs` | Required per-table codecs | | `database` | Caller-provided `SQLiteDatabase` connection | | `name` | Namespace within the connection | | `closeOnDispose` | Closes the connection during disposal; defaults to `false` | **Returns:** `DocumentVaultStore`. ```ts import { DatabaseSync } from 'node:sqlite'; import { table } from '@vielzeug/vault'; import { createSQLite } from '@vielzeug/vault/sqlite'; const store = createSQLite({ database: new DatabaseSync(':memory:'), name: 'tests', schema: { users: table('id') }, codecs: { users: UserSchema }, }); ``` Node `DatabaseSync`, Bun `Database`, and Deno `jsr:@db/sqlite` `Database` satisfy the protocol. Codec output may be any JSON-compatible value. During a `batch()` callback, calls on every Vault store sharing that connection reject; use `tx.*` instead. ## Store Capabilities ### `KeyValueVaultStore` ```ts interface KeyValueVaultStore { clear(table): Promise; count(table): Promise; delete(table, key): Promise; deleteMany(table, keys): Promise; get(table, key): Promise; getAll(table): Promise; getMany(table, keys): Promise; has(table, key): Promise; isEmpty(table): Promise; keys(table, filter?): Promise; update(table, key, changes, ttl?): Promise; upsert(table, key, update, ttl?): Promise; observe(table, listener, options?): Unsubscribe; pruneExpired(): Promise>; put(table: K, value: RecordOf, ttl?: number): Promise>; putAll(table, values, ttl?): Promise; readonly disposalSignal: AbortSignal; dispose(): Promise; readonly disposed: boolean; [Symbol.asyncDispose](): Promise; } ``` The portable key-value store API is returned by Memory and Web Storage factories. `put()`, `update()`, and `upsert()` return the canonical value produced by codec validation. `observe()` emits the current table snapshot by default and then emits after mutations. --- ### `DocumentVaultStore` ```ts interface DocumentVaultStore extends KeyValueVaultStore { batch( tables: readonly K[], fn: (tx: TransactionContext) => Promise, ): Promise; iterate(table: K): AsyncIterable>; } ``` `batch()` runs a scoped atomic callback. `iterate()` lazily yields table records: IndexedDB uses a cursor, SQLite uses keyset pagination. Both are provided by `createIndexedDB()` and `createSQLite()`. | Parameter | Description | | --- | --- | | `tables` | Tables the transaction may access | | `fn` | Async callback that uses only the supplied `tx` context | **Returns:** The callback result after commit. ```ts await store.batch(['users'], async (tx) => { await tx.put('users', { id: 1, name: 'Ada' }); }); for await (const user of store.iterate('users')) console.log(user); ``` ## Bound Helpers Convenience operations are bound to every store and transaction context. For filtering, ordering, and pagination, compose over `getAll()`: it returns a plain array, or use `iterate()` on document stores and `getAllByIndex()` on IndexedDB for declared equality indexes. | Helper | Signature | Description | | --- | --- | --- | | `has()` | `(table, key) => Promise` | Check whether a record exists | | `count()` | `(table) => Promise` | Count live records | | `isEmpty()` | `(table) => Promise` | Check whether a table has no live records | | `getMany()` | `(table, keys) => Promise` | Fetch multiple records by key | | `keys()` | `(table, filter?) => Promise` | Fetch live primary keys, optionally filtered by record | | `deleteMany()` | `(table, keys) => Promise` | Delete multiple records, return count | | `update()` | `(table, key, changes, ttl?) => Promise` | Partially update an existing record | | `upsert()` | `(table, key, fn, ttl?) => Promise` | Read-modify-write with callback | ```ts const exists = await store.has('users', 1); const total = await store.count('users'); const [a, b] = await store.getMany('users', [1, 2]); const updated = await store.update('users', 1, { name: 'Alice' }); const result = await store.upsert('users', 99, (existing) => ({ id: 99, name: existing?.name ?? 'Guest' })); const all = await store.getAll('users'); const active = all.filter((user) => user.status === 'active').sort((a, b) => a.name.localeCompare(b.name)).slice(0, 20); ``` Bound `update()` and `upsert()` are atomic on document stores. Their key-value-store forms are non-atomic convenience operations. ## Types ```ts type VaultKey = number | string; type Unsubscribe = () => void; type Observer = (records: T[]) => void; type AnySchema = Record; type SchemaEntry = { defaultTtl?: number; indexes?: readonly (keyof T & string)[]; key: Key; }; type RecordOf = S[K] extends SchemaEntry ? R : never; type KeyOf = Extract ? R[Key] : never, VaultKey>; ``` ```ts type RecordCodec = { decode(value: unknown): T; encode(value: T): unknown; }; type RecordParser = { parse(value: unknown): T }; type CodecInput = RecordCodec | RecordParser; type TableCodecs = { [K in keyof S]: CodecInput>; }; type MemoryStoreOptions = { schema: S; codecs?: TableCodecs; }; type DurableStoreOptions = { schema: S; codecs: TableCodecs; }; ``` ```ts type MigrationContext = { db: IDBDatabase; newVersion: number | null; oldVersion: number; tx: IDBTransaction; }; type MigrationFn = (ctx: MigrationContext) => void; ``` Import `MigrationContext` and `MigrationFn` from `@vielzeug/vault/indexeddb`. ```ts type SQLiteParameter = null | number | string; interface SQLiteStatement { all(...parameters: SQLiteParameter[]): readonly Record[]; finalize?(): void; get(...parameters: SQLiteParameter[]): Record | null | undefined; run(...parameters: SQLiteParameter[]): unknown; } interface SQLiteDatabase { close?(): void; exec(sql: string): void; prepare(sql: string): SQLiteStatement; } type SQLiteVaultOptions = DurableStoreOptions & { closeOnDispose?: boolean; database: SQLiteDatabase; name: string; }; ``` ```ts interface TransactionContext { clear(table: T): Promise; delete(table: T, key: KeyOf): Promise; get(table: T, key: KeyOf): Promise | undefined>; getAll(table: T): Promise[]>; iterate(table: T): AsyncIterable>; put(table: T, value: RecordOf, ttl?: number): Promise>; putAll(table: T, values: RecordOf[], ttl?: number): Promise; } ``` `TransactionContext` has core CRUD, `iterate()`, and TTL methods, narrowed to the tables declared in `batch()`. Import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`. ## Errors | Error | Trigger | | --- | --- | | `VaultError` | Any Vault-originated validation, serialization, storage, or codec error | | `VaultDisposedError` | An operation after the store or observer hub is disposed | | `VaultScopeError` | A `batch()` callback accesses a table outside its declared scope | | `VaultQuotaError` | A LocalStorage or SessionStorage write exceeds the browser quota | | `VaultMigrationError` | An IndexedDB migration callback throws | Every listed error extends `VaultError`. ### Usage Guide ## Basic Usage Create a key-value store with a schema and codecs, then write a typed row. ```ts import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createLocalStorage } from '@vielzeug/vault/local-storage'; interface Preference { id: string; theme: 'dark' | 'light'; } const PreferenceSchema = s.object({ id: s.string(), theme: s.union('dark', 'light') }); const store = createLocalStorage({ name: 'app-v2', schema: { preferences: table('id') }, codecs: { preferences: PreferenceSchema }, }); await store.put('preferences', { id: 'theme', theme: 'dark' }); console.log(await store.get('preferences', 'theme')); ``` ## Create a Key-Value Store LocalStorage and SessionStorage return `KeyValueVaultStore`; Memory returns a `DocumentVaultStore` (it adds `batch()` and `iterate()`). They share portable string/number keys, bound helpers, fluent queries, TTL, and `observe()`. Memory may omit codecs; Web Storage requires them. Malformed storage envelopes are evicted lazily, while codec validation failures are surfaced without deleting persisted data. The root entry is adapter-free. Import `createMemory` from `@vielzeug/vault/memory`, `createLocalStorage` from `@vielzeug/vault/local-storage`, or `createSessionStorage` from `@vielzeug/vault/session-storage`. Import each adapter from its focused subpath so unused backends stay out of the bundle. Use a new storage name when upgrading from Vault 1. Old key and envelope formats are not read by Vault 2. ```ts const store = createLocalStorage({ name: 'app-v2', schema: { preferences: table('id') }, codecs: { preferences: PreferenceSchema }, }); ``` ## Read and Change Records Use bound `update()` and `upsert()` methods for read-modify-write operations; no helper imports are required. ```ts const updated = await store.update('preferences', 'theme', { theme: 'light' }); await store.upsert('preferences', 'locale', (current) => ({ id: 'locale', theme: current?.theme ?? 'dark', })); console.log(updated); ``` `update()` returns `undefined` for a missing key. `upsert()` always writes the record returned by its callback. Document-store methods run atomically; key-value-store methods are non-atomic conveniences when multiple writers race. ## Iterate and Filter Records Filter the plain array returned by `getAll()` for in-memory selection, sorting, and pagination. Use `iterate()` on a `DocumentVaultStore` for lazy cursor-based traversal, and `getAllByIndex()` on IndexedDB for declared equality indexes. ```ts // In-memory selection over getAll() const themed = (await store.getAll('preferences')).filter((preference) => preference.id.startsWith('theme')); // Document store: iterate lazily for await (const pref of db.iterate('preferences')) { if (pref.id.startsWith('theme')) console.log(pref); } ``` For large tables, prefer `iterate()` on IndexedDB or SQLite instead of materializing every record with `getAll()`. ## Use TTL and Pruning Use `ttl.*` helpers for expiring rows. Call `pruneExpired()` to reclaim storage from stale rows that accumulate without reads; tables with removed rows publish fresh observer snapshots. ```ts import { ttl } from '@vielzeug/vault'; await store.put('preferences', { id: 'temporary', theme: 'dark' }, ttl.hours(1)); // Reclaim expired rows on a schedule owned by the application. const pruneInterval = setInterval(() => store.pruneExpired(), ttl.hours(6)); store.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval)); ``` ## Observe a Table Use `observe()` for current and future snapshots. Notifications are revision-ordered, and explicit TTL pruning publishes fresh snapshots for affected tables. Tie subscription lifetime to an `AbortSignal` when a component or request owns it. ```ts const controller = new AbortController(); store.observe('preferences', (preferences) => { console.log(preferences); }, { signal: controller.signal }); controller.abort(); ``` ## Use IndexedDB for Browser Transactions Choose IndexedDB when browser storage needs multiple writes to commit together or cursor iteration. ```ts import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createIndexedDB } from '@vielzeug/vault/indexeddb'; const EventSchema = s.object({ id: s.number(), type: s.string() }); const db = createIndexedDB({ name: 'app-v2', schema: { events: table('id') }, codecs: { events: EventSchema }, }); await db.batch(['events'], async (tx) => { await tx.put('events', { id: 1, type: 'opened' }); await tx.put('events', { id: 2, type: 'saved' }); }); ``` Transaction contexts expose the same bound helpers; `tx.update()`, `tx.upsert()`, and `tx.deleteMany()` run inside the enclosing transaction. Only await `tx.*` operations inside a batch callback. Do not retain the transaction context after the callback; later use throws `VaultScopeError`. Do not await timers, fetches, or other external asynchronous work because IndexedDB can commit an inactive transaction. ## Use SQLite Outside the Browser Import SQLite from the opt-in subpath so the browser root stays free of runtime drivers. Vault never opens a connection or configures its SQLite process behavior for you. ```ts import { DatabaseSync } from 'node:sqlite'; import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createSQLite } from '@vielzeug/vault/sqlite'; const EventSchema = s.object({ id: s.number(), type: s.string() }); const database = new DatabaseSync('app.db', { timeout: 5_000 }); const store = createSQLite({ database, name: 'app-v2', schema: { events: table('id') }, codecs: { events: EventSchema }, }); await store.batch(['events'], async (tx) => { await tx.put('events', { id: 1, type: 'opened' }); await tx.put('events', { id: 2, type: 'saved' }); }); ``` Node's `node:sqlite` API is experimental. Bun's `bun:sqlite` `Database` satisfies the same positional `exec()` and `prepare()` contract; configure WAL from your application when the deployment needs it. Deno does not include SQLite, but `jsr:@db/sqlite`'s `Database` satisfies the same contract when its FFI, filesystem, and environment permissions are granted. SQLite stores serialize all access through the injected connection. `batch()` starts `BEGIN IMMEDIATE` and rolls back callback failures. While its callback runs, calls on any store sharing that connection reject rather than waiting behind the transaction; use `tx.*` instead. The underlying drivers are synchronous, so move large scans and writes to a worker or isolate when event-loop latency matters. ## Store SQLite Values and Observe Changes SQLite accepts any JSON-compatible codec output. Circular values, `bigint`, class instances, functions, and non-finite numbers must be transformed by the codec before writing. Number and string primary keys remain distinct. `observe()` sees mutations written through Vault stores sharing the same injected connection after a commit. It cannot detect direct SQL changes, writes from another process, or writes through another connection. The connection belongs to the caller by default; use `closeOnDispose: true` only when the store owns it. ## Handle IndexedDB Schema Migrations Declare IndexedDB indexes in the schema. Codecs must preserve each indexed field name and value in their encoded object. Use `migrate` only for IndexedDB version upgrades and mirror Vault's fixed `value.` index path. ```ts import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createIndexedDB, type MigrationFn } from '@vielzeug/vault/indexeddb'; const UserSchema = s.object({ id: s.number(), name: s.string() }); const schema = { users: table('id', { indexes: ['name'] }) }; const migrate: MigrationFn = ({ db, oldVersion, tx }) => { if (oldVersion (store: KeyValueVaultStore, table: K) { const [rows, setRows] = useState[]>([]); useEffect(() => store.observe(table, setRows), [store, table]); return rows; } ``` ```ts [Vue 3] import { onUnmounted, shallowRef } from 'vue'; import type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault'; export function useTable(store: KeyValueVaultStore, table: K) { const rows = shallowRef[]>([]); const stop = store.observe(table, (next) => (rows.value = next)); onUnmounted(stop); return rows; } ``` ```ts [Svelte] import { readable } from 'svelte/store'; import type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault'; export function tableStore(store: KeyValueVaultStore, table: K) { return readable[]>([], (set) => store.observe(table, set)); } ``` ::: ## Working with Other Vielzeug Libraries Keep Ripple signals as application state and persist selected changes through Vault writes. ## Best Practices - Define one schema per storage namespace. - Use string or finite-number primary keys only. - Provide codecs for every durable adapter; pass Spell or another parser schema directly when identity encoding is sufficient. - Use `observe()` for table snapshots. - Use IndexedDB or SQLite for atomic work. - Keep external asynchronous work outside `batch()` callbacks. - Use `ttl.*` instead of raw durations. - Keep SQLite scans and writes off latency-sensitive event loops. - Dispose stores when their owner ends. ### Examples ## Browser Key-Value Store with TTL ```ts import { s } from '@vielzeug/spell'; import { table, ttl } from '@vielzeug/vault'; import { createLocalStorage } from '@vielzeug/vault/local-storage'; interface Session { id: string; token: string; } const SessionSchema = s.object({ id: s.string(), token: s.string() }); const store = createLocalStorage({ name: 'app-v2', schema: { sessions: table('id', { defaultTtl: ttl.hours(1) }) }, codecs: { sessions: SessionSchema }, }); await store.put('sessions', { id: 'current', token: 'FAKE-TOKEN-FOR-DOCS' }); console.log(await store.get('sessions', 'current')); ``` ## IndexedDB with Transactions and Iteration ```ts import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createIndexedDB } from '@vielzeug/vault/indexeddb'; interface Event { id: number; type: string; createdAt: number; } const EventSchema = s.object({ createdAt: s.number(), id: s.number(), type: s.string() }); const db = createIndexedDB({ name: 'events', schema: { events: table('id', { indexes: ['type'] }) }, codecs: { events: EventSchema }, }); await db.batch(['events'], async (tx) => { await tx.put('events', { id: 1, type: 'opened', createdAt: Date.now() }); await tx.put('events', { id: 2, type: 'saved', createdAt: Date.now() }); }); for await (const event of db.iterate('events')) { console.log(event.type); } ``` ## SQLite Document Store ```ts import { DatabaseSync } from 'node:sqlite'; import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createSQLite } from '@vielzeug/vault/sqlite'; interface Job { id: string; status: 'queued' | 'running' | 'done'; } const JobSchema = s.object({ id: s.string(), status: s.union('queued', 'running', 'done') }); const database = new DatabaseSync(':memory:'); const store = createSQLite({ database, name: 'app', schema: { jobs: table('id') }, codecs: { jobs: JobSchema }, }); await store.batch(['jobs'], async (tx) => { await tx.put('jobs', { id: 'job-1', status: 'queued' }); await tx.put('jobs', { id: 'job-2', status: 'running' }); }); for await (const job of store.iterate('jobs')) { console.log(job.id, job.status); } ``` ## Observe Table Changes ```ts const stop = store.observe('sessions', (sessions) => { console.log(`Active sessions: ${sessions.length}`); }); store.disposalSignal.addEventListener('abort', stop); ``` ## Bound Helpers ```ts console.log(await store.has('sessions', 'current')); console.log(await store.count('sessions')); console.log(await store.isEmpty('sessions')); const [a, b] = await store.getMany('sessions', ['current', 'old']); console.log(await store.keys('sessions')); console.log(await store.deleteMany('sessions', ['old', 'expired'])); await store.update('sessions', 'current', { token: 'FAKE-UPDATED-TOKEN' }); await store.upsert('sessions', 'temp', (existing) => ({ id: 'temp', token: existing?.token ?? 'generated', })); const current = (await store.getAll('sessions')).find((session) => session.id === 'current'); (void a, b, current); ``` ## React Hook ```tsx import { useEffect, useState } from 'react'; import type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault'; export function useTable( store: KeyValueVaultStore, table: K, ): RecordOf[] { const [rows, setRows] = useState[]>([]); useEffect(() => store.observe(table, setRows), [store, table]); return rows; } ``` ## Migration Example Object stores and schema-declared indexes are created automatically on upgrade. The `migrate` hook covers only what the schema cannot express, such as deleting a removed store or transforming old records. ```ts import { s } from '@vielzeug/spell'; import { table } from '@vielzeug/vault'; import { createIndexedDB } from '@vielzeug/vault/indexeddb'; const UserSchema = s.object({ email: s.string().email(), id: s.number() }); const db = createIndexedDB({ name: 'app-v2', version: 2, migrate({ db, oldVersion, tx }) { // The 'users' store and its 'email' index already exist; drop a retired store. if (oldVersion ('id', { indexes: ['email'] }) }, codecs: { users: UserSchema }, }); ``` ## Recipe Examples - [CRUD Operations](./examples/crud.md) - [TTL and Pruning](./examples/ttl.md) - [IndexedDB Batch Transactions](./examples/batch.md) - [IndexedDB Lazy Iteration](./examples/iterate.md) - [SQLite Transactions and Iteration](./examples/sqlite.md) - [Reactive Tables with observe()](./examples/reactive.md) - [Validators and Error Handling](./examples/plugins.md) ### REPL Examples - Basic Setup - Initialize Vault (id: `basic-setup`) - Bulk Operations (id: `bulk-operations`) - Cache-First with get + put (id: `cache-first`) - CRUD Operations (id: `crud-operations`) - Filtering: getAll composition, pagination, and deletion (id: `filtering`) - IndexedDB: Atomic Batch & iterate() (id: `indexed-db`) - TTL: pruneExpired with disposalSignal (id: `prune-schedule`) - Reactive: observe() (id: `reactive-observe`) - TTL & Expiration (id: `ttl-expiration`) --- ## @vielzeug/ward **Category:** security ### Overview ## Why Ward? Ward evaluates immutable ordered rules. The first matching rule wins; no match means deny. Typed action, resource, and attribute contracts keep role and ownership policies explicit. ## Quick Start ```ts import { allow, createWard, deny, predicate, WILDCARD } from '@vielzeug/ward'; const ward = createWard([ deny('blocked', WILDCARD, [WILDCARD]), allow('editor', 'posts', ['read', 'update'], { when: predicate.owns('authorId') }), allow('viewer', 'posts', ['read']), ]); const decision = ward.decide({ action: 'update', attributes: { authorId: 'u1' }, principal: { id: 'u1', roles: ['editor'] }, resource: 'posts', }); ``` | Feature | Ward | Inline conditionals | | --- | --- | --- | | Ordered first-match policy | | Manual | | Default deny | | Manual | | Typed actions | | Partial | | Ownership predicates | | Manual | | Zero dependencies | | | ## Features - Ordered `allow` and `deny` decisions - Declarative `roles` data on every rule - Concise role/action rule factories - `predicate.owns()`, `and()`, `or()`, and `not()` - Exact, namespace, and wildcard action/resource patterns - Immutable rules, principals, and JSON-compatible attributes - Typed batch, bound-principal, and allowed-action checks - `tap()` decision observability - Default deny when no rule matches ## Installation ```sh pnpm add @vielzeug/ward ``` ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.md) ## See Also - [Postmaster](/postmaster/): authorize durable job actions at application boundaries. - [Refine](/refine/): consume decisions in accessible UI components. ### API Reference ## API Overview | Symbol | Purpose | Execution mode | Common gotcha | | --- | --- | --- | --- | | `createWard()` | Compile immutable ordered rules | Sync | First match wins | | `allow()` / `deny()` | Create role-conditioned rules | Sync | One rule is produced per action | | `predicate` | Compose typed synchronous conditions | Sync | Async results throw | | `matchesPattern()` | Match exact and wildcard values | Sync | `posts:*` does not match `posts` | ## Package Entry Point | Import | Purpose | | --- | --- | | `@vielzeug/ward` | All runtime APIs, errors, constants, and public types | ## Core API ### `createWard()` ```ts function createWard( rules?: readonly (WardRule | readonly WardRule[])[], ): Ward ``` Returns an immutable ordered policy. Nested rule arrays are flattened once. Rules, declarative attributes, and bound principals are snapshotted. | Parameter | Type | Description | | --- | --- | --- | | `rules` | `readonly (WardRule \| readonly WardRule[])[]` | Rules in first-match order | **Returns:** `Ward`. ```ts import { allow, createWard } from '@vielzeug/ward'; const ward = createWard([allow('viewer', 'posts', ['read'])]); ``` | Method | Returns | Behavior | | --- | --- | --- | | `decide(input)` | `WardDecision` | Evaluates and emits one decision event | | `checkAll(inputs)` | `WardDecision[]` | Evaluates each input in order | | `allowedActions(input)` | `Action[]` | Deduplicates and filters the derived known actions without emitting events | | `forPrincipal(principal?)` | `BoundWard` | Binds an immutable principal snapshot | | `tap(handler, options?)` | `() => void` | Observes decisions; supports `AbortSignal` | | `knownActions` | `readonly Action[]` | Exact allow-side actions derived from the compiled rules, in declaration order | | `rules` | `readonly WardRule[]` | Immutable compiled rules | --- ### `allow()` and `deny()` ```ts allow(role, resource, actions, options?): WardRule[] deny(role, resource, actions, options?): WardRule[] ``` Return one role-conditioned rule per action. The role list is stored as declarative `roles` data on each rule, not a condition callback. | Parameter | Type | Description | | --- | --- | --- | | `role` | `string \| readonly string[]` | Role, role alternatives, `ANONYMOUS`, or `WILDCARD` | | `resource` | `WardPattern` | Exact or wildcard resource | | `actions` | `readonly WardPattern[]` | Exact or wildcard actions | | `options.when` | `WardCondition` | Additional synchronous condition | **Returns:** `WardRule[]`. ```ts import { allow, deny, WILDCARD } from '@vielzeug/ward'; const rules = [deny('blocked', WILDCARD, [WILDCARD]), allow(['editor', 'admin'], 'posts', ['read'])]; ``` --- ### `predicate` ```ts predicate.owns(attribute) predicate.and(...conditions) predicate.or(...conditions) predicate.not(condition) ``` Returns typed `WardCondition` functions. `owns()` accepts only a string key from the selected attribute type. Role checks do not need a predicate: declare `roles` on the rule instead. --- ### Pattern helpers ```ts matchesPattern(pattern: string, value: string): boolean ``` `matchesPattern()` supports exact values, `*`, and namespace wildcards such as `posts:*`. ## Types ```ts type WardAttributeValue = | boolean | number | string | null | readonly WardAttributeValue[] | { readonly [key: string]: WardAttributeValue }; type WardAttributes = Readonly>; type WardPattern = Value | typeof WILDCARD | `${string}:*`; ``` ```ts type UserPrincipal = Readonly; type Principal = UserPrincipal | null; ``` ```ts type WardConditionInput = Readonly; type WardCondition = ( input: WardConditionInput, ) => boolean; ``` ```ts type WardRule = Readonly; attributes?: Attributes; condition?: WardCondition; effect: 'allow' | 'deny'; resource: WardPattern; roles?: readonly string[]; }>; ``` ```ts type WardDecisionInput = Readonly; ``` ```ts type WardDecision = | { effect: 'allow' | 'deny'; matched: true; reason: string; rule: WardRule } | { effect: 'deny'; matched: false; reason: string; rule?: never }; ``` ```ts type WardEvent = Readonly; input: WardDecisionInput & { principal: Principal }; type: 'decision'; }>; ``` `BoundWard` is `Pick` for the principal-bound form, and `BoundWardDecisionInput` is the principal-free decision input. ## Errors | Error | Trigger | Notable properties | | --- | --- | --- | | `WardError` | Base for Ward-originated errors | Standard `cause` support | | `WardConfigError` | Invalid rules, principals, inputs, or attribute values | Configuration message with field path | | `WardConditionError` | Thrown, async, or non-boolean condition result | `ruleIndex`, `cause` | ### Usage Guide ## Basic Usage ```ts import { allow, createWard, deny, WILDCARD } from '@vielzeug/ward'; const ward = createWard([ deny('blocked', WILDCARD, [WILDCARD]), allow('editor', 'posts', ['read', 'update']), allow('viewer', 'posts', ['read']), ]); const decision = ward.decide({ action: 'update', principal: { id: 'u1', roles: ['editor'] }, resource: 'posts', }); ``` Order is policy. Put exceptions and explicit denies before broad allows. No match returns a default-deny decision with `matched: false`. ## Check ownership with typed attributes ```ts import { allow, createWard, predicate } from '@vielzeug/ward'; type Attributes = { authorId: string; status: 'draft' | 'published' }; const ward = createWard([ allow('editor', 'posts', ['update'], { when: predicate.owns('authorId'), }), ]); ward.decide({ action: 'update', attributes: { authorId: post.authorId, status: post.status }, principal, resource: 'posts', }); ``` Attribute values support finite numbers, strings, booleans, `null`, arrays, and plain objects. Ward rejects class instances, dates, maps, sets, functions, `undefined`, non-finite numbers, and circular values. ## Combine conditions ```ts const canPublishOwnPost = predicate.and( predicate.owns('authorId'), ({ attributes }) => attributes?.status === 'draft', ); ``` Conditions are synchronous. A thrown error, Promise, or non-boolean result becomes a `WardConditionError` with the original value in `cause`. Role gating does not need a condition: declare `roles` on the rule. ## Bind a principal ```ts const permissions = ward.forPrincipal(principal); permissions.decide({ action: 'update', attributes, resource: 'posts' }); permissions.checkAll([ { action: 'read', attributes, resource: 'posts' }, { action: 'update', attributes, resource: 'posts' }, ]); permissions.allowedActions({ attributes, resource: 'posts' }); ``` `forPrincipal()` snapshots the principal, roles, and principal attributes. Later caller mutations cannot alter bound decisions. `allowedActions()` draws from `ward.knownActions`: the exact allow-side actions derived from the compiled rules, and accepts an optional `filter` to narrow the candidates. ## Observe decisions ```ts const controller = new AbortController(); ward.tap((event) => audit.write(event), { signal: controller.signal }); controller.abort(); ``` `tap()` observes `decide()` and `checkAll()`. It does not observe the hypothetical checks performed by `allowedActions()`. Handler failures are swallowed. ## Anonymous and wildcard roles - `ANONYMOUS` matches `null` and an omitted principal. - `WILDCARD` as a role matches any authenticated principal. - `WILDCARD` as an action or resource matches every value. ## Testing Construct a fresh Ward for each policy test. Assert decisions and ordering through the public API; rules are immutable snapshots. ## Framework Integration Keep one Ward instance near the application boundary. Bind the current principal when rendering several related controls, and repeat authorization at the mutation boundary. ## Working with Other Vielzeug Libraries Send `WardEvent` values to a Rune logger or Herald bus when an application needs centralized diagnostics. Keep durable audit persistence in the application. ## Best Practices - Put narrow exceptions before broad rules. - Keep default deny. - Prefer `allow()` and `deny()` for role policies. - Prefer declarative attributes before custom conditions. - Type action, resource, and attribute generics at shared policy boundaries. - Pass only authorization-relevant attributes. - Treat `reason` as diagnostics, not user-facing prose. ### Examples ## Examples - [Blog Roles](./examples/blog-roles.md) - [Multi-Role Rules](./examples/multi-role-rules.md) - [Wildcard Action](./examples/wildcard-action.md) - [Priority and Overrides](./examples/inheritance-and-overrides.md) - [Bound Guard in UI Layer](./examples/bound-guard-in-ui-layer.md) - [Rule Specificity](./examples/disabling-wildcard-fallback.md) - [Auditing Decisions](./examples/logger-for-auditing.md) - [Fresh Ward Per Test](./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 (id: `basic-setup`) - Dynamic Permissions (id: `dynamic-permissions`) - Multi-Role Rules (id: `multi-role-rules`) - Permission Checks (id: `permission-checks`) - Permission Management (id: `permission-management`) - Role Hierarchy (id: `role-hierarchy`) - Wildcard Permissions (id: `wildcard-permissions`) --- ## @vielzeug/wayfinder **Category:** routing ### 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: { data: () => ({ message: 'Page not found' }) }, }); router.subscribe((state) => { const leaf = state.matches.at(-1); render(leaf?.data); }); ``` **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 | | | | | Declarative redirects | | | | | Search param validation | | | | | Error in state | | | | | History state in context | | | | | Leave guards | | | | | Zero dependencies | | | | ## Installation ```sh [pnpm] pnpm add @vielzeug/wayfinder ``` ```sh [npm] npm install @vielzeug/wayfinder ``` ```sh [yarn] yarn add @vielzeug/wayfinder ``` ## Quick Start Create a memory-backed router, wait for initial routing, then navigate by name. ```ts import { createMemoryHistory, createRouter } from '@vielzeug/wayfinder'; const router = createRouter({ history: createMemoryHistory('/'), routes: { home: { path: '/' }, settings: { path: '/settings' }, }, }); await router.ready; await router.navigate({ name: 'settings' }); console.log(router.getSnapshot().location.pathname); // /settings router.dispose(); ``` ## Features - `createRouter()`: Compiles named, nested route tables. - `navigate()`: Commits route changes after middleware reaches its terminal stage. - `ready`: Signals that initial routing has settled. - `data()`: Receives cancellation through `AbortSignal` for in-flight loaders. - `beforeLeave()`: Blocks route exits before history changes. - `match()` / `load()`: Inspect routes synchronously or load detached route data without navigation. - `url()` / `href()`: Build base-aware URLs and anchor-ready hrefs for every history driver. - `preload()`: Warms and reuses loader results for the next matching client navigation. - `createViewRegistry()`: Resolves exhaustive typed route views with an explicit not-found fallback. - `scroll` / `viewTransition`: Coordinate browser effects at the navigation commit boundary. - `createHashHistory()`: Runs browser routes on static hosts that cannot rewrite deep links. - `createMemoryHistory()`: Runs routers in tests and non-browser environments. - `subscribe()`: Reactive subscription to navigation state changes. ## Documentation - [Usage Guide](./usage.md) - [API Reference](./api.md) - [Examples](./examples.md) - [Migration Guide](./migration.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 | | `createRouteSignals(router)` | Mirror a router into ripple readables (name/params/query/state) | Sync | `router.getSnapshot()` alone is not ripple-reactive: use this bridge | | `createBrowserHistory()` | Create the default browser history driver | Sync | Requires server-side SPA rewrites | | `createHashHistory(options?)` | Create a static-host-safe hash history driver | Sync | Pass the same `base` to the history and router | | `createHistoryForBase(base)` | Pick browser vs hash history for a deploy base | Sync | Returns the hash driver for any base other than `'/'` | | `createMemoryHistory(initialPath?)` | Create an in-memory history driver | Sync | N/A | | `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.href(name, params?, query?)` | Build an anchor-ready href for a named route | Sync | Hash-prefixed under `createHashHistory` | | `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.match(pathname)` | Inspect a pathname as a branch without side effects | Sync | Returns `null` for redirect routes | | `router.load(url, options?)` | Load a detached URL state for SSR or prerendering | Async | Middleware is not executed and results are not cached | | `router.preload(target)` | Warm loaders for the next matching client navigation | Async | Params and query values are part of the cache key | | `router.createViewRegistry(views, options?)` | Create an exhaustive route-to-view resolver | Sync | Pass `options.notFound` to render the configured fallback | | `router.ready` | Await the initial navigation | Async | Rejects when initial loading fails | | `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 | ## `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: { data: () => ({ message: 'Page not found' }) }, }); ``` | Option | Type | Default | Description | | -------------- | ---------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `base` | `string` | `'/'` | Base path prefix for all routes | | `coerceSearch` | `CoerceSearchFn` | N/A | 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` | `{ data?, middleware? }` | N/A | 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` | N/A | Optional sink for non-awaited/background router errors | | `routes` | `RouteTable` | required | Declarative route table. Object key order defines match precedence. | | `scroll` | `(to, from) => ScrollDecision` | N/A | Apply scroll behavior after successful navigation | | `viewTransition` | `boolean` | `false` | Wrap navigation commits in the browser 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', 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. | | `data` | `DataFn` | Data loader. Runs after middleware; result available as `match.data`. | | `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. ## `createHashHistory(options?)` ```ts import { createHashHistory, createRouter } from '@vielzeug/wayfinder'; const base = '/my-app/'; const router = createRouter({ base, history: createHashHistory({ base }), routes, }); ``` Create a browser history driver that stores the route after `#`. Use it for static hosts that cannot rewrite deep links to the SPA entry file. `push()`, `replace()`, query strings, route hashes, state, and back navigation follow the `HistoryDriver` contract. ## `createHistoryForBase(base)` ```ts import { createHistoryForBase, createRouter } from '@vielzeug/wayfinder'; const base = import.meta.env.BASE_URL; const router = createRouter({ base, history: createHistoryForBase(base), routes }); ``` Choose the history driver for a deploy base: `createBrowserHistory()` when `base` is `'/'` (clean paths), `createHashHistory({ base })` otherwise (deep links survive a static host that cannot rewrite them). Removes the `base === '/' ? … : …` branch every app otherwise hand-rolls. **Returns:** `HistoryDriver` ## `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` | N/A | History state payload | | `force` | `boolean` | `false` | Re-run even when the destination URL is already current | | `viewTransition` | `boolean` | router default | Override view-transition behavior for this navigation | **Returns:** `Promise` History is written only after middleware reaches the terminal stage. Returning from middleware without `next()` cancels the programmatic navigation without changing history or the route snapshot. 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.href(name, params?, query?)` ```ts router.href('userDetail', { id: '42' }); ``` Build the address-bar form of a named route for anchor elements. Under `createBrowserHistory()` and `createMemoryHistory()` it matches `url()`; under `createHashHistory()` the route lives behind `#`, and `href()` returns that form (`/my-app/#/users/42`). **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.match(pathname)` ```ts router.match('/app/dashboard/settings'); // => [ // { name: 'dashboard', ... }, // { name: 'dashboard.settings', ... }, // ] ``` Inspect a pathname without running middleware, 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.load(url, options?)` ```ts // SSR data prefetch const state = await router.load('/users/42'); // With cancellation const controller = new AbortController(); const state = await router.load('/dashboard', { signal: controller.signal }); ``` Load a full URL into a `RouteState` including data loader results, without modifying router state or history. Follows declarative redirects (up to five hops). Returns `null` for unmatched URLs. Middleware is **not** executed: `load` is a data-only prefetch for SSR and pre-rendering where middleware side effects are not wanted. If your data loaders depend on `ctx.locals` set by middleware, use `navigate()` instead. When a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value. **Returns:** `Promise` --- #### `router.preload(target)` ```ts const state = await router.preload({ name: 'userDetail', params: { id: '42' }, query: { tab: 'profile' }, }); ``` Executes data loaders without changing router state or history. Concurrent calls for the same target are deduplicated. A subsequent navigation with the same route, params, and query consumes the cached data instead of rerunning loaders. **Returns:** `Promise` --- #### `router.createViewRegistry(views, options?)` ```ts const views = router.createViewRegistry( { home: HomePage, userDetail: UserPage }, { notFound: NotFoundPage }, ); const Component = views.resolve(router.getSnapshot()); ``` Requires one value for every renderable route name, excludes redirect-only routes, and rejects unknown keys. Values may be framework components, lazy factories, or richer presentation descriptors. `options.notFound` handles the synthetic fallback without exposing its internal route name. **Returns:** `RouteViewRegistry` --- #### `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. **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` ## `createPhaseMirror(options)` Mirrors a domain state machine's phase into a `/:subject/:id/:phase` route segment: the shared routing approach for stepped flows (wizards, chapters, onboarding). Two-way, with the state machine as the sole source of truth: - **Domain → URL**: every phase change lands on the phase's route, replacing so the browser back button exits the flow rather than stepping between phases. - **URL → domain**: a mismatched phase route is a revisit request (`requestRevisit`: the consumer owns its confirm dialog) when `canRevisit(current, target)` allows it, otherwise a bounce back to the actual phase. Reacts to the URL and to the domain loading, never to domain-led changes. The mirror stops following entirely when the route leaves the flow or names a different subject. - **Canonicalization**: the bare `detailRoute` URL redirects onto the current phase's route, so every arrival is shareable and reload-safe. Options carry the router, the valid `phases`, both route names, and ripple `Readable`s for `currentPhase` and `subjectId` (`null` while the subject is missing). The mirror exposes `routePhase`, `revisitPhases` (readables), `phaseHref`, `followPhase`, `bounceIfMismatched` (the dialog cancel path), and `dispose()`. ## `createRouteSignals(router)` ```ts import { createRouteSignals } from '@vielzeug/wayfinder'; const route = createRouteSignals(router); const activeName = computed(() => route.name.value); // re-computes on every navigation ``` Mirrors a router into ripple readables so templates and computeds react to navigation. `router.getSnapshot()` alone is **not** ripple-reactive: reading it inside a `computed()` computes once and never re-runs, because it registers no tracked dependency. This bridge wraps `subscribe()`/`getSnapshot()` with `fromSubscribable` once, for every consumer. **Returns:** `RouteSignals`: `name` (deepest matched route name, `null` while nothing matches), `params` (its path params), `query` (raw string-valued query params of the current location), and `state` (the full `RouteState`, for deriving further computeds). ## `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.ready` A `Promise` for the constructor-triggered navigation. It resolves after initial middleware, redirects, and data loaders settle. It resolves after a blocked or unmatched initial navigation, and rejects if initial navigation fails. ```ts const router = createRouter({ routes }); await router.ready; ``` --- #### `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); // Read data from the leaf match console.log(leaf?.data); }); ``` Register a listener for future state changes. The listener is **not** called with the current snapshot: call `router.getSnapshot()` when you subscribe if you need it. **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 | string, options?: NavigateOptions, ) => Promise; readonly params: Params; readonly pathname: string; readonly query: ResolvedQueryParams; }; ``` `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, ) => MaybePromise; ``` Data loader function. The return value becomes `match.data` on the leaf route match. ### `DataContext` ```ts type DataContext = RouteContext & { readonly signal: AbortSignal; }; ``` The `signal` is aborted when a newer navigation supersedes the current one. Use it to cancel in-flight fetches or other async work. ### `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; }; ``` ### `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 = { /** Result of the route's `data()` function, or `undefined` if none was defined. */ readonly data: unknown; readonly name: string; readonly params: RouteParams; readonly pathname: string; }; ``` ### `RouteMatchBranch` ```ts type RouteMatchBranch = readonly RouteMatch[]; ``` ### `RouteViewName`, `RouteViewMap`, and `RouteViewRegistry` `RouteViewName` contains renderable route names and excludes redirect-only routes. `RouteViewMap` requires one value per renderable route. `RouteViewRegistry` resolves the active view from a `RouteState` without exposing the synthetic not-found name. ### `ScrollDecision` ```ts type ScrollDecision = 'preserve' | 'top' | { x: number; y: number }; ``` ### `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' | 'error'; ``` Top-level status of the router. - `idle`: navigation settled successfully. - `loading`: data loaders are in-flight. - `error`: a data loader threw and no route-level `onError` handled it. ### `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; }; ``` ### `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' }; ``` 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/middleware/coerceSearch/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. ```ts import { WayfinderError } from '@vielzeug/wayfinder'; try { await router.navigate({ name: 'home' }); } catch (e) { if (e instanceof WayfinderError) { // any router-originated error: check e.name or `instanceof` a subclass for detail } } ``` ### `WayfinderDisposedError` Thrown when `navigate()`, `subscribe()`, `beforeLeave()`, or `waitFor()` 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 } } ``` ### `WayfinderConfigError` Thrown at `createRouter()` time when the route table or a path pattern is malformed: for example a route that sets both `index: true` and `path`, a duplicate route name, or a wildcard that is not the final path segment. ### `WayfinderRouteError` Thrown at navigation time when a `url()`/`navigate()` call references an unknown route name or omits a path param the pattern requires. ### `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` | `WayfinderConfigError` | A route sets `index: true` and `path` at the same time | | `Route "X" must define path or set index: true` | `WayfinderConfigError` | A route defines neither `index: true` nor `path` | | `Duplicate route name: "X"` | `WayfinderConfigError` | Two routes resolve to the same compound name during `createRouter()` | | `Missing path param: X` | `WayfinderRouteError` | `url()`/`navigate()` omits a param the path pattern requires | | `Invalid param name ":X" in path "Y"` | `WayfinderConfigError` | A param name contains non-word characters (e.g., `:user-id`) | | `Wildcard "*" must be the final segment in path: X` | `WayfinderConfigError` | A `*` segment appears before the last segment | | `Wildcard param must be final segment in path: X` | `WayfinderConfigError` | 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 | ## Design Notes - Wayfinder is a routing core: route compilation, matching, history, navigation, and cancellation. Data loading is an optional layer via per-route `data()` functions. UI rendering is owned by the framework adapter, not Wayfinder. - 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`. The original error object is never mutated: error context is carried in internal wrappers and the original `cause` chain is preserved. - Declarative `redirect` on a route definition is for permanent alias redirects. The `redirectTo()` middleware helper is for conditional guards. - `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. ## Mirror a State Machine into the URL A stepped flow's URL should name the step the domain is on: shareable, reload-safe, and never ahead of the truth. `createPhaseMirror` wires a `Router` to the domain phase as a ripple `Readable`, replacing (never pushing) so back exits the flow. ```ts import { createPhaseMirror } from '@vielzeug/wayfinder'; import { signal } from '@vielzeug/ripple'; const currentPhase = signal(null); const subjectId = signal(null); const mirror = createPhaseMirror({ canRevisit: (current, target) => current === 'fight' && target === 'preparing', currentPhase, detailRoute: 'easyPathDetail', phases: ['story', 'preparing', 'fight'], phaseRoute: 'easyPathPhase', requestRevisit: (phase) => openConfirmDialog(phase), router, subjectId, }); mirror.routePhase.value; // the phase segment of the current route, or null mirror.phaseHref('preparing'); // anchor href for a tracker link mirror.dispose(); // stop mirroring ``` The consumer supplies reactivity; the mirror never writes history. A Vue app bridges its reactive getters into ripple signals and disposes the mirror with its component scope. ## Basic Usage Create a deterministic router with memory history, wait for startup, and navigate by route name. ```ts import { createMemoryHistory, createRouter } from '@vielzeug/wayfinder'; const router = createRouter({ history: createMemoryHistory('/'), routes: { home: { path: '/' }, settings: { data: async () => ({ section: 'settings' }), path: '/settings', }, }, }); await router.ready; await router.navigate({ name: 'settings' }); console.log(router.getSnapshot().matches.at(-1)?.data); router.dispose(); ``` `routes` is required. Route keys become names, 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 | | `data` | Abortable route data function. Result available as `match.data`. | | `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: { 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. ### 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 ``` ### 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 }); ``` Render anchors with `router.href()`: it returns the address-bar form of a route, so hash-history deployments get `#`-prefixed links that plain `url()` strings cannot provide. `isActive(name)` reads the current router snapshot and is useful for parent navigation items. ## Match a Path Without Navigating ```ts const branch = router.match('/app/dashboard/settings'); if (branch?.at(-1)?.name === 'dashboard.settings') { warmSettingsPanel(); } ``` `match()` strips the configured base automatically and returns the full matched branch (root to leaf). Data loaders are not executed. ## Load a Path for SSR Use `router.load(url)` to load a full route state including data loader results without modifying router state or history. This is useful for server-side data prefetching. ```ts const state = await router.load('/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.load('/users/42', { signal: controller.signal }); ``` `load()` follows declarative redirects (up to five hops). Middleware is not executed and its result is not reused by client navigation. ## Preload Client Navigation Use `preload()` with the same typed named target accepted by `navigate()`. It executes route data loaders without changing router state or history, deduplicates concurrent work, and lets the next matching navigation consume the result. ```ts await router.preload({ name: 'userDetail', params: { id: '42' } }); await router.navigate({ name: 'userDetail', params: { id: '42' } }); ``` Params and query values are part of the cache key. A navigation with different values runs its loaders normally. ## Coordinate Scroll and View Transitions Keep commit-sensitive browser behavior on the router: ```ts const router = createRouter({ routes, scroll: (to, from) => (to.location.pathname === from.location.pathname ? 'preserve' : 'top'), viewTransition: true, }); await router.navigate({ name: 'settings' }, { viewTransition: false }); ``` `scroll` runs after a successful navigation and can return `'top'`, `'preserve'`, or `{ x, y }`. View transitions fall back to plain navigation when the browser API is unavailable. ## State and Subscriptions ```ts router.subscribe((state) => { const leaf = state.matches.at(-1); console.log(leaf?.data); }); ``` 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' | 'error' error; // only set when status === 'error' ``` 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. ## 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. ## Build a Typed View Registry Keep matching and data concerns in the route table while storing framework components, lazy factories, and static presentation metadata in an exhaustive registry: ```ts const views = router.createViewRegistry( { home: { component: HomePage, title: 'Home' }, settings: { component: SettingsPage, title: 'Settings' }, }, { notFound: { component: NotFoundPage, title: 'Not found' } }, ); const view = views.resolve(router.getSnapshot()); ``` Every renderable route name is required; redirect-only routes are excluded. Unknown keys are rejected, and the fallback is explicit rather than relying on the router's internal not-found match name. ## 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: { path: '/' }, settings: { path: '/settings' }, }, notFound: { data: () => ({ message: 'Not found' }) }, }); // Stable router actions are safe to destructure outside the hook. const { getSnapshot, isActive, navigate, subscribe, url } = router; export function useRouter() { const state = useSyncExternalStore(subscribe, getSnapshot); return { isActive, navigate, state, url }; } // RouterView.tsx: exhaustive routes and an explicit fallback const views = router.createViewRegistry( { home: HomePage, settings: SettingsPage }, { notFound: NotFoundPage }, ); export function RouterView() { const { state } = useRouter(); const Component = views.resolve(state); return Component ? : null; } ``` ```ts [Vue 3] import { createRouter } from '@vielzeug/wayfinder'; import { readonly, shallowRef } from 'vue'; const router = createRouter({ routes: { home: { path: '/' }, settings: { path: '/settings' }, }, notFound: { data: () => ({ message: 'Not found' }) }, }); // shallowRef: no need to deep-track immutable route state. const state = shallowRef(router.getSnapshot()); router.subscribe((next) => { state.value = next; }); export function useRouter() { const { isActive, navigate, url } = router; return { isActive, navigate, state: readonly(state), url }; } ``` ```svelte [Svelte] import { createRouter } from '@vielzeug/wayfinder'; import { readable } from 'svelte/store'; const router = createRouter({ routes: { home: { path: '/' }, settings: { path: '/settings' }, }, notFound: { data: () => ({ message: 'Not found' }) }, }); // readable injects the initial value; subscribe() drives updates. export const routerState = readable(router.getSnapshot(), (set) => router.subscribe(set)); export const { isActive, navigate, url } = 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 Logging `router.subscribe()` is the reactive subscription API: it receives every state change, including `loading` and `error` transitions. Attach a listener that logs to `console.debug` to inspect navigation without any dedicated debug tooling. ```ts import { createRouter } from '@vielzeug/wayfinder'; const router = createRouter({ routes }); const stop = router.subscribe((state) => { console.debug(`[wayfinder] ${state.status} ${state.location.pathname}`); }); // Logged once the initial navigation completes: // [wayfinder] idle / // On navigate({ name: 'dashboard' }): // [wayfinder] loading /dashboard // [wayfinder] idle /dashboard ``` The returned function unsubscribes the listener: call it when the logger is no longer needed (e.g. on teardown): ```ts stop(); ``` Errors are surfaced on the state object, so you can log them explicitly: ```ts router.subscribe((state) => { if (state.status === 'error') { console.error(`[wayfinder] ${state.location.pathname}`, state.error); } }); ``` Use a label when running multiple routers to distinguish their log output: ```ts const main = createRouter({ routes }); main.subscribe((state) => console.debug(`[wayfinder:main] ${state.status} ${state.location.pathname}`)); const modal = createRouter({ routes: modalRoutes }); modal.subscribe((state) => console.debug(`[wayfinder:modal] ${state.status} ${state.location.pathname}`)); ``` 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 { allow, createWard } from '@vielzeug/ward'; type User = { id: string; roles: string[] }; const ward = createWard([allow('admin', 'settings', ['view'])]); const router = createRouter({ middleware: [ (ctx, next) => { const user: User = getSessionUser(); const decision = ward.decide({ action: 'view', principal: user, resource: 'settings' }); if (decision.effect !== 'allow') 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. ### Examples ## Examples - [Route Table Basics](./examples/route-table-basics.md) - [Not Found and Error Boundary](./examples/not-found-and-error-boundary.md) - [Page Titles from View Metadata](./examples/page-titles-from-meta.md) - [Auth and Guards](./examples/auth-and-guards.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`) - 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 Cache 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: Path Matching and Active State (id: `url-building`)