@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/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # @marianmeres/ws
2
+
3
+ [![NPM](https://img.shields.io/npm/v/@marianmeres/ws)](https://www.npmjs.com/package/@marianmeres/ws)
4
+ [![JSR](https://jsr.io/badges/@marianmeres/ws)](https://jsr.io/@marianmeres/ws)
5
+ [![License](https://img.shields.io/npm/l/@marianmeres/ws)](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
+ }