@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,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,16 +25,26 @@ 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;
30
- /** Socket closed. `willReconnect` reflects the retry classification. */
44
+ /**
45
+ * Socket closed. `willReconnect` reflects the retry classification, and is
46
+ * `false` for the `4900` a local `disconnect()` emits.
47
+ */
31
48
  close: {
32
49
  code: number;
33
50
  reason: string;
@@ -59,6 +76,16 @@ export interface WSState {
59
76
  /** Most recent error, retained until the next successful connect. */
60
77
  lastError: Error | null;
61
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
+ }
62
89
  /** Per-room subscription options. */
63
90
  export interface SubscribeOptions {
64
91
  /**
@@ -77,11 +104,14 @@ export interface WSClientOptions<TAuth = unknown> {
77
104
  * a path resolved against `location` in the browser. Default `/ws`.
78
105
  */
79
106
  url?: string | URL;
80
- /** 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
+ */
81
111
  namespace?: string;
82
- /** Preferred client id; the server may override it. */
112
+ /** Preferred client id; the server may override or ignore it. */
83
113
  clientId?: string;
84
- /** Rooms joined automatically on every (re)connect. */
114
+ /** Rooms joined automatically on every (re)connect (rooms extension). */
85
115
  rooms?: string[];
86
116
  /**
87
117
  * Produces the auth payload. Called before *every* (re)connect, so
@@ -89,7 +119,7 @@ export interface WSClientOptions<TAuth = unknown> {
89
119
  */
90
120
  auth?: () => TAuth | Promise<TAuth>;
91
121
  /**
92
- * Let the first `subscribe()`/`publish()` start the connection.
122
+ * Let the first `send()`/`subscribe()`/`publish()` start the connection.
93
123
  * Default `true` — with an outbox and infinite retry, requiring an explicit
94
124
  * `connect()` first is ceremony whose only product is an error for people
95
125
  * who forgot.
@@ -124,9 +154,18 @@ export interface WSClientOptions<TAuth = unknown> {
124
154
  decode?: WSDecoder;
125
155
  }
126
156
  /**
127
- * 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.
128
159
  *
129
- * @example
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
+ * ```
167
+ *
168
+ * @example Rooms — the server relays between clients
130
169
  * ```ts
131
170
  * const ws = createWSClient({ url: "/ws", namespace: "org-123" });
132
171
  * const unsub = await ws.subscribe("chat", (msg) => console.log(msg.payload));
@@ -139,7 +178,7 @@ export declare class WSClient<TAuth = unknown> {
139
178
  logger: Logger | null;
140
179
  /**
141
180
  * Nothing connects here — the socket opens on the first `connect()`,
142
- * `subscribe()` or `publish()`.
181
+ * `send()`, `subscribe()` or `publish()`.
143
182
  *
144
183
  * @param options - see {@link WSClientOptions}; every field has a default
145
184
  */
@@ -158,9 +197,15 @@ export declare class WSClient<TAuth = unknown> {
158
197
  get connected(): boolean;
159
198
  /** Current lifecycle state. See {@link WSConnectionState}. */
160
199
  get connectionState(): WSConnectionState;
161
- /** 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
+ */
162
204
  get clientId(): string | null;
163
- /** 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
+ */
164
209
  get namespace(): string;
165
210
  /** Resolved endpoint. A copy — mutating it does not affect the client. */
166
211
  get url(): URL;
@@ -229,6 +274,10 @@ export declare class WSClient<TAuth = unknown> {
229
274
  * Resumable: handlers, room subscriptions and buffered sends all survive,
230
275
  * so a later `connect()` picks up exactly where this left off. Use
231
276
  * {@link dispose} for terminal teardown.
277
+ *
278
+ * Emits `close` with {@link CLOSE.CLIENT_GONE} and `willReconnect: false`
279
+ * when there was a socket to close; nothing when already idle, reconnecting
280
+ * or terminated.
232
281
  */
233
282
  disconnect(): void;
234
283
  /**
@@ -257,7 +306,8 @@ export declare class WSClient<TAuth = unknown> {
257
306
  * @param options - pass `presence` to enable membership tracking
258
307
  * @returns detaches this handler; also `Symbol.dispose`-compatible, and
259
308
  * idempotent, so calling it twice is harmless
260
- * @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
261
311
  * @throws {WSDisposedError} when the client was disposed
262
312
  *
263
313
  * @example
@@ -293,6 +343,73 @@ export declare class WSClient<TAuth = unknown> {
293
343
  * @returns a copy of the members; empty when the room has no presence
294
344
  */
295
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>;
296
413
  /**
297
414
  * Publishes to a room within this client's namespace.
298
415
  *
@@ -309,6 +426,8 @@ export declare class WSClient<TAuth = unknown> {
309
426
  * @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
310
427
  * @throws {WSOutboxDropError} evicted from a full outbox
311
428
  * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
429
+ * @throws {WSTerminatedError} sent after a terminal close, which only an
430
+ * explicit `connect()` recovers from — rejected at once, not buffered
312
431
  * @throws {WSRemoteError} the server rejected it with a `nack`
313
432
  */
314
433
  publish<T = unknown>(room: string, payload: T, namespace?: string): Promise<WSPublishResult>;
@@ -336,14 +455,21 @@ export declare class WSClient<TAuth = unknown> {
336
455
  * @param options - see {@link WSClientOptions}
337
456
  * @returns a client that has not connected yet
338
457
  *
339
- * @example
458
+ * @example Messages
340
459
  * ```ts
341
460
  * const ws = createWSClient({
342
461
  * url: "wss://example.com/ws",
343
- * namespace: "org-123",
344
462
  * auth: () => session.token, // re-read on every reconnect
345
463
  * });
346
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
+ *
347
473
  * await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
348
474
  * await ws.publish("chat", { text: "hello" });
349
475
  * ```