@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,892 @@
1
+ /**
2
+ * The WebSocket client.
3
+ *
4
+ * @module
5
+ */
6
+ import { createClog } from "@marianmeres/clog";
7
+ import { createPubSub } from "@marianmeres/pubsub";
8
+ import { base36 } from "@marianmeres/uid";
9
+ import { CLOSE, DEFAULT_NAMESPACE, DEFAULT_TERMINAL_CLOSE_CODES, FRAME, PROTOCOL_VERSION, } from "../protocol/constants.js";
10
+ import { WSConnectTimeoutError, WSDisposedError, WSError, WSNotConnectedError, WSRemoteError, WSTerminatedError, } from "../protocol/errors.js";
11
+ import { backoffDelay } from "./backoff.js";
12
+ import { Heartbeat } from "./heartbeat.js";
13
+ import { Outbox } from "./outbox.js";
14
+ import { RoomRegistry } from "./rooms.js";
15
+ /**
16
+ * Legal transitions.
17
+ *
18
+ * This exists because the reconnect races are where ad-hoc boolean flags rot:
19
+ * a close arriving mid-`connecting`, a `disconnect()` during backoff, a
20
+ * terminal close while the outbox is flushing. An illegal transition is a bug
21
+ * in this file, so it logs loudly rather than failing quietly.
22
+ */
23
+ const TRANSITIONS = {
24
+ idle: ["connecting", "disposed"],
25
+ connecting: ["authenticating", "reconnecting", "terminated", "idle", "disposed"],
26
+ authenticating: ["open", "reconnecting", "terminated", "idle", "disposed"],
27
+ open: ["reconnecting", "terminated", "idle", "disposed"],
28
+ reconnecting: ["connecting", "terminated", "idle", "disposed"],
29
+ // A terminal state is still re-entrant via an explicit connect() — the user
30
+ // may have refreshed the credentials that got them rejected.
31
+ terminated: ["connecting", "idle", "disposed"],
32
+ disposed: [],
33
+ };
34
+ const DEFAULTS = {
35
+ url: "/ws",
36
+ namespace: DEFAULT_NAMESPACE,
37
+ autoConnect: true,
38
+ reconnectDelay: 500,
39
+ reconnectDelayMax: 30_000,
40
+ pingInterval: 25_000,
41
+ pongTimeout: 10_000,
42
+ connectTimeout: 0,
43
+ sendTimeout: 30_000,
44
+ outboxMaxSize: 100,
45
+ };
46
+ const defaultEncode = (frame) => JSON.stringify(frame);
47
+ const defaultDecode = (raw) => JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
48
+ const noop = () => { };
49
+ /** Idempotent, `using`-compatible unsubscriber. */
50
+ function makeUnsubscriber(fn) {
51
+ let done = false;
52
+ const u = (() => {
53
+ if (done)
54
+ return;
55
+ done = true;
56
+ fn();
57
+ });
58
+ u[Symbol.dispose] = u;
59
+ return u;
60
+ }
61
+ /**
62
+ * A reconnecting WebSocket client with namespaces, rooms and presence.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const ws = createWSClient({ url: "/ws", namespace: "org-123" });
67
+ * const unsub = await ws.subscribe("chat", (msg) => console.log(msg.payload));
68
+ * await ws.publish("chat", { text: "hi" });
69
+ * ```
70
+ */
71
+ export class WSClient {
72
+ #url;
73
+ #requestedNamespace;
74
+ #requestedClientId;
75
+ #authFn;
76
+ #autoConnect;
77
+ #terminalCodes;
78
+ #reconnectDelay;
79
+ #reconnectDelayMax;
80
+ #connectTimeout;
81
+ #pongTimeout;
82
+ #outboxMaxSize;
83
+ #encode;
84
+ #decode;
85
+ /** Logger. Assignable — set to `null` to silence. */
86
+ logger;
87
+ #socket = null;
88
+ /**
89
+ * Guards against events from superseded sockets. Every callback checks its
90
+ * captured generation, so a slow `onclose` from an old socket cannot
91
+ * cancel the reconnect that replaced it.
92
+ */
93
+ #generation = 0;
94
+ #state = "idle";
95
+ #clientId = null;
96
+ #namespace = null;
97
+ #attempt = 0;
98
+ #lastError = null;
99
+ #intentional = false;
100
+ #rooms = new RoomRegistry();
101
+ #outbox;
102
+ #heartbeat;
103
+ #bus = createPubSub();
104
+ #stateBus = createPubSub();
105
+ #reconnectTimer;
106
+ #handshakeTimer;
107
+ #connectTimer;
108
+ #connectDeferred = null;
109
+ #wakeListeners = [];
110
+ /**
111
+ * Nothing connects here — the socket opens on the first `connect()`,
112
+ * `subscribe()` or `publish()`.
113
+ *
114
+ * @param options - see {@link WSClientOptions}; every field has a default
115
+ */
116
+ constructor(options = {}) {
117
+ this.logger = options.logger === undefined ? createClog("ws") : options.logger;
118
+ this.#url = WSClient.resolveUrl(options.url ?? DEFAULTS.url);
119
+ this.#requestedNamespace = options.namespace ?? DEFAULTS.namespace;
120
+ this.#requestedClientId = options.clientId;
121
+ this.#authFn = options.auth;
122
+ this.#autoConnect = options.autoConnect ?? DEFAULTS.autoConnect;
123
+ this.#terminalCodes = options.terminalCloseCodes ??
124
+ DEFAULT_TERMINAL_CLOSE_CODES;
125
+ this.#reconnectDelay = options.reconnectDelay ?? DEFAULTS.reconnectDelay;
126
+ this.#reconnectDelayMax = options.reconnectDelayMax ??
127
+ DEFAULTS.reconnectDelayMax;
128
+ this.#connectTimeout = options.connectTimeout ?? DEFAULTS.connectTimeout;
129
+ this.#pongTimeout = options.pongTimeout ?? DEFAULTS.pongTimeout;
130
+ this.#outboxMaxSize = options.outboxMaxSize ?? DEFAULTS.outboxMaxSize;
131
+ this.#encode = options.encode ?? defaultEncode;
132
+ this.#decode = options.decode ?? defaultDecode;
133
+ this.#outbox = new Outbox({
134
+ maxSize: this.#outboxMaxSize,
135
+ sendTimeout: options.sendTimeout ?? DEFAULTS.sendTimeout,
136
+ onDrop: options.onOutboxDrop,
137
+ });
138
+ this.#heartbeat = new Heartbeat({
139
+ interval: options.pingInterval ?? DEFAULTS.pingInterval,
140
+ timeout: options.pongTimeout ?? DEFAULTS.pongTimeout,
141
+ onPing: () => this.#sendRaw({ type: FRAME.PING }),
142
+ onTimeout: () => {
143
+ this.logger?.warn?.("pong timeout — connection is half-open");
144
+ this.#forceClose(CLOSE.IDLE_TIMEOUT, "pong timeout");
145
+ },
146
+ });
147
+ // Rooms configured up front get a no-op handler; the `message` firehose
148
+ // still fires, so `rooms: [...]` + `on("message")` is a valid style.
149
+ for (const room of options.rooms ?? [])
150
+ this.#rooms.add(room, noop);
151
+ this.#installWakeListeners();
152
+ }
153
+ /**
154
+ * Normalizes an endpoint: relative paths resolve against `location`, and
155
+ * `http(s)` is upgraded to `ws(s)`.
156
+ *
157
+ * @param input - absolute url, or a path when running in a browser
158
+ * @returns the normalized `ws(s)://` url
159
+ * @throws {WSError} when the input cannot be resolved — outside a browser
160
+ * there is no `location` to resolve a relative path against
161
+ */
162
+ static resolveUrl(input) {
163
+ const base = typeof globalThis.location !== "undefined"
164
+ ? globalThis.location.href
165
+ : undefined;
166
+ let url;
167
+ try {
168
+ url = new URL(String(input), base);
169
+ }
170
+ catch {
171
+ throw new WSError(`Invalid url "${input}". Outside a browser an absolute ws:// or ` +
172
+ `wss:// url is required.`);
173
+ }
174
+ if (url.protocol === "http:")
175
+ url.protocol = "ws:";
176
+ else if (url.protocol === "https:")
177
+ url.protocol = "wss:";
178
+ return url;
179
+ }
180
+ // ---------------------------------------------------------------- getters
181
+ /** `true` only when authenticated and usable — not merely socket-open. */
182
+ get connected() {
183
+ return this.#state === "open";
184
+ }
185
+ /** Current lifecycle state. See {@link WSConnectionState}. */
186
+ get connectionState() {
187
+ return this.#state;
188
+ }
189
+ /** Server-assigned id, available once connected. */
190
+ get clientId() {
191
+ return this.#clientId;
192
+ }
193
+ /** Active namespace — the server's assignment wins over the request. */
194
+ get namespace() {
195
+ return this.#namespace ?? this.#requestedNamespace;
196
+ }
197
+ /** Resolved endpoint. A copy — mutating it does not affect the client. */
198
+ get url() {
199
+ return new URL(this.#url.href);
200
+ }
201
+ /**
202
+ * The underlying socket, or `null` while disconnected.
203
+ *
204
+ * Escape hatch for inspection. Sending on it directly bypasses the outbox
205
+ * and the ack correlation, so don't.
206
+ */
207
+ get socket() {
208
+ return this.#socket;
209
+ }
210
+ /** Rooms currently subscribed. */
211
+ get rooms() {
212
+ return this.#rooms.rooms;
213
+ }
214
+ /**
215
+ * Reactive state, Svelte-store compatible: the callback fires immediately
216
+ * with the current value and again on every change.
217
+ */
218
+ get state() {
219
+ return {
220
+ subscribe: (cb) => {
221
+ cb(this.#snapshot());
222
+ return this.#stateBus.subscribe("state", cb);
223
+ },
224
+ };
225
+ }
226
+ /**
227
+ * Debug snapshot: url, state, identity, rooms and outbox counters.
228
+ *
229
+ * For logging and troubleshooting — the shape is not part of the stable API.
230
+ */
231
+ dump() {
232
+ return {
233
+ url: this.#url.href,
234
+ state: this.#state,
235
+ clientId: this.#clientId,
236
+ namespace: this.namespace,
237
+ rooms: this.#rooms.rooms,
238
+ attempt: this.#attempt,
239
+ pending: this.#outbox.pendingCount,
240
+ queued: this.#outbox.queuedCount,
241
+ dropped: this.#outbox.droppedCount,
242
+ };
243
+ }
244
+ // ----------------------------------------------------------------- events
245
+ /**
246
+ * Subscribes to a lifecycle event. See {@link WSEvents}.
247
+ *
248
+ * @param event - event name
249
+ * @param cb - handler; a throw here is caught and reported as `error`
250
+ * @returns detaches the handler; also `Symbol.dispose`-compatible
251
+ */
252
+ on(event, cb) {
253
+ return this.#bus.subscribe(event, cb);
254
+ }
255
+ /**
256
+ * Like {@link on}, but detaches after the first emission.
257
+ *
258
+ * @param event - event name
259
+ * @param cb - handler
260
+ * @returns detaches the handler early, if it has not fired yet
261
+ */
262
+ once(event, cb) {
263
+ return this.#bus.subscribeOnce(event, cb);
264
+ }
265
+ // -------------------------------------------------------------- lifecycle
266
+ /**
267
+ * Starts the connection and resolves once it is established.
268
+ *
269
+ * Idempotent: concurrent calls share one promise, and it resolves
270
+ * immediately when already connected.
271
+ *
272
+ * Rejects **only** where retrying cannot help:
273
+ * - {@link WSTerminatedError} — terminal close code (bad credentials, etc.)
274
+ * - {@link WSConnectTimeoutError} — `connectTimeout` elapsed; note the
275
+ * client keeps retrying in the background, so this bounds *your await*,
276
+ * not the connection attempt
277
+ *
278
+ * Ordinary network failure never rejects; that is what the infinite retry
279
+ * is for.
280
+ *
281
+ * Calling this is optional when `autoConnect` is on — it is a readiness
282
+ * gate, not a prerequisite.
283
+ *
284
+ * @returns resolves once authenticated
285
+ */
286
+ connect() {
287
+ if (this.#state === "disposed") {
288
+ return Promise.reject(new WSDisposedError());
289
+ }
290
+ if (this.#state === "open")
291
+ return Promise.resolve();
292
+ if (this.#connectDeferred)
293
+ return this.#connectDeferred.promise;
294
+ let resolve;
295
+ let reject;
296
+ const promise = new Promise((res, rej) => {
297
+ resolve = res;
298
+ reject = rej;
299
+ });
300
+ this.#connectDeferred = { promise, resolve, reject };
301
+ if (this.#connectTimeout > 0) {
302
+ this.#connectTimer = setTimeout(() => {
303
+ this.#settleConnect(new WSConnectTimeoutError(this.#connectTimeout));
304
+ }, this.#connectTimeout);
305
+ }
306
+ this.#intentional = false;
307
+ if (this.#state === "idle" || this.#state === "terminated")
308
+ this.#open();
309
+ return promise;
310
+ }
311
+ /**
312
+ * Stops retrying and closes the socket.
313
+ *
314
+ * Resumable: handlers, room subscriptions and buffered sends all survive,
315
+ * so a later `connect()` picks up exactly where this left off. Use
316
+ * {@link dispose} for terminal teardown.
317
+ */
318
+ disconnect() {
319
+ if (this.#state === "disposed")
320
+ return;
321
+ this.logger?.debug?.("disconnect()");
322
+ this.#intentional = true;
323
+ this.#clearTimers();
324
+ this.#heartbeat.stop();
325
+ this.#settleConnect(new WSTerminatedError(CLOSE.CLIENT_GONE, "disconnect() called"));
326
+ this.#closeSocket(CLOSE.CLIENT_GONE, "client disconnect");
327
+ this.#setState("idle");
328
+ }
329
+ /**
330
+ * Terminal teardown: disconnects, then drops every handler, room, timer and
331
+ * pending promise. The instance is unusable afterwards.
332
+ */
333
+ dispose() {
334
+ if (this.#state === "disposed")
335
+ return;
336
+ this.logger?.debug?.("dispose()");
337
+ this.disconnect();
338
+ this.#outbox.failAll(new WSDisposedError());
339
+ this.#rooms.clear();
340
+ this.#bus.unsubscribeAll();
341
+ this.#stateBus.unsubscribeAll();
342
+ this.#removeWakeListeners();
343
+ this.#setState("disposed");
344
+ }
345
+ // ---------------------------------------------------------- subscriptions
346
+ /**
347
+ * Subscribes to a room and attaches a handler.
348
+ *
349
+ * The handler is attached **synchronously**, before any frame goes out, so
350
+ * nothing arriving between the request and its acknowledgement is lost.
351
+ *
352
+ * Rooms are refcounted: N handlers produce one wire subscription, and the
353
+ * returned unsubscriber detaches this handler — sending `unsub` only when
354
+ * it was the last one.
355
+ *
356
+ * Resolution: when connected, this awaits the server's acknowledgement, so
357
+ * a rejected subscription surfaces as a rejection here. When not connected
358
+ * it resolves as soon as the room is registered — the subscription is then
359
+ * guaranteed to be established by the re-subscribe step on the next
360
+ * connect, and a failure there surfaces as an `error` event.
361
+ *
362
+ * @param room - room name, scoped to this client's namespace
363
+ * @param handler - receives every message published to the room
364
+ * @param options - pass `presence` to enable membership tracking
365
+ * @returns detaches this handler; also `Symbol.dispose`-compatible, and
366
+ * idempotent, so calling it twice is harmless
367
+ * @throws {WSRemoteError} when connected and the server refuses
368
+ * @throws {WSDisposedError} when the client was disposed
369
+ *
370
+ * @example
371
+ * ```ts
372
+ * const unsub = await ws.subscribe("chat", (msg) => render(msg.payload), {
373
+ * presence: (e) => setMembers(e.members),
374
+ * });
375
+ * ```
376
+ */
377
+ async subscribe(room, handler, options) {
378
+ this.#assertUsable();
379
+ const messageHandler = handler;
380
+ const presenceHandler = options?.presence;
381
+ const { created, presenceUpgraded } = this.#rooms.add(room, messageHandler, presenceHandler);
382
+ const unsubscriber = makeUnsubscriber(() => {
383
+ const emptied = this.#rooms.remove(room, messageHandler, presenceHandler);
384
+ if (emptied && this.connected) {
385
+ this.#sendControl({
386
+ type: FRAME.UNSUB,
387
+ id: this.#nextId(),
388
+ rooms: [room],
389
+ });
390
+ }
391
+ });
392
+ if (this.#autoConnect)
393
+ this.#ensureStarted();
394
+ if ((created || presenceUpgraded) && this.connected) {
395
+ try {
396
+ await this.#sendControl({
397
+ type: FRAME.SUB,
398
+ id: this.#nextId(),
399
+ rooms: [{ room, presence: this.#rooms.wantsPresence(room) }],
400
+ });
401
+ }
402
+ catch (e) {
403
+ // Keep local state honest: if the server refused, we are not
404
+ // subscribed, and pretending otherwise would silently swallow
405
+ // every message the caller expects.
406
+ this.#rooms.remove(room, messageHandler, presenceHandler);
407
+ throw e;
408
+ }
409
+ }
410
+ return unsubscriber;
411
+ }
412
+ /**
413
+ * Removes every handler for a room and unsubscribes it.
414
+ *
415
+ * The blunt counterpart to the refcounted unsubscriber returned by
416
+ * {@link subscribe} — this drops other call sites' handlers too.
417
+ *
418
+ * @param room - room name; unknown rooms are a no-op
419
+ */
420
+ async unsubscribe(room) {
421
+ this.#assertUsable();
422
+ if (!this.#rooms.removeRoom(room))
423
+ return;
424
+ if (this.connected) {
425
+ await this.#sendControl({
426
+ type: FRAME.UNSUB,
427
+ id: this.#nextId(),
428
+ rooms: [room],
429
+ });
430
+ }
431
+ }
432
+ /**
433
+ * Whether the room is held locally.
434
+ *
435
+ * Reflects local intent, not server state: a room registered while offline
436
+ * reads `true` before the wire subscription exists.
437
+ *
438
+ * @param room - room name
439
+ */
440
+ isSubscribed(room) {
441
+ return this.#rooms.has(room);
442
+ }
443
+ /**
444
+ * Last known membership of a presence-enabled room.
445
+ *
446
+ * @param room - room name
447
+ * @returns a copy of the members; empty when the room has no presence
448
+ */
449
+ members(room) {
450
+ return this.#rooms.members(room);
451
+ }
452
+ // --------------------------------------------------------------- sending
453
+ /**
454
+ * Publishes to a room within this client's namespace.
455
+ *
456
+ * Resolves with the recipient count once the server acknowledges. While
457
+ * disconnected the frame is buffered and the promise stays pending until it
458
+ * flushes — bounded by `sendTimeout`, never indefinitely.
459
+ *
460
+ * @param room - target room
461
+ * @param payload - opaque application data; never inspected or mutated
462
+ * @param namespace - must equal this client's namespace; the server rejects
463
+ * anything else, so this is only useful for asserting the expected one
464
+ * @returns the recipient count reported by the receiving server instance —
465
+ * best-effort telemetry, not a delivery guarantee
466
+ * @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
467
+ * @throws {WSOutboxDropError} evicted from a full outbox
468
+ * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
469
+ * @throws {WSRemoteError} the server rejected it with a `nack`
470
+ */
471
+ publish(room, payload, namespace) {
472
+ this.#assertUsable();
473
+ const id = this.#nextId();
474
+ return this.#send({
475
+ type: FRAME.PUB,
476
+ id,
477
+ room,
478
+ payload,
479
+ ...(namespace ? { namespace } : {}),
480
+ }, id);
481
+ }
482
+ /**
483
+ * Publishes to a room across **all** namespaces.
484
+ *
485
+ * This crosses the isolation boundary, which is why it is its own method
486
+ * rather than a flag on {@link publish} — the server gates it separately
487
+ * via `allowBroadcast`, and it denies by default.
488
+ *
489
+ * @param room - target room, in every namespace at once
490
+ * @param payload - opaque application data
491
+ * @returns the recipient count across all namespaces on the receiving
492
+ * server instance
493
+ * @throws {WSRemoteError} with code `forbidden` when `allowBroadcast` denies
494
+ */
495
+ broadcast(room, payload) {
496
+ this.#assertUsable();
497
+ const id = this.#nextId();
498
+ return this.#send({ type: FRAME.BROADCAST, id, room, payload }, id);
499
+ }
500
+ // -------------------------------------------------------------- internals
501
+ #nextId() {
502
+ return base36(12);
503
+ }
504
+ #assertUsable() {
505
+ if (this.#state === "disposed")
506
+ throw new WSDisposedError();
507
+ }
508
+ #ensureStarted() {
509
+ if (this.#state === "idle")
510
+ this.#open();
511
+ }
512
+ #send(frame, id) {
513
+ if (this.#autoConnect)
514
+ this.#ensureStarted();
515
+ const canSendNow = this.connected &&
516
+ this.#socket?.readyState === WebSocket.OPEN;
517
+ if (!canSendNow && this.#outboxMaxSize === 0) {
518
+ return Promise.reject(new WSNotConnectedError());
519
+ }
520
+ const promise = this.#outbox.track(id, frame, !canSendNow);
521
+ if (canSendNow)
522
+ this.#sendRaw(frame);
523
+ return promise;
524
+ }
525
+ /**
526
+ * Sends a control frame that must never be buffered.
527
+ *
528
+ * `sub`/`unsub` bypass the outbox deliberately: they are replayed wholesale
529
+ * by the re-subscribe step on reconnect, so queueing them too would apply
530
+ * them twice.
531
+ */
532
+ #sendControl(frame) {
533
+ const id = "id" in frame ? frame.id : this.#nextId();
534
+ const promise = this.#outbox.track(id, frame, false);
535
+ this.#sendRaw(frame);
536
+ return promise;
537
+ }
538
+ #sendRaw(frame) {
539
+ const socket = this.#socket;
540
+ if (!socket || socket.readyState !== WebSocket.OPEN)
541
+ return;
542
+ try {
543
+ socket.send(this.#encode(frame));
544
+ }
545
+ catch (e) {
546
+ this.#fail(e, "send failed");
547
+ }
548
+ }
549
+ #open() {
550
+ // Defensive: every caller already guards this, but a second socket
551
+ // opened from a race here would leak the first one silently.
552
+ if (this.#state === "connecting" ||
553
+ this.#state === "authenticating" ||
554
+ this.#state === "open") {
555
+ return;
556
+ }
557
+ if (!this.#setState("connecting"))
558
+ return;
559
+ this.#intentional = false;
560
+ const generation = ++this.#generation;
561
+ let socket;
562
+ try {
563
+ socket = new WebSocket(this.#url);
564
+ }
565
+ catch (e) {
566
+ this.#fail(e, "socket construction failed");
567
+ this.#scheduleReconnect();
568
+ return;
569
+ }
570
+ this.#socket = socket;
571
+ this.logger?.debug?.(`connecting to ${this.#url.href}`);
572
+ socket.onopen = () => {
573
+ if (generation !== this.#generation)
574
+ return;
575
+ void this.#onOpen(generation);
576
+ };
577
+ socket.onmessage = (event) => {
578
+ if (generation !== this.#generation)
579
+ return;
580
+ this.#onMessage(event.data);
581
+ };
582
+ socket.onerror = () => {
583
+ if (generation !== this.#generation)
584
+ return;
585
+ // The browser deliberately withholds detail here; onclose carries
586
+ // the actionable information, so this is informational only.
587
+ this.logger?.debug?.("socket error");
588
+ };
589
+ socket.onclose = (event) => {
590
+ if (generation !== this.#generation)
591
+ return;
592
+ this.#onClose(event.code, event.reason);
593
+ };
594
+ }
595
+ async #onOpen(generation) {
596
+ this.#emit("open", undefined);
597
+ if (!this.#setState("authenticating"))
598
+ return;
599
+ let payload = null;
600
+ try {
601
+ payload = (await this.#authFn?.()) ?? null;
602
+ }
603
+ catch (e) {
604
+ this.#fail(e, "auth payload failed");
605
+ this.#forceClose(CLOSE.PROTOCOL_ERROR, "auth payload failed");
606
+ return;
607
+ }
608
+ // The await above yields; a close may have superseded this socket.
609
+ if (generation !== this.#generation)
610
+ return;
611
+ this.#sendRaw({
612
+ type: FRAME.AUTH,
613
+ id: this.#nextId(),
614
+ protocol: PROTOCOL_VERSION,
615
+ payload,
616
+ ...(this.#requestedClientId ? { clientId: this.#requestedClientId } : {}),
617
+ namespace: this.#requestedNamespace,
618
+ });
619
+ // Without this, a server that accepts the socket then never replies
620
+ // leaves us in `authenticating` forever — the heartbeat has not started
621
+ // yet, so nothing else would notice.
622
+ this.#handshakeTimer = setTimeout(() => {
623
+ this.logger?.warn?.("handshake timeout");
624
+ this.#forceClose(CLOSE.AUTH_TIMEOUT, "handshake timeout");
625
+ }, this.#pongTimeout);
626
+ }
627
+ #onMessage(raw) {
628
+ // Any inbound traffic proves the socket is alive, not just a pong.
629
+ this.#heartbeat.alive();
630
+ let frame;
631
+ try {
632
+ frame = this.#decode(raw);
633
+ }
634
+ catch (e) {
635
+ this.#fail(e, "decode failed");
636
+ return;
637
+ }
638
+ if (!frame || typeof frame.type !== "string") {
639
+ this.logger?.warn?.("ignoring frame without a type", frame);
640
+ return;
641
+ }
642
+ switch (frame.type) {
643
+ case FRAME.HELLO:
644
+ this.#onHello(frame.clientId, frame.namespace, frame.protocol);
645
+ break;
646
+ case FRAME.ACK:
647
+ this.#outbox.settle(frame.id, frame.recipients ?? 0);
648
+ break;
649
+ case FRAME.NACK:
650
+ this.#outbox.fail(frame.id, new WSRemoteError(frame.error));
651
+ break;
652
+ case FRAME.MSG: {
653
+ const { type: _t, ...message } = frame;
654
+ this.#emit("message", message);
655
+ this.#rooms.deliver(message.room, message, (e) => this.#fail(e, "message handler threw"));
656
+ break;
657
+ }
658
+ case FRAME.PRESENCE: {
659
+ const { type: _t, ...event } = frame;
660
+ this.#emit("presence", event);
661
+ this.#rooms.deliverPresence(event.room, event, (e) => this.#fail(e, "presence handler threw"));
662
+ break;
663
+ }
664
+ case FRAME.PONG:
665
+ break;
666
+ case FRAME.ERROR:
667
+ this.#fail(new WSRemoteError(frame.error), "server error");
668
+ break;
669
+ default:
670
+ this.logger?.warn?.("ignoring unknown frame type", frame);
671
+ }
672
+ }
673
+ #onHello(clientId, namespace, protocol) {
674
+ clearTimeout(this.#handshakeTimer);
675
+ this.#handshakeTimer = undefined;
676
+ if (protocol !== PROTOCOL_VERSION) {
677
+ this.logger?.warn?.(`protocol version mismatch (client ${PROTOCOL_VERSION}, server ${protocol})`);
678
+ }
679
+ this.#clientId = clientId;
680
+ this.#namespace = namespace;
681
+ this.#attempt = 0;
682
+ this.#lastError = null;
683
+ if (!this.#setState("open"))
684
+ return;
685
+ this.logger?.debug?.(`connected as ${clientId} in "${namespace}"`);
686
+ this.#emit("connected", { clientId, namespace });
687
+ this.#heartbeat.start();
688
+ this.#settleConnect(null);
689
+ // Order matters and is not cosmetic: re-subscribe first, then flush.
690
+ // The socket preserves ordering, so the server registers the rooms
691
+ // before it sees any buffered publish destined for them.
692
+ const requests = this.#rooms.subRequests();
693
+ if (requests.length) {
694
+ this.#sendControl({
695
+ type: FRAME.SUB,
696
+ id: this.#nextId(),
697
+ rooms: requests,
698
+ }).catch((e) => this.#fail(e, "re-subscribe failed"));
699
+ }
700
+ const buffered = this.#outbox.drain();
701
+ if (buffered.length) {
702
+ this.logger?.debug?.(`flushing ${buffered.length} buffered frame(s)`);
703
+ for (const frame of buffered)
704
+ this.#sendRaw(frame);
705
+ }
706
+ }
707
+ #onClose(code, reason) {
708
+ clearTimeout(this.#handshakeTimer);
709
+ this.#handshakeTimer = undefined;
710
+ this.#heartbeat.stop();
711
+ this.#socket = null;
712
+ const terminal = this.#terminalCodes.includes(code);
713
+ const willReconnect = !this.#intentional && !terminal &&
714
+ this.#state !== "disposed";
715
+ this.logger?.debug?.(`closed (${code}${reason ? ` ${reason}` : ""}), reconnect=${willReconnect}`);
716
+ this.#emit("close", { code, reason, willReconnect });
717
+ if (terminal) {
718
+ this.#setState("terminated");
719
+ const error = new WSTerminatedError(code, reason);
720
+ this.#lastError = error;
721
+ // Loud on purpose: this is the only path where a client that
722
+ // otherwise retries forever gives up, and a silent one looks
723
+ // exactly like a network that never came back.
724
+ this.logger?.error?.(error.message);
725
+ this.#outbox.failAll(error);
726
+ this.#settleConnect(error);
727
+ this.#emit("terminated", { code, reason });
728
+ return;
729
+ }
730
+ if (!willReconnect) {
731
+ this.#setState("idle");
732
+ return;
733
+ }
734
+ this.#scheduleReconnect();
735
+ }
736
+ #scheduleReconnect() {
737
+ if (!this.#setState("reconnecting"))
738
+ return;
739
+ this.#attempt++;
740
+ const delay = backoffDelay(this.#attempt, this.#reconnectDelay, this.#reconnectDelayMax);
741
+ this.logger?.debug?.(`reconnecting in ${delay}ms (attempt ${this.#attempt})`);
742
+ this.#emit("reconnecting", { attempt: this.#attempt, delay });
743
+ this.#publishState();
744
+ clearTimeout(this.#reconnectTimer);
745
+ this.#reconnectTimer = setTimeout(() => {
746
+ this.#reconnectTimer = undefined;
747
+ this.#open();
748
+ }, delay);
749
+ }
750
+ /**
751
+ * Reconnect right now instead of waiting out the backoff.
752
+ *
753
+ * Matters more than backoff tuning: a laptop waking from sleep should be
754
+ * back in milliseconds, not sit out a 30s timer it started before sleeping.
755
+ */
756
+ #wake(source) {
757
+ if (this.#state !== "reconnecting")
758
+ return;
759
+ this.logger?.debug?.(`${source} — retrying immediately`);
760
+ clearTimeout(this.#reconnectTimer);
761
+ this.#reconnectTimer = undefined;
762
+ this.#attempt = 0;
763
+ this.#open();
764
+ }
765
+ #installWakeListeners() {
766
+ const target = globalThis;
767
+ if (typeof target.addEventListener !== "function")
768
+ return;
769
+ const onOnline = () => this.#wake("online");
770
+ const onVisible = () => {
771
+ if (target.document?.visibilityState === "visible") {
772
+ this.#wake("tab visible");
773
+ }
774
+ };
775
+ target.addEventListener("online", onOnline);
776
+ this.#wakeListeners.push(() => target.removeEventListener?.("online", onOnline));
777
+ if (target.document) {
778
+ target.addEventListener("visibilitychange", onVisible);
779
+ this.#wakeListeners.push(() => target.removeEventListener?.("visibilitychange", onVisible));
780
+ }
781
+ }
782
+ #removeWakeListeners() {
783
+ for (const remove of this.#wakeListeners)
784
+ remove();
785
+ this.#wakeListeners = [];
786
+ }
787
+ /**
788
+ * Closes a socket we have decided is dead, and drives the close transition
789
+ * ourselves.
790
+ *
791
+ * `#closeSocket` supersedes the generation, so the real `onclose` — if it
792
+ * arrives at all, which for a half-open socket it may not — is ignored.
793
+ * That is the point: waiting for it is exactly the hang we are escaping.
794
+ */
795
+ #forceClose(code, reason) {
796
+ this.#closeSocket(code, reason);
797
+ this.#onClose(code, reason);
798
+ }
799
+ #closeSocket(code, reason) {
800
+ const socket = this.#socket;
801
+ this.#socket = null;
802
+ // Supersede: any late callback from this socket is now ignored.
803
+ this.#generation++;
804
+ if (!socket)
805
+ return;
806
+ try {
807
+ if (socket.readyState === WebSocket.OPEN ||
808
+ socket.readyState === WebSocket.CONNECTING) {
809
+ socket.close(code, reason);
810
+ }
811
+ }
812
+ catch {
813
+ // Closing an already-dead socket is not worth reporting.
814
+ }
815
+ }
816
+ #clearTimers() {
817
+ clearTimeout(this.#reconnectTimer);
818
+ clearTimeout(this.#handshakeTimer);
819
+ this.#reconnectTimer = undefined;
820
+ this.#handshakeTimer = undefined;
821
+ }
822
+ #settleConnect(error) {
823
+ clearTimeout(this.#connectTimer);
824
+ this.#connectTimer = undefined;
825
+ const deferred = this.#connectDeferred;
826
+ if (!deferred)
827
+ return;
828
+ this.#connectDeferred = null;
829
+ if (error)
830
+ deferred.reject(error);
831
+ else
832
+ deferred.resolve();
833
+ }
834
+ #setState(next) {
835
+ if (this.#state === next)
836
+ return true;
837
+ if (!TRANSITIONS[this.#state].includes(next)) {
838
+ this.logger?.warn?.(`ignoring illegal state transition ${this.#state} -> ${next}`);
839
+ return false;
840
+ }
841
+ this.#state = next;
842
+ this.#publishState();
843
+ return true;
844
+ }
845
+ #snapshot() {
846
+ return {
847
+ state: this.#state,
848
+ connected: this.#state === "open",
849
+ connecting: this.#state === "connecting" ||
850
+ this.#state === "authenticating" ||
851
+ this.#state === "reconnecting",
852
+ attempt: this.#attempt,
853
+ lastError: this.#lastError,
854
+ };
855
+ }
856
+ #publishState() {
857
+ this.#stateBus.publish("state", this.#snapshot());
858
+ }
859
+ #emit(event, data) {
860
+ this.#bus.publish(event, data);
861
+ }
862
+ #fail(error, context) {
863
+ const err = error instanceof Error ? error : new WSError(String(error));
864
+ this.#lastError = err;
865
+ this.logger?.error?.(`${context}: ${err.message}`);
866
+ this.#emit("error", err);
867
+ }
868
+ }
869
+ /**
870
+ * Creates a {@link WSClient}.
871
+ *
872
+ * Both this and the class are exported, following the `PubSub` /
873
+ * `createPubSub` precedent in `@marianmeres/pubsub`.
874
+ *
875
+ * @param options - see {@link WSClientOptions}
876
+ * @returns a client that has not connected yet
877
+ *
878
+ * @example
879
+ * ```ts
880
+ * const ws = createWSClient({
881
+ * url: "wss://example.com/ws",
882
+ * namespace: "org-123",
883
+ * auth: () => session.token, // re-read on every reconnect
884
+ * });
885
+ *
886
+ * await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
887
+ * await ws.publish("chat", { text: "hello" });
888
+ * ```
889
+ */
890
+ export function createWSClient(options = {}) {
891
+ return new WSClient(options);
892
+ }