@marianmeres/ws 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +91 -16
- package/API.md +360 -90
- package/README.md +192 -71
- package/dist/client/outbox.d.ts +51 -11
- package/dist/client/outbox.js +56 -18
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +142 -16
- package/dist/client/ws-client.js +170 -37
- package/dist/mod.d.ts +23 -4
- package/dist/mod.js +22 -3
- package/dist/protocol/constants.d.ts +35 -16
- package/dist/protocol/constants.js +40 -18
- package/dist/protocol/errors.d.ts +21 -2
- package/dist/protocol/errors.js +24 -3
- package/dist/protocol/frames.d.ts +68 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +2 -2
package/dist/client/ws-client.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The WebSocket client.
|
|
3
3
|
*
|
|
4
|
+
* Two ways to use it, freely mixed on one connection:
|
|
5
|
+
*
|
|
6
|
+
* - **Messages** (core): `send()` to the server, `on("message")` to receive.
|
|
7
|
+
* Needs nothing from the server beyond the core protocol.
|
|
8
|
+
* - **Rooms** (extension): `subscribe()` / `publish()` / `broadcast()` and
|
|
9
|
+
* presence, for servers that relay between clients.
|
|
10
|
+
*
|
|
4
11
|
* @module
|
|
5
12
|
*/
|
|
6
13
|
import { createClog } from "@marianmeres/clog";
|
|
7
14
|
import { createPubSub } from "@marianmeres/pubsub";
|
|
8
15
|
import { base36 } from "@marianmeres/uid";
|
|
9
16
|
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";
|
|
17
|
+
import { WSConnectionLostError, WSConnectTimeoutError, WSDisposedError, WSError, WSNotConnectedError, WSRemoteError, WSTerminatedError, } from "../protocol/errors.js";
|
|
11
18
|
import { backoffDelay } from "./backoff.js";
|
|
12
19
|
import { Heartbeat } from "./heartbeat.js";
|
|
13
20
|
import { Outbox } from "./outbox.js";
|
|
@@ -59,9 +66,18 @@ function makeUnsubscriber(fn) {
|
|
|
59
66
|
return u;
|
|
60
67
|
}
|
|
61
68
|
/**
|
|
62
|
-
* A reconnecting WebSocket client
|
|
69
|
+
* A reconnecting WebSocket client: plain messages to and from the server, and
|
|
70
|
+
* optionally namespaces, rooms and presence on top.
|
|
71
|
+
*
|
|
72
|
+
* @example Messages — the server is the peer
|
|
73
|
+
* ```ts
|
|
74
|
+
* const ws = createWSClient({ url: "/ws", auth: () => session.token });
|
|
75
|
+
* ws.on("message", (msg) => console.log(msg.payload));
|
|
76
|
+
* await ws.send({ op: "typing" }); // fire-and-forget
|
|
77
|
+
* const doc = await ws.send({ op: "load", id: 42 }, { ack: true }); // reply
|
|
78
|
+
* ```
|
|
63
79
|
*
|
|
64
|
-
* @example
|
|
80
|
+
* @example Rooms — the server relays between clients
|
|
65
81
|
* ```ts
|
|
66
82
|
* const ws = createWSClient({ url: "/ws", namespace: "org-123" });
|
|
67
83
|
* const unsub = await ws.subscribe("chat", (msg) => console.log(msg.payload));
|
|
@@ -71,6 +87,8 @@ function makeUnsubscriber(fn) {
|
|
|
71
87
|
export class WSClient {
|
|
72
88
|
#url;
|
|
73
89
|
#requestedNamespace;
|
|
90
|
+
/** Whether the application chose a namespace, i.e. whether `auth` carries it. */
|
|
91
|
+
#namespaceRequested;
|
|
74
92
|
#requestedClientId;
|
|
75
93
|
#authFn;
|
|
76
94
|
#autoConnect;
|
|
@@ -96,7 +114,12 @@ export class WSClient {
|
|
|
96
114
|
#namespace = null;
|
|
97
115
|
#attempt = 0;
|
|
98
116
|
#lastError = null;
|
|
99
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Kept apart from `#lastError` on purpose: a handler that throws after the
|
|
119
|
+
* terminal close would overwrite `#lastError` and a send rejected from the
|
|
120
|
+
* `terminated` state would then blame the wrong thing.
|
|
121
|
+
*/
|
|
122
|
+
#terminalError = null;
|
|
100
123
|
#rooms = new RoomRegistry();
|
|
101
124
|
#outbox;
|
|
102
125
|
#heartbeat;
|
|
@@ -109,7 +132,7 @@ export class WSClient {
|
|
|
109
132
|
#wakeListeners = [];
|
|
110
133
|
/**
|
|
111
134
|
* Nothing connects here — the socket opens on the first `connect()`,
|
|
112
|
-
* `subscribe()` or `publish()`.
|
|
135
|
+
* `send()`, `subscribe()` or `publish()`.
|
|
113
136
|
*
|
|
114
137
|
* @param options - see {@link WSClientOptions}; every field has a default
|
|
115
138
|
*/
|
|
@@ -117,6 +140,7 @@ export class WSClient {
|
|
|
117
140
|
this.logger = options.logger === undefined ? createClog("ws") : options.logger;
|
|
118
141
|
this.#url = WSClient.resolveUrl(options.url ?? DEFAULTS.url);
|
|
119
142
|
this.#requestedNamespace = options.namespace ?? DEFAULTS.namespace;
|
|
143
|
+
this.#namespaceRequested = options.namespace !== undefined;
|
|
120
144
|
this.#requestedClientId = options.clientId;
|
|
121
145
|
this.#authFn = options.auth;
|
|
122
146
|
this.#autoConnect = options.autoConnect ?? DEFAULTS.autoConnect;
|
|
@@ -186,11 +210,17 @@ export class WSClient {
|
|
|
186
210
|
get connectionState() {
|
|
187
211
|
return this.#state;
|
|
188
212
|
}
|
|
189
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Server-assigned id, available once connected. Stays `null` when the
|
|
215
|
+
* server assigns none (a core-only server may not).
|
|
216
|
+
*/
|
|
190
217
|
get clientId() {
|
|
191
218
|
return this.#clientId;
|
|
192
219
|
}
|
|
193
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* Active namespace — the server's assignment wins over the request. Falls
|
|
222
|
+
* back to the requested one when the server assigns none.
|
|
223
|
+
*/
|
|
194
224
|
get namespace() {
|
|
195
225
|
return this.#namespace ?? this.#requestedNamespace;
|
|
196
226
|
}
|
|
@@ -303,7 +333,6 @@ export class WSClient {
|
|
|
303
333
|
this.#settleConnect(new WSConnectTimeoutError(this.#connectTimeout));
|
|
304
334
|
}, this.#connectTimeout);
|
|
305
335
|
}
|
|
306
|
-
this.#intentional = false;
|
|
307
336
|
if (this.#state === "idle" || this.#state === "terminated")
|
|
308
337
|
this.#open();
|
|
309
338
|
return promise;
|
|
@@ -314,16 +343,29 @@ export class WSClient {
|
|
|
314
343
|
* Resumable: handlers, room subscriptions and buffered sends all survive,
|
|
315
344
|
* so a later `connect()` picks up exactly where this left off. Use
|
|
316
345
|
* {@link dispose} for terminal teardown.
|
|
346
|
+
*
|
|
347
|
+
* Emits `close` with {@link CLOSE.CLIENT_GONE} and `willReconnect: false`
|
|
348
|
+
* when there was a socket to close; nothing when already idle, reconnecting
|
|
349
|
+
* or terminated.
|
|
317
350
|
*/
|
|
318
351
|
disconnect() {
|
|
319
352
|
if (this.#state === "disposed")
|
|
320
353
|
return;
|
|
321
354
|
this.logger?.debug?.("disconnect()");
|
|
322
|
-
this.#intentional = true;
|
|
323
355
|
this.#clearTimers();
|
|
324
356
|
this.#heartbeat.stop();
|
|
325
357
|
this.#settleConnect(new WSTerminatedError(CLOSE.CLIENT_GONE, "disconnect() called"));
|
|
326
|
-
|
|
358
|
+
const reason = "client disconnect";
|
|
359
|
+
const closed = this.#socket !== null;
|
|
360
|
+
this.#closeSocket(CLOSE.CLIENT_GONE, reason);
|
|
361
|
+
if (closed) {
|
|
362
|
+
this.#emit("close", {
|
|
363
|
+
code: CLOSE.CLIENT_GONE,
|
|
364
|
+
reason,
|
|
365
|
+
willReconnect: false,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
this.#settleInFlight();
|
|
327
369
|
this.#setState("idle");
|
|
328
370
|
}
|
|
329
371
|
/**
|
|
@@ -364,7 +406,8 @@ export class WSClient {
|
|
|
364
406
|
* @param options - pass `presence` to enable membership tracking
|
|
365
407
|
* @returns detaches this handler; also `Symbol.dispose`-compatible, and
|
|
366
408
|
* idempotent, so calling it twice is harmless
|
|
367
|
-
* @throws {WSRemoteError} when connected and the server refuses
|
|
409
|
+
* @throws {WSRemoteError} when connected and the server refuses — with code
|
|
410
|
+
* `unsupported` when it does not implement rooms at all
|
|
368
411
|
* @throws {WSDisposedError} when the client was disposed
|
|
369
412
|
*
|
|
370
413
|
* @example
|
|
@@ -386,6 +429,10 @@ export class WSClient {
|
|
|
386
429
|
type: FRAME.UNSUB,
|
|
387
430
|
id: this.#nextId(),
|
|
388
431
|
rooms: [room],
|
|
432
|
+
}).catch((e) => {
|
|
433
|
+
// Not actionable: the handler is already detached locally and
|
|
434
|
+
// the server drops the room on close anyway.
|
|
435
|
+
this.logger?.debug?.(`unsub "${room}" unconfirmed: ${e.message}`);
|
|
389
436
|
});
|
|
390
437
|
}
|
|
391
438
|
});
|
|
@@ -449,7 +496,22 @@ export class WSClient {
|
|
|
449
496
|
members(room) {
|
|
450
497
|
return this.#rooms.members(room);
|
|
451
498
|
}
|
|
452
|
-
|
|
499
|
+
send(payload, options = {}) {
|
|
500
|
+
this.#assertUsable();
|
|
501
|
+
if (options.ack) {
|
|
502
|
+
const id = this.#nextId();
|
|
503
|
+
return this.#send({ type: FRAME.MSG, id, payload }, id).then((r) => r.payload);
|
|
504
|
+
}
|
|
505
|
+
// No wire id: nothing is coming back. The outbox still needs a key to
|
|
506
|
+
// buffer it under while offline.
|
|
507
|
+
const sent = this.#send({ type: FRAME.MSG, payload }, this.#nextId(), false)
|
|
508
|
+
.then(noop);
|
|
509
|
+
// Fire-and-forget invites not awaiting, and an ignored rejection is
|
|
510
|
+
// fatal in Deno and Node. Marked handled, ignoring it is safe; awaiting
|
|
511
|
+
// it still reports the failure.
|
|
512
|
+
sent.catch(noop);
|
|
513
|
+
return sent;
|
|
514
|
+
}
|
|
453
515
|
/**
|
|
454
516
|
* Publishes to a room within this client's namespace.
|
|
455
517
|
*
|
|
@@ -466,6 +528,8 @@ export class WSClient {
|
|
|
466
528
|
* @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
|
|
467
529
|
* @throws {WSOutboxDropError} evicted from a full outbox
|
|
468
530
|
* @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
|
|
531
|
+
* @throws {WSTerminatedError} sent after a terminal close, which only an
|
|
532
|
+
* explicit `connect()` recovers from — rejected at once, not buffered
|
|
469
533
|
* @throws {WSRemoteError} the server rejected it with a `nack`
|
|
470
534
|
*/
|
|
471
535
|
publish(room, payload, namespace) {
|
|
@@ -477,7 +541,7 @@ export class WSClient {
|
|
|
477
541
|
room,
|
|
478
542
|
payload,
|
|
479
543
|
...(namespace ? { namespace } : {}),
|
|
480
|
-
}, id);
|
|
544
|
+
}, id).then(({ recipients }) => ({ recipients }));
|
|
481
545
|
}
|
|
482
546
|
/**
|
|
483
547
|
* Publishes to a room across **all** namespaces.
|
|
@@ -495,7 +559,8 @@ export class WSClient {
|
|
|
495
559
|
broadcast(room, payload) {
|
|
496
560
|
this.#assertUsable();
|
|
497
561
|
const id = this.#nextId();
|
|
498
|
-
return this.#send({ type: FRAME.BROADCAST, id, room, payload }, id)
|
|
562
|
+
return this.#send({ type: FRAME.BROADCAST, id, room, payload }, id)
|
|
563
|
+
.then(({ recipients }) => ({ recipients }));
|
|
499
564
|
}
|
|
500
565
|
// -------------------------------------------------------------- internals
|
|
501
566
|
#nextId() {
|
|
@@ -509,19 +574,38 @@ export class WSClient {
|
|
|
509
574
|
if (this.#state === "idle")
|
|
510
575
|
this.#open();
|
|
511
576
|
}
|
|
512
|
-
|
|
577
|
+
/**
|
|
578
|
+
* Sends a frame through the outbox: now when connected, buffered otherwise.
|
|
579
|
+
*
|
|
580
|
+
* @param id - the frame's wire `id`, or a local key for a frame without one
|
|
581
|
+
* @param awaitAck - `false` completes the send once written, not once acked
|
|
582
|
+
*/
|
|
583
|
+
#send(frame, id, awaitAck = true) {
|
|
513
584
|
if (this.#autoConnect)
|
|
514
585
|
this.#ensureStarted();
|
|
586
|
+
// Nothing restarts from `terminated` except an explicit connect(), so
|
|
587
|
+
// buffering here would only defer the same answer by `sendTimeout`.
|
|
588
|
+
if (this.#terminalError && this.#state === "terminated") {
|
|
589
|
+
return Promise.reject(this.#terminalError);
|
|
590
|
+
}
|
|
515
591
|
const canSendNow = this.connected &&
|
|
516
592
|
this.#socket?.readyState === WebSocket.OPEN;
|
|
517
593
|
if (!canSendNow && this.#outboxMaxSize === 0) {
|
|
518
594
|
return Promise.reject(new WSNotConnectedError());
|
|
519
595
|
}
|
|
520
|
-
const promise = this.#outbox.track(id, frame, !canSendNow);
|
|
596
|
+
const promise = this.#outbox.track(id, frame, !canSendNow, awaitAck);
|
|
521
597
|
if (canSendNow)
|
|
522
|
-
this.#
|
|
598
|
+
this.#transmit(id, frame);
|
|
523
599
|
return promise;
|
|
524
600
|
}
|
|
601
|
+
/** Writes a tracked frame and reports the outcome to the outbox. */
|
|
602
|
+
#transmit(id, frame) {
|
|
603
|
+
const error = this.#sendRaw(frame);
|
|
604
|
+
if (error)
|
|
605
|
+
this.#outbox.fail(id, error);
|
|
606
|
+
else
|
|
607
|
+
this.#outbox.transmitted(id);
|
|
608
|
+
}
|
|
525
609
|
/**
|
|
526
610
|
* Sends a control frame that must never be buffered.
|
|
527
611
|
*
|
|
@@ -530,21 +614,28 @@ export class WSClient {
|
|
|
530
614
|
* them twice.
|
|
531
615
|
*/
|
|
532
616
|
#sendControl(frame) {
|
|
533
|
-
const
|
|
534
|
-
const
|
|
535
|
-
|
|
617
|
+
const promise = this.#outbox.track(frame.id, frame, false);
|
|
618
|
+
const error = this.#sendRaw(frame);
|
|
619
|
+
if (error)
|
|
620
|
+
this.#outbox.fail(frame.id, error);
|
|
536
621
|
return promise;
|
|
537
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* @returns the error the send failed with — a frame that never left cannot
|
|
625
|
+
* be acknowledged, so the caller settles its promise instead of letting it
|
|
626
|
+
* wait out `sendTimeout`.
|
|
627
|
+
*/
|
|
538
628
|
#sendRaw(frame) {
|
|
539
629
|
const socket = this.#socket;
|
|
540
630
|
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
541
|
-
return;
|
|
631
|
+
return null;
|
|
542
632
|
try {
|
|
543
633
|
socket.send(this.#encode(frame));
|
|
544
634
|
}
|
|
545
635
|
catch (e) {
|
|
546
|
-
this.#fail(e, "send failed");
|
|
636
|
+
return this.#fail(e, "send failed");
|
|
547
637
|
}
|
|
638
|
+
return null;
|
|
548
639
|
}
|
|
549
640
|
#open() {
|
|
550
641
|
// Defensive: every caller already guards this, but a second socket
|
|
@@ -556,7 +647,7 @@ export class WSClient {
|
|
|
556
647
|
}
|
|
557
648
|
if (!this.#setState("connecting"))
|
|
558
649
|
return;
|
|
559
|
-
this.#
|
|
650
|
+
this.#terminalError = null;
|
|
560
651
|
const generation = ++this.#generation;
|
|
561
652
|
let socket;
|
|
562
653
|
try {
|
|
@@ -568,6 +659,9 @@ export class WSClient {
|
|
|
568
659
|
return;
|
|
569
660
|
}
|
|
570
661
|
this.#socket = socket;
|
|
662
|
+
// Without this a binary frame arrives as a Blob, which no synchronous
|
|
663
|
+
// decoder can read — the `WSDecoder` contract promises an `ArrayBuffer`.
|
|
664
|
+
socket.binaryType = "arraybuffer";
|
|
571
665
|
this.logger?.debug?.(`connecting to ${this.#url.href}`);
|
|
572
666
|
socket.onopen = () => {
|
|
573
667
|
if (generation !== this.#generation)
|
|
@@ -601,6 +695,13 @@ export class WSClient {
|
|
|
601
695
|
payload = (await this.#authFn?.()) ?? null;
|
|
602
696
|
}
|
|
603
697
|
catch (e) {
|
|
698
|
+
// The await yields, so this rejection may belong to a socket that a
|
|
699
|
+
// later connect() already replaced — closing on it would kill the
|
|
700
|
+
// healthy socket that took its place.
|
|
701
|
+
if (generation !== this.#generation) {
|
|
702
|
+
this.logger?.debug?.("superseded auth attempt failed");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
604
705
|
this.#fail(e, "auth payload failed");
|
|
605
706
|
this.#forceClose(CLOSE.PROTOCOL_ERROR, "auth payload failed");
|
|
606
707
|
return;
|
|
@@ -608,13 +709,14 @@ export class WSClient {
|
|
|
608
709
|
// The await above yields; a close may have superseded this socket.
|
|
609
710
|
if (generation !== this.#generation)
|
|
610
711
|
return;
|
|
712
|
+
// Only what the application actually chose goes on the wire: a server
|
|
713
|
+
// without rooms should not have to wade through identity it never uses.
|
|
611
714
|
this.#sendRaw({
|
|
612
715
|
type: FRAME.AUTH,
|
|
613
|
-
id: this.#nextId(),
|
|
614
716
|
protocol: PROTOCOL_VERSION,
|
|
615
717
|
payload,
|
|
616
718
|
...(this.#requestedClientId ? { clientId: this.#requestedClientId } : {}),
|
|
617
|
-
namespace: this.#requestedNamespace,
|
|
719
|
+
...(this.#namespaceRequested ? { namespace: this.#requestedNamespace } : {}),
|
|
618
720
|
});
|
|
619
721
|
// Without this, a server that accepts the socket then never replies
|
|
620
722
|
// leaves us in `authenticating` forever — the heartbeat has not started
|
|
@@ -644,7 +746,7 @@ export class WSClient {
|
|
|
644
746
|
this.#onHello(frame.clientId, frame.namespace, frame.protocol);
|
|
645
747
|
break;
|
|
646
748
|
case FRAME.ACK:
|
|
647
|
-
this.#outbox.settle(frame.id, frame.recipients ?? 0);
|
|
749
|
+
this.#outbox.settle(frame.id, frame.recipients ?? 0, frame.payload);
|
|
648
750
|
break;
|
|
649
751
|
case FRAME.NACK:
|
|
650
752
|
this.#outbox.fail(frame.id, new WSRemoteError(frame.error));
|
|
@@ -652,7 +754,11 @@ export class WSClient {
|
|
|
652
754
|
case FRAME.MSG: {
|
|
653
755
|
const { type: _t, ...message } = frame;
|
|
654
756
|
this.#emit("message", message);
|
|
655
|
-
|
|
757
|
+
// Only a room delivery carries `room`. A direct message from the
|
|
758
|
+
// server has no room to route by — the firehose is its only way in.
|
|
759
|
+
if (typeof message.room === "string") {
|
|
760
|
+
this.#rooms.deliver(message.room, message, (e) => this.#fail(e, "message handler threw"));
|
|
761
|
+
}
|
|
656
762
|
break;
|
|
657
763
|
}
|
|
658
764
|
case FRAME.PRESENCE: {
|
|
@@ -676,14 +782,16 @@ export class WSClient {
|
|
|
676
782
|
if (protocol !== PROTOCOL_VERSION) {
|
|
677
783
|
this.logger?.warn?.(`protocol version mismatch (client ${PROTOCOL_VERSION}, server ${protocol})`);
|
|
678
784
|
}
|
|
679
|
-
|
|
680
|
-
|
|
785
|
+
// Identity belongs to the rooms extension: a core-only server may
|
|
786
|
+
// assign none, and then there is none — not a stale one from before.
|
|
787
|
+
this.#clientId = typeof clientId === "string" && clientId ? clientId : null;
|
|
788
|
+
this.#namespace = typeof namespace === "string" && namespace ? namespace : null;
|
|
681
789
|
this.#attempt = 0;
|
|
682
790
|
this.#lastError = null;
|
|
683
791
|
if (!this.#setState("open"))
|
|
684
792
|
return;
|
|
685
|
-
this.logger?.debug?.(`connected as ${clientId} in "${namespace}"`);
|
|
686
|
-
this.#emit("connected", { clientId, namespace });
|
|
793
|
+
this.logger?.debug?.(`connected${this.#clientId ? ` as ${this.#clientId}` : ""} in "${this.namespace}"`);
|
|
794
|
+
this.#emit("connected", { clientId: this.#clientId, namespace: this.namespace });
|
|
687
795
|
this.#heartbeat.start();
|
|
688
796
|
this.#settleConnect(null);
|
|
689
797
|
// Order matters and is not cosmetic: re-subscribe first, then flush.
|
|
@@ -700,8 +808,8 @@ export class WSClient {
|
|
|
700
808
|
const buffered = this.#outbox.drain();
|
|
701
809
|
if (buffered.length) {
|
|
702
810
|
this.logger?.debug?.(`flushing ${buffered.length} buffered frame(s)`);
|
|
703
|
-
for (const frame of buffered)
|
|
704
|
-
this.#
|
|
811
|
+
for (const { id, frame } of buffered)
|
|
812
|
+
this.#transmit(id, frame);
|
|
705
813
|
}
|
|
706
814
|
}
|
|
707
815
|
#onClose(code, reason) {
|
|
@@ -710,14 +818,14 @@ export class WSClient {
|
|
|
710
818
|
this.#heartbeat.stop();
|
|
711
819
|
this.#socket = null;
|
|
712
820
|
const terminal = this.#terminalCodes.includes(code);
|
|
713
|
-
const willReconnect = !this.#
|
|
714
|
-
this.#state !== "disposed";
|
|
821
|
+
const willReconnect = !terminal && this.#state !== "disposed";
|
|
715
822
|
this.logger?.debug?.(`closed (${code}${reason ? ` ${reason}` : ""}), reconnect=${willReconnect}`);
|
|
716
823
|
this.#emit("close", { code, reason, willReconnect });
|
|
717
824
|
if (terminal) {
|
|
718
825
|
this.#setState("terminated");
|
|
719
826
|
const error = new WSTerminatedError(code, reason);
|
|
720
827
|
this.#lastError = error;
|
|
828
|
+
this.#terminalError = error;
|
|
721
829
|
// Loud on purpose: this is the only path where a client that
|
|
722
830
|
// otherwise retries forever gives up, and a silent one looks
|
|
723
831
|
// exactly like a network that never came back.
|
|
@@ -727,12 +835,29 @@ export class WSClient {
|
|
|
727
835
|
this.#emit("terminated", { code, reason });
|
|
728
836
|
return;
|
|
729
837
|
}
|
|
838
|
+
this.#settleInFlight();
|
|
730
839
|
if (!willReconnect) {
|
|
731
840
|
this.#setState("idle");
|
|
732
841
|
return;
|
|
733
842
|
}
|
|
734
843
|
this.#scheduleReconnect();
|
|
735
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* Answers the frames that were on the wire when the socket went away.
|
|
847
|
+
*
|
|
848
|
+
* `sub`/`unsub` resolve: the room registry is authoritative locally and the
|
|
849
|
+
* re-subscribe step will establish it on the next connection, which is
|
|
850
|
+
* exactly the contract of a `subscribe()` issued while offline — and the
|
|
851
|
+
* server forgets its rooms on close anyway. Publishes and acked sends
|
|
852
|
+
* reject: they were not confirmed, and at-most-once means they will not be
|
|
853
|
+
* resent. (A send without an ack is never in flight — it completed when it
|
|
854
|
+
* was written.)
|
|
855
|
+
*/
|
|
856
|
+
#settleInFlight() {
|
|
857
|
+
this.#outbox.settleInFlight((frame) => frame.type === FRAME.SUB || frame.type === FRAME.UNSUB
|
|
858
|
+
? null
|
|
859
|
+
: new WSConnectionLostError());
|
|
860
|
+
}
|
|
736
861
|
#scheduleReconnect() {
|
|
737
862
|
if (!this.#setState("reconnecting"))
|
|
738
863
|
return;
|
|
@@ -864,6 +989,7 @@ export class WSClient {
|
|
|
864
989
|
this.#lastError = err;
|
|
865
990
|
this.logger?.error?.(`${context}: ${err.message}`);
|
|
866
991
|
this.#emit("error", err);
|
|
992
|
+
return err;
|
|
867
993
|
}
|
|
868
994
|
}
|
|
869
995
|
/**
|
|
@@ -875,14 +1001,21 @@ export class WSClient {
|
|
|
875
1001
|
* @param options - see {@link WSClientOptions}
|
|
876
1002
|
* @returns a client that has not connected yet
|
|
877
1003
|
*
|
|
878
|
-
* @example
|
|
1004
|
+
* @example Messages
|
|
879
1005
|
* ```ts
|
|
880
1006
|
* const ws = createWSClient({
|
|
881
1007
|
* url: "wss://example.com/ws",
|
|
882
|
-
* namespace: "org-123",
|
|
883
1008
|
* auth: () => session.token, // re-read on every reconnect
|
|
884
1009
|
* });
|
|
885
1010
|
*
|
|
1011
|
+
* ws.on("message", (msg) => console.log(msg.payload));
|
|
1012
|
+
* const reply = await ws.send({ op: "ping" }, { ack: true });
|
|
1013
|
+
* ```
|
|
1014
|
+
*
|
|
1015
|
+
* @example Rooms
|
|
1016
|
+
* ```ts
|
|
1017
|
+
* const ws = createWSClient({ url: "wss://example.com/ws", namespace: "org-123" });
|
|
1018
|
+
*
|
|
886
1019
|
* await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
|
|
887
1020
|
* await ws.publish("chat", { text: "hello" });
|
|
888
1021
|
* ```
|
package/dist/mod.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@marianmeres/ws` — a WebSocket client with
|
|
3
|
-
*
|
|
2
|
+
* `@marianmeres/ws` — a WebSocket client with auto-reconnect, half-open
|
|
3
|
+
* detection and buffered sends, plus optional namespaces, rooms and presence.
|
|
4
|
+
*
|
|
5
|
+
* Two ways to use it, freely mixed on one connection:
|
|
6
|
+
*
|
|
7
|
+
* - **Messages** — `send()` to the server, `on("message")` to receive. The
|
|
8
|
+
* server only has to implement the small core of the protocol.
|
|
9
|
+
* - **Rooms** — `subscribe()` / `publish()` / `broadcast()` and presence, for
|
|
10
|
+
* a server that relays between clients (the rooms extension).
|
|
4
11
|
*
|
|
5
12
|
* The client is dependency-light and runs in browsers, Deno, Node 22+, Bun and
|
|
6
13
|
* Workers — `WebSocket` is a global in all of them, so there is no polyfill and
|
|
@@ -9,7 +16,19 @@
|
|
|
9
16
|
* The reference server lives behind a separate entry point,
|
|
10
17
|
* `@marianmeres/ws/server`, so `demino` never lands in a browser bundle.
|
|
11
18
|
*
|
|
12
|
-
* @example
|
|
19
|
+
* @example Messages
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { createWSClient } from "@marianmeres/ws";
|
|
22
|
+
*
|
|
23
|
+
* const ws = createWSClient({ url: "/ws", auth: () => session.token });
|
|
24
|
+
*
|
|
25
|
+
* ws.on("message", (msg) => console.log(msg.payload));
|
|
26
|
+
*
|
|
27
|
+
* ws.send({ op: "typing" }); // fire-and-forget
|
|
28
|
+
* const doc = await ws.send({ op: "load", id: 42 }, { ack: true }); // the reply
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @example Rooms
|
|
13
32
|
* ```ts
|
|
14
33
|
* import { createWSClient } from "@marianmeres/ws";
|
|
15
34
|
*
|
|
@@ -24,7 +43,7 @@
|
|
|
24
43
|
*
|
|
25
44
|
* @module
|
|
26
45
|
*/
|
|
27
|
-
export { createWSClient, type SubscribeOptions, WSClient, type WSClientOptions, type WSConnectionState, type WSEvents, type WSState, } from "./client/ws-client.js";
|
|
46
|
+
export { createWSClient, type SubscribeOptions, WSClient, type WSClientOptions, type WSConnectionState, type WSEvents, type WSSendOptions, type WSState, } from "./client/ws-client.js";
|
|
28
47
|
export type { MessageHandler, PresenceHandler } from "./client/rooms.js";
|
|
29
48
|
export { backoffDelay } from "./client/backoff.js";
|
|
30
49
|
export * from "./protocol/mod.js";
|
package/dist/mod.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@marianmeres/ws` — a WebSocket client with
|
|
3
|
-
*
|
|
2
|
+
* `@marianmeres/ws` — a WebSocket client with auto-reconnect, half-open
|
|
3
|
+
* detection and buffered sends, plus optional namespaces, rooms and presence.
|
|
4
|
+
*
|
|
5
|
+
* Two ways to use it, freely mixed on one connection:
|
|
6
|
+
*
|
|
7
|
+
* - **Messages** — `send()` to the server, `on("message")` to receive. The
|
|
8
|
+
* server only has to implement the small core of the protocol.
|
|
9
|
+
* - **Rooms** — `subscribe()` / `publish()` / `broadcast()` and presence, for
|
|
10
|
+
* a server that relays between clients (the rooms extension).
|
|
4
11
|
*
|
|
5
12
|
* The client is dependency-light and runs in browsers, Deno, Node 22+, Bun and
|
|
6
13
|
* Workers — `WebSocket` is a global in all of them, so there is no polyfill and
|
|
@@ -9,7 +16,19 @@
|
|
|
9
16
|
* The reference server lives behind a separate entry point,
|
|
10
17
|
* `@marianmeres/ws/server`, so `demino` never lands in a browser bundle.
|
|
11
18
|
*
|
|
12
|
-
* @example
|
|
19
|
+
* @example Messages
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { createWSClient } from "@marianmeres/ws";
|
|
22
|
+
*
|
|
23
|
+
* const ws = createWSClient({ url: "/ws", auth: () => session.token });
|
|
24
|
+
*
|
|
25
|
+
* ws.on("message", (msg) => console.log(msg.payload));
|
|
26
|
+
*
|
|
27
|
+
* ws.send({ op: "typing" }); // fire-and-forget
|
|
28
|
+
* const doc = await ws.send({ op: "load", id: 42 }, { ack: true }); // the reply
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @example Rooms
|
|
13
32
|
* ```ts
|
|
14
33
|
* import { createWSClient } from "@marianmeres/ws";
|
|
15
34
|
*
|
|
@@ -11,8 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* It costs nothing today and it is the only thing that makes a breaking
|
|
13
13
|
* protocol change survivable later.
|
|
14
|
+
*
|
|
15
|
+
* Version 2 split the protocol into a required **core** (handshake, messages
|
|
16
|
+
* in both directions, heartbeat) and an optional **rooms extension**
|
|
17
|
+
* (subscriptions, publishing, presence, broadcast), so a server that only
|
|
18
|
+
* needs a data channel implements the core and nothing else.
|
|
14
19
|
*/
|
|
15
|
-
export declare const PROTOCOL_VERSION =
|
|
20
|
+
export declare const PROTOCOL_VERSION = 2;
|
|
16
21
|
/** Namespace used when the client does not specify one. */
|
|
17
22
|
export declare const DEFAULT_NAMESPACE = "default";
|
|
18
23
|
/**
|
|
@@ -21,34 +26,43 @@ export declare const DEFAULT_NAMESPACE = "default";
|
|
|
21
26
|
* Note these are *protocol* types and have nothing to do with whatever the
|
|
22
27
|
* application puts inside `payload` — the payload is opaque and is never
|
|
23
28
|
* inspected nor mutated by this library.
|
|
29
|
+
*
|
|
30
|
+
* Every server implements the **core** frames. The **rooms extension**
|
|
31
|
+
* frames are sent only when the application uses rooms (`subscribe()`,
|
|
32
|
+
* `publish()`, `broadcast()`, presence), so a server that does not support
|
|
33
|
+
* them never sees one.
|
|
24
34
|
*/
|
|
25
35
|
export declare const FRAME: {
|
|
26
36
|
/** Handshake. Always the first frame; carries the auth payload. */
|
|
27
37
|
readonly AUTH: "auth";
|
|
28
|
-
/** Join one or more rooms, optionally with presence. */
|
|
29
|
-
readonly SUB: "sub";
|
|
30
|
-
/** Leave one or more rooms. */
|
|
31
|
-
readonly UNSUB: "unsub";
|
|
32
|
-
/** Publish into a room within the connection's own namespace. */
|
|
33
|
-
readonly PUB: "pub";
|
|
34
|
-
/** Publish into a room across every namespace. Gated server-side. */
|
|
35
|
-
readonly BROADCAST: "broadcast";
|
|
36
38
|
/** Liveness probe. Answered with `pong`. */
|
|
37
39
|
readonly PING: "ping";
|
|
38
|
-
/** Handshake accepted; carries the
|
|
40
|
+
/** Handshake accepted; carries the protocol version (and, with rooms, identity). */
|
|
39
41
|
readonly HELLO: "hello";
|
|
40
|
-
/** Positive acknowledgement of a
|
|
42
|
+
/** Positive acknowledgement of a frame that carried an `id`; may carry a reply. */
|
|
41
43
|
readonly ACK: "ack";
|
|
42
44
|
/** Negative acknowledgement; carries a `WSErrorInfo`. */
|
|
43
45
|
readonly NACK: "nack";
|
|
44
|
-
/** A message delivered to a subscribed room. */
|
|
45
|
-
readonly MSG: "msg";
|
|
46
|
-
/** A membership change in a presence-enabled room. */
|
|
47
|
-
readonly PRESENCE: "presence";
|
|
48
46
|
/** Reply to `ping`. */
|
|
49
47
|
readonly PONG: "pong";
|
|
50
48
|
/** Uncorrelated error — not tied to any request id. */
|
|
51
49
|
readonly ERROR: "error";
|
|
50
|
+
/**
|
|
51
|
+
* A message. Client -> server it is addressed to the server itself;
|
|
52
|
+
* server -> client it is either a direct message (core) or a room delivery
|
|
53
|
+
* (rooms extension, recognisable by its `room` field).
|
|
54
|
+
*/
|
|
55
|
+
readonly MSG: "msg";
|
|
56
|
+
/** Join one or more rooms, optionally with presence. */
|
|
57
|
+
readonly SUB: "sub";
|
|
58
|
+
/** Leave one or more rooms. */
|
|
59
|
+
readonly UNSUB: "unsub";
|
|
60
|
+
/** Publish into a room within the connection's own namespace. */
|
|
61
|
+
readonly PUB: "pub";
|
|
62
|
+
/** Publish into a room across every namespace. Gated server-side. */
|
|
63
|
+
readonly BROADCAST: "broadcast";
|
|
64
|
+
/** A membership change in a presence-enabled room. */
|
|
65
|
+
readonly PRESENCE: "presence";
|
|
52
66
|
};
|
|
53
67
|
/**
|
|
54
68
|
* WebSocket close codes.
|
|
@@ -99,10 +113,15 @@ export declare const ERROR_CODE: {
|
|
|
99
113
|
readonly BAD_REQUEST: "bad_request";
|
|
100
114
|
/** Frame rate cap exceeded. */
|
|
101
115
|
readonly RATE_LIMITED: "rate_limited";
|
|
116
|
+
/**
|
|
117
|
+
* A frame type this server does not implement — typically a rooms frame
|
|
118
|
+
* sent to a core-only server, or a `msg` to a server with no handler.
|
|
119
|
+
*/
|
|
120
|
+
readonly UNSUPPORTED: "unsupported";
|
|
102
121
|
/** Unexpected server-side failure. */
|
|
103
122
|
readonly INTERNAL: "internal";
|
|
104
123
|
};
|
|
105
|
-
/** Presence event kinds. */
|
|
124
|
+
/** Presence event kinds (rooms extension). */
|
|
106
125
|
export declare const PRESENCE: {
|
|
107
126
|
/** Full membership snapshot, sent on every (re)subscribe. */
|
|
108
127
|
readonly SYNC: "sync";
|