@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/AGENTS.md +63 -15
- package/API.md +232 -61
- package/README.md +176 -80
- package/dist/client/outbox.d.ts +40 -8
- package/dist/client/outbox.js +35 -11
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +132 -15
- package/dist/client/ws-client.js +101 -36
- package/dist/mod.d.ts +23 -4
- package/dist/mod.js +22 -3
- package/dist/protocol/constants.d.ts +35 -16
- package/dist/protocol/constants.js +40 -18
- package/dist/protocol/errors.d.ts +12 -2
- package/dist/protocol/errors.js +13 -3
- package/dist/protocol/frames.d.ts +58 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
[](https://jsr.io/@marianmeres/ws)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
A WebSocket client
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
##
|
|
44
|
+
## Two ways to use it
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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,
|
|
174
|
-
publishes, the broadcast gate, reconnect with buffered
|
|
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
|
-
**
|
|
181
|
-
|
|
182
|
-
|
|
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`, `
|
|
192
|
-
`
|
|
193
|
-
|
|
194
|
-
|
|
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.**
|
|
210
|
-
when the socket died is _not_ resent — that would
|
|
211
|
-
server has no deduplication. It rejects immediately
|
|
212
|
-
rather than waiting out `sendTimeout`: the answer
|
|
213
|
-
|
|
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
|
|
216
|
-
_and_ acknowledgement. Without it, a
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
package/dist/client/outbox.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
77
|
+
transmitted(id: string): void;
|
|
46
78
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
/**
|
package/dist/client/outbox.js
CHANGED
|
@@ -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
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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
|
-
|
|
80
|
+
entries.push({ id, frame: entry.frame });
|
|
73
81
|
}
|
|
74
82
|
this.#queue = [];
|
|
75
|
-
return
|
|
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
|
-
/**
|
|
78
|
-
|
|
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. */
|
package/dist/client/rooms.d.ts
CHANGED
|
@@ -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,
|
|
12
|
-
/** Receives messages published to a room. */
|
|
13
|
-
export type MessageHandler<T = unknown> = (msg:
|
|
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:
|
|
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;
|
package/dist/client/rooms.js
CHANGED