react-websocket 3.2.0 → 3.5.0

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