react-websocket 3.2.0 → 3.3.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/dist/core.cjs CHANGED
@@ -1,783 +1,1037 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/core.ts
21
- var core_exports = {};
22
- __export(core_exports, {
23
- MessageError: () => MessageError,
24
- createWebSocket: () => createWebSocket,
25
- createWebSocketLatency: () => createWebSocketLatency,
26
- createWebSocketRequests: () => createWebSocketRequests,
27
- createWebSocketState: () => createWebSocketState,
28
- createWebSocketTopics: () => createWebSocketTopics
29
- });
30
- module.exports = __toCommonJS(core_exports);
31
-
32
- // src/types.ts
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/types.ts
3
+ /**
4
+ * Reported via the `error` event (and `onError`) when an incoming message can't be
5
+ * decoded by `parse`/`json` or fails `schema` validation. The message is not delivered.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * onError(error) {
10
+ * if (error instanceof MessageError) report(error.data, error.issues);
11
+ * }
12
+ * ```
13
+ */
33
14
  var MessageError = class extends Error {
34
- /** The raw `event.data` that failed. */
35
- data;
36
- /** Schema validation issues, when the message failed `schema`. */
37
- issues;
38
- constructor(message, data, options) {
39
- super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
40
- this.name = "MessageError";
41
- this.data = data;
42
- this.issues = options?.issues;
43
- }
15
+ /** The raw `event.data` that failed. */
16
+ data;
17
+ /** Schema validation issues, when the message failed `schema`. */
18
+ issues;
19
+ constructor(message, data, options) {
20
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
21
+ this.name = "MessageError";
22
+ this.data = data;
23
+ this.issues = options?.issues;
24
+ }
44
25
  };
45
-
46
- // src/client.ts
47
- var OPEN = 1;
48
- var RECONNECT_DEFAULTS = {
49
- maxAttempts: Infinity,
50
- minDelay: 1e3,
51
- maxDelay: 3e4,
52
- factor: 2,
53
- jitter: 0.5
26
+ //#endregion
27
+ //#region src/client.ts
28
+ const OPEN = 1;
29
+ const RECONNECT_DEFAULTS = {
30
+ maxAttempts: Infinity,
31
+ minDelay: 1e3,
32
+ maxDelay: 3e4,
33
+ factor: 2,
34
+ jitter: .5
54
35
  };
36
+ const READY_GRACE = 5e3;
55
37
  function createDeferred() {
56
- let resolve;
57
- let reject;
58
- const promise = new Promise((res, rej) => {
59
- resolve = res;
60
- reject = rej;
61
- });
62
- promise.status = "pending";
63
- promise.catch(() => {
64
- });
65
- const deferred = {
66
- promise,
67
- settled: false,
68
- resolve() {
69
- if (deferred.settled) return;
70
- deferred.settled = true;
71
- promise.status = "fulfilled";
72
- promise.value = void 0;
73
- resolve();
74
- },
75
- reject(reason) {
76
- if (deferred.settled) return;
77
- deferred.settled = true;
78
- promise.status = "rejected";
79
- promise.reason = reason;
80
- reject(reason);
81
- }
82
- };
83
- return deferred;
38
+ let resolve;
39
+ let reject;
40
+ const promise = new Promise((res, rej) => {
41
+ resolve = res;
42
+ reject = rej;
43
+ });
44
+ promise.status = "pending";
45
+ promise.catch(() => {});
46
+ const deferred = {
47
+ promise,
48
+ settled: false,
49
+ resolve() {
50
+ if (deferred.settled) return;
51
+ deferred.settled = true;
52
+ promise.status = "fulfilled";
53
+ promise.value = void 0;
54
+ resolve();
55
+ },
56
+ reject(reason) {
57
+ if (deferred.settled) return;
58
+ deferred.settled = true;
59
+ promise.status = "rejected";
60
+ promise.reason = reason;
61
+ reject(reason);
62
+ }
63
+ };
64
+ return deferred;
84
65
  }
85
66
  function rethrowAsync(error) {
86
- queueMicrotask(() => {
87
- throw error;
88
- });
67
+ queueMicrotask(() => {
68
+ throw error;
69
+ });
89
70
  }
90
71
  function isThenable(value) {
91
- return typeof value?.then === "function";
72
+ return typeof value?.then === "function";
92
73
  }
93
- var ON_TEARDOWN = /* @__PURE__ */ Symbol.for("react-websocket.onTeardown");
74
+ /**
75
+ * @internal Option key for a callback run when the client tears down, which lets the hooks'
76
+ * URL registry forget clients nobody holds anymore. A registered symbol rather than an export,
77
+ * because the hooks entry imports the client from the `core` entry at runtime.
78
+ */
79
+ const ON_TEARDOWN = Symbol.for("react-websocket.onTeardown");
94
80
  function createClient(url, options = {}) {
95
- const onTeardown = options[ON_TEARDOWN];
96
- const log = options.logger ?? (() => {
97
- });
98
- const retry = options.reconnect === false ? null : {
99
- ...RECONNECT_DEFAULTS,
100
- ...typeof options.reconnect === "object" ? options.reconnect : {}
101
- };
102
- const queueLimit = options.queue === false ? 0 : typeof options.queue === "object" ? Math.max(0, options.queue.limit ?? Infinity) : Infinity;
103
- let snapshot = { status: "idle", lastMessage: void 0, attempt: 0 };
104
- const storeListeners = /* @__PURE__ */ new Set();
105
- const listeners = {
106
- open: /* @__PURE__ */ new Set(),
107
- message: /* @__PURE__ */ new Set(),
108
- close: /* @__PURE__ */ new Set(),
109
- error: /* @__PURE__ */ new Set()
110
- };
111
- let ws = null;
112
- let wanted = false;
113
- let refs = 0;
114
- let pinned = false;
115
- let pending = [];
116
- let ready = null;
117
- let opened = false;
118
- let unavailable = null;
119
- let startScheduled = false;
120
- let inbox = null;
121
- let generation = 0;
122
- let windowBound = false;
123
- let retryTimer;
124
- let teardownTimer;
125
- let heartbeatTimer;
126
- let pongTimer;
127
- function set(patch) {
128
- snapshot = { ...snapshot, ...patch };
129
- for (const listener of [...storeListeners]) listener();
130
- }
131
- function emit(type, ...args) {
132
- for (const listener of [...listeners[type]]) {
133
- try {
134
- listener(...args);
135
- } catch (error) {
136
- rethrowAsync(error);
137
- }
138
- }
139
- }
140
- function getImpl() {
141
- if (options.WebSocket) return options.WebSocket;
142
- return typeof window === "undefined" ? void 0 : globalThis.WebSocket;
143
- }
144
- function rejectReady(reason) {
145
- if (!ready || ready.settled) ready = createDeferred();
146
- ready.reject(reason);
147
- }
148
- function retryNow() {
149
- if (retryTimer !== void 0) {
150
- log("network or visibility change, retrying now");
151
- open();
152
- }
153
- }
154
- function onVisibilityChange() {
155
- if (document.visibilityState === "visible") retryNow();
156
- }
157
- function bindWindow() {
158
- if (windowBound || typeof window === "undefined") return;
159
- windowBound = true;
160
- window.addEventListener("online", retryNow);
161
- document.addEventListener("visibilitychange", onVisibilityChange);
162
- }
163
- function unbindWindow() {
164
- if (!windowBound) return;
165
- windowBound = false;
166
- window.removeEventListener("online", retryNow);
167
- document.removeEventListener("visibilitychange", onVisibilityChange);
168
- }
169
- function startHeartbeat(socket) {
170
- const heartbeat = options.heartbeat;
171
- if (!heartbeat) return;
172
- heartbeatTimer = setInterval(() => {
173
- socket.send(heartbeat.message ?? "ping");
174
- if (heartbeat.timeout && pongTimer === void 0) {
175
- pongTimer = setTimeout(() => {
176
- log("heartbeat timeout");
177
- detach(socket, false);
178
- socket.close(4e3, "heartbeat timeout");
179
- handleClose({ code: 4e3, reason: "heartbeat timeout", wasClean: false });
180
- }, heartbeat.timeout);
181
- }
182
- }, heartbeat.interval);
183
- }
184
- function stopHeartbeat() {
185
- clearInterval(heartbeatTimer);
186
- clearTimeout(pongTimer);
187
- heartbeatTimer = pongTimer = void 0;
188
- }
189
- function detach(socket, reportClose) {
190
- if (ws === socket) ws = null;
191
- socket.onopen = socket.onmessage = socket.onerror = null;
192
- socket.onclose = reportClose ? (event) => emit("close", { code: event.code, reason: event.reason, wasClean: event.wasClean }) : null;
193
- }
194
- function backoff(attempt) {
195
- const { minDelay, maxDelay, factor, jitter } = retry;
196
- const base = Math.min(maxDelay, minDelay * factor ** (attempt - 1));
197
- return Math.round(base * (1 - jitter * Math.random()));
198
- }
199
- function open() {
200
- clearTimeout(retryTimer);
201
- retryTimer = void 0;
202
- const Impl = getImpl();
203
- if (!Impl) return;
204
- const id = ++generation;
205
- set({ status: snapshot.attempt === 0 ? "connecting" : "reconnecting" });
206
- let target;
207
- try {
208
- target = typeof url === "string" ? url : url();
209
- } catch (error) {
210
- urlFailed(error);
211
- return;
212
- }
213
- if (typeof target === "string") {
214
- start(Impl, target);
215
- return;
216
- }
217
- Promise.resolve(target).then(
218
- (resolved) => {
219
- if (id === generation && wanted) start(Impl, resolved);
220
- },
221
- (error) => {
222
- if (id === generation && wanted) urlFailed(error);
223
- }
224
- );
225
- }
226
- function urlFailed(error) {
227
- const reason = error instanceof Error ? error : new Error(String(error));
228
- log("url() failed", reason);
229
- emit("error", reason);
230
- retryOrFail({ code: 1006, reason: "url() failed", wasClean: false }, reason);
231
- }
232
- function start(Impl, target) {
233
- let socket;
234
- try {
235
- socket = new Impl(target, options.protocols);
236
- } catch (error) {
237
- emit("error", error);
238
- fail(error);
239
- return;
240
- }
241
- if (options.binaryType) socket.binaryType = options.binaryType;
242
- ws = socket;
243
- log("connecting", target);
244
- socket.onopen = (event) => {
245
- log("open");
246
- opened = true;
247
- set({ status: "open", attempt: 0 });
248
- startHeartbeat(socket);
249
- ready?.resolve();
250
- emit("open", event);
251
- if (ws !== socket) return;
252
- const queued = pending;
253
- pending = [];
254
- for (const { data } of queued) socket.send(data);
255
- };
256
- socket.onmessage = (event) => receive(event, socket);
257
- socket.onerror = (event) => emit("error", event);
258
- socket.onclose = (event) => {
259
- detach(socket, false);
260
- handleClose({ code: event.code, reason: event.reason, wasClean: event.wasClean });
261
- };
262
- }
263
- function handleClose(info) {
264
- stopHeartbeat();
265
- log("closed", info);
266
- retryOrFail(info);
267
- emit("close", info);
268
- }
269
- function retryOrFail(info, error) {
270
- if (wanted && retry && snapshot.attempt < retry.maxAttempts && (retry.shouldReconnect?.(info) ?? true)) {
271
- const attempt = snapshot.attempt + 1;
272
- const delay = backoff(attempt);
273
- set({ status: "reconnecting", attempt });
274
- log(`reconnecting in ${delay}ms (attempt ${attempt})`);
275
- retryTimer = setTimeout(open, delay);
276
- } else {
277
- fail(
278
- error ?? new Error(`WebSocket closed (code ${info.code}${info.reason ? `: ${info.reason}` : ""})`)
279
- );
280
- }
281
- }
282
- function fail(reason) {
283
- wanted = false;
284
- opened = false;
285
- unbindWindow();
286
- pending = [];
287
- set({ status: "closed" });
288
- rejectReady(reason);
289
- }
290
- function receive(event, socket) {
291
- clearTimeout(pongTimer);
292
- pongTimer = void 0;
293
- if (options.heartbeat?.pong !== void 0 && event.data === options.heartbeat.pong) return;
294
- const run = () => decode(event, socket);
295
- if (inbox) {
296
- const next = inbox.then(run).then(() => {
297
- if (inbox === next) inbox = null;
298
- });
299
- inbox = next;
300
- return;
301
- }
302
- const result = run();
303
- if (result) {
304
- const next = result.then(() => {
305
- if (inbox === next) inbox = null;
306
- });
307
- inbox = next;
308
- }
309
- }
310
- function decode(event, socket) {
311
- let value;
312
- try {
313
- value = options.parse ? options.parse(event.data) : options.json ? JSON.parse(event.data) : event.data;
314
- } catch (cause) {
315
- emit("error", new MessageError("Failed to parse message", event.data, { cause }));
316
- return;
317
- }
318
- const schema = options.schema;
319
- if (!schema) return deliver(value, event, socket);
320
- let result;
321
- try {
322
- result = schema["~standard"].validate(value);
323
- } catch (cause) {
324
- emit("error", new MessageError("Schema validation threw", event.data, { cause }));
325
- return;
326
- }
327
- if (isThenable(result)) {
328
- return Promise.resolve(result).then(
329
- (r) => validated(r, event, socket),
330
- (cause) => emit("error", new MessageError("Schema validation threw", event.data, { cause }))
331
- );
332
- }
333
- validated(result, event, socket);
334
- }
335
- function validated(result, event, socket) {
336
- if (result.issues) {
337
- emit(
338
- "error",
339
- new MessageError("Message failed schema validation", event.data, { issues: result.issues })
340
- );
341
- } else {
342
- deliver(result.value, event, socket);
343
- }
344
- }
345
- function deliver(message, event, socket) {
346
- if (ws !== socket) return;
347
- set({ lastMessage: message });
348
- emit("message", message, event);
349
- }
350
- function send(message, sendOptions) {
351
- const data = options.serialize ? options.serialize(message) : options.json ? JSON.stringify(message) : message;
352
- if (ws && ws.readyState === OPEN) {
353
- ws.send(data);
354
- return true;
355
- }
356
- if (queueLimit === 0 || sendOptions?.volatile) {
357
- log("dropped message: socket is not open");
358
- return false;
359
- }
360
- if (!getImpl()) {
361
- log("dropped message: no WebSocket implementation available");
362
- return false;
363
- }
364
- const key = sendOptions?.key;
365
- if (key !== void 0) pending = pending.filter((entry) => entry.key !== key);
366
- pending.push({ data, key });
367
- if (pending.length > queueLimit) {
368
- pending.shift();
369
- log(`dropped oldest queued message: queue limit (${queueLimit}) reached`);
370
- }
371
- return false;
372
- }
373
- function begin() {
374
- wanted = true;
375
- if (snapshot.status === "idle" || snapshot.status === "closed") {
376
- opened = false;
377
- if (ready?.settled) ready = null;
378
- }
379
- snapshot = { ...snapshot, attempt: 0 };
380
- bindWindow();
381
- open();
382
- }
383
- function ensureConnecting() {
384
- if (wanted || !getImpl()) return;
385
- begin();
386
- }
387
- function connect() {
388
- pinned = true;
389
- ensureConnecting();
390
- }
391
- function stop(code, reason) {
392
- wanted = false;
393
- generation++;
394
- clearTimeout(retryTimer);
395
- retryTimer = void 0;
396
- stopHeartbeat();
397
- unbindWindow();
398
- pending = [];
399
- if (ws) {
400
- const socket = ws;
401
- detach(socket, true);
402
- socket.close(code, reason);
403
- }
404
- }
405
- function close(code = 1e3, reason) {
406
- pinned = false;
407
- opened = false;
408
- stop(code, reason);
409
- set({ status: "closed", attempt: 0 });
410
- rejectReady(new Error("WebSocket was closed"));
411
- }
412
- function reconnect() {
413
- if (!getImpl()) return;
414
- clearTimeout(retryTimer);
415
- retryTimer = void 0;
416
- stopHeartbeat();
417
- if (ws) {
418
- const socket = ws;
419
- detach(socket, true);
420
- socket.close(1e3, "reconnect");
421
- }
422
- begin();
423
- }
424
- function teardown() {
425
- if (refs > 0 || pinned) return;
426
- if (ready && !ready.settled) return;
427
- stop();
428
- ready = null;
429
- opened = false;
430
- set({ status: "idle", attempt: 0 });
431
- onTeardown?.();
432
- }
433
- function acquire() {
434
- refs++;
435
- clearTimeout(teardownTimer);
436
- if (snapshot.status === "idle") ensureConnecting();
437
- let released = false;
438
- return () => {
439
- if (released) return;
440
- released = true;
441
- if (--refs > 0) return;
442
- clearTimeout(teardownTimer);
443
- teardownTimer = setTimeout(teardown, 0);
444
- };
445
- }
446
- return {
447
- url,
448
- get ready() {
449
- if (!getImpl()) {
450
- if (!unavailable) {
451
- unavailable = createDeferred();
452
- unavailable.reject(
453
- new Error("react-websocket: no WebSocket available here (server render?)")
454
- );
455
- }
456
- return unavailable.promise;
457
- }
458
- if (snapshot.status === "idle" && !startScheduled) {
459
- startScheduled = true;
460
- queueMicrotask(() => {
461
- startScheduled = false;
462
- if (snapshot.status === "idle") ensureConnecting();
463
- });
464
- }
465
- if (!ready) {
466
- ready = createDeferred();
467
- if (opened) ready.resolve();
468
- }
469
- return ready.promise;
470
- },
471
- get socket() {
472
- return ws;
473
- },
474
- subscribe(listener) {
475
- storeListeners.add(listener);
476
- return () => {
477
- storeListeners.delete(listener);
478
- };
479
- },
480
- getSnapshot: () => snapshot,
481
- on(type, listener) {
482
- const entry = ((...args) => listener(...args));
483
- listeners[type].add(entry);
484
- return () => {
485
- listeners[type].delete(entry);
486
- };
487
- },
488
- send,
489
- connect,
490
- close,
491
- reconnect,
492
- acquire
493
- };
81
+ const onTeardown = options[ON_TEARDOWN];
82
+ const log = options.logger ?? (() => {});
83
+ const retry = options.reconnect === false ? null : {
84
+ ...RECONNECT_DEFAULTS,
85
+ ...typeof options.reconnect === "object" ? options.reconnect : {}
86
+ };
87
+ const queueLimit = options.queue === false ? 0 : typeof options.queue === "object" ? Math.max(0, options.queue.limit ?? Infinity) : Infinity;
88
+ let snapshot = {
89
+ status: "idle",
90
+ lastMessage: void 0,
91
+ attempt: 0
92
+ };
93
+ const storeListeners = /* @__PURE__ */ new Set();
94
+ const listeners = {
95
+ open: /* @__PURE__ */ new Set(),
96
+ message: /* @__PURE__ */ new Set(),
97
+ close: /* @__PURE__ */ new Set(),
98
+ error: /* @__PURE__ */ new Set()
99
+ };
100
+ let ws = null;
101
+ let wanted = false;
102
+ let refs = 0;
103
+ let pinned = false;
104
+ let pending = [];
105
+ let ready = null;
106
+ let opened = false;
107
+ let unavailable = null;
108
+ let startScheduled = false;
109
+ let inbox = null;
110
+ let generation = 0;
111
+ let windowBound = false;
112
+ let retryTimer;
113
+ let teardownTimer;
114
+ let heartbeatTimer;
115
+ let pongTimer;
116
+ function set(patch) {
117
+ snapshot = {
118
+ ...snapshot,
119
+ ...patch
120
+ };
121
+ for (const listener of [...storeListeners]) try {
122
+ listener();
123
+ } catch (error) {
124
+ rethrowAsync(error);
125
+ }
126
+ }
127
+ function emit(type, ...args) {
128
+ for (const listener of [...listeners[type]]) try {
129
+ listener(...args);
130
+ } catch (error) {
131
+ rethrowAsync(error);
132
+ }
133
+ }
134
+ function getImpl() {
135
+ if (options.WebSocket) return options.WebSocket;
136
+ return typeof window === "undefined" ? void 0 : globalThis.WebSocket;
137
+ }
138
+ function rejectReady(reason) {
139
+ if (!ready || ready.settled) ready = createDeferred();
140
+ ready.reject(reason);
141
+ }
142
+ function retryNow() {
143
+ if (retryTimer !== void 0) {
144
+ log("network or visibility change, retrying now");
145
+ open();
146
+ }
147
+ }
148
+ function onVisibilityChange() {
149
+ if (document.visibilityState === "visible") retryNow();
150
+ }
151
+ function bindWindow() {
152
+ if (windowBound || typeof window === "undefined") return;
153
+ windowBound = true;
154
+ window.addEventListener("online", retryNow);
155
+ document.addEventListener("visibilitychange", onVisibilityChange);
156
+ }
157
+ function unbindWindow() {
158
+ if (!windowBound) return;
159
+ windowBound = false;
160
+ window.removeEventListener("online", retryNow);
161
+ document.removeEventListener("visibilitychange", onVisibilityChange);
162
+ }
163
+ function startHeartbeat(socket) {
164
+ const heartbeat = options.heartbeat;
165
+ if (!heartbeat) return;
166
+ heartbeatTimer = setInterval(() => {
167
+ socket.send(heartbeat.message ?? "ping");
168
+ if (heartbeat.timeout && pongTimer === void 0) pongTimer = setTimeout(() => {
169
+ log("heartbeat timeout");
170
+ detach(socket, false);
171
+ socket.close(4e3, "heartbeat timeout");
172
+ handleClose({
173
+ code: 4e3,
174
+ reason: "heartbeat timeout",
175
+ wasClean: false
176
+ });
177
+ }, heartbeat.timeout);
178
+ }, heartbeat.interval);
179
+ }
180
+ function stopHeartbeat() {
181
+ clearInterval(heartbeatTimer);
182
+ clearTimeout(pongTimer);
183
+ heartbeatTimer = pongTimer = void 0;
184
+ }
185
+ /** Stop tracking `socket`. Its eventual close event is still reported unless `reportClose` is false. */
186
+ function detach(socket, reportClose) {
187
+ if (ws === socket) ws = null;
188
+ socket.onopen = socket.onmessage = socket.onerror = null;
189
+ socket.onclose = reportClose ? (event) => emit("close", {
190
+ code: event.code,
191
+ reason: event.reason,
192
+ wasClean: event.wasClean
193
+ }) : null;
194
+ }
195
+ function backoff(attempt) {
196
+ const { minDelay, maxDelay, factor, jitter } = retry;
197
+ const base = Math.min(maxDelay, minDelay * factor ** (attempt - 1));
198
+ return Math.round(base * (1 - jitter * Math.random()));
199
+ }
200
+ function open() {
201
+ clearTimeout(retryTimer);
202
+ retryTimer = void 0;
203
+ const Impl = getImpl();
204
+ if (!Impl) return;
205
+ const id = ++generation;
206
+ set({ status: snapshot.attempt === 0 ? "connecting" : "reconnecting" });
207
+ let target;
208
+ try {
209
+ target = typeof url === "string" ? url : url();
210
+ } catch (error) {
211
+ urlFailed(error);
212
+ return;
213
+ }
214
+ if (typeof target === "string") {
215
+ start(Impl, target);
216
+ return;
217
+ }
218
+ Promise.resolve(target).then((resolved) => {
219
+ if (id === generation && wanted) start(Impl, resolved);
220
+ }, (error) => {
221
+ if (id === generation && wanted) urlFailed(error);
222
+ });
223
+ }
224
+ /** `url()` threw or rejected (e.g. a token refresh failed): count it as a failed attempt. */
225
+ function urlFailed(error) {
226
+ const reason = error instanceof Error ? error : new Error(String(error));
227
+ log("url() failed", reason);
228
+ emit("error", reason);
229
+ retryOrFail({
230
+ code: 1006,
231
+ reason: "url() failed",
232
+ wasClean: false
233
+ }, reason);
234
+ }
235
+ function start(Impl, target) {
236
+ let socket;
237
+ try {
238
+ socket = new Impl(target, options.protocols);
239
+ } catch (error) {
240
+ emit("error", error);
241
+ fail(error);
242
+ return;
243
+ }
244
+ if (options.binaryType) socket.binaryType = options.binaryType;
245
+ ws = socket;
246
+ log("connecting", target);
247
+ socket.onopen = (event) => {
248
+ log("open");
249
+ opened = true;
250
+ set({
251
+ status: "open",
252
+ attempt: 0
253
+ });
254
+ startHeartbeat(socket);
255
+ ready?.resolve();
256
+ if (refs === 0 && !pinned) {
257
+ clearTimeout(teardownTimer);
258
+ teardownTimer = setTimeout(teardown, READY_GRACE);
259
+ }
260
+ emit("open", event);
261
+ if (ws !== socket) return;
262
+ const queued = pending;
263
+ pending = [];
264
+ for (const { data } of queued) socket.send(data);
265
+ };
266
+ socket.onmessage = (event) => receive(event, socket);
267
+ socket.onerror = (event) => emit("error", event);
268
+ socket.onclose = (event) => {
269
+ detach(socket, false);
270
+ handleClose({
271
+ code: event.code,
272
+ reason: event.reason,
273
+ wasClean: event.wasClean
274
+ });
275
+ };
276
+ }
277
+ function handleClose(info) {
278
+ stopHeartbeat();
279
+ log("closed", info);
280
+ retryOrFail(info);
281
+ emit("close", info);
282
+ }
283
+ function retryOrFail(info, error) {
284
+ if (wanted && retry && snapshot.attempt < retry.maxAttempts && (retry.shouldReconnect?.(info) ?? true)) {
285
+ const attempt = snapshot.attempt + 1;
286
+ const delay = backoff(attempt);
287
+ set({
288
+ status: "reconnecting",
289
+ attempt
290
+ });
291
+ log(`reconnecting in ${delay}ms (attempt ${attempt})`);
292
+ retryTimer = setTimeout(open, delay);
293
+ } else fail(error ?? /* @__PURE__ */ new Error(`WebSocket closed (code ${info.code}${info.reason ? `: ${info.reason}` : ""})`));
294
+ }
295
+ function fail(reason) {
296
+ wanted = false;
297
+ opened = false;
298
+ unbindWindow();
299
+ pending = [];
300
+ set({ status: "closed" });
301
+ rejectReady(reason);
302
+ }
303
+ function receive(event, socket) {
304
+ clearTimeout(pongTimer);
305
+ pongTimer = void 0;
306
+ if (options.heartbeat?.pong !== void 0 && event.data === options.heartbeat.pong) return;
307
+ const run = () => decode(event, socket);
308
+ if (inbox) {
309
+ const next = inbox.then(run).catch(rethrowAsync).then(() => {
310
+ if (inbox === next) inbox = null;
311
+ });
312
+ inbox = next;
313
+ return;
314
+ }
315
+ const result = run();
316
+ if (result) {
317
+ const next = result.catch(rethrowAsync).then(() => {
318
+ if (inbox === next) inbox = null;
319
+ });
320
+ inbox = next;
321
+ }
322
+ }
323
+ function decode(event, socket) {
324
+ let value;
325
+ try {
326
+ value = options.parse ? options.parse(event.data) : options.json ? JSON.parse(event.data) : event.data;
327
+ } catch (cause) {
328
+ emit("error", new MessageError("Failed to parse message", event.data, { cause }));
329
+ return;
330
+ }
331
+ const schema = options.schema;
332
+ if (!schema) return deliver(value, event, socket);
333
+ let result;
334
+ try {
335
+ result = schema["~standard"].validate(value);
336
+ } catch (cause) {
337
+ emit("error", new MessageError("Schema validation threw", event.data, { cause }));
338
+ return;
339
+ }
340
+ if (isThenable(result)) return Promise.resolve(result).then((r) => validated(r, event, socket), (cause) => emit("error", new MessageError("Schema validation threw", event.data, { cause })));
341
+ validated(result, event, socket);
342
+ }
343
+ function validated(result, event, socket) {
344
+ if (result.issues) emit("error", new MessageError("Message failed schema validation", event.data, { issues: result.issues }));
345
+ else deliver(result.value, event, socket);
346
+ }
347
+ function deliver(message, event, socket) {
348
+ if (ws !== socket) return;
349
+ set({ lastMessage: message });
350
+ emit("message", message, event);
351
+ }
352
+ function send(message, sendOptions) {
353
+ const data = options.serialize ? options.serialize(message) : options.json ? JSON.stringify(message) : message;
354
+ if (ws && ws.readyState === OPEN) {
355
+ ws.send(data);
356
+ return true;
357
+ }
358
+ if (queueLimit === 0 || sendOptions?.volatile) {
359
+ log("dropped message: socket is not open");
360
+ return false;
361
+ }
362
+ if (!getImpl()) {
363
+ log("dropped message: no WebSocket implementation available");
364
+ return false;
365
+ }
366
+ const key = sendOptions?.key;
367
+ if (key !== void 0) pending = pending.filter((entry) => {
368
+ if (entry.key !== key) return true;
369
+ dropped(entry);
370
+ return false;
371
+ });
372
+ pending.push({
373
+ data,
374
+ key,
375
+ onDrop: sendOptions?.onDrop
376
+ });
377
+ if (pending.length > queueLimit) {
378
+ dropped(pending.shift());
379
+ log(`dropped oldest queued message: queue limit (${queueLimit}) reached`);
380
+ }
381
+ return false;
382
+ }
383
+ function dropped(entry) {
384
+ try {
385
+ entry.onDrop?.();
386
+ } catch (error) {
387
+ rethrowAsync(error);
388
+ }
389
+ }
390
+ function begin() {
391
+ wanted = true;
392
+ if (snapshot.status === "idle" || snapshot.status === "closed") {
393
+ opened = false;
394
+ if (ready?.settled) ready = null;
395
+ }
396
+ snapshot = {
397
+ ...snapshot,
398
+ attempt: 0
399
+ };
400
+ bindWindow();
401
+ open();
402
+ }
403
+ /** Start connecting unless already wanted. Used by holders (`acquire`, `ready`). */
404
+ function ensureConnecting() {
405
+ if (wanted || !getImpl()) return;
406
+ begin();
407
+ }
408
+ function connect() {
409
+ pinned = true;
410
+ ensureConnecting();
411
+ }
412
+ function stop(code, reason) {
413
+ wanted = false;
414
+ generation++;
415
+ clearTimeout(retryTimer);
416
+ retryTimer = void 0;
417
+ stopHeartbeat();
418
+ unbindWindow();
419
+ pending = [];
420
+ if (ws) {
421
+ const socket = ws;
422
+ detach(socket, true);
423
+ socket.close(code, reason);
424
+ }
425
+ }
426
+ function close(code = 1e3, reason) {
427
+ pinned = false;
428
+ opened = false;
429
+ stop(code, reason);
430
+ set({
431
+ status: "closed",
432
+ attempt: 0
433
+ });
434
+ rejectReady(/* @__PURE__ */ new Error("WebSocket was closed"));
435
+ }
436
+ function reconnect() {
437
+ if (!getImpl()) return;
438
+ clearTimeout(retryTimer);
439
+ retryTimer = void 0;
440
+ stopHeartbeat();
441
+ if (ws) {
442
+ const socket = ws;
443
+ detach(socket, true);
444
+ socket.close(1e3, "reconnect");
445
+ }
446
+ begin();
447
+ }
448
+ function teardown() {
449
+ if (refs > 0 || pinned) return;
450
+ if (ready && !ready.settled) return;
451
+ stop();
452
+ ready = null;
453
+ opened = false;
454
+ set({
455
+ status: "idle",
456
+ attempt: 0
457
+ });
458
+ onTeardown?.();
459
+ }
460
+ function acquire() {
461
+ refs++;
462
+ clearTimeout(teardownTimer);
463
+ if (snapshot.status === "idle") ensureConnecting();
464
+ let released = false;
465
+ return () => {
466
+ if (released) return;
467
+ released = true;
468
+ if (--refs > 0) return;
469
+ clearTimeout(teardownTimer);
470
+ teardownTimer = setTimeout(teardown, 0);
471
+ };
472
+ }
473
+ return {
474
+ url,
475
+ get ready() {
476
+ if (!getImpl()) {
477
+ if (!unavailable) {
478
+ unavailable = createDeferred();
479
+ unavailable.reject(/* @__PURE__ */ new Error("react-websocket: no WebSocket available here (server render?)"));
480
+ }
481
+ return unavailable.promise;
482
+ }
483
+ if (snapshot.status === "idle" && !startScheduled) {
484
+ startScheduled = true;
485
+ queueMicrotask(() => {
486
+ startScheduled = false;
487
+ if (snapshot.status === "idle") ensureConnecting();
488
+ });
489
+ }
490
+ if (!ready) {
491
+ ready = createDeferred();
492
+ if (opened) ready.resolve();
493
+ }
494
+ return ready.promise;
495
+ },
496
+ get socket() {
497
+ return ws;
498
+ },
499
+ subscribe(listener) {
500
+ storeListeners.add(listener);
501
+ return () => {
502
+ storeListeners.delete(listener);
503
+ };
504
+ },
505
+ getSnapshot: () => snapshot,
506
+ on(type, listener) {
507
+ const entry = ((...args) => listener(...args));
508
+ listeners[type].add(entry);
509
+ return () => {
510
+ listeners[type].delete(entry);
511
+ };
512
+ },
513
+ send,
514
+ connect,
515
+ close,
516
+ reconnect,
517
+ acquire
518
+ };
494
519
  }
520
+ /**
521
+ * Create a WebSocket client, usually at module level, to share across components.
522
+ *
523
+ * Nothing connects until a hook uses it, `ready` is read, or `connect()` is called.
524
+ * It closes one tick after the last hook unmounts, unless `connect()` was called.
525
+ *
526
+ * @param url - A URL, or a function (sync or async) called before every connection
527
+ * attempt, e.g. to put a fresh auth token in the URL on each reconnect.
528
+ * @typeParam In - Type of incoming messages. Inferred from `options.schema` if given.
529
+ * @typeParam Out - Type accepted by `send()`.
530
+ *
531
+ * @example
532
+ * ```ts
533
+ * type ChatIn = { user: string; text: string };
534
+ * type ChatOut = { text: string };
535
+ *
536
+ * export const chat = createWebSocket<ChatIn, ChatOut>('wss://api.example.com/chat', { json: true });
537
+ * ```
538
+ */
495
539
  function createWebSocket(url, options) {
496
- return createClient(url, options);
540
+ return createClient(url, options);
497
541
  }
498
-
499
- // src/latency.ts
500
- var EMPTY = Object.freeze({ rtt: void 0, offset: void 0 });
542
+ //#endregion
543
+ //#region src/latency.ts
544
+ const EMPTY = Object.freeze({
545
+ rtt: void 0,
546
+ offset: void 0
547
+ });
501
548
  function median(values) {
502
- const sorted = [...values].sort((a, b) => a - b);
503
- const middle = sorted.length >> 1;
504
- return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
549
+ const sorted = [...values].sort((a, b) => a - b);
550
+ const middle = sorted.length >> 1;
551
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
505
552
  }
553
+ /**
554
+ * Measure round-trip time and estimate the server's clock, for ping displays, countdowns
555
+ * and timestamps that must agree across players.
556
+ *
557
+ * Pings are sent on an interval while the socket is open, and paused otherwise. Measurements
558
+ * from a previous connection are discarded on reconnect, but the last values stay readable
559
+ * until new ones arrive. It doesn't open the connection by itself.
560
+ *
561
+ * Pongs are ordinary messages: they also reach `onMessage`, `lastMessage` and state stores.
562
+ *
563
+ * @example
564
+ * ```ts
565
+ * export const latency = createWebSocketLatency(game, {
566
+ * ping: id => ({ type: 'ping', id }),
567
+ * pong: message => (message.type === 'pong' ? { id: message.id, time: message.time } : undefined)
568
+ * });
569
+ *
570
+ * // In a component:
571
+ * const { rtt } = useWebSocketLatency(latency);
572
+ *
573
+ * // Anywhere: seconds left in a round that ends at the server's `endsAt`.
574
+ * const left = (endsAt - latency.serverNow()) / 1000;
575
+ * ```
576
+ */
506
577
  function createWebSocketLatency(client, options) {
507
- const interval = options.interval ?? 2e3;
508
- const keep = Math.max(1, options.samples ?? 5);
509
- const listeners = /* @__PURE__ */ new Set();
510
- let snapshot = EMPTY;
511
- const inFlight = /* @__PURE__ */ new Map();
512
- let samples = [];
513
- let nextId = 0;
514
- let timer;
515
- function ping() {
516
- if (client.getSnapshot().status !== "open") return;
517
- const id = ++nextId;
518
- inFlight.set(String(id), performance.now());
519
- if (inFlight.size > keep * 2) inFlight.delete(inFlight.keys().next().value);
520
- client.send(options.ping(id), { volatile: true });
521
- }
522
- function start() {
523
- stop();
524
- inFlight.clear();
525
- samples = [];
526
- ping();
527
- timer = setInterval(ping, interval);
528
- }
529
- function stop() {
530
- clearInterval(timer);
531
- timer = void 0;
532
- }
533
- function measure(id, serverTime) {
534
- const sentAt = inFlight.get(id);
535
- if (sentAt === void 0) return;
536
- inFlight.delete(id);
537
- const rtt = performance.now() - sentAt;
538
- const offset = serverTime === void 0 ? void 0 : serverTime + rtt / 2 - Date.now();
539
- samples.push({ rtt, offset });
540
- if (samples.length > keep) samples.shift();
541
- let best;
542
- for (const sample of samples) {
543
- if (sample.offset !== void 0 && (!best || sample.rtt < best.rtt)) best = sample;
544
- }
545
- snapshot = {
546
- rtt: median(samples.map((sample) => sample.rtt)),
547
- offset: best ? best.offset : snapshot.offset
548
- };
549
- for (const listener of [...listeners]) listener();
550
- }
551
- const offs = [
552
- client.on("open", start),
553
- client.on("message", (message) => {
554
- const pong = options.pong(message);
555
- if (pong != null) measure(String(pong.id), pong.time);
556
- }),
557
- client.subscribe(() => {
558
- if (client.getSnapshot().status !== "open") stop();
559
- })
560
- ];
561
- if (client.getSnapshot().status === "open") start();
562
- return {
563
- client,
564
- getSnapshot: () => snapshot,
565
- subscribe(listener) {
566
- listeners.add(listener);
567
- return () => {
568
- listeners.delete(listener);
569
- };
570
- },
571
- serverNow: () => Date.now() + (snapshot.offset ?? 0),
572
- ping,
573
- dispose() {
574
- stop();
575
- offs.forEach((off) => off());
576
- }
577
- };
578
+ const interval = options.interval ?? 2e3;
579
+ const keep = Math.max(1, options.samples ?? 5);
580
+ const listeners = /* @__PURE__ */ new Set();
581
+ let snapshot = EMPTY;
582
+ const inFlight = /* @__PURE__ */ new Map();
583
+ let samples = [];
584
+ let nextId = 0;
585
+ let timer;
586
+ function ping() {
587
+ if (client.getSnapshot().status !== "open") return;
588
+ const id = ++nextId;
589
+ inFlight.set(String(id), performance.now());
590
+ if (inFlight.size > keep * 2) inFlight.delete(inFlight.keys().next().value);
591
+ client.send(options.ping(id), { volatile: true });
592
+ }
593
+ function start() {
594
+ stop();
595
+ inFlight.clear();
596
+ samples = [];
597
+ ping();
598
+ timer = setInterval(ping, interval);
599
+ }
600
+ function stop() {
601
+ clearInterval(timer);
602
+ timer = void 0;
603
+ }
604
+ function measure(id, serverTime) {
605
+ const sentAt = inFlight.get(id);
606
+ if (sentAt === void 0) return;
607
+ inFlight.delete(id);
608
+ const rtt = performance.now() - sentAt;
609
+ const offset = serverTime === void 0 ? void 0 : serverTime + rtt / 2 - Date.now();
610
+ samples.push({
611
+ rtt,
612
+ offset
613
+ });
614
+ if (samples.length > keep) samples.shift();
615
+ let best;
616
+ for (const sample of samples) if (sample.offset !== void 0 && (!best || sample.rtt < best.rtt)) best = sample;
617
+ snapshot = {
618
+ rtt: median(samples.map((sample) => sample.rtt)),
619
+ offset: best ? best.offset : snapshot.offset
620
+ };
621
+ for (const listener of [...listeners]) listener();
622
+ }
623
+ const offs = [
624
+ client.on("open", start),
625
+ client.on("message", (message) => {
626
+ const pong = options.pong(message);
627
+ if (pong != null) measure(String(pong.id), pong.time);
628
+ }),
629
+ client.subscribe(() => {
630
+ if (client.getSnapshot().status !== "open") stop();
631
+ })
632
+ ];
633
+ if (client.getSnapshot().status === "open") start();
634
+ return {
635
+ client,
636
+ getSnapshot: () => snapshot,
637
+ subscribe(listener) {
638
+ listeners.add(listener);
639
+ return () => {
640
+ listeners.delete(listener);
641
+ };
642
+ },
643
+ serverNow: () => Date.now() + (snapshot.offset ?? 0),
644
+ ping,
645
+ dispose() {
646
+ stop();
647
+ offs.forEach((off) => off());
648
+ }
649
+ };
578
650
  }
579
-
580
- // src/requests.ts
651
+ //#endregion
652
+ //#region src/requests.ts
653
+ const lastIds = /* @__PURE__ */ new WeakMap();
654
+ /**
655
+ * Send requests and await their replies, matched by an id the library generates.
656
+ *
657
+ * Replies are ordinary messages: they also reach `onMessage`, `lastMessage` and state stores.
658
+ *
659
+ * @example
660
+ * ```ts
661
+ * type Request = { type: 'join'; room: string } | { type: 'leave' };
662
+ *
663
+ * export const rpc = createWebSocketRequests(game, {
664
+ * send: (message: Request, id) => ({ ...message, id }),
665
+ * reply: message => (message.type === 'reply' ? message.id : undefined),
666
+ * error: message => (message.type === 'reply' && message.error ? new Error(message.error) : undefined)
667
+ * });
668
+ *
669
+ * const reply = await rpc.request({ type: 'join', room: 'lobby' }, { timeout: 5000 });
670
+ * ```
671
+ */
581
672
  function createWebSocketRequests(client, options) {
582
- const waiting = /* @__PURE__ */ new Map();
583
- let nextId = 0;
584
- function rejectWhere(test, reason) {
585
- for (const entry of [...waiting.values()]) if (test(entry)) entry.reject(reason());
586
- }
587
- const offs = [
588
- client.on("message", (message) => {
589
- const id = options.reply(message);
590
- const entry = id == null ? void 0 : waiting.get(String(id));
591
- if (!entry) return;
592
- const error = options.error?.(message);
593
- if (error === void 0) entry.resolve(message);
594
- else entry.reject(error);
595
- }),
596
- // `open` listeners run before the client flushes its queue: queued requests go out on this socket.
597
- client.on("open", () => {
598
- for (const entry of waiting.values()) entry.socket ??= client.socket;
599
- }),
600
- client.subscribe(() => {
601
- const { status } = client.getSnapshot();
602
- if (status === "closed" || status === "idle") {
603
- rejectWhere(
604
- () => true,
605
- () => new Error("WebSocket was closed before the request was answered")
606
- );
607
- } else {
608
- rejectWhere(
609
- (entry) => entry.socket !== null && entry.socket !== client.socket,
610
- () => new Error("WebSocket connection was lost before the request was answered")
611
- );
612
- }
613
- })
614
- ];
615
- return {
616
- client,
617
- request(message, requestOptions = {}) {
618
- const { signal } = requestOptions;
619
- if (signal?.aborted) return Promise.reject(signal.reason);
620
- const id = ++nextId;
621
- const key = String(id);
622
- const timeout = requestOptions.timeout ?? options.timeout ?? 1e4;
623
- return new Promise((resolve, reject) => {
624
- let timer;
625
- const release = client.acquire();
626
- const onAbort = () => entry.reject(signal.reason);
627
- function settle() {
628
- waiting.delete(key);
629
- clearTimeout(timer);
630
- signal?.removeEventListener("abort", onAbort);
631
- release();
632
- }
633
- const entry = {
634
- socket: null,
635
- resolve(reply) {
636
- settle();
637
- resolve(reply);
638
- },
639
- reject(reason) {
640
- settle();
641
- reject(reason);
642
- }
643
- };
644
- waiting.set(key, entry);
645
- signal?.addEventListener("abort", onAbort, { once: true });
646
- if (timeout > 0 && timeout < Infinity) {
647
- timer = setTimeout(
648
- () => entry.reject(
649
- new DOMException(`Request ${id} timed out after ${timeout}ms`, "TimeoutError")
650
- ),
651
- timeout
652
- );
653
- }
654
- const { status } = client.getSnapshot();
655
- if (status === "closed" || status === "idle") {
656
- entry.reject(new Error(`WebSocket is ${status}: request not sent`));
657
- return;
658
- }
659
- let sent;
660
- try {
661
- sent = client.send(options.send(message, id), requestOptions);
662
- } catch (error) {
663
- entry.reject(error);
664
- return;
665
- }
666
- if (sent) entry.socket = client.socket;
667
- else if (requestOptions.volatile)
668
- entry.reject(new Error("WebSocket is not open: request dropped"));
669
- });
670
- },
671
- pending: () => waiting.size,
672
- dispose() {
673
- offs.forEach((off) => off());
674
- rejectWhere(
675
- () => true,
676
- () => new Error("Requests were disposed")
677
- );
678
- }
679
- };
673
+ const waiting = /* @__PURE__ */ new Map();
674
+ function rejectWhere(test, reason) {
675
+ for (const entry of [...waiting.values()]) if (test(entry)) entry.reject(reason());
676
+ }
677
+ const offs = [
678
+ client.on("message", (message) => {
679
+ const id = options.reply(message);
680
+ const entry = id == null ? void 0 : waiting.get(String(id));
681
+ if (!entry) return;
682
+ const error = options.error?.(message);
683
+ if (error === void 0) entry.resolve(message);
684
+ else entry.reject(error);
685
+ }),
686
+ client.on("open", () => {
687
+ for (const entry of waiting.values()) entry.socket ??= client.socket;
688
+ }),
689
+ client.subscribe(() => {
690
+ const { status } = client.getSnapshot();
691
+ if (status === "closed" || status === "idle") rejectWhere(() => true, () => /* @__PURE__ */ new Error("WebSocket was closed before the request was answered"));
692
+ else rejectWhere((entry) => entry.socket !== null && entry.socket !== client.socket, () => /* @__PURE__ */ new Error("WebSocket connection was lost before the request was answered"));
693
+ })
694
+ ];
695
+ return {
696
+ client,
697
+ request(message, requestOptions = {}) {
698
+ const { signal } = requestOptions;
699
+ if (signal?.aborted) return Promise.reject(signal.reason);
700
+ const id = (lastIds.get(client) ?? 0) + 1;
701
+ lastIds.set(client, id);
702
+ const key = String(id);
703
+ const timeout = requestOptions.timeout ?? options.timeout ?? 1e4;
704
+ return new Promise((resolve, reject) => {
705
+ let timer;
706
+ const release = client.acquire();
707
+ const onAbort = () => entry.reject(signal.reason);
708
+ function settle() {
709
+ waiting.delete(key);
710
+ clearTimeout(timer);
711
+ signal?.removeEventListener("abort", onAbort);
712
+ release();
713
+ }
714
+ const entry = {
715
+ socket: null,
716
+ resolve(reply) {
717
+ settle();
718
+ resolve(reply);
719
+ },
720
+ reject(reason) {
721
+ settle();
722
+ reject(reason);
723
+ }
724
+ };
725
+ waiting.set(key, entry);
726
+ signal?.addEventListener("abort", onAbort, { once: true });
727
+ if (timeout > 0 && timeout < Infinity) timer = setTimeout(() => entry.reject(new DOMException(`Request ${id} timed out after ${timeout}ms`, "TimeoutError")), timeout);
728
+ const { status } = client.getSnapshot();
729
+ if (status === "closed" || status === "idle") {
730
+ entry.reject(/* @__PURE__ */ new Error(`WebSocket is ${status}: request not sent`));
731
+ return;
732
+ }
733
+ let sent;
734
+ try {
735
+ sent = client.send(options.send(message, id), {
736
+ ...requestOptions,
737
+ onDrop() {
738
+ entry.reject(/* @__PURE__ */ new Error("Request was dropped from the send queue before it was sent"));
739
+ requestOptions.onDrop?.();
740
+ }
741
+ });
742
+ } catch (error) {
743
+ entry.reject(error);
744
+ return;
745
+ }
746
+ if (sent) entry.socket = client.socket;
747
+ else if (requestOptions.volatile) entry.reject(/* @__PURE__ */ new Error("WebSocket is not open: request dropped"));
748
+ });
749
+ },
750
+ pending: () => waiting.size,
751
+ dispose() {
752
+ offs.forEach((off) => off());
753
+ rejectWhere(() => true, () => /* @__PURE__ */ new Error("Requests were disposed"));
754
+ }
755
+ };
680
756
  }
681
-
682
- // src/state.ts
757
+ //#endregion
758
+ //#region src/responder.ts
759
+ const VOLATILE = { volatile: true };
760
+ const abortError = (message) => new DOMException(message, "AbortError");
761
+ /**
762
+ * Answer requests the server sends: run a handler for tools that only exist in the browser
763
+ * (selection, clipboard, geolocation), or let a person answer, e.g. to approve an agent's action.
764
+ * The reverse of `createWebSocketRequests`.
765
+ *
766
+ * Every request gets exactly one reply while its connection lives: the handler's result, an
767
+ * `error` reply when it throws, has no registration or times out. When the connection a request
768
+ * arrived on closes, the request is dropped and nothing is sent, since the server can't match a
769
+ * reply on a new connection. Replies are never queued.
770
+ *
771
+ * Requests are ordinary messages: they also reach `onMessage`, `lastMessage` and state stores.
772
+ *
773
+ * @example
774
+ * ```ts
775
+ * export const tools = createWebSocketResponder(agent, {
776
+ * request: message => (message.type === 'call' ? message : undefined),
777
+ * respond: (id, result: unknown) => ({ type: 'result', id, result }),
778
+ * error: (id, error) => ({ type: 'result', id, error: String(error) })
779
+ * });
780
+ *
781
+ * tools.onRequest('get_selection', () => window.getSelection()?.toString() ?? '');
782
+ *
783
+ * // In a component: requests a person answers.
784
+ * const approvals = useAgentApproval(tools, 'approve');
785
+ * approvals[0]?.respond({ approved: true });
786
+ * ```
787
+ */
788
+ function createWebSocketResponder(client, options) {
789
+ const entries = /* @__PURE__ */ new Map();
790
+ const registrations = /* @__PURE__ */ new Map();
791
+ const listeners = /* @__PURE__ */ new Set();
792
+ let waiting = [];
793
+ function setWaiting(next) {
794
+ waiting = next;
795
+ for (const listener of [...listeners]) listener();
796
+ }
797
+ /** Send a reply, or an `error` reply if building or encoding it throws. */
798
+ function reply(id, build) {
799
+ try {
800
+ return client.send(build(), VOLATILE);
801
+ } catch (error) {
802
+ return client.send(options.error(id, error), VOLATILE);
803
+ }
804
+ }
805
+ function receive(incoming) {
806
+ const { id, method } = incoming;
807
+ const key = String(id);
808
+ if (entries.has(key)) return;
809
+ const registration = registrations.get(method)?.at(-1);
810
+ if (!registration) {
811
+ const error = new DOMException(`No handler for request method "${method}"`, "NotFoundError");
812
+ reply(id, () => options.error(id, error));
813
+ return;
814
+ }
815
+ const controller = new AbortController();
816
+ let timer;
817
+ let done = false;
818
+ let running = false;
819
+ function settle(build, reason) {
820
+ if (done) return false;
821
+ done = true;
822
+ entries.delete(key);
823
+ clearTimeout(timer);
824
+ if (waiting.includes(request)) setWaiting(waiting.filter((entry) => entry !== request));
825
+ if (reason !== void 0) controller.abort(reason);
826
+ return build ? reply(id, build) : false;
827
+ }
828
+ const answeredElsewhere = () => running ? abortError(`Request ${id} was answered elsewhere`) : void 0;
829
+ const request = {
830
+ id,
831
+ method,
832
+ params: incoming.params,
833
+ signal: controller.signal,
834
+ respond: (result) => settle(() => options.respond(id, result), answeredElsewhere()),
835
+ reject: (error) => settle(() => options.error(id, error), answeredElsewhere())
836
+ };
837
+ const fail = (error) => {
838
+ settle(() => options.error(id, error), error);
839
+ };
840
+ entries.set(key, {
841
+ socket: client.socket,
842
+ drop: (reason) => {
843
+ settle(null, reason);
844
+ },
845
+ fail
846
+ });
847
+ const timeout = incoming.timeout ?? options.timeout ?? Infinity;
848
+ if (timeout > 0 && timeout < Infinity) timer = setTimeout(() => fail(new DOMException(`Request ${id} was not answered within ${timeout}ms`, "TimeoutError")), timeout);
849
+ const { handler } = registration;
850
+ if (!handler) {
851
+ setWaiting([...waiting, request]);
852
+ return;
853
+ }
854
+ running = true;
855
+ new Promise((resolve) => resolve(handler(request.params, request))).then((result) => {
856
+ running = false;
857
+ request.respond(result);
858
+ }, (error) => {
859
+ running = false;
860
+ request.reject(error);
861
+ });
862
+ }
863
+ const offs = [client.on("message", (message) => {
864
+ const incoming = options.request(message);
865
+ if (incoming != null) {
866
+ receive(incoming);
867
+ return;
868
+ }
869
+ const id = options.cancel?.(message);
870
+ if (id != null) entries.get(String(id))?.drop(abortError(`Request ${id} was cancelled`));
871
+ }), client.subscribe(() => {
872
+ for (const entry of [...entries.values()]) if (entry.socket !== client.socket) entry.drop(abortError("WebSocket connection was lost before the request was answered"));
873
+ })];
874
+ return {
875
+ client,
876
+ onRequest(method, handler) {
877
+ let stack = registrations.get(method);
878
+ if (!stack) registrations.set(method, stack = []);
879
+ const registration = { handler };
880
+ stack.push(registration);
881
+ return () => {
882
+ const index = stack.indexOf(registration);
883
+ if (index !== -1) stack.splice(index, 1);
884
+ };
885
+ },
886
+ getSnapshot: () => waiting,
887
+ subscribe(listener) {
888
+ listeners.add(listener);
889
+ return () => {
890
+ listeners.delete(listener);
891
+ };
892
+ },
893
+ dispose() {
894
+ offs.forEach((off) => off());
895
+ registrations.clear();
896
+ for (const entry of [...entries.values()]) entry.fail(abortError("Responder was disposed"));
897
+ }
898
+ };
899
+ }
900
+ //#endregion
901
+ //#region src/state.ts
902
+ /**
903
+ * Keep state derived from a client's messages, like a reducer that runs on every message.
904
+ * It starts listening immediately and keeps the state across reconnects; it doesn't open
905
+ * the connection by itself.
906
+ *
907
+ * @example
908
+ * ```ts
909
+ * const prices = createWebSocket<Tick>('wss://api.example.com/prices', { json: true });
910
+ *
911
+ * export const priceState = createWebSocketState(prices, {
912
+ * initialState: {} as Record<string, number>,
913
+ * reduce: (state, tick) => ({ ...state, [tick.symbol]: tick.price })
914
+ * });
915
+ *
916
+ * // In a component: re-renders only when ACME's price changes.
917
+ * const acme = useWebSocketState(priceState, state => state.ACME);
918
+ * ```
919
+ */
683
920
  function createWebSocketState(client, options) {
684
- const { initialState, reduce } = options;
685
- let state = initialState;
686
- const listeners = /* @__PURE__ */ new Set();
687
- function setState(update) {
688
- const next = typeof update === "function" ? update(state) : update;
689
- if (Object.is(next, state)) return;
690
- state = next;
691
- for (const listener of [...listeners]) listener();
692
- }
693
- const off = client.on("message", (message) => setState((current) => reduce(current, message)));
694
- return {
695
- client,
696
- initialState,
697
- getState: () => state,
698
- subscribe(listener) {
699
- listeners.add(listener);
700
- return () => {
701
- listeners.delete(listener);
702
- };
703
- },
704
- setState,
705
- reset: () => setState(initialState),
706
- dispose: off
707
- };
921
+ const { initialState, reduce } = options;
922
+ let state = initialState;
923
+ const listeners = /* @__PURE__ */ new Set();
924
+ function setState(update) {
925
+ const next = typeof update === "function" ? update(state) : update;
926
+ if (Object.is(next, state)) return;
927
+ state = next;
928
+ for (const listener of [...listeners]) listener();
929
+ }
930
+ return {
931
+ client,
932
+ initialState,
933
+ getState: () => state,
934
+ subscribe(listener) {
935
+ listeners.add(listener);
936
+ return () => {
937
+ listeners.delete(listener);
938
+ };
939
+ },
940
+ setState,
941
+ reset: () => setState(initialState),
942
+ dispose: client.on("message", (message) => setState((current) => reduce(current, message)))
943
+ };
708
944
  }
709
-
710
- // src/topics.ts
945
+ //#endregion
946
+ //#region src/topics.ts
947
+ /**
948
+ * Manage topic subscriptions that survive reconnects.
949
+ *
950
+ * - The first holder of a topic sends `subscribe(topic)`. Later holders share it.
951
+ * - A tick after the last holder releases it, `unsubscribe(topic)` is sent, so StrictMode
952
+ * and route transitions don't churn subscriptions.
953
+ * - After every reconnect, every held topic is subscribed again on the new socket, before
954
+ * messages queued while disconnected are sent.
955
+ *
956
+ * Subscriptions are only sent while the socket is open; nothing is queued for them.
957
+ *
958
+ * @example
959
+ * ```ts
960
+ * export const topics = createWebSocketTopics(live, {
961
+ * subscribe: topic => ({ type: 'subscribe', topic }),
962
+ * unsubscribe: topic => ({ type: 'unsubscribe', topic })
963
+ * });
964
+ *
965
+ * // In a component:
966
+ * useWebSocketTopic(topics, `prices:${symbol}`);
967
+ * ```
968
+ */
711
969
  function createWebSocketTopics(client, options) {
712
- const counts = /* @__PURE__ */ new Map();
713
- const pendingRemoval = /* @__PURE__ */ new Map();
714
- let sentOn = null;
715
- let sent = /* @__PURE__ */ new Set();
716
- function isOpen() {
717
- return client.getSnapshot().status === "open" && client.socket !== null;
718
- }
719
- function subscribe(topic) {
720
- if (!isOpen()) return;
721
- if (client.socket !== sentOn) {
722
- sentOn = client.socket;
723
- sent = /* @__PURE__ */ new Set();
724
- }
725
- if (sent.has(topic)) return;
726
- sent.add(topic);
727
- client.send(options.subscribe(topic));
728
- }
729
- function unsubscribe(topic) {
730
- if (!isOpen() || client.socket !== sentOn || !sent.delete(topic)) return;
731
- if (options.unsubscribe) client.send(options.unsubscribe(topic));
732
- }
733
- const off = client.on("open", () => {
734
- sentOn = client.socket;
735
- sent = /* @__PURE__ */ new Set();
736
- for (const topic of counts.keys()) subscribe(topic);
737
- });
738
- return {
739
- client,
740
- acquire(topic) {
741
- const releaseConnection = client.acquire();
742
- clearTimeout(pendingRemoval.get(topic));
743
- pendingRemoval.delete(topic);
744
- counts.set(topic, (counts.get(topic) ?? 0) + 1);
745
- subscribe(topic);
746
- let released = false;
747
- return () => {
748
- if (released) return;
749
- released = true;
750
- const remaining = (counts.get(topic) ?? 1) - 1;
751
- if (remaining > 0) {
752
- counts.set(topic, remaining);
753
- } else {
754
- counts.delete(topic);
755
- pendingRemoval.set(
756
- topic,
757
- setTimeout(() => {
758
- pendingRemoval.delete(topic);
759
- if (!counts.has(topic)) unsubscribe(topic);
760
- }, 0)
761
- );
762
- }
763
- releaseConnection();
764
- };
765
- },
766
- active: () => [...counts.keys()],
767
- dispose() {
768
- off();
769
- for (const timer of pendingRemoval.values()) clearTimeout(timer);
770
- pendingRemoval.clear();
771
- }
772
- };
970
+ const counts = /* @__PURE__ */ new Map();
971
+ const pendingRemoval = /* @__PURE__ */ new Map();
972
+ let sentOn = null;
973
+ let sent = /* @__PURE__ */ new Set();
974
+ function isOpen() {
975
+ return client.getSnapshot().status === "open" && client.socket !== null;
976
+ }
977
+ function subscribe(topic) {
978
+ if (!isOpen()) return;
979
+ if (client.socket !== sentOn) {
980
+ sentOn = client.socket;
981
+ sent = /* @__PURE__ */ new Set();
982
+ }
983
+ if (sent.has(topic)) return;
984
+ sent.add(topic);
985
+ client.send(options.subscribe(topic));
986
+ }
987
+ function unsubscribe(topic) {
988
+ if (!isOpen() || client.socket !== sentOn || !sent.delete(topic)) return;
989
+ if (options.unsubscribe) client.send(options.unsubscribe(topic));
990
+ }
991
+ const off = client.on("open", () => {
992
+ sentOn = client.socket;
993
+ sent = /* @__PURE__ */ new Set();
994
+ for (const topic of counts.keys()) subscribe(topic);
995
+ });
996
+ return {
997
+ client,
998
+ acquire(topic) {
999
+ const releaseConnection = client.acquire();
1000
+ clearTimeout(pendingRemoval.get(topic));
1001
+ pendingRemoval.delete(topic);
1002
+ counts.set(topic, (counts.get(topic) ?? 0) + 1);
1003
+ subscribe(topic);
1004
+ let released = false;
1005
+ return () => {
1006
+ if (released) return;
1007
+ released = true;
1008
+ const remaining = (counts.get(topic) ?? 1) - 1;
1009
+ if (remaining > 0) counts.set(topic, remaining);
1010
+ else {
1011
+ counts.delete(topic);
1012
+ pendingRemoval.set(topic, setTimeout(() => {
1013
+ pendingRemoval.delete(topic);
1014
+ if (!counts.has(topic)) unsubscribe(topic);
1015
+ }, 0));
1016
+ }
1017
+ releaseConnection();
1018
+ };
1019
+ },
1020
+ active: () => [...counts.keys()],
1021
+ dispose() {
1022
+ off();
1023
+ for (const timer of pendingRemoval.values()) clearTimeout(timer);
1024
+ pendingRemoval.clear();
1025
+ }
1026
+ };
773
1027
  }
774
- // Annotate the CommonJS export names for ESM import in node:
775
- 0 && (module.exports = {
776
- MessageError,
777
- createWebSocket,
778
- createWebSocketLatency,
779
- createWebSocketRequests,
780
- createWebSocketState,
781
- createWebSocketTopics
782
- });
1028
+ //#endregion
1029
+ exports.MessageError = MessageError;
1030
+ exports.createWebSocket = createWebSocket;
1031
+ exports.createWebSocketLatency = createWebSocketLatency;
1032
+ exports.createWebSocketRequests = createWebSocketRequests;
1033
+ exports.createWebSocketResponder = createWebSocketResponder;
1034
+ exports.createWebSocketState = createWebSocketState;
1035
+ exports.createWebSocketTopics = createWebSocketTopics;
1036
+
783
1037
  //# sourceMappingURL=core.cjs.map