@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.
- package/AGENTS.md +63 -15
- package/API.md +232 -61
- package/README.md +176 -80
- package/dist/client/outbox.d.ts +40 -8
- package/dist/client/outbox.js +35 -11
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +132 -15
- package/dist/client/ws-client.js +101 -36
- 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 +12 -2
- package/dist/protocol/errors.js +13 -3
- package/dist/protocol/frames.d.ts +58 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
* ```
|
package/dist/client/ws-client.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
|
796
|
-
*
|
|
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
|
|
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
|
*
|