@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.
@@ -0,0 +1,120 @@
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
+ /** Tracks rooms, their handlers, and their known membership. */
12
+ export class RoomRegistry {
13
+ #rooms = new Map();
14
+ /** Rooms currently held, in insertion order. */
15
+ get rooms() {
16
+ return [...this.#rooms.keys()];
17
+ }
18
+ has(room) {
19
+ return this.#rooms.has(room);
20
+ }
21
+ wantsPresence(room) {
22
+ const entry = this.#rooms.get(room);
23
+ return !!entry && entry.presenceHandlers.size > 0;
24
+ }
25
+ members(room) {
26
+ return [...(this.#rooms.get(room)?.members ?? [])];
27
+ }
28
+ /**
29
+ * Attaches handlers, creating the room entry if needed.
30
+ *
31
+ * Presence is enabled by *providing a presence handler* rather than by a
32
+ * separate boolean — one way to express the intent instead of two that can
33
+ * disagree.
34
+ */
35
+ add(room, handler, presenceHandler) {
36
+ let entry = this.#rooms.get(room);
37
+ const created = !entry;
38
+ const hadPresence = !!entry && entry.presenceHandlers.size > 0;
39
+ if (!entry) {
40
+ entry = { handlers: new Set(), presenceHandlers: new Set(), members: [] };
41
+ this.#rooms.set(room, entry);
42
+ }
43
+ entry.handlers.add(handler);
44
+ if (presenceHandler)
45
+ entry.presenceHandlers.add(presenceHandler);
46
+ return {
47
+ created,
48
+ presenceUpgraded: !created && !hadPresence && !!presenceHandler,
49
+ };
50
+ }
51
+ /**
52
+ * Detaches handlers.
53
+ *
54
+ * @returns `true` when the room became empty and should be unsubscribed.
55
+ */
56
+ remove(room, handler, presenceHandler) {
57
+ const entry = this.#rooms.get(room);
58
+ if (!entry)
59
+ return false;
60
+ entry.handlers.delete(handler);
61
+ if (presenceHandler)
62
+ entry.presenceHandlers.delete(presenceHandler);
63
+ if (entry.handlers.size === 0 && entry.presenceHandlers.size === 0) {
64
+ this.#rooms.delete(room);
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ /** Force-removes a room and every handler attached to it. */
70
+ removeRoom(room) {
71
+ return this.#rooms.delete(room);
72
+ }
73
+ /**
74
+ * The full subscription set, for re-subscribing after a reconnect in a
75
+ * single batched frame rather than one round-trip per room.
76
+ */
77
+ subRequests() {
78
+ return [...this.#rooms.entries()].map(([room, entry]) => ({
79
+ room,
80
+ ...(entry.presenceHandlers.size > 0 ? { presence: true } : {}),
81
+ }));
82
+ }
83
+ /**
84
+ * Delivers to every handler for the room.
85
+ *
86
+ * Handlers are snapshotted first, so a handler that unsubscribes (or
87
+ * subscribes) during delivery cannot corrupt the in-flight iteration.
88
+ */
89
+ deliver(room, msg, onError) {
90
+ const entry = this.#rooms.get(room);
91
+ if (!entry)
92
+ return;
93
+ for (const handler of [...entry.handlers]) {
94
+ try {
95
+ handler(msg);
96
+ }
97
+ catch (e) {
98
+ onError(e);
99
+ }
100
+ }
101
+ }
102
+ /** Updates cached membership, then delivers to presence handlers. */
103
+ deliverPresence(room, event, onError) {
104
+ const entry = this.#rooms.get(room);
105
+ if (!entry)
106
+ return;
107
+ entry.members = event.members;
108
+ for (const handler of [...entry.presenceHandlers]) {
109
+ try {
110
+ handler(event);
111
+ }
112
+ catch (e) {
113
+ onError(e);
114
+ }
115
+ }
116
+ }
117
+ clear() {
118
+ this.#rooms.clear();
119
+ }
120
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The WebSocket client.
3
+ *
4
+ * @module
5
+ */
6
+ import { type Logger } from "@marianmeres/clog";
7
+ import { type Subscriber, type Unsubscriber } from "@marianmeres/pubsub";
8
+ import type { ClientFrame, WSDecoder, WSEncoder, WSMessage, WSPresenceEvent, WSPublishResult } from "../protocol/frames.js";
9
+ import { type MessageHandler, type PresenceHandler } from "./rooms.js";
10
+ /**
11
+ * Connection lifecycle states.
12
+ *
13
+ * A plain union rather than a state-machine dependency — the transition table
14
+ * below is the whole of the machinery, and it is small enough to read.
15
+ */
16
+ export type WSConnectionState = "idle" | "connecting" | "authenticating" | "open" | "reconnecting" | "terminated" | "disposed";
17
+ /** Events emitted by {@link WSClient}. */
18
+ export interface WSEvents {
19
+ /** Socket opened; authentication has not happened yet. */
20
+ open: void;
21
+ /** Authenticated and ready. */
22
+ connected: {
23
+ clientId: string;
24
+ namespace: string;
25
+ };
26
+ /** Firehose — every message, regardless of room. */
27
+ message: WSMessage;
28
+ /** Membership change in a room subscribed with presence enabled. */
29
+ presence: WSPresenceEvent;
30
+ /** Socket closed. `willReconnect` reflects the retry classification. */
31
+ close: {
32
+ code: number;
33
+ reason: string;
34
+ willReconnect: boolean;
35
+ };
36
+ /** A retry is scheduled; `delay` is the jittered backoff in ms. */
37
+ reconnecting: {
38
+ attempt: number;
39
+ delay: number;
40
+ };
41
+ /** Gave up — the only non-retrying exit. */
42
+ terminated: {
43
+ code: number;
44
+ reason: string;
45
+ };
46
+ /** Something failed but the client carries on: decode, a throwing handler, … */
47
+ error: Error;
48
+ }
49
+ /** Reactive connection state, delivered through the Svelte store contract. */
50
+ export interface WSState {
51
+ /** Current lifecycle state. */
52
+ state: WSConnectionState;
53
+ /** `true` only in the `open` state — authenticated and usable. */
54
+ connected: boolean;
55
+ /** `true` while connecting, authenticating or waiting out a backoff. */
56
+ connecting: boolean;
57
+ /** Consecutive failed connection attempts; resets to 0 on success. */
58
+ attempt: number;
59
+ /** Most recent error, retained until the next successful connect. */
60
+ lastError: Error | null;
61
+ }
62
+ /** Per-room subscription options. */
63
+ export interface SubscribeOptions {
64
+ /**
65
+ * Enables presence tracking for this room.
66
+ *
67
+ * Presence is opt-in per room rather than global: a 10k-subscriber
68
+ * notification room does not want 10k join events every time the fleet
69
+ * reconnects.
70
+ */
71
+ presence?: PresenceHandler;
72
+ }
73
+ /** Configuration for {@link WSClient}. */
74
+ export interface WSClientOptions<TAuth = unknown> {
75
+ /**
76
+ * Endpoint. `ws://`/`wss://`, or `http(s)://` (upgraded automatically), or
77
+ * a path resolved against `location` in the browser. Default `/ws`.
78
+ */
79
+ url?: string | URL;
80
+ /** Isolation boundary. Default `"default"`. */
81
+ namespace?: string;
82
+ /** Preferred client id; the server may override it. */
83
+ clientId?: string;
84
+ /** Rooms joined automatically on every (re)connect. */
85
+ rooms?: string[];
86
+ /**
87
+ * Produces the auth payload. Called before *every* (re)connect, so
88
+ * returning a fresh token here is all that token refresh requires.
89
+ */
90
+ auth?: () => TAuth | Promise<TAuth>;
91
+ /**
92
+ * Let the first `subscribe()`/`publish()` start the connection.
93
+ * Default `true` — with an outbox and infinite retry, requiring an explicit
94
+ * `connect()` first is ceremony whose only product is an error for people
95
+ * who forgot.
96
+ */
97
+ autoConnect?: boolean;
98
+ /** `null` disables logging. */
99
+ logger?: Logger | null;
100
+ /** Initial reconnect delay in ms. Default 500. */
101
+ reconnectDelay?: number;
102
+ /** Reconnect delay ceiling in ms. Default 30_000. */
103
+ reconnectDelayMax?: number;
104
+ /** Close codes after which retrying stops. Default `[4001, 4003]`. */
105
+ terminalCloseCodes?: number[];
106
+ /** Ping cadence in ms. `0` disables. Default 25_000. */
107
+ pingInterval?: number;
108
+ /**
109
+ * Liveness deadline in ms — bounds both the pong reply and the initial
110
+ * auth handshake. Default 10_000.
111
+ */
112
+ pongTimeout?: number;
113
+ /** Bound for the *first* `connect()` await. `0` waits indefinitely. */
114
+ connectTimeout?: number;
115
+ /** Per-send deadline covering queue + flight + ack. Default 30_000. */
116
+ sendTimeout?: number;
117
+ /** Frames buffered while disconnected. `0` disables buffering. Default 100. */
118
+ outboxMaxSize?: number;
119
+ /** Called with frames evicted from a full outbox. */
120
+ onOutboxDrop?: (frames: ClientFrame[]) => void;
121
+ /** Custom wire encoder. Must match the server's. */
122
+ encode?: WSEncoder;
123
+ /** Custom wire decoder. Must match the server's. */
124
+ decode?: WSDecoder;
125
+ }
126
+ /**
127
+ * A reconnecting WebSocket client with namespaces, rooms and presence.
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * const ws = createWSClient({ url: "/ws", namespace: "org-123" });
132
+ * const unsub = await ws.subscribe("chat", (msg) => console.log(msg.payload));
133
+ * await ws.publish("chat", { text: "hi" });
134
+ * ```
135
+ */
136
+ export declare class WSClient<TAuth = unknown> {
137
+ #private;
138
+ /** Logger. Assignable — set to `null` to silence. */
139
+ logger: Logger | null;
140
+ /**
141
+ * Nothing connects here — the socket opens on the first `connect()`,
142
+ * `subscribe()` or `publish()`.
143
+ *
144
+ * @param options - see {@link WSClientOptions}; every field has a default
145
+ */
146
+ constructor(options?: WSClientOptions<TAuth>);
147
+ /**
148
+ * Normalizes an endpoint: relative paths resolve against `location`, and
149
+ * `http(s)` is upgraded to `ws(s)`.
150
+ *
151
+ * @param input - absolute url, or a path when running in a browser
152
+ * @returns the normalized `ws(s)://` url
153
+ * @throws {WSError} when the input cannot be resolved — outside a browser
154
+ * there is no `location` to resolve a relative path against
155
+ */
156
+ static resolveUrl(input: string | URL): URL;
157
+ /** `true` only when authenticated and usable — not merely socket-open. */
158
+ get connected(): boolean;
159
+ /** Current lifecycle state. See {@link WSConnectionState}. */
160
+ get connectionState(): WSConnectionState;
161
+ /** Server-assigned id, available once connected. */
162
+ get clientId(): string | null;
163
+ /** Active namespace — the server's assignment wins over the request. */
164
+ get namespace(): string;
165
+ /** Resolved endpoint. A copy — mutating it does not affect the client. */
166
+ get url(): URL;
167
+ /**
168
+ * The underlying socket, or `null` while disconnected.
169
+ *
170
+ * Escape hatch for inspection. Sending on it directly bypasses the outbox
171
+ * and the ack correlation, so don't.
172
+ */
173
+ get socket(): WebSocket | null;
174
+ /** Rooms currently subscribed. */
175
+ get rooms(): string[];
176
+ /**
177
+ * Reactive state, Svelte-store compatible: the callback fires immediately
178
+ * with the current value and again on every change.
179
+ */
180
+ get state(): {
181
+ subscribe: (cb: Subscriber<WSState>) => Unsubscriber;
182
+ };
183
+ /**
184
+ * Debug snapshot: url, state, identity, rooms and outbox counters.
185
+ *
186
+ * For logging and troubleshooting — the shape is not part of the stable API.
187
+ */
188
+ dump(): Record<string, unknown>;
189
+ /**
190
+ * Subscribes to a lifecycle event. See {@link WSEvents}.
191
+ *
192
+ * @param event - event name
193
+ * @param cb - handler; a throw here is caught and reported as `error`
194
+ * @returns detaches the handler; also `Symbol.dispose`-compatible
195
+ */
196
+ on<K extends keyof WSEvents>(event: K, cb: (data: WSEvents[K]) => void): Unsubscriber;
197
+ /**
198
+ * Like {@link on}, but detaches after the first emission.
199
+ *
200
+ * @param event - event name
201
+ * @param cb - handler
202
+ * @returns detaches the handler early, if it has not fired yet
203
+ */
204
+ once<K extends keyof WSEvents>(event: K, cb: (data: WSEvents[K]) => void): Unsubscriber;
205
+ /**
206
+ * Starts the connection and resolves once it is established.
207
+ *
208
+ * Idempotent: concurrent calls share one promise, and it resolves
209
+ * immediately when already connected.
210
+ *
211
+ * Rejects **only** where retrying cannot help:
212
+ * - {@link WSTerminatedError} — terminal close code (bad credentials, etc.)
213
+ * - {@link WSConnectTimeoutError} — `connectTimeout` elapsed; note the
214
+ * client keeps retrying in the background, so this bounds *your await*,
215
+ * not the connection attempt
216
+ *
217
+ * Ordinary network failure never rejects; that is what the infinite retry
218
+ * is for.
219
+ *
220
+ * Calling this is optional when `autoConnect` is on — it is a readiness
221
+ * gate, not a prerequisite.
222
+ *
223
+ * @returns resolves once authenticated
224
+ */
225
+ connect(): Promise<void>;
226
+ /**
227
+ * Stops retrying and closes the socket.
228
+ *
229
+ * Resumable: handlers, room subscriptions and buffered sends all survive,
230
+ * so a later `connect()` picks up exactly where this left off. Use
231
+ * {@link dispose} for terminal teardown.
232
+ */
233
+ disconnect(): void;
234
+ /**
235
+ * Terminal teardown: disconnects, then drops every handler, room, timer and
236
+ * pending promise. The instance is unusable afterwards.
237
+ */
238
+ dispose(): void;
239
+ /**
240
+ * Subscribes to a room and attaches a handler.
241
+ *
242
+ * The handler is attached **synchronously**, before any frame goes out, so
243
+ * nothing arriving between the request and its acknowledgement is lost.
244
+ *
245
+ * Rooms are refcounted: N handlers produce one wire subscription, and the
246
+ * returned unsubscriber detaches this handler — sending `unsub` only when
247
+ * it was the last one.
248
+ *
249
+ * Resolution: when connected, this awaits the server's acknowledgement, so
250
+ * a rejected subscription surfaces as a rejection here. When not connected
251
+ * it resolves as soon as the room is registered — the subscription is then
252
+ * guaranteed to be established by the re-subscribe step on the next
253
+ * connect, and a failure there surfaces as an `error` event.
254
+ *
255
+ * @param room - room name, scoped to this client's namespace
256
+ * @param handler - receives every message published to the room
257
+ * @param options - pass `presence` to enable membership tracking
258
+ * @returns detaches this handler; also `Symbol.dispose`-compatible, and
259
+ * idempotent, so calling it twice is harmless
260
+ * @throws {WSRemoteError} when connected and the server refuses
261
+ * @throws {WSDisposedError} when the client was disposed
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * const unsub = await ws.subscribe("chat", (msg) => render(msg.payload), {
266
+ * presence: (e) => setMembers(e.members),
267
+ * });
268
+ * ```
269
+ */
270
+ subscribe<T = unknown>(room: string, handler: MessageHandler<T>, options?: SubscribeOptions): Promise<Unsubscriber>;
271
+ /**
272
+ * Removes every handler for a room and unsubscribes it.
273
+ *
274
+ * The blunt counterpart to the refcounted unsubscriber returned by
275
+ * {@link subscribe} — this drops other call sites' handlers too.
276
+ *
277
+ * @param room - room name; unknown rooms are a no-op
278
+ */
279
+ unsubscribe(room: string): Promise<void>;
280
+ /**
281
+ * Whether the room is held locally.
282
+ *
283
+ * Reflects local intent, not server state: a room registered while offline
284
+ * reads `true` before the wire subscription exists.
285
+ *
286
+ * @param room - room name
287
+ */
288
+ isSubscribed(room: string): boolean;
289
+ /**
290
+ * Last known membership of a presence-enabled room.
291
+ *
292
+ * @param room - room name
293
+ * @returns a copy of the members; empty when the room has no presence
294
+ */
295
+ members(room: string): string[];
296
+ /**
297
+ * Publishes to a room within this client's namespace.
298
+ *
299
+ * Resolves with the recipient count once the server acknowledges. While
300
+ * disconnected the frame is buffered and the promise stays pending until it
301
+ * flushes — bounded by `sendTimeout`, never indefinitely.
302
+ *
303
+ * @param room - target room
304
+ * @param payload - opaque application data; never inspected or mutated
305
+ * @param namespace - must equal this client's namespace; the server rejects
306
+ * anything else, so this is only useful for asserting the expected one
307
+ * @returns the recipient count reported by the receiving server instance —
308
+ * best-effort telemetry, not a delivery guarantee
309
+ * @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
310
+ * @throws {WSOutboxDropError} evicted from a full outbox
311
+ * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
312
+ * @throws {WSRemoteError} the server rejected it with a `nack`
313
+ */
314
+ publish<T = unknown>(room: string, payload: T, namespace?: string): Promise<WSPublishResult>;
315
+ /**
316
+ * Publishes to a room across **all** namespaces.
317
+ *
318
+ * This crosses the isolation boundary, which is why it is its own method
319
+ * rather than a flag on {@link publish} — the server gates it separately
320
+ * via `allowBroadcast`, and it denies by default.
321
+ *
322
+ * @param room - target room, in every namespace at once
323
+ * @param payload - opaque application data
324
+ * @returns the recipient count across all namespaces on the receiving
325
+ * server instance
326
+ * @throws {WSRemoteError} with code `forbidden` when `allowBroadcast` denies
327
+ */
328
+ broadcast<T = unknown>(room: string, payload: T): Promise<WSPublishResult>;
329
+ }
330
+ /**
331
+ * Creates a {@link WSClient}.
332
+ *
333
+ * Both this and the class are exported, following the `PubSub` /
334
+ * `createPubSub` precedent in `@marianmeres/pubsub`.
335
+ *
336
+ * @param options - see {@link WSClientOptions}
337
+ * @returns a client that has not connected yet
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * const ws = createWSClient({
342
+ * url: "wss://example.com/ws",
343
+ * namespace: "org-123",
344
+ * auth: () => session.token, // re-read on every reconnect
345
+ * });
346
+ *
347
+ * await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
348
+ * await ws.publish("chat", { text: "hello" });
349
+ * ```
350
+ */
351
+ export declare function createWSClient<TAuth = unknown>(options?: WSClientOptions<TAuth>): WSClient<TAuth>;