@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.
@@ -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 with namespaces, rooms and presence.
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
- #intentional = false;
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
- /** Server-assigned id, available once connected. */
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
- /** Active namespace — the server's assignment wins over the request. */
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
- this.#closeSocket(CLOSE.CLIENT_GONE, "client disconnect");
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
- // --------------------------------------------------------------- sending
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
- #send(frame, id) {
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.#sendRaw(frame);
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 id = "id" in frame ? frame.id : this.#nextId();
534
- const promise = this.#outbox.track(id, frame, false);
535
- this.#sendRaw(frame);
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.#intentional = false;
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
- this.#rooms.deliver(message.room, message, (e) => this.#fail(e, "message handler threw"));
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
- this.#clientId = clientId;
680
- this.#namespace = namespace;
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.#sendRaw(frame);
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.#intentional && !terminal &&
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 namespaces, rooms, presence and
3
- * auto-reconnect.
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 namespaces, rooms, presence and
3
- * auto-reconnect.
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 = 1;
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 assigned id, namespace and version. */
40
+ /** Handshake accepted; carries the protocol version (and, with rooms, identity). */
39
41
  readonly HELLO: "hello";
40
- /** Positive acknowledgement of a correlated request. */
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";