@vielzeug/tavern 0.0.0-stage → 26.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @vielzeug/tavern
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Table sessions over mesh — host-owned state replication with guest command forwarding
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @vielzeug/tavern
9
+ npm install @vielzeug/tavern
10
+ yarn add @vielzeug/tavern
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ts
16
+ import { hostTavern, joinTavern } from '@vielzeug/tavern';
17
+
18
+ const host = hostTavern({
19
+ commands: {
20
+ apply: (name, args) => myStore.apply(name, args),
21
+ has: (name) => name in myStore,
22
+ },
23
+ subjectId: 'doc-1',
24
+ subjects: {
25
+ onChanged: (listener) => myStore.onUpdated('doc-1', listener),
26
+ onRemoved: (listener) => myStore.onRemoved('doc-1', listener),
27
+ snapshot: () => myStore.read('doc-1'),
28
+ },
29
+ });
30
+
31
+ const invitation = await host.createInvitationText();
32
+ const { answerText, guest } = await joinTavern({
33
+ invitationText: invitation,
34
+ mount: (snapshot) => parseAndMount(snapshot),
35
+ name: 'Alex',
36
+ });
37
+ await host.acceptAnswerText(answerText);
38
+
39
+ guest.sendCommand('doc-1', 'rename', ['Quarterly report']);
40
+ ```
41
+
42
+ ## Documentation
43
+
44
+ - [Overview](https://vielzeug.dev/tavern/)
45
+ - [Usage Guide](https://vielzeug.dev/tavern/usage)
46
+ - [API Reference](https://vielzeug.dev/tavern/api)
47
+ - [Examples](https://vielzeug.dev/tavern/examples)
48
+
49
+ ## License
50
+
51
+ MIT © [Helmuth Saatkamp](https://github.com/helmuthdu) — part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
@@ -0,0 +1,2 @@
1
+ var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{};exports.TavernError=TavernError,exports.TavernPairingError=TavernPairingError;
2
+ //# sourceMappingURL=errors.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.cjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n"],"mappings":"AAIA,IAAa,YAAb,cAAiC,KAAM,CACrC,YAAY,EAAiB,EAAqB,CAChD,MAAM,EAAS,CAAI,EACnB,KAAK,KAAO,WAAW,KACvB,OAAO,eAAe,KAAM,WAAW,SAAS,CAClD,CACF,EAGa,mBAAb,cAAwC,WAAY,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Base class for all tavern errors.
3
+ * Use `instanceof TavernError` to catch any tavern-originated error in one branch.
4
+ */
5
+ export declare class TavernError extends Error {
6
+ constructor(message: string, opts?: ErrorOptions);
7
+ }
8
+ /** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */
9
+ export declare class TavernPairingError extends TavernError {
10
+ }
11
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,WAAY,SAAQ,KAAK;gBACxB,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,YAAY;CAKjD;AAED,8FAA8F;AAC9F,qBAAa,kBAAmB,SAAQ,WAAW;CAAG"}
package/dist/errors.js ADDED
@@ -0,0 +1,2 @@
1
+ var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{};export{TavernError,TavernPairingError};
2
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n"],"mappings":"AAIA,IAAa,YAAb,cAAiC,KAAM,CACrC,YAAY,EAAiB,EAAqB,CAChD,MAAM,EAAS,CAAI,EACnB,KAAK,KAAO,WAAW,KACvB,OAAO,eAAe,KAAM,WAAW,SAAS,CAClD,CACF,EAGa,mBAAb,cAAwC,WAAY,CAAC"}
package/dist/index.cjs ADDED
@@ -0,0 +1,2 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./errors.cjs");let t=require("@vielzeug/mesh");function toPairingError(t){let n=t instanceof Error?t.message:`That code could not be read.`;return new e.TavernPairingError(n,{cause:t})}var n=524288,commandId=()=>`cmd-${Math.random().toString(36).slice(2,10)}${Date.now().toString(36)}`;function parseWireCommand(e){if(typeof e!=`object`||!e)return null;let{args:t,id:n,name:r,subjectId:i}=e;return typeof n!=`string`||typeof r!=`string`||typeof i!=`string`||!Array.isArray(t)?null:{args:t,id:n,name:r,subjectId:i}}function hostTavern(r){let i=(0,t.createMeshHost)({maxMessageBytes:n,rtc:r.rtc}),a=!1,o=!1,close=()=>{if(!a){a=!0;for(let e of s.splice(0))e();i.dispose(),r.onEnded?.()}},readSnapshot=()=>{try{return r.subjects.snapshot()}catch(e){return r.onWarning?.(e instanceof Error?e.message:`Reading the snapshot failed.`),null}},broadcastSnapshot=()=>{a||o||(o=!0,queueMicrotask(()=>{if(o=!1,a)return;let e=readSnapshot();e!=null&&i.broadcast(`snapshot`,e)}))},handleGuestCommand=(e,t)=>{let n=parseWireCommand(t);if(!n||!r.commands.has(n.name)||n.subjectId!==r.subjectId){i.send(e,`rejected`,{commandId:n?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{r.commands.apply(n.name,n.args)}catch(t){i.send(e,`rejected`,{commandId:n.id,message:t instanceof Error?t.message:`The host could not apply the command.`})}},s=[i.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),r.subjects.onChanged(broadcastSnapshot),r.subjects.onRemoved(close),i.tap(e=>{if((e.type===`peer-joined`||e.type===`peer-left`)&&r.onPeersChanged?.([...i.peers.values()]),e.type===`peer-joined`){let t=readSnapshot();t!=null&&i.send(e.peer.id,`snapshot`,t),r.onPeerJoined?.(e.peer)}e.type===`peer-left`&&r.onPeerLeft?.(e.peer),e.type===`error`&&r.onWarning?.(e.error.message)})];return{acceptAnswerText:async n=>{let r;try{r=await t.meshQrCodec.decode(n)}catch(e){throw e instanceof t.MeshPairingError?toPairingError(e):e}if(!(`proof`in r))throw new e.TavernPairingError(`That code is an invitation, not a guest answer.`);try{return await i.acceptAnswer(r)}catch(e){throw e instanceof t.MeshPairingError?toPairingError(e):e}},createInvitationText:async()=>t.meshQrCodec.encode(await i.createInvitation()),get disposalSignal(){return i.disposalSignal},dispose:close,get disposed(){return a},kick:e=>{i.kick(e)},relayNotice:e=>{if(a)return;let t=r.notices?r.notices.toWire(e):null;t!=null&&i.broadcast(`notice`,t)},[Symbol.dispose](){close()}}}async function joinTavern(r){let i;try{i=await t.meshQrCodec.decode(r.invitationText)}catch(e){throw e instanceof t.MeshPairingError?toPairingError(e):e}if(!(`secret`in i))throw new e.TavernPairingError(`That code is not a session invitation.`);let a=(0,t.createMeshGuest)({maxMessageBytes:n,rtc:r.rtc}),o=null,s=!1,endSession=()=>{if(s)return;s=!0;let e=o;o=null;for(let e of c.splice(0))e();a.dispose(),e!==null&&r.onEnded?.(e)},c=[a.on(`snapshot`,({payload:e})=>{let t=r.mount(e);t===null||s||o===null&&(o=t,r.onJoined?.(t))}),a.on(`notice`,({payload:e})=>{r.notices?.fromWire(e)}),a.on(`rejected`,({payload:e})=>{r.onRejected?.(e.message||`The host rejected the action.`)}),a.tap(e=>{(e.type===`status-change`||e.type===`peer-status-change`)&&(e.type!==`peer-status-change`||e.peerId===a.host?.id)&&(e.status===`failed`&&o===null&&!s&&r.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),a.tap(e=>{e.type===`error`&&r.onWarning?.(e.error.message)})];try{let n=await a.acceptInvitation(i,{name:r.name});return{answerText:await t.meshQrCodec.encode(n),guest:{get disposalSignal(){return a.disposalSignal},dispose:endSession,get disposed(){return s},sendCommand:(t,n,r)=>{if(s)throw new e.TavernError(`The session has ended.`);a.send(`command`,{args:[...r],id:commandId(),name:n,subjectId:t})},[Symbol.dispose](){endSession()}}}}catch(e){throw endSession(),e instanceof t.MeshPairingError?toPairingError(e):e}}exports.TavernError=e.TavernError,exports.TavernPairingError=e.TavernPairingError,exports.hostTavern=hostTavern,exports.joinTavern=joinTavern;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["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"],"mappings":"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"}
@@ -0,0 +1,131 @@
1
+ import { type MeshPeer, type MeshRtcFactory } from '@vielzeug/mesh';
2
+ import { TavernError, TavernPairingError } from './errors';
3
+ export { TavernError, TavernPairingError };
4
+ /**
5
+ * Table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests
6
+ * mirror it and forward every command through the host's command table, so guest actions follow
7
+ * exactly the same code path as host actions.
8
+ *
9
+ * The consumer supplies the seams — which commands exist, how subject state is read and mounted,
10
+ * how notices cross between the wire and the local UI. Tavern owns the pairing, the protocol,
11
+ * command validation, snapshot broadcasting, notice relaying, and presence.
12
+ */
13
+ /** What the host can do on behalf of a guest. */
14
+ export interface TavernCommands {
15
+ /** Applies the command; throwing rejects the guest with the error's message. */
16
+ apply(name: string, args: readonly unknown[]): unknown;
17
+ /** Whether a command name is known; unknown names reject without applying. */
18
+ has(name: string): boolean;
19
+ }
20
+ /**
21
+ * How the host reads and watches the subject it is sharing. All three close over whatever
22
+ * subject state the consumer owns — the host routes by `subjectId`, not by a subject object.
23
+ */
24
+ export interface TavernSubjects {
25
+ /** Subscribes to local changes of the hosted subject — the re-broadcast trigger. */
26
+ onChanged(listener: () => void): () => void;
27
+ /** Subscribes to the hosted subject's removal — hosting ends when it fires. */
28
+ onRemoved(listener: () => void): () => void;
29
+ /** Reads the snapshot to broadcast; null while the subject is missing. */
30
+ snapshot(): unknown;
31
+ }
32
+ /**
33
+ * Notice relay between host and guests. Notices cross the wire as opaque values — each client
34
+ * translates locally.
35
+ *
36
+ * **Echo guard**: a tab that both hosts and guests must guard against re-broadcasting a
37
+ * wire-originated notice back to its own guests — `fromWire` emits locally, the host's local
38
+ * subscription picks it up, and without a guard it would loop. The consumer owns this guard
39
+ * because only they know their local event system; one boolean set during `fromWire` and
40
+ * checked in `toWire` is sufficient.
41
+ */
42
+ export interface TavernNotices {
43
+ /** Re-emits a received wire notice locally. */
44
+ fromWire(wire: unknown): void;
45
+ /** Serializes a local notice for the wire; null skips this one. */
46
+ toWire(notice: unknown): unknown | null;
47
+ }
48
+ export interface TavernHostOptions {
49
+ commands: TavernCommands;
50
+ notices?: TavernNotices;
51
+ /** Hosting ended — the subject was removed or the host was disposed. Fires exactly once. */
52
+ onEnded?(): void;
53
+ onPeerJoined?(peer: MeshPeer): void;
54
+ onPeerLeft?(peer: MeshPeer): void;
55
+ onPeersChanged?(peers: MeshPeer[]): void;
56
+ onWarning?(message: string): void;
57
+ rtc?: MeshRtcFactory;
58
+ /** The id of the subject being hosted — guest commands targeting anything else reject. */
59
+ subjectId: string;
60
+ subjects: TavernSubjects;
61
+ }
62
+ export interface TavernHost {
63
+ /** Consumes a guest's answer code; resolves with the peer once the channel is open. */
64
+ acceptAnswerText(text: string): Promise<MeshPeer>;
65
+ /** Produces a single-use invitation code, QR-compact when the environment allows. */
66
+ createInvitationText(): Promise<string>;
67
+ /** `AbortSignal` aborted when hosting ends. */
68
+ readonly disposalSignal: AbortSignal;
69
+ /** Stops hosting: closes every channel and detaches all subscriptions. */
70
+ dispose(): void;
71
+ /** Whether hosting has ended — the subject was removed or `dispose()` ran. */
72
+ readonly disposed: boolean;
73
+ kick(peerId: string): void;
74
+ /** Relays one local notice to every guest; the serializer decides what crosses. */
75
+ relayNotice(notice: unknown): void;
76
+ /** Delegates to `dispose()`. Enables `using` declarations. */
77
+ [Symbol.dispose](): void;
78
+ }
79
+ /**
80
+ * Starts hosting one subject. Every guest command is validated and applied through the
81
+ * command table; local changes re-broadcast the snapshot (coalesced on a microtask); removal
82
+ * ends the session.
83
+ */
84
+ export declare function hostTavern(options: TavernHostOptions): TavernHost;
85
+ export interface TavernGuestOptions<Mounted> {
86
+ /** The invitation code the host produced. */
87
+ invitationText: string;
88
+ /**
89
+ * Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives
90
+ * as parsed JSON — validate before trusting it.
91
+ */
92
+ mount(snapshot: unknown): Mounted | null;
93
+ /** The guest's display name on the channel. */
94
+ name: string;
95
+ notices?: TavernNotices;
96
+ /** The channel ended after joining; the mounted value is passed for unmounting. */
97
+ onEnded?(subject: Mounted): void;
98
+ /** The host never accepted the answer in time. */
99
+ onFailed?(reason: string): void;
100
+ /** The mounted value once the first snapshot arrives. */
101
+ onJoined?(subject: Mounted): void;
102
+ /** The host rejected a forwarded command. */
103
+ onRejected?(message: string): void;
104
+ onWarning?(message: string): void;
105
+ rtc?: MeshRtcFactory;
106
+ }
107
+ export interface TavernGuest {
108
+ /** `AbortSignal` aborted when the session ends. */
109
+ readonly disposalSignal: AbortSignal;
110
+ /** Leaves the session and drops the channel. */
111
+ dispose(): void;
112
+ /** Whether the session has ended — the channel dropped or `dispose()` ran. */
113
+ readonly disposed: boolean;
114
+ /**
115
+ * Forwards a command to the host. The subject id is the consumer's routing key — the host
116
+ * rejects commands that do not name the subject it is hosting.
117
+ */
118
+ sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;
119
+ /** Delegates to `dispose()`. Enables `using` declarations. */
120
+ [Symbol.dispose](): void;
121
+ }
122
+ /**
123
+ * Joins a session as a guest: consumes the host's invitation and returns the answer code to
124
+ * show back. The session goes live once the host accepts the answer and the first snapshot
125
+ * mounts; disposing the guest (or the channel dropping) ends it exactly once.
126
+ */
127
+ export declare function joinTavern<Mounted>(options: TavernGuestOptions<Mounted>): Promise<{
128
+ answerText: string;
129
+ guest: TavernGuest;
130
+ }>;
131
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import{TavernError as e,TavernPairingError as t}from"./errors.js";import{MeshPairingError as n,createMeshGuest as r,createMeshHost as i,meshQrCodec as a}from"@vielzeug/mesh";function toPairingError(e){let n=e instanceof Error?e.message:`That code could not be read.`;return new t(n,{cause:e})}var o=524288,commandId=()=>`cmd-${Math.random().toString(36).slice(2,10)}${Date.now().toString(36)}`;function parseWireCommand(e){if(typeof e!=`object`||!e)return null;let{args:t,id:n,name:r,subjectId:i}=e;return typeof n!=`string`||typeof r!=`string`||typeof i!=`string`||!Array.isArray(t)?null:{args:t,id:n,name:r,subjectId:i}}function hostTavern(e){let r=i({maxMessageBytes:o,rtc:e.rtc}),s=!1,c=!1,close=()=>{if(!s){s=!0;for(let e of l.splice(0))e();r.dispose(),e.onEnded?.()}},readSnapshot=()=>{try{return e.subjects.snapshot()}catch(t){return e.onWarning?.(t instanceof Error?t.message:`Reading the snapshot failed.`),null}},broadcastSnapshot=()=>{s||c||(c=!0,queueMicrotask(()=>{if(c=!1,s)return;let e=readSnapshot();e!=null&&r.broadcast(`snapshot`,e)}))},handleGuestCommand=(t,n)=>{let i=parseWireCommand(n);if(!i||!e.commands.has(i.name)||i.subjectId!==e.subjectId){r.send(t,`rejected`,{commandId:i?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{e.commands.apply(i.name,i.args)}catch(e){r.send(t,`rejected`,{commandId:i.id,message:e instanceof Error?e.message:`The host could not apply the command.`})}},l=[r.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),e.subjects.onChanged(broadcastSnapshot),e.subjects.onRemoved(close),r.tap(t=>{if((t.type===`peer-joined`||t.type===`peer-left`)&&e.onPeersChanged?.([...r.peers.values()]),t.type===`peer-joined`){let n=readSnapshot();n!=null&&r.send(t.peer.id,`snapshot`,n),e.onPeerJoined?.(t.peer)}t.type===`peer-left`&&e.onPeerLeft?.(t.peer),t.type===`error`&&e.onWarning?.(t.error.message)})];return{acceptAnswerText:async e=>{let i;try{i=await a.decode(e)}catch(e){throw e instanceof n?toPairingError(e):e}if(!(`proof`in i))throw new t(`That code is an invitation, not a guest answer.`);try{return await r.acceptAnswer(i)}catch(e){throw e instanceof n?toPairingError(e):e}},createInvitationText:async()=>a.encode(await r.createInvitation()),get disposalSignal(){return r.disposalSignal},dispose:close,get disposed(){return s},kick:e=>{r.kick(e)},relayNotice:t=>{if(s)return;let n=e.notices?e.notices.toWire(t):null;n!=null&&r.broadcast(`notice`,n)},[Symbol.dispose](){close()}}}async function joinTavern(i){let s;try{s=await a.decode(i.invitationText)}catch(e){throw e instanceof n?toPairingError(e):e}if(!(`secret`in s))throw new t(`That code is not a session invitation.`);let c=r({maxMessageBytes:o,rtc:i.rtc}),l=null,u=!1,endSession=()=>{if(u)return;u=!0;let e=l;l=null;for(let e of d.splice(0))e();c.dispose(),e!==null&&i.onEnded?.(e)},d=[c.on(`snapshot`,({payload:e})=>{let t=i.mount(e);t===null||u||l===null&&(l=t,i.onJoined?.(t))}),c.on(`notice`,({payload:e})=>{i.notices?.fromWire(e)}),c.on(`rejected`,({payload:e})=>{i.onRejected?.(e.message||`The host rejected the action.`)}),c.tap(e=>{(e.type===`status-change`||e.type===`peer-status-change`)&&(e.type!==`peer-status-change`||e.peerId===c.host?.id)&&(e.status===`failed`&&l===null&&!u&&i.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),c.tap(e=>{e.type===`error`&&i.onWarning?.(e.error.message)})];try{let t=await c.acceptInvitation(s,{name:i.name});return{answerText:await a.encode(t),guest:{get disposalSignal(){return c.disposalSignal},dispose:endSession,get disposed(){return u},sendCommand:(t,n,r)=>{if(u)throw new e(`The session has ended.`);c.send(`command`,{args:[...r],id:commandId(),name:n,subjectId:t})},[Symbol.dispose](){endSession()}}}}catch(e){throw endSession(),e instanceof n?toPairingError(e):e}}export{e as TavernError,t as TavernPairingError,hostTavern,joinTavern};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["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"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@vielzeug/mesh");var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{};function toPairingError(e){return new TavernPairingError(e instanceof Error?e.message:`That code could not be read.`,{cause:e})}var t=524288,commandId=()=>`cmd-${Math.random().toString(36).slice(2,10)}${Date.now().toString(36)}`;function parseWireCommand(e){if(typeof e!=`object`||!e)return null;let{args:t,id:n,name:r,subjectId:i}=e;return typeof n!=`string`||typeof r!=`string`||typeof i!=`string`||!Array.isArray(t)?null:{args:t,id:n,name:r,subjectId:i}}function hostTavern(n){let r=(0,e.createMeshHost)({maxMessageBytes:t,rtc:n.rtc}),i=!1,a=!1,close=()=>{if(!i){i=!0;for(let e of o.splice(0))e();r.dispose(),n.onEnded?.()}},readSnapshot=()=>{try{return n.subjects.snapshot()}catch(e){return n.onWarning?.(e instanceof Error?e.message:`Reading the snapshot failed.`),null}},broadcastSnapshot=()=>{i||a||(a=!0,queueMicrotask(()=>{if(a=!1,i)return;let e=readSnapshot();e!=null&&r.broadcast(`snapshot`,e)}))},handleGuestCommand=(e,t)=>{let i=parseWireCommand(t);if(!i||!n.commands.has(i.name)||i.subjectId!==n.subjectId){r.send(e,`rejected`,{commandId:i?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{n.commands.apply(i.name,i.args)}catch(t){r.send(e,`rejected`,{commandId:i.id,message:t instanceof Error?t.message:`The host could not apply the command.`})}},o=[r.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),n.subjects.onChanged(broadcastSnapshot),n.subjects.onRemoved(close),r.tap(e=>{if((e.type===`peer-joined`||e.type===`peer-left`)&&n.onPeersChanged?.([...r.peers.values()]),e.type===`peer-joined`){let t=readSnapshot();t!=null&&r.send(e.peer.id,`snapshot`,t),n.onPeerJoined?.(e.peer)}e.type===`peer-left`&&n.onPeerLeft?.(e.peer),e.type===`error`&&n.onWarning?.(e.error.message)})];return{acceptAnswerText:async t=>{let n;try{n=await e.meshQrCodec.decode(t)}catch(t){throw t instanceof e.MeshPairingError?toPairingError(t):t}if(!(`proof`in n))throw new TavernPairingError(`That code is an invitation, not a guest answer.`);try{return await r.acceptAnswer(n)}catch(t){throw t instanceof e.MeshPairingError?toPairingError(t):t}},createInvitationText:async()=>e.meshQrCodec.encode(await r.createInvitation()),get disposalSignal(){return r.disposalSignal},dispose:close,get disposed(){return i},kick:e=>{r.kick(e)},relayNotice:e=>{if(i)return;let t=n.notices?n.notices.toWire(e):null;t!=null&&r.broadcast(`notice`,t)},[Symbol.dispose](){close()}}}async function joinTavern(n){let r;try{r=await e.meshQrCodec.decode(n.invitationText)}catch(t){throw t instanceof e.MeshPairingError?toPairingError(t):t}if(!(`secret`in r))throw new TavernPairingError(`That code is not a session invitation.`);let i=(0,e.createMeshGuest)({maxMessageBytes:t,rtc:n.rtc}),a=null,o=!1,endSession=()=>{if(o)return;o=!0;let e=a;a=null;for(let e of s.splice(0))e();i.dispose(),e!==null&&n.onEnded?.(e)},s=[i.on(`snapshot`,({payload:e})=>{let t=n.mount(e);t===null||o||a===null&&(a=t,n.onJoined?.(t))}),i.on(`notice`,({payload:e})=>{n.notices?.fromWire(e)}),i.on(`rejected`,({payload:e})=>{n.onRejected?.(e.message||`The host rejected the action.`)}),i.tap(e=>{(e.type===`status-change`||e.type===`peer-status-change`)&&(e.type!==`peer-status-change`||e.peerId===i.host?.id)&&(e.status===`failed`&&a===null&&!o&&n.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),i.tap(e=>{e.type===`error`&&n.onWarning?.(e.error.message)})];try{let t=await i.acceptInvitation(r,{name:n.name});return{answerText:await e.meshQrCodec.encode(t),guest:{get disposalSignal(){return i.disposalSignal},dispose:endSession,get disposed(){return o},sendCommand:(e,t,n)=>{if(o)throw new TavernError(`The session has ended.`);i.send(`command`,{args:[...n],id:commandId(),name:t,subjectId:e})},[Symbol.dispose](){endSession()}}}}catch(t){throw endSession(),t instanceof e.MeshPairingError?toPairingError(t):t}}exports.TavernError=TavernError,exports.TavernPairingError=TavernPairingError,exports.hostTavern=hostTavern,exports.joinTavern=joinTavern;
2
+ //# sourceMappingURL=tavern.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tavern.cjs","names":[],"sources":["../src/errors.ts","../src/index.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n","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"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ var Tavern=(function(e,t){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{};function toPairingError(e){return new TavernPairingError(e instanceof Error?e.message:`That code could not be read.`,{cause:e})}var n=524288,commandId=()=>`cmd-${Math.random().toString(36).slice(2,10)}${Date.now().toString(36)}`;function parseWireCommand(e){if(typeof e!=`object`||!e)return null;let{args:t,id:n,name:r,subjectId:i}=e;return typeof n!=`string`||typeof r!=`string`||typeof i!=`string`||!Array.isArray(t)?null:{args:t,id:n,name:r,subjectId:i}}function hostTavern(e){let r=(0,t.createMeshHost)({maxMessageBytes:n,rtc:e.rtc}),i=!1,a=!1,close=()=>{if(!i){i=!0;for(let e of o.splice(0))e();r.dispose(),e.onEnded?.()}},readSnapshot=()=>{try{return e.subjects.snapshot()}catch(t){return e.onWarning?.(t instanceof Error?t.message:`Reading the snapshot failed.`),null}},broadcastSnapshot=()=>{i||a||(a=!0,queueMicrotask(()=>{if(a=!1,i)return;let e=readSnapshot();e!=null&&r.broadcast(`snapshot`,e)}))},handleGuestCommand=(t,n)=>{let i=parseWireCommand(n);if(!i||!e.commands.has(i.name)||i.subjectId!==e.subjectId){r.send(t,`rejected`,{commandId:i?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{e.commands.apply(i.name,i.args)}catch(e){r.send(t,`rejected`,{commandId:i.id,message:e instanceof Error?e.message:`The host could not apply the command.`})}},o=[r.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),e.subjects.onChanged(broadcastSnapshot),e.subjects.onRemoved(close),r.tap(t=>{if((t.type===`peer-joined`||t.type===`peer-left`)&&e.onPeersChanged?.([...r.peers.values()]),t.type===`peer-joined`){let n=readSnapshot();n!=null&&r.send(t.peer.id,`snapshot`,n),e.onPeerJoined?.(t.peer)}t.type===`peer-left`&&e.onPeerLeft?.(t.peer),t.type===`error`&&e.onWarning?.(t.error.message)})];return{acceptAnswerText:async e=>{let n;try{n=await t.meshQrCodec.decode(e)}catch(e){throw e instanceof t.MeshPairingError?toPairingError(e):e}if(!(`proof`in n))throw new TavernPairingError(`That code is an invitation, not a guest answer.`);try{return await r.acceptAnswer(n)}catch(e){throw e instanceof t.MeshPairingError?toPairingError(e):e}},createInvitationText:async()=>t.meshQrCodec.encode(await r.createInvitation()),get disposalSignal(){return r.disposalSignal},dispose:close,get disposed(){return i},kick:e=>{r.kick(e)},relayNotice:t=>{if(i)return;let n=e.notices?e.notices.toWire(t):null;n!=null&&r.broadcast(`notice`,n)},[Symbol.dispose](){close()}}}async function joinTavern(e){let r;try{r=await t.meshQrCodec.decode(e.invitationText)}catch(e){throw e instanceof t.MeshPairingError?toPairingError(e):e}if(!(`secret`in r))throw new TavernPairingError(`That code is not a session invitation.`);let i=(0,t.createMeshGuest)({maxMessageBytes:n,rtc:e.rtc}),a=null,o=!1,endSession=()=>{if(o)return;o=!0;let t=a;a=null;for(let e of s.splice(0))e();i.dispose(),t!==null&&e.onEnded?.(t)},s=[i.on(`snapshot`,({payload:t})=>{let n=e.mount(t);n===null||o||a===null&&(a=n,e.onJoined?.(n))}),i.on(`notice`,({payload:t})=>{e.notices?.fromWire(t)}),i.on(`rejected`,({payload:t})=>{e.onRejected?.(t.message||`The host rejected the action.`)}),i.tap(t=>{(t.type===`status-change`||t.type===`peer-status-change`)&&(t.type!==`peer-status-change`||t.peerId===i.host?.id)&&(t.status===`failed`&&a===null&&!o&&e.onFailed?.(`The host did not accept the answer in time.`),(t.status===`disconnected`||t.status===`failed`)&&endSession())}),i.tap(t=>{t.type===`error`&&e.onWarning?.(t.error.message)})];try{let n=await i.acceptInvitation(r,{name:e.name});return{answerText:await t.meshQrCodec.encode(n),guest:{get disposalSignal(){return i.disposalSignal},dispose:endSession,get disposed(){return o},sendCommand:(e,t,n)=>{if(o)throw new TavernError(`The session has ended.`);i.send(`command`,{args:[...n],id:commandId(),name:t,subjectId:e})},[Symbol.dispose](){endSession()}}}}catch(e){throw endSession(),e instanceof t.MeshPairingError?toPairingError(e):e}}return e.TavernError=TavernError,e.TavernPairingError=TavernPairingError,e.hostTavern=hostTavern,e.joinTavern=joinTavern,e})({},Mesh);
2
+ //# sourceMappingURL=tavern.iife.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tavern.iife.js","names":[],"sources":["../src/errors.ts","../src/index.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n","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"],"mappings":"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"}
package/dist/tavern.js ADDED
@@ -0,0 +1,2 @@
1
+ import{MeshPairingError as e,createMeshGuest as t,createMeshHost as n,meshQrCodec as r}from"@vielzeug/mesh";var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{};function toPairingError(e){return new TavernPairingError(e instanceof Error?e.message:`That code could not be read.`,{cause:e})}var i=524288,commandId=()=>`cmd-${Math.random().toString(36).slice(2,10)}${Date.now().toString(36)}`;function parseWireCommand(e){if(typeof e!=`object`||!e)return null;let{args:t,id:n,name:r,subjectId:i}=e;return typeof n!=`string`||typeof r!=`string`||typeof i!=`string`||!Array.isArray(t)?null:{args:t,id:n,name:r,subjectId:i}}function hostTavern(t){let a=n({maxMessageBytes:i,rtc:t.rtc}),o=!1,s=!1,close=()=>{if(!o){o=!0;for(let e of c.splice(0))e();a.dispose(),t.onEnded?.()}},readSnapshot=()=>{try{return t.subjects.snapshot()}catch(e){return t.onWarning?.(e instanceof Error?e.message:`Reading the snapshot failed.`),null}},broadcastSnapshot=()=>{o||s||(s=!0,queueMicrotask(()=>{if(s=!1,o)return;let e=readSnapshot();e!=null&&a.broadcast(`snapshot`,e)}))},handleGuestCommand=(e,n)=>{let r=parseWireCommand(n);if(!r||!t.commands.has(r.name)||r.subjectId!==t.subjectId){a.send(e,`rejected`,{commandId:r?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{t.commands.apply(r.name,r.args)}catch(t){a.send(e,`rejected`,{commandId:r.id,message:t instanceof Error?t.message:`The host could not apply the command.`})}},c=[a.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),t.subjects.onChanged(broadcastSnapshot),t.subjects.onRemoved(close),a.tap(e=>{if((e.type===`peer-joined`||e.type===`peer-left`)&&t.onPeersChanged?.([...a.peers.values()]),e.type===`peer-joined`){let n=readSnapshot();n!=null&&a.send(e.peer.id,`snapshot`,n),t.onPeerJoined?.(e.peer)}e.type===`peer-left`&&t.onPeerLeft?.(e.peer),e.type===`error`&&t.onWarning?.(e.error.message)})];return{acceptAnswerText:async t=>{let n;try{n=await r.decode(t)}catch(t){throw t instanceof e?toPairingError(t):t}if(!(`proof`in n))throw new TavernPairingError(`That code is an invitation, not a guest answer.`);try{return await a.acceptAnswer(n)}catch(t){throw t instanceof e?toPairingError(t):t}},createInvitationText:async()=>r.encode(await a.createInvitation()),get disposalSignal(){return a.disposalSignal},dispose:close,get disposed(){return o},kick:e=>{a.kick(e)},relayNotice:e=>{if(o)return;let n=t.notices?t.notices.toWire(e):null;n!=null&&a.broadcast(`notice`,n)},[Symbol.dispose](){close()}}}async function joinTavern(n){let a;try{a=await r.decode(n.invitationText)}catch(t){throw t instanceof e?toPairingError(t):t}if(!(`secret`in a))throw new TavernPairingError(`That code is not a session invitation.`);let o=t({maxMessageBytes:i,rtc:n.rtc}),s=null,c=!1,endSession=()=>{if(c)return;c=!0;let e=s;s=null;for(let e of l.splice(0))e();o.dispose(),e!==null&&n.onEnded?.(e)},l=[o.on(`snapshot`,({payload:e})=>{let t=n.mount(e);t===null||c||s===null&&(s=t,n.onJoined?.(t))}),o.on(`notice`,({payload:e})=>{n.notices?.fromWire(e)}),o.on(`rejected`,({payload:e})=>{n.onRejected?.(e.message||`The host rejected the action.`)}),o.tap(e=>{(e.type===`status-change`||e.type===`peer-status-change`)&&(e.type!==`peer-status-change`||e.peerId===o.host?.id)&&(e.status===`failed`&&s===null&&!c&&n.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),o.tap(e=>{e.type===`error`&&n.onWarning?.(e.error.message)})];try{let e=await o.acceptInvitation(a,{name:n.name});return{answerText:await r.encode(e),guest:{get disposalSignal(){return o.disposalSignal},dispose:endSession,get disposed(){return c},sendCommand:(e,t,n)=>{if(c)throw new TavernError(`The session has ended.`);o.send(`command`,{args:[...n],id:commandId(),name:t,subjectId:e})},[Symbol.dispose](){endSession()}}}}catch(t){throw endSession(),t instanceof e?toPairingError(t):t}}export{TavernError,TavernPairingError,hostTavern,joinTavern};
2
+ //# sourceMappingURL=tavern.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tavern.js","names":[],"sources":["../src/errors.ts","../src/index.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n","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"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@vielzeug/tavern",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "26.10.0",
4
+ "description": "Table sessions over mesh — host-owned state replication with guest command forwarding",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/helmuthdu/vielzeug",
8
+ "directory": "packages/tavern"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "main": "./dist/index.cjs",
18
+ "module": "./dist/index.js",
19
+ "types": "dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "import": "./dist/index.js",
23
+ "require": "./dist/index.cjs",
24
+ "types": "./dist/index.d.ts"
25
+ }
26
+ },
27
+ "sideEffects": false,
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/"
31
+ },
32
+ "scripts": {
33
+ "build": "vite build && pnpm --silent run build:bundle && pnpm --silent run build:types",
34
+ "build:bundle": "vite build --config vite.bundle.config.ts",
35
+ "build:types": "tsc -p tsconfig.declarations.json",
36
+ "fix": "biome check --write src",
37
+ "lint": "biome ci src",
38
+ "prepublishOnly": "pnpm --silent run build",
39
+ "test": "vitest"
40
+ },
41
+ "dependencies": {
42
+ "@vielzeug/mesh": "26.10.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^26.6.2",
46
+ "typescript": "^6.0.3",
47
+ "vite": "^8.3.0",
48
+ "vitest": "^5.0.1"
49
+ }
50
+ }