@marianmeres/ws 0.4.1 → 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,6 +1,13 @@
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 { type Logger } from "@marianmeres/clog";
@@ -18,12 +25,19 @@ export type WSConnectionState = "idle" | "connecting" | "authenticating" | "open
18
25
  export interface WSEvents {
19
26
  /** Socket opened; authentication has not happened yet. */
20
27
  open: void;
21
- /** Authenticated and ready. */
28
+ /**
29
+ * Authenticated and ready. `clientId` is `null` when the server assigned
30
+ * none — identity belongs to the rooms extension, and a core-only server
31
+ * may skip it.
32
+ */
22
33
  connected: {
23
- clientId: string;
34
+ clientId: string | null;
24
35
  namespace: string;
25
36
  };
26
- /** Firehose — every message, regardless of room. */
37
+ /**
38
+ * Every inbound message: direct messages from the server, and — with
39
+ * rooms — every room delivery regardless of room. `room` tells them apart.
40
+ */
27
41
  message: WSMessage;
28
42
  /** Membership change in a room subscribed with presence enabled. */
29
43
  presence: WSPresenceEvent;
@@ -62,6 +76,16 @@ export interface WSState {
62
76
  /** Most recent error, retained until the next successful connect. */
63
77
  lastError: Error | null;
64
78
  }
79
+ /** Options for {@link WSClient.send}. */
80
+ export interface WSSendOptions {
81
+ /**
82
+ * Wait for the server's acknowledgement and resolve with its reply.
83
+ *
84
+ * Default `false`: resolve as soon as the frame is written to the socket,
85
+ * with no confirmation that the server ever received it.
86
+ */
87
+ ack?: boolean;
88
+ }
65
89
  /** Per-room subscription options. */
66
90
  export interface SubscribeOptions {
67
91
  /**
@@ -80,11 +104,14 @@ export interface WSClientOptions<TAuth = unknown> {
80
104
  * a path resolved against `location` in the browser. Default `/ws`.
81
105
  */
82
106
  url?: string | URL;
83
- /** Isolation boundary. Default `"default"`. */
107
+ /**
108
+ * Isolation boundary for rooms. Default `"default"`. Sent to the server only
109
+ * when set here — a server without rooms has no use for it.
110
+ */
84
111
  namespace?: string;
85
- /** Preferred client id; the server may override it. */
112
+ /** Preferred client id; the server may override or ignore it. */
86
113
  clientId?: string;
87
- /** Rooms joined automatically on every (re)connect. */
114
+ /** Rooms joined automatically on every (re)connect (rooms extension). */
88
115
  rooms?: string[];
89
116
  /**
90
117
  * Produces the auth payload. Called before *every* (re)connect, so
@@ -92,7 +119,7 @@ export interface WSClientOptions<TAuth = unknown> {
92
119
  */
93
120
  auth?: () => TAuth | Promise<TAuth>;
94
121
  /**
95
- * Let the first `subscribe()`/`publish()` start the connection.
122
+ * Let the first `send()`/`subscribe()`/`publish()` start the connection.
96
123
  * Default `true` — with an outbox and infinite retry, requiring an explicit
97
124
  * `connect()` first is ceremony whose only product is an error for people
98
125
  * who forgot.
@@ -127,9 +154,18 @@ export interface WSClientOptions<TAuth = unknown> {
127
154
  decode?: WSDecoder;
128
155
  }
129
156
  /**
130
- * A reconnecting WebSocket client with namespaces, rooms and presence.
157
+ * A reconnecting WebSocket client: plain messages to and from the server, and
158
+ * optionally namespaces, rooms and presence on top.
159
+ *
160
+ * @example Messages — the server is the peer
161
+ * ```ts
162
+ * const ws = createWSClient({ url: "/ws", auth: () => session.token });
163
+ * ws.on("message", (msg) => console.log(msg.payload));
164
+ * await ws.send({ op: "typing" }); // fire-and-forget
165
+ * const doc = await ws.send({ op: "load", id: 42 }, { ack: true }); // reply
166
+ * ```
131
167
  *
132
- * @example
168
+ * @example Rooms — the server relays between clients
133
169
  * ```ts
134
170
  * const ws = createWSClient({ url: "/ws", namespace: "org-123" });
135
171
  * const unsub = await ws.subscribe("chat", (msg) => console.log(msg.payload));
@@ -142,7 +178,7 @@ export declare class WSClient<TAuth = unknown> {
142
178
  logger: Logger | null;
143
179
  /**
144
180
  * Nothing connects here — the socket opens on the first `connect()`,
145
- * `subscribe()` or `publish()`.
181
+ * `send()`, `subscribe()` or `publish()`.
146
182
  *
147
183
  * @param options - see {@link WSClientOptions}; every field has a default
148
184
  */
@@ -161,9 +197,15 @@ export declare class WSClient<TAuth = unknown> {
161
197
  get connected(): boolean;
162
198
  /** Current lifecycle state. See {@link WSConnectionState}. */
163
199
  get connectionState(): WSConnectionState;
164
- /** Server-assigned id, available once connected. */
200
+ /**
201
+ * Server-assigned id, available once connected. Stays `null` when the
202
+ * server assigns none (a core-only server may not).
203
+ */
165
204
  get clientId(): string | null;
166
- /** Active namespace — the server's assignment wins over the request. */
205
+ /**
206
+ * Active namespace — the server's assignment wins over the request. Falls
207
+ * back to the requested one when the server assigns none.
208
+ */
167
209
  get namespace(): string;
168
210
  /** Resolved endpoint. A copy — mutating it does not affect the client. */
169
211
  get url(): URL;
@@ -264,7 +306,8 @@ export declare class WSClient<TAuth = unknown> {
264
306
  * @param options - pass `presence` to enable membership tracking
265
307
  * @returns detaches this handler; also `Symbol.dispose`-compatible, and
266
308
  * idempotent, so calling it twice is harmless
267
- * @throws {WSRemoteError} when connected and the server refuses
309
+ * @throws {WSRemoteError} when connected and the server refuses — with code
310
+ * `unsupported` when it does not implement rooms at all
268
311
  * @throws {WSDisposedError} when the client was disposed
269
312
  *
270
313
  * @example
@@ -300,6 +343,73 @@ export declare class WSClient<TAuth = unknown> {
300
343
  * @returns a copy of the members; empty when the room has no presence
301
344
  */
302
345
  members(room: string): string[];
346
+ /**
347
+ * Sends a message to the server — the room-free way to talk to it, and all
348
+ * a core-only server has to understand.
349
+ *
350
+ * Fire-and-forget: resolves as soon as the frame is written to the socket.
351
+ * While disconnected it is buffered and resolves when flushed after the
352
+ * next connect — bounded by `sendTimeout`, never indefinitely. There is no
353
+ * delivery confirmation: a frame written into a connection that turns out
354
+ * to be dead is lost, exactly as with a plain `WebSocket`. Pass
355
+ * `{ ack: true }` when that matters.
356
+ *
357
+ * Safe to call without awaiting: the returned promise is marked handled,
358
+ * so a failure nobody awaits is not an unhandled rejection. Await it to
359
+ * learn about one.
360
+ *
361
+ * @param payload - opaque application data; never inspected or mutated
362
+ * @param options - see {@link WSSendOptions}
363
+ * @returns resolves once the frame is written to the socket
364
+ * @throws {WSTimeoutError} still buffered when `sendTimeout` elapsed
365
+ * @throws {WSOutboxDropError} evicted from a full outbox
366
+ * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
367
+ * @throws {WSTerminatedError} sent after a terminal close
368
+ *
369
+ * @example
370
+ * ```ts
371
+ * ws.send({ op: "cursor", x: 10, y: 20 });
372
+ * ```
373
+ */
374
+ send<T = unknown>(payload: T, options?: WSSendOptions & {
375
+ ack?: false;
376
+ }): Promise<void>;
377
+ /**
378
+ * Sends a message to the server and waits for its acknowledgement.
379
+ *
380
+ * Resolves with the reply the server put in the ack — which makes this a
381
+ * request/response call — or with `undefined` when it sent a bare ack.
382
+ * Buffered while disconnected like any other send, and one `sendTimeout`
383
+ * spans queue, flight and ack.
384
+ *
385
+ * @param payload - opaque application data; never inspected or mutated
386
+ * @param options - `{ ack: true }`
387
+ * @returns the server's reply, `undefined` when there was none
388
+ * @throws {WSRemoteError} the server rejected it with a `nack` — code
389
+ * `unsupported` when it accepts no messages at all
390
+ * @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
391
+ * @throws {WSConnectionLostError} the socket closed before the ack arrived;
392
+ * the message was not resent
393
+ * @throws {WSOutboxDropError} evicted from a full outbox
394
+ * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
395
+ * @throws {WSTerminatedError} sent after a terminal close
396
+ *
397
+ * @example
398
+ * ```ts
399
+ * const doc = await ws.send<Doc>({ op: "load", id: 42 }, { ack: true });
400
+ * ```
401
+ */
402
+ send<R = unknown, T = unknown>(payload: T, options: WSSendOptions & {
403
+ ack: true;
404
+ }): Promise<R>;
405
+ /**
406
+ * Either of the above, decided at runtime by `options.ack`.
407
+ *
408
+ * @param payload - opaque application data; never inspected or mutated
409
+ * @param options - see {@link WSSendOptions}
410
+ * @returns `undefined` without an ack, the server's reply with one
411
+ */
412
+ send<T = unknown>(payload: T, options?: WSSendOptions): Promise<unknown>;
303
413
  /**
304
414
  * Publishes to a room within this client's namespace.
305
415
  *
@@ -345,14 +455,21 @@ export declare class WSClient<TAuth = unknown> {
345
455
  * @param options - see {@link WSClientOptions}
346
456
  * @returns a client that has not connected yet
347
457
  *
348
- * @example
458
+ * @example Messages
349
459
  * ```ts
350
460
  * const ws = createWSClient({
351
461
  * url: "wss://example.com/ws",
352
- * namespace: "org-123",
353
462
  * auth: () => session.token, // re-read on every reconnect
354
463
  * });
355
464
  *
465
+ * ws.on("message", (msg) => console.log(msg.payload));
466
+ * const reply = await ws.send({ op: "ping" }, { ack: true });
467
+ * ```
468
+ *
469
+ * @example Rooms
470
+ * ```ts
471
+ * const ws = createWSClient({ url: "wss://example.com/ws", namespace: "org-123" });
472
+ *
356
473
  * await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
357
474
  * await ws.publish("chat", { text: "hello" });
358
475
  * ```
@@ -1,6 +1,13 @@
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";
@@ -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.
63
71
  *
64
- * @example
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
+ * ```
79
+ *
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;
@@ -114,7 +132,7 @@ export class WSClient {
114
132
  #wakeListeners = [];
115
133
  /**
116
134
  * Nothing connects here — the socket opens on the first `connect()`,
117
- * `subscribe()` or `publish()`.
135
+ * `send()`, `subscribe()` or `publish()`.
118
136
  *
119
137
  * @param options - see {@link WSClientOptions}; every field has a default
120
138
  */
@@ -122,6 +140,7 @@ export class WSClient {
122
140
  this.logger = options.logger === undefined ? createClog("ws") : options.logger;
123
141
  this.#url = WSClient.resolveUrl(options.url ?? DEFAULTS.url);
124
142
  this.#requestedNamespace = options.namespace ?? DEFAULTS.namespace;
143
+ this.#namespaceRequested = options.namespace !== undefined;
125
144
  this.#requestedClientId = options.clientId;
126
145
  this.#authFn = options.auth;
127
146
  this.#autoConnect = options.autoConnect ?? DEFAULTS.autoConnect;
@@ -191,11 +210,17 @@ export class WSClient {
191
210
  get connectionState() {
192
211
  return this.#state;
193
212
  }
194
- /** 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
+ */
195
217
  get clientId() {
196
218
  return this.#clientId;
197
219
  }
198
- /** 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
+ */
199
224
  get namespace() {
200
225
  return this.#namespace ?? this.#requestedNamespace;
201
226
  }
@@ -381,7 +406,8 @@ export class WSClient {
381
406
  * @param options - pass `presence` to enable membership tracking
382
407
  * @returns detaches this handler; also `Symbol.dispose`-compatible, and
383
408
  * idempotent, so calling it twice is harmless
384
- * @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
385
411
  * @throws {WSDisposedError} when the client was disposed
386
412
  *
387
413
  * @example
@@ -470,7 +496,22 @@ export class WSClient {
470
496
  members(room) {
471
497
  return this.#rooms.members(room);
472
498
  }
473
- // --------------------------------------------------------------- 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
+ }
474
515
  /**
475
516
  * Publishes to a room within this client's namespace.
476
517
  *
@@ -500,7 +541,7 @@ export class WSClient {
500
541
  room,
501
542
  payload,
502
543
  ...(namespace ? { namespace } : {}),
503
- }, id);
544
+ }, id).then(({ recipients }) => ({ recipients }));
504
545
  }
505
546
  /**
506
547
  * Publishes to a room across **all** namespaces.
@@ -518,7 +559,8 @@ export class WSClient {
518
559
  broadcast(room, payload) {
519
560
  this.#assertUsable();
520
561
  const id = this.#nextId();
521
- 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 }));
522
564
  }
523
565
  // -------------------------------------------------------------- internals
524
566
  #nextId() {
@@ -532,7 +574,13 @@ export class WSClient {
532
574
  if (this.#state === "idle")
533
575
  this.#open();
534
576
  }
535
- #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) {
536
584
  if (this.#autoConnect)
537
585
  this.#ensureStarted();
538
586
  // Nothing restarts from `terminated` except an explicit connect(), so
@@ -545,14 +593,19 @@ export class WSClient {
545
593
  if (!canSendNow && this.#outboxMaxSize === 0) {
546
594
  return Promise.reject(new WSNotConnectedError());
547
595
  }
548
- const promise = this.#outbox.track(id, frame, !canSendNow);
549
- if (canSendNow) {
550
- const error = this.#sendRaw(frame);
551
- if (error)
552
- this.#outbox.fail(id, error);
553
- }
596
+ const promise = this.#outbox.track(id, frame, !canSendNow, awaitAck);
597
+ if (canSendNow)
598
+ this.#transmit(id, frame);
554
599
  return promise;
555
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
+ }
556
609
  /**
557
610
  * Sends a control frame that must never be buffered.
558
611
  *
@@ -561,11 +614,10 @@ export class WSClient {
561
614
  * them twice.
562
615
  */
563
616
  #sendControl(frame) {
564
- const id = "id" in frame ? frame.id : this.#nextId();
565
- const promise = this.#outbox.track(id, frame, false);
617
+ const promise = this.#outbox.track(frame.id, frame, false);
566
618
  const error = this.#sendRaw(frame);
567
619
  if (error)
568
- this.#outbox.fail(id, error);
620
+ this.#outbox.fail(frame.id, error);
569
621
  return promise;
570
622
  }
571
623
  /**
@@ -657,13 +709,14 @@ export class WSClient {
657
709
  // The await above yields; a close may have superseded this socket.
658
710
  if (generation !== this.#generation)
659
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.
660
714
  this.#sendRaw({
661
715
  type: FRAME.AUTH,
662
- id: this.#nextId(),
663
716
  protocol: PROTOCOL_VERSION,
664
717
  payload,
665
718
  ...(this.#requestedClientId ? { clientId: this.#requestedClientId } : {}),
666
- namespace: this.#requestedNamespace,
719
+ ...(this.#namespaceRequested ? { namespace: this.#requestedNamespace } : {}),
667
720
  });
668
721
  // Without this, a server that accepts the socket then never replies
669
722
  // leaves us in `authenticating` forever — the heartbeat has not started
@@ -693,7 +746,7 @@ export class WSClient {
693
746
  this.#onHello(frame.clientId, frame.namespace, frame.protocol);
694
747
  break;
695
748
  case FRAME.ACK:
696
- this.#outbox.settle(frame.id, frame.recipients ?? 0);
749
+ this.#outbox.settle(frame.id, frame.recipients ?? 0, frame.payload);
697
750
  break;
698
751
  case FRAME.NACK:
699
752
  this.#outbox.fail(frame.id, new WSRemoteError(frame.error));
@@ -701,7 +754,11 @@ export class WSClient {
701
754
  case FRAME.MSG: {
702
755
  const { type: _t, ...message } = frame;
703
756
  this.#emit("message", message);
704
- 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
+ }
705
762
  break;
706
763
  }
707
764
  case FRAME.PRESENCE: {
@@ -725,14 +782,16 @@ export class WSClient {
725
782
  if (protocol !== PROTOCOL_VERSION) {
726
783
  this.logger?.warn?.(`protocol version mismatch (client ${PROTOCOL_VERSION}, server ${protocol})`);
727
784
  }
728
- this.#clientId = clientId;
729
- 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;
730
789
  this.#attempt = 0;
731
790
  this.#lastError = null;
732
791
  if (!this.#setState("open"))
733
792
  return;
734
- this.logger?.debug?.(`connected as ${clientId} in "${namespace}"`);
735
- 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 });
736
795
  this.#heartbeat.start();
737
796
  this.#settleConnect(null);
738
797
  // Order matters and is not cosmetic: re-subscribe first, then flush.
@@ -749,11 +808,8 @@ export class WSClient {
749
808
  const buffered = this.#outbox.drain();
750
809
  if (buffered.length) {
751
810
  this.logger?.debug?.(`flushing ${buffered.length} buffered frame(s)`);
752
- for (const frame of buffered) {
753
- const error = this.#sendRaw(frame);
754
- if (error && "id" in frame)
755
- this.#outbox.fail(frame.id, error);
756
- }
811
+ for (const { id, frame } of buffered)
812
+ this.#transmit(id, frame);
757
813
  }
758
814
  }
759
815
  #onClose(code, reason) {
@@ -792,8 +848,10 @@ export class WSClient {
792
848
  * `sub`/`unsub` resolve: the room registry is authoritative locally and the
793
849
  * re-subscribe step will establish it on the next connection, which is
794
850
  * exactly the contract of a `subscribe()` issued while offline — and the
795
- * server forgets its rooms on close anyway. Publishes reject: they were not
796
- * delivered, and at-most-once means they will not be resent.
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.)
797
855
  */
798
856
  #settleInFlight() {
799
857
  this.#outbox.settleInFlight((frame) => frame.type === FRAME.SUB || frame.type === FRAME.UNSUB
@@ -943,14 +1001,21 @@ export class WSClient {
943
1001
  * @param options - see {@link WSClientOptions}
944
1002
  * @returns a client that has not connected yet
945
1003
  *
946
- * @example
1004
+ * @example Messages
947
1005
  * ```ts
948
1006
  * const ws = createWSClient({
949
1007
  * url: "wss://example.com/ws",
950
- * namespace: "org-123",
951
1008
  * auth: () => session.token, // re-read on every reconnect
952
1009
  * });
953
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
+ *
954
1019
  * await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
955
1020
  * await ws.publish("chat", { text: "hello" });
956
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
  *