@marianmeres/ws 0.3.0 → 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 +91 -16
- package/API.md +360 -90
- package/README.md +192 -71
- package/dist/client/outbox.d.ts +51 -11
- package/dist/client/outbox.js +56 -18
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +142 -16
- package/dist/client/ws-client.js +170 -37
- 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 +21 -2
- package/dist/protocol/errors.js +24 -3
- package/dist/protocol/frames.d.ts +68 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,22 +4,24 @@
|
|
|
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
|
|
|
13
15
|
- **Reconnects forever** — capped exponential backoff with jitter, plus instant
|
|
14
|
-
retry when the browser comes back online or the tab
|
|
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,7 +165,42 @@ await ws.subscribe("room", onMessage, {
|
|
|
106
165
|
ws.members("room"); // last known membership
|
|
107
166
|
```
|
|
108
167
|
|
|
109
|
-
|
|
168
|
+
**The server:**
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { createWSApp } from "@marianmeres/ws/server";
|
|
172
|
+
|
|
173
|
+
const { app, service } = createWSApp("/ws", [], {
|
|
174
|
+
// `requested` is what the client asked for — hints, never facts.
|
|
175
|
+
verify: async (payload, req, requested) => {
|
|
176
|
+
const user = await authenticate(payload?.token);
|
|
177
|
+
// Returning null closes the socket with a terminal code.
|
|
178
|
+
if (!user || !user.orgs.includes(requested.namespace)) return null;
|
|
179
|
+
return { clientId: user.id, namespace: requested.namespace };
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Push into a room from anywhere in your app.
|
|
184
|
+
await service.publish("notifications", { text: "deploy finished" }, "org-123");
|
|
185
|
+
|
|
186
|
+
Deno.serve(app);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Namespace is the isolation boundary, and it falls back to what the client asked
|
|
190
|
+
for when `verify` returns none — so in a multi-tenant deployment `verify` must
|
|
191
|
+
return `namespace` and `clientId`, validating `requested` rather than trusting
|
|
192
|
+
it.
|
|
193
|
+
|
|
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:
|
|
110
204
|
|
|
111
205
|
```svelte
|
|
112
206
|
<script>
|
|
@@ -122,34 +216,36 @@ ws.members("room"); // last known membership
|
|
|
122
216
|
{/if}
|
|
123
217
|
```
|
|
124
218
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
import { createWSApp } from "@marianmeres/ws/server";
|
|
129
|
-
|
|
130
|
-
const { app, service } = createWSApp("/ws", [], {
|
|
131
|
-
verify: async (payload, req) => {
|
|
132
|
-
const user = await authenticate(payload?.token);
|
|
133
|
-
// Returning null closes the socket with a terminal code.
|
|
134
|
-
return user ? { clientId: user.id, namespace: user.orgId } : null;
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// Push to connected clients from anywhere in your app.
|
|
139
|
-
await service.publish("notifications", { text: "deploy finished" }, "org-123");
|
|
140
|
-
|
|
141
|
-
Deno.serve(app);
|
|
142
|
-
```
|
|
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.
|
|
143
222
|
|
|
144
|
-
|
|
223
|
+
The reference server mounts these routes, relative to the mount path:
|
|
145
224
|
|
|
146
225
|
| Method | Path | Notes |
|
|
147
226
|
| ------ | ----------------------------- | ----------------------------------------- |
|
|
148
227
|
| GET | `/` | WebSocket upgrade |
|
|
149
|
-
| GET | `/stats` |
|
|
228
|
+
| GET | `/stats` | Requires `httpAuth`, else **not mounted** |
|
|
150
229
|
| POST | `/publish/[namespace]/[room]` | Requires `httpAuth`, else **not mounted** |
|
|
151
230
|
| POST | `/broadcast/[room]` | Requires `httpAuth`, else **not mounted** |
|
|
152
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
|
+
|
|
153
249
|
## Example
|
|
154
250
|
|
|
155
251
|
A complete room chat — demino server plus a plain HTML client — lives in
|
|
@@ -159,16 +255,21 @@ A complete room chat — demino server plus a plain HTML client — lives in
|
|
|
159
255
|
deno task example # builds the client bundle, then serves on :8000
|
|
160
256
|
```
|
|
161
257
|
|
|
162
|
-
It exercises the handshake, namespaces, rooms,
|
|
163
|
-
publishes, the broadcast gate, reconnect with buffered
|
|
164
|
-
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
|
|
165
261
|
[example/README.md](https://github.com/marianmeres/ws/blob/master/example/README.md).
|
|
166
262
|
|
|
167
263
|
## Concepts
|
|
168
264
|
|
|
169
|
-
**
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
172
273
|
|
|
173
274
|
**Room** — a channel within a namespace. Subscribe to receive its messages.
|
|
174
275
|
|
|
@@ -177,6 +278,11 @@ method rather than a flag on `publish()` precisely because crossing an isolation
|
|
|
177
278
|
boundary deserves its own name and its own server-side check: `allowBroadcast`
|
|
178
279
|
**denies by default**.
|
|
179
280
|
|
|
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.
|
|
285
|
+
|
|
180
286
|
## Behaviour worth knowing
|
|
181
287
|
|
|
182
288
|
**Reconnect classification.** Everything reconnects except a local
|
|
@@ -189,15 +295,22 @@ come back.
|
|
|
189
295
|
rejects any pending `connect()`, emits `terminated`, and logs at error level. A
|
|
190
296
|
silent one would be indistinguishable from a network that never recovered.
|
|
191
297
|
|
|
192
|
-
**Delivery is at-most-once.**
|
|
193
|
-
when the socket died is _not_ resent — that would
|
|
194
|
-
server has no deduplication. It rejects
|
|
195
|
-
|
|
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.
|
|
196
305
|
|
|
197
|
-
**Sends are bounded.** Every
|
|
198
|
-
_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
|
|
199
308
|
forever behind an infinite retry.
|
|
200
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
|
+
|
|
201
314
|
**`disconnect()` is resumable; `dispose()` is terminal.** Handlers, rooms and
|
|
202
315
|
buffered sends survive a `disconnect()`, so a later `connect()` picks up where
|
|
203
316
|
it left off.
|
|
@@ -206,6 +319,14 @@ it left off.
|
|
|
206
319
|
|
|
207
320
|
See [API.md](API.md) for complete API documentation.
|
|
208
321
|
|
|
322
|
+
## Protocol
|
|
323
|
+
|
|
324
|
+
The reference server is Deno-only; the protocol is not.
|
|
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.
|
|
329
|
+
|
|
209
330
|
## License
|
|
210
331
|
|
|
211
332
|
[MIT](LICENSE)
|
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.
|
|
44
71
|
*/
|
|
45
|
-
|
|
72
|
+
drain(): OutboxEntry[];
|
|
46
73
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
49
76
|
*/
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
|
|
77
|
+
transmitted(id: string): void;
|
|
78
|
+
/**
|
|
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
|
|
83
|
+
*/
|
|
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
|
/**
|
|
@@ -58,8 +90,16 @@ export declare class Outbox {
|
|
|
58
90
|
*/
|
|
59
91
|
failAll(error: Error): void;
|
|
60
92
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
93
|
+
* Settles every already-transmitted frame, leaving the queued ones to wait
|
|
94
|
+
* for the next connection.
|
|
95
|
+
*
|
|
96
|
+
* Used when the socket closes: the ack can no longer arrive, because the
|
|
97
|
+
* socket that would have carried it is gone and the next one is a new
|
|
98
|
+
* session. Waiting out `sendTimeout` would only delay an answer that is
|
|
99
|
+
* already known.
|
|
100
|
+
*
|
|
101
|
+
* @param decide - per frame: the error to reject with, or `null` to resolve
|
|
102
|
+
* it with no recipients
|
|
63
103
|
*/
|
|
64
|
-
|
|
104
|
+
settleInFlight(decide: (frame: ClientFrame) => Error | null): void;
|
|
65
105
|
}
|
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;
|
|
76
84
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
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 });
|
|
95
|
+
}
|
|
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. */
|
|
@@ -104,14 +128,28 @@ export class Outbox {
|
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
130
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
131
|
+
* Settles every already-transmitted frame, leaving the queued ones to wait
|
|
132
|
+
* for the next connection.
|
|
133
|
+
*
|
|
134
|
+
* Used when the socket closes: the ack can no longer arrive, because the
|
|
135
|
+
* socket that would have carried it is gone and the next one is a new
|
|
136
|
+
* session. Waiting out `sendTimeout` would only delay an answer that is
|
|
137
|
+
* already known.
|
|
138
|
+
*
|
|
139
|
+
* @param decide - per frame: the error to reject with, or `null` to resolve
|
|
140
|
+
* it with no recipients
|
|
109
141
|
*/
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
142
|
+
settleInFlight(decide) {
|
|
143
|
+
for (const [id, entry] of [...this.#pending]) {
|
|
144
|
+
if (entry.queued)
|
|
145
|
+
continue;
|
|
146
|
+
const error = decide(entry.frame);
|
|
147
|
+
this.#discard(id);
|
|
148
|
+
if (error)
|
|
149
|
+
entry.reject(error);
|
|
150
|
+
else
|
|
151
|
+
entry.resolve({ recipients: 0 });
|
|
152
|
+
}
|
|
115
153
|
}
|
|
116
154
|
#discard(id) {
|
|
117
155
|
const entry = this.#pending.get(id);
|
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