@vielzeug/tavern 26.10.0 → 26.10.5

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,6 +1,6 @@
1
1
  # @vielzeug/tavern
2
2
 
3
- > Table sessions over mesh — host-owned state replication with guest command forwarding
3
+ > Table sessions over mesh: host-owned state replication with guest command forwarding
4
4
 
5
5
  ## Installation
6
6
 
@@ -48,4 +48,4 @@ guest.sendCommand('doc-1', 'rename', ['Quarterly report']);
48
48
 
49
49
  ## License
50
50
 
51
- MIT © [Helmuth Saatkamp](https://github.com/helmuthdu) — part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
51
+ MIT © [Helmuth Saatkamp](https://github.com/helmuthdu): part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
@@ -0,0 +1,2 @@
1
+ function createTappers(){let e=new Set,t=!1;return{emit(t){if(e.size!==0)for(let n of e)try{n(t)}catch{}},stop(){t=!0,e.clear()},tap(n,r){if(t||r?.signal?.aborted)return()=>{};let detach=()=>{e.delete(n)};return e.add(n),r?.signal?.addEventListener(`abort`,detach,{once:!0}),detach}}}exports.createTappers=createTappers;
2
+ //# sourceMappingURL=_tappers.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_tappers.cjs","names":[],"sources":["../src/_tappers.ts"],"sourcesContent":["/**\n * The `tap()` machinery shared by the host and guest handles. Kept private: the\n * public contract is each handle's `tap` method and its event union, not this.\n *\n * Follows the repo observability rules: zero overhead with no tappers, handler\n * errors swallowed, auto-detach on an abort signal, and a no-op unsubscribe once\n * the handle has stopped.\n */\nexport interface Tappers<E extends { readonly type: string }> {\n /** Emits to every tapper; a no-op when none are registered. */\n emit(event: E): void;\n /** Drops every tapper and makes later `tap` calls no-ops. Idempotent. */\n stop(): void;\n tap(handler: (event: E) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport function createTappers<E extends { readonly type: string }>(): Tappers<E> {\n const tappers = new Set<(event: E) => void>();\n let stopped = false;\n\n return {\n emit(event): void {\n if (tappers.size === 0) return;\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect session behavior.\n }\n }\n },\n stop(): void {\n stopped = true;\n tappers.clear();\n },\n tap(handler, options): () => void {\n if (stopped || options?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n options?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n };\n}\n"],"mappings":"AAgBA,SAAgB,eAAiE,CAC/E,IAAM,EAAU,IAAI,IAChB,EAAU,GAEd,MAAO,CACL,KAAK,EAAa,CACZ,KAAQ,OAAS,EACrB,IAAK,IAAM,KAAU,EACnB,GAAI,CACF,EAAO,CAAK,CACd,MAAQ,CAER,CAEJ,EACA,MAAa,CACX,EAAU,GACV,EAAQ,MAAM,CAChB,EACA,IAAI,EAAS,EAAqB,CAChC,GAAI,GAAW,GAAS,QAAQ,QAAS,UAAa,CAAC,EACvD,IAAM,WAAqB,CACzB,EAAQ,OAAO,CAAO,CACxB,EAGA,OAFA,EAAQ,IAAI,CAAO,EACnB,GAAS,QAAQ,iBAAiB,QAAS,OAAQ,CAAE,KAAM,EAAK,CAAC,EAC1D,MACT,CACF,CACF"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `tap()` machinery shared by the host and guest handles. Kept private: the
3
+ * public contract is each handle's `tap` method and its event union, not this.
4
+ *
5
+ * Follows the repo observability rules: zero overhead with no tappers, handler
6
+ * errors swallowed, auto-detach on an abort signal, and a no-op unsubscribe once
7
+ * the handle has stopped.
8
+ */
9
+ export interface Tappers<E extends {
10
+ readonly type: string;
11
+ }> {
12
+ /** Emits to every tapper; a no-op when none are registered. */
13
+ emit(event: E): void;
14
+ /** Drops every tapper and makes later `tap` calls no-ops. Idempotent. */
15
+ stop(): void;
16
+ tap(handler: (event: E) => void, options?: {
17
+ readonly signal?: AbortSignal;
18
+ }): () => void;
19
+ }
20
+ export declare function createTappers<E extends {
21
+ readonly type: string;
22
+ }>(): Tappers<E>;
23
+ //# sourceMappingURL=_tappers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_tappers.d.ts","sourceRoot":"","sources":["../src/_tappers.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,OAAO,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE;IAC1D,+DAA+D;IAC/D,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACrB,yEAAyE;IACzE,IAAI,IAAI,IAAI,CAAC;IACb,GAAG,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,MAAM,IAAI,CAAC;CAC3F;AAED,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAAC,CAAC,CAAC,CA6B/E"}
@@ -0,0 +1,2 @@
1
+ function createTappers(){let e=new Set,t=!1;return{emit(t){if(e.size!==0)for(let n of e)try{n(t)}catch{}},stop(){t=!0,e.clear()},tap(n,r){if(t||r?.signal?.aborted)return()=>{};let detach=()=>{e.delete(n)};return e.add(n),r?.signal?.addEventListener(`abort`,detach,{once:!0}),detach}}}export{createTappers};
2
+ //# sourceMappingURL=_tappers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_tappers.js","names":[],"sources":["../src/_tappers.ts"],"sourcesContent":["/**\n * The `tap()` machinery shared by the host and guest handles. Kept private: the\n * public contract is each handle's `tap` method and its event union, not this.\n *\n * Follows the repo observability rules: zero overhead with no tappers, handler\n * errors swallowed, auto-detach on an abort signal, and a no-op unsubscribe once\n * the handle has stopped.\n */\nexport interface Tappers<E extends { readonly type: string }> {\n /** Emits to every tapper; a no-op when none are registered. */\n emit(event: E): void;\n /** Drops every tapper and makes later `tap` calls no-ops. Idempotent. */\n stop(): void;\n tap(handler: (event: E) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport function createTappers<E extends { readonly type: string }>(): Tappers<E> {\n const tappers = new Set<(event: E) => void>();\n let stopped = false;\n\n return {\n emit(event): void {\n if (tappers.size === 0) return;\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect session behavior.\n }\n }\n },\n stop(): void {\n stopped = true;\n tappers.clear();\n },\n tap(handler, options): () => void {\n if (stopped || options?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n options?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n };\n}\n"],"mappings":"AAgBA,SAAgB,eAAiE,CAC/E,IAAM,EAAU,IAAI,IAChB,EAAU,GAEd,MAAO,CACL,KAAK,EAAa,CACZ,KAAQ,OAAS,EACrB,IAAK,IAAM,KAAU,EACnB,GAAI,CACF,EAAO,CAAK,CACd,MAAQ,CAER,CAEJ,EACA,MAAa,CACX,EAAU,GACV,EAAQ,MAAM,CAChB,EACA,IAAI,EAAS,EAAqB,CAChC,GAAI,GAAW,GAAS,QAAQ,QAAS,UAAa,CAAC,EACvD,IAAM,WAAqB,CACzB,EAAQ,OAAO,CAAO,CACxB,EAGA,OAFA,EAAQ,IAAI,CAAO,EACnB,GAAS,QAAQ,iBAAiB,QAAS,OAAQ,CAAE,KAAM,EAAK,CAAC,EAC1D,MACT,CACF,CACF"}
package/dist/errors.cjs CHANGED
@@ -1,2 +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;
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{},TavernDisposedError=class extends TavernError{};exports.TavernDisposedError=TavernDisposedError,exports.TavernError=TavernError,exports.TavernPairingError=TavernPairingError;
2
2
  //# sourceMappingURL=errors.cjs.map
@@ -1 +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"}
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\n/**\n * Thrown when a stopped host or an ended guest session is used again. Mirrors\n * mesh's `MeshDisposedError` inside the tavern error hierarchy, so\n * `instanceof TavernError` catches every tavern failure in one branch.\n */\nexport class TavernDisposedError 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,EAOxC,oBAAb,cAAyC,WAAY,CAAC"}
package/dist/errors.d.ts CHANGED
@@ -5,7 +5,14 @@
5
5
  export declare class TavernError extends Error {
6
6
  constructor(message: string, opts?: ErrorOptions);
7
7
  }
8
- /** A pairing code the consumer pasted could not be used — a user-input mistake, not a bug. */
8
+ /** A pairing code the consumer pasted could not be used: a user-input mistake, not a bug. */
9
9
  export declare class TavernPairingError extends TavernError {
10
10
  }
11
+ /**
12
+ * Thrown when a stopped host or an ended guest session is used again. Mirrors
13
+ * mesh's `MeshDisposedError` inside the tavern error hierarchy, so
14
+ * `instanceof TavernError` catches every tavern failure in one branch.
15
+ */
16
+ export declare class TavernDisposedError extends TavernError {
17
+ }
11
18
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +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"}
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,6FAA6F;AAC7F,qBAAa,kBAAmB,SAAQ,WAAW;CAAG;AAEtD;;;;GAIG;AACH,qBAAa,mBAAoB,SAAQ,WAAW;CAAG"}
package/dist/errors.js CHANGED
@@ -1,2 +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};
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{},TavernDisposedError=class extends TavernError{};export{TavernDisposedError,TavernError,TavernPairingError};
2
2
  //# sourceMappingURL=errors.js.map
@@ -1 +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"}
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\n/**\n * Thrown when a stopped host or an ended guest session is used again. Mirrors\n * mesh's `MeshDisposedError` inside the tavern error hierarchy, so\n * `instanceof TavernError` catches every tavern failure in one branch.\n */\nexport class TavernDisposedError 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,EAOxC,oBAAb,cAAyC,WAAY,CAAC"}
package/dist/index.cjs CHANGED
@@ -1,2 +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;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./_tappers.cjs"),t=require("./errors.cjs");let n=require("@vielzeug/mesh");function toPairingError(e){let n=e instanceof Error?e.message:`That code could not be read.`;return new t.TavernPairingError(n,{cause:e})}var r=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(i){let a=(0,n.createMeshHost)({maxMessageBytes:r,rtc:i.rtc}),o=e.createTappers(),s=!1,c=!1,close=()=>{if(!s){s=!0;for(let e of l.splice(0))e();a.dispose(),o.stop(),i.onEnded?.()}},disposedError=()=>new t.TavernDisposedError(`This host has stopped.`),warn=e=>{o.emit({error:e,message:e instanceof Error?e.message:String(e),type:`warning`})},readSnapshot=()=>{try{return i.subjects.snapshot()}catch(e){return warn(e),null}},broadcastSnapshot=()=>{s||c||(c=!0,queueMicrotask(()=>{if(c=!1,s)return;let e=readSnapshot();e!=null&&a.broadcast(`snapshot`,e)}))},handleGuestCommand=(e,t)=>{let n=parseWireCommand(t);if(!n||!i.commands.has(n.name)||n.subjectId!==i.subjectId){a.send(e,`rejected`,{commandId:n?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{i.commands.apply(n.name,n.args)}catch(t){a.send(e,`rejected`,{commandId:n.id,message:t instanceof Error?t.message:`The host could not apply the command.`})}},l=[a.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),i.subjects.onChanged(broadcastSnapshot),i.subjects.onRemoved(close),a.tap(e=>{if(e.type===`peer-joined`){let t=readSnapshot();t!=null&&a.send(e.peer.id,`snapshot`,t),o.emit({peer:e.peer,type:`peer-joined`})}e.type===`peer-left`&&o.emit({peer:e.peer,reason:e.reason,type:`peer-left`}),e.type===`error`&&warn(e.error)})];return{acceptAnswerText:async e=>{if(s)throw disposedError();let r;try{r=await n.meshQrCodec.decode(e)}catch(e){throw e instanceof n.MeshPairingError?toPairingError(e):e}if(!(`proof`in r))throw new t.TavernPairingError(`That code is an invitation, not a guest answer.`);try{return await a.acceptAnswer(r)}catch(e){throw e instanceof n.MeshPairingError?toPairingError(e):e}},createInvitationText:async()=>{if(s)throw disposedError();return n.meshQrCodec.encode(await a.createInvitation())},get disposalSignal(){return a.disposalSignal},dispose:close,get disposed(){return s},kick:e=>{if(s)throw disposedError();a.kick(e)},get peers(){return[...a.peers.values()]},relayNotice:e=>{if(s)return;let t=i.notices?i.notices.toWire(e):null;t!=null&&a.broadcast(`notice`,t)},tap:o.tap,[Symbol.dispose](){close()}}}async function joinTavern(i){let a;try{a=await n.meshQrCodec.decode(i.invitationText)}catch(e){throw e instanceof n.MeshPairingError?toPairingError(e):e}if(!(`secret`in a))throw new t.TavernPairingError(`That code is not a session invitation.`);let o=(0,n.createMeshGuest)({maxMessageBytes:r,rtc:i.rtc}),s=e.createTappers(),c=null,l=!1,endSession=()=>{if(l)return;l=!0;let e=c;c=null;for(let e of u.splice(0))e();o.dispose(),s.stop(),e!==null&&i.onEnded?.(e)},u=[o.on(`snapshot`,({payload:e})=>{let t=i.mount(e);t===null||l||c===null&&(c=t,i.onJoined?.(t))}),o.on(`notice`,({payload:e})=>{i.notices?.fromWire(e)}),o.on(`rejected`,({payload:e})=>{s.emit({commandId:e.commandId,message:e.message||`The host rejected the action.`,type:`rejected`})}),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`&&c===null&&!l&&i.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),o.tap(e=>{e.type===`error`&&s.emit({error:e.error,message:e.error.message,type:`warning`})})];try{let e=await o.acceptInvitation(a,{name:i.name});return{answerText:await n.meshQrCodec.encode(e),guest:{get disposalSignal(){return o.disposalSignal},dispose:endSession,get disposed(){return l},sendCommand:(e,n,r)=>{if(l)throw new t.TavernDisposedError(`The session has ended.`);o.send(`command`,{args:[...r],id:commandId(),name:n,subjectId:e})},tap:s.tap,[Symbol.dispose](){endSession()}}}}catch(e){throw endSession(),e instanceof n.MeshPairingError?toPairingError(e):e}}exports.TavernDisposedError=t.TavernDisposedError,exports.TavernError=t.TavernError,exports.TavernPairingError=t.TavernPairingError,exports.hostTavern=hostTavern,exports.joinTavern=joinTavern;
2
2
  //# sourceMappingURL=index.cjs.map
@@ -1 +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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"}
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 { createTappers } from './_tappers';\nimport { TavernDisposedError, TavernError, TavernPairingError } from './errors';\n\nexport { TavernDisposedError, 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/** Notable host moments reported through {@link TavernHost.tap}. */\nexport type TavernHostEvent =\n | { readonly peer: MeshPeer; readonly type: 'peer-joined' }\n | { readonly peer: MeshPeer; readonly reason?: string; readonly type: 'peer-left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** Notable guest moments reported through {@link TavernGuest.tap}. */\nexport type TavernGuestEvent =\n | { readonly commandId: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\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 /**\n * Hosting ended: the subject was removed or the host was disposed. Fires exactly\n * once. This is a lifecycle transition, not observability: presence, transport\n * warnings, and the peer list all belong to {@link TavernHost.tap} and\n * {@link TavernHost.peers}.\n */\n onEnded?(): 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 /** The guests currently connected: read it from a `peer-joined`/`peer-left` tap event. */\n readonly peers: readonly MeshPeer[];\n /** Relays one local notice to every guest; the serializer decides what crosses.\n * A notice relayed after hosting ended is dropped, not thrown: it is ephemeral\n * UI state with no audience left, unlike the control-plane methods, which fail\n * with `TavernDisposedError` so a leaked handle surfaces. */\n relayNotice(notice: unknown): void;\n /** Subscribes to {@link TavernHostEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or hosting ends. */\n tap(handler: (event: TavernHostEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernHostEvent>();\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 tappers.stop();\n options.onEnded?.();\n };\n const disposedError = (): TavernDisposedError => new TavernDisposedError('This host has stopped.');\n const warn = (error: unknown): void => {\n tappers.emit({ error, message: error instanceof Error ? error.message : String(error), type: 'warning' });\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 warn(error);\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') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n tappers.emit({ peer: event.peer, type: 'peer-joined' });\n }\n if (event.type === 'peer-left') tappers.emit({ peer: event.peer, reason: event.reason, type: 'peer-left' });\n if (event.type === 'error') warn(event.error);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n if (closed) throw disposedError();\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 () => {\n if (closed) throw disposedError();\n return meshQrCodec.encode(await host.createInvitation());\n },\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n if (closed) throw disposedError();\n host.kick(peerId);\n },\n get peers(): readonly MeshPeer[] {\n return [...host.peers.values()];\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 tap: tappers.tap,\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 /**\n * The channel ended after joining; the mounted value is passed for unmounting.\n * Lifecycle, not observability: rejections and transport warnings belong to\n * {@link TavernGuest.tap}. `onJoined` and `onFailed` stay callbacks for the same\n * reason: they fire while {@link joinTavern} is still resolving, before the\n * consumer can hold the handle and register a tapper.\n */\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 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 * Throws `TavernDisposedError` after the session has ended.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Subscribes to {@link TavernGuestEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or the session ends. */\n tap(handler: (event: TavernGuestEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernGuestEvent>();\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 tappers.stop();\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 tappers.emit({\n commandId: payload.commandId,\n message: payload.message || 'The host rejected the action.',\n type: 'rejected',\n });\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') {\n tappers.emit({ error: event.error, message: event.error.message, type: 'warning' });\n }\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 TavernDisposedError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n tap: tappers.tap,\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/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { type MeshPeer, type MeshRtcFactory } from '@vielzeug/mesh';
2
- import { TavernError, TavernPairingError } from './errors';
3
- export { TavernError, TavernPairingError };
2
+ import { TavernDisposedError, TavernError, TavernPairingError } from './errors';
3
+ export { TavernDisposedError, TavernError, TavernPairingError };
4
4
  /**
5
5
  * Table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests
6
6
  * mirror it and forward every command through the host's command table, so guest actions follow
7
7
  * exactly the same code path as host actions.
8
8
  *
9
- * The consumer supplies the seams — which commands exist, how subject state is read and mounted,
9
+ * The consumer supplies the seams, which commands exist, how subject state is read and mounted,
10
10
  * how notices cross between the wire and the local UI. Tavern owns the pairing, the protocol,
11
11
  * command validation, snapshot broadcasting, notice relaying, and presence.
12
12
  */
@@ -19,22 +19,22 @@ export interface TavernCommands {
19
19
  }
20
20
  /**
21
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.
22
+ * subject state the consumer owns: the host routes by `subjectId`, not by a subject object.
23
23
  */
24
24
  export interface TavernSubjects {
25
- /** Subscribes to local changes of the hosted subject — the re-broadcast trigger. */
25
+ /** Subscribes to local changes of the hosted subject: the re-broadcast trigger. */
26
26
  onChanged(listener: () => void): () => void;
27
- /** Subscribes to the hosted subject's removal — hosting ends when it fires. */
27
+ /** Subscribes to the hosted subject's removal: hosting ends when it fires. */
28
28
  onRemoved(listener: () => void): () => void;
29
29
  /** Reads the snapshot to broadcast; null while the subject is missing. */
30
30
  snapshot(): unknown;
31
31
  }
32
32
  /**
33
- * Notice relay between host and guests. Notices cross the wire as opaque values — each client
33
+ * Notice relay between host and guests. Notices cross the wire as opaque values: each client
34
34
  * translates locally.
35
35
  *
36
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
37
+ * wire-originated notice back to its own guests: `fromWire` emits locally, the host's local
38
38
  * subscription picks it up, and without a guard it would loop. The consumer owns this guard
39
39
  * because only they know their local event system; one boolean set during `fromWire` and
40
40
  * checked in `toWire` is sufficient.
@@ -45,17 +45,41 @@ export interface TavernNotices {
45
45
  /** Serializes a local notice for the wire; null skips this one. */
46
46
  toWire(notice: unknown): unknown | null;
47
47
  }
48
+ /** Notable host moments reported through {@link TavernHost.tap}. */
49
+ export type TavernHostEvent = {
50
+ readonly peer: MeshPeer;
51
+ readonly type: 'peer-joined';
52
+ } | {
53
+ readonly peer: MeshPeer;
54
+ readonly reason?: string;
55
+ readonly type: 'peer-left';
56
+ } | {
57
+ readonly error: unknown;
58
+ readonly message: string;
59
+ readonly type: 'warning';
60
+ };
61
+ /** Notable guest moments reported through {@link TavernGuest.tap}. */
62
+ export type TavernGuestEvent = {
63
+ readonly commandId: string;
64
+ readonly message: string;
65
+ readonly type: 'rejected';
66
+ } | {
67
+ readonly error: unknown;
68
+ readonly message: string;
69
+ readonly type: 'warning';
70
+ };
48
71
  export interface TavernHostOptions {
49
72
  commands: TavernCommands;
50
73
  notices?: TavernNotices;
51
- /** Hosting ended — the subject was removed or the host was disposed. Fires exactly once. */
74
+ /**
75
+ * Hosting ended: the subject was removed or the host was disposed. Fires exactly
76
+ * once. This is a lifecycle transition, not observability: presence, transport
77
+ * warnings, and the peer list all belong to {@link TavernHost.tap} and
78
+ * {@link TavernHost.peers}.
79
+ */
52
80
  onEnded?(): void;
53
- onPeerJoined?(peer: MeshPeer): void;
54
- onPeerLeft?(peer: MeshPeer): void;
55
- onPeersChanged?(peers: MeshPeer[]): void;
56
- onWarning?(message: string): void;
57
81
  rtc?: MeshRtcFactory;
58
- /** The id of the subject being hosted — guest commands targeting anything else reject. */
82
+ /** The id of the subject being hosted: guest commands targeting anything else reject. */
59
83
  subjectId: string;
60
84
  subjects: TavernSubjects;
61
85
  }
@@ -68,11 +92,21 @@ export interface TavernHost {
68
92
  readonly disposalSignal: AbortSignal;
69
93
  /** Stops hosting: closes every channel and detaches all subscriptions. */
70
94
  dispose(): void;
71
- /** Whether hosting has ended — the subject was removed or `dispose()` ran. */
95
+ /** Whether hosting has ended: the subject was removed or `dispose()` ran. */
72
96
  readonly disposed: boolean;
73
97
  kick(peerId: string): void;
74
- /** Relays one local notice to every guest; the serializer decides what crosses. */
98
+ /** The guests currently connected: read it from a `peer-joined`/`peer-left` tap event. */
99
+ readonly peers: readonly MeshPeer[];
100
+ /** Relays one local notice to every guest; the serializer decides what crosses.
101
+ * A notice relayed after hosting ended is dropped, not thrown: it is ephemeral
102
+ * UI state with no audience left, unlike the control-plane methods, which fail
103
+ * with `TavernDisposedError` so a leaked handle surfaces. */
75
104
  relayNotice(notice: unknown): void;
105
+ /** Subscribes to {@link TavernHostEvent}s. Handler errors are swallowed; the
106
+ * subscription detaches when `signal` aborts or hosting ends. */
107
+ tap(handler: (event: TavernHostEvent) => void, options?: {
108
+ readonly signal?: AbortSignal;
109
+ }): () => void;
76
110
  /** Delegates to `dispose()`. Enables `using` declarations. */
77
111
  [Symbol.dispose](): void;
78
112
  }
@@ -87,21 +121,24 @@ export interface TavernGuestOptions<Mounted> {
87
121
  invitationText: string;
88
122
  /**
89
123
  * Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives
90
- * as parsed JSON — validate before trusting it.
124
+ * as parsed JSON: validate before trusting it.
91
125
  */
92
126
  mount(snapshot: unknown): Mounted | null;
93
127
  /** The guest's display name on the channel. */
94
128
  name: string;
95
129
  notices?: TavernNotices;
96
- /** The channel ended after joining; the mounted value is passed for unmounting. */
130
+ /**
131
+ * The channel ended after joining; the mounted value is passed for unmounting.
132
+ * Lifecycle, not observability: rejections and transport warnings belong to
133
+ * {@link TavernGuest.tap}. `onJoined` and `onFailed` stay callbacks for the same
134
+ * reason: they fire while {@link joinTavern} is still resolving, before the
135
+ * consumer can hold the handle and register a tapper.
136
+ */
97
137
  onEnded?(subject: Mounted): void;
98
138
  /** The host never accepted the answer in time. */
99
139
  onFailed?(reason: string): void;
100
140
  /** The mounted value once the first snapshot arrives. */
101
141
  onJoined?(subject: Mounted): void;
102
- /** The host rejected a forwarded command. */
103
- onRejected?(message: string): void;
104
- onWarning?(message: string): void;
105
142
  rtc?: MeshRtcFactory;
106
143
  }
107
144
  export interface TavernGuest {
@@ -109,13 +146,19 @@ export interface TavernGuest {
109
146
  readonly disposalSignal: AbortSignal;
110
147
  /** Leaves the session and drops the channel. */
111
148
  dispose(): void;
112
- /** Whether the session has ended — the channel dropped or `dispose()` ran. */
149
+ /** Whether the session has ended: the channel dropped or `dispose()` ran. */
113
150
  readonly disposed: boolean;
114
151
  /**
115
- * Forwards a command to the host. The subject id is the consumer's routing key — the host
152
+ * Forwards a command to the host. The subject id is the consumer's routing key: the host
116
153
  * rejects commands that do not name the subject it is hosting.
154
+ * Throws `TavernDisposedError` after the session has ended.
117
155
  */
118
156
  sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;
157
+ /** Subscribes to {@link TavernGuestEvent}s. Handler errors are swallowed; the
158
+ * subscription detaches when `signal` aborts or the session ends. */
159
+ tap(handler: (event: TavernGuestEvent) => void, options?: {
160
+ readonly signal?: AbortSignal;
161
+ }): () => void;
119
162
  /** Delegates to `dispose()`. Enables `using` declarations. */
120
163
  [Symbol.dispose](): void;
121
164
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,2 +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};
1
+ import{createTappers as e}from"./_tappers.js";import{TavernDisposedError as t,TavernError as n,TavernPairingError as r}from"./errors.js";import{MeshPairingError as i,createMeshGuest as a,createMeshHost as o,meshQrCodec as s}from"@vielzeug/mesh";function toPairingError(e){let t=e instanceof Error?e.message:`That code could not be read.`;return new r(t,{cause:e})}var c=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 a=o({maxMessageBytes:c,rtc:n.rtc}),l=e(),u=!1,d=!1,close=()=>{if(!u){u=!0;for(let e of f.splice(0))e();a.dispose(),l.stop(),n.onEnded?.()}},disposedError=()=>new t(`This host has stopped.`),warn=e=>{l.emit({error:e,message:e instanceof Error?e.message:String(e),type:`warning`})},readSnapshot=()=>{try{return n.subjects.snapshot()}catch(e){return warn(e),null}},broadcastSnapshot=()=>{u||d||(d=!0,queueMicrotask(()=>{if(d=!1,u)return;let e=readSnapshot();e!=null&&a.broadcast(`snapshot`,e)}))},handleGuestCommand=(e,t)=>{let r=parseWireCommand(t);if(!r||!n.commands.has(r.name)||r.subjectId!==n.subjectId){a.send(e,`rejected`,{commandId:r?.id??``,message:`The host rejected an unknown or out-of-session command.`});return}try{n.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.`})}},f=[a.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),n.subjects.onChanged(broadcastSnapshot),n.subjects.onRemoved(close),a.tap(e=>{if(e.type===`peer-joined`){let t=readSnapshot();t!=null&&a.send(e.peer.id,`snapshot`,t),l.emit({peer:e.peer,type:`peer-joined`})}e.type===`peer-left`&&l.emit({peer:e.peer,reason:e.reason,type:`peer-left`}),e.type===`error`&&warn(e.error)})];return{acceptAnswerText:async e=>{if(u)throw disposedError();let t;try{t=await s.decode(e)}catch(e){throw e instanceof i?toPairingError(e):e}if(!(`proof`in t))throw new r(`That code is an invitation, not a guest answer.`);try{return await a.acceptAnswer(t)}catch(e){throw e instanceof i?toPairingError(e):e}},createInvitationText:async()=>{if(u)throw disposedError();return s.encode(await a.createInvitation())},get disposalSignal(){return a.disposalSignal},dispose:close,get disposed(){return u},kick:e=>{if(u)throw disposedError();a.kick(e)},get peers(){return[...a.peers.values()]},relayNotice:e=>{if(u)return;let t=n.notices?n.notices.toWire(e):null;t!=null&&a.broadcast(`notice`,t)},tap:l.tap,[Symbol.dispose](){close()}}}async function joinTavern(n){let o;try{o=await s.decode(n.invitationText)}catch(e){throw e instanceof i?toPairingError(e):e}if(!(`secret`in o))throw new r(`That code is not a session invitation.`);let l=a({maxMessageBytes:c,rtc:n.rtc}),u=e(),d=null,f=!1,endSession=()=>{if(f)return;f=!0;let e=d;d=null;for(let e of p.splice(0))e();l.dispose(),u.stop(),e!==null&&n.onEnded?.(e)},p=[l.on(`snapshot`,({payload:e})=>{let t=n.mount(e);t===null||f||d===null&&(d=t,n.onJoined?.(t))}),l.on(`notice`,({payload:e})=>{n.notices?.fromWire(e)}),l.on(`rejected`,({payload:e})=>{u.emit({commandId:e.commandId,message:e.message||`The host rejected the action.`,type:`rejected`})}),l.tap(e=>{(e.type===`status-change`||e.type===`peer-status-change`)&&(e.type!==`peer-status-change`||e.peerId===l.host?.id)&&(e.status===`failed`&&d===null&&!f&&n.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),l.tap(e=>{e.type===`error`&&u.emit({error:e.error,message:e.error.message,type:`warning`})})];try{let e=await l.acceptInvitation(o,{name:n.name});return{answerText:await s.encode(e),guest:{get disposalSignal(){return l.disposalSignal},dispose:endSession,get disposed(){return f},sendCommand:(e,n,r)=>{if(f)throw new t(`The session has ended.`);l.send(`command`,{args:[...r],id:commandId(),name:n,subjectId:e})},tap:u.tap,[Symbol.dispose](){endSession()}}}}catch(e){throw endSession(),e instanceof i?toPairingError(e):e}}export{t as TavernDisposedError,n as TavernError,r as TavernPairingError,hostTavern,joinTavern};
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +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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"}
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 { createTappers } from './_tappers';\nimport { TavernDisposedError, TavernError, TavernPairingError } from './errors';\n\nexport { TavernDisposedError, 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/** Notable host moments reported through {@link TavernHost.tap}. */\nexport type TavernHostEvent =\n | { readonly peer: MeshPeer; readonly type: 'peer-joined' }\n | { readonly peer: MeshPeer; readonly reason?: string; readonly type: 'peer-left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** Notable guest moments reported through {@link TavernGuest.tap}. */\nexport type TavernGuestEvent =\n | { readonly commandId: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\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 /**\n * Hosting ended: the subject was removed or the host was disposed. Fires exactly\n * once. This is a lifecycle transition, not observability: presence, transport\n * warnings, and the peer list all belong to {@link TavernHost.tap} and\n * {@link TavernHost.peers}.\n */\n onEnded?(): 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 /** The guests currently connected: read it from a `peer-joined`/`peer-left` tap event. */\n readonly peers: readonly MeshPeer[];\n /** Relays one local notice to every guest; the serializer decides what crosses.\n * A notice relayed after hosting ended is dropped, not thrown: it is ephemeral\n * UI state with no audience left, unlike the control-plane methods, which fail\n * with `TavernDisposedError` so a leaked handle surfaces. */\n relayNotice(notice: unknown): void;\n /** Subscribes to {@link TavernHostEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or hosting ends. */\n tap(handler: (event: TavernHostEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernHostEvent>();\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 tappers.stop();\n options.onEnded?.();\n };\n const disposedError = (): TavernDisposedError => new TavernDisposedError('This host has stopped.');\n const warn = (error: unknown): void => {\n tappers.emit({ error, message: error instanceof Error ? error.message : String(error), type: 'warning' });\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 warn(error);\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') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n tappers.emit({ peer: event.peer, type: 'peer-joined' });\n }\n if (event.type === 'peer-left') tappers.emit({ peer: event.peer, reason: event.reason, type: 'peer-left' });\n if (event.type === 'error') warn(event.error);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n if (closed) throw disposedError();\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 () => {\n if (closed) throw disposedError();\n return meshQrCodec.encode(await host.createInvitation());\n },\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n if (closed) throw disposedError();\n host.kick(peerId);\n },\n get peers(): readonly MeshPeer[] {\n return [...host.peers.values()];\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 tap: tappers.tap,\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 /**\n * The channel ended after joining; the mounted value is passed for unmounting.\n * Lifecycle, not observability: rejections and transport warnings belong to\n * {@link TavernGuest.tap}. `onJoined` and `onFailed` stay callbacks for the same\n * reason: they fire while {@link joinTavern} is still resolving, before the\n * consumer can hold the handle and register a tapper.\n */\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 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 * Throws `TavernDisposedError` after the session has ended.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Subscribes to {@link TavernGuestEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or the session ends. */\n tap(handler: (event: TavernGuestEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernGuestEvent>();\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 tappers.stop();\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 tappers.emit({\n commandId: payload.commandId,\n message: payload.message || 'The host rejected the action.',\n type: 'rejected',\n });\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') {\n tappers.emit({ error: event.error, message: event.error.message, type: 'warning' });\n }\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 TavernDisposedError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n tap: tappers.tap,\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.cjs CHANGED
@@ -1,2 +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;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@vielzeug/mesh");function createTappers(){let e=new Set,t=!1;return{emit(t){if(e.size!==0)for(let n of e)try{n(t)}catch{}},stop(){t=!0,e.clear()},tap(n,r){if(t||r?.signal?.aborted)return()=>{};let detach=()=>{e.delete(n)};return e.add(n),r?.signal?.addEventListener(`abort`,detach,{once:!0}),detach}}}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{},TavernDisposedError=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=createTappers(),a=!1,o=!1,close=()=>{if(!a){a=!0;for(let e of s.splice(0))e();r.dispose(),i.stop(),n.onEnded?.()}},disposedError=()=>new TavernDisposedError(`This host has stopped.`),warn=e=>{i.emit({error:e,message:e instanceof Error?e.message:String(e),type:`warning`})},readSnapshot=()=>{try{return n.subjects.snapshot()}catch(e){return warn(e),null}},broadcastSnapshot=()=>{a||o||(o=!0,queueMicrotask(()=>{if(o=!1,a)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.`})}},s=[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`){let t=readSnapshot();t!=null&&r.send(e.peer.id,`snapshot`,t),i.emit({peer:e.peer,type:`peer-joined`})}e.type===`peer-left`&&i.emit({peer:e.peer,reason:e.reason,type:`peer-left`}),e.type===`error`&&warn(e.error)})];return{acceptAnswerText:async t=>{if(a)throw disposedError();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()=>{if(a)throw disposedError();return e.meshQrCodec.encode(await r.createInvitation())},get disposalSignal(){return r.disposalSignal},dispose:close,get disposed(){return a},kick:e=>{if(a)throw disposedError();r.kick(e)},get peers(){return[...r.peers.values()]},relayNotice:e=>{if(a)return;let t=n.notices?n.notices.toWire(e):null;t!=null&&r.broadcast(`notice`,t)},tap:i.tap,[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=createTappers(),o=null,s=!1,endSession=()=>{if(s)return;s=!0;let e=o;o=null;for(let e of c.splice(0))e();i.dispose(),a.stop(),e!==null&&n.onEnded?.(e)},c=[i.on(`snapshot`,({payload:e})=>{let t=n.mount(e);t===null||s||o===null&&(o=t,n.onJoined?.(t))}),i.on(`notice`,({payload:e})=>{n.notices?.fromWire(e)}),i.on(`rejected`,({payload:e})=>{a.emit({commandId:e.commandId,message:e.message||`The host rejected the action.`,type:`rejected`})}),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`&&o===null&&!s&&n.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),i.tap(e=>{e.type===`error`&&a.emit({error:e.error,message:e.error.message,type:`warning`})})];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 s},sendCommand:(e,t,n)=>{if(s)throw new TavernDisposedError(`The session has ended.`);i.send(`command`,{args:[...n],id:commandId(),name:t,subjectId:e})},tap:a.tap,[Symbol.dispose](){endSession()}}}}catch(t){throw endSession(),t instanceof e.MeshPairingError?toPairingError(t):t}}exports.TavernDisposedError=TavernDisposedError,exports.TavernError=TavernError,exports.TavernPairingError=TavernPairingError,exports.hostTavern=hostTavern,exports.joinTavern=joinTavern;
2
2
  //# sourceMappingURL=tavern.cjs.map
@@ -1 +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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"}
1
+ {"version":3,"file":"tavern.cjs","names":[],"sources":["../src/_tappers.ts","../src/errors.ts","../src/index.ts"],"sourcesContent":["/**\n * The `tap()` machinery shared by the host and guest handles. Kept private: the\n * public contract is each handle's `tap` method and its event union, not this.\n *\n * Follows the repo observability rules: zero overhead with no tappers, handler\n * errors swallowed, auto-detach on an abort signal, and a no-op unsubscribe once\n * the handle has stopped.\n */\nexport interface Tappers<E extends { readonly type: string }> {\n /** Emits to every tapper; a no-op when none are registered. */\n emit(event: E): void;\n /** Drops every tapper and makes later `tap` calls no-ops. Idempotent. */\n stop(): void;\n tap(handler: (event: E) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport function createTappers<E extends { readonly type: string }>(): Tappers<E> {\n const tappers = new Set<(event: E) => void>();\n let stopped = false;\n\n return {\n emit(event): void {\n if (tappers.size === 0) return;\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect session behavior.\n }\n }\n },\n stop(): void {\n stopped = true;\n tappers.clear();\n },\n tap(handler, options): () => void {\n if (stopped || options?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n options?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n };\n}\n","/**\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\n/**\n * Thrown when a stopped host or an ended guest session is used again. Mirrors\n * mesh's `MeshDisposedError` inside the tavern error hierarchy, so\n * `instanceof TavernError` catches every tavern failure in one branch.\n */\nexport class TavernDisposedError 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 { createTappers } from './_tappers';\nimport { TavernDisposedError, TavernError, TavernPairingError } from './errors';\n\nexport { TavernDisposedError, 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/** Notable host moments reported through {@link TavernHost.tap}. */\nexport type TavernHostEvent =\n | { readonly peer: MeshPeer; readonly type: 'peer-joined' }\n | { readonly peer: MeshPeer; readonly reason?: string; readonly type: 'peer-left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** Notable guest moments reported through {@link TavernGuest.tap}. */\nexport type TavernGuestEvent =\n | { readonly commandId: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\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 /**\n * Hosting ended: the subject was removed or the host was disposed. Fires exactly\n * once. This is a lifecycle transition, not observability: presence, transport\n * warnings, and the peer list all belong to {@link TavernHost.tap} and\n * {@link TavernHost.peers}.\n */\n onEnded?(): 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 /** The guests currently connected: read it from a `peer-joined`/`peer-left` tap event. */\n readonly peers: readonly MeshPeer[];\n /** Relays one local notice to every guest; the serializer decides what crosses.\n * A notice relayed after hosting ended is dropped, not thrown: it is ephemeral\n * UI state with no audience left, unlike the control-plane methods, which fail\n * with `TavernDisposedError` so a leaked handle surfaces. */\n relayNotice(notice: unknown): void;\n /** Subscribes to {@link TavernHostEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or hosting ends. */\n tap(handler: (event: TavernHostEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernHostEvent>();\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 tappers.stop();\n options.onEnded?.();\n };\n const disposedError = (): TavernDisposedError => new TavernDisposedError('This host has stopped.');\n const warn = (error: unknown): void => {\n tappers.emit({ error, message: error instanceof Error ? error.message : String(error), type: 'warning' });\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 warn(error);\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') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n tappers.emit({ peer: event.peer, type: 'peer-joined' });\n }\n if (event.type === 'peer-left') tappers.emit({ peer: event.peer, reason: event.reason, type: 'peer-left' });\n if (event.type === 'error') warn(event.error);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n if (closed) throw disposedError();\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 () => {\n if (closed) throw disposedError();\n return meshQrCodec.encode(await host.createInvitation());\n },\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n if (closed) throw disposedError();\n host.kick(peerId);\n },\n get peers(): readonly MeshPeer[] {\n return [...host.peers.values()];\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 tap: tappers.tap,\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 /**\n * The channel ended after joining; the mounted value is passed for unmounting.\n * Lifecycle, not observability: rejections and transport warnings belong to\n * {@link TavernGuest.tap}. `onJoined` and `onFailed` stay callbacks for the same\n * reason: they fire while {@link joinTavern} is still resolving, before the\n * consumer can hold the handle and register a tapper.\n */\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 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 * Throws `TavernDisposedError` after the session has ended.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Subscribes to {@link TavernGuestEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or the session ends. */\n tap(handler: (event: TavernGuestEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernGuestEvent>();\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 tappers.stop();\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 tappers.emit({\n commandId: payload.commandId,\n message: payload.message || 'The host rejected the action.',\n type: 'rejected',\n });\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') {\n tappers.emit({ error: event.error, message: event.error.message, type: 'warning' });\n }\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 TavernDisposedError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n tap: tappers.tap,\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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"}
@@ -1,2 +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);
1
+ var Tavern=(function(e,t){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});function createTappers(){let e=new Set,t=!1;return{emit(t){if(e.size!==0)for(let n of e)try{n(t)}catch{}},stop(){t=!0,e.clear()},tap(n,r){if(t||r?.signal?.aborted)return()=>{};let detach=()=>{e.delete(n)};return e.add(n),r?.signal?.addEventListener(`abort`,detach,{once:!0}),detach}}}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{},TavernDisposedError=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=createTappers(),a=!1,o=!1,close=()=>{if(!a){a=!0;for(let e of s.splice(0))e();r.dispose(),i.stop(),e.onEnded?.()}},disposedError=()=>new TavernDisposedError(`This host has stopped.`),warn=e=>{i.emit({error:e,message:e instanceof Error?e.message:String(e),type:`warning`})},readSnapshot=()=>{try{return e.subjects.snapshot()}catch(e){return warn(e),null}},broadcastSnapshot=()=>{a||o||(o=!0,queueMicrotask(()=>{if(o=!1,a)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.`})}},s=[r.on(`command`,({peerId:e,payload:t})=>handleGuestCommand(e,t)),e.subjects.onChanged(broadcastSnapshot),e.subjects.onRemoved(close),r.tap(e=>{if(e.type===`peer-joined`){let t=readSnapshot();t!=null&&r.send(e.peer.id,`snapshot`,t),i.emit({peer:e.peer,type:`peer-joined`})}e.type===`peer-left`&&i.emit({peer:e.peer,reason:e.reason,type:`peer-left`}),e.type===`error`&&warn(e.error)})];return{acceptAnswerText:async e=>{if(a)throw disposedError();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()=>{if(a)throw disposedError();return t.meshQrCodec.encode(await r.createInvitation())},get disposalSignal(){return r.disposalSignal},dispose:close,get disposed(){return a},kick:e=>{if(a)throw disposedError();r.kick(e)},get peers(){return[...r.peers.values()]},relayNotice:t=>{if(a)return;let n=e.notices?e.notices.toWire(t):null;n!=null&&r.broadcast(`notice`,n)},tap:i.tap,[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=createTappers(),o=null,s=!1,endSession=()=>{if(s)return;s=!0;let t=o;o=null;for(let e of c.splice(0))e();i.dispose(),a.stop(),t!==null&&e.onEnded?.(t)},c=[i.on(`snapshot`,({payload:t})=>{let n=e.mount(t);n===null||s||o===null&&(o=n,e.onJoined?.(n))}),i.on(`notice`,({payload:t})=>{e.notices?.fromWire(t)}),i.on(`rejected`,({payload:e})=>{a.emit({commandId:e.commandId,message:e.message||`The host rejected the action.`,type:`rejected`})}),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`&&o===null&&!s&&e.onFailed?.(`The host did not accept the answer in time.`),(t.status===`disconnected`||t.status===`failed`)&&endSession())}),i.tap(e=>{e.type===`error`&&a.emit({error:e.error,message:e.error.message,type:`warning`})})];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 s},sendCommand:(e,t,n)=>{if(s)throw new TavernDisposedError(`The session has ended.`);i.send(`command`,{args:[...n],id:commandId(),name:t,subjectId:e})},tap:a.tap,[Symbol.dispose](){endSession()}}}}catch(e){throw endSession(),e instanceof t.MeshPairingError?toPairingError(e):e}}return e.TavernDisposedError=TavernDisposedError,e.TavernError=TavernError,e.TavernPairingError=TavernPairingError,e.hostTavern=hostTavern,e.joinTavern=joinTavern,e})({},Mesh);
2
2
  //# sourceMappingURL=tavern.iife.js.map
@@ -1 +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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"}
1
+ {"version":3,"file":"tavern.iife.js","names":[],"sources":["../src/_tappers.ts","../src/errors.ts","../src/index.ts"],"sourcesContent":["/**\n * The `tap()` machinery shared by the host and guest handles. Kept private: the\n * public contract is each handle's `tap` method and its event union, not this.\n *\n * Follows the repo observability rules: zero overhead with no tappers, handler\n * errors swallowed, auto-detach on an abort signal, and a no-op unsubscribe once\n * the handle has stopped.\n */\nexport interface Tappers<E extends { readonly type: string }> {\n /** Emits to every tapper; a no-op when none are registered. */\n emit(event: E): void;\n /** Drops every tapper and makes later `tap` calls no-ops. Idempotent. */\n stop(): void;\n tap(handler: (event: E) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport function createTappers<E extends { readonly type: string }>(): Tappers<E> {\n const tappers = new Set<(event: E) => void>();\n let stopped = false;\n\n return {\n emit(event): void {\n if (tappers.size === 0) return;\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect session behavior.\n }\n }\n },\n stop(): void {\n stopped = true;\n tappers.clear();\n },\n tap(handler, options): () => void {\n if (stopped || options?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n options?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n };\n}\n","/**\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\n/**\n * Thrown when a stopped host or an ended guest session is used again. Mirrors\n * mesh's `MeshDisposedError` inside the tavern error hierarchy, so\n * `instanceof TavernError` catches every tavern failure in one branch.\n */\nexport class TavernDisposedError 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 { createTappers } from './_tappers';\nimport { TavernDisposedError, TavernError, TavernPairingError } from './errors';\n\nexport { TavernDisposedError, 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/** Notable host moments reported through {@link TavernHost.tap}. */\nexport type TavernHostEvent =\n | { readonly peer: MeshPeer; readonly type: 'peer-joined' }\n | { readonly peer: MeshPeer; readonly reason?: string; readonly type: 'peer-left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** Notable guest moments reported through {@link TavernGuest.tap}. */\nexport type TavernGuestEvent =\n | { readonly commandId: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\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 /**\n * Hosting ended: the subject was removed or the host was disposed. Fires exactly\n * once. This is a lifecycle transition, not observability: presence, transport\n * warnings, and the peer list all belong to {@link TavernHost.tap} and\n * {@link TavernHost.peers}.\n */\n onEnded?(): 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 /** The guests currently connected: read it from a `peer-joined`/`peer-left` tap event. */\n readonly peers: readonly MeshPeer[];\n /** Relays one local notice to every guest; the serializer decides what crosses.\n * A notice relayed after hosting ended is dropped, not thrown: it is ephemeral\n * UI state with no audience left, unlike the control-plane methods, which fail\n * with `TavernDisposedError` so a leaked handle surfaces. */\n relayNotice(notice: unknown): void;\n /** Subscribes to {@link TavernHostEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or hosting ends. */\n tap(handler: (event: TavernHostEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernHostEvent>();\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 tappers.stop();\n options.onEnded?.();\n };\n const disposedError = (): TavernDisposedError => new TavernDisposedError('This host has stopped.');\n const warn = (error: unknown): void => {\n tappers.emit({ error, message: error instanceof Error ? error.message : String(error), type: 'warning' });\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 warn(error);\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') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n tappers.emit({ peer: event.peer, type: 'peer-joined' });\n }\n if (event.type === 'peer-left') tappers.emit({ peer: event.peer, reason: event.reason, type: 'peer-left' });\n if (event.type === 'error') warn(event.error);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n if (closed) throw disposedError();\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 () => {\n if (closed) throw disposedError();\n return meshQrCodec.encode(await host.createInvitation());\n },\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n if (closed) throw disposedError();\n host.kick(peerId);\n },\n get peers(): readonly MeshPeer[] {\n return [...host.peers.values()];\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 tap: tappers.tap,\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 /**\n * The channel ended after joining; the mounted value is passed for unmounting.\n * Lifecycle, not observability: rejections and transport warnings belong to\n * {@link TavernGuest.tap}. `onJoined` and `onFailed` stay callbacks for the same\n * reason: they fire while {@link joinTavern} is still resolving, before the\n * consumer can hold the handle and register a tapper.\n */\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 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 * Throws `TavernDisposedError` after the session has ended.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Subscribes to {@link TavernGuestEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or the session ends. */\n tap(handler: (event: TavernGuestEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernGuestEvent>();\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 tappers.stop();\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 tappers.emit({\n commandId: payload.commandId,\n message: payload.message || 'The host rejected the action.',\n type: 'rejected',\n });\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') {\n tappers.emit({ error: event.error, message: event.error.message, type: 'warning' });\n }\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 TavernDisposedError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n tap: tappers.tap,\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 CHANGED
@@ -1,2 +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};
1
+ import{MeshPairingError as e,createMeshGuest as t,createMeshHost as n,meshQrCodec as r}from"@vielzeug/mesh";function createTappers(){let e=new Set,t=!1;return{emit(t){if(e.size!==0)for(let n of e)try{n(t)}catch{}},stop(){t=!0,e.clear()},tap(n,r){if(t||r?.signal?.aborted)return()=>{};let detach=()=>{e.delete(n)};return e.add(n),r?.signal?.addEventListener(`abort`,detach,{once:!0}),detach}}}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{},TavernDisposedError=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=createTappers(),s=!1,c=!1,close=()=>{if(!s){s=!0;for(let e of l.splice(0))e();a.dispose(),o.stop(),t.onEnded?.()}},disposedError=()=>new TavernDisposedError(`This host has stopped.`),warn=e=>{o.emit({error:e,message:e instanceof Error?e.message:String(e),type:`warning`})},readSnapshot=()=>{try{return t.subjects.snapshot()}catch(e){return warn(e),null}},broadcastSnapshot=()=>{s||c||(c=!0,queueMicrotask(()=>{if(c=!1,s)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.`})}},l=[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`){let t=readSnapshot();t!=null&&a.send(e.peer.id,`snapshot`,t),o.emit({peer:e.peer,type:`peer-joined`})}e.type===`peer-left`&&o.emit({peer:e.peer,reason:e.reason,type:`peer-left`}),e.type===`error`&&warn(e.error)})];return{acceptAnswerText:async t=>{if(s)throw disposedError();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()=>{if(s)throw disposedError();return r.encode(await a.createInvitation())},get disposalSignal(){return a.disposalSignal},dispose:close,get disposed(){return s},kick:e=>{if(s)throw disposedError();a.kick(e)},get peers(){return[...a.peers.values()]},relayNotice:e=>{if(s)return;let n=t.notices?t.notices.toWire(e):null;n!=null&&a.broadcast(`notice`,n)},tap:o.tap,[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=createTappers(),c=null,l=!1,endSession=()=>{if(l)return;l=!0;let e=c;c=null;for(let e of u.splice(0))e();o.dispose(),s.stop(),e!==null&&n.onEnded?.(e)},u=[o.on(`snapshot`,({payload:e})=>{let t=n.mount(e);t===null||l||c===null&&(c=t,n.onJoined?.(t))}),o.on(`notice`,({payload:e})=>{n.notices?.fromWire(e)}),o.on(`rejected`,({payload:e})=>{s.emit({commandId:e.commandId,message:e.message||`The host rejected the action.`,type:`rejected`})}),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`&&c===null&&!l&&n.onFailed?.(`The host did not accept the answer in time.`),(e.status===`disconnected`||e.status===`failed`)&&endSession())}),o.tap(e=>{e.type===`error`&&s.emit({error:e.error,message:e.error.message,type:`warning`})})];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 l},sendCommand:(e,t,n)=>{if(l)throw new TavernDisposedError(`The session has ended.`);o.send(`command`,{args:[...n],id:commandId(),name:t,subjectId:e})},tap:s.tap,[Symbol.dispose](){endSession()}}}}catch(t){throw endSession(),t instanceof e?toPairingError(t):t}}export{TavernDisposedError,TavernError,TavernPairingError,hostTavern,joinTavern};
2
2
  //# sourceMappingURL=tavern.js.map
@@ -1 +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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"}
1
+ {"version":3,"file":"tavern.js","names":[],"sources":["../src/_tappers.ts","../src/errors.ts","../src/index.ts"],"sourcesContent":["/**\n * The `tap()` machinery shared by the host and guest handles. Kept private: the\n * public contract is each handle's `tap` method and its event union, not this.\n *\n * Follows the repo observability rules: zero overhead with no tappers, handler\n * errors swallowed, auto-detach on an abort signal, and a no-op unsubscribe once\n * the handle has stopped.\n */\nexport interface Tappers<E extends { readonly type: string }> {\n /** Emits to every tapper; a no-op when none are registered. */\n emit(event: E): void;\n /** Drops every tapper and makes later `tap` calls no-ops. Idempotent. */\n stop(): void;\n tap(handler: (event: E) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport function createTappers<E extends { readonly type: string }>(): Tappers<E> {\n const tappers = new Set<(event: E) => void>();\n let stopped = false;\n\n return {\n emit(event): void {\n if (tappers.size === 0) return;\n for (const tapper of tappers) {\n try {\n tapper(event);\n } catch {\n // Observability must not affect session behavior.\n }\n }\n },\n stop(): void {\n stopped = true;\n tappers.clear();\n },\n tap(handler, options): () => void {\n if (stopped || options?.signal?.aborted) return () => {};\n const detach = (): void => {\n tappers.delete(handler);\n };\n tappers.add(handler);\n options?.signal?.addEventListener('abort', detach, { once: true });\n return detach;\n },\n };\n}\n","/**\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\n/**\n * Thrown when a stopped host or an ended guest session is used again. Mirrors\n * mesh's `MeshDisposedError` inside the tavern error hierarchy, so\n * `instanceof TavernError` catches every tavern failure in one branch.\n */\nexport class TavernDisposedError 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 { createTappers } from './_tappers';\nimport { TavernDisposedError, TavernError, TavernPairingError } from './errors';\n\nexport { TavernDisposedError, 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/** Notable host moments reported through {@link TavernHost.tap}. */\nexport type TavernHostEvent =\n | { readonly peer: MeshPeer; readonly type: 'peer-joined' }\n | { readonly peer: MeshPeer; readonly reason?: string; readonly type: 'peer-left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** Notable guest moments reported through {@link TavernGuest.tap}. */\nexport type TavernGuestEvent =\n | { readonly commandId: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\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 /**\n * Hosting ended: the subject was removed or the host was disposed. Fires exactly\n * once. This is a lifecycle transition, not observability: presence, transport\n * warnings, and the peer list all belong to {@link TavernHost.tap} and\n * {@link TavernHost.peers}.\n */\n onEnded?(): 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 /** The guests currently connected: read it from a `peer-joined`/`peer-left` tap event. */\n readonly peers: readonly MeshPeer[];\n /** Relays one local notice to every guest; the serializer decides what crosses.\n * A notice relayed after hosting ended is dropped, not thrown: it is ephemeral\n * UI state with no audience left, unlike the control-plane methods, which fail\n * with `TavernDisposedError` so a leaked handle surfaces. */\n relayNotice(notice: unknown): void;\n /** Subscribes to {@link TavernHostEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or hosting ends. */\n tap(handler: (event: TavernHostEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernHostEvent>();\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 tappers.stop();\n options.onEnded?.();\n };\n const disposedError = (): TavernDisposedError => new TavernDisposedError('This host has stopped.');\n const warn = (error: unknown): void => {\n tappers.emit({ error, message: error instanceof Error ? error.message : String(error), type: 'warning' });\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 warn(error);\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') {\n const snapshot = readSnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n tappers.emit({ peer: event.peer, type: 'peer-joined' });\n }\n if (event.type === 'peer-left') tappers.emit({ peer: event.peer, reason: event.reason, type: 'peer-left' });\n if (event.type === 'error') warn(event.error);\n }),\n ];\n\n return {\n acceptAnswerText: async (text: string): Promise<MeshPeer> => {\n if (closed) throw disposedError();\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 () => {\n if (closed) throw disposedError();\n return meshQrCodec.encode(await host.createInvitation());\n },\n get disposalSignal() {\n return host.disposalSignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerId: string): void => {\n if (closed) throw disposedError();\n host.kick(peerId);\n },\n get peers(): readonly MeshPeer[] {\n return [...host.peers.values()];\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 tap: tappers.tap,\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 /**\n * The channel ended after joining; the mounted value is passed for unmounting.\n * Lifecycle, not observability: rejections and transport warnings belong to\n * {@link TavernGuest.tap}. `onJoined` and `onFailed` stay callbacks for the same\n * reason: they fire while {@link joinTavern} is still resolving, before the\n * consumer can hold the handle and register a tapper.\n */\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 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 * Throws `TavernDisposedError` after the session has ended.\n */\n sendCommand(subjectId: string, name: string, args: readonly unknown[]): void;\n /** Subscribes to {@link TavernGuestEvent}s. Handler errors are swallowed; the\n * subscription detaches when `signal` aborts or the session ends. */\n tap(handler: (event: TavernGuestEvent) => void, options?: { readonly signal?: AbortSignal }): () => 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 const tappers = createTappers<TavernGuestEvent>();\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 tappers.stop();\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 tappers.emit({\n commandId: payload.commandId,\n message: payload.message || 'The host rejected the action.',\n type: 'rejected',\n });\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') {\n tappers.emit({ error: event.error, message: event.error.message, type: 'warning' });\n }\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 TavernDisposedError('The session has ended.');\n guest.send('command', { args: [...args], id: commandId(), name, subjectId });\n },\n tap: tappers.tap,\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,7 +1,7 @@
1
1
  {
2
2
  "name": "@vielzeug/tavern",
3
- "version": "26.10.0",
4
- "description": "Table sessions over mesh — host-owned state replication with guest command forwarding",
3
+ "version": "26.10.5",
4
+ "description": "Table sessions over mesh: host-owned state replication with guest command forwarding",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/helmuthdu/vielzeug",
@@ -39,7 +39,7 @@
39
39
  "test": "vitest"
40
40
  },
41
41
  "dependencies": {
42
- "@vielzeug/mesh": "26.10.0"
42
+ "@vielzeug/mesh": "26.10.5"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^26.6.2",