@marianmeres/ws 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,11 @@
4
4
  [![JSR](https://jsr.io/badges/@marianmeres/ws)](https://jsr.io/@marianmeres/ws)
5
5
  [![License](https://img.shields.io/npm/l/@marianmeres/ws)](LICENSE)
6
6
 
7
- A WebSocket client with namespaces, rooms, presence and reconnect that actually
8
- survives real networks plus a mountable reference server implementing the same
9
- [protocol](./PROTOCOL.md).
7
+ A WebSocket client that survives real networks reconnect, half-open
8
+ detection, buffered sends, token refresh used either as a plain **message
9
+ channel to your server** or with **rooms and presence** on top. Plus a mountable
10
+ reference server, and a [protocol](./PROTOCOL.md) small enough to implement in
11
+ any language.
10
12
 
11
13
  ## Features
12
14
 
@@ -14,12 +16,12 @@ survives real networks — plus a mountable reference server implementing the sa
14
16
  retry when the browser comes back online or the tab becomes visible again
15
17
  - **Detects half-open connections** — the failure where the peer vanishes, no
16
18
  `onclose` ever fires, and a naive client sits "connected" receiving nothing
17
- - **Namespaces and rooms** — namespace isolates, rooms are channels within it
18
- - **Presence** opt-in per room; membership snapshot plus join/leave deltas,
19
- re-synced automatically after every reconnect
20
- - **Buffered sends** — publishes issued while offline are queued (capped, never
21
- unbounded) and flushed after re-subscribe
22
- - **Acknowledged publishes** `publish()` resolves with a recipient count
19
+ - **Buffered sends** — anything sent while offline is queued (capped, never
20
+ unbounded) and flushed on reconnect, with one deadline per send
21
+ - **Token refresh for free** — the `auth()` callback runs before every
22
+ (re)connect
23
+ - **Two ways to talk** — plain messages to and from the server, fire-and-forget
24
+ or request/response; or namespaces, rooms, presence and broadcast
23
25
  - **Runs everywhere** — `WebSocket` is a global in browsers, Deno, Node 22+, Bun
24
26
  and Workers, so there is no polyfill and no transport dependency
25
27
  - **Svelte-store compatible** reactive connection state
@@ -39,26 +41,92 @@ deno add jsr:@marianmeres/ws
39
41
  > The client is fully runtime-agnostic, so npm consumers lose nothing they could
40
42
  > have used.
41
43
 
42
- ## ws or sse?
44
+ ## Two ways to use it
43
45
 
44
- `@marianmeres/sse` is the sibling package: same shape of API, different
45
- transport, different strengths. In short
46
+ The protocol has a small required **core** a handshake, messages both ways, a
47
+ heartbeat and an optional **rooms extension**. Which one you use decides what
48
+ your server has to implement:
46
49
 
47
- - **Reach for `ws`** when traffic is genuinely bidirectional and chatty
48
- (collaborative editing, games, chat with typing indicators), when you need
49
- presence, or when you need binary frames.
50
- - **Reach for `sse`** when traffic is mostly server → client (notifications,
51
- live dashboards, progress, activity feeds), or when losing messages across a
52
- reconnect is not acceptable SSE resumes from `Last-Event-ID`, WebSocket has
53
- no equivalent.
50
+ | | Messages | Rooms |
51
+ | ---------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
52
+ | Who talks to whom | the client and **the server** | clients to **each other**, relayed by the server |
53
+ | Client API | `send()`, `on("message")` | `subscribe()`, `publish()`, `broadcast()` |
54
+ | The server implements | the core only 8 small frame types | the core plus the rooms extension |
55
+ | Server in any language | [PROTOCOL.md §8](PROTOCOL.md#8-python-a-core-server) (Python) | [PROTOCOL.md §9](PROTOCOL.md#9-python-the-full-reference-with-rooms) (Python) |
56
+ | Reference server | `onMessage` + `service.send()` | works as is |
54
57
 
55
- They are not drop-in replacements for one another and are not meant to be. See
56
- [COMPARISON.md](https://github.com/marianmeres/sse/blob/master/COMPARISON.md)
57
- in the `sse` package for the full table.
58
+ Both get everything in the feature list above. They also mix freely on one
59
+ connection: a room-based app can still `send()` to the server, and a server can
60
+ still push directly to one client.
61
+
62
+ ### 1. Messages — a data channel to your server
63
+
64
+ No rooms, no namespaces, no ceremony: the client sends messages to the server,
65
+ the server sends messages to the client.
66
+
67
+ ```typescript
68
+ import { createWSClient, WSRemoteError } from "@marianmeres/ws";
69
+
70
+ const ws = createWSClient({
71
+ url: "wss://api.example.com/ws",
72
+ auth: () => session.token, // called on every (re)connect, so refresh works
73
+ });
74
+
75
+ // Everything the server pushes.
76
+ ws.on("message", (msg) => console.log(msg.payload));
77
+
78
+ // Fire-and-forget: resolves once written to the socket. Safe not to await.
79
+ ws.send({ op: "cursor", x: 10, y: 20 });
80
+
81
+ // Request/response: waits for the server's acknowledgement, resolves with its reply.
82
+ const doc = await ws.send<Doc>({ op: "load", id: 42 }, { ack: true });
83
+
84
+ // A refusal arrives as a typed error carrying the server's own code.
85
+ try {
86
+ await ws.send({ op: "delete", id: 42 }, { ack: true });
87
+ } catch (e) {
88
+ if (e instanceof WSRemoteError && e.code === "forbidden") showNotAllowed();
89
+ }
90
+ ```
58
91
 
59
- ## Usage
92
+ **Fire-and-forget or acknowledged — your call, per message.** Without
93
+ `{ ack: true }` there is no delivery confirmation: a message written into a
94
+ connection that turns out to be dead is lost, exactly as with a plain
95
+ `WebSocket`. With it, the server confirms every message, can answer it, and a
96
+ message lost with its connection rejects with `WSConnectionLostError` instead of
97
+ silently vanishing.
60
98
 
61
- ### Client
99
+ **The server side** is small enough to write in any language:
100
+ [PROTOCOL.md](PROTOCOL.md) specifies it, section 8 is a complete Python
101
+ server, and Appendix A is a conformance script that drives this client against
102
+ yours. The reference server does it with one hook and one method:
103
+
104
+ ```typescript
105
+ import { createWSApp, WSRemoteError } from "@marianmeres/ws/server";
106
+
107
+ const { app, service } = createWSApp("/ws", [], {
108
+ verify: async (payload) => {
109
+ const user = await authenticate(payload); // null closes with 4001
110
+ return user ? { clientId: user.id } : null;
111
+ },
112
+ // Every ws.send() lands here. The return value is the reply for { ack: true }.
113
+ onMessage: async (ctx, payload) => {
114
+ const { op, id } = payload as { op: string; id: number };
115
+ if (op === "load") return await loadDoc(id);
116
+ throw new WSRemoteError({ code: "unknown_op", message: `unknown op ${op}` });
117
+ },
118
+ });
119
+
120
+ // Push to one client, any time.
121
+ service.send(userId, { op: "progress", done: 42 });
122
+
123
+ Deno.serve(app);
124
+ ```
125
+
126
+ ### 2. Rooms — the server as a relay
127
+
128
+ Namespaces, rooms, presence and broadcast, for clients that talk to each other:
129
+ chat, collaboration, live cursors.
62
130
 
63
131
  ```typescript
64
132
  import { createWSClient } from "@marianmeres/ws";
@@ -66,7 +134,7 @@ import { createWSClient } from "@marianmeres/ws";
66
134
  const ws = createWSClient({
67
135
  url: "/ws",
68
136
  namespace: "org-123",
69
- auth: () => session.token, // called on every (re)connect, so refresh works
137
+ auth: () => session.token,
70
138
  });
71
139
 
72
140
  // Subscribe and handle in one call; the returned function detaches the handler
@@ -81,18 +149,9 @@ unsub();
81
149
  ws.dispose();
82
150
  ```
83
151
 
84
- `connect()` is optional the first `subscribe()` or `publish()` starts the
85
- connection. Call it explicitly when you want a readiness gate:
86
-
87
- ```typescript
88
- await ws.connect(); // resolves once connected; rejects only if retrying cannot help
89
- ```
90
-
91
- ### Presence
92
-
93
- Presence is enabled by _providing a presence handler_, and is opt-in per room —
94
- a room with thousands of subscribers does not want a join event per peer every
95
- time the fleet reconnects.
152
+ **Presence** is enabled by _providing a presence handler_, and is opt-in per
153
+ room a room with thousands of subscribers does not want a join event per peer
154
+ every time the fleet reconnects.
96
155
 
97
156
  ```typescript
98
157
  await ws.subscribe("room", onMessage, {
@@ -106,23 +165,7 @@ await ws.subscribe("room", onMessage, {
106
165
  ws.members("room"); // last known membership
107
166
  ```
108
167
 
109
- ### Reactive state (Svelte)
110
-
111
- ```svelte
112
- <script>
113
- import { createWSClient } from "@marianmeres/ws";
114
- const ws = createWSClient({ url: "/ws" });
115
- const state = ws.state;
116
- </script>
117
-
118
- {#if $state.connected}
119
- <Online />
120
- {:else if $state.attempt > 0}
121
- <p>Reconnecting… (attempt {$state.attempt})</p>
122
- {/if}
123
- ```
124
-
125
- ### Server
168
+ **The server:**
126
169
 
127
170
  ```typescript
128
171
  import { createWSApp } from "@marianmeres/ws/server";
@@ -137,7 +180,7 @@ const { app, service } = createWSApp("/ws", [], {
137
180
  },
138
181
  });
139
182
 
140
- // Push to connected clients from anywhere in your app.
183
+ // Push into a room from anywhere in your app.
141
184
  await service.publish("notifications", { text: "deploy finished" }, "org-123");
142
185
 
143
186
  Deno.serve(app);
@@ -148,11 +191,36 @@ for when `verify` returns none — so in a multi-tenant deployment `verify` must
148
191
  return `namespace` and `clientId`, validating `requested` rather than trusting
149
192
  it.
150
193
 
151
- If `verify` authenticates from cookies, set `allowedOrigins` as well: browsers
152
- attach cookies to a WebSocket opened from any site, and without an `Origin`
153
- check that is cross-site WebSocket hijacking.
194
+ ### Either way
195
+
196
+ `connect()` is optional the first `send()`, `subscribe()` or `publish()`
197
+ starts the connection. Call it explicitly when you want a readiness gate:
198
+
199
+ ```typescript
200
+ await ws.connect(); // resolves once connected; rejects only if retrying cannot help
201
+ ```
202
+
203
+ Connection state is reactive, through the Svelte store contract:
204
+
205
+ ```svelte
206
+ <script>
207
+ import { createWSClient } from "@marianmeres/ws";
208
+ const ws = createWSClient({ url: "/ws" });
209
+ const state = ws.state;
210
+ </script>
211
+
212
+ {#if $state.connected}
213
+ <Online />
214
+ {:else if $state.attempt > 0}
215
+ <p>Reconnecting… (attempt {$state.attempt})</p>
216
+ {/if}
217
+ ```
218
+
219
+ If `verify` authenticates from cookies, set `allowedOrigins` on the server as
220
+ well: browsers attach cookies to a WebSocket opened from any site, and without
221
+ an `Origin` check that is cross-site WebSocket hijacking.
154
222
 
155
- Mounted routes, relative to the mount path:
223
+ The reference server mounts these routes, relative to the mount path:
156
224
 
157
225
  | Method | Path | Notes |
158
226
  | ------ | ----------------------------- | ----------------------------------------- |
@@ -161,6 +229,23 @@ Mounted routes, relative to the mount path:
161
229
  | POST | `/publish/[namespace]/[room]` | Requires `httpAuth`, else **not mounted** |
162
230
  | POST | `/broadcast/[room]` | Requires `httpAuth`, else **not mounted** |
163
231
 
232
+ ## ws or sse?
233
+
234
+ `@marianmeres/sse` is the sibling package: same shape of API, different
235
+ transport, different strengths. In short —
236
+
237
+ - **Reach for `ws`** when traffic is genuinely bidirectional and chatty
238
+ (collaborative editing, games, chat with typing indicators), when you need
239
+ presence, or when you need binary frames.
240
+ - **Reach for `sse`** when traffic is mostly server → client (notifications,
241
+ live dashboards, progress, activity feeds), or when losing messages across a
242
+ reconnect is not acceptable — SSE resumes from `Last-Event-ID`, WebSocket has
243
+ no equivalent.
244
+
245
+ They are not drop-in replacements for one another and are not meant to be. See
246
+ [COMPARISON.md](https://github.com/marianmeres/sse/blob/master/COMPARISON.md)
247
+ in the `sse` package for the full table.
248
+
164
249
  ## Example
165
250
 
166
251
  A complete room chat — demino server plus a plain HTML client — lives in
@@ -170,16 +255,21 @@ A complete room chat — demino server plus a plain HTML client — lives in
170
255
  deno task example # builds the client bundle, then serves on :8000
171
256
  ```
172
257
 
173
- It exercises the handshake, namespaces, rooms, presence, acknowledged
174
- publishes, the broadcast gate, reconnect with buffered sends, HTTP injection
175
- and the pub/sub adapter seam. See
258
+ It exercises the rooms side end to end: the handshake, namespaces, rooms,
259
+ presence, acknowledged publishes, the broadcast gate, reconnect with buffered
260
+ sends, HTTP injection and the pub/sub adapter seam. See
176
261
  [example/README.md](https://github.com/marianmeres/ws/blob/master/example/README.md).
177
262
 
178
263
  ## Concepts
179
264
 
180
- **Namespace** — the isolation boundary. Clients in different namespaces can
181
- subscribe to identically named rooms without ever seeing each other's messages.
182
- A client may only publish into its own namespace.
265
+ **Message** — what the application sends and receives. Its `payload` is opaque:
266
+ never inspected, never mutated, free to carry its own `type` field. A message
267
+ the server sends directly has nothing else; one delivered through a room also
268
+ carries `room`, `namespace`, `from` and `timestamp` (a `WSRoomMessage`).
269
+
270
+ **Namespace** — the isolation boundary for rooms. Clients in different
271
+ namespaces can subscribe to identically named rooms without ever seeing each
272
+ other's messages. A client may only publish into its own namespace.
183
273
 
184
274
  **Room** — a channel within a namespace. Subscribe to receive its messages.
185
275
 
@@ -188,11 +278,10 @@ method rather than a flag on `publish()` precisely because crossing an isolation
188
278
  boundary deserves its own name and its own server-side check: `allowBroadcast`
189
279
  **denies by default**.
190
280
 
191
- **Frame vs message** — a _frame_ is one JSON protocol envelope (`auth`, `sub`,
192
- `pub`, `msg`, `ack`, …), carried in exactly one WebSocket text message — not an
193
- RFC 6455 fragment. A _message_ is the application-level object a `msg` frame
194
- delivers (`room`, `namespace`, `from`, `payload`, `timestamp`) — a `msg` frame
195
- minus its `type` field _is_ a `WSMessage`, which is what your handlers receive.
281
+ **Frame vs message** — a _frame_ is one JSON protocol envelope (`auth`, `msg`,
282
+ `ack`, `sub`, …), carried in exactly one WebSocket text message — not an RFC
283
+ 6455 fragment. A `msg` frame minus its `type` field _is_ the message your
284
+ handlers receive.
196
285
 
197
286
  ## Behaviour worth knowing
198
287
 
@@ -206,16 +295,22 @@ come back.
206
295
  rejects any pending `connect()`, emits `terminated`, and logs at error level. A
207
296
  silent one would be indistinguishable from a network that never recovered.
208
297
 
209
- **Delivery is at-most-once.** A publish that was transmitted but unacknowledged
210
- when the socket died is _not_ resent — that would risk duplicates, and the
211
- server has no deduplication. It rejects immediately with `WSConnectionLostError`
212
- rather than waiting out `sendTimeout`: the answer is already known at the close.
213
- At-least-once would need server-side replay, which this version does not do.
298
+ **Delivery is at-most-once.** An acknowledged send or a publish that was
299
+ transmitted but unacknowledged when the socket died is _not_ resent — that would
300
+ risk duplicates, and the server has no deduplication. It rejects immediately
301
+ with `WSConnectionLostError` rather than waiting out `sendTimeout`: the answer
302
+ is already known at the close. A fire-and-forget `send()` has no such answer to
303
+ give — it resolved when it was written. At-least-once would need server-side
304
+ replay, which this version does not do.
214
305
 
215
- **Sends are bounded.** Every publish carries one deadline covering queue, flight
216
- _and_ acknowledgement. Without it, a publish issued while offline would pend
306
+ **Sends are bounded.** Every send carries one deadline covering queue, flight
307
+ _and_ acknowledgement. Without it, a send issued while offline would pend
217
308
  forever behind an infinite retry.
218
309
 
310
+ **A server that does not do rooms says so.** Against a core-only server,
311
+ `subscribe()` and `publish()` reject at once with `WSRemoteError` code
312
+ `unsupported` — they do not time out.
313
+
219
314
  **`disconnect()` is resumable; `dispose()` is terminal.** Handlers, rooms and
220
315
  buffered sends survive a `disconnect()`, so a later `connect()` picks up where
221
316
  it left off.
@@ -227,9 +322,10 @@ See [API.md](API.md) for complete API documentation.
227
322
  ## Protocol
228
323
 
229
324
  The reference server is Deno-only; the protocol is not.
230
- [PROTOCOL.md](PROTOCOL.md) specifies the wire format frame by frame with a
231
- complete Python implementation and a conformance script that drives the real
232
- client so a compatible server can be written in any language.
325
+ [PROTOCOL.md](PROTOCOL.md) specifies the wire format frame by frame, core first
326
+ and the rooms extension after it with complete Python servers for both (a
327
+ core-only one, and the full reference) and a conformance script that drives the
328
+ real client against yours.
233
329
 
234
330
  ## License
235
331
 
@@ -6,13 +6,31 @@
6
6
  * that pend indefinitely, so every tracked frame carries **one timeout
7
7
  * spanning queue + flight + ack** — not an ack-only timeout.
8
8
  *
9
+ * Not every frame awaits an ack. A `msg` sent without `{ ack: true }` is
10
+ * complete the moment it is written to the socket; it passes through here
11
+ * only so that, while offline, it is buffered and bounded like everything else.
12
+ *
9
13
  * Written by hand rather than on top of `@marianmeres/batch`: that flusher
10
14
  * triggers on interval/count, whereas this one triggers on connection state.
11
15
  * Bending it into shape costs more than the little code it saves.
12
16
  *
13
17
  * @module
14
18
  */
15
- import type { ClientFrame, WSPublishResult } from "../protocol/frames.js";
19
+ import type { ClientFrame } from "../protocol/frames.js";
20
+ /** What a settled send resolves with — the parts of the `ack` the caller needs. */
21
+ export interface OutboxResult {
22
+ /** Delivery count from a `pub`/`broadcast` ack; `0` otherwise. */
23
+ recipients: number;
24
+ /** The server's reply from a `msg` ack; `undefined` otherwise. */
25
+ payload?: unknown;
26
+ }
27
+ /** A tracked frame together with the key it is tracked under. */
28
+ export interface OutboxEntry {
29
+ /** The key — the frame's wire `id` when it has one, a local one otherwise. */
30
+ id: string;
31
+ /** The frame itself. */
32
+ frame: ClientFrame;
33
+ }
16
34
  /** Configuration for {@link Outbox}. */
17
35
  export interface OutboxOptions {
18
36
  /** Max frames buffered while disconnected. `0` disables buffering. */
@@ -38,18 +56,32 @@ export declare class Outbox {
38
56
  /**
39
57
  * Registers a frame and returns the promise the caller awaits.
40
58
  *
41
- * @param id - correlation id, matched against the server's ack/nack
59
+ * @param id - correlation id, matched against the server's ack/nack; for a
60
+ * frame that awaits no ack, any locally unique key
42
61
  * @param frame - the frame itself, retained so it can be flushed later
43
62
  * @param queued - `true` to buffer it, `false` if it is going out now
63
+ * @param awaitAck - `false` when the frame is complete once transmitted —
64
+ * see {@link transmitted}
65
+ */
66
+ track(id: string, frame: ClientFrame, queued: boolean, awaitAck?: boolean): Promise<OutboxResult>;
67
+ /**
68
+ * Takes every buffered frame out of the queue and returns them in FIFO
69
+ * order, for the caller to write and then report via {@link transmitted}
70
+ * or {@link fail}. They stay pending — awaiting acks now, not a connection.
71
+ */
72
+ drain(): OutboxEntry[];
73
+ /**
74
+ * Reports a frame as written to the socket. One that awaits no ack is
75
+ * complete and resolves here; one that does keeps waiting for its ack.
44
76
  */
45
- track(id: string, frame: ClientFrame, queued: boolean): Promise<WSPublishResult>;
77
+ transmitted(id: string): void;
46
78
  /**
47
- * Marks every buffered frame as transmitted and returns them in FIFO order.
48
- * They stay pending — they are awaiting acks now, not a connection.
79
+ * Resolves a pending frame the server acked it.
80
+ *
81
+ * @param recipients - the ack's delivery count, `0` when it carried none
82
+ * @param payload - the ack's reply, when it carried one
49
83
  */
50
- drain(): ClientFrame[];
51
- /** Resolves a pending frame — the server acked it. */
52
- settle(id: string, recipients: number): boolean;
84
+ settle(id: string, recipients: number, payload?: unknown): boolean;
53
85
  /** Rejects a single pending frame. */
54
86
  fail(id: string, error: Error): boolean;
55
87
  /**
@@ -6,6 +6,10 @@
6
6
  * that pend indefinitely, so every tracked frame carries **one timeout
7
7
  * spanning queue + flight + ack** — not an ack-only timeout.
8
8
  *
9
+ * Not every frame awaits an ack. A `msg` sent without `{ ack: true }` is
10
+ * complete the moment it is written to the socket; it passes through here
11
+ * only so that, while offline, it is buffered and bounded like everything else.
12
+ *
9
13
  * Written by hand rather than on top of `@marianmeres/batch`: that flusher
10
14
  * triggers on interval/count, whereas this one triggers on connection state.
11
15
  * Bending it into shape costs more than the little code it saves.
@@ -41,17 +45,20 @@ export class Outbox {
41
45
  /**
42
46
  * Registers a frame and returns the promise the caller awaits.
43
47
  *
44
- * @param id - correlation id, matched against the server's ack/nack
48
+ * @param id - correlation id, matched against the server's ack/nack; for a
49
+ * frame that awaits no ack, any locally unique key
45
50
  * @param frame - the frame itself, retained so it can be flushed later
46
51
  * @param queued - `true` to buffer it, `false` if it is going out now
52
+ * @param awaitAck - `false` when the frame is complete once transmitted —
53
+ * see {@link transmitted}
47
54
  */
48
- track(id, frame, queued) {
55
+ track(id, frame, queued, awaitAck = true) {
49
56
  return new Promise((resolve, reject) => {
50
57
  const timer = setTimeout(() => {
51
58
  this.#discard(id);
52
59
  reject(new WSTimeoutError(this.#options.sendTimeout));
53
60
  }, this.#options.sendTimeout);
54
- this.#pending.set(id, { frame, resolve, reject, timer, queued });
61
+ this.#pending.set(id, { frame, resolve, reject, timer, queued, awaitAck });
55
62
  if (queued) {
56
63
  this.#queue.push(id);
57
64
  this.#applyCap();
@@ -59,27 +66,44 @@ export class Outbox {
59
66
  });
60
67
  }
61
68
  /**
62
- * Marks every buffered frame as transmitted and returns them in FIFO order.
63
- * They stay pending they are awaiting acks now, not a connection.
69
+ * Takes every buffered frame out of the queue and returns them in FIFO
70
+ * order, for the caller to write and then report via {@link transmitted}
71
+ * or {@link fail}. They stay pending — awaiting acks now, not a connection.
64
72
  */
65
73
  drain() {
66
- const frames = [];
74
+ const entries = [];
67
75
  for (const id of this.#queue) {
68
76
  const entry = this.#pending.get(id);
69
77
  if (!entry)
70
78
  continue;
71
79
  entry.queued = false;
72
- frames.push(entry.frame);
80
+ entries.push({ id, frame: entry.frame });
73
81
  }
74
82
  this.#queue = [];
75
- return frames;
83
+ return entries;
84
+ }
85
+ /**
86
+ * Reports a frame as written to the socket. One that awaits no ack is
87
+ * complete and resolves here; one that does keeps waiting for its ack.
88
+ */
89
+ transmitted(id) {
90
+ const entry = this.#pending.get(id);
91
+ if (!entry || entry.awaitAck)
92
+ return;
93
+ this.#discard(id);
94
+ entry.resolve({ recipients: 0 });
76
95
  }
77
- /** Resolves a pending frame — the server acked it. */
78
- settle(id, recipients) {
96
+ /**
97
+ * Resolves a pending frame — the server acked it.
98
+ *
99
+ * @param recipients - the ack's delivery count, `0` when it carried none
100
+ * @param payload - the ack's reply, when it carried one
101
+ */
102
+ settle(id, recipients, payload) {
79
103
  const entry = this.#discard(id);
80
104
  if (!entry)
81
105
  return false;
82
- entry.resolve({ recipients });
106
+ entry.resolve(payload === undefined ? { recipients } : { recipients, payload });
83
107
  return true;
84
108
  }
85
109
  /** Rejects a single pending frame. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Refcounted room registry.
2
+ * Refcounted room registry (rooms extension).
3
3
  *
4
4
  * N handlers on one room produce exactly one wire subscription; the `unsub`
5
5
  * frame goes out when the last handler detaches. This is what lets
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * @module
10
10
  */
11
- import type { SubRequest, WSMessage, WSPresenceEvent } from "../protocol/frames.js";
12
- /** Receives messages published to a room. */
13
- export type MessageHandler<T = unknown> = (msg: WSMessage<T>) => void;
11
+ import type { SubRequest, WSPresenceEvent, WSRoomMessage } from "../protocol/frames.js";
12
+ /** Receives messages published to a room — every routing field is present. */
13
+ export type MessageHandler<T = unknown> = (msg: WSRoomMessage<T>) => void;
14
14
  /** Receives membership changes for a room subscribed with presence enabled. */
15
15
  export type PresenceHandler = (event: WSPresenceEvent) => void;
16
16
  /** What changed as a result of an `add()`, and therefore what the wire needs. */
@@ -55,7 +55,7 @@ export declare class RoomRegistry {
55
55
  * Handlers are snapshotted first, so a handler that unsubscribes (or
56
56
  * subscribes) during delivery cannot corrupt the in-flight iteration.
57
57
  */
58
- deliver(room: string, msg: WSMessage, onError: (e: unknown) => void): void;
58
+ deliver(room: string, msg: WSRoomMessage, onError: (e: unknown) => void): void;
59
59
  /** Updates cached membership, then delivers to presence handlers. */
60
60
  deliverPresence(room: string, event: WSPresenceEvent, onError: (e: unknown) => void): void;
61
61
  clear(): void;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Refcounted room registry.
2
+ * Refcounted room registry (rooms extension).
3
3
  *
4
4
  * N handlers on one room produce exactly one wire subscription; the `unsub`
5
5
  * frame goes out when the last handler detaches. This is what lets