@marianmeres/ws 0.2.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 +161 -0
- package/API.md +672 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/client/backoff.d.ts +25 -0
- package/dist/client/backoff.js +31 -0
- package/dist/client/heartbeat.d.ts +38 -0
- package/dist/client/heartbeat.js +78 -0
- package/dist/client/outbox.d.ts +65 -0
- package/dist/client/outbox.js +155 -0
- package/dist/client/rooms.d.ts +62 -0
- package/dist/client/rooms.js +120 -0
- package/dist/client/ws-client.d.ts +351 -0
- package/dist/client/ws-client.js +892 -0
- package/dist/mod.d.ts +30 -0
- package/dist/mod.js +29 -0
- package/dist/protocol/constants.d.ts +113 -0
- package/dist/protocol/constants.js +118 -0
- package/dist/protocol/errors.d.ts +92 -0
- package/dist/protocol/errors.js +109 -0
- package/dist/protocol/frames.d.ts +151 -0
- package/dist/protocol/frames.js +12 -0
- package/dist/protocol/mod.d.ts +10 -0
- package/dist/protocol/mod.js +10 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.js +9 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# @marianmeres/ws
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@marianmeres/ws)
|
|
4
|
+
[](https://jsr.io/@marianmeres/ws)
|
|
5
|
+
[](LICENSE)
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Reconnects forever** — capped exponential backoff with jitter, plus instant
|
|
14
|
+
retry when the browser comes back online or the tab regains focus
|
|
15
|
+
- **Detects half-open connections** — the failure where the peer vanishes, no
|
|
16
|
+
`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
|
|
23
|
+
- **Runs everywhere** — `WebSocket` is a global in browsers, Deno, Node 22+, Bun
|
|
24
|
+
and Workers, so there is no polyfill and no transport dependency
|
|
25
|
+
- **Svelte-store compatible** reactive connection state
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @marianmeres/ws
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
deno add jsr:@marianmeres/ws
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> **npm ships the client only.** The reference server needs
|
|
38
|
+
> `Deno.upgradeWebSocket`, so it exists solely as `jsr:@marianmeres/ws/server`.
|
|
39
|
+
> The client is fully runtime-agnostic, so npm consumers lose nothing they could
|
|
40
|
+
> have used.
|
|
41
|
+
|
|
42
|
+
## ws or sse?
|
|
43
|
+
|
|
44
|
+
`@marianmeres/sse` is the sibling package: same shape of API, different
|
|
45
|
+
transport, different strengths. In short —
|
|
46
|
+
|
|
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.
|
|
54
|
+
|
|
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
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Client
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { createWSClient } from "@marianmeres/ws";
|
|
65
|
+
|
|
66
|
+
const ws = createWSClient({
|
|
67
|
+
url: "/ws",
|
|
68
|
+
namespace: "org-123",
|
|
69
|
+
auth: () => session.token, // called on every (re)connect, so refresh works
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Subscribe and handle in one call; the returned function detaches the handler
|
|
73
|
+
// and unsubscribes the room when it was the last one.
|
|
74
|
+
const unsub = await ws.subscribe("chat", (msg) => {
|
|
75
|
+
console.log(msg.from, msg.payload, msg.timestamp);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const { recipients } = await ws.publish("chat", { text: "hello" });
|
|
79
|
+
|
|
80
|
+
unsub();
|
|
81
|
+
ws.dispose();
|
|
82
|
+
```
|
|
83
|
+
|
|
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.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
await ws.subscribe("room", onMessage, {
|
|
99
|
+
presence: (e) => {
|
|
100
|
+
// e.event is "sync" | "join" | "leave"
|
|
101
|
+
// "sync" carries the full snapshot, and fires again after every reconnect
|
|
102
|
+
console.log(e.event, e.clientId, e.members);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
ws.members("room"); // last known membership
|
|
107
|
+
```
|
|
108
|
+
|
|
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
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
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
|
+
```
|
|
143
|
+
|
|
144
|
+
Mounted routes, relative to the mount path:
|
|
145
|
+
|
|
146
|
+
| Method | Path | Notes |
|
|
147
|
+
| ------ | ----------------------------- | ----------------------------------------- |
|
|
148
|
+
| GET | `/` | WebSocket upgrade |
|
|
149
|
+
| GET | `/stats` | Guarded by `httpAuth` when supplied |
|
|
150
|
+
| POST | `/publish/[namespace]/[room]` | Requires `httpAuth`, else **not mounted** |
|
|
151
|
+
| POST | `/broadcast/[room]` | Requires `httpAuth`, else **not mounted** |
|
|
152
|
+
|
|
153
|
+
## Example
|
|
154
|
+
|
|
155
|
+
A complete room chat — demino server plus a plain HTML client — lives in
|
|
156
|
+
[example/](https://github.com/marianmeres/ws/tree/master/example):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
deno task example # builds the client bundle, then serves on :8000
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
It exercises the handshake, namespaces, rooms, presence, acknowledged
|
|
163
|
+
publishes, the broadcast gate, reconnect with buffered sends, HTTP injection
|
|
164
|
+
and the pub/sub adapter seam. See
|
|
165
|
+
[example/README.md](https://github.com/marianmeres/ws/blob/master/example/README.md).
|
|
166
|
+
|
|
167
|
+
## Concepts
|
|
168
|
+
|
|
169
|
+
**Namespace** — the isolation boundary. Clients in different namespaces can
|
|
170
|
+
subscribe to identically named rooms without ever seeing each other's messages.
|
|
171
|
+
A client may only publish into its own namespace.
|
|
172
|
+
|
|
173
|
+
**Room** — a channel within a namespace. Subscribe to receive its messages.
|
|
174
|
+
|
|
175
|
+
**Broadcast** — the one operation that crosses namespaces. It is a separate
|
|
176
|
+
method rather than a flag on `publish()` precisely because crossing an isolation
|
|
177
|
+
boundary deserves its own name and its own server-side check: `allowBroadcast`
|
|
178
|
+
**denies by default**.
|
|
179
|
+
|
|
180
|
+
## Behaviour worth knowing
|
|
181
|
+
|
|
182
|
+
**Reconnect classification.** Everything reconnects except a local
|
|
183
|
+
`disconnect()` and an explicit terminal close code (`4001 AUTH_FAILED`,
|
|
184
|
+
`4003 FORBIDDEN` by default). A server-sent `1000 Normal Closure` _does_
|
|
185
|
+
reconnect — a graceful shutdown or rolling deploy is exactly when clients must
|
|
186
|
+
come back.
|
|
187
|
+
|
|
188
|
+
**Terminal failures are loud.** Giving up is the only non-retrying exit, so it
|
|
189
|
+
rejects any pending `connect()`, emits `terminated`, and logs at error level. A
|
|
190
|
+
silent one would be indistinguishable from a network that never recovered.
|
|
191
|
+
|
|
192
|
+
**Delivery is at-most-once.** A publish that was transmitted but unacknowledged
|
|
193
|
+
when the socket died is _not_ resent — that would risk duplicates, and the
|
|
194
|
+
server has no deduplication. It rejects on `sendTimeout`. At-least-once would
|
|
195
|
+
need server-side replay, which this version does not do.
|
|
196
|
+
|
|
197
|
+
**Sends are bounded.** Every publish carries one deadline covering queue, flight
|
|
198
|
+
_and_ acknowledgement. Without it, a publish issued while offline would pend
|
|
199
|
+
forever behind an infinite retry.
|
|
200
|
+
|
|
201
|
+
**`disconnect()` is resumable; `dispose()` is terminal.** Handlers, rooms and
|
|
202
|
+
buffered sends survive a `disconnect()`, so a later `connect()` picks up where
|
|
203
|
+
it left off.
|
|
204
|
+
|
|
205
|
+
## API
|
|
206
|
+
|
|
207
|
+
See [API.md](API.md) for complete API documentation.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnect backoff. Pure and injectable, so the curve can be unit tested
|
|
3
|
+
* without waiting on real timers.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Exponential backoff with **equal jitter**.
|
|
9
|
+
*
|
|
10
|
+
* Returns a delay in `[d/2, d]` where `d = min(max, base * 2^(attempt-1))`.
|
|
11
|
+
*
|
|
12
|
+
* Equal jitter rather than full jitter (`random() * d`): full jitter can
|
|
13
|
+
* produce near-zero waits, which means a server coming back up gets hammered
|
|
14
|
+
* by the very clients it just dropped. Equal jitter keeps a floor while still
|
|
15
|
+
* breaking the lockstep that would otherwise make 10k clients retry in unison.
|
|
16
|
+
*
|
|
17
|
+
* The cap matters as much as the jitter — uncapped `base * 2^n` reaches ~17
|
|
18
|
+
* minutes by attempt 11, which is indistinguishable from "dead" to a user.
|
|
19
|
+
*
|
|
20
|
+
* @param attempt - 1-based attempt number
|
|
21
|
+
* @param base - initial delay in ms
|
|
22
|
+
* @param max - ceiling in ms
|
|
23
|
+
* @param rnd - randomness source, injectable for tests
|
|
24
|
+
*/
|
|
25
|
+
export declare function backoffDelay(attempt: number, base: number, max: number, rnd?: () => number): number;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnect backoff. Pure and injectable, so the curve can be unit tested
|
|
3
|
+
* without waiting on real timers.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Exponential backoff with **equal jitter**.
|
|
9
|
+
*
|
|
10
|
+
* Returns a delay in `[d/2, d]` where `d = min(max, base * 2^(attempt-1))`.
|
|
11
|
+
*
|
|
12
|
+
* Equal jitter rather than full jitter (`random() * d`): full jitter can
|
|
13
|
+
* produce near-zero waits, which means a server coming back up gets hammered
|
|
14
|
+
* by the very clients it just dropped. Equal jitter keeps a floor while still
|
|
15
|
+
* breaking the lockstep that would otherwise make 10k clients retry in unison.
|
|
16
|
+
*
|
|
17
|
+
* The cap matters as much as the jitter — uncapped `base * 2^n` reaches ~17
|
|
18
|
+
* minutes by attempt 11, which is indistinguishable from "dead" to a user.
|
|
19
|
+
*
|
|
20
|
+
* @param attempt - 1-based attempt number
|
|
21
|
+
* @param base - initial delay in ms
|
|
22
|
+
* @param max - ceiling in ms
|
|
23
|
+
* @param rnd - randomness source, injectable for tests
|
|
24
|
+
*/
|
|
25
|
+
export function backoffDelay(attempt, base, max, rnd = Math.random) {
|
|
26
|
+
const n = Math.max(1, Math.floor(attempt));
|
|
27
|
+
// 2^n overflows to Infinity well before it matters; Math.min handles it.
|
|
28
|
+
const full = Math.min(max, base * Math.pow(2, n - 1));
|
|
29
|
+
const half = full / 2;
|
|
30
|
+
return Math.round(half + rnd() * half);
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-level ping/pong liveness probe.
|
|
3
|
+
*
|
|
4
|
+
* Half-open TCP is *the* defining WebSocket failure: the peer vanishes, no FIN
|
|
5
|
+
* ever arrives, `onclose` never fires, and the client sits there looking
|
|
6
|
+
* connected while receiving nothing. Browsers expose no protocol-level ping,
|
|
7
|
+
* so detecting it requires an application-level probe.
|
|
8
|
+
*
|
|
9
|
+
* The interval doubles as proxy keep-alive — nginx and friends drop idle
|
|
10
|
+
* sockets at 60s by default, so the 25s default sits comfortably under that.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** Configuration for {@link Heartbeat}. */
|
|
15
|
+
export interface HeartbeatOptions {
|
|
16
|
+
/** Ping cadence in ms. `0` disables the heartbeat entirely. */
|
|
17
|
+
interval: number;
|
|
18
|
+
/** How long to wait for a pong before declaring the socket dead. */
|
|
19
|
+
timeout: number;
|
|
20
|
+
/** Send a ping frame. */
|
|
21
|
+
onPing: () => void;
|
|
22
|
+
/** No pong arrived in time — the connection is dead but pretending. */
|
|
23
|
+
onTimeout: () => void;
|
|
24
|
+
}
|
|
25
|
+
/** Drives ping frames and enforces the pong deadline. */
|
|
26
|
+
export declare class Heartbeat {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(options: HeartbeatOptions);
|
|
29
|
+
/** Whether the heartbeat is currently running. */
|
|
30
|
+
get running(): boolean;
|
|
31
|
+
start(): void;
|
|
32
|
+
stop(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Any inbound traffic proves the connection is alive, not just a literal
|
|
35
|
+
* `pong` — so the client feeds every received frame through here.
|
|
36
|
+
*/
|
|
37
|
+
alive(): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-level ping/pong liveness probe.
|
|
3
|
+
*
|
|
4
|
+
* Half-open TCP is *the* defining WebSocket failure: the peer vanishes, no FIN
|
|
5
|
+
* ever arrives, `onclose` never fires, and the client sits there looking
|
|
6
|
+
* connected while receiving nothing. Browsers expose no protocol-level ping,
|
|
7
|
+
* so detecting it requires an application-level probe.
|
|
8
|
+
*
|
|
9
|
+
* The interval doubles as proxy keep-alive — nginx and friends drop idle
|
|
10
|
+
* sockets at 60s by default, so the 25s default sits comfortably under that.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { createTicker } from "@marianmeres/ticker";
|
|
15
|
+
/** Drives ping frames and enforces the pong deadline. */
|
|
16
|
+
export class Heartbeat {
|
|
17
|
+
#options;
|
|
18
|
+
#ticker = null;
|
|
19
|
+
#unsubscribe = null;
|
|
20
|
+
#deadline;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.#options = options;
|
|
23
|
+
}
|
|
24
|
+
/** Whether the heartbeat is currently running. */
|
|
25
|
+
get running() {
|
|
26
|
+
return !!this.#ticker;
|
|
27
|
+
}
|
|
28
|
+
start() {
|
|
29
|
+
if (this.#options.interval <= 0 || this.#ticker)
|
|
30
|
+
return;
|
|
31
|
+
this.#ticker = createTicker(this.#options.interval);
|
|
32
|
+
this.#unsubscribe = this.#ticker.subscribe((timestamp) => {
|
|
33
|
+
// The ticker emits 0 on stop; only real ticks should ping.
|
|
34
|
+
if (!timestamp)
|
|
35
|
+
return;
|
|
36
|
+
this.#options.onPing();
|
|
37
|
+
this.#armDeadline();
|
|
38
|
+
});
|
|
39
|
+
this.#ticker.start();
|
|
40
|
+
}
|
|
41
|
+
stop() {
|
|
42
|
+
this.#clearDeadline();
|
|
43
|
+
this.#unsubscribe?.();
|
|
44
|
+
this.#unsubscribe = null;
|
|
45
|
+
this.#ticker?.stop();
|
|
46
|
+
this.#ticker = null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Any inbound traffic proves the connection is alive, not just a literal
|
|
50
|
+
* `pong` — so the client feeds every received frame through here.
|
|
51
|
+
*/
|
|
52
|
+
alive() {
|
|
53
|
+
this.#clearDeadline();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Starts the pong deadline if one is not already running.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately does **not** restart a pending deadline. The deadline
|
|
59
|
+
* measures time since the *oldest* unanswered ping, so a later ping must
|
|
60
|
+
* not postpone the verdict on an earlier one — otherwise any configuration
|
|
61
|
+
* with `pingInterval <= pongTimeout` would reset the timer forever and
|
|
62
|
+
* silently disable half-open detection altogether.
|
|
63
|
+
*/
|
|
64
|
+
#armDeadline() {
|
|
65
|
+
if (this.#deadline !== undefined)
|
|
66
|
+
return;
|
|
67
|
+
this.#deadline = setTimeout(() => {
|
|
68
|
+
this.#deadline = undefined;
|
|
69
|
+
this.#options.onTimeout();
|
|
70
|
+
}, this.#options.timeout);
|
|
71
|
+
}
|
|
72
|
+
#clearDeadline() {
|
|
73
|
+
if (this.#deadline !== undefined) {
|
|
74
|
+
clearTimeout(this.#deadline);
|
|
75
|
+
this.#deadline = undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox + pending-ack registry.
|
|
3
|
+
*
|
|
4
|
+
* This is where three decisions collide: awaited acks, buffering while
|
|
5
|
+
* disconnected, and retrying forever. Combined naively they produce promises
|
|
6
|
+
* that pend indefinitely, so every tracked frame carries **one timeout
|
|
7
|
+
* spanning queue + flight + ack** — not an ack-only timeout.
|
|
8
|
+
*
|
|
9
|
+
* Written by hand rather than on top of `@marianmeres/batch`: that flusher
|
|
10
|
+
* triggers on interval/count, whereas this one triggers on connection state.
|
|
11
|
+
* Bending it into shape costs more than the little code it saves.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { ClientFrame, WSPublishResult } from "../protocol/frames.js";
|
|
16
|
+
/** Configuration for {@link Outbox}. */
|
|
17
|
+
export interface OutboxOptions {
|
|
18
|
+
/** Max frames buffered while disconnected. `0` disables buffering. */
|
|
19
|
+
maxSize: number;
|
|
20
|
+
/** Overall deadline per frame, covering queue + flight + ack. */
|
|
21
|
+
sendTimeout: number;
|
|
22
|
+
/** Called with frames evicted because the queue was full. */
|
|
23
|
+
onDrop?: (frames: ClientFrame[]) => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Tracks frames awaiting acknowledgement, and buffers those that could not be
|
|
27
|
+
* sent yet.
|
|
28
|
+
*/
|
|
29
|
+
export declare class Outbox {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(options: OutboxOptions);
|
|
32
|
+
/** Frames buffered but not yet transmitted. */
|
|
33
|
+
get queuedCount(): number;
|
|
34
|
+
/** Frames awaiting an ack, transmitted or not. */
|
|
35
|
+
get pendingCount(): number;
|
|
36
|
+
/** Total frames evicted because the queue was full, for the lifetime. */
|
|
37
|
+
get droppedCount(): number;
|
|
38
|
+
/**
|
|
39
|
+
* Registers a frame and returns the promise the caller awaits.
|
|
40
|
+
*
|
|
41
|
+
* @param id - correlation id, matched against the server's ack/nack
|
|
42
|
+
* @param frame - the frame itself, retained so it can be flushed later
|
|
43
|
+
* @param queued - `true` to buffer it, `false` if it is going out now
|
|
44
|
+
*/
|
|
45
|
+
track(id: string, frame: ClientFrame, queued: boolean): Promise<WSPublishResult>;
|
|
46
|
+
/**
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
drain(): ClientFrame[];
|
|
51
|
+
/** Resolves a pending frame — the server acked it. */
|
|
52
|
+
settle(id: string, recipients: number): boolean;
|
|
53
|
+
/** Rejects a single pending frame. */
|
|
54
|
+
fail(id: string, error: Error): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Rejects everything. Used on terminal close and on dispose — without it,
|
|
57
|
+
* callers would wait out `sendTimeout` for an answer that can never come.
|
|
58
|
+
*/
|
|
59
|
+
failAll(error: Error): void;
|
|
60
|
+
/**
|
|
61
|
+
* Rejects only the frames still waiting to be transmitted, leaving
|
|
62
|
+
* in-flight ones to their acks or timeouts.
|
|
63
|
+
*/
|
|
64
|
+
failQueued(error: Error): void;
|
|
65
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox + pending-ack registry.
|
|
3
|
+
*
|
|
4
|
+
* This is where three decisions collide: awaited acks, buffering while
|
|
5
|
+
* disconnected, and retrying forever. Combined naively they produce promises
|
|
6
|
+
* that pend indefinitely, so every tracked frame carries **one timeout
|
|
7
|
+
* spanning queue + flight + ack** — not an ack-only timeout.
|
|
8
|
+
*
|
|
9
|
+
* Written by hand rather than on top of `@marianmeres/batch`: that flusher
|
|
10
|
+
* triggers on interval/count, whereas this one triggers on connection state.
|
|
11
|
+
* Bending it into shape costs more than the little code it saves.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { WSOutboxDropError, WSTimeoutError } from "../protocol/errors.js";
|
|
16
|
+
/**
|
|
17
|
+
* Tracks frames awaiting acknowledgement, and buffers those that could not be
|
|
18
|
+
* sent yet.
|
|
19
|
+
*/
|
|
20
|
+
export class Outbox {
|
|
21
|
+
#pending = new Map();
|
|
22
|
+
/** Ids of not-yet-transmitted frames, in FIFO order. */
|
|
23
|
+
#queue = [];
|
|
24
|
+
#dropped = 0;
|
|
25
|
+
#options;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.#options = options;
|
|
28
|
+
}
|
|
29
|
+
/** Frames buffered but not yet transmitted. */
|
|
30
|
+
get queuedCount() {
|
|
31
|
+
return this.#queue.length;
|
|
32
|
+
}
|
|
33
|
+
/** Frames awaiting an ack, transmitted or not. */
|
|
34
|
+
get pendingCount() {
|
|
35
|
+
return this.#pending.size;
|
|
36
|
+
}
|
|
37
|
+
/** Total frames evicted because the queue was full, for the lifetime. */
|
|
38
|
+
get droppedCount() {
|
|
39
|
+
return this.#dropped;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Registers a frame and returns the promise the caller awaits.
|
|
43
|
+
*
|
|
44
|
+
* @param id - correlation id, matched against the server's ack/nack
|
|
45
|
+
* @param frame - the frame itself, retained so it can be flushed later
|
|
46
|
+
* @param queued - `true` to buffer it, `false` if it is going out now
|
|
47
|
+
*/
|
|
48
|
+
track(id, frame, queued) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
this.#discard(id);
|
|
52
|
+
reject(new WSTimeoutError(this.#options.sendTimeout));
|
|
53
|
+
}, this.#options.sendTimeout);
|
|
54
|
+
this.#pending.set(id, { frame, resolve, reject, timer, queued });
|
|
55
|
+
if (queued) {
|
|
56
|
+
this.#queue.push(id);
|
|
57
|
+
this.#applyCap();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
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.
|
|
64
|
+
*/
|
|
65
|
+
drain() {
|
|
66
|
+
const frames = [];
|
|
67
|
+
for (const id of this.#queue) {
|
|
68
|
+
const entry = this.#pending.get(id);
|
|
69
|
+
if (!entry)
|
|
70
|
+
continue;
|
|
71
|
+
entry.queued = false;
|
|
72
|
+
frames.push(entry.frame);
|
|
73
|
+
}
|
|
74
|
+
this.#queue = [];
|
|
75
|
+
return frames;
|
|
76
|
+
}
|
|
77
|
+
/** Resolves a pending frame — the server acked it. */
|
|
78
|
+
settle(id, recipients) {
|
|
79
|
+
const entry = this.#discard(id);
|
|
80
|
+
if (!entry)
|
|
81
|
+
return false;
|
|
82
|
+
entry.resolve({ recipients });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/** Rejects a single pending frame. */
|
|
86
|
+
fail(id, error) {
|
|
87
|
+
const entry = this.#discard(id);
|
|
88
|
+
if (!entry)
|
|
89
|
+
return false;
|
|
90
|
+
entry.reject(error);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Rejects everything. Used on terminal close and on dispose — without it,
|
|
95
|
+
* callers would wait out `sendTimeout` for an answer that can never come.
|
|
96
|
+
*/
|
|
97
|
+
failAll(error) {
|
|
98
|
+
const entries = [...this.#pending.values()];
|
|
99
|
+
this.#pending.clear();
|
|
100
|
+
this.#queue = [];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
clearTimeout(entry.timer);
|
|
103
|
+
entry.reject(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Rejects only the frames still waiting to be transmitted, leaving
|
|
108
|
+
* in-flight ones to their acks or timeouts.
|
|
109
|
+
*/
|
|
110
|
+
failQueued(error) {
|
|
111
|
+
const ids = this.#queue;
|
|
112
|
+
this.#queue = [];
|
|
113
|
+
for (const id of ids)
|
|
114
|
+
this.fail(id, error);
|
|
115
|
+
}
|
|
116
|
+
#discard(id) {
|
|
117
|
+
const entry = this.#pending.get(id);
|
|
118
|
+
if (!entry)
|
|
119
|
+
return undefined;
|
|
120
|
+
clearTimeout(entry.timer);
|
|
121
|
+
this.#pending.delete(id);
|
|
122
|
+
if (entry.queued) {
|
|
123
|
+
const at = this.#queue.indexOf(id);
|
|
124
|
+
if (at !== -1)
|
|
125
|
+
this.#queue.splice(at, 1);
|
|
126
|
+
}
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Enforces `maxSize` by dropping the **oldest** frames.
|
|
131
|
+
*
|
|
132
|
+
* Dropping the oldest keeps the freshest state, which is what almost every
|
|
133
|
+
* real-time use wants. Each victim's promise rejects immediately, so a
|
|
134
|
+
* caller always gets an answer rather than silently losing a message.
|
|
135
|
+
*/
|
|
136
|
+
#applyCap() {
|
|
137
|
+
const max = this.#options.maxSize;
|
|
138
|
+
const evicted = [];
|
|
139
|
+
while (this.#queue.length > max) {
|
|
140
|
+
const id = this.#queue.shift();
|
|
141
|
+
if (id === undefined)
|
|
142
|
+
break;
|
|
143
|
+
const entry = this.#pending.get(id);
|
|
144
|
+
if (!entry)
|
|
145
|
+
continue;
|
|
146
|
+
clearTimeout(entry.timer);
|
|
147
|
+
this.#pending.delete(id);
|
|
148
|
+
this.#dropped++;
|
|
149
|
+
evicted.push(entry.frame);
|
|
150
|
+
entry.reject(new WSOutboxDropError());
|
|
151
|
+
}
|
|
152
|
+
if (evicted.length)
|
|
153
|
+
this.#options.onDrop?.(evicted);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refcounted room registry.
|
|
3
|
+
*
|
|
4
|
+
* N handlers on one room produce exactly one wire subscription; the `unsub`
|
|
5
|
+
* frame goes out when the last handler detaches. This is what lets
|
|
6
|
+
* `subscribe()` hand back a plain unsubscriber that is safe to call from
|
|
7
|
+
* component teardown without any coordination between call sites.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
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;
|
|
14
|
+
/** Receives membership changes for a room subscribed with presence enabled. */
|
|
15
|
+
export type PresenceHandler = (event: WSPresenceEvent) => void;
|
|
16
|
+
/** What changed as a result of an `add()`, and therefore what the wire needs. */
|
|
17
|
+
export interface AddResult {
|
|
18
|
+
/** The room is new — a `sub` frame is required. */
|
|
19
|
+
created: boolean;
|
|
20
|
+
/** Presence was requested for a room that did not have it — re-`sub`. */
|
|
21
|
+
presenceUpgraded: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Tracks rooms, their handlers, and their known membership. */
|
|
24
|
+
export declare class RoomRegistry {
|
|
25
|
+
#private;
|
|
26
|
+
/** Rooms currently held, in insertion order. */
|
|
27
|
+
get rooms(): string[];
|
|
28
|
+
has(room: string): boolean;
|
|
29
|
+
wantsPresence(room: string): boolean;
|
|
30
|
+
members(room: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Attaches handlers, creating the room entry if needed.
|
|
33
|
+
*
|
|
34
|
+
* Presence is enabled by *providing a presence handler* rather than by a
|
|
35
|
+
* separate boolean — one way to express the intent instead of two that can
|
|
36
|
+
* disagree.
|
|
37
|
+
*/
|
|
38
|
+
add(room: string, handler: MessageHandler, presenceHandler?: PresenceHandler): AddResult;
|
|
39
|
+
/**
|
|
40
|
+
* Detaches handlers.
|
|
41
|
+
*
|
|
42
|
+
* @returns `true` when the room became empty and should be unsubscribed.
|
|
43
|
+
*/
|
|
44
|
+
remove(room: string, handler: MessageHandler, presenceHandler?: PresenceHandler): boolean;
|
|
45
|
+
/** Force-removes a room and every handler attached to it. */
|
|
46
|
+
removeRoom(room: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The full subscription set, for re-subscribing after a reconnect in a
|
|
49
|
+
* single batched frame rather than one round-trip per room.
|
|
50
|
+
*/
|
|
51
|
+
subRequests(): SubRequest[];
|
|
52
|
+
/**
|
|
53
|
+
* Delivers to every handler for the room.
|
|
54
|
+
*
|
|
55
|
+
* Handlers are snapshotted first, so a handler that unsubscribes (or
|
|
56
|
+
* subscribes) during delivery cannot corrupt the in-flight iteration.
|
|
57
|
+
*/
|
|
58
|
+
deliver(room: string, msg: WSMessage, onError: (e: unknown) => void): void;
|
|
59
|
+
/** Updates cached membership, then delivers to presence handlers. */
|
|
60
|
+
deliverPresence(room: string, event: WSPresenceEvent, onError: (e: unknown) => void): void;
|
|
61
|
+
clear(): void;
|
|
62
|
+
}
|