@vielzeug/tavern 0.0.0-stage → 26.10.3
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 +50 -2
- package/dist/_tappers.cjs +2 -0
- package/dist/_tappers.cjs.map +1 -0
- package/dist/_tappers.d.ts +23 -0
- package/dist/_tappers.d.ts.map +1 -0
- package/dist/_tappers.js +2 -0
- package/dist/_tappers.js.map +1 -0
- package/dist/errors.cjs +2 -0
- package/dist/errors.cjs.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +174 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/tavern.cjs +2 -0
- package/dist/tavern.cjs.map +1 -0
- package/dist/tavern.iife.js +2 -0
- package/dist/tavern.iife.js.map +1 -0
- package/dist/tavern.js +2 -0
- package/dist/tavern.js.map +1 -0
- package/package.json +48 -4
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vielzeug/tavern
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Table sessions over mesh: host-owned state replication with guest command forwarding
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @vielzeug/tavern
|
|
9
|
+
npm install @vielzeug/tavern
|
|
10
|
+
yarn add @vielzeug/tavern
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { hostTavern, joinTavern } from '@vielzeug/tavern';
|
|
17
|
+
|
|
18
|
+
const host = hostTavern({
|
|
19
|
+
commands: {
|
|
20
|
+
apply: (name, args) => myStore.apply(name, args),
|
|
21
|
+
has: (name) => name in myStore,
|
|
22
|
+
},
|
|
23
|
+
subjectId: 'doc-1',
|
|
24
|
+
subjects: {
|
|
25
|
+
onChanged: (listener) => myStore.onUpdated('doc-1', listener),
|
|
26
|
+
onRemoved: (listener) => myStore.onRemoved('doc-1', listener),
|
|
27
|
+
snapshot: () => myStore.read('doc-1'),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const invitation = await host.createInvitationText();
|
|
32
|
+
const { answerText, guest } = await joinTavern({
|
|
33
|
+
invitationText: invitation,
|
|
34
|
+
mount: (snapshot) => parseAndMount(snapshot),
|
|
35
|
+
name: 'Alex',
|
|
36
|
+
});
|
|
37
|
+
await host.acceptAnswerText(answerText);
|
|
38
|
+
|
|
39
|
+
guest.sendCommand('doc-1', 'rename', ['Quarterly report']);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Documentation
|
|
43
|
+
|
|
44
|
+
- [Overview](https://vielzeug.dev/tavern/)
|
|
45
|
+
- [Usage Guide](https://vielzeug.dev/tavern/usage)
|
|
46
|
+
- [API Reference](https://vielzeug.dev/tavern/api)
|
|
47
|
+
- [Examples](https://vielzeug.dev/tavern/examples)
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT © [Helmuth Saatkamp](https://github.com/helmuthdu): part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
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"}
|
package/dist/_tappers.js
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{},TavernDisposedError=class extends TavernError{};exports.TavernDisposedError=TavernDisposedError,exports.TavernError=TavernError,exports.TavernPairingError=TavernPairingError;
|
|
2
|
+
//# sourceMappingURL=errors.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.cjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used: a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n\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
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for all tavern errors.
|
|
3
|
+
* Use `instanceof TavernError` to catch any tavern-originated error in one branch.
|
|
4
|
+
*/
|
|
5
|
+
export declare class TavernError extends Error {
|
|
6
|
+
constructor(message: string, opts?: ErrorOptions);
|
|
7
|
+
}
|
|
8
|
+
/** A pairing code the consumer pasted could not be used: a user-input mistake, not a bug. */
|
|
9
|
+
export declare class TavernPairingError extends TavernError {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
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
|
+
}
|
|
18
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,WAAY,SAAQ,KAAK;gBACxB,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,YAAY;CAKjD;AAED,6FAA6F;AAC7F,qBAAa,kBAAmB,SAAQ,WAAW;CAAG;AAEtD;;;;GAIG;AACH,qBAAa,mBAAoB,SAAQ,WAAW;CAAG"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var TavernError=class extends Error{constructor(e,t){super(e,t),this.name=new.target.name,Object.setPrototypeOf(this,new.target.prototype)}},TavernPairingError=class extends TavernError{},TavernDisposedError=class extends TavernError{};export{TavernDisposedError,TavernError,TavernPairingError};
|
|
2
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Base class for all tavern errors.\n * Use `instanceof TavernError` to catch any tavern-originated error in one branch.\n */\nexport class TavernError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** A pairing code the consumer pasted could not be used: a user-input mistake, not a bug. */\nexport class TavernPairingError extends TavernError {}\n\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
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
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
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import {\n createMeshGuest,\n createMeshHost,\n type MeshAnswer,\n type MeshInvitation,\n MeshPairingError,\n type MeshPeer,\n type MeshProtocol,\n type MeshRtcFactory,\n meshQrCodec,\n} from '@vielzeug/mesh';\n\nimport { 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
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { type MeshPeer, type MeshRtcFactory } from '@vielzeug/mesh';
|
|
2
|
+
import { TavernDisposedError, TavernError, TavernPairingError } from './errors';
|
|
3
|
+
export { TavernDisposedError, TavernError, TavernPairingError };
|
|
4
|
+
/**
|
|
5
|
+
* Table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests
|
|
6
|
+
* mirror it and forward every command through the host's command table, so guest actions follow
|
|
7
|
+
* exactly the same code path as host actions.
|
|
8
|
+
*
|
|
9
|
+
* The consumer supplies the seams, which commands exist, how subject state is read and mounted,
|
|
10
|
+
* how notices cross between the wire and the local UI. Tavern owns the pairing, the protocol,
|
|
11
|
+
* command validation, snapshot broadcasting, notice relaying, and presence.
|
|
12
|
+
*/
|
|
13
|
+
/** What the host can do on behalf of a guest. */
|
|
14
|
+
export interface TavernCommands {
|
|
15
|
+
/** Applies the command; throwing rejects the guest with the error's message. */
|
|
16
|
+
apply(name: string, args: readonly unknown[]): unknown;
|
|
17
|
+
/** Whether a command name is known; unknown names reject without applying. */
|
|
18
|
+
has(name: string): boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* How the host reads and watches the subject it is sharing. All three close over whatever
|
|
22
|
+
* subject state the consumer owns: the host routes by `subjectId`, not by a subject object.
|
|
23
|
+
*/
|
|
24
|
+
export interface TavernSubjects {
|
|
25
|
+
/** Subscribes to local changes of the hosted subject: the re-broadcast trigger. */
|
|
26
|
+
onChanged(listener: () => void): () => void;
|
|
27
|
+
/** Subscribes to the hosted subject's removal: hosting ends when it fires. */
|
|
28
|
+
onRemoved(listener: () => void): () => void;
|
|
29
|
+
/** Reads the snapshot to broadcast; null while the subject is missing. */
|
|
30
|
+
snapshot(): unknown;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Notice relay between host and guests. Notices cross the wire as opaque values: each client
|
|
34
|
+
* translates locally.
|
|
35
|
+
*
|
|
36
|
+
* **Echo guard**: a tab that both hosts and guests must guard against re-broadcasting a
|
|
37
|
+
* wire-originated notice back to its own guests: `fromWire` emits locally, the host's local
|
|
38
|
+
* subscription picks it up, and without a guard it would loop. The consumer owns this guard
|
|
39
|
+
* because only they know their local event system; one boolean set during `fromWire` and
|
|
40
|
+
* checked in `toWire` is sufficient.
|
|
41
|
+
*/
|
|
42
|
+
export interface TavernNotices {
|
|
43
|
+
/** Re-emits a received wire notice locally. */
|
|
44
|
+
fromWire(wire: unknown): void;
|
|
45
|
+
/** Serializes a local notice for the wire; null skips this one. */
|
|
46
|
+
toWire(notice: unknown): unknown | null;
|
|
47
|
+
}
|
|
48
|
+
/** 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
|
+
};
|
|
71
|
+
export interface TavernHostOptions {
|
|
72
|
+
commands: TavernCommands;
|
|
73
|
+
notices?: TavernNotices;
|
|
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
|
+
*/
|
|
80
|
+
onEnded?(): void;
|
|
81
|
+
rtc?: MeshRtcFactory;
|
|
82
|
+
/** The id of the subject being hosted: guest commands targeting anything else reject. */
|
|
83
|
+
subjectId: string;
|
|
84
|
+
subjects: TavernSubjects;
|
|
85
|
+
}
|
|
86
|
+
export interface TavernHost {
|
|
87
|
+
/** Consumes a guest's answer code; resolves with the peer once the channel is open. */
|
|
88
|
+
acceptAnswerText(text: string): Promise<MeshPeer>;
|
|
89
|
+
/** Produces a single-use invitation code, QR-compact when the environment allows. */
|
|
90
|
+
createInvitationText(): Promise<string>;
|
|
91
|
+
/** `AbortSignal` aborted when hosting ends. */
|
|
92
|
+
readonly disposalSignal: AbortSignal;
|
|
93
|
+
/** Stops hosting: closes every channel and detaches all subscriptions. */
|
|
94
|
+
dispose(): void;
|
|
95
|
+
/** Whether hosting has ended: the subject was removed or `dispose()` ran. */
|
|
96
|
+
readonly disposed: boolean;
|
|
97
|
+
kick(peerId: string): void;
|
|
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. */
|
|
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;
|
|
110
|
+
/** Delegates to `dispose()`. Enables `using` declarations. */
|
|
111
|
+
[Symbol.dispose](): void;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Starts hosting one subject. Every guest command is validated and applied through the
|
|
115
|
+
* command table; local changes re-broadcast the snapshot (coalesced on a microtask); removal
|
|
116
|
+
* ends the session.
|
|
117
|
+
*/
|
|
118
|
+
export declare function hostTavern(options: TavernHostOptions): TavernHost;
|
|
119
|
+
export interface TavernGuestOptions<Mounted> {
|
|
120
|
+
/** The invitation code the host produced. */
|
|
121
|
+
invitationText: string;
|
|
122
|
+
/**
|
|
123
|
+
* Mounts a received snapshot; the mounted value, or null to ignore. The snapshot arrives
|
|
124
|
+
* as parsed JSON: validate before trusting it.
|
|
125
|
+
*/
|
|
126
|
+
mount(snapshot: unknown): Mounted | null;
|
|
127
|
+
/** The guest's display name on the channel. */
|
|
128
|
+
name: string;
|
|
129
|
+
notices?: TavernNotices;
|
|
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
|
+
*/
|
|
137
|
+
onEnded?(subject: Mounted): void;
|
|
138
|
+
/** The host never accepted the answer in time. */
|
|
139
|
+
onFailed?(reason: string): void;
|
|
140
|
+
/** The mounted value once the first snapshot arrives. */
|
|
141
|
+
onJoined?(subject: Mounted): void;
|
|
142
|
+
rtc?: MeshRtcFactory;
|
|
143
|
+
}
|
|
144
|
+
export interface TavernGuest {
|
|
145
|
+
/** `AbortSignal` aborted when the session ends. */
|
|
146
|
+
readonly disposalSignal: AbortSignal;
|
|
147
|
+
/** Leaves the session and drops the channel. */
|
|
148
|
+
dispose(): void;
|
|
149
|
+
/** Whether the session has ended: the channel dropped or `dispose()` ran. */
|
|
150
|
+
readonly disposed: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Forwards a command to the host. The subject id is the consumer's routing key: the host
|
|
153
|
+
* rejects commands that do not name the subject it is hosting.
|
|
154
|
+
* Throws `TavernDisposedError` after the session has ended.
|
|
155
|
+
*/
|
|
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;
|
|
162
|
+
/** Delegates to `dispose()`. Enables `using` declarations. */
|
|
163
|
+
[Symbol.dispose](): void;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Joins a session as a guest: consumes the host's invitation and returns the answer code to
|
|
167
|
+
* show back. The session goes live once the host accepts the answer and the first snapshot
|
|
168
|
+
* mounts; disposing the guest (or the channel dropping) ends it exactly once.
|
|
169
|
+
*/
|
|
170
|
+
export declare function joinTavern<Mounted>(options: TavernGuestOptions<Mounted>): Promise<{
|
|
171
|
+
answerText: string;
|
|
172
|
+
guest: TavernGuest;
|
|
173
|
+
}>;
|
|
174
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{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
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import {\n createMeshGuest,\n createMeshHost,\n type MeshAnswer,\n type MeshInvitation,\n MeshPairingError,\n type MeshPeer,\n type MeshProtocol,\n type MeshRtcFactory,\n meshQrCodec,\n} from '@vielzeug/mesh';\n\nimport { 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":"qPAsBA,SAAS,eAAe,EAAoC,CAC1D,IAAM,EAAU,aAAiB,MAAQ,EAAM,QAAU,+BACzD,OAAO,IAAI,EAAmB,EAAS,CAAE,MAAO,CAAM,CAAC,CACzD,CAyEA,IAAM,EAA4B,OAE5B,cAA0B,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAG,EAAE,IAAI,KAAK,IAAI,CAAC,CAAC,SAAS,EAAE,IAGvG,SAAS,iBAAiB,EAAuF,CAC/G,GAAI,OAAO,GAAQ,WAAY,EAAc,OAAO,KACpD,GAAM,CAAE,OAAM,KAAI,OAAM,aAAc,EAItC,OAHI,OAAO,GAAO,UAAY,OAAO,GAAS,UAAY,OAAO,GAAc,UAAY,CAAC,MAAM,QAAQ,CAAI,EACrG,KAEF,CAAE,OAAM,KAAI,OAAM,WAAU,CACrC,CAqDA,SAAgB,WAAW,EAAwC,CACjE,IAAM,EAAO,EAA6B,CACxC,gBAAiB,EACjB,IAAK,EAAQ,GACf,CAAC,EACK,EAAU,EAA+B,EAC3C,EAAS,GACT,EAAiB,GAEf,UAAoB,CACpB,MACJ,GAAS,GACT,IAAK,IAAM,KAAO,EAAO,OAAO,CAAC,EAAG,EAAI,EACxC,EAAK,QAAQ,EACb,EAAQ,KAAK,EACb,EAAQ,UAAU,CAJT,CAKX,EACM,kBAA2C,IAAI,EAAoB,wBAAwB,EAC3F,KAAQ,GAAyB,CACrC,EAAQ,KAAK,CAAE,QAAO,QAAS,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,EAAG,KAAM,SAAU,CAAC,CAC1G,EAGM,iBAA8B,CAClC,GAAI,CACF,OAAO,EAAQ,SAAS,SAAS,CACnC,OAAS,EAAO,CAEd,OADA,KAAK,CAAK,EACH,IACT,CACF,EAGM,sBAAgC,CAChC,GAAU,IACd,EAAiB,GACjB,mBAAqB,CAEnB,GADA,EAAiB,GACb,EAAQ,OACZ,IAAM,EAAW,aAAa,EAC1B,GAAa,MAAgC,EAAK,UAAU,WAAY,CAAQ,CACtF,CAAC,EACH,EAOM,oBAAsB,EAAgB,IAAuB,CACjE,IAAM,EAAU,iBAAiB,CAAG,EACpC,GAAI,CAAC,GAAW,CAAC,EAAQ,SAAS,IAAI,EAAQ,IAAI,GAAK,EAAQ,YAAc,EAAQ,UAAW,CAC9F,EAAK,KAAK,EAAQ,WAAY,CAC5B,UAAW,GAAS,IAAM,GAC1B,QAAS,yDACX,CAAC,EACD,MACF,CACA,GAAI,CACF,EAAQ,SAAS,MAAM,EAAQ,KAAM,EAAQ,IAAI,CACnD,OAAS,EAAO,CACd,EAAK,KAAK,EAAQ,WAAY,CAC5B,UAAW,EAAQ,GACnB,QAAS,aAAiB,MAAQ,EAAM,QAAU,uCACpD,CAAC,CACH,CACF,EAEM,EAAS,CACb,EAAK,GAAG,WAAY,CAAE,SAAQ,aAAc,mBAAmB,EAAQ,CAAO,CAAC,EAC/E,EAAQ,SAAS,UAAU,iBAAiB,EAC5C,EAAQ,SAAS,UAAU,KAAK,EAChC,EAAK,IAAK,GAAU,CAClB,GAAI,EAAM,OAAS,cAAe,CAChC,IAAM,EAAW,aAAa,EAC1B,GAAa,MAAgC,EAAK,KAAK,EAAM,KAAK,GAAI,WAAY,CAAQ,EAC9F,EAAQ,KAAK,CAAE,KAAM,EAAM,KAAM,KAAM,aAAc,CAAC,CACxD,CACI,EAAM,OAAS,aAAa,EAAQ,KAAK,CAAE,KAAM,EAAM,KAAM,OAAQ,EAAM,OAAQ,KAAM,WAAY,CAAC,EACtG,EAAM,OAAS,SAAS,KAAK,EAAM,KAAK,CAC9C,CAAC,CACH,EAEA,MAAO,CACL,iBAAkB,KAAO,IAAoC,CAC3D,GAAI,EAAQ,MAAM,cAAc,EAChC,IAAI,EACJ,GAAI,CACF,EAAU,MAAM,EAAY,OAAO,CAAI,CACzC,OAAS,EAAO,CACd,MAAM,aAAiB,EAAmB,eAAe,CAAK,EAAI,CACpE,CACA,GAAI,EAAE,UAAW,GACf,MAAM,IAAI,EAAmB,iDAAiD,EAEhF,GAAI,CACF,OAAO,MAAM,EAAK,aAAa,CAAO,CACxC,OAAS,EAAO,CACd,MAAM,aAAiB,EAAmB,eAAe,CAAK,EAAI,CACpE,CACF,EACA,qBAAsB,SAAY,CAChC,GAAI,EAAQ,MAAM,cAAc,EAChC,OAAO,EAAY,OAAO,MAAM,EAAK,iBAAiB,CAAC,CACzD,EACA,IAAI,gBAAiB,CACnB,OAAO,EAAK,cACd,EACA,QAAS,MACT,IAAI,UAAW,CACb,OAAO,CACT,EACA,KAAO,GAAyB,CAC9B,GAAI,EAAQ,MAAM,cAAc,EAChC,EAAK,KAAK,CAAM,CAClB,EACA,IAAI,OAA6B,CAC/B,MAAO,CAAC,GAAG,EAAK,MAAM,OAAO,CAAC,CAChC,EACA,YAAc,GAA0B,CACtC,GAAI,EAAQ,OACZ,IAAM,EAAO,EAAQ,QAAU,EAAQ,QAAQ,OAAO,CAAM,EAAI,KAC5D,GAAS,MAA4B,EAAK,UAAU,SAAU,CAAI,CACxE,EACA,IAAK,EAAQ,IACb,CAAC,OAAO,UAAW,CACjB,MAAM,CACR,CACF,CACF,CAyDA,eAAsB,WACpB,EACqD,CACrD,IAAI,EACJ,GAAI,CACF,EAAU,MAAM,EAAY,OAAO,EAAQ,cAAc,CAC3D,OAAS,EAAO,CACd,MAAM,aAAiB,EAAmB,eAAe,CAAK,EAAI,CACpE,CACA,GAAI,EAAE,WAAY,GAChB,MAAM,IAAI,EAAmB,wCAAwC,EAGvE,IAAM,EAAQ,EAA8B,CAC1C,gBAAiB,EACjB,IAAK,EAAQ,GACf,CAAC,EACK,EAAU,EAAgC,EAC5C,EAA0B,KAC1B,EAAQ,GAGN,eAAyB,CAC7B,GAAI,EAAO,OACX,EAAQ,GACR,IAAM,EAAU,EAChB,EAAU,KACV,IAAK,IAAM,KAAO,EAAO,OAAO,CAAC,EAAG,EAAI,EACxC,EAAM,QAAQ,EACd,EAAQ,KAAK,EACT,IAAY,MAAM,EAAQ,UAAU,CAAO,CACjD,EAEM,EAAS,CACb,EAAM,GAAG,YAAa,CAAE,aAAc,CACpC,IAAM,EAAU,EAAQ,MAAM,CAAO,EACjC,IAAY,MAAQ,GACpB,IAAY,OACd,EAAU,EACV,EAAQ,WAAW,CAAO,EAE9B,CAAC,EACD,EAAM,GAAG,UAAW,CAAE,aAAc,CAClC,EAAQ,SAAS,SAAS,CAAO,CACnC,CAAC,EACD,EAAM,GAAG,YAAa,CAAE,aAAc,CACpC,EAAQ,KAAK,CACX,UAAW,EAAQ,UACnB,QAAS,EAAQ,SAAW,gCAC5B,KAAM,UACR,CAAC,CACH,CAAC,EACD,EAAM,IAAK,GAAU,EACf,EAAM,OAAS,iBAAmB,EAAM,OAAS,wBACjD,EAAM,OAAS,sBAAwB,EAAM,SAAW,EAAM,MAAM,MACpE,EAAM,SAAW,UAAY,IAAY,MAAQ,CAAC,GACpD,EAAQ,WAAW,6CAA6C,GAE9D,EAAM,SAAW,gBAAkB,EAAM,SAAW,WAAU,WAAW,EAC/E,CAAC,EACD,EAAM,IAAK,GAAU,CACf,EAAM,OAAS,SACjB,EAAQ,KAAK,CAAE,MAAO,EAAM,MAAO,QAAS,EAAM,MAAM,QAAS,KAAM,SAAU,CAAC,CAEtF,CAAC,CACH,EAEA,GAAI,CACF,IAAM,EAAS,MAAM,EAAM,iBAAiB,EAAS,CAAE,KAAM,EAAQ,IAAK,CAAC,EAC3E,MAAO,CACL,WAAY,MAAM,EAAY,OAAO,CAAM,EAC3C,MAAO,CACL,IAAI,gBAAiB,CACnB,OAAO,EAAM,cACf,EACA,QAAS,WACT,IAAI,UAAW,CACb,OAAO,CACT,EACA,aAAc,EAAmB,EAAc,IAAmC,CAChF,GAAI,EAAO,MAAM,IAAI,EAAoB,wBAAwB,EACjE,EAAM,KAAK,UAAW,CAAE,KAAM,CAAC,GAAG,CAAI,EAAG,GAAI,UAAU,EAAG,OAAM,WAAU,CAAC,CAC7E,EACA,IAAK,EAAQ,IACb,CAAC,OAAO,UAAW,CACjB,WAAW,CACb,CACF,CACF,CACF,OAAS,EAAO,CAGd,MADA,WAAW,EACL,aAAiB,EAAmB,eAAe,CAAK,EAAI,CACpE,CACF"}
|
package/dist/tavern.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
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
|
+
//# sourceMappingURL=tavern.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
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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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
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
|
+
//# sourceMappingURL=tavern.iife.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
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
|
+
//# sourceMappingURL=tavern.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vielzeug/tavern",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "26.10.3",
|
|
4
|
+
"description": "Table sessions over mesh: host-owned state replication with guest command forwarding",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/helmuthdu/vielzeug",
|
|
8
|
+
"directory": "packages/tavern"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"main": "./dist/index.cjs",
|
|
18
|
+
"module": "./dist/index.js",
|
|
19
|
+
"types": "dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"require": "./dist/index.cjs",
|
|
24
|
+
"types": "./dist/index.d.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org/"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "vite build && pnpm --silent run build:bundle && pnpm --silent run build:types",
|
|
34
|
+
"build:bundle": "vite build --config vite.bundle.config.ts",
|
|
35
|
+
"build:types": "tsc -p tsconfig.declarations.json",
|
|
36
|
+
"fix": "biome check --write src",
|
|
37
|
+
"lint": "biome ci src",
|
|
38
|
+
"prepublishOnly": "pnpm --silent run build",
|
|
39
|
+
"test": "vitest"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@vielzeug/mesh": "26.10.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^26.6.2",
|
|
46
|
+
"typescript": "^6.0.3",
|
|
47
|
+
"vite": "^8.3.0",
|
|
48
|
+
"vitest": "^5.0.1"
|
|
49
|
+
}
|
|
50
|
+
}
|