@marianmeres/ws 0.2.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 +161 -0
- package/API.md +672 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/client/backoff.d.ts +25 -0
- package/dist/client/backoff.js +31 -0
- package/dist/client/heartbeat.d.ts +38 -0
- package/dist/client/heartbeat.js +78 -0
- package/dist/client/outbox.d.ts +65 -0
- package/dist/client/outbox.js +155 -0
- package/dist/client/rooms.d.ts +62 -0
- package/dist/client/rooms.js +120 -0
- package/dist/client/ws-client.d.ts +351 -0
- package/dist/client/ws-client.js +892 -0
- package/dist/mod.d.ts +30 -0
- package/dist/mod.js +29 -0
- package/dist/protocol/constants.d.ts +113 -0
- package/dist/protocol/constants.js +118 -0
- package/dist/protocol/errors.d.ts +92 -0
- package/dist/protocol/errors.js +109 -0
- package/dist/protocol/frames.d.ts +151 -0
- package/dist/protocol/frames.js +12 -0
- package/dist/protocol/mod.d.ts +10 -0
- package/dist/protocol/mod.js +10 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.js +9 -0
- package/package.json +39 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebSocket client.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { createClog } from "@marianmeres/clog";
|
|
7
|
+
import { createPubSub } from "@marianmeres/pubsub";
|
|
8
|
+
import { base36 } from "@marianmeres/uid";
|
|
9
|
+
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";
|
|
11
|
+
import { backoffDelay } from "./backoff.js";
|
|
12
|
+
import { Heartbeat } from "./heartbeat.js";
|
|
13
|
+
import { Outbox } from "./outbox.js";
|
|
14
|
+
import { RoomRegistry } from "./rooms.js";
|
|
15
|
+
/**
|
|
16
|
+
* Legal transitions.
|
|
17
|
+
*
|
|
18
|
+
* This exists because the reconnect races are where ad-hoc boolean flags rot:
|
|
19
|
+
* a close arriving mid-`connecting`, a `disconnect()` during backoff, a
|
|
20
|
+
* terminal close while the outbox is flushing. An illegal transition is a bug
|
|
21
|
+
* in this file, so it logs loudly rather than failing quietly.
|
|
22
|
+
*/
|
|
23
|
+
const TRANSITIONS = {
|
|
24
|
+
idle: ["connecting", "disposed"],
|
|
25
|
+
connecting: ["authenticating", "reconnecting", "terminated", "idle", "disposed"],
|
|
26
|
+
authenticating: ["open", "reconnecting", "terminated", "idle", "disposed"],
|
|
27
|
+
open: ["reconnecting", "terminated", "idle", "disposed"],
|
|
28
|
+
reconnecting: ["connecting", "terminated", "idle", "disposed"],
|
|
29
|
+
// A terminal state is still re-entrant via an explicit connect() — the user
|
|
30
|
+
// may have refreshed the credentials that got them rejected.
|
|
31
|
+
terminated: ["connecting", "idle", "disposed"],
|
|
32
|
+
disposed: [],
|
|
33
|
+
};
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
url: "/ws",
|
|
36
|
+
namespace: DEFAULT_NAMESPACE,
|
|
37
|
+
autoConnect: true,
|
|
38
|
+
reconnectDelay: 500,
|
|
39
|
+
reconnectDelayMax: 30_000,
|
|
40
|
+
pingInterval: 25_000,
|
|
41
|
+
pongTimeout: 10_000,
|
|
42
|
+
connectTimeout: 0,
|
|
43
|
+
sendTimeout: 30_000,
|
|
44
|
+
outboxMaxSize: 100,
|
|
45
|
+
};
|
|
46
|
+
const defaultEncode = (frame) => JSON.stringify(frame);
|
|
47
|
+
const defaultDecode = (raw) => JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
|
|
48
|
+
const noop = () => { };
|
|
49
|
+
/** Idempotent, `using`-compatible unsubscriber. */
|
|
50
|
+
function makeUnsubscriber(fn) {
|
|
51
|
+
let done = false;
|
|
52
|
+
const u = (() => {
|
|
53
|
+
if (done)
|
|
54
|
+
return;
|
|
55
|
+
done = true;
|
|
56
|
+
fn();
|
|
57
|
+
});
|
|
58
|
+
u[Symbol.dispose] = u;
|
|
59
|
+
return u;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A reconnecting WebSocket client with namespaces, rooms and presence.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const ws = createWSClient({ url: "/ws", namespace: "org-123" });
|
|
67
|
+
* const unsub = await ws.subscribe("chat", (msg) => console.log(msg.payload));
|
|
68
|
+
* await ws.publish("chat", { text: "hi" });
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export class WSClient {
|
|
72
|
+
#url;
|
|
73
|
+
#requestedNamespace;
|
|
74
|
+
#requestedClientId;
|
|
75
|
+
#authFn;
|
|
76
|
+
#autoConnect;
|
|
77
|
+
#terminalCodes;
|
|
78
|
+
#reconnectDelay;
|
|
79
|
+
#reconnectDelayMax;
|
|
80
|
+
#connectTimeout;
|
|
81
|
+
#pongTimeout;
|
|
82
|
+
#outboxMaxSize;
|
|
83
|
+
#encode;
|
|
84
|
+
#decode;
|
|
85
|
+
/** Logger. Assignable — set to `null` to silence. */
|
|
86
|
+
logger;
|
|
87
|
+
#socket = null;
|
|
88
|
+
/**
|
|
89
|
+
* Guards against events from superseded sockets. Every callback checks its
|
|
90
|
+
* captured generation, so a slow `onclose` from an old socket cannot
|
|
91
|
+
* cancel the reconnect that replaced it.
|
|
92
|
+
*/
|
|
93
|
+
#generation = 0;
|
|
94
|
+
#state = "idle";
|
|
95
|
+
#clientId = null;
|
|
96
|
+
#namespace = null;
|
|
97
|
+
#attempt = 0;
|
|
98
|
+
#lastError = null;
|
|
99
|
+
#intentional = false;
|
|
100
|
+
#rooms = new RoomRegistry();
|
|
101
|
+
#outbox;
|
|
102
|
+
#heartbeat;
|
|
103
|
+
#bus = createPubSub();
|
|
104
|
+
#stateBus = createPubSub();
|
|
105
|
+
#reconnectTimer;
|
|
106
|
+
#handshakeTimer;
|
|
107
|
+
#connectTimer;
|
|
108
|
+
#connectDeferred = null;
|
|
109
|
+
#wakeListeners = [];
|
|
110
|
+
/**
|
|
111
|
+
* Nothing connects here — the socket opens on the first `connect()`,
|
|
112
|
+
* `subscribe()` or `publish()`.
|
|
113
|
+
*
|
|
114
|
+
* @param options - see {@link WSClientOptions}; every field has a default
|
|
115
|
+
*/
|
|
116
|
+
constructor(options = {}) {
|
|
117
|
+
this.logger = options.logger === undefined ? createClog("ws") : options.logger;
|
|
118
|
+
this.#url = WSClient.resolveUrl(options.url ?? DEFAULTS.url);
|
|
119
|
+
this.#requestedNamespace = options.namespace ?? DEFAULTS.namespace;
|
|
120
|
+
this.#requestedClientId = options.clientId;
|
|
121
|
+
this.#authFn = options.auth;
|
|
122
|
+
this.#autoConnect = options.autoConnect ?? DEFAULTS.autoConnect;
|
|
123
|
+
this.#terminalCodes = options.terminalCloseCodes ??
|
|
124
|
+
DEFAULT_TERMINAL_CLOSE_CODES;
|
|
125
|
+
this.#reconnectDelay = options.reconnectDelay ?? DEFAULTS.reconnectDelay;
|
|
126
|
+
this.#reconnectDelayMax = options.reconnectDelayMax ??
|
|
127
|
+
DEFAULTS.reconnectDelayMax;
|
|
128
|
+
this.#connectTimeout = options.connectTimeout ?? DEFAULTS.connectTimeout;
|
|
129
|
+
this.#pongTimeout = options.pongTimeout ?? DEFAULTS.pongTimeout;
|
|
130
|
+
this.#outboxMaxSize = options.outboxMaxSize ?? DEFAULTS.outboxMaxSize;
|
|
131
|
+
this.#encode = options.encode ?? defaultEncode;
|
|
132
|
+
this.#decode = options.decode ?? defaultDecode;
|
|
133
|
+
this.#outbox = new Outbox({
|
|
134
|
+
maxSize: this.#outboxMaxSize,
|
|
135
|
+
sendTimeout: options.sendTimeout ?? DEFAULTS.sendTimeout,
|
|
136
|
+
onDrop: options.onOutboxDrop,
|
|
137
|
+
});
|
|
138
|
+
this.#heartbeat = new Heartbeat({
|
|
139
|
+
interval: options.pingInterval ?? DEFAULTS.pingInterval,
|
|
140
|
+
timeout: options.pongTimeout ?? DEFAULTS.pongTimeout,
|
|
141
|
+
onPing: () => this.#sendRaw({ type: FRAME.PING }),
|
|
142
|
+
onTimeout: () => {
|
|
143
|
+
this.logger?.warn?.("pong timeout — connection is half-open");
|
|
144
|
+
this.#forceClose(CLOSE.IDLE_TIMEOUT, "pong timeout");
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
// Rooms configured up front get a no-op handler; the `message` firehose
|
|
148
|
+
// still fires, so `rooms: [...]` + `on("message")` is a valid style.
|
|
149
|
+
for (const room of options.rooms ?? [])
|
|
150
|
+
this.#rooms.add(room, noop);
|
|
151
|
+
this.#installWakeListeners();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Normalizes an endpoint: relative paths resolve against `location`, and
|
|
155
|
+
* `http(s)` is upgraded to `ws(s)`.
|
|
156
|
+
*
|
|
157
|
+
* @param input - absolute url, or a path when running in a browser
|
|
158
|
+
* @returns the normalized `ws(s)://` url
|
|
159
|
+
* @throws {WSError} when the input cannot be resolved — outside a browser
|
|
160
|
+
* there is no `location` to resolve a relative path against
|
|
161
|
+
*/
|
|
162
|
+
static resolveUrl(input) {
|
|
163
|
+
const base = typeof globalThis.location !== "undefined"
|
|
164
|
+
? globalThis.location.href
|
|
165
|
+
: undefined;
|
|
166
|
+
let url;
|
|
167
|
+
try {
|
|
168
|
+
url = new URL(String(input), base);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new WSError(`Invalid url "${input}". Outside a browser an absolute ws:// or ` +
|
|
172
|
+
`wss:// url is required.`);
|
|
173
|
+
}
|
|
174
|
+
if (url.protocol === "http:")
|
|
175
|
+
url.protocol = "ws:";
|
|
176
|
+
else if (url.protocol === "https:")
|
|
177
|
+
url.protocol = "wss:";
|
|
178
|
+
return url;
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------- getters
|
|
181
|
+
/** `true` only when authenticated and usable — not merely socket-open. */
|
|
182
|
+
get connected() {
|
|
183
|
+
return this.#state === "open";
|
|
184
|
+
}
|
|
185
|
+
/** Current lifecycle state. See {@link WSConnectionState}. */
|
|
186
|
+
get connectionState() {
|
|
187
|
+
return this.#state;
|
|
188
|
+
}
|
|
189
|
+
/** Server-assigned id, available once connected. */
|
|
190
|
+
get clientId() {
|
|
191
|
+
return this.#clientId;
|
|
192
|
+
}
|
|
193
|
+
/** Active namespace — the server's assignment wins over the request. */
|
|
194
|
+
get namespace() {
|
|
195
|
+
return this.#namespace ?? this.#requestedNamespace;
|
|
196
|
+
}
|
|
197
|
+
/** Resolved endpoint. A copy — mutating it does not affect the client. */
|
|
198
|
+
get url() {
|
|
199
|
+
return new URL(this.#url.href);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The underlying socket, or `null` while disconnected.
|
|
203
|
+
*
|
|
204
|
+
* Escape hatch for inspection. Sending on it directly bypasses the outbox
|
|
205
|
+
* and the ack correlation, so don't.
|
|
206
|
+
*/
|
|
207
|
+
get socket() {
|
|
208
|
+
return this.#socket;
|
|
209
|
+
}
|
|
210
|
+
/** Rooms currently subscribed. */
|
|
211
|
+
get rooms() {
|
|
212
|
+
return this.#rooms.rooms;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Reactive state, Svelte-store compatible: the callback fires immediately
|
|
216
|
+
* with the current value and again on every change.
|
|
217
|
+
*/
|
|
218
|
+
get state() {
|
|
219
|
+
return {
|
|
220
|
+
subscribe: (cb) => {
|
|
221
|
+
cb(this.#snapshot());
|
|
222
|
+
return this.#stateBus.subscribe("state", cb);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Debug snapshot: url, state, identity, rooms and outbox counters.
|
|
228
|
+
*
|
|
229
|
+
* For logging and troubleshooting — the shape is not part of the stable API.
|
|
230
|
+
*/
|
|
231
|
+
dump() {
|
|
232
|
+
return {
|
|
233
|
+
url: this.#url.href,
|
|
234
|
+
state: this.#state,
|
|
235
|
+
clientId: this.#clientId,
|
|
236
|
+
namespace: this.namespace,
|
|
237
|
+
rooms: this.#rooms.rooms,
|
|
238
|
+
attempt: this.#attempt,
|
|
239
|
+
pending: this.#outbox.pendingCount,
|
|
240
|
+
queued: this.#outbox.queuedCount,
|
|
241
|
+
dropped: this.#outbox.droppedCount,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// ----------------------------------------------------------------- events
|
|
245
|
+
/**
|
|
246
|
+
* Subscribes to a lifecycle event. See {@link WSEvents}.
|
|
247
|
+
*
|
|
248
|
+
* @param event - event name
|
|
249
|
+
* @param cb - handler; a throw here is caught and reported as `error`
|
|
250
|
+
* @returns detaches the handler; also `Symbol.dispose`-compatible
|
|
251
|
+
*/
|
|
252
|
+
on(event, cb) {
|
|
253
|
+
return this.#bus.subscribe(event, cb);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Like {@link on}, but detaches after the first emission.
|
|
257
|
+
*
|
|
258
|
+
* @param event - event name
|
|
259
|
+
* @param cb - handler
|
|
260
|
+
* @returns detaches the handler early, if it has not fired yet
|
|
261
|
+
*/
|
|
262
|
+
once(event, cb) {
|
|
263
|
+
return this.#bus.subscribeOnce(event, cb);
|
|
264
|
+
}
|
|
265
|
+
// -------------------------------------------------------------- lifecycle
|
|
266
|
+
/**
|
|
267
|
+
* Starts the connection and resolves once it is established.
|
|
268
|
+
*
|
|
269
|
+
* Idempotent: concurrent calls share one promise, and it resolves
|
|
270
|
+
* immediately when already connected.
|
|
271
|
+
*
|
|
272
|
+
* Rejects **only** where retrying cannot help:
|
|
273
|
+
* - {@link WSTerminatedError} — terminal close code (bad credentials, etc.)
|
|
274
|
+
* - {@link WSConnectTimeoutError} — `connectTimeout` elapsed; note the
|
|
275
|
+
* client keeps retrying in the background, so this bounds *your await*,
|
|
276
|
+
* not the connection attempt
|
|
277
|
+
*
|
|
278
|
+
* Ordinary network failure never rejects; that is what the infinite retry
|
|
279
|
+
* is for.
|
|
280
|
+
*
|
|
281
|
+
* Calling this is optional when `autoConnect` is on — it is a readiness
|
|
282
|
+
* gate, not a prerequisite.
|
|
283
|
+
*
|
|
284
|
+
* @returns resolves once authenticated
|
|
285
|
+
*/
|
|
286
|
+
connect() {
|
|
287
|
+
if (this.#state === "disposed") {
|
|
288
|
+
return Promise.reject(new WSDisposedError());
|
|
289
|
+
}
|
|
290
|
+
if (this.#state === "open")
|
|
291
|
+
return Promise.resolve();
|
|
292
|
+
if (this.#connectDeferred)
|
|
293
|
+
return this.#connectDeferred.promise;
|
|
294
|
+
let resolve;
|
|
295
|
+
let reject;
|
|
296
|
+
const promise = new Promise((res, rej) => {
|
|
297
|
+
resolve = res;
|
|
298
|
+
reject = rej;
|
|
299
|
+
});
|
|
300
|
+
this.#connectDeferred = { promise, resolve, reject };
|
|
301
|
+
if (this.#connectTimeout > 0) {
|
|
302
|
+
this.#connectTimer = setTimeout(() => {
|
|
303
|
+
this.#settleConnect(new WSConnectTimeoutError(this.#connectTimeout));
|
|
304
|
+
}, this.#connectTimeout);
|
|
305
|
+
}
|
|
306
|
+
this.#intentional = false;
|
|
307
|
+
if (this.#state === "idle" || this.#state === "terminated")
|
|
308
|
+
this.#open();
|
|
309
|
+
return promise;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Stops retrying and closes the socket.
|
|
313
|
+
*
|
|
314
|
+
* Resumable: handlers, room subscriptions and buffered sends all survive,
|
|
315
|
+
* so a later `connect()` picks up exactly where this left off. Use
|
|
316
|
+
* {@link dispose} for terminal teardown.
|
|
317
|
+
*/
|
|
318
|
+
disconnect() {
|
|
319
|
+
if (this.#state === "disposed")
|
|
320
|
+
return;
|
|
321
|
+
this.logger?.debug?.("disconnect()");
|
|
322
|
+
this.#intentional = true;
|
|
323
|
+
this.#clearTimers();
|
|
324
|
+
this.#heartbeat.stop();
|
|
325
|
+
this.#settleConnect(new WSTerminatedError(CLOSE.CLIENT_GONE, "disconnect() called"));
|
|
326
|
+
this.#closeSocket(CLOSE.CLIENT_GONE, "client disconnect");
|
|
327
|
+
this.#setState("idle");
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Terminal teardown: disconnects, then drops every handler, room, timer and
|
|
331
|
+
* pending promise. The instance is unusable afterwards.
|
|
332
|
+
*/
|
|
333
|
+
dispose() {
|
|
334
|
+
if (this.#state === "disposed")
|
|
335
|
+
return;
|
|
336
|
+
this.logger?.debug?.("dispose()");
|
|
337
|
+
this.disconnect();
|
|
338
|
+
this.#outbox.failAll(new WSDisposedError());
|
|
339
|
+
this.#rooms.clear();
|
|
340
|
+
this.#bus.unsubscribeAll();
|
|
341
|
+
this.#stateBus.unsubscribeAll();
|
|
342
|
+
this.#removeWakeListeners();
|
|
343
|
+
this.#setState("disposed");
|
|
344
|
+
}
|
|
345
|
+
// ---------------------------------------------------------- subscriptions
|
|
346
|
+
/**
|
|
347
|
+
* Subscribes to a room and attaches a handler.
|
|
348
|
+
*
|
|
349
|
+
* The handler is attached **synchronously**, before any frame goes out, so
|
|
350
|
+
* nothing arriving between the request and its acknowledgement is lost.
|
|
351
|
+
*
|
|
352
|
+
* Rooms are refcounted: N handlers produce one wire subscription, and the
|
|
353
|
+
* returned unsubscriber detaches this handler — sending `unsub` only when
|
|
354
|
+
* it was the last one.
|
|
355
|
+
*
|
|
356
|
+
* Resolution: when connected, this awaits the server's acknowledgement, so
|
|
357
|
+
* a rejected subscription surfaces as a rejection here. When not connected
|
|
358
|
+
* it resolves as soon as the room is registered — the subscription is then
|
|
359
|
+
* guaranteed to be established by the re-subscribe step on the next
|
|
360
|
+
* connect, and a failure there surfaces as an `error` event.
|
|
361
|
+
*
|
|
362
|
+
* @param room - room name, scoped to this client's namespace
|
|
363
|
+
* @param handler - receives every message published to the room
|
|
364
|
+
* @param options - pass `presence` to enable membership tracking
|
|
365
|
+
* @returns detaches this handler; also `Symbol.dispose`-compatible, and
|
|
366
|
+
* idempotent, so calling it twice is harmless
|
|
367
|
+
* @throws {WSRemoteError} when connected and the server refuses
|
|
368
|
+
* @throws {WSDisposedError} when the client was disposed
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* ```ts
|
|
372
|
+
* const unsub = await ws.subscribe("chat", (msg) => render(msg.payload), {
|
|
373
|
+
* presence: (e) => setMembers(e.members),
|
|
374
|
+
* });
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
async subscribe(room, handler, options) {
|
|
378
|
+
this.#assertUsable();
|
|
379
|
+
const messageHandler = handler;
|
|
380
|
+
const presenceHandler = options?.presence;
|
|
381
|
+
const { created, presenceUpgraded } = this.#rooms.add(room, messageHandler, presenceHandler);
|
|
382
|
+
const unsubscriber = makeUnsubscriber(() => {
|
|
383
|
+
const emptied = this.#rooms.remove(room, messageHandler, presenceHandler);
|
|
384
|
+
if (emptied && this.connected) {
|
|
385
|
+
this.#sendControl({
|
|
386
|
+
type: FRAME.UNSUB,
|
|
387
|
+
id: this.#nextId(),
|
|
388
|
+
rooms: [room],
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
if (this.#autoConnect)
|
|
393
|
+
this.#ensureStarted();
|
|
394
|
+
if ((created || presenceUpgraded) && this.connected) {
|
|
395
|
+
try {
|
|
396
|
+
await this.#sendControl({
|
|
397
|
+
type: FRAME.SUB,
|
|
398
|
+
id: this.#nextId(),
|
|
399
|
+
rooms: [{ room, presence: this.#rooms.wantsPresence(room) }],
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
// Keep local state honest: if the server refused, we are not
|
|
404
|
+
// subscribed, and pretending otherwise would silently swallow
|
|
405
|
+
// every message the caller expects.
|
|
406
|
+
this.#rooms.remove(room, messageHandler, presenceHandler);
|
|
407
|
+
throw e;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return unsubscriber;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Removes every handler for a room and unsubscribes it.
|
|
414
|
+
*
|
|
415
|
+
* The blunt counterpart to the refcounted unsubscriber returned by
|
|
416
|
+
* {@link subscribe} — this drops other call sites' handlers too.
|
|
417
|
+
*
|
|
418
|
+
* @param room - room name; unknown rooms are a no-op
|
|
419
|
+
*/
|
|
420
|
+
async unsubscribe(room) {
|
|
421
|
+
this.#assertUsable();
|
|
422
|
+
if (!this.#rooms.removeRoom(room))
|
|
423
|
+
return;
|
|
424
|
+
if (this.connected) {
|
|
425
|
+
await this.#sendControl({
|
|
426
|
+
type: FRAME.UNSUB,
|
|
427
|
+
id: this.#nextId(),
|
|
428
|
+
rooms: [room],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Whether the room is held locally.
|
|
434
|
+
*
|
|
435
|
+
* Reflects local intent, not server state: a room registered while offline
|
|
436
|
+
* reads `true` before the wire subscription exists.
|
|
437
|
+
*
|
|
438
|
+
* @param room - room name
|
|
439
|
+
*/
|
|
440
|
+
isSubscribed(room) {
|
|
441
|
+
return this.#rooms.has(room);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Last known membership of a presence-enabled room.
|
|
445
|
+
*
|
|
446
|
+
* @param room - room name
|
|
447
|
+
* @returns a copy of the members; empty when the room has no presence
|
|
448
|
+
*/
|
|
449
|
+
members(room) {
|
|
450
|
+
return this.#rooms.members(room);
|
|
451
|
+
}
|
|
452
|
+
// --------------------------------------------------------------- sending
|
|
453
|
+
/**
|
|
454
|
+
* Publishes to a room within this client's namespace.
|
|
455
|
+
*
|
|
456
|
+
* Resolves with the recipient count once the server acknowledges. While
|
|
457
|
+
* disconnected the frame is buffered and the promise stays pending until it
|
|
458
|
+
* flushes — bounded by `sendTimeout`, never indefinitely.
|
|
459
|
+
*
|
|
460
|
+
* @param room - target room
|
|
461
|
+
* @param payload - opaque application data; never inspected or mutated
|
|
462
|
+
* @param namespace - must equal this client's namespace; the server rejects
|
|
463
|
+
* anything else, so this is only useful for asserting the expected one
|
|
464
|
+
* @returns the recipient count reported by the receiving server instance —
|
|
465
|
+
* best-effort telemetry, not a delivery guarantee
|
|
466
|
+
* @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
|
|
467
|
+
* @throws {WSOutboxDropError} evicted from a full outbox
|
|
468
|
+
* @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
|
|
469
|
+
* @throws {WSRemoteError} the server rejected it with a `nack`
|
|
470
|
+
*/
|
|
471
|
+
publish(room, payload, namespace) {
|
|
472
|
+
this.#assertUsable();
|
|
473
|
+
const id = this.#nextId();
|
|
474
|
+
return this.#send({
|
|
475
|
+
type: FRAME.PUB,
|
|
476
|
+
id,
|
|
477
|
+
room,
|
|
478
|
+
payload,
|
|
479
|
+
...(namespace ? { namespace } : {}),
|
|
480
|
+
}, id);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Publishes to a room across **all** namespaces.
|
|
484
|
+
*
|
|
485
|
+
* This crosses the isolation boundary, which is why it is its own method
|
|
486
|
+
* rather than a flag on {@link publish} — the server gates it separately
|
|
487
|
+
* via `allowBroadcast`, and it denies by default.
|
|
488
|
+
*
|
|
489
|
+
* @param room - target room, in every namespace at once
|
|
490
|
+
* @param payload - opaque application data
|
|
491
|
+
* @returns the recipient count across all namespaces on the receiving
|
|
492
|
+
* server instance
|
|
493
|
+
* @throws {WSRemoteError} with code `forbidden` when `allowBroadcast` denies
|
|
494
|
+
*/
|
|
495
|
+
broadcast(room, payload) {
|
|
496
|
+
this.#assertUsable();
|
|
497
|
+
const id = this.#nextId();
|
|
498
|
+
return this.#send({ type: FRAME.BROADCAST, id, room, payload }, id);
|
|
499
|
+
}
|
|
500
|
+
// -------------------------------------------------------------- internals
|
|
501
|
+
#nextId() {
|
|
502
|
+
return base36(12);
|
|
503
|
+
}
|
|
504
|
+
#assertUsable() {
|
|
505
|
+
if (this.#state === "disposed")
|
|
506
|
+
throw new WSDisposedError();
|
|
507
|
+
}
|
|
508
|
+
#ensureStarted() {
|
|
509
|
+
if (this.#state === "idle")
|
|
510
|
+
this.#open();
|
|
511
|
+
}
|
|
512
|
+
#send(frame, id) {
|
|
513
|
+
if (this.#autoConnect)
|
|
514
|
+
this.#ensureStarted();
|
|
515
|
+
const canSendNow = this.connected &&
|
|
516
|
+
this.#socket?.readyState === WebSocket.OPEN;
|
|
517
|
+
if (!canSendNow && this.#outboxMaxSize === 0) {
|
|
518
|
+
return Promise.reject(new WSNotConnectedError());
|
|
519
|
+
}
|
|
520
|
+
const promise = this.#outbox.track(id, frame, !canSendNow);
|
|
521
|
+
if (canSendNow)
|
|
522
|
+
this.#sendRaw(frame);
|
|
523
|
+
return promise;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Sends a control frame that must never be buffered.
|
|
527
|
+
*
|
|
528
|
+
* `sub`/`unsub` bypass the outbox deliberately: they are replayed wholesale
|
|
529
|
+
* by the re-subscribe step on reconnect, so queueing them too would apply
|
|
530
|
+
* them twice.
|
|
531
|
+
*/
|
|
532
|
+
#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);
|
|
536
|
+
return promise;
|
|
537
|
+
}
|
|
538
|
+
#sendRaw(frame) {
|
|
539
|
+
const socket = this.#socket;
|
|
540
|
+
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
541
|
+
return;
|
|
542
|
+
try {
|
|
543
|
+
socket.send(this.#encode(frame));
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
this.#fail(e, "send failed");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
#open() {
|
|
550
|
+
// Defensive: every caller already guards this, but a second socket
|
|
551
|
+
// opened from a race here would leak the first one silently.
|
|
552
|
+
if (this.#state === "connecting" ||
|
|
553
|
+
this.#state === "authenticating" ||
|
|
554
|
+
this.#state === "open") {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (!this.#setState("connecting"))
|
|
558
|
+
return;
|
|
559
|
+
this.#intentional = false;
|
|
560
|
+
const generation = ++this.#generation;
|
|
561
|
+
let socket;
|
|
562
|
+
try {
|
|
563
|
+
socket = new WebSocket(this.#url);
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
this.#fail(e, "socket construction failed");
|
|
567
|
+
this.#scheduleReconnect();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
this.#socket = socket;
|
|
571
|
+
this.logger?.debug?.(`connecting to ${this.#url.href}`);
|
|
572
|
+
socket.onopen = () => {
|
|
573
|
+
if (generation !== this.#generation)
|
|
574
|
+
return;
|
|
575
|
+
void this.#onOpen(generation);
|
|
576
|
+
};
|
|
577
|
+
socket.onmessage = (event) => {
|
|
578
|
+
if (generation !== this.#generation)
|
|
579
|
+
return;
|
|
580
|
+
this.#onMessage(event.data);
|
|
581
|
+
};
|
|
582
|
+
socket.onerror = () => {
|
|
583
|
+
if (generation !== this.#generation)
|
|
584
|
+
return;
|
|
585
|
+
// The browser deliberately withholds detail here; onclose carries
|
|
586
|
+
// the actionable information, so this is informational only.
|
|
587
|
+
this.logger?.debug?.("socket error");
|
|
588
|
+
};
|
|
589
|
+
socket.onclose = (event) => {
|
|
590
|
+
if (generation !== this.#generation)
|
|
591
|
+
return;
|
|
592
|
+
this.#onClose(event.code, event.reason);
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async #onOpen(generation) {
|
|
596
|
+
this.#emit("open", undefined);
|
|
597
|
+
if (!this.#setState("authenticating"))
|
|
598
|
+
return;
|
|
599
|
+
let payload = null;
|
|
600
|
+
try {
|
|
601
|
+
payload = (await this.#authFn?.()) ?? null;
|
|
602
|
+
}
|
|
603
|
+
catch (e) {
|
|
604
|
+
this.#fail(e, "auth payload failed");
|
|
605
|
+
this.#forceClose(CLOSE.PROTOCOL_ERROR, "auth payload failed");
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// The await above yields; a close may have superseded this socket.
|
|
609
|
+
if (generation !== this.#generation)
|
|
610
|
+
return;
|
|
611
|
+
this.#sendRaw({
|
|
612
|
+
type: FRAME.AUTH,
|
|
613
|
+
id: this.#nextId(),
|
|
614
|
+
protocol: PROTOCOL_VERSION,
|
|
615
|
+
payload,
|
|
616
|
+
...(this.#requestedClientId ? { clientId: this.#requestedClientId } : {}),
|
|
617
|
+
namespace: this.#requestedNamespace,
|
|
618
|
+
});
|
|
619
|
+
// Without this, a server that accepts the socket then never replies
|
|
620
|
+
// leaves us in `authenticating` forever — the heartbeat has not started
|
|
621
|
+
// yet, so nothing else would notice.
|
|
622
|
+
this.#handshakeTimer = setTimeout(() => {
|
|
623
|
+
this.logger?.warn?.("handshake timeout");
|
|
624
|
+
this.#forceClose(CLOSE.AUTH_TIMEOUT, "handshake timeout");
|
|
625
|
+
}, this.#pongTimeout);
|
|
626
|
+
}
|
|
627
|
+
#onMessage(raw) {
|
|
628
|
+
// Any inbound traffic proves the socket is alive, not just a pong.
|
|
629
|
+
this.#heartbeat.alive();
|
|
630
|
+
let frame;
|
|
631
|
+
try {
|
|
632
|
+
frame = this.#decode(raw);
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
this.#fail(e, "decode failed");
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (!frame || typeof frame.type !== "string") {
|
|
639
|
+
this.logger?.warn?.("ignoring frame without a type", frame);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
switch (frame.type) {
|
|
643
|
+
case FRAME.HELLO:
|
|
644
|
+
this.#onHello(frame.clientId, frame.namespace, frame.protocol);
|
|
645
|
+
break;
|
|
646
|
+
case FRAME.ACK:
|
|
647
|
+
this.#outbox.settle(frame.id, frame.recipients ?? 0);
|
|
648
|
+
break;
|
|
649
|
+
case FRAME.NACK:
|
|
650
|
+
this.#outbox.fail(frame.id, new WSRemoteError(frame.error));
|
|
651
|
+
break;
|
|
652
|
+
case FRAME.MSG: {
|
|
653
|
+
const { type: _t, ...message } = frame;
|
|
654
|
+
this.#emit("message", message);
|
|
655
|
+
this.#rooms.deliver(message.room, message, (e) => this.#fail(e, "message handler threw"));
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
case FRAME.PRESENCE: {
|
|
659
|
+
const { type: _t, ...event } = frame;
|
|
660
|
+
this.#emit("presence", event);
|
|
661
|
+
this.#rooms.deliverPresence(event.room, event, (e) => this.#fail(e, "presence handler threw"));
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
case FRAME.PONG:
|
|
665
|
+
break;
|
|
666
|
+
case FRAME.ERROR:
|
|
667
|
+
this.#fail(new WSRemoteError(frame.error), "server error");
|
|
668
|
+
break;
|
|
669
|
+
default:
|
|
670
|
+
this.logger?.warn?.("ignoring unknown frame type", frame);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
#onHello(clientId, namespace, protocol) {
|
|
674
|
+
clearTimeout(this.#handshakeTimer);
|
|
675
|
+
this.#handshakeTimer = undefined;
|
|
676
|
+
if (protocol !== PROTOCOL_VERSION) {
|
|
677
|
+
this.logger?.warn?.(`protocol version mismatch (client ${PROTOCOL_VERSION}, server ${protocol})`);
|
|
678
|
+
}
|
|
679
|
+
this.#clientId = clientId;
|
|
680
|
+
this.#namespace = namespace;
|
|
681
|
+
this.#attempt = 0;
|
|
682
|
+
this.#lastError = null;
|
|
683
|
+
if (!this.#setState("open"))
|
|
684
|
+
return;
|
|
685
|
+
this.logger?.debug?.(`connected as ${clientId} in "${namespace}"`);
|
|
686
|
+
this.#emit("connected", { clientId, namespace });
|
|
687
|
+
this.#heartbeat.start();
|
|
688
|
+
this.#settleConnect(null);
|
|
689
|
+
// Order matters and is not cosmetic: re-subscribe first, then flush.
|
|
690
|
+
// The socket preserves ordering, so the server registers the rooms
|
|
691
|
+
// before it sees any buffered publish destined for them.
|
|
692
|
+
const requests = this.#rooms.subRequests();
|
|
693
|
+
if (requests.length) {
|
|
694
|
+
this.#sendControl({
|
|
695
|
+
type: FRAME.SUB,
|
|
696
|
+
id: this.#nextId(),
|
|
697
|
+
rooms: requests,
|
|
698
|
+
}).catch((e) => this.#fail(e, "re-subscribe failed"));
|
|
699
|
+
}
|
|
700
|
+
const buffered = this.#outbox.drain();
|
|
701
|
+
if (buffered.length) {
|
|
702
|
+
this.logger?.debug?.(`flushing ${buffered.length} buffered frame(s)`);
|
|
703
|
+
for (const frame of buffered)
|
|
704
|
+
this.#sendRaw(frame);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
#onClose(code, reason) {
|
|
708
|
+
clearTimeout(this.#handshakeTimer);
|
|
709
|
+
this.#handshakeTimer = undefined;
|
|
710
|
+
this.#heartbeat.stop();
|
|
711
|
+
this.#socket = null;
|
|
712
|
+
const terminal = this.#terminalCodes.includes(code);
|
|
713
|
+
const willReconnect = !this.#intentional && !terminal &&
|
|
714
|
+
this.#state !== "disposed";
|
|
715
|
+
this.logger?.debug?.(`closed (${code}${reason ? ` ${reason}` : ""}), reconnect=${willReconnect}`);
|
|
716
|
+
this.#emit("close", { code, reason, willReconnect });
|
|
717
|
+
if (terminal) {
|
|
718
|
+
this.#setState("terminated");
|
|
719
|
+
const error = new WSTerminatedError(code, reason);
|
|
720
|
+
this.#lastError = error;
|
|
721
|
+
// Loud on purpose: this is the only path where a client that
|
|
722
|
+
// otherwise retries forever gives up, and a silent one looks
|
|
723
|
+
// exactly like a network that never came back.
|
|
724
|
+
this.logger?.error?.(error.message);
|
|
725
|
+
this.#outbox.failAll(error);
|
|
726
|
+
this.#settleConnect(error);
|
|
727
|
+
this.#emit("terminated", { code, reason });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (!willReconnect) {
|
|
731
|
+
this.#setState("idle");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.#scheduleReconnect();
|
|
735
|
+
}
|
|
736
|
+
#scheduleReconnect() {
|
|
737
|
+
if (!this.#setState("reconnecting"))
|
|
738
|
+
return;
|
|
739
|
+
this.#attempt++;
|
|
740
|
+
const delay = backoffDelay(this.#attempt, this.#reconnectDelay, this.#reconnectDelayMax);
|
|
741
|
+
this.logger?.debug?.(`reconnecting in ${delay}ms (attempt ${this.#attempt})`);
|
|
742
|
+
this.#emit("reconnecting", { attempt: this.#attempt, delay });
|
|
743
|
+
this.#publishState();
|
|
744
|
+
clearTimeout(this.#reconnectTimer);
|
|
745
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
746
|
+
this.#reconnectTimer = undefined;
|
|
747
|
+
this.#open();
|
|
748
|
+
}, delay);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Reconnect right now instead of waiting out the backoff.
|
|
752
|
+
*
|
|
753
|
+
* Matters more than backoff tuning: a laptop waking from sleep should be
|
|
754
|
+
* back in milliseconds, not sit out a 30s timer it started before sleeping.
|
|
755
|
+
*/
|
|
756
|
+
#wake(source) {
|
|
757
|
+
if (this.#state !== "reconnecting")
|
|
758
|
+
return;
|
|
759
|
+
this.logger?.debug?.(`${source} — retrying immediately`);
|
|
760
|
+
clearTimeout(this.#reconnectTimer);
|
|
761
|
+
this.#reconnectTimer = undefined;
|
|
762
|
+
this.#attempt = 0;
|
|
763
|
+
this.#open();
|
|
764
|
+
}
|
|
765
|
+
#installWakeListeners() {
|
|
766
|
+
const target = globalThis;
|
|
767
|
+
if (typeof target.addEventListener !== "function")
|
|
768
|
+
return;
|
|
769
|
+
const onOnline = () => this.#wake("online");
|
|
770
|
+
const onVisible = () => {
|
|
771
|
+
if (target.document?.visibilityState === "visible") {
|
|
772
|
+
this.#wake("tab visible");
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
target.addEventListener("online", onOnline);
|
|
776
|
+
this.#wakeListeners.push(() => target.removeEventListener?.("online", onOnline));
|
|
777
|
+
if (target.document) {
|
|
778
|
+
target.addEventListener("visibilitychange", onVisible);
|
|
779
|
+
this.#wakeListeners.push(() => target.removeEventListener?.("visibilitychange", onVisible));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
#removeWakeListeners() {
|
|
783
|
+
for (const remove of this.#wakeListeners)
|
|
784
|
+
remove();
|
|
785
|
+
this.#wakeListeners = [];
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Closes a socket we have decided is dead, and drives the close transition
|
|
789
|
+
* ourselves.
|
|
790
|
+
*
|
|
791
|
+
* `#closeSocket` supersedes the generation, so the real `onclose` — if it
|
|
792
|
+
* arrives at all, which for a half-open socket it may not — is ignored.
|
|
793
|
+
* That is the point: waiting for it is exactly the hang we are escaping.
|
|
794
|
+
*/
|
|
795
|
+
#forceClose(code, reason) {
|
|
796
|
+
this.#closeSocket(code, reason);
|
|
797
|
+
this.#onClose(code, reason);
|
|
798
|
+
}
|
|
799
|
+
#closeSocket(code, reason) {
|
|
800
|
+
const socket = this.#socket;
|
|
801
|
+
this.#socket = null;
|
|
802
|
+
// Supersede: any late callback from this socket is now ignored.
|
|
803
|
+
this.#generation++;
|
|
804
|
+
if (!socket)
|
|
805
|
+
return;
|
|
806
|
+
try {
|
|
807
|
+
if (socket.readyState === WebSocket.OPEN ||
|
|
808
|
+
socket.readyState === WebSocket.CONNECTING) {
|
|
809
|
+
socket.close(code, reason);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// Closing an already-dead socket is not worth reporting.
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
#clearTimers() {
|
|
817
|
+
clearTimeout(this.#reconnectTimer);
|
|
818
|
+
clearTimeout(this.#handshakeTimer);
|
|
819
|
+
this.#reconnectTimer = undefined;
|
|
820
|
+
this.#handshakeTimer = undefined;
|
|
821
|
+
}
|
|
822
|
+
#settleConnect(error) {
|
|
823
|
+
clearTimeout(this.#connectTimer);
|
|
824
|
+
this.#connectTimer = undefined;
|
|
825
|
+
const deferred = this.#connectDeferred;
|
|
826
|
+
if (!deferred)
|
|
827
|
+
return;
|
|
828
|
+
this.#connectDeferred = null;
|
|
829
|
+
if (error)
|
|
830
|
+
deferred.reject(error);
|
|
831
|
+
else
|
|
832
|
+
deferred.resolve();
|
|
833
|
+
}
|
|
834
|
+
#setState(next) {
|
|
835
|
+
if (this.#state === next)
|
|
836
|
+
return true;
|
|
837
|
+
if (!TRANSITIONS[this.#state].includes(next)) {
|
|
838
|
+
this.logger?.warn?.(`ignoring illegal state transition ${this.#state} -> ${next}`);
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
this.#state = next;
|
|
842
|
+
this.#publishState();
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
#snapshot() {
|
|
846
|
+
return {
|
|
847
|
+
state: this.#state,
|
|
848
|
+
connected: this.#state === "open",
|
|
849
|
+
connecting: this.#state === "connecting" ||
|
|
850
|
+
this.#state === "authenticating" ||
|
|
851
|
+
this.#state === "reconnecting",
|
|
852
|
+
attempt: this.#attempt,
|
|
853
|
+
lastError: this.#lastError,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
#publishState() {
|
|
857
|
+
this.#stateBus.publish("state", this.#snapshot());
|
|
858
|
+
}
|
|
859
|
+
#emit(event, data) {
|
|
860
|
+
this.#bus.publish(event, data);
|
|
861
|
+
}
|
|
862
|
+
#fail(error, context) {
|
|
863
|
+
const err = error instanceof Error ? error : new WSError(String(error));
|
|
864
|
+
this.#lastError = err;
|
|
865
|
+
this.logger?.error?.(`${context}: ${err.message}`);
|
|
866
|
+
this.#emit("error", err);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Creates a {@link WSClient}.
|
|
871
|
+
*
|
|
872
|
+
* Both this and the class are exported, following the `PubSub` /
|
|
873
|
+
* `createPubSub` precedent in `@marianmeres/pubsub`.
|
|
874
|
+
*
|
|
875
|
+
* @param options - see {@link WSClientOptions}
|
|
876
|
+
* @returns a client that has not connected yet
|
|
877
|
+
*
|
|
878
|
+
* @example
|
|
879
|
+
* ```ts
|
|
880
|
+
* const ws = createWSClient({
|
|
881
|
+
* url: "wss://example.com/ws",
|
|
882
|
+
* namespace: "org-123",
|
|
883
|
+
* auth: () => session.token, // re-read on every reconnect
|
|
884
|
+
* });
|
|
885
|
+
*
|
|
886
|
+
* await ws.subscribe("chat", (msg) => console.log(msg.from, msg.payload));
|
|
887
|
+
* await ws.publish("chat", { text: "hello" });
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
890
|
+
export function createWSClient(options = {}) {
|
|
891
|
+
return new WSClient(options);
|
|
892
|
+
}
|