@vielzeug/codex 2.2.9 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/catalog.json +85 -41
- package/data/llms-full.txt +1514 -370
- package/data/llms.txt +2 -1
- package/data/manifest.json +1 -1
- package/data/packages/clockwork.json +2 -2
- package/data/packages/conduit.json +1 -1
- package/data/packages/courier.json +7 -6
- package/data/packages/dnd.json +1 -1
- package/data/packages/familiar.json +1 -1
- package/data/packages/forge.json +1 -1
- package/data/packages/gesture.json +1 -1
- package/data/packages/herald.json +18 -18
- package/data/packages/keymap.json +2 -2
- package/data/packages/lingua.json +1 -1
- package/data/packages/necromancer.json +1 -1
- package/data/packages/ore.json +1 -1
- package/data/packages/postmaster.json +51 -0
- package/data/packages/pulse.json +31 -30
- package/data/packages/scout.json +13 -12
- package/data/packages/scroll.json +1 -1
- package/data/packages/sentinel.json +1 -1
- package/data/packages/spell.json +1 -1
- package/data/packages/vault.json +22 -28
- package/data/packages/ward.json +28 -28
- package/data/packages/wayfinder.json +5 -5
- package/data/refine.json +4150 -4190
- package/data/search.json +80 -54
- package/package.json +2 -1
package/data/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vielzeug
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 37 focused TypeScript packages. Version: 2.3.0
|
|
4
4
|
|
|
5
5
|
Install any package independently: `pnpm add @vielzeug/<name>`
|
|
6
6
|
|
|
@@ -27,6 +27,7 @@ Install any package independently: `pnpm add @vielzeug/<name>`
|
|
|
27
27
|
- [@vielzeug/necromancer](/necromancer/): Lifecycle-owned Web Animations API primitives for native playback, groups, and additive FLIP transitions.
|
|
28
28
|
- [@vielzeug/orbit](/orbit/): Dependency-free floating positioning with lifecycle-owned geometry and middleware.
|
|
29
29
|
- [@vielzeug/ore](/ore/): Functional custom-element authoring with typed props, reactive templates, lifecycle helpers, and testing utilities.
|
|
30
|
+
- [@vielzeug/postmaster](/postmaster/): Typed durable job outbox with leased processing, retries, and dead-letter recovery for browser applications.
|
|
30
31
|
- [@vielzeug/prism](/prism/): Reactive SVG charting library — line, bar, and area charts. Signal-driven updates, CSS-themeable, accessible.
|
|
31
32
|
- [@vielzeug/pulse](/pulse/): Explicitly connected, typed WebSocket sessions with scoped channels, ref-counted rooms with reactive presence, reconnect restoration, and heartbeat.
|
|
32
33
|
- [@vielzeug/refine](/refine/): Accessible, themeable web components built with Ore for framework and vanilla DOM apps.
|
package/data/manifest.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"apiSource": "export type { ClockworkErrorCode } from './errors.js';\nexport { ClockworkError } from './errors.js';\nexport { defineMachine } from './interpret.js';\nexport type {\n Actor,\n ActorErrorContext,\n ActorErrorDisposition,\n ActorOptions,\n After,\n Effect,\n EffectArgs,\n EventByType,\n EventType,\n Guard,\n Invoke,\n InvokeArgs,\n Machine,\n MachineConfig,\n MachineEvent,\n MachineSnapshot,\n Reducer,\n StateNode,\n Transition,\n TransitionInput,\n TransitionResult,\n} from './types.js';\n",
|
|
3
3
|
"docs": {
|
|
4
4
|
"index": "---\ntitle: Clockwork — Typed finite state machines for TypeScript\ndescription: Framework-neutral typed state machines with pure transitions, actor-owned runtime work, timers, invokes, and explicit effects.\npackage: clockwork\ncategory: state\nkeywords: [state-machine, finite-state, typed, actor, async-tasks]\nrelated: [herald, ripple, ward]\nexports: [defineMachine, ClockworkError, Machine, Actor, MachineConfig, MachineSnapshot, TransitionResult]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"clockwork\" />\n\n## Why Clockwork?\n\nApplication 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.\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\n// Before\nif (status === 'idle') status = 'loading';\nfetchItems().then((items) => {\n status = 'ready';\n data = items;\n});\n\n// After\ntype Event = { type: 'FETCH' } | { items: string[]; type: 'DONE' };\nconst machine = defineMachine<{ items: string[] }, Event>()({\n context: { items: [] },\n initial: 'idle',\n states: {\n idle: { on: { FETCH: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: ({ signal }) => fetch('/api/items', { signal }).then((response) => response.json() as Promise<string[]>),\n onDone: ({ result }) => ({ items: result, type: 'DONE' }),\n }],\n on: { DONE: { reduce: ({ event }) => ({ items: event.items }), target: 'ready' } },\n },\n ready: {},\n },\n});\n```\n\n| Feature | Clockwork | XState | Zustand |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"clockwork\" type=\"size\" /> | Larger actor/statechart runtime | Smaller store runtime |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Pure transition API | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> Statechart-focused | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Owned cancellation | <ore-icon name=\"check\" size=\"16\"></ore-icon> Actor disposal | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Framework coupling | <ore-icon name=\"check\" size=\"16\"></ore-icon> None | <ore-icon name=\"check\" size=\"16\"></ore-icon> None | <ore-icon name=\"check\" size=\"16\"></ore-icon> None |\n\n<div class=\"decision-callout\">\n\n**Use Clockwork when** your feature has explicit workflow states, cancellable work, or effects that must run after a state commit.\n\n**Consider XState when** you need statecharts, visual tooling, or its broader actor ecosystem.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/clockwork\n```\n\n```sh [npm]\nnpm install @vielzeug/clockwork\n```\n\n```sh [yarn]\nyarn add @vielzeug/clockwork\n```\n\n:::\n\n## Quick Start\n\nDefine the context and event union, create an actor, observe its snapshot, then dispose it when its owner ends.\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\ntype Event = { type: 'DEC' } | { type: 'INC' };\n\nconst counter = defineMachine<{ count: number }, Event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n DEC: { reduce: ({ context }) => ({ count: context.count - 1 }), target: 'idle' },\n INC: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n },\n },\n },\n});\n\nusing actor = counter.createActor();\nactor.subscribe((snapshot) => console.log(snapshot));\nactor.send({ type: 'INC' });\n// { context: { count: 1 }, state: 'idle' }\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`defineMachine()`** — validates and compiles one flat machine definition.\n- **`machine.transition()`** — evaluates a transition without actor runtime work.\n- **`machine.createActor()`** — creates isolated, disposable runtime ownership.\n- **`reduce`** — returns a replacement context from a transition.\n- **`effects`** — run only after the actor commits and notifies subscribers.\n- **`invoke`** — runs cancellable asynchronous work on state entry.\n- **`after`** — schedules cancellable delayed transitions.\n- **`actor.snapshot`** — exposes the current readonly state/context value.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Herald](/herald/) — publish events between independent actors without coupling machine definitions.\n- [Ripple](/ripple/) — bridge actor snapshots into a reactive graph when you need fine-grained rendering.\n- [Ward](/ward/) — call authorization predicates from transition guards.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Clockwork — API Reference\ndescription: Reference for Clockwork machine definitions, actors, devtools, and types.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `defineMachine()` | Compile a typed flat machine definition | Sync | Call the generic factory before supplying the definition |\n| `Machine.transition()` | Resolve a pure next snapshot | Sync | Does not run effects, invokes, or timers |\n| `Machine.createActor()` | Create a runtime owner | Sync | Fresh and restored actors have different entry behavior |\n| `Actor.send()` | Dispatch an event | Sync | Returns `void`; re-entrant events queue internally |\n| `debugActor()` | Observe committed snapshots | Sync | Observes only; it does not trace sends or errors |\n| `ClockworkError` | Report definition and snapshot validation failures | Sync | Use `code`, not message text |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/clockwork` | Machine compiler, actor runtime, errors, and types |\n| `@vielzeug/clockwork/devtools` | Opt-in snapshot observation through `debugActor()` |\n\n## Core Functions\n\n### `defineMachine()`\n\n```ts\nfunction defineMachine<\n Context extends Record<string, unknown> = Record<string, never>,\n Event extends MachineEvent = MachineEvent,\n>(): <State extends string>(definition: MachineConfig<State, Context, Event>) => Machine<State, Context, Event>;\n```\n\nReturns a factory that validates and compiles a typed flat machine definition. Context must be a non-array record. Omit `context` only when the context type has no keys.\n\n**Returns:** A definition function that returns `Machine`.\n\n**Example:**\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\ntype Event = { type: 'START' };\n\nconst machine = defineMachine<Record<string, never>, Event>()({\n initial: 'idle',\n states: { idle: { on: { START: { target: 'running' } } }, running: {} },\n});\n```\n\nThrows `ClockworkError` when a definition has an invalid context, initial state, target, transition, effect, invoke, or timer delay.\n\n---\n\n### `debugActor()`\n\n```ts\nfunction debugActor<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent>(\n actor: Actor<State, Context, Event>,\n options?: DebugActorOptions<State, Context>,\n): () => void;\n```\n\nSubscribes to committed actor snapshots and logs each one with `console.debug` by default. It does not modify actor behavior and does not observe dispatched events or runtime errors.\n\n**Returns:** An unsubscribe cleanup function.\n\n**Example:**\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\nimport { debugActor } from '@vielzeug/clockwork/devtools';\n\nconst machine = defineMachine<Record<string, never>, { type: 'NEXT' }>()({\n initial: 'idle',\n states: { idle: { on: { NEXT: { target: 'idle' } } } },\n});\n\nconst actor = machine.createActor();\nconst stopDebugging = debugActor(actor);\nactor.send({ type: 'NEXT' });\nstopDebugging();\nactor.dispose();\n```\n\n## Machine Methods\n\n### `machine.transition()`\n\n```ts\ntransition(\n snapshot: MachineSnapshot<State, Context>,\n event: Event,\n): TransitionResult<State, Context>;\n```\n\nResolves a snapshot for one user event without actor runtime work.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `snapshot` | `MachineSnapshot<State, Context>` | Input state and context |\n| `event` | `Event` | User event to evaluate |\n\n**Returns:** A `TransitionResult` with `transition` or `ignored` type.\n\n**Example:**\n\n```ts\nconst result = machine.transition(machine.initialSnapshot, { type: 'START' });\n```\n\n---\n\n### `machine.can()`\n\n```ts\ncan(snapshot: MachineSnapshot<State, Context>, event: Event): boolean;\n```\n\nReturns whether a transition exists and its guard passes.\n\n**Returns:** `true` when the supplied snapshot accepts the event.\n\n---\n\n### `machine.createActor()`\n\n```ts\ncreateActor(options?: ActorOptions<State, Context, Event>): Actor<State, Context, Event>;\n```\n\nCreates 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.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.snapshot` | `MachineSnapshot<State, Context>` | Optional restored actor snapshot |\n| `options.maxTransitions` | `number` | Positive queued-transition limit for one synchronous flush |\n| `options.onError` | `(error, context) => 'continue' \\| 'dispose'` | Explicit disposition for runtime failures |\n\n**Returns:** Disposable `Actor`.\n\n**Example:**\n\n```ts\nconst actor = machine.createActor({\n onError(error, { phase, state }) {\n console.error(phase, state, error);\n return 'continue';\n },\n snapshot: { context: {}, state: 'idle' },\n});\n```\n\n## Actor Methods\n\n### `actor.send()`\n\n```ts\nsend(event: Event): void;\n```\n\nDispatches 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.\n\n**Returns:** Nothing.\n\n---\n\n### `actor.can()`\n\n```ts\ncan(event: Event): boolean;\n```\n\nReturns whether the current actor snapshot accepts an event. Returns `false` after disposal.\n\n**Returns:** Boolean transition availability.\n\n---\n\n### `actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: MachineSnapshot<State, Context>) => void): () => void;\n```\n\nRegisters a listener for committed snapshots. The listener does not run immediately.\n\n**Returns:** An unsubscribe function.\n\n---\n\n### `actor.dispose()`\n\n```ts\ndispose(): void;\n[Symbol.dispose](): void;\n```\n\nCancels timers and invokes, clears queued events and listeners, and aborts `disposalSignal`.\n\n**Returns:** Nothing. Idempotent.\n\n## Types\n\n### `MachineEvent`\n\n```ts\ntype MachineEvent = { readonly type: string };\n```\n\nBase constraint for event unions.\n\n### `EventType<Event>` and `EventByType<Event, Type>`\n\n```ts\ntype EventType<Event extends MachineEvent> = Event['type'] & string;\n\ntype EventByType<Event extends MachineEvent, Type extends EventType<Event>> =\n Extract<Event, { type: Type }>;\n```\n\nExtract event type names and a matching event from an event union.\n\n### `MachineSnapshot<State, Context>`\n\n```ts\ntype MachineSnapshot<State extends string, Context extends Record<string, unknown>> = {\n readonly context: Readonly<Context>;\n readonly state: State;\n};\n```\n\nThe plain readonly snapshot value used by machines and actors. Readonly is a TypeScript contract; Clockwork does not copy or freeze snapshots at runtime.\n\n### `Guard<Context, Event>` and `Reducer<Context, Event>`\n\n```ts\ntype Guard<Context extends Record<string, unknown>, Event> = (args: {\n readonly context: Readonly<Context>;\n readonly event: Event;\n}) => boolean;\n\ntype Reducer<Context extends Record<string, unknown>, Event> = (args: {\n readonly context: Readonly<Context>;\n readonly event: Event;\n}) => Context;\n```\n\nA guard selects a transition. A reducer returns replacement context, which must be a non-array record.\n\n### `EffectArgs<Context, Event>` and `Effect<Context, Event>`\n\n```ts\ntype EffectArgs<Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly context: Readonly<Context>;\n readonly event: Event | undefined;\n readonly send: (event: Event) => void;\n readonly signal: AbortSignal;\n};\n\ntype Effect<Context extends Record<string, unknown>, Event extends MachineEvent> =\n (args: EffectArgs<Context, Event>) => void;\n```\n\nPost-commit effects receive `undefined` for initial entry and actor timer transitions. They cannot update machine context directly.\n\n### `Transition<State, Context, Event, Type>` and `TransitionInput`\n\n```ts\ntype Transition<\n State extends string,\n Context extends Record<string, unknown>,\n Event extends MachineEvent,\n Type extends EventType<Event> = EventType<Event>,\n> = {\n readonly effects?: readonly Effect<Context, Event>[];\n readonly guard?: Guard<Context, EventByType<Event, Type>>;\n readonly reduce?: Reducer<Context, EventByType<Event, Type>>;\n readonly target: State;\n};\n\ntype TransitionInput<\n State extends string,\n Context extends Record<string, unknown>,\n Event extends MachineEvent,\n Type extends EventType<Event> = EventType<Event>,\n> = Transition<State, Context, Event, Type> | readonly Transition<State, Context, Event, Type>[];\n```\n\nAn ordered transition array selects the first guard that passes.\n\n### `After<State, Context, Event>`\n\n```ts\ntype After<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly delay: number;\n readonly effects?: readonly Effect<Context, Event>[];\n readonly guard?: Guard<Context, Event | undefined>;\n readonly reduce?: Reducer<Context, Event | undefined>;\n readonly target: State;\n};\n```\n\nA delayed state transition. Its guard and reducer receive `event: undefined`.\n\n### `InvokeArgs<Context, Event>` and `Invoke<Context, Event, Result>`\n\n```ts\ntype InvokeArgs<Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly context: Readonly<Context>;\n readonly event: Event | undefined;\n readonly signal: AbortSignal;\n};\n\ntype Invoke<Context extends Record<string, unknown>, Event extends MachineEvent, Result = unknown> = {\n readonly onDone?: (args: { readonly context: Readonly<Context>; readonly result: Result }) => Event;\n readonly onError?: (args: { readonly context: Readonly<Context>; readonly error: unknown }) => Event;\n readonly src: (args: InvokeArgs<Context, Event>) => Promise<Result> | Result;\n};\n```\n\nAn actor-owned task started on state entry. `event` is the triggering event or `undefined` for initial or restored resources.\n\n### `StateNode<State, Context, Event>` and `MachineConfig<State, Context, Event>`\n\n```ts\ntype StateNode<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly after?: readonly After<State, Context, Event>[];\n readonly entry?: readonly Effect<Context, Event>[];\n readonly exit?: readonly Effect<Context, Event>[];\n readonly invoke?: readonly Invoke<Context, Event>[];\n readonly on?: Partial<{ [Type in EventType<Event>]: TransitionInput<State, Context, Event, Type> }>;\n};\n\ntype MachineConfig<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> =\n (keyof Context extends never ? { readonly context?: Context } : { readonly context: Context }) & {\n readonly initial: State;\n readonly states: Record<State, StateNode<State, Context, Event>>;\n };\n```\n\nA flat machine definition. State nodes cannot contain child states.\n\n### `TransitionResult<State, Context>`\n\n```ts\ntype TransitionResult<State extends string, Context extends Record<string, unknown>> = {\n readonly snapshot: MachineSnapshot<State, Context>;\n readonly type: 'ignored' | 'transition';\n};\n```\n\nResult of a pure user-event transition. It contains no effect plan.\n\n### `ActorErrorContext<State, Event>`, `ActorErrorDisposition`, and `ActorOptions<State, Context, Event>`\n\n```ts\ntype ActorErrorContext<State extends string, Event extends MachineEvent> = {\n readonly event?: Event;\n readonly phase: 'effect' | 'invoke' | 'subscriber' | 'transition';\n readonly state: State;\n};\n\ntype ActorErrorDisposition = 'continue' | 'dispose';\n\ntype ActorOptions<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly maxTransitions?: number;\n readonly onError?: (error: unknown, context: ActorErrorContext<State, Event>) => ActorErrorDisposition;\n readonly snapshot?: MachineSnapshot<State, Context>;\n};\n```\n\n`onError` must explicitly return `'continue'` to keep the actor alive or `'dispose'` to end it. Without an error handler, Clockwork disposes the actor silently.\n\n### `Actor<State, Context, Event>`\n\n```ts\ntype Actor<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n [Symbol.dispose](): void;\n can(event: Event): boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n send(event: Event): void;\n readonly snapshot: MachineSnapshot<State, Context>;\n subscribe(listener: (snapshot: MachineSnapshot<State, Context>) => void): () => void;\n};\n```\n\nAn actor's `snapshot` is the current plain readonly snapshot.\n\n### `Machine<State, Context, Event>`\n\n```ts\ntype Machine<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n can(snapshot: MachineSnapshot<State, Context>, event: Event): boolean;\n createActor(options?: ActorOptions<State, Context, Event>): Actor<State, Context, Event>;\n readonly initialSnapshot: MachineSnapshot<State, Context>;\n transition(snapshot: MachineSnapshot<State, Context>, event: Event): TransitionResult<State, Context>;\n};\n```\n\nA compiled, reusable machine. Its transition lookup is map-based, so unknown or poison event names such as `__proto__` are safely ignored when no transition exists.\n\n### `DebugActorOptions<State, Context>`\n\n```ts\ntype DebugActorOptions<State extends string, Context extends Record<string, unknown>> = {\n readonly logger?: (snapshot: MachineSnapshot<State, Context>) => void;\n};\n```\n\nOptional logger for `debugActor()`. Logger failures are ignored so observation cannot affect the actor's error policy.\n\n## Errors\n\n### `ClockworkErrorCode`\n\n```ts\ntype ClockworkErrorCode =\n | 'INVALID_AFTER_DELAY'\n | 'INVALID_CONTEXT'\n | 'INVALID_DEFINITION'\n | 'INVALID_EFFECT'\n | 'INVALID_INITIAL_STATE'\n | 'INVALID_INVOKE'\n | 'INVALID_MAX_TRANSITIONS'\n | 'INVALID_SNAPSHOT_STATE'\n | 'INVALID_TRANSITION'\n | 'INVALID_TRANSITION_LIMIT'\n | 'UNKNOWN_TARGET';\n```\n\nStable machine-readable code identifying a Clockwork failure category.\n\n### `ClockworkError`\n\n`ClockworkError` reports invalid definitions, contexts, snapshots, and actor transition limits. It has `code`, `details`, and standard `Error` fields. Use `instanceof ClockworkError` to narrow an unknown error.\n\n```ts\nif (error instanceof ClockworkError) {\n console.error(error.code, error.details);\n}\n```\n",
|
|
6
|
-
"usage": "---\ntitle: Clockwork — Usage Guide\ndescription: Build deterministic state machines with pure transitions and actor-owned runtime work.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCall `defineMachine<Context, Event>()` 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.\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\ntype Event = { type: 'TOGGLE' };\n\nconst machine = defineMachine<Record<string, never>, Event>()({\n initial: 'on',\n states: {\n off: { on: { TOGGLE: { target: 'on' } } },\n on: { on: { TOGGLE: { target: 'off' } } },\n },\n});\n\nconst actor = machine.createActor();\nactor.send({ type: 'TOGGLE' });\nconsole.log(actor.snapshot.state); // 'off'\nactor.dispose();\n```\n\nDispose actors when a feature, request, or test ends. You can use `using` when the surrounding runtime supports `Symbol.dispose`.\n\n```ts\nusing actor = machine.createActor();\nactor.send({ type: 'TOGGLE' });\n```\n\n## Context reducers\n\nA reducer receives readonly context and returns the next context. Clockwork does not copy or freeze context at runtime, so do not mutate data that other code may retain.\n\n```ts\ntype Event = { type: 'DEC' } | { type: 'INC' } | { type: 'RESET' };\n\nconst counter = defineMachine<{ count: number }, Event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n DEC: { reduce: ({ context }) => ({ count: context.count - 1 }), target: 'idle' },\n INC: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n RESET: { reduce: () => ({ count: 0 }), target: 'idle' },\n },\n },\n },\n});\n```\n\nKeep reducers pure. Make nested copies yourself when nested data changes.\n\n```ts\nSAVE: {\n reduce: ({ context, event }) => ({\n ...context,\n profile: { ...context.profile, name: event.name },\n }),\n target: 'editing',\n}\n```\n\n## Guards\n\nGuards 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.\n\n```ts\nPAY: [\n {\n guard: ({ context }) => context.balance >= context.total,\n reduce: ({ context }) => ({ ...context, balance: context.balance - context.total }),\n target: 'success',\n },\n { target: 'insufficientFunds' },\n]\n```\n\nCall `actor.can(event)` for the current actor snapshot or `machine.can(snapshot, event)` for an arbitrary snapshot.\n\n## Pure transitions\n\n`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.\n\n```ts\nconst result = counter.transition(\n { context: { count: 3 }, state: 'idle' },\n { type: 'INC' },\n);\n\nif (result.type === 'transition') {\n console.log(result.snapshot.context.count); // 4\n}\n```\n\n## Effects\n\nEntry, exit, and transition effects run only through an actor. The actor commits, establishes the new state's timers and invokes, notifies subscribers, then runs exit, transition, and entry effects. Effects cannot change context; send a regular event for another state change.\n\n```ts\ntype WorkflowEvent = { type: 'SUBMIT' };\nconst workflow = defineMachine<{ orderId: string }, WorkflowEvent>()({\n context: { orderId: '' },\n initial: 'draft',\n states: {\n draft: {\n on: {\n SUBMIT: {\n effects: [({ context }) => console.debug('submitted', context)],\n target: 'submitted',\n },\n },\n },\n submitted: { entry: [({ context }) => console.log(`Submitted ${context.orderId}`)] },\n },\n});\n```\n\nEffects receive `context`, the triggering `event` (or `undefined` for initial entry), actor `send`, and the actor lifetime `signal`.\n\n## Async invokes\n\nInvokes 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.\n\n```ts\ntype LoadEvent =\n | { type: 'FETCH' }\n | { items: string[]; type: 'SUCCESS' }\n | { message: string; type: 'FAILURE' }\n | { type: 'RETRY' };\n\nconst loader = defineMachine<{ error: string; items: string[] }, LoadEvent>()({\n context: { error: '', items: [] },\n initial: 'idle',\n states: {\n idle: { on: { FETCH: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: async ({ signal }) => {\n const response = await fetch('/api/items', { signal });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n return response.json() as Promise<string[]>;\n },\n onDone: ({ result }) => ({ items: result, type: 'SUCCESS' }),\n onError: ({ error }) => ({ message: String(error), type: 'FAILURE' }),\n }],\n on: {\n FAILURE: { reduce: ({ event }) => ({ error: event.message, items: [] }), target: 'error' },\n SUCCESS: { reduce: ({ event }) => ({ error: '', items: event.items }), target: 'ready' },\n },\n },\n ready: {},\n error: { on: { RETRY: { target: 'loading' } } },\n },\n});\n```\n\n## Delayed transitions\n\n`after` starts timers on state entry and cancels them on exit or disposal. Its guard and reducer receive `event: undefined`; a user event with `type: '$after'` remains a normal user event.\n\n```ts\ntype NotificationEvent = { type: 'DISMISS' } | { message: string; type: 'SHOW' };\nconst notification = defineMachine<{ message: string }, NotificationEvent>()({\n context: { message: '' },\n initial: 'hidden',\n states: {\n hidden: { on: { SHOW: { reduce: ({ event }) => ({ message: event.message }), target: 'visible' } } },\n visible: {\n after: [{ delay: 5_000, target: 'hidden' }],\n on: { DISMISS: { target: 'hidden' } },\n },\n },\n});\n```\n\n## Snapshot observation and persistence\n\n`actor.snapshot` is the current plain readonly snapshot; 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.\n\n```ts\nconst stored = sessionStorage.getItem('wizard');\nconst actor = machine.createActor({\n snapshot: stored ? JSON.parse(stored) : undefined,\n});\n\nconst stopSaving = actor.subscribe((snapshot) => {\n sessionStorage.setItem('wizard', JSON.stringify(snapshot));\n});\n\nconsole.log(actor.snapshot);\nstopSaving();\nactor.dispose();\n```\n\nValidate untrusted persisted data before passing it to `createActor()`. Clockwork validates the restored state name but cannot validate application-specific context fields.\n\n## Error handling\n\nUse `onError` to choose what happens after failures from transitions, effects, invokes, or subscribers. The context identifies the runtime phase and state; an event is present when one triggered the failure. Return `'continue'` to keep the actor alive or `'dispose'` to end it.\n\n```ts\nconst actor = machine.createActor({\n onError(error, { event, phase, state }) {\n console.error({ error, event, phase, state });\n return 'continue';\n },\n});\n```\n\nWithout `onError`, an actor disposes silently. Return `'dispose'` explicitly when an error handler logs an unrecoverable failure.\n\n## Debugging\n\nUse opt-in snapshot logging during development. `debugActor()` observes committed snapshots only; it does not trace dispatched events or runtime errors.\n\n```ts\nimport { debugActor } from '@vielzeug/clockwork/devtools';\n\nconst actor = machine.createActor();\nconst stopDebugging = debugActor(actor);\nactor.send({ type: 'NEXT' });\nstopDebugging();\nactor.dispose();\n```\n\nFor richer inspection, subscribe to snapshots and record them in application devtools. Clockwork intentionally has no internal trace buffer.\n\n## Flat state maps\n\nClockwork has flat state IDs. Prefer explicit states such as `editingDraft` and `editingSaving`, or compose several actors when domains have independent lifecycles.\n\n## SSR\n\nReuse a compiled machine definition, but create and dispose an actor per request. Never share an actor across concurrent requests.\n\n## Testing\n\nTest deterministic state behavior through `machine.transition()`. Create actors only for timers, invokes, effects, queueing, subscriptions, or disposal behavior.\n\n```ts\nimport { expect, test } from 'vitest';\n\ntest('increments without an actor', () => {\n const result = counter.transition(\n { context: { count: 2 }, state: 'idle' },\n { type: 'INC' },\n );\n\n expect(result).toMatchObject({\n snapshot: { context: { count: 3 }, state: 'idle' },\n type: 'transition',\n });\n});\n```\n\n## Framework Integration\n\nBridge the current actor snapshot into renderer state through one subscription. Dispose that subscription with component lifecycle.\n\n::: code-group\n\n```ts [React]\nimport { useSyncExternalStore } from 'react';\n\nfunction useActor<Snapshot>(actor: { readonly snapshot: Snapshot; subscribe(listener: (snapshot: Snapshot) => void): () => void }) {\n return useSyncExternalStore(\n (notify) => actor.subscribe(() => notify()),\n () => actor.snapshot,\n );\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nconst snapshot = shallowRef(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nonUnmounted(stop);\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\n\nlet snapshot = actor.snapshot;\nconst stop = actor.subscribe((next) => (snapshot = next));\nonDestroy(stop);\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Herald when separate actors exchange application events. Bridge Clockwork snapshots into Ripple only at a UI or application boundary.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ REFRESH: void }>();\nbus.on('REFRESH', () => actor.send({ type: 'FETCH' }));\n```\n\n## Best Practices\n\n- Define context and event unions with `defineMachine<Context, Event>()`.\n- Return replacement context from reducers; do not rely on runtime copying or freezing.\n- Keep guards and reducers pure.\n- Use actors for effects, timers, invokes, subscriptions, and cancellation.\n- Read the current snapshot from `actor.snapshot`, not a wrapper value.\n- Validate persisted context before restoring a snapshot.\n- Dispose every actor at its ownership boundary.\n- Route runtime failures through `onError` when the owner can recover.\n",
|
|
5
|
+
"api": "---\ntitle: Clockwork — API Reference\ndescription: Reference for Clockwork machine definitions, actors, devtools, and types.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `defineMachine()` | Compile a typed flat machine definition | Sync | Call the generic factory before supplying the definition |\n| `Machine.transition()` | Resolve a pure next snapshot | Sync | Does not run effects, invokes, or timers |\n| `Machine.createActor()` | Create a runtime owner | Sync | Fresh and restored actors have different entry behavior |\n| `Actor.send()` | Dispatch an event | Sync | Returns `void`; re-entrant events queue internally |\n| `Actor.subscribe()` | Observe committed snapshots | Sync | Observes only; it does not trace sends or errors |\n| `ClockworkError` | Report definition and snapshot validation failures | Sync | Use `code`, not message text |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/clockwork` | Machine compiler, actor runtime, errors, and types |\n\n## Core Functions\n\n### `defineMachine()`\n\n```ts\nfunction defineMachine<\n Context extends Record<string, unknown> = Record<string, never>,\n Event extends MachineEvent = MachineEvent,\n>(): <State extends string>(definition: MachineConfig<State, Context, Event>) => Machine<State, Context, Event>;\n```\n\nReturns a factory that validates and compiles a typed flat machine definition. Context must be a non-array record. Omit `context` only when the context type has no keys.\n\n**Returns:** A definition function that returns `Machine`.\n\n**Example:**\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\ntype Event = { type: 'START' };\n\nconst machine = defineMachine<Record<string, never>, Event>()({\n initial: 'idle',\n states: { idle: { on: { START: { target: 'running' } } }, running: {} },\n});\n```\n\nThrows `ClockworkError` when a definition has an invalid context, initial state, target, transition, effect, invoke, or timer delay.\n\n---\n\n### `Actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: ActorSnapshot<State, Context>) => void): () => void;\n```\n\nSubscribes to committed actor snapshots. Returns an unsubscribe function. The listener receives the current snapshot immediately on subscribe, then on every committed transition. It does not observe dispatched events or runtime errors.\n\n**Example:**\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\nconst machine = defineMachine<Record<string, never>, { type: 'NEXT' }>()({\n initial: 'idle',\n states: { idle: { on: { NEXT: { target: 'idle' } } } },\n});\n\nconst actor = machine.createActor();\nconst stop = actor.subscribe((snapshot) => console.debug(snapshot));\nactor.send({ type: 'NEXT' });\nstop();\nactor.dispose();\n```\n\n## Machine Methods\n\n### `machine.transition()`\n\n```ts\ntransition(\n snapshot: MachineSnapshot<State, Context>,\n event: Event,\n): TransitionResult<State, Context>;\n```\n\nResolves a snapshot for one user event without actor runtime work.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `snapshot` | `MachineSnapshot<State, Context>` | Input state and context |\n| `event` | `Event` | User event to evaluate |\n\n**Returns:** A `TransitionResult` with `transition` or `ignored` type.\n\n**Example:**\n\n```ts\nconst result = machine.transition(machine.initialSnapshot, { type: 'START' });\n```\n\n---\n\n### `machine.can()`\n\n```ts\ncan(snapshot: MachineSnapshot<State, Context>, event: Event): boolean;\n```\n\nReturns whether a transition exists and its guard passes.\n\n**Returns:** `true` when the supplied snapshot accepts the event.\n\n---\n\n### `machine.createActor()`\n\n```ts\ncreateActor(options?: ActorOptions<State, Context, Event>): Actor<State, Context, Event>;\n```\n\nCreates 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.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.snapshot` | `MachineSnapshot<State, Context>` | Optional restored actor snapshot |\n| `options.maxTransitions` | `number` | Positive queued-transition limit for one synchronous flush |\n| `options.onError` | `(error, context) => 'continue' \\| 'dispose'` | Explicit disposition for runtime failures |\n\n**Returns:** Disposable `Actor`.\n\n**Example:**\n\n```ts\nconst actor = machine.createActor({\n onError(error, { phase, state }) {\n console.error(phase, state, error);\n return 'continue';\n },\n snapshot: { context: {}, state: 'idle' },\n});\n```\n\n## Actor Methods\n\n### `actor.send()`\n\n```ts\nsend(event: Event): void;\n```\n\nDispatches 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.\n\n**Returns:** Nothing.\n\n---\n\n### `actor.can()`\n\n```ts\ncan(event: Event): boolean;\n```\n\nReturns whether the current actor snapshot accepts an event. Returns `false` after disposal.\n\n**Returns:** Boolean transition availability.\n\n---\n\n### `actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: MachineSnapshot<State, Context>) => void): () => void;\n```\n\nRegisters a listener for committed snapshots. The listener does not run immediately.\n\n**Returns:** An unsubscribe function.\n\n---\n\n### `actor.dispose()`\n\n```ts\ndispose(): void;\n[Symbol.dispose](): void;\n```\n\nCancels timers and invokes, clears queued events and listeners, and aborts `disposalSignal`.\n\n**Returns:** Nothing. Idempotent.\n\n## Types\n\n### `MachineEvent`\n\n```ts\ntype MachineEvent = { readonly type: string };\n```\n\nBase constraint for event unions.\n\n### `EventType<Event>` and `EventByType<Event, Type>`\n\n```ts\ntype EventType<Event extends MachineEvent> = Event['type'] & string;\n\ntype EventByType<Event extends MachineEvent, Type extends EventType<Event>> =\n Extract<Event, { type: Type }>;\n```\n\nExtract event type names and a matching event from an event union.\n\n### `MachineSnapshot<State, Context>`\n\n```ts\ntype MachineSnapshot<State extends string, Context extends Record<string, unknown>> = {\n readonly context: Readonly<Context>;\n readonly state: State;\n};\n```\n\nThe plain readonly snapshot value used by machines and actors. Readonly is a TypeScript contract; Clockwork does not copy or freeze snapshots at runtime.\n\n### `Guard<Context, Event>` and `Reducer<Context, Event>`\n\n```ts\ntype Guard<Context extends Record<string, unknown>, Event> = (args: {\n readonly context: Readonly<Context>;\n readonly event: Event;\n}) => boolean;\n\ntype Reducer<Context extends Record<string, unknown>, Event> = (args: {\n readonly context: Readonly<Context>;\n readonly event: Event;\n}) => Context;\n```\n\nA guard selects a transition. A reducer returns replacement context, which must be a non-array record.\n\n### `EffectArgs<Context, Event>` and `Effect<Context, Event>`\n\n```ts\ntype EffectArgs<Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly context: Readonly<Context>;\n readonly event: Event | undefined;\n readonly send: (event: Event) => void;\n readonly signal: AbortSignal;\n};\n\ntype Effect<Context extends Record<string, unknown>, Event extends MachineEvent> =\n (args: EffectArgs<Context, Event>) => void;\n```\n\nPost-commit effects receive `undefined` for initial entry and actor timer transitions. They cannot update machine context directly.\n\n### `Transition<State, Context, Event, Type>` and `TransitionInput`\n\n```ts\ntype Transition<\n State extends string,\n Context extends Record<string, unknown>,\n Event extends MachineEvent,\n Type extends EventType<Event> = EventType<Event>,\n> = {\n readonly effects?: readonly Effect<Context, Event>[];\n readonly guard?: Guard<Context, EventByType<Event, Type>>;\n readonly reduce?: Reducer<Context, EventByType<Event, Type>>;\n readonly target: State;\n};\n\ntype TransitionInput<\n State extends string,\n Context extends Record<string, unknown>,\n Event extends MachineEvent,\n Type extends EventType<Event> = EventType<Event>,\n> = Transition<State, Context, Event, Type> | readonly Transition<State, Context, Event, Type>[];\n```\n\nAn ordered transition array selects the first guard that passes.\n\n### `After<State, Context, Event>`\n\n```ts\ntype After<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly delay: number;\n readonly effects?: readonly Effect<Context, Event>[];\n readonly guard?: Guard<Context, Event | undefined>;\n readonly reduce?: Reducer<Context, Event | undefined>;\n readonly target: State;\n};\n```\n\nA delayed state transition. Its guard and reducer receive `event: undefined`.\n\n### `InvokeArgs<Context, Event>` and `Invoke<Context, Event, Result>`\n\n```ts\ntype InvokeArgs<Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly context: Readonly<Context>;\n readonly event: Event | undefined;\n readonly signal: AbortSignal;\n};\n\ntype Invoke<Context extends Record<string, unknown>, Event extends MachineEvent, Result = unknown> = {\n readonly onDone?: (args: { readonly context: Readonly<Context>; readonly result: Result }) => Event;\n readonly onError?: (args: { readonly context: Readonly<Context>; readonly error: unknown }) => Event;\n readonly src: (args: InvokeArgs<Context, Event>) => Promise<Result> | Result;\n};\n```\n\nAn actor-owned task started on state entry. `event` is the triggering event or `undefined` for initial or restored resources.\n\n### `StateNode<State, Context, Event>` and `MachineConfig<State, Context, Event>`\n\n```ts\ntype StateNode<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly after?: readonly After<State, Context, Event>[];\n readonly entry?: readonly Effect<Context, Event>[];\n readonly exit?: readonly Effect<Context, Event>[];\n readonly invoke?: readonly Invoke<Context, Event>[];\n readonly on?: Partial<{ [Type in EventType<Event>]: TransitionInput<State, Context, Event, Type> }>;\n};\n\ntype MachineConfig<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> =\n (keyof Context extends never ? { readonly context?: Context } : { readonly context: Context }) & {\n readonly initial: State;\n readonly states: Record<State, StateNode<State, Context, Event>>;\n };\n```\n\nA flat machine definition. State nodes cannot contain child states.\n\n### `TransitionResult<State, Context>`\n\n```ts\ntype TransitionResult<State extends string, Context extends Record<string, unknown>> = {\n readonly snapshot: MachineSnapshot<State, Context>;\n readonly type: 'ignored' | 'transition';\n};\n```\n\nResult of a pure user-event transition. It contains no effect plan.\n\n### `ActorErrorContext<State, Event>`, `ActorErrorDisposition`, and `ActorOptions<State, Context, Event>`\n\n```ts\ntype ActorErrorContext<State extends string, Event extends MachineEvent> = {\n readonly event?: Event;\n readonly phase: 'effect' | 'invoke' | 'subscriber' | 'transition';\n readonly state: State;\n};\n\ntype ActorErrorDisposition = 'continue' | 'dispose';\n\ntype ActorOptions<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n readonly maxTransitions?: number;\n readonly onError?: (error: unknown, context: ActorErrorContext<State, Event>) => ActorErrorDisposition;\n readonly snapshot?: MachineSnapshot<State, Context>;\n};\n```\n\n`onError` must explicitly return `'continue'` to keep the actor alive or `'dispose'` to end it. Without an error handler, Clockwork disposes the actor silently.\n\n### `Actor<State, Context, Event>`\n\n```ts\ntype Actor<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n [Symbol.dispose](): void;\n can(event: Event): boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n send(event: Event): void;\n readonly snapshot: MachineSnapshot<State, Context>;\n subscribe(listener: (snapshot: MachineSnapshot<State, Context>) => void): () => void;\n};\n```\n\nAn actor's `snapshot` is the current plain readonly snapshot.\n\n### `Machine<State, Context, Event>`\n\n```ts\ntype Machine<State extends string, Context extends Record<string, unknown>, Event extends MachineEvent> = {\n can(snapshot: MachineSnapshot<State, Context>, event: Event): boolean;\n createActor(options?: ActorOptions<State, Context, Event>): Actor<State, Context, Event>;\n readonly initialSnapshot: MachineSnapshot<State, Context>;\n transition(snapshot: MachineSnapshot<State, Context>, event: Event): TransitionResult<State, Context>;\n};\n```\n\nA compiled, reusable machine. Its transition lookup is map-based, so unknown or poison event names such as `__proto__` are safely ignored when no transition exists.\n\n## Errors\n\n### `ClockworkErrorCode`\n\n```ts\ntype ClockworkErrorCode =\n | 'INVALID_AFTER_DELAY'\n | 'INVALID_CONTEXT'\n | 'INVALID_DEFINITION'\n | 'INVALID_EFFECT'\n | 'INVALID_INITIAL_STATE'\n | 'INVALID_INVOKE'\n | 'INVALID_MAX_TRANSITIONS'\n | 'INVALID_SNAPSHOT_STATE'\n | 'INVALID_TRANSITION'\n | 'INVALID_TRANSITION_LIMIT'\n | 'UNKNOWN_TARGET';\n```\n\nStable machine-readable code identifying a Clockwork failure category.\n\n### `ClockworkError`\n\n`ClockworkError` reports invalid definitions, contexts, snapshots, and actor transition limits. It has `code`, `details`, and standard `Error` fields. Use `instanceof ClockworkError` to narrow an unknown error.\n\n```ts\nif (error instanceof ClockworkError) {\n console.error(error.code, error.details);\n}\n```\n",
|
|
6
|
+
"usage": "---\ntitle: Clockwork — Usage Guide\ndescription: Build deterministic state machines with pure transitions and actor-owned runtime work.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCall `defineMachine<Context, Event>()` 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.\n\n```ts\nimport { defineMachine } from '@vielzeug/clockwork';\n\ntype Event = { type: 'TOGGLE' };\n\nconst machine = defineMachine<Record<string, never>, Event>()({\n initial: 'on',\n states: {\n off: { on: { TOGGLE: { target: 'on' } } },\n on: { on: { TOGGLE: { target: 'off' } } },\n },\n});\n\nconst actor = machine.createActor();\nactor.send({ type: 'TOGGLE' });\nconsole.log(actor.snapshot.state); // 'off'\nactor.dispose();\n```\n\nDispose actors when a feature, request, or test ends. You can use `using` when the surrounding runtime supports `Symbol.dispose`.\n\n```ts\nusing actor = machine.createActor();\nactor.send({ type: 'TOGGLE' });\n```\n\n## Context reducers\n\nA reducer receives readonly context and returns the next context. Clockwork does not copy or freeze context at runtime, so do not mutate data that other code may retain.\n\n```ts\ntype Event = { type: 'DEC' } | { type: 'INC' } | { type: 'RESET' };\n\nconst counter = defineMachine<{ count: number }, Event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n DEC: { reduce: ({ context }) => ({ count: context.count - 1 }), target: 'idle' },\n INC: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n RESET: { reduce: () => ({ count: 0 }), target: 'idle' },\n },\n },\n },\n});\n```\n\nKeep reducers pure. Make nested copies yourself when nested data changes.\n\n```ts\nSAVE: {\n reduce: ({ context, event }) => ({\n ...context,\n profile: { ...context.profile, name: event.name },\n }),\n target: 'editing',\n}\n```\n\n## Guards\n\nGuards 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.\n\n```ts\nPAY: [\n {\n guard: ({ context }) => context.balance >= context.total,\n reduce: ({ context }) => ({ ...context, balance: context.balance - context.total }),\n target: 'success',\n },\n { target: 'insufficientFunds' },\n]\n```\n\nCall `actor.can(event)` for the current actor snapshot or `machine.can(snapshot, event)` for an arbitrary snapshot.\n\n## Pure transitions\n\n`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.\n\n```ts\nconst result = counter.transition(\n { context: { count: 3 }, state: 'idle' },\n { type: 'INC' },\n);\n\nif (result.type === 'transition') {\n console.log(result.snapshot.context.count); // 4\n}\n```\n\n## Effects\n\nEntry, exit, and transition effects run only through an actor. The actor commits, establishes the new state's timers and invokes, notifies subscribers, then runs exit, transition, and entry effects. Effects cannot change context; send a regular event for another state change.\n\n```ts\ntype WorkflowEvent = { type: 'SUBMIT' };\nconst workflow = defineMachine<{ orderId: string }, WorkflowEvent>()({\n context: { orderId: '' },\n initial: 'draft',\n states: {\n draft: {\n on: {\n SUBMIT: {\n effects: [({ context }) => console.debug('submitted', context)],\n target: 'submitted',\n },\n },\n },\n submitted: { entry: [({ context }) => console.log(`Submitted ${context.orderId}`)] },\n },\n});\n```\n\nEffects receive `context`, the triggering `event` (or `undefined` for initial entry), actor `send`, and the actor lifetime `signal`.\n\n## Async invokes\n\nInvokes 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.\n\n```ts\ntype LoadEvent =\n | { type: 'FETCH' }\n | { items: string[]; type: 'SUCCESS' }\n | { message: string; type: 'FAILURE' }\n | { type: 'RETRY' };\n\nconst loader = defineMachine<{ error: string; items: string[] }, LoadEvent>()({\n context: { error: '', items: [] },\n initial: 'idle',\n states: {\n idle: { on: { FETCH: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: async ({ signal }) => {\n const response = await fetch('/api/items', { signal });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n return response.json() as Promise<string[]>;\n },\n onDone: ({ result }) => ({ items: result, type: 'SUCCESS' }),\n onError: ({ error }) => ({ message: String(error), type: 'FAILURE' }),\n }],\n on: {\n FAILURE: { reduce: ({ event }) => ({ error: event.message, items: [] }), target: 'error' },\n SUCCESS: { reduce: ({ event }) => ({ error: '', items: event.items }), target: 'ready' },\n },\n },\n ready: {},\n error: { on: { RETRY: { target: 'loading' } } },\n },\n});\n```\n\n## Delayed transitions\n\n`after` starts timers on state entry and cancels them on exit or disposal. Its guard and reducer receive `event: undefined`; a user event with `type: '$after'` remains a normal user event.\n\n```ts\ntype NotificationEvent = { type: 'DISMISS' } | { message: string; type: 'SHOW' };\nconst notification = defineMachine<{ message: string }, NotificationEvent>()({\n context: { message: '' },\n initial: 'hidden',\n states: {\n hidden: { on: { SHOW: { reduce: ({ event }) => ({ message: event.message }), target: 'visible' } } },\n visible: {\n after: [{ delay: 5_000, target: 'hidden' }],\n on: { DISMISS: { target: 'hidden' } },\n },\n },\n});\n```\n\n## Snapshot observation and persistence\n\n`actor.snapshot` is the current plain readonly snapshot; 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.\n\n```ts\nconst stored = sessionStorage.getItem('wizard');\nconst actor = machine.createActor({\n snapshot: stored ? JSON.parse(stored) : undefined,\n});\n\nconst stopSaving = actor.subscribe((snapshot) => {\n sessionStorage.setItem('wizard', JSON.stringify(snapshot));\n});\n\nconsole.log(actor.snapshot);\nstopSaving();\nactor.dispose();\n```\n\nValidate untrusted persisted data before passing it to `createActor()`. Clockwork validates the restored state name but cannot validate application-specific context fields.\n\n## Error handling\n\nUse `onError` to choose what happens after failures from transitions, effects, invokes, or subscribers. The context identifies the runtime phase and state; an event is present when one triggered the failure. Return `'continue'` to keep the actor alive or `'dispose'` to end it.\n\n```ts\nconst actor = machine.createActor({\n onError(error, { event, phase, state }) {\n console.error({ error, event, phase, state });\n return 'continue';\n },\n});\n```\n\nWithout `onError`, an actor disposes silently. Return `'dispose'` explicitly when an error handler logs an unrecoverable failure.\n\n## Debugging\n\nUse `actor.subscribe()` to observe committed snapshots during development. It observes snapshots only; it does not trace dispatched events or runtime errors.\n\n```ts\nconst actor = machine.createActor();\nconst stop = actor.subscribe((snapshot) => console.debug(snapshot));\nactor.send({ type: 'NEXT' });\nstop();\nactor.dispose();\n```\n\nFor richer inspection, route snapshots to application devtools. Clockwork intentionally has no internal trace buffer.\n\n## Flat state maps\n\nClockwork has flat state IDs. Prefer explicit states such as `editingDraft` and `editingSaving`, or compose several actors when domains have independent lifecycles.\n\n## SSR\n\nReuse a compiled machine definition, but create and dispose an actor per request. Never share an actor across concurrent requests.\n\n## Testing\n\nTest deterministic state behavior through `machine.transition()`. Create actors only for timers, invokes, effects, queueing, subscriptions, or disposal behavior.\n\n```ts\nimport { expect, test } from 'vitest';\n\ntest('increments without an actor', () => {\n const result = counter.transition(\n { context: { count: 2 }, state: 'idle' },\n { type: 'INC' },\n );\n\n expect(result).toMatchObject({\n snapshot: { context: { count: 3 }, state: 'idle' },\n type: 'transition',\n });\n});\n```\n\n## Framework Integration\n\nBridge the current actor snapshot into renderer state through one subscription. Dispose that subscription with component lifecycle.\n\n::: code-group\n\n```ts [React]\nimport { useSyncExternalStore } from 'react';\n\nfunction useActor<Snapshot>(actor: { readonly snapshot: Snapshot; subscribe(listener: (snapshot: Snapshot) => void): () => void }) {\n return useSyncExternalStore(\n (notify) => actor.subscribe(() => notify()),\n () => actor.snapshot,\n );\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nconst snapshot = shallowRef(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nonUnmounted(stop);\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\n\nlet snapshot = actor.snapshot;\nconst stop = actor.subscribe((next) => (snapshot = next));\nonDestroy(stop);\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Herald when separate actors exchange application events. Bridge Clockwork snapshots into Ripple only at a UI or application boundary.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ REFRESH: void }>();\nbus.on('REFRESH', () => actor.send({ type: 'FETCH' }));\n```\n\n## Best Practices\n\n- Define context and event unions with `defineMachine<Context, Event>()`.\n- Return replacement context from reducers; do not rely on runtime copying or freezing.\n- Keep guards and reducers pure.\n- Use actors for effects, timers, invokes, subscriptions, and cancellation.\n- Read the current snapshot from `actor.snapshot`, not a wrapper value.\n- Validate persisted context before restoring a snapshot.\n- Dispose every actor at its ownership boundary.\n- Route runtime failures through `onError` when the owner can recover.\n",
|
|
7
7
|
"examples": "---\ntitle: Clockwork — Examples\ndescription: Practical state machine patterns with pure transitions and actors.\n---\n\n- [Counter with Reset](./examples/counter-with-reset.md)\n- [Form Validation](./examples/form-validation.md)\n- [Auto-Dismiss Notification](./examples/auto-dismiss-notification.md)\n- [Model Nested Workflows with Flat States](./examples/hierarchical-states.md)\n- [Pure Transition Testing](./examples/unit-testing.md)\n- [Auth Flow with Guards](./examples/auth-flow.md)\n- [Data Fetching with Error Recovery](./examples/data-fetching.md)\n- [Fetch with Retry](./examples/fetch-retry.md)\n- [Paginated Data Loading](./examples/paginated-data-loading.md)\n- [Media Player](./examples/media-player.md)\n- [Persisted Wizard](./examples/persisted-wizard.md)\n- [Multi-Step Wizard with Routing](./examples/wizard-with-routing.md)\n- [Shopping Cart Checkout](./examples/checkout.md)\n- [Permission-Based Access Control](./examples/permission-based-access.md)\n- [Event Boundaries](./examples/middleware-pipeline.md)\n- [Multi-Machine Coordination](./examples/multi-machine-coordination.md)\n- [Debugging Transitions](./examples/debugging-transitions.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"apiSource": "export { createContainer } from './container';\nexport {\n ConduitCircularDependencyError,\n ConduitDisposedError,\n ConduitDisposeError,\n ConduitDuplicateRegistrationError,\n ConduitError,\n ConduitProviderNotFoundError,\n ConduitScopedResolutionError,\n} from './errors';\nexport type { Container, FactoryOptions, InferTokens, Lifetime, ScopeToken, Token, ValueOptions } from './types';\nexport { scope, token } from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
4
|
"index": "---\ntitle: Conduit — Dependency Injection for TypeScript\ndescription: Dependency-first asynchronous dependency injection with typed tokens, lifecycle scopes, startup validation, and deterministic disposal.\npackage: conduit\ncategory: infrastructure\nkeywords: [dependency injection, container, token, lifecycle, scope]\nexports: [createContainer, token, scope]\nrelated: [courier, vault, rune]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"conduit\" />\n\n## Why Conduit?\n\nConduit makes service wiring explicit. Factory dependency tuples are source of truth for creation, startup validation, and disposal order.\n\n```ts\n// Before\nconst service = createService(createApi(config), logger);\n\n// After\ncontainer.factory(Service, [Api, Logger], (api, logger) => createService(api, logger));\n```\n\n| Feature | Conduit | Inversify | tsyringe |\n| --- | --- | --- | --- |\n| Dependencies | Explicit token tuples | Decorators/runtime metadata | Decorators/runtime metadata |\n| Async factories | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | Partial |\n| Lifecycle scopes | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Runtime dependencies | 0 | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Conduit when** application services need explicit wiring and owned lifecycle cleanup.\n\n**Consider direct imports when** dependencies are static, small, and need no replacement or disposal boundary.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/conduit\n```\n\n```sh [npm]\nnpm install @vielzeug/conduit\n```\n\n```sh [yarn]\nyarn add @vielzeug/conduit\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createContainer, token } from '@vielzeug/conduit';\n\nconst Config = token<{ baseUrl: string }>('Config');\nconst Client = token<{ url: string }>('Client');\nconst container = createContainer();\n\ncontainer.value(Config, { baseUrl: '/api' });\ncontainer.factory(Client, [Config], (config) => ({ url: `${config.baseUrl}/users` }));\n\nconsole.log(await container.resolve(Client));\nawait container.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`token`**: typed dependency identity\n- **`factory`**: static dependency-first creation\n- **`validate`**: startup graph validation\n- **`scope`**: explicit request and job ownership\n- **`dispose`**: in-flight-safe resource cleanup\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Courier](/courier/) — inject HTTP clients into application services.\n- [Vault](/vault/) — inject persistence adapters with scoped ownership.\n- [Rune](/rune/) — provide application logging services.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Conduit — API Reference\ndescription: Reference for Conduit tokens, dependency-first factories, scopes, validation, and lifecycle disposal.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Mode | Common gotcha |\n| --- | --- | --- | --- |\n| `token` | Create typed dependency identity | Sync | Same description does not mean same token |\n| `scope` | Create named lifecycle identity | Sync | Must match factory lifetime |\n| `createContainer` | Create root registry | Sync | Dispose when application ends |\n| `value` | Register an existing value | Sync | One registration per token/container |\n| `factory` | Register static dependency factory | Sync | Tuple is copied and authoritative |\n| `has` | Check registration visibility | Sync | Walks parent containers |\n| `resolve` | Resolve one dependency | Async | Missing provider throws |\n| `validate` | Validate static graph | Sync | Run after registration |\n| `createScope` | Create child owner | Sync | Named scope required for scoped factories |\n| `dispose` | Release owned resources | Async | May throw `ConduitDisposeError` after cleanup attempts |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/conduit` | Complete Conduit API |\n\n## Tokens and Scopes\n\n```ts\ntoken<T>(description: string): Token<T>\nscope(name: string): ScopeToken\n```\n\nTokens and scopes are unique symbols. Descriptions exist only for diagnostics.\n\n## Container\n\n```ts\ncreateContainer(options?: { name?: string }): Container\n```\n\n### value\n\n```ts\ncontainer.value(token, value, options?)\n```\n\n`options.dispose` runs during container disposal.\n\n### has\n\n```ts\ncontainer.has(token): boolean\n```\n\nChecks local and parent registrations without creating a factory result.\n\n### factory\n\n```ts\ncontainer.factory(token, dependencies, create, options?)\n```\n\n```ts\ncontainer.factory(Service, [Api, Logger], (api, logger) => createService(api, logger));\n```\n\n`dependencies` is copied at registration and drives creation, validation, cycle detection, and teardown order. Factories may return a value or promise.\n\n`options.lifetime` accepts `'singleton'`, `'transient'`, or `ScopeToken`. A singleton cannot depend on a scoped resource.\n\n```ts\ntype FactoryOptions<T> = {\n dispose?: (value: T) => void | Promise<void>;\n lifetime?: 'singleton' | 'transient' | ScopeToken;\n};\n```\n\n### resolve\n\n```ts\ncontainer.resolve(token): Promise<T>\n```\n\nSingleton resolutions deduplicate concurrent callers.\n\n### validate\n\n```ts\ncontainer.validate(): Container\n```\n\nThrows for missing dependencies and circular factory tuples.\n\n### createScope\n\n```ts\ncontainer.createScope(scope?: ScopeToken, options?: { name?: string }): Container\n```\n\nA matching scope owns resources registered with its `ScopeToken` lifetime. Disposing a parent also disposes its active child scopes.\n\n### dispose\n\n```ts\ncontainer.dispose(): Promise<void>\ncontainer.disposalSignal: AbortSignal\ncontainer.disposed: boolean\n```\n\nDisposal blocks new work, aborts `disposalSignal`, disposes active child scopes, waits for in-flight creation, then disposes owned resources in reverse creation order. Cleanup failures are aggregated in `ConduitDisposeError.errors`.\n\n## Types\n\n```ts\ntype Token<T = unknown> = symbol;\ntype ScopeToken = symbol;\ntype Lifetime = 'singleton' | 'transient' | ScopeToken;\n\ntype ValueOptions<T> = Readonly<{\n dispose?: (value: T) => Promise<void> | void;\n}>;\n\ntype FactoryOptions<T> = Readonly<{\n dispose?: (value: T) => Promise<void> | void;\n lifetime?: Lifetime;\n}>;\n\ntype InferTokens<T extends readonly Token<unknown>[]> = {\n [K in keyof T]: T[K] extends Token<infer Value> ? Value : never;\n};\n\ninterface Container {\n createScope(scope?: ScopeToken, options?: { name?: string }): Container;\n readonly disposalSignal: AbortSignal;\n dispose(): Promise<void>;\n readonly disposed: boolean;\n factory<T, Dependencies extends readonly Token<unknown>[]>(\n token: Token<T>,\n dependencies: Dependencies,\n create: (...values: InferTokens<Dependencies>) => Promise<T> | T,\n options?: FactoryOptions<T>,\n ): this;\n has<T>(token: Token<T>): boolean;\n readonly name: string;\n resolve<T>(token: Token<T>): Promise<T>;\n validate(): this;\n value<T>(token: Token<T>, value: T, options?: ValueOptions<T>): this;\n [Symbol.asyncDispose](): Promise<void>;\n}\n```\n\n## Errors\n\n- `ConduitError` — base class; `ConduitError
|
|
5
|
+
"api": "---\ntitle: Conduit — API Reference\ndescription: Reference for Conduit tokens, dependency-first factories, scopes, validation, and lifecycle disposal.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Mode | Common gotcha |\n| --- | --- | --- | --- |\n| `token` | Create typed dependency identity | Sync | Same description does not mean same token |\n| `scope` | Create named lifecycle identity | Sync | Must match factory lifetime |\n| `createContainer` | Create root registry | Sync | Dispose when application ends |\n| `value` | Register an existing value | Sync | One registration per token/container |\n| `factory` | Register static dependency factory | Sync | Tuple is copied and authoritative |\n| `has` | Check registration visibility | Sync | Walks parent containers |\n| `resolve` | Resolve one dependency | Async | Missing provider throws |\n| `validate` | Validate static graph | Sync | Run after registration |\n| `createScope` | Create child owner | Sync | Named scope required for scoped factories |\n| `dispose` | Release owned resources | Async | May throw `ConduitDisposeError` after cleanup attempts |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/conduit` | Complete Conduit API |\n\n## Tokens and Scopes\n\n```ts\ntoken<T>(description: string): Token<T>\nscope(name: string): ScopeToken\n```\n\nTokens and scopes are unique symbols. Descriptions exist only for diagnostics.\n\n## Container\n\n```ts\ncreateContainer(options?: { name?: string }): Container\n```\n\n### value\n\n```ts\ncontainer.value(token, value, options?)\n```\n\n`options.dispose` runs during container disposal.\n\n### has\n\n```ts\ncontainer.has(token): boolean\n```\n\nChecks local and parent registrations without creating a factory result.\n\n### factory\n\n```ts\ncontainer.factory(token, dependencies, create, options?)\n```\n\n```ts\ncontainer.factory(Service, [Api, Logger], (api, logger) => createService(api, logger));\n```\n\n`dependencies` is copied at registration and drives creation, validation, cycle detection, and teardown order. Factories may return a value or promise.\n\n`options.lifetime` accepts `'singleton'`, `'transient'`, or `ScopeToken`. A singleton cannot depend on a scoped resource.\n\n```ts\ntype FactoryOptions<T> = {\n dispose?: (value: T) => void | Promise<void>;\n lifetime?: 'singleton' | 'transient' | ScopeToken;\n};\n```\n\n### resolve\n\n```ts\ncontainer.resolve(token): Promise<T>\n```\n\nSingleton resolutions deduplicate concurrent callers.\n\n### validate\n\n```ts\ncontainer.validate(): Container\n```\n\nThrows for missing dependencies and circular factory tuples.\n\n### createScope\n\n```ts\ncontainer.createScope(scope?: ScopeToken, options?: { name?: string }): Container\n```\n\nA matching scope owns resources registered with its `ScopeToken` lifetime. Disposing a parent also disposes its active child scopes.\n\n### dispose\n\n```ts\ncontainer.dispose(): Promise<void>\ncontainer.disposalSignal: AbortSignal\ncontainer.disposed: boolean\n```\n\nDisposal blocks new work, aborts `disposalSignal`, disposes active child scopes, waits for in-flight creation, then disposes owned resources in reverse creation order. Cleanup failures are aggregated in `ConduitDisposeError.errors`.\n\n## Types\n\n```ts\ntype Token<T = unknown> = symbol;\ntype ScopeToken = symbol;\ntype Lifetime = 'singleton' | 'transient' | ScopeToken;\n\ntype ValueOptions<T> = Readonly<{\n dispose?: (value: T) => Promise<void> | void;\n}>;\n\ntype FactoryOptions<T> = Readonly<{\n dispose?: (value: T) => Promise<void> | void;\n lifetime?: Lifetime;\n}>;\n\ntype InferTokens<T extends readonly Token<unknown>[]> = {\n [K in keyof T]: T[K] extends Token<infer Value> ? Value : never;\n};\n\ninterface Container {\n createScope(scope?: ScopeToken, options?: { name?: string }): Container;\n readonly disposalSignal: AbortSignal;\n dispose(): Promise<void>;\n readonly disposed: boolean;\n factory<T, Dependencies extends readonly Token<unknown>[]>(\n token: Token<T>,\n dependencies: Dependencies,\n create: (...values: InferTokens<Dependencies>) => Promise<T> | T,\n options?: FactoryOptions<T>,\n ): this;\n has<T>(token: Token<T>): boolean;\n readonly name: string;\n resolve<T>(token: Token<T>): Promise<T>;\n validate(): this;\n value<T>(token: Token<T>, value: T, options?: ValueOptions<T>): this;\n [Symbol.asyncDispose](): Promise<void>;\n}\n```\n\n## Errors\n\n- `ConduitError` — base class; use `instanceof ConduitError` to narrow package errors.\n- `ConduitProviderNotFoundError` — dependency has no registration.\n- `ConduitCircularDependencyError` — static factory tuple graph contains a cycle.\n- `ConduitDuplicateRegistrationError` — token registered twice in one container.\n- `ConduitScopedResolutionError` — scoped factory resolved without matching scope.\n- `ConduitDisposedError` — operation attempted after disposal began.\n- `ConduitDisposeError` — one or more cleanup hooks failed.\n",
|
|
6
6
|
"usage": "---\ntitle: Conduit — Usage Guide\ndescription: Register static dependency tuples, resolve services asynchronously, create scopes, validate startup wiring, and dispose owned resources.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate tokens once, register values and factories, then resolve through one async API.\n\n```ts\nimport { createContainer, token } from '@vielzeug/conduit';\n\nconst Config = token<{ baseUrl: string }>('Config');\nconst Client = token<{ url: string }>('Client');\n\nconst container = createContainer();\ncontainer.value(Config, { baseUrl: '/api' });\ncontainer.factory(Client, [Config], (config) => ({ url: `${config.baseUrl}/users` }));\n\nconsole.log(await container.resolve(Client));\nawait container.dispose();\n```\n\n## Define Dependencies\n\nFactory token tuples are authoritative. Conduit resolves tuple values in order, validates every edge, and disposes created services in reverse dependency order.\n\n```ts\nconst Logger = token<{ info(message: string): void }>('Logger');\nconst Api = token<{ get(path: string): Promise<unknown> }>('Api');\nconst Service = token<{ load(): Promise<unknown> }>('Service');\n\ncontainer.factory(Service, [Api, Logger], (api, logger) => ({\n async load() {\n logger.info('Loading data');\n return api.get('/data');\n },\n}));\n```\n\n## Choose Lifetimes\n\nFactories are singletons by default. Use transient lifetime for a new value on every resolution. Conduit retains a transient only when its factory has a `dispose` hook.\n\n```ts\nconst RequestId = token<{ id: string }>('RequestId');\n\ncontainer.factory(RequestId, [], () => ({ id: crypto.randomUUID() }), {\n lifetime: 'transient',\n});\n```\n\nConcurrent singleton resolutions share one in-flight factory result. A singleton cannot depend on a scoped resource; give dependent factory equal-or-shorter lifetime instead. Factory dependency tuples are copied at registration, so later caller mutation cannot change Conduit's graph.\n\n## Create Named Scopes\n\nUse a scope token when a resource belongs to a request, job, or test lifecycle.\n\n```ts\nimport { createContainer, scope, token } from '@vielzeug/conduit';\n\nconst Request = scope('request');\nconst Session = token<{ id: string }>('Session');\nconst root = createContainer();\n\nroot.factory(Session, [], () => ({ id: crypto.randomUUID() }), { lifetime: Request });\n\nconst request = root.createScope(Request);\nconst session = await request.resolve(Session);\nawait request.dispose();\nawait root.dispose();\n```\n\n## Validate Startup Wiring\n\nCall `validate()` after registration. It detects missing dependencies and cycles before service resolution. Parent singleton factories validate dependencies from their registration owner; child overrides do not satisfy them.\n\n```ts\ncontainer.validate();\n```\n\n## Dispose Resources\n\n`dispose()` rejects new work, aborts `disposalSignal`, disposes child scopes, 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`.\n\n```ts\nawait container.dispose();\n```\n\n`ConduitDisposeError.errors` contains every cleanup failure after Conduit attempts all hooks, including cleanup from in-flight factories and child scopes.\n\n## Testing\n\nCreate a container per test and register explicit values for external dependencies.\n\n```ts\nconst Clock = token<{ now(): number }>('Clock');\nconst Service = token<{ timestamp: number }>('Service');\nconst container = createContainer();\n\ncontainer.value(Clock, { now: () => 123 });\ncontainer.factory(Service, [Clock], (clock) => ({ timestamp: clock.now() }));\n\nexpect(await container.resolve(Service)).toEqual({ timestamp: 123 });\nawait container.dispose();\n```\n\n## Best Practices\n\n- Create tokens at module scope.\n- Declare every factory dependency in its tuple.\n- Keep factories focused on one service.\n- Use scopes for request/job-owned resources.\n- Call `validate()` during startup.\n- Dispose every scope and root container.\n- Keep optional application fallback policy outside Conduit.\n- Use `await using container = createContainer()` when lexical async disposal fits application lifetime.\n",
|
|
7
7
|
"examples": "---\ntitle: Conduit — Examples\ndescription: Dependency-first container recipes.\n---\n\n## Examples\n\n- [Basic setup](./examples/basic-setup.md)\n- [Static async providers](./examples/async-providers.md)\n- [Lifetimes](./examples/lifetimes.md)\n- [Named scopes](./examples/named-scopes.md)\n- [Disposal lifecycle](./examples/dispose-lifecycle.md)\n- [Startup validation](./examples/startup-hardening.md)\n"
|
|
8
8
|
},
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "export { type Courier, type CourierOptions, createCourier } from './courier';\nexport {\n CourierAbortError,\n CourierDisposedError,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierParseError,\n CourierSchemaValidationError,\n CourierTimeoutError,\n} from './errors';\nexport { withBearerAuth, withLogging, withRequestId } from './interceptors';\nexport type { StreamEvent, StreamOptions } from './stream';\nexport type { FetchContext, Interceptor, TransportOptions } from './transport';\nexport type {\n AsyncState,\n MutationContext,\n MutationOptions,\n QueryCache,\n QueryContext,\n QueryDefinition,\n QueryKey,\n QueryKeyAtom,\n Unsubscribe,\n} from './types';\nexport type { HttpRequestConfig as RequestConfig, Params } from './url';\n",
|
|
2
|
+
"apiSource": "export { type Courier, type CourierEvent, type CourierOptions, createCourier } from './courier';\nexport {\n CourierAbortError,\n CourierDisposedError,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierParseError,\n CourierSchemaValidationError,\n CourierTimeoutError,\n} from './errors';\nexport { withBearerAuth, withLogging, withRequestId } from './interceptors';\nexport type { StreamEvent, StreamOptions } from './stream';\nexport type { FetchContext, Interceptor, TransportOptions } from './transport';\nexport type {\n AsyncState,\n MutationContext,\n MutationOptions,\n QueryCache,\n QueryContext,\n QueryDefinition,\n QueryKey,\n QueryKeyAtom,\n Unsubscribe,\n} from './types';\nexport type { HttpRequestConfig as RequestConfig, Params } from './url';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Courier — HTTP, queries, and streaming\ndescription: A framework-neutral fetch client with explicit cache keys, direct mutations, and abortable streams.\npackage: courier\ncategory: http\nkeywords: [http-client, fetch, caching, queries, mutations, sse, streaming, interceptors]\nrelated: [flux, ripple, spell]\nexports:\n [\n createCourier,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierTimeoutError,\n CourierAbortError,\n CourierSchemaValidationError,\n withBearerAuth,\n withRequestId,\n withLogging,\n ]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"courier\" />\n\n## Why Courier?\n\nNative `fetch` leaves request policy, cached reads, and stream lifecycles to each application. Courier keeps\nthose concerns in one client while making cache identity and fetch policy explicit at every cached read.\n\n```ts\n// Before\nconst response = await fetch(`/api/users/${userId}`);\nif (!response.ok) throw new Error(`HTTP ${response.status}`);\nconst user = await response.json();\n\n// After\nawait courier.queries.fetch({\n key: ['users', userId],\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: userId }, signal }),\n});\n```\n\n| Feature | Courier | TanStack Query | ky |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"courier\" type=\"size\" /> | Framework adapter required | Separate package |\n| Zero runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Native fetch transport | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Bring your own | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Explicit cache keys | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| SSE and NDJSON iteration | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| External runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Courier when** one application client should own typed HTTP, explicit cached reads, direct writes, and\nabortable response streams.\n\n**Consider TanStack Query when** you need a maintained framework adapter or advanced cache features such as\ninfinite queries. **Consider ky when** you only need a compact fetch wrapper without caching or streams.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## Quick Start\n\nCreate one client for an application or request scope, then fetch a cache entry by its explicit key.\n\n```ts\nimport { CourierHttpError, createCourier } from '@vielzeug/courier';\n\ntype User = { id: number; name: string };\n\nconst courier = createCourier({ baseUrl: 'https://api.example.com', query: { staleTime: 30_000 } });\nconst key = ['users', 42] as const;\n\ntry {\n await courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: 42 }, signal }),\n });\n console.log(courier.queries.getSnapshot<User>(key)?.data);\n} catch (error) {\n if (CourierHttpError.is(error, 404)) console.log('User not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`createCourier()`** — one lifecycle, interceptor pipeline, header store, and cancellation boundary.\n- **`get()` / `post()` / `put()` / `patch()` / `delete()`** — typed paths, query strings, request bodies, validation, and structured errors.\n- **`queries.fetch()`** — key-based cached reads, subscriptions, invalidation with refetch, and automatic garbage collection.\n- **`mutate()`** — direct write operation with `invalidateKeys` for one-step cache refetch, without hidden retries or a second state store.\n- **`events()` / `read()`** — abortable SSE, text, and NDJSON iteration with normalized request errors.\n- **`withBearerAuth()` / `withRequestId()` / `withLogging()`** — composable transport policies.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Flux](/flux/) — adapts Courier cache entries and event iterators into composable streams.\n- [Ripple](/ripple/) — stores Courier snapshots in fine-grained reactive state.\n- [Spell](/spell/) — validates parsed HTTP payloads through Courier's schema option.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Courier — API Reference\ndescription: Reference for Courier HTTP, cache, mutation, interceptor, and stream APIs.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createCourier()` | Creates unified application client | Sync | Dispose only when whole scope ends |\n| `Courier` HTTP methods | Sends and parses HTTP requests | Async | Direct calls never deduplicate |\n| `queries.fetch()` | Fetches one keyed cache entry | Async | Key must include all response identity inputs |\n| `mutate()` | Runs one write operation | Async | It never retries automatically |\n| `events()` / `read()` | Opens abortable response iterators | Async iteration | Breaking iteration aborts request |\n| `withBearerAuth()` | Adds authorization interceptor | Sync | Token provider runs per request |\n| `withRequestId()` | Adds request identifier interceptor | Sync | Default generator uses `uuid()` |\n| `withLogging()` | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/courier` | Client factory, errors, interceptors, and public types |\n\n## Client\n\n### `createCourier()`\n\n```ts\ncreateCourier(options?: CourierOptions): Courier;\n```\n\nReturns client sharing transport configuration, headers, interceptors, cancellation, cache, mutations, and streams.\n\n| `CourierOptions` field | Type | Default | Description |\n| --- | --- | --- | --- |\n| `baseUrl` | `string` | `''` | Prefix for relative request paths |\n| `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Fetch implementation |\n| `headers` | `Record<string, string>` | `{}` | Global request headers |\n| `timeout` | `number` | `30_000` | Default HTTP timeout in milliseconds |\n| `query.staleTime` | `number` | `0` | Cache freshness duration |\n| `query.gcTime` | `number` | `300_000` | Garbage-collect entries with no subscribers after this duration (ms); `Infinity` disables |\n\n**Returns:** `Courier`.\n\n```ts\nimport { createCourier } from '@vielzeug/courier';\n\nconst courier = createCourier({ baseUrl: 'https://api.example.com' });\n```\n\n| `Courier` member | Signature | Description |\n| --- | --- | --- |\n| `get` / `post` / `put` / `patch` / `delete` | `<T, P>(url: P, config?) => Promise<T>` | Sends one HTTP request |\n| `setHeaders` | `(updates) => void` | Updates global headers |\n| `getHeaders` | `() => Readonly<Record<string, string>>` | Returns header snapshot |\n| `use` | `(interceptor) => () => void` | Registers interceptor |\n| `cancelAll` | `() => void` | Aborts active HTTP, cache, and mutation work; a subsequent `queries.fetch()` starts a fresh request |\n| `queries` | `QueryCache` | Owns keyed cache entries |\n| `mutate` | `<T>(options) => Promise<T>` | Runs one write operation |\n| `events` | `<T, P>(url, options?) => AsyncIterableIterator<StreamEvent<T>>` | Opens SSE iterator |\n| `read` | `<T, P>(url, options?) => AsyncIterableIterator<T>` | Opens text or NDJSON iterator |\n| `dispose` | `() => void` | Final disposal; aborts work and clears cache |\n| `disposed` | `boolean` | Whether final disposal occurred |\n| `disposalSignal` | `AbortSignal` | Aborts on final disposal |\n\n---\n\n## Queries\n\n### `queries.fetch()`\n\n```ts\nfetch<T>(definition: QueryDefinition<T>, options?: { force?: boolean }): Promise<T>;\n```\n\nRegisters latest definition for `definition.key`, then returns fresh cached data or runs its fetch function.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `definition.key` | `QueryKey` | Cache identity; include every response identity input |\n| `definition.fetch` | `(context: QueryContext) => Promise<T>` | Request function for this key |\n| `definition.staleTime` | `number` | Per-entry freshness duration |\n| `options.force` | `boolean` | Fetch even when cached data is fresh |\n\n**Returns:** Cached or fetched data.\n\n```ts\nconst key = ['profile', 1] as const;\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n```\n\n| `QueryCache` method | Returns | Description |\n| --- | --- | --- |\n| `get(key)` | `T \\| undefined` | Returns successful cached data |\n| `getSnapshot(key)` | `AsyncState<T> \\| null` | Returns snapshot by key |\n| `set(key, data, options?)` | `void` | Sets successful cache value |\n| `delete(key)` | `void` | Removes one cache entry and aborts its active fetch if present |\n| `invalidate(prefix, options?)` | `void` | Marks matching key prefixes stale; `options.refetch` triggers background refetch |\n| `keys()` | `QueryKey[]` | Lists known keys |\n| `subscribe(key, listener)` | `Unsubscribe` | Subscribes to one key |\n| `clear()` | `void` | Removes every cache entry |\n\n---\n\n## Mutations\n\n### `mutate()`\n\n```ts\nmutate<T>(options: MutationOptions<T>): Promise<T>;\n```\n\nRuns `options.request` once, then calls `onSuccess` after successful completion, then invalidates (and refetches) each key in `invalidateKeys`.\n\n| `MutationOptions<T>` field | Type | Description |\n| --- | --- | --- |\n| `request` | `(context: MutationContext) => Promise<T>` | Write operation |\n| `onSuccess` | `(data, queries) => void \\| Promise<void>` | Cache update callback |\n| `invalidateKeys` | `readonly (readonly unknown[])[]` | Key prefixes to invalidate and refetch after success |\n| `signal` | `AbortSignal` | Caller-controlled cancellation |\n\n**Returns:** Request result.\n\n---\n\n## Streams\n\n### `events()` and `read()`\n\n```ts\nevents<T, P extends string>(url: P, options?: StreamOptions<P>): AsyncIterableIterator<StreamEvent<T>>;\nread<T, P extends string>(url: P, options?: StreamOptions<P> & { parse?: 'ndjson' | 'text' }): AsyncIterableIterator<T>;\n```\n\nBoth iterators abort request when `return()` runs or `for await` loop exits. `events()` parses `event` and `data`\nfields; it does not retain event IDs or reconnect.\n\n`StreamOptions<P>` extends `RequestConfig<P>` (typed path params) with an optional `method` field. It omits\n`responseType` and `schema` (not applicable to streaming).\n\n**Returns:** Abortable async iterator.\n\n---\n\n## Interceptors\n\n### Interceptor helpers\n\n```ts\nwithBearerAuth(token: string | (() => string | Promise<string>)): Interceptor;\nwithRequestId(options?: { generate?: () => string; header?: string }): Interceptor;\nwithLogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): Interceptor;\n```\n\nEach helper returns an `Interceptor` accepted by `courier.use()`. `withLogging` requires an explicit `logger`\nfunction — no default console output.\n\n## Types\n\n```ts\ntype TransportOptions = {\n baseUrl?: string;\n fetch?: typeof globalThis.fetch;\n headers?: Record<string, string>;\n timeout?: number;\n};\n\ntype CourierOptions = TransportOptions & {\n query?: { gcTime?: number; staleTime?: number };\n};\n\ntype FetchContext = {\n readonly headers: Readonly<Record<string, string>>;\n readonly init: Readonly<Omit<RequestInit, 'headers'>>;\n readonly url: string;\n withHeaders(updates: Record<string, string>): FetchContext;\n};\n\ntype Interceptor = (ctx: FetchContext, next: (ctx: FetchContext) => Promise<Response>) => Promise<Response>;\n\ntype AsyncState<T> =\n | { data: undefined; error: null; isFetching: boolean; status: 'loading'; updatedAt: undefined }\n | { data: T; error: null; isFetching: boolean; status: 'success'; updatedAt: number }\n | { data: T | undefined; error: Error; isFetching: false; status: 'error'; updatedAt: number };\n\ntype QueryContext = { readonly key: QueryKey; readonly signal: AbortSignal };\ntype QueryDefinition<T> = { fetch: (context: QueryContext) => Promise<T>; key: QueryKey; staleTime?: number };\ntype QueryKey = readonly [QueryKeyAtom, ...QueryKeyAtom[]];\ntype QueryKeyAtom = string | number | boolean | null;\ntype QueryCache = {\n clear(): void;\n delete(key: QueryKey): void;\n fetch<T>(definition: QueryDefinition<T>, options?: { force?: boolean }): Promise<T>;\n get<T>(key: QueryKey): T | undefined;\n getSnapshot<T>(key: QueryKey): AsyncState<T> | null;\n invalidate(prefix: readonly unknown[], options?: { refetch?: boolean }): void;\n keys(): QueryKey[];\n set<T>(key: QueryKey, data: T, options?: { updatedAt?: number }): void;\n subscribe(key: QueryKey, listener: () => void): Unsubscribe;\n};\ntype MutationContext = { readonly signal: AbortSignal };\ntype MutationOptions<T> = {\n invalidateKeys?: readonly (readonly unknown[])[];\n onSuccess?: (data: T, queries: QueryCache) => void | Promise<void>;\n request: (context: MutationContext) => Promise<T>;\n signal?: AbortSignal;\n};\ntype StreamEvent<T = unknown> = { readonly data: T; readonly event: string };\ntype StreamOptions<P extends string = string> = Omit<RequestConfig<P>, 'responseType' | 'schema'> & {\n method?: string;\n};\ntype Unsubscribe = () => void;\n```\n\n```ts\ntype ParamValue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype Params = Record<string, ParamValue>;\ntype RequestConfig<P extends string = string, T = unknown> = {\n body?: unknown;\n fetchInit?: Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: Record<string, string>;\n params?: Record<string, string | number | boolean>;\n query?: Params;\n responseType?: 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'raw';\n schema?: { parse(data: unknown): T };\n signal?: AbortSignal;\n timeout?: number;\n};\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `CourierError` | Base class for all Courier errors | Use `instanceof` to narrow |\n| `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`; `CourierHttpError.is(e, status?)` narrows by status |\n| `CourierNetworkError` | Request failure without response | `method`, `url`, `cause` |\n| `CourierTimeoutError` | Timeout signal aborts request | `method`, `url`, `cause` |\n| `CourierAbortError` | Caller, client, or iterator cancellation | `method`, `url`, `cause` |\n| `CourierSchemaValidationError` | Response schema fails | `data`, `cause` |\n| `CourierParseError` | Response body cannot parse | — |\n| `CourierDisposedError` | Work starts after disposal | — |\n",
|
|
4
|
+
"index": "---\ntitle: Courier — HTTP, queries, and streaming\ndescription: A framework-neutral fetch client with explicit cache keys, direct mutations, and abortable streams.\npackage: courier\ncategory: http\nkeywords: [http-client, fetch, caching, queries, mutations, sse, streaming, interceptors]\nrelated: [flux, ripple, spell]\nexports:\n [\n createCourier,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierTimeoutError,\n CourierAbortError,\n CourierSchemaValidationError,\n withBearerAuth,\n withRequestId,\n withLogging,\n ]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"courier\" />\n\n## Why Courier?\n\nNative `fetch` leaves request policy, cached reads, and stream lifecycles to each application. Courier keeps\nthose concerns in one client while making cache identity and fetch policy explicit at every cached read.\n\n```ts\n// Before\nconst response = await fetch(`/api/users/${userId}`);\nif (!response.ok) throw new Error(`HTTP ${response.status}`);\nconst user = await response.json();\n\n// After\nawait courier.queries.fetch({\n key: ['users', userId],\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: userId }, signal }),\n});\n```\n\n| Feature | Courier | TanStack Query | ky |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"courier\" type=\"size\" /> | Framework adapter required | Separate package |\n| Zero runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Native fetch transport | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Bring your own | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Explicit cache keys | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| SSE and NDJSON iteration | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| External runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Courier when** one application client should own typed HTTP, explicit cached reads, direct writes, and\nabortable response streams.\n\n**Consider TanStack Query when** you need a maintained framework adapter or advanced cache features such as\ninfinite queries. **Consider ky when** you only need a compact fetch wrapper without caching or streams.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## Quick Start\n\nCreate one client for an application or request scope, then fetch a cache entry by its explicit key.\n\n```ts\nimport { CourierHttpError, createCourier } from '@vielzeug/courier';\n\ntype User = { id: number; name: string };\n\nconst courier = createCourier({ baseUrl: 'https://api.example.com', query: { staleTime: 30_000 } });\nconst key = ['users', 42] as const;\n\ntry {\n await courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: 42 }, signal }),\n });\n console.log(courier.queries.getSnapshot<User>(key)?.data);\n} catch (error) {\n if (CourierHttpError.is(error, 404)) console.log('User not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`createCourier()`** — one lifecycle, interceptor pipeline, header store, and cancellation boundary.\n- **`get()` / `post()` / `put()` / `patch()` / `delete()`** — typed paths, query strings, request bodies, validation, and structured errors.\n- **`queries.fetch()`** — key-based cached reads, subscriptions, invalidation with refetch, and automatic garbage collection.\n- **`mutate()`** — direct write operation with `invalidateKeys` for one-step cache refetch, without hidden retries or a second state store.\n- **`events()` / `read()`** — abortable SSE, text, and NDJSON iteration with normalized request errors.\n- **`tap()`** — runtime observability for request lifecycle events (start, success, error).\n- **`withBearerAuth()` / `withRequestId()` / `withLogging()`** — composable transport policies.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Flux](/flux/) — adapts Courier cache entries and event iterators into composable streams.\n- [Ripple](/ripple/) — stores Courier snapshots in fine-grained reactive state.\n- [Spell](/spell/) — validates parsed HTTP payloads through Courier's schema option.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Courier — API Reference\ndescription: Reference for Courier HTTP, cache, mutation, interceptor, and stream APIs.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createCourier()` | Creates unified application client | Sync | Dispose only when whole scope ends |\n| `Courier` HTTP methods | Sends and parses HTTP requests | Async | Direct calls never deduplicate |\n| `queries.fetch()` | Fetches one keyed cache entry | Async | Key must include all response identity inputs |\n| `mutate()` | Runs one write operation | Async | It never retries automatically |\n| `events()` / `read()` | Opens abortable response iterators | Async iteration | Breaking iteration aborts request |\n| `withBearerAuth()` | Adds authorization interceptor | Sync | Token provider runs per request |\n| `withRequestId()` | Adds request identifier interceptor | Sync | Default generator uses `uuid()` |\n| `withLogging()` | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/courier` | Client factory, errors, interceptors, and public types |\n\n## Client\n\n### `createCourier()`\n\n```ts\ncreateCourier(options?: CourierOptions): Courier;\n```\n\nReturns client sharing transport configuration, headers, interceptors, cancellation, cache, mutations, and streams.\n\n| `CourierOptions` field | Type | Default | Description |\n| --- | --- | --- | --- |\n| `baseUrl` | `string` | `''` | Prefix for relative request paths |\n| `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Fetch implementation |\n| `headers` | `Record<string, string>` | `{}` | Global request headers |\n| `timeout` | `number` | `30_000` | Default HTTP timeout in milliseconds |\n| `query.staleTime` | `number` | `0` | Cache freshness duration |\n| `query.gcTime` | `number` | `300_000` | Garbage-collect entries with no subscribers after this duration (ms); `Infinity` disables |\n\n**Returns:** `Courier`.\n\n```ts\nimport { createCourier } from '@vielzeug/courier';\n\nconst courier = createCourier({ baseUrl: 'https://api.example.com' });\n```\n\n| `Courier` member | Signature | Description |\n| --- | --- | --- |\n| `get` / `post` / `put` / `patch` / `delete` | `<T, P>(url: P, config?) => Promise<T>` | Sends one HTTP request |\n| `setHeaders` | `(updates) => void` | Updates global headers |\n| `getHeaders` | `() => Readonly<Record<string, string>>` | Returns header snapshot |\n| `use` | `(interceptor) => () => void` | Registers interceptor |\n| `cancelAll` | `() => void` | Aborts active HTTP, cache, and mutation work; a subsequent `queries.fetch()` starts a fresh request |\n| `queries` | `QueryCache` | Owns keyed cache entries |\n| `mutate` | `<T>(options) => Promise<T>` | Runs one write operation |\n| `events` | `<T, P>(url, options?) => AsyncIterableIterator<StreamEvent<T>>` | Opens SSE iterator |\n| `read` | `<T, P>(url, options?) => AsyncIterableIterator<T>` | Opens text or NDJSON iterator |\n| `dispose` | `() => void` | Final disposal; aborts work and clears cache |\n| `disposed` | `boolean` | Whether final disposal occurred |\n| `disposalSignal` | `AbortSignal` | Aborts on final disposal |\n\n---\n\n## Queries\n\n### `queries.fetch()`\n\n```ts\nfetch<T>(definition: QueryDefinition<T>, options?: { force?: boolean }): Promise<T>;\n```\n\nRegisters latest definition for `definition.key`, then returns fresh cached data or runs its fetch function.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `definition.key` | `QueryKey` | Cache identity; include every response identity input |\n| `definition.fetch` | `(context: QueryContext) => Promise<T>` | Request function for this key |\n| `definition.staleTime` | `number` | Per-entry freshness duration |\n| `options.force` | `boolean` | Fetch even when cached data is fresh |\n\n**Returns:** Cached or fetched data.\n\n```ts\nconst key = ['profile', 1] as const;\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n```\n\n| `QueryCache` method | Returns | Description |\n| --- | --- | --- |\n| `get(key)` | `T \\| undefined` | Returns successful cached data |\n| `getSnapshot(key)` | `AsyncState<T> \\| null` | Returns snapshot by key |\n| `set(key, data, options?)` | `void` | Sets successful cache value |\n| `delete(key)` | `void` | Removes one cache entry and aborts its active fetch if present |\n| `invalidate(prefix, options?)` | `void` | Marks matching key prefixes stale; `options.refetch` triggers background refetch |\n| `keys()` | `QueryKey[]` | Lists known keys |\n| `subscribe(key, listener)` | `Unsubscribe` | Subscribes to one key |\n| `clear()` | `void` | Removes every cache entry |\n\n---\n\n## Mutations\n\n### `mutate()`\n\n```ts\nmutate<T>(options: MutationOptions<T>): Promise<T>;\n```\n\nRuns `options.request` once, then calls `onSuccess` after successful completion, then invalidates (and refetches) each key in `invalidateKeys`.\n\n| `MutationOptions<T>` field | Type | Description |\n| --- | --- | --- |\n| `request` | `(context: MutationContext) => Promise<T>` | Write operation |\n| `onSuccess` | `(data, queries) => void \\| Promise<void>` | Cache update callback |\n| `invalidateKeys` | `readonly (readonly unknown[])[]` | Key prefixes to invalidate and refetch after success |\n| `signal` | `AbortSignal` | Caller-controlled cancellation |\n\n**Returns:** Request result.\n\n---\n\n## Streams\n\n### `events()` and `read()`\n\n```ts\nevents<T, P extends string>(url: P, options?: StreamOptions<P>): AsyncIterableIterator<StreamEvent<T>>;\nread<T, P extends string>(url: P, options?: StreamOptions<P> & { parse?: 'ndjson' | 'text' }): AsyncIterableIterator<T>;\n```\n\nBoth iterators abort request when `return()` runs or `for await` loop exits. `events()` parses `event` and `data`\nfields; it does not retain event IDs or reconnect.\n\n`StreamOptions<P>` extends `RequestConfig<P>` (typed path params) with an optional `method` field. It omits\n`responseType` and `schema` (not applicable to streaming).\n\n**Returns:** Abortable async iterator.\n\n---\n\n## Observability\n\n### `tap()`\n\n```ts\ntap(handler: (event: CourierEvent) => void, options?: { signal?: AbortSignal }): () => void;\n```\n\nObserve request lifecycle events without affecting courier behavior. Handler errors are swallowed. Returns an unsubscribe function.\n\n```ts\ntype CourierEvent =\n | { type: 'request-start'; method: string; url: string }\n | { type: 'request-success'; method: string; url: string; status: number; duration: number }\n | { type: 'request-error'; method: string; url: string; error: unknown }\n | { type: 'dispose' };\n```\n\n**Example:**\n\n```ts\nconst courier = createCourier({ baseUrl: '/api' });\ncourier.tap((event) => {\n if (event.type === 'request-error') console.error(event.method, event.url, event.error);\n if (event.type === 'request-success') console.debug(event.method, event.url, event.duration);\n});\n```\n\nFor structured logging, route tap events to rune:\n\n```ts\nimport { createLogger } from '@vielzeug/rune';\nconst log = createLogger({ name: 'courier' });\ncourier.tap((event) => log.debug(event, `courier:${event.type}`));\n```\n\n## Interceptors\n\n### Interceptor helpers\n\n```ts\nwithBearerAuth(token: string | (() => string | Promise<string>)): Interceptor;\nwithRequestId(options?: { generate?: () => string; header?: string }): Interceptor;\nwithLogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): Interceptor;\n```\n\nEach helper returns an `Interceptor` accepted by `courier.use()`. `withLogging` requires an explicit `logger`\nfunction — no default console output.\n\n## Types\n\n```ts\ntype TransportOptions = {\n baseUrl?: string;\n fetch?: typeof globalThis.fetch;\n headers?: Record<string, string>;\n timeout?: number;\n};\n\ntype CourierOptions = TransportOptions & {\n query?: { gcTime?: number; staleTime?: number };\n};\n\ntype FetchContext = {\n readonly headers: Readonly<Record<string, string>>;\n readonly init: Readonly<Omit<RequestInit, 'headers'>>;\n readonly url: string;\n withHeaders(updates: Record<string, string>): FetchContext;\n};\n\ntype Interceptor = (ctx: FetchContext, next: (ctx: FetchContext) => Promise<Response>) => Promise<Response>;\n\ntype AsyncState<T> =\n | { data: undefined; error: null; isFetching: boolean; status: 'loading'; updatedAt: undefined }\n | { data: T; error: null; isFetching: boolean; status: 'success'; updatedAt: number }\n | { data: T | undefined; error: Error; isFetching: false; status: 'error'; updatedAt: number };\n\ntype QueryContext = { readonly key: QueryKey; readonly signal: AbortSignal };\ntype QueryDefinition<T> = { fetch: (context: QueryContext) => Promise<T>; key: QueryKey; staleTime?: number };\ntype QueryKey = readonly [QueryKeyAtom, ...QueryKeyAtom[]];\ntype QueryKeyAtom = string | number | boolean | null;\ntype QueryCache = {\n clear(): void;\n delete(key: QueryKey): void;\n fetch<T>(definition: QueryDefinition<T>, options?: { force?: boolean }): Promise<T>;\n get<T>(key: QueryKey): T | undefined;\n getSnapshot<T>(key: QueryKey): AsyncState<T> | null;\n invalidate(prefix: readonly unknown[], options?: { refetch?: boolean }): void;\n keys(): QueryKey[];\n set<T>(key: QueryKey, data: T, options?: { updatedAt?: number }): void;\n subscribe(key: QueryKey, listener: () => void): Unsubscribe;\n};\ntype MutationContext = { readonly signal: AbortSignal };\ntype MutationOptions<T> = {\n invalidateKeys?: readonly (readonly unknown[])[];\n onSuccess?: (data: T, queries: QueryCache) => void | Promise<void>;\n request: (context: MutationContext) => Promise<T>;\n signal?: AbortSignal;\n};\ntype StreamEvent<T = unknown> = { readonly data: T; readonly event: string };\ntype StreamOptions<P extends string = string> = Omit<RequestConfig<P>, 'responseType' | 'schema'> & {\n method?: string;\n};\ntype Unsubscribe = () => void;\n```\n\n```ts\ntype ParamValue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype Params = Record<string, ParamValue>;\ntype RequestConfig<P extends string = string, T = unknown> = {\n body?: unknown;\n fetchInit?: Omit<RequestInit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: Record<string, string>;\n params?: Record<string, string | number | boolean>;\n query?: Params;\n responseType?: 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'raw';\n schema?: { parse(data: unknown): T };\n signal?: AbortSignal;\n timeout?: number;\n};\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `CourierError` | Base class for all Courier errors | Use `instanceof` to narrow |\n| `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`; `CourierHttpError.is(e, status?)` narrows by status |\n| `CourierNetworkError` | Request failure without response | `method`, `url`, `cause` |\n| `CourierTimeoutError` | Timeout signal aborts request | `method`, `url`, `cause` |\n| `CourierAbortError` | Caller, client, or iterator cancellation | `method`, `url`, `cause` |\n| `CourierSchemaValidationError` | Response schema fails | `data`, `cause` |\n| `CourierParseError` | Response body cannot parse | — |\n| `CourierDisposedError` | Work starts after disposal | — |\n",
|
|
6
6
|
"usage": "---\ntitle: Courier — Usage Guide\ndescription: Use one Courier client for HTTP, explicit cached reads, direct mutations, and abortable streams.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one Courier client for an application or request scope. Its transport policy and disposal lifecycle apply\nto every request, cache entry, mutation, and stream.\n\n```ts\nimport { createCourier } from '@vielzeug/courier';\n\ntype User = { id: number; name: string };\n\nconst courier = createCourier({ baseUrl: 'https://api.example.com', query: { staleTime: 30_000 } });\nconst key = ['users', 1] as const;\n\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get<User>('/users/{id}', { params: { id: 1 }, signal }),\n});\nconsole.log(courier.queries.get<User>(key)?.name);\n```\n\n## HTTP Requests\n\nUse root methods for REST requests. Courier encodes path parameters, serializes plain-object bodies, and parses\nsuccessful response bodies. Each direct HTTP call is independent; use a query key when concurrent cached reads\nshould share work.\n\n```ts\nconst posts = await courier.get<{ id: number; title: string }[]>('/users/{id}/posts', {\n params: { id: 1 },\n query: { limit: 20, status: 'published' },\n});\n\nawait courier.patch('/posts/{id}', {\n body: { title: 'Updated title' },\n params: { id: posts[0].id },\n});\n```\n\nCall `courier.setHeaders({ authorization: 'Bearer token' })` to update subsequent calls.\n\n## Interceptors\n\nInterceptors apply to HTTP and streaming requests. Register a policy once, then remove it when its containing\nscope ends.\n\n```ts\nimport { withBearerAuth, withRequestId } from '@vielzeug/courier';\n\nconst removeAuth = courier.use(withBearerAuth(async () => sessionStorage.getItem('access-token') ?? ''));\nconst removeRequestId = courier.use(withRequestId());\n\nremoveRequestId();\nremoveAuth();\n```\n\nUse `withLogging()` with an explicit `logger` function to log requests during local development. `withLogging()`\nincludes full URLs, so sanitize query values before persistent logging.\n\n```ts\nimport { withLogging } from '@vielzeug/courier';\n\ncourier.use(withLogging({ logger: (msg) => console.log(msg) }));\n```\n\n## Cached Queries\n\nPass a stable key and fetch definition to `queries.fetch()`. The cache owns data, snapshots, subscriptions, and\nin-flight deduplication for that key. Entries with no subscribers are garbage-collected after `gcTime` (default\n5 min; `Infinity` disables).\n\n```ts\nconst key = ['profile', 1] as const;\nconst definition = {\n key,\n fetch: ({ signal }) => courier.get<{ id: number; name: string }>('/profile/{id}', { params: { id: 1 }, signal }),\n staleTime: 60_000,\n};\n\nconst stop = courier.queries.subscribe(key, () => {\n const state = courier.queries.getSnapshot<{ id: number; name: string }>(key);\n if (state?.status === 'success') console.log(state.data.name);\n if (state?.status === 'error') console.error(state.error);\n});\n\nawait courier.queries.fetch(definition);\nstop();\n```\n\n`queries.fetch(definition)` reuses fresh data. Pass `{ force: true }` to fetch regardless of freshness.\n`invalidate(prefix, { refetch: true })` marks matching key prefixes stale and refetches them in the background\nin a single call.\n\n## Direct Mutations\n\nUse `mutate()` for a write operation. Pass `invalidateKeys` to invalidate and refetch cache entries after a\nsuccessful write — no manual `invalidate()` + refetch boilerplate. Use `onSuccess` for custom cache writes\n(e.g. seeding a created entity). Courier never retries writes: retry only operations your application can prove\nidempotent.\n\n```ts\ntype User = { id: number; name: string };\n\nconst created = await courier.mutate<User>({\n request: ({ signal }) => courier.post<User>('/users', { body: { name: 'Ada' }, signal }),\n onSuccess: (user, queries) => queries.set(['users', user.id], user),\n invalidateKeys: [['users']],\n});\n\nconsole.log(created.id);\n```\n\nPass an external `signal` when caller owns cancellation. Keep pending and error UI state in framework that owns\nthat UI.\n\n## Server-Sent Events\n\n`events()` returns an abortable `AsyncIterableIterator`. Breaking loop, calling `return()`, aborting a provided\nsignal, or disposing client stops its request immediately. Courier sends `Accept: text/event-stream` and\n`Cache-Control: no-cache` by default; pass headers to override either value.\n\n```ts\ntype Notification = { text: string };\n\nfor await (const event of courier.events<Notification>('/events')) {\n if (event.event !== 'message') continue;\n console.log(event.data.text);\n break;\n}\n```\n\nCourier parses valid JSON event data and otherwise returns text. It does not reconnect automatically or retain\nSSE event IDs; application owns reconnect policy.\n\n## HTTP Streaming\n\nUse `read()` for text chunks or NDJSON records.\n\n```ts\ntype ChatChunk = { done: boolean; delta: string };\n\nfor await (const chunk of courier.read<ChatChunk>('/chat', {\n body: { prompt: 'Explain cached queries.' },\n method: 'POST',\n parse: 'ndjson',\n})) {\n console.log(chunk.delta);\n if (chunk.done) break;\n}\n```\n\nStreams have no timeout unless `timeout` is supplied. HTTP, network, timeout, and cancellation failures use\nCourier error classes; starting a stream after disposal throws `CourierDisposedError`.\n\n## Framework Integration\n\nCreate Courier at application or route boundary. Views read a key snapshot synchronously, subscribe during\ntheir lifecycle, and let framework own rendering state.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useSyncExternalStore } from 'react';\nimport { createCourier } from '@vielzeug/courier';\nimport type { AsyncState, QueryDefinition } from '@vielzeug/courier';\n\ntype User = { id: number; name: string };\n\nexport function Profile({ courier, definition }: { courier: ReturnType<typeof createCourier>; definition: QueryDefinition<User> }) {\n const state = useSyncExternalStore(\n (listener) => courier.queries.subscribe(definition.key, listener),\n () => courier.queries.getSnapshot<User>(definition.key),\n () => courier.queries.getSnapshot<User>(definition.key),\n ) as AsyncState<User> | null;\n\n useEffect(() => void courier.queries.fetch(definition), [courier, definition]);\n\n if (!state || state.status === 'loading') return <p>Loading...</p>;\n if (state.status === 'error') return <p role=\"alert\">{state.error.message}</p>;\n return <p>{state.data.name}</p>;\n}\n```\n\n```ts [Vue 3]\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createCourier } from '@vielzeug/courier';\nimport type { AsyncState, QueryDefinition } from '@vielzeug/courier';\n\ntype User = { id: number; name: string };\n\nexport function useProfile(courier: ReturnType<typeof createCourier>, definition: QueryDefinition<User>) {\n const state = ref<AsyncState<User> | null>(courier.queries.getSnapshot(definition.key));\n const unsubscribe = courier.queries.subscribe(definition.key, () => {\n state.value = courier.queries.getSnapshot(definition.key);\n });\n\n onMounted(() => void courier.queries.fetch(definition));\n onUnmounted(unsubscribe);\n\n return { state };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createCourier } from '@vielzeug/courier';\n import type { AsyncState, QueryDefinition } from '@vielzeug/courier';\n\n type User = { id: number; name: string };\n\n export let courier: ReturnType<typeof createCourier>;\n export let definition: QueryDefinition<User>;\n let state: AsyncState<User> | null = courier.queries.getSnapshot(definition.key);\n\n onMount(() => {\n const unsubscribe = courier.queries.subscribe(definition.key, () => (state = courier.queries.getSnapshot(definition.key)));\n void courier.queries.fetch(definition);\n return unsubscribe;\n });\n</script>\n\n{#if state?.status === 'success'}\n <p>{state.data.name}</p>\n{/if}\n```\n\n:::\n\nCourier exposes no framework-specific loading or error store. Render `AsyncState` in framework that owns view.\n\n## Working with Other Vielzeug Libraries\n\n### Flux\n\nUse Flux when cache snapshots or SSE events need filtering, composition, or subscription lifecycle separate from\nUI framework. Pass cache and query definition to `fromQuery()`.\n\n```ts\nimport { fromQuery } from '@vielzeug/flux/courier';\n\nconst profile = {\n key: ['profile'] as const,\n fetch: ({ signal }: { signal: AbortSignal }) => courier.get<{ id: number; name: string }>('/profile', { signal }),\n};\nconst profile$ = fromQuery(courier.queries, profile);\n\nvoid courier.queries.fetch(profile);\n\nconst profileSubscription = profile$.subscribe((state) => console.log(state?.status));\n\nprofileSubscription.unsubscribe();\n```\n\n### Ripple\n\nUse a Ripple signal when Courier data must participate in fine-grained reactive state outside a component. Mirror\nonly cache snapshot into signal.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst key = ['profile', 1] as const;\nconst profileState = signal(courier.queries.getSnapshot<{ id: number; name: string }>(key));\nconst unsubscribe = courier.queries.subscribe(key, () => (profileState.value = courier.queries.getSnapshot(key)));\n\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n\nunsubscribe();\n```\n\n## Gotchas\n\n### `mutate()` runs `onSuccess` before `invalidateKeys`\n\n`mutate()` executes in order: `request` → `onSuccess` → `invalidateKeys`. If `onSuccess` calls `queries.set()` for a key also in `invalidateKeys`, the seeded data is overwritten by the background refetch. This is correct — `invalidateKeys` means \"refetch to confirm\" — but the order matters when `onSuccess` seeds optimistic data that `invalidateKeys` then replaces.\n\n### Optimistic rollback should use `getSnapshot()`, not `get()`\n\n`queries.get()` only returns successful data and drops snapshot metadata (`status`, `error`, `updatedAt`). It also cannot distinguish a missing entry from a successful cached value of `undefined`. For optimistic rollback, capture `getSnapshot()` first so you can restore prior success data with `updatedAt`, or `delete(key)` when no previous success snapshot existed.\n\n### `baseUrl` should not include query parameters\n\n`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.\n\n### Background refetch after error transitions through `loading`\n\nWhen `invalidate({ refetch: true })` triggers a background refetch on an error-state entry, the snapshot transitions to `loading` (losing the previous error). Consumers building \"error + retrying\" UI should track retry state separately — `AsyncState` has no `error` with `isFetching: true` variant.\n\n## Best Practices\n\n- Create one Courier client per application or SSR request scope.\n- Use stable, complete cache keys for every cached response identity.\n- Fetch through `queries.fetch()` when work should deduplicate and cache.\n- Use `invalidateKeys` on mutations to refetch affected cache entries in one step.\n- Keep retries outside mutations until operation idempotency is proven.\n- Dispose only at final application or request boundary.\n- Keep credentials out of URLs when using logging interceptors.\n",
|
|
7
7
|
"examples": "---\ntitle: Courier — Examples\ndescription: Practical examples and recipes for courier.\n---\n\n## Examples\n\n- [Authentication](./examples/authentication.md)\n- [CRUD Operations](./examples/crud-operations.md)\n- [Disposal](./examples/disposal.md)\n- [Error Handling Patterns](./examples/error-handling-patterns.md)\n- [File Uploads](./examples/file-uploads.md)\n- [Optimistic Updates](./examples/optimistic-updates.md)\n- [Polling](./examples/polling.md)\n- [Real-time Events](./examples/sse-events.md)\n- [AI Token Stream](./examples/ai-token-stream.md)\n"
|
|
8
8
|
},
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
}
|
|
25
25
|
],
|
|
26
26
|
"typeSignatures": {
|
|
27
|
-
"Courier": "export { type Courier, type CourierOptions, createCourier } from './courier';",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
27
|
+
"Courier": "export { type Courier, type CourierEvent, type CourierOptions, createCourier } from './courier';",
|
|
28
|
+
"CourierEvent": "export { type Courier, type CourierEvent, type CourierOptions, createCourier } from './courier';",
|
|
29
|
+
"CourierOptions": "export { type Courier, type CourierEvent, type CourierOptions, createCourier } from './courier';",
|
|
30
|
+
"createCourier": "export { type Courier, type CourierEvent, type CourierOptions, createCourier } from './courier';",
|
|
30
31
|
"CourierAbortError": "export {\n CourierAbortError,\n CourierDisposedError,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierParseError,\n CourierSchemaValidationError,\n CourierTimeoutError,\n} from './errors';",
|
|
31
32
|
"CourierDisposedError": "export {\n CourierAbortError,\n CourierDisposedError,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierParseError,\n CourierSchemaValidationError,\n CourierTimeoutError,\n} from './errors';",
|
|
32
33
|
"CourierError": "export {\n CourierAbortError,\n CourierDisposedError,\n CourierError,\n CourierHttpError,\n CourierNetworkError,\n CourierParseError,\n CourierSchemaValidationError,\n CourierTimeoutError,\n} from './errors';",
|
package/data/packages/dnd.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"apiSource": "export * from './drop-zone';\nexport { DndError, DndScopeError } from './errors';\nexport * from './sortable';\nexport * from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
4
|
"index": "---\ntitle: Dnd — Drag-and-drop primitives for the DOM\ndescription: Framework-agnostic drag-and-drop. Drop zones with MIME filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.\npackage: dnd\ncategory: ui-interaction\nkeywords: [drag-drop, sortable, file-upload, drop-zone, dnd, reorder]\nrelated: [ore, scroll, refine]\nexports: [createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"dnd\" />\n\n## Why Dnd?\n\nThe 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.\n\n```ts\n// Before — raw HTML5 Drag & Drop\nlet enterCount = 0;\ndropzone.addEventListener('dragenter', () => {\n enterCount++;\n dropzone.classList.add('over');\n});\ndropzone.addEventListener('dragleave', () => {\n if (--enterCount === 0) dropzone.classList.remove('over');\n});\ndropzone.addEventListener('dragover', (e) => e.preventDefault());\ndropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n enterCount = 0;\n const files = [...e.dataTransfer!.files];\n if (!files.every((f) => f.type.startsWith('image/'))) return showError('Images only');\n uploadFiles(files);\n});\n\n// After — Dnd\nimport { createDropZone } from '@vielzeug/dnd';\nconst zone = createDropZone({\n element: dropzone,\n accept: ['image/*'],\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError(`${files.length} file(s) not accepted`),\n onHoverChange: (hovered) => dropzone.classList.toggle('over', hovered),\n});\n```\n\n| Feature | DND | SortableJS | dnd-kit |\n| ------------------- | -------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ |\n| Bundle size | <PackageInfo package=\"dnd\" type=\"size\" /> | ~15 kB | ~30 kB |\n| Framework agnostic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| MIME type filtering | <ore-icon name=\"check\" size=\"16\"></ore-icon> Pre-validated | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Counter-based hover | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | N/A |\n| Sortable lists | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Drag handles | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| `using` support | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Touch support | <ore-icon name=\"check\" size=\"16\"></ore-icon> Scoped opt-in | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.\n\n**Consider dnd-kit** if you are building a React app and need complex multi-container drag interactions or accessibility-first sortable trees.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/dnd\n```\n\n```sh [npm]\nnpm install @vielzeug/dnd\n```\n\n```sh [yarn]\nyarn add @vielzeug/dnd\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createDropZone, createSortable } from '@vielzeug/dnd';\n\n// File drop zone — with async validation and paste support\nconst dropzone = document.getElementById('dropzone')!;\n\nusing zone = createDropZone({\n element: dropzone,\n accept: ['image/*', '.pdf'],\n paste: true,\n onValidate: (files) => files.every((file) => file.size <= 5_000_000),\n onDrop: (files) => console.log('Upload', files),\n onDropRejected: (files) => {\n console.warn(`${files.length} file(s) rejected`);\n },\n onHoverChange: (hovered) => {\n dropzone.classList.toggle('drag-over', hovered);\n },\n});\n\n// Sortable list — with revert support for optimistic updates\nlet currentOrder = ['a', 'b', 'c'];\n\nusing sortable = createSortable({\n element: document.getElementById('list')!,\n keyboard: true,\n onBeforeReorder: (from, to) => {\n // record positions here before the DOM commits (for FLIP animations)\n },\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n currentOrder = ids;\n setRevert(() => {\n currentOrder = prev;\n });\n },\n});\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **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\n- **MIME type pre-validation** — queries `dataTransfer.items` during drag to set `dropEffect='none'` before the drop; confirmed against `File.type` on drop\n- **Flexible accept patterns** — MIME types (`image/png`), wildcards (`image/*`), and file extensions (`.pdf`)\n- **`maxFiles` limit** — cap the number of accepted files per drop; excess files are forwarded to `onDropRejected`\n- **`onValidate` async gating** — optional cancellable async step after type filtering; `zone.validating` remains `true` until every pending validation settles\n- **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\n- **`onDropRejected`** — separate callback for files that didn't match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`; event type reflects whether the rejection came from a drop or a paste\n- **Sortable lists** — reorders DOM children with a placeholder indicator; fires `onReorder` only when the order actually changes\n- **Drag handles** — scope dragging to a child selector via `handle`; whole item is draggable when omitted\n- **Custom drag preview** — pass an element or a `(id, item, event) => element | null` factory; control hotspot with `dragImageOffset`\n- **`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\n- **`sortable.revert()`** — register a revert function via `event.setRevert(fn)` inside `onReorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure\n- **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally\n- **Transactional connected scopes** — one `onMove` callback receives each cross-list transfer with both final orders\n- **Scoped touch support** — `createSortableScope({ touch: true })` handles only items registered to that scope and uses an inert outline preview\n- **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers\n- **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup\n- **Reactive-friendly options** — `disabled` is re-read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone\n- **Zero dependencies** — <PackageInfo package=\"dnd\" type=\"size\" /> gzipped, <PackageInfo package=\"dnd\" type=\"dependencies\" /> dependencies\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Orbit](/orbit/) — floating element positioning; use alongside Dnd to anchor drag previews and drop-zone indicators to precise positions\n- [Ore](/ore/) — web-component authoring framework; build draggable custom elements with Dnd's pointer event primitives\n- [Refine](/refine/) — accessible web components; Dnd powers the drag-and-drop inside Refine's sortable list and kanban components\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Dnd — API Reference\ndescription: Complete API reference for Dnd.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- |\n| `createDropZone()` | Create a typed drop-zone controller | Sync | Dispose the controller during teardown |\n| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |\n| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |\n| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |\n| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |\n| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |\n| `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |\n| `DndError` | Base class for Dnd errors | Sync | Use `DndError.is()` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --------------- | ---------------------- |\n| `@vielzeug/dnd` | Main exports and types |\n\n## Types\n\n### `Disposable`\n\n```ts\ninterface Disposable {\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n### `DropZoneOptions`\n\n```ts\ninterface DropZoneOptions {\n element: HTMLElement;\n accept?: string[];\n maxFiles?: number;\n onValidate?: (files: File[], context: DropValidationContext) => boolean | Promise<boolean>;\n disabled?: boolean;\n dropEffect?: DataTransfer['dropEffect'];\n onDrop?: (files: File[]) => void;\n onDropRejected?: (files: File[]) => void;\n onHoverChange?: (hovered: boolean) => void;\n onValidatingChange?: (validating: boolean) => void;\n paste?: boolean;\n onPaste?: (files: File[]) => void;\n}\n```\n\n### `DropZone`\n\n```ts\ninterface DropZone extends Disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `DropValidationContext`\n\n```ts\ninterface DropValidationContext {\n readonly signal: AbortSignal;\n}\n```\n\n### `SortableOptions`\n\n```ts\ninterface SortableOptions {\n element: HTMLElement;\n getKey: (element: HTMLElement) => string;\n scope?: SortableScope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoScroll?: boolean | AutoScrollOptions;\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n dragImageOffset?: [number, number];\n placeholderClass?: string;\n disabled?: boolean;\n onDragStart?: (id: string, event: DragEvent) => void;\n onDragEnd?: (id: string, event: DragEvent) => void;\n onBeforeReorder?: (from: string[], to: string[]) => void;\n onReorder?: (event: ReorderEvent) => void;\n}\n```\n\n### `AutoScrollOptions`\n\n```ts\ninterface AutoScrollOptions {\n edgeThreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `ReorderEvent`\n\n```ts\ninterface ReorderEvent {\n ids: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `Sortable`\n\n```ts\ninterface Sortable extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `SortableScope`\n\n```ts\ninterface SortableScope extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n}\n```\n\n### `SortableScopeOptions`\n\n```ts\ninterface SortableScopeOptions {\n onMove?: (event: SortableMoveEvent) => void;\n touch?: boolean | SortableTouchOptions;\n}\n```\n\n### `SortableMoveEvent`\n\n```ts\ninterface SortableMoveEvent {\n readonly itemId: string;\n readonly source: HTMLElement;\n readonly sourceIds: string[];\n readonly target: HTMLElement;\n readonly targetIds: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `SortableTouchOptions`\n\n```ts\ninterface SortableTouchOptions {\n preview?: false | ((item: HTMLElement) => HTMLElement | null);\n}\n```\n\n`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.\n\nTouch sorting tracks the initiating touch by identifier. Secondary touches are ignored, and cancellation of the initiating touch restores the pre-drag order without firing `onReorder`.\n\n## `createDropZone()`\n\n```ts\ndeclare function createDropZone(options: DropZoneOptions): DropZone;\n```\n\nAttaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle.\n\n| Option | Type | Default | Description |\n| ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. |\n| `onValidate` | `(files, { signal }) => boolean \\| Promise<boolean>` | — | Optional async gating step. Return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropEffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. |\n| `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. |\n| `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. |\n| `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. |\n| `onValidatingChange` | `(validating: boolean) => void` | — | Called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. |\n| `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. |\n\n**Returns:** `DropZone`\n\nNotes:\n\n- Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time.\n- 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`.\n- Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf'],\n onDrop: (files) => {\n upload(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} rejected`);\n },\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\n## `DropZone` Interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onValidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. Safe to read at any time.\n\n### `zone.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`. Called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n} // zone.dispose() runs here\n```\n\n## `createSortable()`\n\n```ts\ndeclare function createSortable(options: SortableOptions): Sortable;\n```\n\nMakes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.\n\n`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.\n\n- `element`: `HTMLElement`, required. The container whose children become sortable.\n- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.\n- `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n- `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable.\n- `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`.\n- `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion.\n- `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`.\n- `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely.\n- `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor.\n- `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element.\n- `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order.\n- `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts.\n- `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled.\n- `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for [`captureLayout()`](/necromancer/api.md#capturelayout) setup.\n- `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**Returns:** `Sortable`\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => saveMove(itemId, sourceIds, targetIds),\n touch: true,\n});\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.id!,\n handle: '.drag-handle',\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n saveOrder(ids);\n setRevert(() => saveOrder(prev));\n },\n scope: boardScope,\n});\n```\n\n### `createSortableScope()`\n\n```ts\ndeclare function createSortableScope(options?: SortableScopeOptions): SortableScope;\n```\n\nUse 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`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `SortableScopeOptions` | Optional cross-list move callback and scope-owned touch configuration |\n\n**Returns:** `SortableScope`.\n\n```ts\nimport { createSortableScope } from '@vielzeug/dnd';\n\nconst scope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n```\n\n## `Sortable` Interface\n\n### `sortable.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\nCalls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders.\n\nOnly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids);\n setRevert(() => setOrder(prev)); // ← enable revert\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nRe-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nRemoves 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.\n\n### `sortable[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`.\n\n## `SortableScope` Interface\n\n### `scope.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\nCalls and clears the rollback registered with `SortableMoveEvent.setRevert()` for the latest cross-list move. It is a no-op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\nDisposes scope-owned touch input and prevents registered lists from participating in future connected moves.\n\n## DOM Attributes\n\nDnd reads and writes the following DOM attributes:\n\n- `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Restored on `dispose()`.\n- `draggable`, roles, tabindex, and `touchAction`: managed only as needed and restored to their exact prior values on `dispose()`.\n- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.\n- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.\n- `aria-hidden=\"true\"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.\n- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## CSS Classes\n\n| Class | Applied to | When |\n| ----------------- | ---------------------------- | ------------------------------------------------------------- |\n| `dnd-placeholder` | `<div>` inserted by sortable | While an item is being dragged, in the placeholder's position |\n\n## `matchesAccept()`\n\n```ts\ndeclare function matchesAccept(file: File, accept: string[]): boolean;\n```\n\nTests whether a `File` matches an accept pattern list. Each pattern can be:\n\n- A MIME type: `'image/png'`\n- A MIME wildcard: `'image/*'`\n- A file extension: `'.pdf'`\n\nAn empty list accepts everything. Extension matching is case-insensitive.\n\n**Returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesAccept } from '@vielzeug/dnd';\n\nmatchesAccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyReorder()`\n\n```ts\ndeclare function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[];\n```\n\nApplies a DOM reorder result (`orderedIds`) to your backing array.\n\n- IDs missing from `items` are ignored.\n- Items not listed in `ids` are appended in original order.\n- Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored.\n\n**Returns:** A new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyReorder(items, orderedIds, (item) => item.id);\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `DndError` | Base class for package errors | `DndError.is(error)` |\n| `DndScopeError` | A sortable receives a scope not created by `createSortableScope()` | — |\n",
|
|
5
|
+
"api": "---\ntitle: Dnd — API Reference\ndescription: Complete API reference for Dnd.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- |\n| `createDropZone()` | Create a typed drop-zone controller | Sync | Dispose the controller during teardown |\n| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |\n| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |\n| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |\n| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |\n| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |\n| `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |\n| `DndError` | Base class for Dnd errors | Sync | Use `instanceof DndError` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --------------- | ---------------------- |\n| `@vielzeug/dnd` | Main exports and types |\n\n## Types\n\n### `Disposable`\n\n```ts\ninterface Disposable {\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n### `DropZoneOptions`\n\n```ts\ninterface DropZoneOptions {\n element: HTMLElement;\n accept?: string[];\n maxFiles?: number;\n onValidate?: (files: File[], context: DropValidationContext) => boolean | Promise<boolean>;\n disabled?: boolean;\n dropEffect?: DataTransfer['dropEffect'];\n onDrop?: (files: File[]) => void;\n onDropRejected?: (files: File[]) => void;\n onHoverChange?: (hovered: boolean) => void;\n onValidatingChange?: (validating: boolean) => void;\n paste?: boolean;\n onPaste?: (files: File[]) => void;\n}\n```\n\n### `DropZone`\n\n```ts\ninterface DropZone extends Disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `DropValidationContext`\n\n```ts\ninterface DropValidationContext {\n readonly signal: AbortSignal;\n}\n```\n\n### `SortableOptions`\n\n```ts\ninterface SortableOptions {\n element: HTMLElement;\n getKey: (element: HTMLElement) => string;\n scope?: SortableScope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoScroll?: boolean | AutoScrollOptions;\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n dragImageOffset?: [number, number];\n placeholderClass?: string;\n disabled?: boolean;\n onDragStart?: (id: string, event: DragEvent) => void;\n onDragEnd?: (id: string, event: DragEvent) => void;\n onBeforeReorder?: (from: string[], to: string[]) => void;\n onReorder?: (event: ReorderEvent) => void;\n}\n```\n\n### `AutoScrollOptions`\n\n```ts\ninterface AutoScrollOptions {\n edgeThreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `ReorderEvent`\n\n```ts\ninterface ReorderEvent {\n ids: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `Sortable`\n\n```ts\ninterface Sortable extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `SortableScope`\n\n```ts\ninterface SortableScope extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n}\n```\n\n### `SortableScopeOptions`\n\n```ts\ninterface SortableScopeOptions {\n onMove?: (event: SortableMoveEvent) => void;\n touch?: boolean | SortableTouchOptions;\n}\n```\n\n### `SortableMoveEvent`\n\n```ts\ninterface SortableMoveEvent {\n readonly itemId: string;\n readonly source: HTMLElement;\n readonly sourceIds: string[];\n readonly target: HTMLElement;\n readonly targetIds: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `SortableTouchOptions`\n\n```ts\ninterface SortableTouchOptions {\n preview?: false | ((item: HTMLElement) => HTMLElement | null);\n}\n```\n\n`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.\n\nTouch sorting tracks the initiating touch by identifier. Secondary touches are ignored, and cancellation of the initiating touch restores the pre-drag order without firing `onReorder`.\n\n## `createDropZone()`\n\n```ts\ndeclare function createDropZone(options: DropZoneOptions): DropZone;\n```\n\nAttaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle.\n\n| Option | Type | Default | Description |\n| ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. |\n| `onValidate` | `(files, { signal }) => boolean \\| Promise<boolean>` | — | Optional async gating step. Return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropEffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. |\n| `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. |\n| `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. |\n| `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. |\n| `onValidatingChange` | `(validating: boolean) => void` | — | Called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. |\n| `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. |\n\n**Returns:** `DropZone`\n\nNotes:\n\n- Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time.\n- 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`.\n- Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf'],\n onDrop: (files) => {\n upload(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} rejected`);\n },\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\n## `DropZone` Interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onValidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. Safe to read at any time.\n\n### `zone.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`. Called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n} // zone.dispose() runs here\n```\n\n## `createSortable()`\n\n```ts\ndeclare function createSortable(options: SortableOptions): Sortable;\n```\n\nMakes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.\n\n`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.\n\n- `element`: `HTMLElement`, required. The container whose children become sortable.\n- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.\n- `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n- `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable.\n- `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`.\n- `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion.\n- `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`.\n- `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely.\n- `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor.\n- `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element.\n- `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order.\n- `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts.\n- `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled.\n- `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for [`captureLayout()`](/necromancer/api.md#capturelayout) setup.\n- `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**Returns:** `Sortable`\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => saveMove(itemId, sourceIds, targetIds),\n touch: true,\n});\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.id!,\n handle: '.drag-handle',\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n saveOrder(ids);\n setRevert(() => saveOrder(prev));\n },\n scope: boardScope,\n});\n```\n\n### `createSortableScope()`\n\n```ts\ndeclare function createSortableScope(options?: SortableScopeOptions): SortableScope;\n```\n\nUse 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`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `SortableScopeOptions` | Optional cross-list move callback and scope-owned touch configuration |\n\n**Returns:** `SortableScope`.\n\n```ts\nimport { createSortableScope } from '@vielzeug/dnd';\n\nconst scope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n```\n\n## `Sortable` Interface\n\n### `sortable.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\nCalls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders.\n\nOnly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids);\n setRevert(() => setOrder(prev)); // ← enable revert\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nRe-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nRemoves 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.\n\n### `sortable[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`.\n\n## `SortableScope` Interface\n\n### `scope.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\nCalls and clears the rollback registered with `SortableMoveEvent.setRevert()` for the latest cross-list move. It is a no-op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\nDisposes scope-owned touch input and prevents registered lists from participating in future connected moves.\n\n## DOM Attributes\n\nDnd reads and writes the following DOM attributes:\n\n- `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Restored on `dispose()`.\n- `draggable`, roles, tabindex, and `touchAction`: managed only as needed and restored to their exact prior values on `dispose()`.\n- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.\n- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.\n- `aria-hidden=\"true\"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.\n- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## CSS Classes\n\n| Class | Applied to | When |\n| ----------------- | ---------------------------- | ------------------------------------------------------------- |\n| `dnd-placeholder` | `<div>` inserted by sortable | While an item is being dragged, in the placeholder's position |\n\n## `matchesAccept()`\n\n```ts\ndeclare function matchesAccept(file: File, accept: string[]): boolean;\n```\n\nTests whether a `File` matches an accept pattern list. Each pattern can be:\n\n- A MIME type: `'image/png'`\n- A MIME wildcard: `'image/*'`\n- A file extension: `'.pdf'`\n\nAn empty list accepts everything. Extension matching is case-insensitive.\n\n**Returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesAccept } from '@vielzeug/dnd';\n\nmatchesAccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyReorder()`\n\n```ts\ndeclare function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[];\n```\n\nApplies a DOM reorder result (`orderedIds`) to your backing array.\n\n- IDs missing from `items` are ignored.\n- Items not listed in `ids` are appended in original order.\n- Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored.\n\n**Returns:** A new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyReorder(items, orderedIds, (item) => item.id);\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `DndError` | Base class for package errors | Use `instanceof DndError` to narrow |\n| `DndScopeError` | A sortable receives a scope not created by `createSortableScope()` | — |\n",
|
|
6
6
|
"usage": "---\ntitle: Dnd — Usage Guide\ndescription: Drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with Dnd.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`createDropZone` attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter.\n\n```ts\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst dropzone = document.getElementById('dropzone')!;\n\nconst zone = createDropZone({\n element: dropzone,\n onDrop: (files) => {\n console.log('Accepted files:', files);\n },\n});\n```\n\n### Accept filtering\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf', 'application/json'],\n onDrop: (files) => {\n // accepted files only\n },\n onDropRejected: (files) => {\n showToast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nThe `accept` list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### Hover state\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\nRead zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### Drop effect\n\n```ts\ncreateDropZone({\n element: dropEl,\n dropEffect: 'move',\n onDrop: (files) => {\n // ...\n },\n});\n```\n\n### Disabled state\n\n```ts\nconst options = { disabled: false, element: dropEl, onDrop: handleFiles };\nconst zone = createDropZone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isReadOnly;\n```\n\n### File limit\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n maxFiles: 5,\n onDrop: (files) => {\n // 1-5 accepted files\n },\n onDropRejected: (files) => {\n showToast(`Only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### Cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n```\n\n### Async validation\n\nGate drops behind an async check with `onValidate`. The zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n onValidate: async (files, { signal }) => {\n const ok = await checkServerQuota(files, { signal });\n return ok; // false → all files forwarded to onDropRejected\n },\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError('Quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\nA synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onValidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n onDrop: handleFiles,\n});\n```\n\n### Clipboard paste\n\nSet `paste: true` to accept files pasted from the clipboard. The same `accept`, `maxFiles`, and `onValidate` pipeline applies.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n paste: true,\n accept: ['image/*'],\n onPaste: (files) => {\n uploadFiles(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nWhen `onPaste` is omitted, accepted pasted files fall through to `onDrop`.\n\n## Sortable\n\n`createSortable` makes direct children of a container reorderable via drag.\n\n### Setup\n\n```html\n<ul id=\"task-list\">\n <li data-sort-id=\"task-1\">Design</li>\n <li data-sort-id=\"task-2\">Develop</li>\n <li data-sort-id=\"task-3\">Review</li>\n</ul>\n```\n\n```ts\nconst sortable = createSortable({\n element: document.getElementById('task-list')!,\n getKey: (el) => el.dataset.sortId!,\n axis: 'vertical',\n onReorder: ({ ids }) => {\n saveTaskOrder(ids);\n },\n});\n```\n\nDnd automatically sets:\n\n- `draggable=\"true\"` on sortable nodes (or handles)\n- `role=\"listitem\"` on each item\n- `role=\"list\"` on the container\n- `tabindex=\"0\"` on each item for keyboard reordering\n\n### Drag handles\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n handle: '.drag-handle',\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Keyboard reordering\n\nFocus an item and use arrow keys to move it. `Home` and `End` move to the boundary positions.\n\nWhen 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`.\n\n### Connected lists\n\nCreate a shared scope when items should move between containers:\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n\ncreateSortable({\n element: todoEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\ncreateSortable({\n element: doneEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\n```\n\n### Auto-scroll and drag preview\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },\n dragImage: (id, item) => item,\n dragImageOffset: [8, 8],\n});\n```\n\nViewport scrolling is opt-in. Container scrolling stays enabled by default.\n\n### Lifecycle hooks\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Custom identity function\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.getAttribute('data-id')!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Dynamic lists\n\nCall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createElement('li');\nitem.dataset.sortId = 'task-4';\nitem.textContent = 'Deploy';\nlistEl.appendChild(item);\nsortable.sync();\n```\n\n### Disabled state\n\n```ts\nimport { createSortable, type SortableOptions } from '@vielzeug/dnd';\n\nconst options: SortableOptions = {\n disabled: false,\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n};\nconst sortable = createSortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isLocked;\n```\n\n### Placeholder styling\n\n```css\n.dnd-placeholder {\n background: var(--color-primary-50);\n border: 2px dashed var(--color-primary-300);\n border-radius: 4px;\n box-sizing: border-box;\n}\n\n[data-dragging] {\n opacity: 0.35;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### Mapping DOM order back to data\n\n```ts\nimport { applyReorder, createSortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task-1', title: 'Design' },\n { id: 'task-2', title: 'Develop' },\n { id: 'task-3', title: 'Review' },\n];\n\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items = applyReorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### Cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### FLIP animation hook\n\n`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.\n\n```ts\nimport { captureLayout, type LayoutTransition } from '@vielzeug/necromancer';\n\nlet layout: LayoutTransition | undefined;\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onBeforeReorder: () => {\n layout = captureLayout(listEl.querySelectorAll('[data-sort-id]'), {\n getKey: (el) => el.dataset.sortId!,\n });\n },\n onReorder: ({ ids }) => {\n saveOrder(ids); // Commit a framework render here when needed.\n layout?.animate({\n duration: 200,\n easing: 'ease-out',\n elements: listEl.querySelectorAll('[data-sort-id]'),\n });\n layout = undefined;\n },\n});\n```\n\nIf `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 stays dependency-free: the application chooses to install and import Necromancer when it wants this integration.\n\n### Optimistic updates and revert\n\nCall `sortable.revert()` to roll back the most recent reorder. Register a revert function via `setRevert` inside `onReorder`.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids); // optimistic update\n setRevert(() => setOrder(prev)); // registered for sortable.revert()\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(currentOrder);\n} catch {\n sortable.revert();\n}\n```\n\n## Touch Support\n\nHTML5 drag-and-drop has no native touch story. Enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements.\n\n```ts\nimport { createSortable, createSortableScope } from '@vielzeug/dnd';\n\nusing scope = createSortableScope({ touch: true });\nusing sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.id!, scope });\n```\n\nThe scope tracks the touch that initiated the drag by its identifier. Additional fingers cannot move, finish, or replace the active drag. If the initiating touch is cancelled, Dnd restores the original item order and removes the transient preview.\n\n### Touch preview\n\nTouch 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.\n\n```ts\nconst scope = createSortableScope({\n touch: {\n // The returned element is cloned before Dnd mounts it as a transient preview.\n preview: (item) => item.querySelector<HTMLElement>('.drag-preview'),\n },\n});\n```\n\n### Why draggable items get `touch-action: none`\n\n`createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set). This prevents a mobile browser from treating the initial movement as page scrolling before the scope controller can start the drag.\n\nThis has no effect on mouse/pointer input.\n\n## Testing\n\nTest 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.\n\n```ts\nimport { afterEach, expect, it, vi } from 'vitest';\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst zones: Array<{ dispose(): void }> = [];\n\nafterEach(() => zones.splice(0).forEach((zone) => zone.dispose()));\n\nit('forwards accepted files', async () => {\n const element = document.createElement('div');\n const onDrop = vi.fn();\n const zone = createDropZone({ element, onDrop });\n zones.push(zone);\n const file = new File(['content'], 'readme.txt', { type: 'text/plain' });\n const event = new Event('drop') as DragEvent;\n\n Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } });\n element.dispatchEvent(event);\n\n await Promise.resolve();\n\n expect(onDrop).toHaveBeenCalledWith([file]);\n expect(zone.disposed).toBe(false);\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSortable, applyReorder } from '@vielzeug/dnd';\n\nfunction SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) {\n const listRef = useRef<HTMLUListElement>(null);\n const items = useRef(initialItems);\n\n useEffect(() => {\n const sortable = createSortable({\n element: listRef.current!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items.current = applyReorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listRef}>\n {initialItems.map((item) => (\n <li key={item.id} data-sort-id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [Vue 3]\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd';\n\nfunction useSortable(items: { id: string; text: string }[]) {\n const listRef = ref<HTMLElement | null>(null);\n const orderedItems = ref(items);\n let sortable: Sortable | null = null;\n\n onMounted(() => {\n sortable = createSortable({\n element: listRef.value!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id);\n },\n });\n });\n\n onUnmounted(() => sortable?.dispose());\n return { listRef, orderedItems };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSortable, applyReorder } from '@vielzeug/dnd';\n\n export let initialItems: { id: string; text: string }[] = [];\n let items = initialItems;\n let listEl: HTMLUListElement;\n\n onMount(() => {\n const sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listEl}>\n {#each items as item (item.id)}\n <li data-sort-id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nUse Dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createSortable } from '@vielzeug/dnd';\nimport { define, getHost, html, onMounted } from '@vielzeug/ore';\n\ndefine('task-list', {\n setup(_props) {\n const el = getHost();\n\n onMounted(() => {\n const sortable = createSortable({\n element: el,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## Best Practices\n\n- Attach `createDropZone` and `createSortable` after the container element is in the DOM — use `onMounted` in component frameworks.\n- Call `.dispose()` in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks.\n- Use `data-sort-id` attributes that match your data's identity field — do not use DOM index as an identifier.\n- Prefer `applyReorder()` over manual array splicing to keep your data array in sync with DOM order.\n- Use `createSortableScope()` only when items should genuinely move between containers.\n- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.\n- Enable `touch: true` only on scopes that own touch-sortable lists.\n",
|
|
7
7
|
"examples": "---\ntitle: Dnd — Examples\ndescription: Practical examples and recipes for dnd.\n---\n\n## Examples\n\n- [Sortable List](./examples/sortable-list.md)\n- [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)\n- [File Upload Drop Zone](./examples/file-upload-drop-zone.md)\n- [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)\n- [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)\n- [Connected Kanban Keyboard Sorting](./examples/connected-kanban-keyboard-sorting.md)\n- [Web Component With Ore](./examples/web-component-with-craft.md)\n- [Using `using` for scoped cleanup](./examples/using-using-for-scoped-cleanup.md)\n"
|
|
8
8
|
},
|