@vielzeug/codex 26.10.0 → 26.10.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +53 -59
  3. package/data/llms-full.txt +1322 -1069
  4. package/data/llms.txt +6 -6
  5. package/data/manifest.json +1 -1
  6. package/data/packages/arsenal.json +13 -13
  7. package/data/packages/assay.json +4 -4
  8. package/data/packages/clockwork.json +4 -4
  9. package/data/packages/codex.json +3 -3
  10. package/data/packages/coins.json +4 -4
  11. package/data/packages/conduit.json +4 -4
  12. package/data/packages/courier.json +4 -4
  13. package/data/packages/dnd.json +10 -10
  14. package/data/packages/familiar.json +4 -4
  15. package/data/packages/flux.json +4 -4
  16. package/data/packages/focus.json +4 -4
  17. package/data/packages/forge.json +4 -4
  18. package/data/packages/gesture.json +4 -4
  19. package/data/packages/herald.json +12 -12
  20. package/data/packages/illusionist.json +5 -5
  21. package/data/packages/keymap.json +9 -9
  22. package/data/packages/ledger.json +4 -4
  23. package/data/packages/lingua.json +4 -4
  24. package/data/packages/mesh.json +5 -5
  25. package/data/packages/necromancer.json +5 -5
  26. package/data/packages/orbit.json +7 -7
  27. package/data/packages/ore.json +5 -5
  28. package/data/packages/postmaster.json +4 -4
  29. package/data/packages/prism.json +50 -42
  30. package/data/packages/pulse.json +8 -8
  31. package/data/packages/refine.json +3 -3
  32. package/data/packages/ripple.json +4 -4
  33. package/data/packages/rune.json +9 -9
  34. package/data/packages/sandbox.json +4 -4
  35. package/data/packages/scout.json +7 -7
  36. package/data/packages/scroll.json +10 -10
  37. package/data/packages/sentinel.json +4 -4
  38. package/data/packages/sigil.json +6 -6
  39. package/data/packages/sourcerer.json +4 -4
  40. package/data/packages/spell.json +9 -9
  41. package/data/packages/tandem.json +5 -5
  42. package/data/packages/tavern.json +12 -12
  43. package/data/packages/tempo.json +4 -4
  44. package/data/packages/vault.json +13 -13
  45. package/data/packages/ward.json +9 -9
  46. package/data/packages/wayfinder.json +17 -17
  47. package/data/refine.json +259 -87
  48. package/data/search.json +246 -246
  49. package/dist/http.js +2 -2
  50. package/dist/http.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/refine.d.ts +1 -1
  54. package/dist/refine.js +1 -1
  55. package/dist/server.d.ts +1 -1
  56. package/dist/server.js +1 -1
  57. package/dist/tools/refine.js +1 -1
  58. package/package.json +2 -2
  59. package/skills/vielzeug/SKILL.md +57 -57
@@ -1,23 +1,23 @@
1
1
  {
2
- "apiSource": "import {\n createMeshGuest,\n createMeshHost,\n type MeshAnswer,\n type MeshInvitation,\n MeshPairingError,\n type MeshPeer,\n type MeshProtocol,\n type MeshRtcFactory,\n meshQrCodec,\n} from '@vielzeug/mesh';\n\nimport { TavernError, TavernPairingError } from './errors';\n\nexport { TavernError, TavernPairingError };\n\n/**\n * Reclassifies a mesh pairing failure as the tavern-level user mistake it is:\n * the code the consumer pasted could not be used. The message is preserved and\n * the original error is chained as `cause`.\n */\nfunction toPairingError(error: unknown): TavernPairingError {\n const message = error instanceof Error ? error.message : 'That code could not be read.';\n return new TavernPairingError(message, { cause: error });\n}\n\n/**\n * Table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests\n * mirror it and forward every command through the host's command table, so guest actions follow\n * exactly the same code path as host actions.\n *\n * The consumer supplies the seams — which commands exist, how subject state is read and mounted,\n * how notices cross between the wire and the local UI. Tavern owns the pairing, the protocol,\n * command validation, snapshot broadcasting, notice relaying, and presence.\n */\n\n/** What the host can do on behalf of a guest. */\nexport interface TavernCommands {\n /** Applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n /** Whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n}\n\n/**\n * How the host reads and watches the subject it is sharing. All three close over whatever\n * subject state the consumer owns — the host routes by `subjectId`, not by a subject object.\n */\nexport interface TavernSubjects {\n /** Subscribes to local changes of the hosted subject — the re-broadcast trigger. */\n onChanged(listener: () => void): () => void;\n /** Subscribes to the hosted subject's removal — hosting ends when it fires. */\n onRemoved(listener: () => void): () => void;\n /** Reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n}\n\n/**\n * Notice relay between host and guests. Notices cross the wire as opaque values — each client\n * translates locally.\n *\n * **Echo guard**: a tab that both hosts and guests must guard against re-broadcasting a\n * wire-originated notice back to its own guests — `fromWire` emits locally, the host's local\n * subscription picks it up, and without a guard it would loop. The consumer owns this guard\n * because only they know their local event system; one boolean set during `fromWire` and\n * checked in `toWire` is sufficient.\n */\nexport interface TavernNotices {\n /** Re-emits a received wire notice locally. */\n fromWire(wire: unknown): void;\n /** Serializes a local notice for the wire; null skips this one. */\n toWire(notice: unknown): unknown | null;\n}\n\ninterface WireProtocol extends MeshProtocol {\n toGuest: {\n notice: unknown;\n rejected: { commandId: string; message: string };\n snapshot: unknown;\n };\n toHost: {\n command: { args: unknown[]; id: string; name: string; subjectId: string };\n };\n}\n\n/** Snapshots can be large; the default mesh frame cap is too tight for whole documents. */\nconst DEFAULT_MAX_MESSAGE_BYTES = 512 * 1024;\n\nconst commandId = (): string => `cmd-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;\n\n/** Runtime guard for the wire command shape — the protocol is Tavern's, so Tavern validates it. */\nfunction parseWireCommand(raw: unknown): { args: unknown[]; id: string; name: string; subjectId: string } | null {\n if (typeof raw !== 'object' || raw === null) return null;\n const { args, id, name, subjectId } = raw as Record<string, unknown>;\n if (typeof id !== 'string' || typeof name !== 'string' || typeof subjectId !== 'string' || !Array.isArray(args)) {\n return null;\n }\n return { args, id, name, subjectId };\n}\n\n// ---------------------------------------------------------------------------\n// Host\n// ---------------------------------------------------------------------------\n\nexport interface TavernHostOptions {\n commands: TavernCommands;\n notices?: TavernNotices;\n /** Hosting ended — the subject was removed or the host was disposed. Fires exactly once. */\n onEnded?(): void;\n onPeerJoined?(peer: MeshPeer): void;\n onPeerLeft?(peer: MeshPeer): void;\n onPeersChanged?(peers: MeshPeer[]): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n /** The id of the subject being hosted — guest commands targeting anything else reject. */\n subjectId: string;\n subjects: TavernSubjects;\n}\n\nexport interface TavernHost {\n /** Consumes a guest's answer code; resolves with the peer once the channel is open. */\n acceptAnswerText(text: string): Promise<MeshPeer>;\n /** Produces a single-use invitation code, QR-compact when the environment allows. */\n createInvitationText(): Promise<string>;\n /** `AbortSignal` aborted when hosting ends. */\n readonly disposalSignal: AbortSignal;\n /** Stops hosting: closes every channel and detaches all subscriptions. */\n dispose(): void;\n /** Whether hosting has ended — the subject was removed or `dispose()` ran. */\n readonly disposed: boolean;\n kick(peerId: string): void;\n /** Relays one local notice to every guest; the serializer decides what crosses. */\n relayNotice(notice: unknown): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}\n\n/**\n * Starts hosting one subject. Every guest command is validated and applied through the\n * command table; local changes re-broadcast the snapshot (coalesced on a microtask); removal\n * ends the session.\n */\nexport function hostTavern(options: TavernHostOptions): TavernHost {\n const host = createMeshHost<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let closed = false;\n let flushScheduled = false;\n\n const close = (): void => {\n if (closed) return;\n closed = true;\n for (const off of detach.splice(0)) off();\n host.dispose();\n options.onEnded?.();\n };\n\n /** Reads the snapshot for a broadcast; a throwing read warns instead of crashing the host. */\n const readSnapshot = (): unknown => {\n try {\n return options.subjects.snapshot();\n } catch (error) {\n options.onWarning?.(error instanceof Error ? error.message : 'Reading the snapshot failed.');\n return null;\n }\n };\n\n /** Coalesces broadcasts on a microtask so bursts of local changes ship one snapshot. */\n const broadcastSnapshot = (): void => {\n if (closed || flushScheduled) return;\n flushScheduled = true;\n queueMicrotask(() => {\n flushScheduled = false;\n if (closed) return;\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.broadcast('snapshot', snapshot);\n });\n };\n\n /**\n * Applies a guest command through the command table — the same table the host's own UI\n * calls — so an unknown name, a foreign subject id or a rule violation is all the host\n * can ever reject with.\n */\n const handleGuestCommand = (peerId: string, raw: unknown): void => {\n const command = parseWireCommand(raw);\n if (!command || !options.commands.has(command.name) || command.subjectId !== options.subjectId) {\n host.send(peerId, 'rejected', {\n commandId: command?.id ?? '',\n message: 'The host rejected an unknown or out-of-session command.',\n });\n return;\n }\n try {\n options.commands.apply(command.name, command.args);\n } catch (error) {\n host.send(peerId, 'rejected', {\n commandId: command.id,\n message: error instanceof Error ? error.message : 'The host could not apply the command.',\n });\n }\n };\n\n const detach = [\n host.on('command', ({ peerId, payload }) => handleGuestCommand(peerId, payload)),\n options.subjects.onChanged(broadcastSnapshot),\n options.subjects.onRemoved(close),\n host.tap((event) => {\n if (event.type === 'peer-joined' || event.type === 'peer-left') {\n options.onPeersChanged?.([...host.peers.values()]);\n }\n if (event.type === 'peer-joined') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n options.onPeerJoined?.(event.peer);\n }\n if (event.type === 'peer-left') options.onPeerLeft?.(event.peer);\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(text);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('proof' in decoded)) {\n throw new TavernPairingError('That code is an invitation, not a guest answer.');\n }\n try {\n return await host.acceptAnswer(decoded);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n },\n createInvitationText: async () => meshQrCodec.encode(await host.createInvitation()),\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n host.kick(peerId);\n },\n relayNotice: (notice: unknown): void => {\n if (closed) return;\n const wire = options.notices ? options.notices.toWire(notice) : null;\n if (wire !== null && wire !== undefined) host.broadcast('notice', wire);\n },\n [Symbol.dispose]() {\n close();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Guest\n// ---------------------------------------------------------------------------\n\nexport interface TavernGuestOptions<Mounted> {\n /** The invitation code the host produced. */\n invitationText: string;\n /**\n * Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives\n * as parsed JSON — validate before trusting it.\n */\n mount(snapshot: unknown): Mounted | null;\n /** The guest's display name on the channel. */\n name: string;\n notices?: TavernNotices;\n /** The channel ended after joining; the mounted value is passed for unmounting. */\n onEnded?(subject: Mounted): void;\n /** The host never accepted the answer in time. */\n onFailed?(reason: string): void;\n /** The mounted value once the first snapshot arrives. */\n onJoined?(subject: Mounted): void;\n /** The host rejected a forwarded command. */\n onRejected?(message: string): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n}\n\nexport interface TavernGuest {\n /** `AbortSignal` aborted when the session ends. */\n readonly disposalSignal: AbortSignal;\n /** Leaves the session and drops the channel. */\n dispose(): void;\n /** Whether the session has ended — the channel dropped or `dispose()` ran. */\n readonly disposed: boolean;\n /**\n * Forwards a command to the host. The subject id is the consumer's routing key — the host\n * rejects commands that do not name the subject it is hosting.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}\n\n/**\n * Joins a session as a guest: consumes the host's invitation and returns the answer code to\n * show back. The session goes live once the host accepts the answer and the first snapshot\n * mounts; disposing the guest (or the channel dropping) ends it exactly once.\n */\nexport async function joinTavern<Mounted>(\n options: TavernGuestOptions<Mounted>,\n): Promise<{ answerText: string; guest: TavernGuest }> {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(options.invitationText);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('secret' in decoded)) {\n throw new TavernPairingError('That code is not a session invitation.');\n }\n\n const guest = createMeshGuest<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let mounted: Mounted | null = null;\n let ended = false;\n\n /** Ends the session exactly once — from the channel dropping or an explicit dispose. */\n const endSession = (): void => {\n if (ended) return;\n ended = true;\n const subject = mounted;\n mounted = null;\n for (const off of detach.splice(0)) off();\n guest.dispose();\n if (subject !== null) options.onEnded?.(subject);\n };\n\n const detach = [\n guest.on('snapshot', ({ payload }) => {\n const subject = options.mount(payload);\n if (subject === null || ended) return;\n if (mounted === null) {\n mounted = subject;\n options.onJoined?.(subject);\n }\n }),\n guest.on('notice', ({ payload }) => {\n options.notices?.fromWire(payload);\n }),\n guest.on('rejected', ({ payload }) => {\n options.onRejected?.(payload.message || 'The host rejected the action.');\n }),\n guest.tap((event) => {\n if (event.type !== 'status-change' && event.type !== 'peer-status-change') return;\n if (event.type === 'peer-status-change' && event.peerId !== guest.host?.id) return;\n if (event.status === 'failed' && mounted === null && !ended) {\n options.onFailed?.('The host did not accept the answer in time.');\n }\n if (event.status === 'disconnected' || event.status === 'failed') endSession();\n }),\n guest.tap((event) => {\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n try {\n const answer = await guest.acceptInvitation(decoded, { name: options.name });\n return {\n answerText: await meshQrCodec.encode(answer),\n guest: {\n get disposalSignal() {\n return guest.disposalSignal;\n },\n dispose: endSession,\n get disposed() {\n return ended;\n },\n sendCommand: (subjectId: string, name: string, args: readonly unknown[]): void => {\n if (ended) throw new TavernError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n [Symbol.dispose]() {\n endSession();\n },\n },\n };\n } catch (error) {\n // A failed join must not leave the node and its subscriptions behind.\n endSession();\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n}\n",
2
+ "apiSource": "import {\n createMeshGuest,\n createMeshHost,\n type MeshAnswer,\n type MeshInvitation,\n MeshPairingError,\n type MeshPeer,\n type MeshProtocol,\n type MeshRtcFactory,\n meshQrCodec,\n} from '@vielzeug/mesh';\n\nimport { TavernError, TavernPairingError } from './errors';\n\nexport { TavernError, TavernPairingError };\n\n/**\n * Reclassifies a mesh pairing failure as the tavern-level user mistake it is:\n * the code the consumer pasted could not be used. The message is preserved and\n * the original error is chained as `cause`.\n */\nfunction toPairingError(error: unknown): TavernPairingError {\n const message = error instanceof Error ? error.message : 'That code could not be read.';\n return new TavernPairingError(message, { cause: error });\n}\n\n/**\n * Table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests\n * mirror it and forward every command through the host's command table, so guest actions follow\n * exactly the same code path as host actions.\n *\n * The consumer supplies the seams, which commands exist, how subject state is read and mounted,\n * how notices cross between the wire and the local UI. Tavern owns the pairing, the protocol,\n * command validation, snapshot broadcasting, notice relaying, and presence.\n */\n\n/** What the host can do on behalf of a guest. */\nexport interface TavernCommands {\n /** Applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n /** Whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n}\n\n/**\n * How the host reads and watches the subject it is sharing. All three close over whatever\n * subject state the consumer owns: the host routes by `subjectId`, not by a subject object.\n */\nexport interface TavernSubjects {\n /** Subscribes to local changes of the hosted subject: the re-broadcast trigger. */\n onChanged(listener: () => void): () => void;\n /** Subscribes to the hosted subject's removal: hosting ends when it fires. */\n onRemoved(listener: () => void): () => void;\n /** Reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n}\n\n/**\n * Notice relay between host and guests. Notices cross the wire as opaque values: each client\n * translates locally.\n *\n * **Echo guard**: a tab that both hosts and guests must guard against re-broadcasting a\n * wire-originated notice back to its own guests: `fromWire` emits locally, the host's local\n * subscription picks it up, and without a guard it would loop. The consumer owns this guard\n * because only they know their local event system; one boolean set during `fromWire` and\n * checked in `toWire` is sufficient.\n */\nexport interface TavernNotices {\n /** Re-emits a received wire notice locally. */\n fromWire(wire: unknown): void;\n /** Serializes a local notice for the wire; null skips this one. */\n toWire(notice: unknown): unknown | null;\n}\n\ninterface WireProtocol extends MeshProtocol {\n toGuest: {\n notice: unknown;\n rejected: { commandId: string; message: string };\n snapshot: unknown;\n };\n toHost: {\n command: { args: unknown[]; id: string; name: string; subjectId: string };\n };\n}\n\n/** Snapshots can be large; the default mesh frame cap is too tight for whole documents. */\nconst DEFAULT_MAX_MESSAGE_BYTES = 512 * 1024;\n\nconst commandId = (): string => `cmd-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;\n\n/** Runtime guard for the wire command shape: the protocol is Tavern's, so Tavern validates it. */\nfunction parseWireCommand(raw: unknown): { args: unknown[]; id: string; name: string; subjectId: string } | null {\n if (typeof raw !== 'object' || raw === null) return null;\n const { args, id, name, subjectId } = raw as Record<string, unknown>;\n if (typeof id !== 'string' || typeof name !== 'string' || typeof subjectId !== 'string' || !Array.isArray(args)) {\n return null;\n }\n return { args, id, name, subjectId };\n}\n\n// ---------------------------------------------------------------------------\n// Host\n// ---------------------------------------------------------------------------\n\nexport interface TavernHostOptions {\n commands: TavernCommands;\n notices?: TavernNotices;\n /** Hosting ended: the subject was removed or the host was disposed. Fires exactly once. */\n onEnded?(): void;\n onPeerJoined?(peer: MeshPeer): void;\n onPeerLeft?(peer: MeshPeer): void;\n onPeersChanged?(peers: MeshPeer[]): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n /** The id of the subject being hosted: guest commands targeting anything else reject. */\n subjectId: string;\n subjects: TavernSubjects;\n}\n\nexport interface TavernHost {\n /** Consumes a guest's answer code; resolves with the peer once the channel is open. */\n acceptAnswerText(text: string): Promise<MeshPeer>;\n /** Produces a single-use invitation code, QR-compact when the environment allows. */\n createInvitationText(): Promise<string>;\n /** `AbortSignal` aborted when hosting ends. */\n readonly disposalSignal: AbortSignal;\n /** Stops hosting: closes every channel and detaches all subscriptions. */\n dispose(): void;\n /** Whether hosting has ended: the subject was removed or `dispose()` ran. */\n readonly disposed: boolean;\n kick(peerId: string): void;\n /** Relays one local notice to every guest; the serializer decides what crosses. */\n relayNotice(notice: unknown): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}\n\n/**\n * Starts hosting one subject. Every guest command is validated and applied through the\n * command table; local changes re-broadcast the snapshot (coalesced on a microtask); removal\n * ends the session.\n */\nexport function hostTavern(options: TavernHostOptions): TavernHost {\n const host = createMeshHost<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let closed = false;\n let flushScheduled = false;\n\n const close = (): void => {\n if (closed) return;\n closed = true;\n for (const off of detach.splice(0)) off();\n host.dispose();\n options.onEnded?.();\n };\n\n /** Reads the snapshot for a broadcast; a throwing read warns instead of crashing the host. */\n const readSnapshot = (): unknown => {\n try {\n return options.subjects.snapshot();\n } catch (error) {\n options.onWarning?.(error instanceof Error ? error.message : 'Reading the snapshot failed.');\n return null;\n }\n };\n\n /** Coalesces broadcasts on a microtask so bursts of local changes ship one snapshot. */\n const broadcastSnapshot = (): void => {\n if (closed || flushScheduled) return;\n flushScheduled = true;\n queueMicrotask(() => {\n flushScheduled = false;\n if (closed) return;\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.broadcast('snapshot', snapshot);\n });\n };\n\n /**\n * Applies a guest command through the command table: the same table the host's own UI\n * calls, so an unknown name, a foreign subject id or a rule violation is all the host\n * can ever reject with.\n */\n const handleGuestCommand = (peerId: string, raw: unknown): void => {\n const command = parseWireCommand(raw);\n if (!command || !options.commands.has(command.name) || command.subjectId !== options.subjectId) {\n host.send(peerId, 'rejected', {\n commandId: command?.id ?? '',\n message: 'The host rejected an unknown or out-of-session command.',\n });\n return;\n }\n try {\n options.commands.apply(command.name, command.args);\n } catch (error) {\n host.send(peerId, 'rejected', {\n commandId: command.id,\n message: error instanceof Error ? error.message : 'The host could not apply the command.',\n });\n }\n };\n\n const detach = [\n host.on('command', ({ peerId, payload }) => handleGuestCommand(peerId, payload)),\n options.subjects.onChanged(broadcastSnapshot),\n options.subjects.onRemoved(close),\n host.tap((event) => {\n if (event.type === 'peer-joined' || event.type === 'peer-left') {\n options.onPeersChanged?.([...host.peers.values()]);\n }\n if (event.type === 'peer-joined') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n options.onPeerJoined?.(event.peer);\n }\n if (event.type === 'peer-left') options.onPeerLeft?.(event.peer);\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(text);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('proof' in decoded)) {\n throw new TavernPairingError('That code is an invitation, not a guest answer.');\n }\n try {\n return await host.acceptAnswer(decoded);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n },\n createInvitationText: async () => meshQrCodec.encode(await host.createInvitation()),\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n host.kick(peerId);\n },\n relayNotice: (notice: unknown): void => {\n if (closed) return;\n const wire = options.notices ? options.notices.toWire(notice) : null;\n if (wire !== null && wire !== undefined) host.broadcast('notice', wire);\n },\n [Symbol.dispose]() {\n close();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Guest\n// ---------------------------------------------------------------------------\n\nexport interface TavernGuestOptions<Mounted> {\n /** The invitation code the host produced. */\n invitationText: string;\n /**\n * Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives\n * as parsed JSON: validate before trusting it.\n */\n mount(snapshot: unknown): Mounted | null;\n /** The guest's display name on the channel. */\n name: string;\n notices?: TavernNotices;\n /** The channel ended after joining; the mounted value is passed for unmounting. */\n onEnded?(subject: Mounted): void;\n /** The host never accepted the answer in time. */\n onFailed?(reason: string): void;\n /** The mounted value once the first snapshot arrives. */\n onJoined?(subject: Mounted): void;\n /** The host rejected a forwarded command. */\n onRejected?(message: string): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n}\n\nexport interface TavernGuest {\n /** `AbortSignal` aborted when the session ends. */\n readonly disposalSignal: AbortSignal;\n /** Leaves the session and drops the channel. */\n dispose(): void;\n /** Whether the session has ended: the channel dropped or `dispose()` ran. */\n readonly disposed: boolean;\n /**\n * Forwards a command to the host. The subject id is the consumer's routing key: the host\n * rejects commands that do not name the subject it is hosting.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}\n\n/**\n * Joins a session as a guest: consumes the host's invitation and returns the answer code to\n * show back. The session goes live once the host accepts the answer and the first snapshot\n * mounts; disposing the guest (or the channel dropping) ends it exactly once.\n */\nexport async function joinTavern<Mounted>(\n options: TavernGuestOptions<Mounted>,\n): Promise<{ answerText: string; guest: TavernGuest }> {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(options.invitationText);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('secret' in decoded)) {\n throw new TavernPairingError('That code is not a session invitation.');\n }\n\n const guest = createMeshGuest<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let mounted: Mounted | null = null;\n let ended = false;\n\n /** Ends the session exactly once: from the channel dropping or an explicit dispose. */\n const endSession = (): void => {\n if (ended) return;\n ended = true;\n const subject = mounted;\n mounted = null;\n for (const off of detach.splice(0)) off();\n guest.dispose();\n if (subject !== null) options.onEnded?.(subject);\n };\n\n const detach = [\n guest.on('snapshot', ({ payload }) => {\n const subject = options.mount(payload);\n if (subject === null || ended) return;\n if (mounted === null) {\n mounted = subject;\n options.onJoined?.(subject);\n }\n }),\n guest.on('notice', ({ payload }) => {\n options.notices?.fromWire(payload);\n }),\n guest.on('rejected', ({ payload }) => {\n options.onRejected?.(payload.message || 'The host rejected the action.');\n }),\n guest.tap((event) => {\n if (event.type !== 'status-change' && event.type !== 'peer-status-change') return;\n if (event.type === 'peer-status-change' && event.peerId !== guest.host?.id) return;\n if (event.status === 'failed' && mounted === null && !ended) {\n options.onFailed?.('The host did not accept the answer in time.');\n }\n if (event.status === 'disconnected' || event.status === 'failed') endSession();\n }),\n guest.tap((event) => {\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n try {\n const answer = await guest.acceptInvitation(decoded, { name: options.name });\n return {\n answerText: await meshQrCodec.encode(answer),\n guest: {\n get disposalSignal() {\n return guest.disposalSignal;\n },\n dispose: endSession,\n get disposed() {\n return ended;\n },\n sendCommand: (subjectId: string, name: string, args: readonly unknown[]): void => {\n if (ended) throw new TavernError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n [Symbol.dispose]() {\n endSession();\n },\n },\n };\n } catch (error) {\n // A failed join must not leave the node and its subscriptions behind.\n endSession();\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n}\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Tavern\ndescription: \"Table sessions over mesh — host-owned state replication with guest command forwarding\"\npackage: tavern\ncategory: utilities\nkeywords: [session, host, guest, replication, mesh, commands, snapshots, collaboration, peer-to-peer]\nexports: [hostTavern, joinTavern, TavernHost, TavernGuest, TavernCommands, TavernNotices, TavernError, TavernPairingError]\nrelated: [mesh, ledger, herald]\nenvironments: [browser, node]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"tavern\" />\n\n## Why Tavern?\n\nPeer-to-peer apps share one arrangement problem: the canonical state of something — a document, a dashboard, a record — lives on exactly one device, while every other participant should see it and be able to change it from their own. Tavern turns that arrangement into a session: **one host owns the canonical state, guests mirror it, and every guest command is forwarded through the host's own command table** — so remote actions follow exactly the same code path as local ones.\n\n```ts\n// Before: hand-rolled session orchestration — pairing, protocol validation,\n// command routing, snapshot broadcasting, notice relaying, presence tracking.\nconst host = createMeshHost({ /* ... */ });\nhost.on('command', ({ peerId, payload }) => {\n const command = mySchema.safeParse(payload);\n if (!command.success) { host.send(peerId, 'rejected', { /* ... */ }); return; }\n try { applySubjectCommand(command.data.name, subject, command.data.args); }\n catch (error) { host.send(peerId, 'rejected', { /* ... */ }); }\n});\nbus.on('subject:updated', () => { host.broadcast('snapshot', snapshotFor(subject)); });\n// ... 40+ more lines of plumbing\n\n// After: tavern owns the protocol, the validation, the broadcasting.\nconst host = hostTavern({\n commands: { apply: (name, args) => store.apply(name, args), has: (name) => name in store },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => store.onUpdated('doc-1', listener),\n onRemoved: (listener) => store.onRemoved('doc-1', listener),\n snapshot: () => store.read('doc-1'),\n },\n});\n```\n\n| Feature | Tavern | Raw mesh | Socket.io rooms |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"tavern\" type=\"size\" /> | <PackageInfo package=\"mesh\" type=\"size\" /> | — |\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| Host-authoritative commands | <ore-icon name=\"check\" size=\"16\"></ore-icon> — same table as local UI | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> — wire it yourself | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> — wire it yourself |\n| Snapshot replication | <ore-icon name=\"check\" size=\"16\"></ore-icon> — microtask-coalesced | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Out-of-band pairing (QR / copy-paste) | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> — needs a server |\n| Notice relaying | <ore-icon name=\"check\" size=\"16\"></ore-icon> — consumer-defined wire shape | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> — server-mediated |\n\n<div class=\"decision-callout\">\n\n**Use Tavern when** one device should own the canonical state of something — a shared document, a live dashboard, a record under review — and every other device should mirror it and send commands back through the host's own command table, which stays the single validation path.\n\n**Consider raw `@vielzeug/mesh` when** you need P2P messaging without the host-authoritative replication model — chat, whiteboards, file transfer, or custom topologies.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/tavern\n```\n\n```sh [npm]\nnpm install @vielzeug/tavern\n```\n\n```sh [yarn]\nyarn add @vielzeug/tavern\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { hostTavern, joinTavern } from '@vielzeug/tavern';\n\n// Host: pick a subject, wire the command table and snapshot access.\nconst host = hostTavern({\n commands: {\n apply: (name, args) => myStore.apply(name, args),\n has: (name) => name in myStore,\n },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\n\n// Produce a single-use invitation; deliver it out-of-band (QR, copy/paste).\nconst invitation = await host.createInvitationText();\n\n// Guest: consume the invitation, produce an answer for the host.\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => parseAndMount(snapshot),\n name: 'Alex',\n});\nawait host.acceptAnswerText(answerText);\n\n// The guest's commands run through the host's own table.\nguest.sendCommand('doc-1', 'rename', ['Quarterly report']);\n\n// Cleanup — hosting ends for every guest; onEnded fires exactly once on both sides.\nhost.dispose();\nguest.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`hostTavern`** — host one subject: pairing, protocol validation, command application, coalesced snapshot broadcasts\n- **`joinTavern`** — join as a guest: consume the invitation, produce the answer, mirror the host's snapshots\n- **`TavernCommands`** — the host's command table: the same object the host's own UI calls, so remote actions cannot bypass validation\n- **`TavernNotices`** — consumer-defined notice serialization: catalog keys cross the wire, each client translates locally\n- **Coalesced snapshots** — bursts of local changes ship one snapshot per microtask, not one per change\n- **`onEnded` fires exactly once** — on both handles: channel drops, subject removal, and explicit disposal share a single idempotent cleanup path\n- **Typed errors** — `TavernPairingError` covers every unusable pasted code with the mesh failure chained as `cause`\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\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Mesh](/mesh/) — the P2P transport underneath: WebRTC pairing, data channels, QR-optimized codecs.\n- [Ledger](/ledger/) — undo/redo history for the commands the host applies.\n- [Herald](/herald/) — typed event bus for the local notice stream tavern relays.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Tavern — API Reference\ndescription: Public API of @vielzeug/tavern.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `hostTavern()` | Host a subject: pairing, command validation, snapshot broadcast | Sync factory | Disposing ends hosting for every guest |\n| `joinTavern()` | Join a session: consume invitation, produce answer | Async | The session lives only after the host accepts and the first snapshot mounts |\n| `TavernHost` | Host handle: invitation, answer acceptance, kick, notice relay | — | `dispose()` is idempotent |\n| `TavernGuest` | Guest handle: command forwarding, disposal | — | `dispose()` fires `onEnded` exactly once |\n| `TavernCommands` | The host's command table | — | Unknown names and foreign subject ids reject without applying |\n| `TavernSubjects` | Snapshot reading, change and removal subscriptions | — | `snapshot` returning `null` skips the broadcast |\n| `TavernNotices` | Wire serialization of local notices | — | `toWire` returning `null` skips that notice |\n| `TavernError` | Base error for every Tavern failure | — | — |\n| `TavernPairingError` | The consumer pasted the wrong kind of pairing code | — | — |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/tavern` | Complete public Tavern API |\n\n## `hostTavern(options)`\n\nCreates a mesh host for one subject. Guest commands are validated against the wire shape (Tavern's own protocol), checked against `commands.has` and the `subjectId`, then applied through `commands.apply` — throwing rejects the guest with the error's message. Local changes (via `subjects.onChanged`) re-broadcast `subjects.snapshot()` — coalesced on a microtask; `subjects.onRemoved` ends hosting. Peers are reported through `onPeersChanged`, `onPeerJoined`, and `onPeerLeft`; hosting ending — from removal or `dispose()` — fires `onEnded` exactly once. `relayNotice` serializes through `notices.toWire` and broadcasts to every guest.\n\n**Returns:** `TavernHost` — the host handle.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `commands` | `TavernCommands` | The host's command table — the same object the host's own UI calls |\n| `notices?` | `TavernNotices` | Notice relay; omit to disable notice broadcasting |\n| `onEnded?` | `() => void` | Hosting ended — the subject was removed or `dispose()` ran. Fires exactly once |\n| `onPeersChanged?` | `(peers: MeshPeer[]) => void` | The full peer list, whenever it changes |\n| `onPeerJoined?` | `(peer: MeshPeer) => void` | A peer joined |\n| `onPeerLeft?` | `(peer: MeshPeer) => void` | A peer left |\n| `onWarning?` | `(message: string) => void` | A transport-level warning |\n| `rtc?` | `MeshRtcFactory` | WebRTC factory — the injection point for tests and non-browser runtimes |\n| `subjectId` | `string` | The id of the hosted subject — guest commands targeting anything else reject |\n| `subjects` | `TavernSubjects` | Snapshot reading, change and removal subscriptions |\n\n**Example**\n\n```ts\nimport { hostTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n commands: {\n apply: (name, args) => myStore.apply(name, args),\n has: (name) => name in myStore,\n },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\n```\n\n### `TavernHost` members\n\n| Member | Returns | Description |\n| --- | --- | --- |\n| `acceptAnswerText(text)` | `Promise<MeshPeer>` | Consumes a guest's answer code; resolves with the peer once the channel opens. Throws `TavernPairingError` for any unusable code — wrong kind, malformed, expired — with the underlying error as `cause` |\n| `createInvitationText()` | `Promise<string>` | Produces a single-use invitation code, QR-compact when the environment allows |\n| `disposalSignal` | `AbortSignal` | Aborted when hosting ends |\n| `dispose()` | `void` | Stops hosting: closes every channel and detaches all subscriptions. Idempotent; fires `onEnded` exactly once |\n| `disposed` | `boolean` | Whether hosting has ended |\n| `kick(peerId)` | `void` | Disconnects a peer; the guest observes a disconnect |\n| `relayNotice(notice)` | `void` | Relays one local notice to every guest; the serializer decides what crosses |\n| `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations |\n\n---\n\n## `joinTavern(options)`\n\nConsumes an invitation and returns `{ answerText, guest }`. The guest mounts snapshots through `mount(snapshot)` — the first successful mount fires `onJoined`; wire notices are relayed through `notices.fromWire`; rejections through `onRejected`. Channel failures before the first mount fire `onFailed` (once); afterwards, a disconnect or `guest.dispose()` fires `onEnded` with the mounted subject exactly once. `guest.sendCommand` forwards a command to the host with the subject id the consumer routes by. An unusable invitation — wrong kind, malformed, expired — rejects with `TavernPairingError` and leaves no node or subscriptions behind.\n\n**Returns:** `Promise<{ answerText: string; guest: TavernGuest }>` — the answer code to show back and the guest handle.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `invitationText` | `string` | The invitation code the host produced |\n| `mount` | `(snapshot: unknown) => Mounted \\| null` | Mounts a received snapshot; the mounted value, or `null` to ignore |\n| `name` | `string` | The guest's display name on the channel |\n| `notices?` | `TavernNotices` | Notice relay; wire notices re-emit locally through `fromWire` |\n| `onEnded?` | `(subject: Mounted) => void` | The channel ended after joining; the mounted value is passed for unmounting |\n| `onFailed?` | `(reason: string) => void` | The host never accepted the answer in time |\n| `onJoined?` | `(subject: Mounted) => void` | The mounted value once the first snapshot arrives |\n| `onRejected?` | `(message: string) => void` | The host rejected a forwarded command |\n| `onWarning?` | `(message: string) => void` | A transport-level warning |\n| `rtc?` | `MeshRtcFactory` | WebRTC factory — the injection point for tests and non-browser runtimes |\n\n**Example**\n\n```ts\nimport { joinTavern } from '@vielzeug/tavern';\n\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => sanitizeAndMount(snapshot),\n name: 'Alex',\n onEnded: (subject) => unmountMirror(subject.id),\n onJoined: (subject) => mountMirror(subject),\n});\n\nguest.sendCommand('doc-1', 'rename', ['New name']);\nguest.dispose();\n```\n\n### `TavernGuest` members\n\n| Member | Returns | Description |\n| --- | --- | --- |\n| `disposalSignal` | `AbortSignal` | Aborted when the session ends |\n| `dispose()` | `void` | Leaves the session and drops the channel. Fires `onEnded` exactly once |\n| `disposed` | `boolean` | Whether the session has ended — the channel dropped or `dispose()` ran |\n| `sendCommand(subjectId, name, args)` | `void` | Forwards a command to the host. Throws `TavernError` if the session has ended |\n| `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations |\n\n---\n\n## Types\n\n```ts\n/** What the host can do on behalf of a guest. */\ninterface TavernCommands {\n /** Whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n /** Applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n}\n\n/**\n * How the host reads and watches the subject it is sharing. All three close over\n * whatever subject state the consumer owns — the host routes by `subjectId`,\n * not by a subject object.\n */\ninterface TavernSubjects {\n /** Reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n /** Subscribes to local changes of the hosted subject — the re-broadcast trigger. */\n onChanged(listener: () => void): () => void;\n /** Subscribes to the hosted subject's removal — hosting ends when it fires. */\n onRemoved(listener: () => void): () => void;\n}\n\n/**\n * Notice relay between host and guests. Notices cross the wire as opaque\n * values — each client translates locally.\n */\ninterface TavernNotices {\n /** Serializes a local notice for the wire; null skips this one. */\n toWire(notice: unknown): unknown | null;\n /** Re-emits a received wire notice locally. */\n fromWire(wire: unknown): void;\n}\n\n/** Options for `hostTavern`. */\ninterface TavernHostOptions {\n commands: TavernCommands;\n notices?: TavernNotices;\n /** Hosting ended — the subject was removed or `dispose()` ran. Fires exactly once. */\n onEnded?(): void;\n onPeersChanged?(peers: MeshPeer[]): void;\n onPeerJoined?(peer: MeshPeer): void;\n onPeerLeft?(peer: MeshPeer): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n subjectId: string;\n subjects: TavernSubjects;\n}\n\n/** Options for `joinTavern`. */\ninterface TavernGuestOptions<Mounted> {\n invitationText: string;\n /** Mounts a received snapshot; the mounted value, or null to ignore. */\n mount(snapshot: unknown): Mounted | null;\n name: string;\n notices?: TavernNotices;\n onEnded?(subject: Mounted): void;\n onFailed?(reason: string): void;\n onJoined?(subject: Mounted): void;\n onRejected?(message: string): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n}\n\n/** Host handle returned by `hostTavern`. */\ninterface TavernHost {\n acceptAnswerText(text: string): Promise<MeshPeer>;\n createInvitationText(): Promise<string>;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n kick(peerId: string): void;\n relayNotice(notice: unknown): void;\n [Symbol.dispose](): void;\n}\n\n/** Guest handle returned by `joinTavern`. */\ninterface TavernGuest {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n [Symbol.dispose](): void;\n}\n```\n\n## Errors\n\n| Error | Triggered by | Notable properties |\n| --- | --- | --- |\n| `TavernError` | Base class for every Tavern failure — catch this to handle all Tavern errors in one branch | — |\n| `TavernPairingError` | A pairing code the consumer pasted could not be used — wrong kind, malformed, expired, or refused by the host. The mesh-level failure is chained as `cause` | `cause` |\n\n`TavernPairingError` extends `TavernError`. Every user-input pairing mistake — a garbage code, the wrong code kind, an expired invitation, a refused answer — surfaces as `TavernPairingError` from `acceptAnswerText` and `joinTavern`; transport-level failures (timeouts, connection errors) propagate unchanged. `TavernGuest.sendCommand` throws `TavernError` when the session has already ended.\n",
6
- "usage": "---\ntitle: Tavern — Usage Guide\ndescription: How to use @vielzeug/tavern.\n---\n\n[[toc]]\n\n## Basic Usage\n\nHost one subject per session; guests join through the invitation/answer pair. Both sides share one command table, so remote actions run the same code as local ones.\n\n```ts\nimport { hostTavern, joinTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n commands: { apply: (name, args) => myStore.apply(name, args), has: (name) => name in myStore },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\nconst invitation = await host.createInvitationText();\n\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => parseAndMount(snapshot),\n name: 'Alex',\n});\nawait host.acceptAnswerText(answerText);\n\nguest.sendCommand('doc-1', 'rename', ['Quarterly report']);\n```\n\n## Host a Subject\n\nThe host validates every guest command through the command table and broadcasts the snapshot on every local change — coalesced on a microtask, so bursts of changes ship one snapshot, not many. `onEnded` fires exactly once when hosting ends, whether the subject was removed or the host was disposed — reset your session UI there instead of intercepting your own `onRemoved` seam.\n\n```ts\nimport { hostTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n commands: {\n apply: (name, args) => myStore.apply(name, args),\n has: (name) => name in myStore,\n },\n onEnded: () => updateSessionUi(), // hosting ended — subject removed or disposed\n onPeersChanged: (peers) => updatePeerList(peers),\n onPeerJoined: (peer) => toast(`${peer.name ?? 'A guest'} joined`),\n onPeerLeft: (peer) => toast(`${peer.name ?? 'A guest'} left`),\n onWarning: (message) => log.warn(message),\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\n\nconst invitation = await host.createInvitationText();\n// Show the invitation (QR or copy/paste); the guest returns an answer code.\nawait host.acceptAnswerText(answerCode);\n\n// Kick a peer; the guest observes a disconnect and fires onEnded.\nhost.kick(peerId);\n\nhost.dispose(); // stop hosting — every guest disconnects\n```\n\n## Join as a Guest\n\nJoining consumes the invitation and produces the answer code to show back. The session goes live once the host accepts the answer and the first snapshot arrives.\n\n```ts\nimport { joinTavern } from '@vielzeug/tavern';\n\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => sanitizeAndMount(snapshot),\n name: 'Alex',\n onEnded: (subject) => unmountMirror(subject.id),\n onFailed: (reason) => showError(reason),\n onJoined: (subject) => mountMirror(subject),\n onRejected: (message) => showToast(message),\n});\n\nguest.sendCommand('doc-1', 'rename', ['New name']);\nguest.dispose(); // leave; onEnded fires with the mounted subject exactly once\n```\n\nThe snapshot arrives as parsed JSON — `mount` receives `unknown` and should validate before trusting it. Returning `null` ignores the snapshot; returning the mounted value fires `onJoined` (first time only) and passes it to `onEnded` when the session ends.\n\n## Relay Notices\n\nGive both sides a notice seam — catalog keys rather than prose, so each client translates locally — and the host relays its own notices to every guest.\n\n```ts\nimport type { TavernNotices } from '@vielzeug/tavern';\n\nconst notices: TavernNotices = {\n fromWire: (wire) => bus.emit('notify', wire),\n toWire: (notice) => (isLocalNotice(notice) ? toWireShape(notice) : null),\n};\n\n// Host side: forward every local notice through the session.\nhost.relayNotice(localNotice);\n```\n\n**Echo guard**: a tab that both hosts and guests must prevent re-broadcasting a wire-originated notice back to its own guests. One boolean set during `fromWire` and checked in `toWire` is sufficient — the consumer owns this because only they know their local event system.\n\n## Handle Pairing Mistakes\n\nEvery user-input pairing failure surfaces as `TavernPairingError` from both `acceptAnswerText` and `joinTavern` — a garbage code, the wrong code kind, an expired invitation, or an answer the host refuses. Catch it to show a helpful message instead of a raw error; the underlying mesh failure is chained as `cause` when you need it.\n\n```ts\nimport { hostTavern, TavernPairingError } from '@vielzeug/tavern';\n\ntry {\n await host.acceptAnswerText(pastedCode);\n} catch (error) {\n if (error instanceof TavernPairingError) {\n showToast(error.message); // \"That code is an invitation, not a guest answer.\"\n } else {\n throw error;\n }\n}\n```\n\n## Working with Other Vielzeug Libraries\n\nTavern builds on `@vielzeug/mesh` for the WebRTC transport, pairing codes, and QR-optimized codecs. You do not interact with mesh directly — tavern owns the protocol — but the `rtc` option accepts a `MeshRtcFactory` for testing or non-browser runtimes, and the peer and status types in callbacks are mesh types.\n\n```ts\nimport type { MeshRtcFactory } from '@vielzeug/mesh';\nimport { hostTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n // ... commands, subjects\n rtc: myTestRtcFactory, // inject an in-memory WebRTC fake\n subjectId: 'doc-1',\n});\n```\n\nPair `@vielzeug/ledger` on the host to give the commands tavern applies an undo history — commands applied through the table land in the ledger like local ones.\n\n## Best Practices\n\n- Keep the command table the same object the host's own UI calls, so guest actions cannot bypass local validation.\n- Return `null` from `snapshot` while the subject is missing — the broadcast simply skips.\n- Reset host-side session UI in `onEnded` — it fires exactly once for subject removal and explicit disposal alike, so you never need to double-wire your own `onRemoved` seam.\n- Treat notices as catalog keys, not prose — each client translates locally.\n- Guard against notice echo if a tab can both host and guest — one boolean in `toWire` is sufficient.\n- Dispose guests and hosts when their screen unmounts; channels also clean up on disconnect through `onEnded`, which fires exactly once on both sides.\n- Catch `TavernPairingError` separately from other errors — it means the pasted code could not be used, not that something is broken.\n",
7
- "examples": "---\ntitle: Tavern — Examples\ndescription: Practical examples and recipes for @vielzeug/tavern.\n---\n\n## Examples\n\n- [Share State Across Devices](./examples/share-state-across-devices.md)\n"
4
+ "index": "---\ntitle: Tavern\ndescription: \"Table sessions over mesh: host-owned state replication with guest command forwarding\"\npackage: tavern\ncategory: utilities\nkeywords: [session, host, guest, replication, mesh, commands, snapshots, collaboration, peer-to-peer]\nexports: [hostTavern, joinTavern, TavernHost, TavernGuest, TavernCommands, TavernNotices, TavernError, TavernPairingError]\nrelated: [mesh, ledger, herald]\nenvironments: [browser, node]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"tavern\" />\n\n## Why Tavern?\n\nPeer-to-peer apps share one arrangement problem: the canonical state of something: a document, a dashboard, a record: lives on exactly one device, while every other participant should see it and be able to change it from their own. Tavern turns that arrangement into a session: **one host owns the canonical state, guests mirror it, and every guest command is forwarded through the host's own command table**, so remote actions follow exactly the same code path as local ones.\n\n```ts\n// Before: hand-rolled session orchestration: pairing, protocol validation,\n// command routing, snapshot broadcasting, notice relaying, presence tracking.\nconst host = createMeshHost({ /* ... */ });\nhost.on('command', ({ peerId, payload }) => {\n const command = mySchema.safeParse(payload);\n if (!command.success) { host.send(peerId, 'rejected', { /* ... */ }); return; }\n try { applySubjectCommand(command.data.name, subject, command.data.args); }\n catch (error) { host.send(peerId, 'rejected', { /* ... */ }); }\n});\nbus.on('subject:updated', () => { host.broadcast('snapshot', snapshotFor(subject)); });\n// ... 40+ more lines of plumbing\n\n// After: tavern owns the protocol, the validation, the broadcasting.\nconst host = hostTavern({\n commands: { apply: (name, args) => store.apply(name, args), has: (name) => name in store },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => store.onUpdated('doc-1', listener),\n onRemoved: (listener) => store.onRemoved('doc-1', listener),\n snapshot: () => store.read('doc-1'),\n },\n});\n```\n\n| Feature | Tavern | Raw mesh | Socket.io rooms |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"tavern\" type=\"size\" /> | <PackageInfo package=\"mesh\" type=\"size\" /> | N/A |\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| Host-authoritative commands | <ore-icon name=\"check\" size=\"16\"></ore-icon>: same table as local UI | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon>: wire it yourself | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon>: wire it yourself |\n| Snapshot replication | <ore-icon name=\"check\" size=\"16\"></ore-icon>: microtask-coalesced | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Out-of-band pairing (QR / copy-paste) | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon>: needs a server |\n| Notice relaying | <ore-icon name=\"check\" size=\"16\"></ore-icon>: consumer-defined wire shape | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon>: server-mediated |\n\n<div class=\"decision-callout\">\n\n**Use Tavern when** one device should own the canonical state of something: a shared document, a live dashboard, a record under review, and every other device should mirror it and send commands back through the host's own command table, which stays the single validation path.\n\n**Consider raw `@vielzeug/mesh` when** you need P2P messaging without the host-authoritative replication model: chat, whiteboards, file transfer, or custom topologies.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/tavern\n```\n\n```sh [npm]\nnpm install @vielzeug/tavern\n```\n\n```sh [yarn]\nyarn add @vielzeug/tavern\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { hostTavern, joinTavern } from '@vielzeug/tavern';\n\n// Host: pick a subject, wire the command table and snapshot access.\nconst host = hostTavern({\n commands: {\n apply: (name, args) => myStore.apply(name, args),\n has: (name) => name in myStore,\n },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\n\n// Produce a single-use invitation; deliver it out-of-band (QR, copy/paste).\nconst invitation = await host.createInvitationText();\n\n// Guest: consume the invitation, produce an answer for the host.\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => parseAndMount(snapshot),\n name: 'Alex',\n});\nawait host.acceptAnswerText(answerText);\n\n// The guest's commands run through the host's own table.\nguest.sendCommand('doc-1', 'rename', ['Quarterly report']);\n\n// Cleanup: hosting ends for every guest; onEnded fires exactly once on both sides.\nhost.dispose();\nguest.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`hostTavern`**: host one subject: pairing, protocol validation, command application, coalesced snapshot broadcasts\n- **`joinTavern`**: join as a guest: consume the invitation, produce the answer, mirror the host's snapshots\n- **`TavernCommands`**: the host's command table: the same object the host's own UI calls, so remote actions cannot bypass validation\n- **`TavernNotices`**: consumer-defined notice serialization: catalog keys cross the wire, each client translates locally\n- **Coalesced snapshots**: bursts of local changes ship one snapshot per microtask, not one per change\n- **`onEnded` fires exactly once**: on both handles: channel drops, subject removal, and explicit disposal share a single idempotent cleanup path\n- **Typed errors**: `TavernPairingError` covers every unusable pasted code with the mesh failure chained as `cause`\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\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Mesh](/mesh/): the P2P transport underneath: WebRTC pairing, data channels, QR-optimized codecs.\n- [Ledger](/ledger/): undo/redo history for the commands the host applies.\n- [Herald](/herald/): typed event bus for the local notice stream tavern relays.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Tavern: API Reference'\ndescription: Public API of @vielzeug/tavern.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `hostTavern()` | Host a subject: pairing, command validation, snapshot broadcast | Sync factory | Disposing ends hosting for every guest |\n| `joinTavern()` | Join a session: consume invitation, produce answer | Async | The session lives only after the host accepts and the first snapshot mounts |\n| `TavernHost` | Host handle: invitation, answer acceptance, kick, notice relay | N/A | `dispose()` is idempotent |\n| `TavernGuest` | Guest handle: command forwarding, disposal | N/A | `dispose()` fires `onEnded` exactly once |\n| `TavernCommands` | The host's command table | N/A | Unknown names and foreign subject ids reject without applying |\n| `TavernSubjects` | Snapshot reading, change and removal subscriptions | N/A | `snapshot` returning `null` skips the broadcast |\n| `TavernNotices` | Wire serialization of local notices | N/A | `toWire` returning `null` skips that notice |\n| `TavernError` | Base error for every Tavern failure | N/A | N/A |\n| `TavernPairingError` | The consumer pasted the wrong kind of pairing code | N/A | N/A |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/tavern` | Complete public Tavern API |\n\n## `hostTavern(options)`\n\nCreates a mesh host for one subject. Guest commands are validated against the wire shape (Tavern's own protocol), checked against `commands.has` and the `subjectId`, then applied through `commands.apply`: throwing rejects the guest with the error's message. Local changes (via `subjects.onChanged`) re-broadcast `subjects.snapshot()`: coalesced on a microtask; `subjects.onRemoved` ends hosting. Peers are reported through `onPeersChanged`, `onPeerJoined`, and `onPeerLeft`; hosting ending: from removal or `dispose()`: fires `onEnded` exactly once. `relayNotice` serializes through `notices.toWire` and broadcasts to every guest.\n\n**Returns:** `TavernHost`: the host handle.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `commands` | `TavernCommands` | The host's command table: the same object the host's own UI calls |\n| `notices?` | `TavernNotices` | Notice relay; omit to disable notice broadcasting |\n| `onEnded?` | `() => void` | Hosting ended: the subject was removed or `dispose()` ran. Fires exactly once |\n| `onPeersChanged?` | `(peers: MeshPeer[]) => void` | The full peer list, whenever it changes |\n| `onPeerJoined?` | `(peer: MeshPeer) => void` | A peer joined |\n| `onPeerLeft?` | `(peer: MeshPeer) => void` | A peer left |\n| `onWarning?` | `(message: string) => void` | A transport-level warning |\n| `rtc?` | `MeshRtcFactory` | WebRTC factory: the injection point for tests and non-browser runtimes |\n| `subjectId` | `string` | The id of the hosted subject: guest commands targeting anything else reject |\n| `subjects` | `TavernSubjects` | Snapshot reading, change and removal subscriptions |\n\n**Example**\n\n```ts\nimport { hostTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n commands: {\n apply: (name, args) => myStore.apply(name, args),\n has: (name) => name in myStore,\n },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\n```\n\n### `TavernHost` members\n\n| Member | Returns | Description |\n| --- | --- | --- |\n| `acceptAnswerText(text)` | `Promise<MeshPeer>` | Consumes a guest's answer code; resolves with the peer once the channel opens. Throws `TavernPairingError` for any unusable code: wrong kind, malformed, expired: with the underlying error as `cause` |\n| `createInvitationText()` | `Promise<string>` | Produces a single-use invitation code, QR-compact when the environment allows |\n| `disposalSignal` | `AbortSignal` | Aborted when hosting ends |\n| `dispose()` | `void` | Stops hosting: closes every channel and detaches all subscriptions. Idempotent; fires `onEnded` exactly once |\n| `disposed` | `boolean` | Whether hosting has ended |\n| `kick(peerId)` | `void` | Disconnects a peer; the guest observes a disconnect |\n| `relayNotice(notice)` | `void` | Relays one local notice to every guest; the serializer decides what crosses |\n| `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations |\n\n---\n\n## `joinTavern(options)`\n\nConsumes an invitation and returns `{ answerText, guest }`. The guest mounts snapshots through `mount(snapshot)`: the first successful mount fires `onJoined`; wire notices are relayed through `notices.fromWire`; rejections through `onRejected`. Channel failures before the first mount fire `onFailed` (once); afterwards, a disconnect or `guest.dispose()` fires `onEnded` with the mounted subject exactly once. `guest.sendCommand` forwards a command to the host with the subject id the consumer routes by. An unusable invitation: wrong kind, malformed, expired: rejects with `TavernPairingError` and leaves no node or subscriptions behind.\n\n**Returns:** `Promise<{ answerText: string; guest: TavernGuest }>`: the answer code to show back and the guest handle.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `invitationText` | `string` | The invitation code the host produced |\n| `mount` | `(snapshot: unknown) => Mounted \\| null` | Mounts a received snapshot; the mounted value, or `null` to ignore |\n| `name` | `string` | The guest's display name on the channel |\n| `notices?` | `TavernNotices` | Notice relay; wire notices re-emit locally through `fromWire` |\n| `onEnded?` | `(subject: Mounted) => void` | The channel ended after joining; the mounted value is passed for unmounting |\n| `onFailed?` | `(reason: string) => void` | The host never accepted the answer in time |\n| `onJoined?` | `(subject: Mounted) => void` | The mounted value once the first snapshot arrives |\n| `onRejected?` | `(message: string) => void` | The host rejected a forwarded command |\n| `onWarning?` | `(message: string) => void` | A transport-level warning |\n| `rtc?` | `MeshRtcFactory` | WebRTC factory: the injection point for tests and non-browser runtimes |\n\n**Example**\n\n```ts\nimport { joinTavern } from '@vielzeug/tavern';\n\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => sanitizeAndMount(snapshot),\n name: 'Alex',\n onEnded: (subject) => unmountMirror(subject.id),\n onJoined: (subject) => mountMirror(subject),\n});\n\nguest.sendCommand('doc-1', 'rename', ['New name']);\nguest.dispose();\n```\n\n### `TavernGuest` members\n\n| Member | Returns | Description |\n| --- | --- | --- |\n| `disposalSignal` | `AbortSignal` | Aborted when the session ends |\n| `dispose()` | `void` | Leaves the session and drops the channel. Fires `onEnded` exactly once |\n| `disposed` | `boolean` | Whether the session has ended: the channel dropped or `dispose()` ran |\n| `sendCommand(subjectId, name, args)` | `void` | Forwards a command to the host. Throws `TavernError` if the session has ended |\n| `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations |\n\n---\n\n## Types\n\n```ts\n/** What the host can do on behalf of a guest. */\ninterface TavernCommands {\n /** Whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n /** Applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n}\n\n/**\n * How the host reads and watches the subject it is sharing. All three close over\n * whatever subject state the consumer owns: the host routes by `subjectId`,\n * not by a subject object.\n */\ninterface TavernSubjects {\n /** Reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n /** Subscribes to local changes of the hosted subject: the re-broadcast trigger. */\n onChanged(listener: () => void): () => void;\n /** Subscribes to the hosted subject's removal: hosting ends when it fires. */\n onRemoved(listener: () => void): () => void;\n}\n\n/**\n * Notice relay between host and guests. Notices cross the wire as opaque\n * values: each client translates locally.\n */\ninterface TavernNotices {\n /** Serializes a local notice for the wire; null skips this one. */\n toWire(notice: unknown): unknown | null;\n /** Re-emits a received wire notice locally. */\n fromWire(wire: unknown): void;\n}\n\n/** Options for `hostTavern`. */\ninterface TavernHostOptions {\n commands: TavernCommands;\n notices?: TavernNotices;\n /** Hosting ended: the subject was removed or `dispose()` ran. Fires exactly once. */\n onEnded?(): void;\n onPeersChanged?(peers: MeshPeer[]): void;\n onPeerJoined?(peer: MeshPeer): void;\n onPeerLeft?(peer: MeshPeer): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n subjectId: string;\n subjects: TavernSubjects;\n}\n\n/** Options for `joinTavern`. */\ninterface TavernGuestOptions<Mounted> {\n invitationText: string;\n /** Mounts a received snapshot; the mounted value, or null to ignore. */\n mount(snapshot: unknown): Mounted | null;\n name: string;\n notices?: TavernNotices;\n onEnded?(subject: Mounted): void;\n onFailed?(reason: string): void;\n onJoined?(subject: Mounted): void;\n onRejected?(message: string): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n}\n\n/** Host handle returned by `hostTavern`. */\ninterface TavernHost {\n acceptAnswerText(text: string): Promise<MeshPeer>;\n createInvitationText(): Promise<string>;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n kick(peerId: string): void;\n relayNotice(notice: unknown): void;\n [Symbol.dispose](): void;\n}\n\n/** Guest handle returned by `joinTavern`. */\ninterface TavernGuest {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n [Symbol.dispose](): void;\n}\n```\n\n## Errors\n\n| Error | Triggered by | Notable properties |\n| --- | --- | --- |\n| `TavernError` | Base class for every Tavern failure: catch this to handle all Tavern errors in one branch | N/A |\n| `TavernPairingError` | A pairing code the consumer pasted could not be used: wrong kind, malformed, expired, or refused by the host. The mesh-level failure is chained as `cause` | `cause` |\n\n`TavernPairingError` extends `TavernError`. Every user-input pairing mistake: a garbage code, the wrong code kind, an expired invitation, a refused answer: surfaces as `TavernPairingError` from `acceptAnswerText` and `joinTavern`; transport-level failures (timeouts, connection errors) propagate unchanged. `TavernGuest.sendCommand` throws `TavernError` when the session has already ended.\n",
6
+ "usage": "---\ntitle: 'Tavern: Usage Guide'\ndescription: How to use @vielzeug/tavern.\n---\n\n[[toc]]\n\n## Basic Usage\n\nHost one subject per session; guests join through the invitation/answer pair. Both sides share one command table, so remote actions run the same code as local ones.\n\n```ts\nimport { hostTavern, joinTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n commands: { apply: (name, args) => myStore.apply(name, args), has: (name) => name in myStore },\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\nconst invitation = await host.createInvitationText();\n\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => parseAndMount(snapshot),\n name: 'Alex',\n});\nawait host.acceptAnswerText(answerText);\n\nguest.sendCommand('doc-1', 'rename', ['Quarterly report']);\n```\n\n## Host a Subject\n\nThe host validates every guest command through the command table and broadcasts the snapshot on every local change: coalesced on a microtask, so bursts of changes ship one snapshot, not many. `onEnded` fires exactly once when hosting ends, whether the subject was removed or the host was disposed: reset your session UI there instead of intercepting your own `onRemoved` seam.\n\n```ts\nimport { hostTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n commands: {\n apply: (name, args) => myStore.apply(name, args),\n has: (name) => name in myStore,\n },\n onEnded: () => updateSessionUi(), // hosting ended: subject removed or disposed\n onPeersChanged: (peers) => updatePeerList(peers),\n onPeerJoined: (peer) => toast(`${peer.name ?? 'A guest'} joined`),\n onPeerLeft: (peer) => toast(`${peer.name ?? 'A guest'} left`),\n onWarning: (message) => log.warn(message),\n subjectId: 'doc-1',\n subjects: {\n onChanged: (listener) => myStore.onUpdated('doc-1', listener),\n onRemoved: (listener) => myStore.onRemoved('doc-1', listener),\n snapshot: () => myStore.read('doc-1'),\n },\n});\n\nconst invitation = await host.createInvitationText();\n// Show the invitation (QR or copy/paste); the guest returns an answer code.\nawait host.acceptAnswerText(answerCode);\n\n// Kick a peer; the guest observes a disconnect and fires onEnded.\nhost.kick(peerId);\n\nhost.dispose(); // stop hosting: every guest disconnects\n```\n\n## Join as a Guest\n\nJoining consumes the invitation and produces the answer code to show back. The session goes live once the host accepts the answer and the first snapshot arrives.\n\n```ts\nimport { joinTavern } from '@vielzeug/tavern';\n\nconst { answerText, guest } = await joinTavern({\n invitationText: invitation,\n mount: (snapshot) => sanitizeAndMount(snapshot),\n name: 'Alex',\n onEnded: (subject) => unmountMirror(subject.id),\n onFailed: (reason) => showError(reason),\n onJoined: (subject) => mountMirror(subject),\n onRejected: (message) => showToast(message),\n});\n\nguest.sendCommand('doc-1', 'rename', ['New name']);\nguest.dispose(); // leave; onEnded fires with the mounted subject exactly once\n```\n\nThe snapshot arrives as parsed JSON: `mount` receives `unknown` and should validate before trusting it. Returning `null` ignores the snapshot; returning the mounted value fires `onJoined` (first time only) and passes it to `onEnded` when the session ends.\n\n## Relay Notices\n\nGive both sides a notice seam: catalog keys rather than prose, so each client translates locally, and the host relays its own notices to every guest.\n\n```ts\nimport type { TavernNotices } from '@vielzeug/tavern';\n\nconst notices: TavernNotices = {\n fromWire: (wire) => bus.emit('notify', wire),\n toWire: (notice) => (isLocalNotice(notice) ? toWireShape(notice) : null),\n};\n\n// Host side: forward every local notice through the session.\nhost.relayNotice(localNotice);\n```\n\n**Echo guard**: a tab that both hosts and guests must prevent re-broadcasting a wire-originated notice back to its own guests. One boolean set during `fromWire` and checked in `toWire` is sufficient: the consumer owns this because only they know their local event system.\n\n## Handle Pairing Mistakes\n\nEvery user-input pairing failure surfaces as `TavernPairingError` from both `acceptAnswerText` and `joinTavern`: a garbage code, the wrong code kind, an expired invitation, or an answer the host refuses. Catch it to show a helpful message instead of a raw error; the underlying mesh failure is chained as `cause` when you need it.\n\n```ts\nimport { hostTavern, TavernPairingError } from '@vielzeug/tavern';\n\ntry {\n await host.acceptAnswerText(pastedCode);\n} catch (error) {\n if (error instanceof TavernPairingError) {\n showToast(error.message); // \"That code is an invitation, not a guest answer.\"\n } else {\n throw error;\n }\n}\n```\n\n## Working with Other Vielzeug Libraries\n\nTavern builds on `@vielzeug/mesh` for the WebRTC transport, pairing codes, and QR-optimized codecs. You do not interact with mesh directly: tavern owns the protocol, but the `rtc` option accepts a `MeshRtcFactory` for testing or non-browser runtimes, and the peer and status types in callbacks are mesh types.\n\n```ts\nimport type { MeshRtcFactory } from '@vielzeug/mesh';\nimport { hostTavern } from '@vielzeug/tavern';\n\nconst host = hostTavern({\n // ... commands, subjects\n rtc: myTestRtcFactory, // inject an in-memory WebRTC fake\n subjectId: 'doc-1',\n});\n```\n\nPair `@vielzeug/ledger` on the host to give the commands tavern applies an undo history: commands applied through the table land in the ledger like local ones.\n\n## Best Practices\n\n- Keep the command table the same object the host's own UI calls, so guest actions cannot bypass local validation.\n- Return `null` from `snapshot` while the subject is missing: the broadcast simply skips.\n- Reset host-side session UI in `onEnded`: it fires exactly once for subject removal and explicit disposal alike, so you never need to double-wire your own `onRemoved` seam.\n- Treat notices as catalog keys, not prose: each client translates locally.\n- Guard against notice echo if a tab can both host and guest: one boolean in `toWire` is sufficient.\n- Dispose guests and hosts when their screen unmounts; channels also clean up on disconnect through `onEnded`, which fires exactly once on both sides.\n- Catch `TavernPairingError` separately from other errors: it means the pasted code could not be used, not that something is broken.\n",
7
+ "examples": "---\ntitle: 'Tavern: Examples'\ndescription: Practical examples and recipes for @vielzeug/tavern.\n---\n\n## Examples\n\n- [Share State Across Devices](./examples/share-state-across-devices.md)\n"
8
8
  },
9
9
  "examples": [],
10
10
  "typeSignatures": {
11
11
  "TavernError": "export { TavernError, TavernPairingError };",
12
12
  "TavernPairingError": "export { TavernError, TavernPairingError };",
13
13
  "TavernCommands": "export interface TavernCommands {\n /** Applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n /** Whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n}",
14
- "TavernSubjects": "export interface TavernSubjects {\n /** Subscribes to local changes of the hosted subject — the re-broadcast trigger. */\n onChanged(listener: () => void): () => void;\n /** Subscribes to the hosted subject's removal — hosting ends when it fires. */\n onRemoved(listener: () => void): () => void;\n /** Reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n}",
14
+ "TavernSubjects": "export interface TavernSubjects {\n /** Subscribes to local changes of the hosted subject: the re-broadcast trigger. */\n onChanged(listener: () => void): () => void;\n /** Subscribes to the hosted subject's removal: hosting ends when it fires. */\n onRemoved(listener: () => void): () => void;\n /** Reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n}",
15
15
  "TavernNotices": "export interface TavernNotices {\n /** Re-emits a received wire notice locally. */\n fromWire(wire: unknown): void;\n /** Serializes a local notice for the wire; null skips this one. */\n toWire(notice: unknown): unknown | null;\n}",
16
- "TavernHostOptions": "export interface TavernHostOptions {\n commands: TavernCommands;\n notices?: TavernNotices;\n /** Hosting ended — the subject was removed or the host was disposed. Fires exactly once. */\n onEnded?(): void;\n onPeerJoined?(peer: MeshPeer): void;\n onPeerLeft?(peer: MeshPeer): void;\n onPeersChanged?(peers: MeshPeer[]): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n /** The id of the subject being hosted — guest commands targeting anything else reject. */\n subjectId: string;\n subjects: TavernSubjects;\n}",
17
- "TavernHost": "export interface TavernHost {\n /** Consumes a guest's answer code; resolves with the peer once the channel is open. */\n acceptAnswerText(text: string): Promise<MeshPeer>;\n /** Produces a single-use invitation code, QR-compact when the environment allows. */\n createInvitationText(): Promise<string>;\n /** `AbortSignal` aborted when hosting ends. */\n readonly disposalSignal: AbortSignal;\n /** Stops hosting: closes every channel and detaches all subscriptions. */\n dispose(): void;\n /** Whether hosting has ended — the subject was removed or `dispose()` ran. */\n readonly disposed: boolean;\n kick(peerId: string): void;\n /** Relays one local notice to every guest; the serializer decides what crosses. */\n relayNotice(notice: unknown): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}",
18
- "hostTavern": "export function hostTavern(options: TavernHostOptions): TavernHost {\n const host = createMeshHost<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let closed = false;\n let flushScheduled = false;\n\n const close = (): void => {\n if (closed) return;\n closed = true;\n for (const off of detach.splice(0)) off();\n host.dispose();\n options.onEnded?.();\n };\n\n /** Reads the snapshot for a broadcast; a throwing read warns instead of crashing the host. */\n const readSnapshot = (): unknown => {\n try {\n return options.subjects.snapshot();\n } catch (error) {\n options.onWarning?.(error instanceof Error ? error.message : 'Reading the snapshot failed.');\n return null;\n }\n };\n\n /** Coalesces broadcasts on a microtask so bursts of local changes ship one snapshot. */\n const broadcastSnapshot = (): void => {\n if (closed || flushScheduled) return;\n flushScheduled = true;\n queueMicrotask(() => {\n flushScheduled = false;\n if (closed) return;\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.broadcast('snapshot', snapshot);\n });\n };\n\n /**\n * Applies a guest command through the command table — the same table the host's own UI\n * calls — so an unknown name, a foreign subject id or a rule violation is all the host\n * can ever reject with.\n */\n const handleGuestCommand = (peerId: string, raw: unknown): void => {\n const command = parseWireCommand(raw);\n if (!command || !options.commands.has(command.name) || command.subjectId !== options.subjectId) {\n host.send(peerId, 'rejected', {\n commandId: command?.id ?? '',\n message: 'The host rejected an unknown or out-of-session command.',\n });\n return;\n }\n try {\n options.commands.apply(command.name, command.args);\n } catch (error) {\n host.send(peerId, 'rejected', {\n commandId: command.id,\n message: error instanceof Error ? error.message : 'The host could not apply the command.',\n });\n }\n };\n\n const detach = [\n host.on('command', ({ peerId, payload }) => handleGuestCommand(peerId, payload)),\n options.subjects.onChanged(broadcastSnapshot),\n options.subjects.onRemoved(close),\n host.tap((event) => {\n if (event.type === 'peer-joined' || event.type === 'peer-left') {\n options.onPeersChanged?.([...host.peers.values()]);\n }\n if (event.type === 'peer-joined') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n options.onPeerJoined?.(event.peer);\n }\n if (event.type === 'peer-left') options.onPeerLeft?.(event.peer);\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(text);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('proof' in decoded)) {\n throw new TavernPairingError('That code is an invitation, not a guest answer.');\n }\n try {\n return await host.acceptAnswer(decoded);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n },\n createInvitationText: async () => meshQrCodec.encode(await host.createInvitation()),\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n host.kick(peerId);\n },\n relayNotice: (notice: unknown): void => {\n if (closed) return;\n const wire = options.notices ? options.notices.toWire(notice) : null;\n if (wire !== null && wire !== undefined) host.broadcast('notice', wire);\n },\n [Symbol.dispose]() {\n close();\n },\n };\n}",
19
- "TavernGuestOptions": "export interface TavernGuestOptions<Mounted> {\n /** The invitation code the host produced. */\n invitationText: string;\n /**\n * Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives\n * as parsed JSON — validate before trusting it.\n */\n mount(snapshot: unknown): Mounted | null;\n /** The guest's display name on the channel. */\n name: string;\n notices?: TavernNotices;\n /** The channel ended after joining; the mounted value is passed for unmounting. */\n onEnded?(subject: Mounted): void;\n /** The host never accepted the answer in time. */\n onFailed?(reason: string): void;\n /** The mounted value once the first snapshot arrives. */\n onJoined?(subject: Mounted): void;\n /** The host rejected a forwarded command. */\n onRejected?(message: string): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n}",
20
- "TavernGuest": "export interface TavernGuest {\n /** `AbortSignal` aborted when the session ends. */\n readonly disposalSignal: AbortSignal;\n /** Leaves the session and drops the channel. */\n dispose(): void;\n /** Whether the session has ended — the channel dropped or `dispose()` ran. */\n readonly disposed: boolean;\n /**\n * Forwards a command to the host. The subject id is the consumer's routing key — the host\n * rejects commands that do not name the subject it is hosting.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}",
21
- "joinTavern": "export async function joinTavern<Mounted>(\n options: TavernGuestOptions<Mounted>,\n): Promise<{ answerText: string; guest: TavernGuest }> {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(options.invitationText);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('secret' in decoded)) {\n throw new TavernPairingError('That code is not a session invitation.');\n }\n\n const guest = createMeshGuest<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let mounted: Mounted | null = null;\n let ended = false;\n\n /** Ends the session exactly once — from the channel dropping or an explicit dispose. */\n const endSession = (): void => {\n if (ended) return;\n ended = true;\n const subject = mounted;\n mounted = null;\n for (const off of detach.splice(0)) off();\n guest.dispose();\n if (subject !== null) options.onEnded?.(subject);\n };\n\n const detach = [\n guest.on('snapshot', ({ payload }) => {\n const subject = options.mount(payload);\n if (subject === null || ended) return;\n if (mounted === null) {\n mounted = subject;\n options.onJoined?.(subject);\n }\n }),\n guest.on('notice', ({ payload }) => {\n options.notices?.fromWire(payload);\n }),\n guest.on('rejected', ({ payload }) => {\n options.onRejected?.(payload.message || 'The host rejected the action.');\n }),\n guest.tap((event) => {\n if (event.type !== 'status-change' && event.type !== 'peer-status-change') return;\n if (event.type === 'peer-status-change' && event.peerId !== guest.host?.id) return;\n if (event.status === 'failed' && mounted === null && !ended) {\n options.onFailed?.('The host did not accept the answer in time.');\n }\n if (event.status === 'disconnected' || event.status === 'failed') endSession();\n }),\n guest.tap((event) => {\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n try {\n const answer = await guest.acceptInvitation(decoded, { name: options.name });\n return {\n answerText: await meshQrCodec.encode(answer),\n guest: {\n get disposalSignal() {\n return guest.disposalSignal;\n },\n dispose: endSession,\n get disposed() {\n return ended;\n },\n sendCommand: (subjectId: string, name: string, args: readonly unknown[]): void => {\n if (ended) throw new TavernError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n [Symbol.dispose]() {\n endSession();\n },\n },\n };\n } catch (error) {\n // A failed join must not leave the node and its subscriptions behind.\n endSession();\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n}"
16
+ "TavernHostOptions": "export interface TavernHostOptions {\n commands: TavernCommands;\n notices?: TavernNotices;\n /** Hosting ended: the subject was removed or the host was disposed. Fires exactly once. */\n onEnded?(): void;\n onPeerJoined?(peer: MeshPeer): void;\n onPeerLeft?(peer: MeshPeer): void;\n onPeersChanged?(peers: MeshPeer[]): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n /** The id of the subject being hosted: guest commands targeting anything else reject. */\n subjectId: string;\n subjects: TavernSubjects;\n}",
17
+ "TavernHost": "export interface TavernHost {\n /** Consumes a guest's answer code; resolves with the peer once the channel is open. */\n acceptAnswerText(text: string): Promise<MeshPeer>;\n /** Produces a single-use invitation code, QR-compact when the environment allows. */\n createInvitationText(): Promise<string>;\n /** `AbortSignal` aborted when hosting ends. */\n readonly disposalSignal: AbortSignal;\n /** Stops hosting: closes every channel and detaches all subscriptions. */\n dispose(): void;\n /** Whether hosting has ended: the subject was removed or `dispose()` ran. */\n readonly disposed: boolean;\n kick(peerId: string): void;\n /** Relays one local notice to every guest; the serializer decides what crosses. */\n relayNotice(notice: unknown): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}",
18
+ "hostTavern": "export function hostTavern(options: TavernHostOptions): TavernHost {\n const host = createMeshHost<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let closed = false;\n let flushScheduled = false;\n\n const close = (): void => {\n if (closed) return;\n closed = true;\n for (const off of detach.splice(0)) off();\n host.dispose();\n options.onEnded?.();\n };\n\n /** Reads the snapshot for a broadcast; a throwing read warns instead of crashing the host. */\n const readSnapshot = (): unknown => {\n try {\n return options.subjects.snapshot();\n } catch (error) {\n options.onWarning?.(error instanceof Error ? error.message : 'Reading the snapshot failed.');\n return null;\n }\n };\n\n /** Coalesces broadcasts on a microtask so bursts of local changes ship one snapshot. */\n const broadcastSnapshot = (): void => {\n if (closed || flushScheduled) return;\n flushScheduled = true;\n queueMicrotask(() => {\n flushScheduled = false;\n if (closed) return;\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.broadcast('snapshot', snapshot);\n });\n };\n\n /**\n * Applies a guest command through the command table: the same table the host's own UI\n * calls, so an unknown name, a foreign subject id or a rule violation is all the host\n * can ever reject with.\n */\n const handleGuestCommand = (peerId: string, raw: unknown): void => {\n const command = parseWireCommand(raw);\n if (!command || !options.commands.has(command.name) || command.subjectId !== options.subjectId) {\n host.send(peerId, 'rejected', {\n commandId: command?.id ?? '',\n message: 'The host rejected an unknown or out-of-session command.',\n });\n return;\n }\n try {\n options.commands.apply(command.name, command.args);\n } catch (error) {\n host.send(peerId, 'rejected', {\n commandId: command.id,\n message: error instanceof Error ? error.message : 'The host could not apply the command.',\n });\n }\n };\n\n const detach = [\n host.on('command', ({ peerId, payload }) => handleGuestCommand(peerId, payload)),\n options.subjects.onChanged(broadcastSnapshot),\n options.subjects.onRemoved(close),\n host.tap((event) => {\n if (event.type === 'peer-joined' || event.type === 'peer-left') {\n options.onPeersChanged?.([...host.peers.values()]);\n }\n if (event.type === 'peer-joined') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n options.onPeerJoined?.(event.peer);\n }\n if (event.type === 'peer-left') options.onPeerLeft?.(event.peer);\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(text);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('proof' in decoded)) {\n throw new TavernPairingError('That code is an invitation, not a guest answer.');\n }\n try {\n return await host.acceptAnswer(decoded);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n },\n createInvitationText: async () => meshQrCodec.encode(await host.createInvitation()),\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n host.kick(peerId);\n },\n relayNotice: (notice: unknown): void => {\n if (closed) return;\n const wire = options.notices ? options.notices.toWire(notice) : null;\n if (wire !== null && wire !== undefined) host.broadcast('notice', wire);\n },\n [Symbol.dispose]() {\n close();\n },\n };\n}",
19
+ "TavernGuestOptions": "export interface TavernGuestOptions<Mounted> {\n /** The invitation code the host produced. */\n invitationText: string;\n /**\n * Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives\n * as parsed JSON: validate before trusting it.\n */\n mount(snapshot: unknown): Mounted | null;\n /** The guest's display name on the channel. */\n name: string;\n notices?: TavernNotices;\n /** The channel ended after joining; the mounted value is passed for unmounting. */\n onEnded?(subject: Mounted): void;\n /** The host never accepted the answer in time. */\n onFailed?(reason: string): void;\n /** The mounted value once the first snapshot arrives. */\n onJoined?(subject: Mounted): void;\n /** The host rejected a forwarded command. */\n onRejected?(message: string): void;\n onWarning?(message: string): void;\n rtc?: MeshRtcFactory;\n}",
20
+ "TavernGuest": "export interface TavernGuest {\n /** `AbortSignal` aborted when the session ends. */\n readonly disposalSignal: AbortSignal;\n /** Leaves the session and drops the channel. */\n dispose(): void;\n /** Whether the session has ended: the channel dropped or `dispose()` ran. */\n readonly disposed: boolean;\n /**\n * Forwards a command to the host. The subject id is the consumer's routing key: the host\n * rejects commands that do not name the subject it is hosting.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Delegates to `dispose()`. Enables `using` declarations. */\n [Symbol.dispose](): void;\n}",
21
+ "joinTavern": "export async function joinTavern<Mounted>(\n options: TavernGuestOptions<Mounted>,\n): Promise<{ answerText: string; guest: TavernGuest }> {\n let decoded: MeshInvitation | MeshAnswer;\n try {\n decoded = await meshQrCodec.decode(options.invitationText);\n } catch (error) {\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n if (!('secret' in decoded)) {\n throw new TavernPairingError('That code is not a session invitation.');\n }\n\n const guest = createMeshGuest<WireProtocol>({\n maxMessageBytes: DEFAULT_MAX_MESSAGE_BYTES,\n rtc: options.rtc,\n });\n let mounted: Mounted | null = null;\n let ended = false;\n\n /** Ends the session exactly once: from the channel dropping or an explicit dispose. */\n const endSession = (): void => {\n if (ended) return;\n ended = true;\n const subject = mounted;\n mounted = null;\n for (const off of detach.splice(0)) off();\n guest.dispose();\n if (subject !== null) options.onEnded?.(subject);\n };\n\n const detach = [\n guest.on('snapshot', ({ payload }) => {\n const subject = options.mount(payload);\n if (subject === null || ended) return;\n if (mounted === null) {\n mounted = subject;\n options.onJoined?.(subject);\n }\n }),\n guest.on('notice', ({ payload }) => {\n options.notices?.fromWire(payload);\n }),\n guest.on('rejected', ({ payload }) => {\n options.onRejected?.(payload.message || 'The host rejected the action.');\n }),\n guest.tap((event) => {\n if (event.type !== 'status-change' && event.type !== 'peer-status-change') return;\n if (event.type === 'peer-status-change' && event.peerId !== guest.host?.id) return;\n if (event.status === 'failed' && mounted === null && !ended) {\n options.onFailed?.('The host did not accept the answer in time.');\n }\n if (event.status === 'disconnected' || event.status === 'failed') endSession();\n }),\n guest.tap((event) => {\n if (event.type === 'error') options.onWarning?.(event.error.message);\n }),\n ];\n\n try {\n const answer = await guest.acceptInvitation(decoded, { name: options.name });\n return {\n answerText: await meshQrCodec.encode(answer),\n guest: {\n get disposalSignal() {\n return guest.disposalSignal;\n },\n dispose: endSession,\n get disposed() {\n return ended;\n },\n sendCommand: (subjectId: string, name: string, args: readonly unknown[]): void => {\n if (ended) throw new TavernError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n [Symbol.dispose]() {\n endSession();\n },\n },\n };\n } catch (error) {\n // A failed join must not leave the node and its subscriptions behind.\n endSession();\n throw error instanceof MeshPairingError ? toPairingError(error) : error;\n }\n}"
22
22
  }
23
23
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "apiSource": "// Tempo keeps this re-export so all consumers share one Temporal implementation and version.\nexport { Temporal } from '@js-temporal/polyfill';\nexport { inTimeZone, toInstant } from './_convert';\nexport { endOf, startOf } from './boundary';\nexport { classifyExpiry, timeDiff } from './classify';\nexport { clamp, contains, isAfter, isBefore, isSame } from './compare';\nexport { difference, isValid, now, nowInstant, parse, shift } from './core';\nexport {\n TempoError,\n TempoInvalidInputError,\n TempoInvalidTzError,\n TempoMissingTzError,\n TempoUnsupportedInputError,\n} from './errors';\nexport {\n format,\n formatDuration,\n formatInstant,\n formatParts,\n formatRange,\n formatRangeParts,\n formatRelative,\n formatTimer,\n formatZoned,\n humanize,\n parseDuration,\n} from './format';\nexport { dateRange, recurrence } from './range';\nexport type {\n AbsoluteTime,\n BoundaryOptions,\n BoundaryUnit,\n CalendarUnit,\n ClampInput,\n ClassifyExpiryInput,\n CompareOptions,\n ContainsInput,\n DifferenceInput,\n Disambiguation,\n DisambiguationOptions,\n DurationFormatOptions,\n ExpiryThresholds,\n FixedDuration,\n FormatOptions,\n FormatPattern,\n ParseAs,\n RecurrenceRule,\n RelativeFormatOptions,\n RelativeTimeInput,\n ShiftOptions,\n TempoUnit,\n TimeDiffResult,\n TimeDiffUnit,\n TimeInput,\n TimerFormatOptions,\n TimeZoneOptions,\n WallTime,\n WeekStartDay,\n} from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Tempo — Temporal date and time utilities\ndescription: Explicit Temporal parsing, timezone-safe arithmetic, and localized date/time formatting for TypeScript.\npackage: tempo\ncategory: time\nkeywords: [temporal, date-time, timezone, formatting, arithmetic, dst, intl]\nrelated: [rune, vault]\nexports: [Temporal, parse, now, nowInstant, isValid, toInstant, inTimeZone, shift, difference, isBefore, isAfter, isSame, contains, clamp, startOf, endOf, dateRange, recurrence, format, formatParts, formatRange, formatRangeParts, formatInstant, formatZoned, formatRelative, parseDuration, formatDuration, formatTimer, classifyExpiry, timeDiff, humanize, TempoError, TempoInvalidInputError, TempoInvalidTzError, TempoMissingTzError, TempoUnsupportedInputError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"tempo\" />\n\n## Why Tempo?\n\nDate/time bugs come from treating an instant and a wall-clock value as interchangeable. Tempo requires an explicit parse target and requires `timeZone` whenever a wall-clock value becomes an instant.\n\n```ts\n// Before\nconst reminder = new Date(meeting.getTime() - 15 * 60_000);\n\n// After\nimport { parse, shift } from '@vielzeug/tempo';\n\nconst localMeeting = parse('2026-03-21T10:30:00', { as: 'plainDateTime' });\nconst reminder = shift(localMeeting, { minutes: -15 }, { timeZone: 'America/New_York' });\n```\n\n| Feature | Tempo | date-fns | Native Date |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"tempo\" type=\"size\" /> | ~10 kB | 0 kB |\n| Zero dependencies | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@js-temporal/polyfill` | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Explicit wall-time conversion | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| DST-safe arithmetic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual | Manual |\n| Localized formatting | <ore-icon name=\"check\" size=\"16\"></ore-icon> `Intl` | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Tempo when** you need Temporal values, explicit timezone rules, and DST-safe operations.\n\n**Consider native `Date` when** your data is only elapsed milliseconds and you do not need calendar or timezone behavior.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/tempo\n```\n\n```sh [npm]\nnpm install @vielzeug/tempo\n```\n\n```sh [yarn]\nyarn add @vielzeug/tempo\n```\n\n:::\n\n## Quick Start\n\nParse a wall-clock input explicitly, attach its timezone, then format it for a user.\n\n```ts\nimport { format, parse, shift } from '@vielzeug/tempo';\n\nconst localMeeting = parse('2026-03-21T10:30:00', { as: 'plainDateTime' });\nconst reminder = shift(localMeeting, { minutes: -15 }, { timeZone: 'America/New_York' });\nconst text = format(reminder, {\n locale: 'en-US',\n pattern: 'short',\n});\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `parse()` — Requires an explicit ISO target: instant, zoned date-time, plain date-time, or plain date.\n- `toInstant()` / `inTimeZone()` — Convert wall-clock and absolute values with explicit timezone semantics.\n- `shift()` / `difference()` — Apply DST-safe arithmetic across instant, zoned, and wall-clock inputs.\n- `contains()` / `clamp()` — Compare normalized ranges with optional calendar-unit precision.\n- `startOf()` / `endOf()` — Resolve timezone-aware calendar boundaries, including configurable week starts.\n- `dateRange()` / `recurrence()` — Generate validated, lazy zoned calendar sequences.\n- `format()` / `formatRelative()` / `formatDuration()` — Render localized values through `Intl`, including calendar-aware relative months and years.\n- `formatTimer()` — Deterministic stopwatch clocks (`47:12`, `1:02:35`) for elapsed-time UI.\n- `classifyExpiry()` — Classify fixed elapsed-time thresholds without month or year approximation.\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- [Rune](/rune/) — format stable Temporal timestamps before writing structured log records.\n- [Vault](/vault/) — derive explicit expiry moments before storing records with TTL policies.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Tempo — API Reference\ndescription: Reference for Tempo Temporal parsing, conversion, arithmetic, comparison, sequence, formatting, and classification APIs.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `parse()` | Parse ISO text to an explicit Temporal kind | Sync | `as` is required |\n| `isValid()` | Narrow an unknown runtime value to `TimeInput` | Sync | Does not parse strings |\n| `toInstant()` | Resolve a value as an absolute instant | Sync | Plain values require `timeZone` |\n| `inTimeZone()` | Project a value to a zone | Sync | Preserves instant, changes wall-clock fields |\n| `shift()` / `difference()` | Timezone-aware arithmetic | Sync | Calendar operations require a timezone |\n| `isBefore()` / `isAfter()` / `isSame()` | Compare absolute values or calendar units | Sync | Plain values require `timeZone` |\n| `contains()` / `clamp()` | Compare normalized ranges | Sync | Bounds normalize automatically |\n| `startOf()` / `endOf()` | Resolve calendar boundaries | Sync | Weeks default to Monday |\n| `dateRange()` / `recurrence()` | Generate lazy zoned sequences | Sync | Steps and intervals must advance |\n| `classifyExpiry()` | Classify fixed elapsed-time thresholds | Sync | Months and years are rejected |\n| `format()` family | Localized and machine formatting | Sync | Use `timeZone`, not `tz` |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/tempo` | Tempo utilities, errors, types, and shared `Temporal` namespace |\n\n## Core Functions\n\n### `parse(input, { as })`\n\n```ts\nparse(input: string, options: { as: 'instant' }): Temporal.Instant;\nparse(input: string, options: { as: 'zonedDateTime' }): Temporal.ZonedDateTime;\nparse(input: string, options: { as: 'plainDateTime' }): Temporal.PlainDateTime;\nparse(input: string, options: { as: 'plainDate' }): Temporal.PlainDate;\n```\n\nParses an ISO 8601 string as the requested temporal kind.\n\n**Parameters**\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `input` | `string` | ISO 8601 input |\n| `options.as` | `ParseAs` | Required result kind |\n\n**Returns:** Requested Temporal value.\n\n**Example:**\n\n```ts\nimport { parse } from '@vielzeug/tempo';\n\nconst instant = parse('2026-03-21T10:15:30Z', { as: 'instant' });\n```\n\n---\n\n### `isValid(value)`\n\n```ts\nisValid(value: unknown): value is TimeInput;\n```\n\nReturns whether `value` is a Tempo-supported Temporal value. It does not parse ISO strings.\n\n**Example:**\n\n```ts\nimport { isValid, parse } from '@vielzeug/tempo';\n\nconst value: unknown = parse('2026-03-21T10:15:30Z', { as: 'instant' });\nconst valid = isValid(value); // true\n```\n\n---\n\n### `now({ timeZone })` / `nowInstant()`\n\n```ts\nnow(options: { timeZone: string }): Temporal.ZonedDateTime;\nnowInstant(): Temporal.Instant;\n```\n\nReturns current zoned or absolute time.\n\n**Example:**\n\n```ts\nimport { now, nowInstant } from '@vielzeug/tempo';\n\nnow({ timeZone: 'Europe/Berlin' });\nnowInstant();\n```\n\n---\n\n### `toInstant(input, options?)` / `inTimeZone(input, timeZone)`\n\n```ts\ntoInstant(input: AbsoluteTime): Temporal.Instant;\ntoInstant(input: WallTime, options: { timeZone: string } & DisambiguationOptions): Temporal.Instant;\ninTimeZone(input: TimeInput, timeZone: string): Temporal.ZonedDateTime;\n```\n\n`toInstant()` resolves wall-clock values. `inTimeZone()` projects a value into a requested timezone.\n\n**Example:**\n\n```ts\nimport { inTimeZone, parse, toInstant } from '@vielzeug/tempo';\n\nconst local = parse('2026-11-01T01:30:00', { as: 'plainDateTime' });\nconst instant = toInstant(local, { disambiguation: 'later', timeZone: 'America/New_York' });\ninTimeZone(instant, 'Europe/Berlin');\n```\n\n---\n\n## Arithmetic and Comparison\n\n### `shift(input, duration, options?)`\n\n```ts\nshift(input: Temporal.ZonedDateTime, duration: Temporal.DurationLike, options?: ShiftOptions): Temporal.ZonedDateTime;\nshift(input: Exclude<TimeInput, Temporal.ZonedDateTime>, duration: Temporal.DurationLike, options: ShiftOptions & { timeZone: string }): Temporal.ZonedDateTime;\n```\n\nAdds a duration through zoned calendar rules.\n\n**Returns:** `Temporal.ZonedDateTime`.\n\n---\n\n### `difference({ start, end, ...options })`\n\n```ts\ndifference(input: DifferenceInput): Temporal.Duration;\n```\n\nReturns the signed duration from `start` to `end` with optional Temporal rounding.\n\n**Returns:** `Temporal.Duration`.\n\n---\n\n### `isBefore()` / `isAfter()` / `isSame()`\n\n```ts\nisBefore(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean;\nisAfter(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean;\nisSame(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean;\n```\n\nCompare absolute instants or their containing calendar units.\n\n**Returns:** A boolean comparison result.\n\n---\n\n### `contains({ value, start, end, ...options })` / `clamp({ value, start, end, ...options })`\n\n```ts\ncontains(input: ContainsInput): boolean;\nclamp(input: ClampInput & { value: Temporal.ZonedDateTime }): Temporal.ZonedDateTime;\nclamp(input: ClampInput): Temporal.Instant;\n```\n\nNormalizes reversed bounds, then checks or constrains the value.\n\n**Returns:** `contains()` returns `boolean`; `clamp()` returns the normalized value or nearest bound.\n\n---\n\n### `startOf()` / `endOf()`\n\n```ts\nstartOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime;\nendOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime;\n```\n\nReturns the first or last nanosecond of a timezone-aware calendar unit.\n\n**Returns:** `Temporal.ZonedDateTime`.\n\n## Calendar Sequences\n\n### `dateRange(start, end, step, options?)`\n\n```ts\ndateRange(start: TimeInput, end: TimeInput, step: Temporal.DurationLike, options?: TimeZoneOptions): Generator<Temporal.ZonedDateTime>;\n```\n\nReturns an inclusive lazy zoned range and rejects steps that do not advance time.\n\n**Returns:** `Generator<Temporal.ZonedDateTime>`.\n\n---\n\n### `recurrence(start, rule, options?)`\n\n```ts\nrecurrence(start: TimeInput, rule: RecurrenceRule, options?: TimeZoneOptions): Generator<Temporal.ZonedDateTime>;\n```\n\nReturns a count- or date-limited lazy recurrence. Intervals must be positive safe integers; counts must be non-negative safe integers.\n\n**Returns:** `Generator<Temporal.ZonedDateTime>`.\n\n## Formatting\n\n### `format(input, options?)`\n\n```ts\nformat(input: TimeInput, options?: FormatOptions): string;\n```\n\nFormats a value through `Intl.DateTimeFormat`.\n\n**Example:**\n\n```ts\nimport { format, parse } from '@vielzeug/tempo';\n\nformat(parse('2026-03-21T10:15:30Z', { as: 'instant' }), {\n locale: 'en-GB',\n pattern: 'short',\n timeZone: 'UTC',\n});\n```\n\n### `formatInstant()` / `formatZoned()` / `formatRelative()` / `formatDuration()` / `formatTimer()`\n\n```ts\nformatInstant(input: TimeInput, options?: TimeZoneOptions): string;\nformatZoned(input: TimeInput, options?: TimeZoneOptions): string;\nformatRelative(input: RelativeTimeInput, options?: RelativeFormatOptions): string;\nformatDuration(input: string | Temporal.DurationLike, options?: DurationFormatOptions): string;\nformatTimer(input: string | Temporal.DurationLike, options?: TimerFormatOptions): string;\n```\n\n`formatInstant()` produces UTC transport text (`timeZone` needed for wall-time input, ignored for `Instant`). `formatZoned()` produces zoned ISO text (`timeZone` required for non-`ZonedDateTime` input). `formatRelative()` uses fixed units for short spans and complete calendar months or years in the requested or inferred timezone. Zoned inputs with different zones require `options.timeZone`. `formatDuration()` falls back to English when `Intl.DurationFormat` is unavailable. `formatTimer()` renders a stopwatch-style clock — `47:12` under an hour, `1:02:35` from an hour up — deterministic and locale-independent; sub-second parts are truncated, negative durations clamp to zero, `days` fold into hours, and durations above days throw `TempoInvalidInputError`.\n\n### `formatParts()` / `formatRange()` / `formatRangeParts()`\n\n```ts\nformatParts(input: TimeInput, options?: FormatOptions): Intl.DateTimeFormatPart[];\nformatRange(start: TimeInput, end: TimeInput, options?: FormatOptions): string;\nformatRangeParts(\n start: TimeInput,\n end: TimeInput,\n options?: FormatOptions,\n): ReturnType<Intl.DateTimeFormat['formatRangeToParts']>;\n```\n\nReturn `Intl` parts or localized range strings using `FormatOptions`.\n\n### `parseDuration()` / `humanize()`\n\n```ts\nparseDuration(input: string | Temporal.DurationLike): Temporal.Duration;\nhumanize(diff: TimeDiffResult, options?: { locale?: Intl.LocalesArgument }): string;\n```\n\n`humanize()` localizes numbers only. Unit names remain English.\n\n## Classification\n\n### `classifyExpiry({ value, thresholds, relativeTo?, timeZone? })`\n\n```ts\nclassifyExpiry<K extends string>(input: ClassifyExpiryInput<K>): K | null;\n```\n\nClassifies an expiry against fixed elapsed-time thresholds in milliseconds or larger units. Months and years throw `TempoInvalidInputError`.\n\n### `timeDiff(a, b?, options?)`\n\n```ts\ntimeDiff(a: TimeInput, b?: TimeInput, options?: TimeZoneOptions): TimeDiffResult;\n```\n\nReturns absolute calendar difference in its largest meaningful unit. Uses Temporal's calendar-aware `.since()` for correct month and year handling.\n\n## Types\n\n```ts\ntype AbsoluteTime = Temporal.Instant | Temporal.ZonedDateTime;\ntype WallTime = Temporal.PlainDate | Temporal.PlainDateTime;\ntype TimeInput = AbsoluteTime | WallTime;\ntype RelativeTimeInput = AbsoluteTime;\ntype ParseAs = 'instant' | 'plainDate' | 'plainDateTime' | 'zonedDateTime';\ntype Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';\ntype CalendarUnit = 'day' | 'month' | 'week' | 'year';\ntype BoundaryUnit = 'day' | 'hour' | 'minute' | 'month' | 'week' | 'year';\ntype WeekStartDay = 1 | 2 | 3 | 4 | 5 | 6 | 7;\ntype FormatPattern = 'date-only' | 'long' | 'medium' | 'short' | 'time-only';\ntype TempoUnit = 'day' | 'hour' | 'microsecond' | 'millisecond' | 'minute' | 'month' | 'nanosecond' | 'second' | 'week' | 'year';\ntype FixedDuration = Pick<Temporal.DurationLike, 'days' | 'hours' | 'microseconds' | 'milliseconds' | 'minutes' | 'nanoseconds' | 'seconds' | 'weeks'>;\ntype ExpiryThresholds<K extends string> = Record<K, FixedDuration>;\ntype TimeDiffUnit = Exclude<TempoUnit, 'microsecond' | 'nanosecond'>;\ntype TimeDiffResult = { unit: TimeDiffUnit; value: number };\n\ninterface TimeZoneOptions { timeZone?: string }\ninterface DisambiguationOptions { disambiguation?: Disambiguation }\ninterface ShiftOptions extends DisambiguationOptions, TimeZoneOptions {}\ninterface DifferenceInput extends DisambiguationOptions, TimeZoneOptions {\n end: TimeInput;\n largestUnit?: Temporal.DateTimeUnit;\n roundingIncrement?: number;\n roundingMode?: Temporal.RoundingMode;\n smallestUnit?: Temporal.DateTimeUnit;\n start: TimeInput;\n}\ninterface BoundaryOptions extends TimeZoneOptions { weekStartsOn?: WeekStartDay }\ninterface CompareOptions extends TimeZoneOptions { unit?: BoundaryUnit; weekStartsOn?: WeekStartDay }\ninterface ContainsInput extends CompareOptions { end: TimeInput; start: TimeInput; value: TimeInput }\ninterface ClampInput extends CompareOptions { end: TimeInput; start: TimeInput; value: TimeInput }\ntype FormatOptions =\n | { intl: Intl.DateTimeFormatOptions; locale?: Intl.LocalesArgument; pattern?: never; timeZone?: string }\n | { intl?: never; locale?: Intl.LocalesArgument; pattern?: FormatPattern; timeZone?: string };\ninterface RelativeFormatOptions extends TimeZoneOptions {\n base?: RelativeTimeInput;\n locale?: Intl.LocalesArgument;\n numeric?: Intl.RelativeTimeFormatNumeric;\n style?: Intl.RelativeTimeFormatStyle;\n}\ninterface DurationFormatOptions {\n locale?: Intl.LocalesArgument;\n style?: 'digital' | 'long' | 'narrow' | 'short';\n}\ninterface ClassifyExpiryInput<K extends string> extends TimeZoneOptions {\n value: TimeInput;\n thresholds: ExpiryThresholds<K>;\n relativeTo?: Temporal.Instant;\n}\ntype RecurrenceRule = { frequency: 'daily' | 'monthly' | 'weekly' | 'yearly'; interval?: number } &\n ({ count: number; until?: TimeInput } | { count?: number; until: TimeInput });\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `TempoError` | Base Tempo error | `instanceof TempoError` narrows every subtype |\n| `TempoInvalidInputError` | Invalid parse, duration, or fixed-threshold input | Extends `TempoError` |\n| `TempoInvalidTzError` | Invalid IANA zone or offset | Extends `TempoError` |\n| `TempoMissingTzError` | Wall time without required `timeZone` | Extends `TempoError` |\n| `TempoUnsupportedInputError` | Non-Temporal input passed to conversion | Extends `TempoError` |\n\nEvery Tempo error thrown from `parse()`, `parseDuration()`, and timezone validation carries the original Temporal error on its `cause` property (ES2022). Inspect `err.cause` for root-cause details when debugging.\n",
6
- "usage": "---\ntitle: Tempo — Usage Guide\ndescription: Parse explicit Temporal values, resolve wall-clock time, perform timezone-aware arithmetic, and format dates with Tempo.\n---\n\n[[toc]]\n\n## Basic Usage\n\nParse ISO input with a declared target. Convert plain values with `timeZone` before treating them as an instant.\n\n```ts\nimport { format, parse, shift } from '@vielzeug/tempo';\n\nconst local = parse('2026-03-21T10:15:30', { as: 'plainDateTime' });\nconst reminder = shift(local, { minutes: -15 }, { timeZone: 'America/New_York' });\n\nformat(reminder, { locale: 'en-US', pattern: 'short' });\n```\n\n## Parse ISO Values\n\nChoose the value your boundary actually represents. Tempo does not auto-detect ISO strings.\n\n```ts\nimport { parse } from '@vielzeug/tempo';\n\nconst occurredAt = parse('2026-03-21T10:15:30Z', { as: 'instant' });\nconst meeting = parse('2026-03-21T10:15:30+01:00[Europe/Berlin]', { as: 'zonedDateTime' });\nconst localStart = parse('2026-03-21T10:15:30', { as: 'plainDateTime' });\nconst birthday = parse('2026-03-21', { as: 'plainDate' });\n```\n\n## Convert Timezones\n\nUse `inTimeZone()` to project an absolute value. Use `toInstant()` only when resolving a wall-clock value.\n\n```ts\nimport { inTimeZone, parse, toInstant } from '@vielzeug/tempo';\n\nconst local = parse('2026-11-01T01:30:00', { as: 'plainDateTime' });\nconst firstOccurrence = toInstant(local, {\n disambiguation: 'earlier',\n timeZone: 'America/New_York',\n});\n\nconst berlin = inTimeZone(firstOccurrence, 'Europe/Berlin');\n```\n\n## Calculate and Compare\n\nUse Tempo helpers when an operation must resolve timezones, normalize ranges, or compare calendar units. Use Temporal methods directly when values are already the exact kind and timezone you need.\n\n```ts\nimport { clamp, contains, difference, parse, shift } from '@vielzeug/tempo';\n\nconst start = parse('2026-03-21T10:00:00Z', { as: 'instant' });\nconst end = parse('2026-03-21T12:00:00Z', { as: 'instant' });\nconst value = parse('2026-03-21T13:00:00Z', { as: 'instant' });\n\nconst duration = difference({ end, largestUnit: 'hour', start });\nconst isScheduled = contains({ end, start, value });\nconst bounded = clamp({ end, start, value });\n\nconst local = parse('2026-03-08T01:30:00', { as: 'plainDateTime' });\nconst tomorrow = shift(local, { days: 1 }, { timeZone: 'America/New_York' });\n```\n\n## Classify Expiry\n\nUse fixed elapsed-time thresholds in milliseconds or larger units. Handle `null` as the unclassified state instead of adding a far-future catch-all.\n\n```ts\nimport { classifyExpiry, parse } from '@vielzeug/tempo';\n\nconst status = classifyExpiry({\n relativeTo: parse('2026-06-01T00:00:00Z', { as: 'instant' }),\n thresholds: {\n expired: { days: 0 },\n critical: { days: 3 },\n warning: { days: 14 },\n },\n value: parse('2026-06-04T00:00:00Z', { as: 'instant' }),\n});\n\nconst label = status ?? 'safe';\n```\n\n## Format Values\n\nUse `format()` for UI, `formatInstant()` for transport, and `formatZoned()` for a zoned ISO string.\n\n```ts\nimport { format, formatInstant, formatRelative, formatZoned, parse } from '@vielzeug/tempo';\n\nconst instant = parse('2026-03-21T10:15:30Z', { as: 'instant' });\n\nformat(instant, { locale: 'en-GB', pattern: 'short', timeZone: 'UTC' });\nformatInstant(instant);\nformatZoned(instant, { timeZone: 'Europe/Berlin' });\nformatRelative(instant, { base: parse('2026-03-21T09:15:30Z', { as: 'instant' }) });\n```\n\n`formatRelative()` uses fixed elapsed-time units for short spans and complete calendar months or years for longer spans. Pass `timeZone` when calendar-relative output must use a specific regional calendar boundary.\n\nUse `formatTimer()` for stopwatch-style clocks — elapsed timers, recorded durations — where localized prose does not fit:\n\n```ts\nimport { formatTimer } from '@vielzeug/tempo';\n\nformatTimer('PT47M12S'); // '47:12'\nformatTimer({ milliseconds: 3_753_000 }); // '1:02:33'\nformatTimer('PT5M', { hours: 'always' }); // '0:05:00'\n```\n\n## Generate Calendar Sequences\n\nUse zoned values when the sequence already has a timezone. Plain and instant starts require `timeZone`.\n\n```ts\nimport { dateRange, parse, recurrence } from '@vielzeug/tempo';\n\nconst start = parse('2026-03-01T09:00:00[America/New_York]', { as: 'zonedDateTime' });\nconst end = parse('2026-03-03T09:00:00[America/New_York]', { as: 'zonedDateTime' });\n\nconst days = [...dateRange(start, end, { days: 1 })];\nconst meetings = [...recurrence(start, { count: 4, frequency: 'weekly', interval: 2 })];\n```\n\nBoth functions advance with zoned calendar arithmetic. Non-advancing range steps, invalid recurrence intervals, and negative or fractional counts throw `TempoInvalidInputError`.\n\n## Testing\n\nPin the reference instant for deterministic expiry tests.\n\n```ts\nimport { classifyExpiry, parse } from '@vielzeug/tempo';\n\nconst relativeTo = parse('2026-06-01T00:00:00Z', { as: 'instant' });\nconst value = parse('2026-05-31T00:00:00Z', { as: 'instant' });\n\nclassifyExpiry({ relativeTo, thresholds: { expired: { days: 0 } }, value });\n```\n\n## Framework Integration\n\nPass ISO strings through component props. Parse and format at the rendering boundary.\n\n::: code-group\n\n```tsx [React]\nimport { format, parse } from '@vielzeug/tempo';\n\nconst label = format(parse(iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' });\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { format, parse } from '@vielzeug/tempo';\n\nconst props = defineProps<{ iso: string }>();\nconst label = format(parse(props.iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' });\n</script>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { format, parse } from '@vielzeug/tempo';\n export let iso: string;\n $: label = format(parse(iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' });\n</script>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Rune\n\nWrite stable UTC timestamps to structured logs.\n\n```ts\nimport { formatInstant, nowInstant } from '@vielzeug/tempo';\n\nlogger.info({ timestamp: formatInstant(nowInstant()) }, 'server started');\n```\n\n### With Vault\n\nCalculate an explicit instant before storing an expiring record.\n\n```ts\nimport { now } from '@vielzeug/tempo';\n\nconst expiresAt = now({ timeZone: 'UTC' }).add({ minutes: 30 }).toInstant();\n```\n\n## Best Practices\n\n- Parse strings with their actual temporal meaning and pass `timeZone` when resolving plain values.\n- Use `disambiguation` for DST overlap and gap handling.\n- Use Tempo helpers for timezone resolution, normalized ranges, and calendar units; use Temporal directly for already-normalized values.\n- Use fixed duration units for expiry thresholds.\n- Reject non-advancing range and recurrence configuration.\n- Store instants for transport and database values.\n- Use `formatInstant()` for machine output and `format()` for user-facing text.\n- Pass `timeZone` to `formatRelative()` when month and year boundaries must use a specific region.\n",
7
- "examples": "---\ntitle: Tempo — Examples\ndescription: Practical examples and recipes for tempo.\n---\n\n## Examples\n\n- [DST-Safe Arithmetic](./examples/dst-safe-arithmetic.md)\n- [Date Ranges and Recurrence](./examples/date-ranges-and-recurrence.md)\n- [Locale Formatting](./examples/locale-formatting.md)\n- [Timezone Conversion](./examples/timezone-conversion.md)\n- [Expiry Classification](./examples/expiry-classification.md)\n"
4
+ "index": "---\ntitle: 'Tempo: Temporal date and time utilities'\ndescription: Explicit Temporal parsing, timezone-safe arithmetic, and localized date/time formatting for TypeScript.\npackage: tempo\ncategory: time\nkeywords: [temporal, date-time, timezone, formatting, arithmetic, dst, intl]\nrelated: [rune, vault]\nexports: [Temporal, parse, now, nowInstant, isValid, toInstant, inTimeZone, shift, difference, isBefore, isAfter, isSame, contains, clamp, startOf, endOf, dateRange, recurrence, format, formatParts, formatRange, formatRangeParts, formatInstant, formatZoned, formatRelative, parseDuration, formatDuration, formatTimer, classifyExpiry, timeDiff, humanize, TempoError, TempoInvalidInputError, TempoInvalidTzError, TempoMissingTzError, TempoUnsupportedInputError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"tempo\" />\n\n## Why Tempo?\n\nDate/time bugs come from treating an instant and a wall-clock value as interchangeable. Tempo requires an explicit parse target and requires `timeZone` whenever a wall-clock value becomes an instant.\n\n```ts\n// Before\nconst reminder = new Date(meeting.getTime() - 15 * 60_000);\n\n// After\nimport { parse, shift } from '@vielzeug/tempo';\n\nconst localMeeting = parse('2026-03-21T10:30:00', { as: 'plainDateTime' });\nconst reminder = shift(localMeeting, { minutes: -15 }, { timeZone: 'America/New_York' });\n```\n\n| Feature | Tempo | date-fns | Native Date |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"tempo\" type=\"size\" /> | ~10 kB | 0 kB |\n| Zero dependencies | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@js-temporal/polyfill` | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Explicit wall-time conversion | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| DST-safe arithmetic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual | Manual |\n| Localized formatting | <ore-icon name=\"check\" size=\"16\"></ore-icon> `Intl` | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Tempo when** you need Temporal values, explicit timezone rules, and DST-safe operations.\n\n**Consider native `Date` when** your data is only elapsed milliseconds and you do not need calendar or timezone behavior.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/tempo\n```\n\n```sh [npm]\nnpm install @vielzeug/tempo\n```\n\n```sh [yarn]\nyarn add @vielzeug/tempo\n```\n\n:::\n\n## Quick Start\n\nParse a wall-clock input explicitly, attach its timezone, then format it for a user.\n\n```ts\nimport { format, parse, shift } from '@vielzeug/tempo';\n\nconst localMeeting = parse('2026-03-21T10:30:00', { as: 'plainDateTime' });\nconst reminder = shift(localMeeting, { minutes: -15 }, { timeZone: 'America/New_York' });\nconst text = format(reminder, {\n locale: 'en-US',\n pattern: 'short',\n});\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `parse()`: Requires an explicit ISO target: instant, zoned date-time, plain date-time, or plain date.\n- `toInstant()` / `inTimeZone()`: Convert wall-clock and absolute values with explicit timezone semantics.\n- `shift()` / `difference()`: Apply DST-safe arithmetic across instant, zoned, and wall-clock inputs.\n- `contains()` / `clamp()`: Compare normalized ranges with optional calendar-unit precision.\n- `startOf()` / `endOf()`: Resolve timezone-aware calendar boundaries, including configurable week starts.\n- `dateRange()` / `recurrence()`: Generate validated, lazy zoned calendar sequences.\n- `format()` / `formatRelative()` / `formatDuration()`: Render localized values through `Intl`, including calendar-aware relative months and years.\n- `formatTimer()`: Deterministic stopwatch clocks (`47:12`, `1:02:35`) for elapsed-time UI.\n- `classifyExpiry()`: Classify fixed elapsed-time thresholds without month or year approximation.\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- [Rune](/rune/): format stable Temporal timestamps before writing structured log records.\n- [Vault](/vault/): derive explicit expiry moments before storing records with TTL policies.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Tempo: API Reference'\ndescription: Reference for Tempo Temporal parsing, conversion, arithmetic, comparison, sequence, formatting, and classification APIs.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `parse()` | Parse ISO text to an explicit Temporal kind | Sync | `as` is required |\n| `isValid()` | Narrow an unknown runtime value to `TimeInput` | Sync | Does not parse strings |\n| `toInstant()` | Resolve a value as an absolute instant | Sync | Plain values require `timeZone` |\n| `inTimeZone()` | Project a value to a zone | Sync | Preserves instant, changes wall-clock fields |\n| `shift()` / `difference()` | Timezone-aware arithmetic | Sync | Calendar operations require a timezone |\n| `isBefore()` / `isAfter()` / `isSame()` | Compare absolute values or calendar units | Sync | Plain values require `timeZone` |\n| `contains()` / `clamp()` | Compare normalized ranges | Sync | Bounds normalize automatically |\n| `startOf()` / `endOf()` | Resolve calendar boundaries | Sync | Weeks default to Monday |\n| `dateRange()` / `recurrence()` | Generate lazy zoned sequences | Sync | Steps and intervals must advance |\n| `classifyExpiry()` | Classify fixed elapsed-time thresholds | Sync | Months and years are rejected |\n| `format()` family | Localized and machine formatting | Sync | Use `timeZone`, not `tz` |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/tempo` | Tempo utilities, errors, types, and shared `Temporal` namespace |\n\n## Core Functions\n\n### `parse(input, { as })`\n\n```ts\nparse(input: string, options: { as: 'instant' }): Temporal.Instant;\nparse(input: string, options: { as: 'zonedDateTime' }): Temporal.ZonedDateTime;\nparse(input: string, options: { as: 'plainDateTime' }): Temporal.PlainDateTime;\nparse(input: string, options: { as: 'plainDate' }): Temporal.PlainDate;\n```\n\nParses an ISO 8601 string as the requested temporal kind.\n\n**Parameters**\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `input` | `string` | ISO 8601 input |\n| `options.as` | `ParseAs` | Required result kind |\n\n**Returns:** Requested Temporal value.\n\n**Example:**\n\n```ts\nimport { parse } from '@vielzeug/tempo';\n\nconst instant = parse('2026-03-21T10:15:30Z', { as: 'instant' });\n```\n\n---\n\n### `isValid(value)`\n\n```ts\nisValid(value: unknown): value is TimeInput;\n```\n\nReturns whether `value` is a Tempo-supported Temporal value. It does not parse ISO strings.\n\n**Example:**\n\n```ts\nimport { isValid, parse } from '@vielzeug/tempo';\n\nconst value: unknown = parse('2026-03-21T10:15:30Z', { as: 'instant' });\nconst valid = isValid(value); // true\n```\n\n---\n\n### `now({ timeZone })` / `nowInstant()`\n\n```ts\nnow(options: { timeZone: string }): Temporal.ZonedDateTime;\nnowInstant(): Temporal.Instant;\n```\n\nReturns current zoned or absolute time.\n\n**Example:**\n\n```ts\nimport { now, nowInstant } from '@vielzeug/tempo';\n\nnow({ timeZone: 'Europe/Berlin' });\nnowInstant();\n```\n\n---\n\n### `toInstant(input, options?)` / `inTimeZone(input, timeZone)`\n\n```ts\ntoInstant(input: AbsoluteTime): Temporal.Instant;\ntoInstant(input: WallTime, options: { timeZone: string } & DisambiguationOptions): Temporal.Instant;\ninTimeZone(input: TimeInput, timeZone: string): Temporal.ZonedDateTime;\n```\n\n`toInstant()` resolves wall-clock values. `inTimeZone()` projects a value into a requested timezone.\n\n**Example:**\n\n```ts\nimport { inTimeZone, parse, toInstant } from '@vielzeug/tempo';\n\nconst local = parse('2026-11-01T01:30:00', { as: 'plainDateTime' });\nconst instant = toInstant(local, { disambiguation: 'later', timeZone: 'America/New_York' });\ninTimeZone(instant, 'Europe/Berlin');\n```\n\n---\n\n## Arithmetic and Comparison\n\n### `shift(input, duration, options?)`\n\n```ts\nshift(input: Temporal.ZonedDateTime, duration: Temporal.DurationLike, options?: ShiftOptions): Temporal.ZonedDateTime;\nshift(input: Exclude<TimeInput, Temporal.ZonedDateTime>, duration: Temporal.DurationLike, options: ShiftOptions & { timeZone: string }): Temporal.ZonedDateTime;\n```\n\nAdds a duration through zoned calendar rules.\n\n**Returns:** `Temporal.ZonedDateTime`.\n\n---\n\n### `difference({ start, end, ...options })`\n\n```ts\ndifference(input: DifferenceInput): Temporal.Duration;\n```\n\nReturns the signed duration from `start` to `end` with optional Temporal rounding.\n\n**Returns:** `Temporal.Duration`.\n\n---\n\n### `isBefore()` / `isAfter()` / `isSame()`\n\n```ts\nisBefore(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean;\nisAfter(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean;\nisSame(a: TimeInput, b: TimeInput, options?: CompareOptions): boolean;\n```\n\nCompare absolute instants or their containing calendar units.\n\n**Returns:** A boolean comparison result.\n\n---\n\n### `contains({ value, start, end, ...options })` / `clamp({ value, start, end, ...options })`\n\n```ts\ncontains(input: ContainsInput): boolean;\nclamp(input: ClampInput & { value: Temporal.ZonedDateTime }): Temporal.ZonedDateTime;\nclamp(input: ClampInput): Temporal.Instant;\n```\n\nNormalizes reversed bounds, then checks or constrains the value.\n\n**Returns:** `contains()` returns `boolean`; `clamp()` returns the normalized value or nearest bound.\n\n---\n\n### `startOf()` / `endOf()`\n\n```ts\nstartOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime;\nendOf(input: TimeInput, unit: BoundaryUnit, options?: BoundaryOptions): Temporal.ZonedDateTime;\n```\n\nReturns the first or last nanosecond of a timezone-aware calendar unit.\n\n**Returns:** `Temporal.ZonedDateTime`.\n\n## Calendar Sequences\n\n### `dateRange(start, end, step, options?)`\n\n```ts\ndateRange(start: TimeInput, end: TimeInput, step: Temporal.DurationLike, options?: TimeZoneOptions): Generator<Temporal.ZonedDateTime>;\n```\n\nReturns an inclusive lazy zoned range and rejects steps that do not advance time.\n\n**Returns:** `Generator<Temporal.ZonedDateTime>`.\n\n---\n\n### `recurrence(start, rule, options?)`\n\n```ts\nrecurrence(start: TimeInput, rule: RecurrenceRule, options?: TimeZoneOptions): Generator<Temporal.ZonedDateTime>;\n```\n\nReturns a count- or date-limited lazy recurrence. Intervals must be positive safe integers; counts must be non-negative safe integers.\n\n**Returns:** `Generator<Temporal.ZonedDateTime>`.\n\n## Formatting\n\n### `format(input, options?)`\n\n```ts\nformat(input: TimeInput, options?: FormatOptions): string;\n```\n\nFormats a value through `Intl.DateTimeFormat`.\n\n**Example:**\n\n```ts\nimport { format, parse } from '@vielzeug/tempo';\n\nformat(parse('2026-03-21T10:15:30Z', { as: 'instant' }), {\n locale: 'en-GB',\n pattern: 'short',\n timeZone: 'UTC',\n});\n```\n\n### `formatInstant()` / `formatZoned()` / `formatRelative()` / `formatDuration()` / `formatTimer()`\n\n```ts\nformatInstant(input: TimeInput, options?: TimeZoneOptions): string;\nformatZoned(input: TimeInput, options?: TimeZoneOptions): string;\nformatRelative(input: RelativeTimeInput, options?: RelativeFormatOptions): string;\nformatDuration(input: string | Temporal.DurationLike, options?: DurationFormatOptions): string;\nformatTimer(input: string | Temporal.DurationLike, options?: TimerFormatOptions): string;\n```\n\n`formatInstant()` produces UTC transport text (`timeZone` needed for wall-time input, ignored for `Instant`). `formatZoned()` produces zoned ISO text (`timeZone` required for non-`ZonedDateTime` input). `formatRelative()` uses fixed units for short spans and complete calendar months or years in the requested or inferred timezone. Zoned inputs with different zones require `options.timeZone`. `formatDuration()` falls back to English when `Intl.DurationFormat` is unavailable. `formatTimer()` renders a stopwatch-style clock: `47:12` under an hour, `1:02:35` from an hour up: deterministic and locale-independent; sub-second parts are truncated, negative durations clamp to zero, `days` fold into hours, and durations above days throw `TempoInvalidInputError`.\n\n### `formatParts()` / `formatRange()` / `formatRangeParts()`\n\n```ts\nformatParts(input: TimeInput, options?: FormatOptions): Intl.DateTimeFormatPart[];\nformatRange(start: TimeInput, end: TimeInput, options?: FormatOptions): string;\nformatRangeParts(\n start: TimeInput,\n end: TimeInput,\n options?: FormatOptions,\n): ReturnType<Intl.DateTimeFormat['formatRangeToParts']>;\n```\n\nReturn `Intl` parts or localized range strings using `FormatOptions`.\n\n### `parseDuration()` / `humanize()`\n\n```ts\nparseDuration(input: string | Temporal.DurationLike): Temporal.Duration;\nhumanize(diff: TimeDiffResult, options?: { locale?: Intl.LocalesArgument }): string;\n```\n\n`humanize()` localizes numbers only. Unit names remain English.\n\n## Classification\n\n### `classifyExpiry({ value, thresholds, relativeTo?, timeZone? })`\n\n```ts\nclassifyExpiry<K extends string>(input: ClassifyExpiryInput<K>): K | null;\n```\n\nClassifies an expiry against fixed elapsed-time thresholds in milliseconds or larger units. Months and years throw `TempoInvalidInputError`.\n\n### `timeDiff(a, b?, options?)`\n\n```ts\ntimeDiff(a: TimeInput, b?: TimeInput, options?: TimeZoneOptions): TimeDiffResult;\n```\n\nReturns absolute calendar difference in its largest meaningful unit. Uses Temporal's calendar-aware `.since()` for correct month and year handling.\n\n## Types\n\n```ts\ntype AbsoluteTime = Temporal.Instant | Temporal.ZonedDateTime;\ntype WallTime = Temporal.PlainDate | Temporal.PlainDateTime;\ntype TimeInput = AbsoluteTime | WallTime;\ntype RelativeTimeInput = AbsoluteTime;\ntype ParseAs = 'instant' | 'plainDate' | 'plainDateTime' | 'zonedDateTime';\ntype Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';\ntype CalendarUnit = 'day' | 'month' | 'week' | 'year';\ntype BoundaryUnit = 'day' | 'hour' | 'minute' | 'month' | 'week' | 'year';\ntype WeekStartDay = 1 | 2 | 3 | 4 | 5 | 6 | 7;\ntype FormatPattern = 'date-only' | 'long' | 'medium' | 'short' | 'time-only';\ntype TempoUnit = 'day' | 'hour' | 'microsecond' | 'millisecond' | 'minute' | 'month' | 'nanosecond' | 'second' | 'week' | 'year';\ntype FixedDuration = Pick<Temporal.DurationLike, 'days' | 'hours' | 'microseconds' | 'milliseconds' | 'minutes' | 'nanoseconds' | 'seconds' | 'weeks'>;\ntype ExpiryThresholds<K extends string> = Record<K, FixedDuration>;\ntype TimeDiffUnit = Exclude<TempoUnit, 'microsecond' | 'nanosecond'>;\ntype TimeDiffResult = { unit: TimeDiffUnit; value: number };\n\ninterface TimeZoneOptions { timeZone?: string }\ninterface DisambiguationOptions { disambiguation?: Disambiguation }\ninterface ShiftOptions extends DisambiguationOptions, TimeZoneOptions {}\ninterface DifferenceInput extends DisambiguationOptions, TimeZoneOptions {\n end: TimeInput;\n largestUnit?: Temporal.DateTimeUnit;\n roundingIncrement?: number;\n roundingMode?: Temporal.RoundingMode;\n smallestUnit?: Temporal.DateTimeUnit;\n start: TimeInput;\n}\ninterface BoundaryOptions extends TimeZoneOptions { weekStartsOn?: WeekStartDay }\ninterface CompareOptions extends TimeZoneOptions { unit?: BoundaryUnit; weekStartsOn?: WeekStartDay }\ninterface ContainsInput extends CompareOptions { end: TimeInput; start: TimeInput; value: TimeInput }\ninterface ClampInput extends CompareOptions { end: TimeInput; start: TimeInput; value: TimeInput }\ntype FormatOptions =\n | { intl: Intl.DateTimeFormatOptions; locale?: Intl.LocalesArgument; pattern?: never; timeZone?: string }\n | { intl?: never; locale?: Intl.LocalesArgument; pattern?: FormatPattern; timeZone?: string };\ninterface RelativeFormatOptions extends TimeZoneOptions {\n base?: RelativeTimeInput;\n locale?: Intl.LocalesArgument;\n numeric?: Intl.RelativeTimeFormatNumeric;\n style?: Intl.RelativeTimeFormatStyle;\n}\ninterface DurationFormatOptions {\n locale?: Intl.LocalesArgument;\n style?: 'digital' | 'long' | 'narrow' | 'short';\n}\ninterface ClassifyExpiryInput<K extends string> extends TimeZoneOptions {\n value: TimeInput;\n thresholds: ExpiryThresholds<K>;\n relativeTo?: Temporal.Instant;\n}\ntype RecurrenceRule = { frequency: 'daily' | 'monthly' | 'weekly' | 'yearly'; interval?: number } &\n ({ count: number; until?: TimeInput } | { count?: number; until: TimeInput });\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `TempoError` | Base Tempo error | `instanceof TempoError` narrows every subtype |\n| `TempoInvalidInputError` | Invalid parse, duration, or fixed-threshold input | Extends `TempoError` |\n| `TempoInvalidTzError` | Invalid IANA zone or offset | Extends `TempoError` |\n| `TempoMissingTzError` | Wall time without required `timeZone` | Extends `TempoError` |\n| `TempoUnsupportedInputError` | Non-Temporal input passed to conversion | Extends `TempoError` |\n\nEvery Tempo error thrown from `parse()`, `parseDuration()`, and timezone validation carries the original Temporal error on its `cause` property (ES2022). Inspect `err.cause` for root-cause details when debugging.\n",
6
+ "usage": "---\ntitle: 'Tempo: Usage Guide'\ndescription: Parse explicit Temporal values, resolve wall-clock time, perform timezone-aware arithmetic, and format dates with Tempo.\n---\n\n[[toc]]\n\n## Basic Usage\n\nParse ISO input with a declared target. Convert plain values with `timeZone` before treating them as an instant.\n\n```ts\nimport { format, parse, shift } from '@vielzeug/tempo';\n\nconst local = parse('2026-03-21T10:15:30', { as: 'plainDateTime' });\nconst reminder = shift(local, { minutes: -15 }, { timeZone: 'America/New_York' });\n\nformat(reminder, { locale: 'en-US', pattern: 'short' });\n```\n\n## Parse ISO Values\n\nChoose the value your boundary actually represents. Tempo does not auto-detect ISO strings.\n\n```ts\nimport { parse } from '@vielzeug/tempo';\n\nconst occurredAt = parse('2026-03-21T10:15:30Z', { as: 'instant' });\nconst meeting = parse('2026-03-21T10:15:30+01:00[Europe/Berlin]', { as: 'zonedDateTime' });\nconst localStart = parse('2026-03-21T10:15:30', { as: 'plainDateTime' });\nconst birthday = parse('2026-03-21', { as: 'plainDate' });\n```\n\n## Convert Timezones\n\nUse `inTimeZone()` to project an absolute value. Use `toInstant()` only when resolving a wall-clock value.\n\n```ts\nimport { inTimeZone, parse, toInstant } from '@vielzeug/tempo';\n\nconst local = parse('2026-11-01T01:30:00', { as: 'plainDateTime' });\nconst firstOccurrence = toInstant(local, {\n disambiguation: 'earlier',\n timeZone: 'America/New_York',\n});\n\nconst berlin = inTimeZone(firstOccurrence, 'Europe/Berlin');\n```\n\n## Calculate and Compare\n\nUse Tempo helpers when an operation must resolve timezones, normalize ranges, or compare calendar units. Use Temporal methods directly when values are already the exact kind and timezone you need.\n\n```ts\nimport { clamp, contains, difference, parse, shift } from '@vielzeug/tempo';\n\nconst start = parse('2026-03-21T10:00:00Z', { as: 'instant' });\nconst end = parse('2026-03-21T12:00:00Z', { as: 'instant' });\nconst value = parse('2026-03-21T13:00:00Z', { as: 'instant' });\n\nconst duration = difference({ end, largestUnit: 'hour', start });\nconst isScheduled = contains({ end, start, value });\nconst bounded = clamp({ end, start, value });\n\nconst local = parse('2026-03-08T01:30:00', { as: 'plainDateTime' });\nconst tomorrow = shift(local, { days: 1 }, { timeZone: 'America/New_York' });\n```\n\n## Classify Expiry\n\nUse fixed elapsed-time thresholds in milliseconds or larger units. Handle `null` as the unclassified state instead of adding a far-future catch-all.\n\n```ts\nimport { classifyExpiry, parse } from '@vielzeug/tempo';\n\nconst status = classifyExpiry({\n relativeTo: parse('2026-06-01T00:00:00Z', { as: 'instant' }),\n thresholds: {\n expired: { days: 0 },\n critical: { days: 3 },\n warning: { days: 14 },\n },\n value: parse('2026-06-04T00:00:00Z', { as: 'instant' }),\n});\n\nconst label = status ?? 'safe';\n```\n\n## Format Values\n\nUse `format()` for UI, `formatInstant()` for transport, and `formatZoned()` for a zoned ISO string.\n\n```ts\nimport { format, formatInstant, formatRelative, formatZoned, parse } from '@vielzeug/tempo';\n\nconst instant = parse('2026-03-21T10:15:30Z', { as: 'instant' });\n\nformat(instant, { locale: 'en-GB', pattern: 'short', timeZone: 'UTC' });\nformatInstant(instant);\nformatZoned(instant, { timeZone: 'Europe/Berlin' });\nformatRelative(instant, { base: parse('2026-03-21T09:15:30Z', { as: 'instant' }) });\n```\n\n`formatRelative()` uses fixed elapsed-time units for short spans and complete calendar months or years for longer spans. Pass `timeZone` when calendar-relative output must use a specific regional calendar boundary.\n\nUse `formatTimer()` for stopwatch-style clocks: elapsed timers, recorded durations: where localized prose does not fit:\n\n```ts\nimport { formatTimer } from '@vielzeug/tempo';\n\nformatTimer('PT47M12S'); // '47:12'\nformatTimer({ milliseconds: 3_753_000 }); // '1:02:33'\nformatTimer('PT5M', { hours: 'always' }); // '0:05:00'\n```\n\n## Generate Calendar Sequences\n\nUse zoned values when the sequence already has a timezone. Plain and instant starts require `timeZone`.\n\n```ts\nimport { dateRange, parse, recurrence } from '@vielzeug/tempo';\n\nconst start = parse('2026-03-01T09:00:00[America/New_York]', { as: 'zonedDateTime' });\nconst end = parse('2026-03-03T09:00:00[America/New_York]', { as: 'zonedDateTime' });\n\nconst days = [...dateRange(start, end, { days: 1 })];\nconst meetings = [...recurrence(start, { count: 4, frequency: 'weekly', interval: 2 })];\n```\n\nBoth functions advance with zoned calendar arithmetic. Non-advancing range steps, invalid recurrence intervals, and negative or fractional counts throw `TempoInvalidInputError`.\n\n## Testing\n\nPin the reference instant for deterministic expiry tests.\n\n```ts\nimport { classifyExpiry, parse } from '@vielzeug/tempo';\n\nconst relativeTo = parse('2026-06-01T00:00:00Z', { as: 'instant' });\nconst value = parse('2026-05-31T00:00:00Z', { as: 'instant' });\n\nclassifyExpiry({ relativeTo, thresholds: { expired: { days: 0 } }, value });\n```\n\n## Framework Integration\n\nPass ISO strings through component props. Parse and format at the rendering boundary.\n\n::: code-group\n\n```tsx [React]\nimport { format, parse } from '@vielzeug/tempo';\n\nconst label = format(parse(iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' });\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { format, parse } from '@vielzeug/tempo';\n\nconst props = defineProps<{ iso: string }>();\nconst label = format(parse(props.iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' });\n</script>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { format, parse } from '@vielzeug/tempo';\n export let iso: string;\n $: label = format(parse(iso, { as: 'instant' }), { locale: 'en-US', pattern: 'medium', timeZone: 'UTC' });\n</script>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Rune\n\nWrite stable UTC timestamps to structured logs.\n\n```ts\nimport { formatInstant, nowInstant } from '@vielzeug/tempo';\n\nlogger.info({ timestamp: formatInstant(nowInstant()) }, 'server started');\n```\n\n### With Vault\n\nCalculate an explicit instant before storing an expiring record.\n\n```ts\nimport { now } from '@vielzeug/tempo';\n\nconst expiresAt = now({ timeZone: 'UTC' }).add({ minutes: 30 }).toInstant();\n```\n\n## Best Practices\n\n- Parse strings with their actual temporal meaning and pass `timeZone` when resolving plain values.\n- Use `disambiguation` for DST overlap and gap handling.\n- Use Tempo helpers for timezone resolution, normalized ranges, and calendar units; use Temporal directly for already-normalized values.\n- Use fixed duration units for expiry thresholds.\n- Reject non-advancing range and recurrence configuration.\n- Store instants for transport and database values.\n- Use `formatInstant()` for machine output and `format()` for user-facing text.\n- Pass `timeZone` to `formatRelative()` when month and year boundaries must use a specific region.\n",
7
+ "examples": "---\ntitle: 'Tempo: Examples'\ndescription: Practical examples and recipes for tempo.\n---\n\n## Examples\n\n- [DST-Safe Arithmetic](./examples/dst-safe-arithmetic.md)\n- [Date Ranges and Recurrence](./examples/date-ranges-and-recurrence.md)\n- [Locale Formatting](./examples/locale-formatting.md)\n- [Timezone Conversion](./examples/timezone-conversion.md)\n- [Expiry Classification](./examples/expiry-classification.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {