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