react-websocket 3.6.0 → 4.18.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/README.md +34 -5
- package/dist/ag-ui.cjs +421 -0
- package/dist/ag-ui.cjs.map +1 -0
- package/dist/ag-ui.d.cts +493 -0
- package/dist/ag-ui.d.ts +171 -0
- package/dist/ag-ui.js +418 -0
- package/dist/ag-ui.js.map +1 -0
- package/dist/core.cjs +727 -162
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +501 -62
- package/dist/core.d.ts +501 -62
- package/dist/core.js +723 -163
- package/dist/core.js.map +1 -1
- package/dist/devtools.cjs +186 -0
- package/dist/devtools.cjs.map +1 -0
- package/dist/devtools.d.cts +32 -0
- package/dist/devtools.d.ts +32 -0
- package/dist/devtools.js +185 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.cjs +214 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +705 -56
- package/dist/index.d.ts +421 -30
- package/dist/index.js +211 -21
- package/dist/index.js.map +1 -1
- package/dist/outbox.cjs +222 -0
- package/dist/outbox.cjs.map +1 -0
- package/dist/outbox.d.cts +127 -0
- package/dist/outbox.d.ts +127 -0
- package/dist/outbox.js +219 -0
- package/dist/outbox.js.map +1 -0
- package/dist/replay.cjs +324 -0
- package/dist/replay.cjs.map +1 -0
- package/dist/replay.d.cts +124 -0
- package/dist/replay.d.ts +124 -0
- package/dist/replay.js +322 -0
- package/dist/replay.js.map +1 -0
- package/dist/tabs.cjs +336 -0
- package/dist/tabs.cjs.map +1 -0
- package/dist/tabs.d.cts +81 -0
- package/dist/tabs.d.ts +81 -0
- package/dist/tabs.js +335 -0
- package/dist/tabs.js.map +1 -0
- package/dist/timeline.d.cts +1 -213
- package/dist/types-BEecFqIB.d.cts +268 -0
- package/dist/webtransport.cjs +243 -0
- package/dist/webtransport.cjs.map +1 -0
- package/dist/webtransport.d.cts +67 -0
- package/dist/webtransport.d.ts +67 -0
- package/dist/webtransport.js +242 -0
- package/dist/webtransport.js.map +1 -0
- package/package.json +139 -3
package/dist/core.js
CHANGED
|
@@ -30,8 +30,43 @@ const RECONNECT_DEFAULTS = {
|
|
|
30
30
|
minDelay: 1e3,
|
|
31
31
|
maxDelay: 3e4,
|
|
32
32
|
factor: 2,
|
|
33
|
-
jitter:
|
|
33
|
+
jitter: 1,
|
|
34
|
+
minUptime: 5e3
|
|
34
35
|
};
|
|
36
|
+
const QUEUE_LIMIT = 100;
|
|
37
|
+
const MAX_TIMEOUT = 2 ** 31 - 1;
|
|
38
|
+
/**
|
|
39
|
+
* The default `reconnect.shouldReconnect`: retry every close except `1002` (protocol error),
|
|
40
|
+
* `1003` (unsupported data) and `1008` (policy violation, often a rejected login), which
|
|
41
|
+
* retrying the same way can't fix. Call it from your own `shouldReconnect` to keep that rule.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* createWebSocket(url, {
|
|
46
|
+
* // Also stop on our server's "invalid token" code.
|
|
47
|
+
* reconnect: { shouldReconnect: close => close.code !== 4001 && retryableClose(close) }
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
function retryableClose(close) {
|
|
52
|
+
return close.code !== 1002 && close.code !== 1003 && close.code !== 1008;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The default `reconnect.retryAfter`: for a `1012` (Service Restart) or `1013` (Try Again Later)
|
|
56
|
+
* close whose reason is a number of seconds, like an HTTP `Retry-After` header, that delay in ms.
|
|
57
|
+
* Call it from your own `retryAfter` to keep reading it.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* createWebSocket(url, {
|
|
62
|
+
* // Our server also closes with 4429 and the delay in ms as the reason.
|
|
63
|
+
* reconnect: { retryAfter: close => (close.code === 4429 ? Number(close.reason) : retryAfter(close)) }
|
|
64
|
+
* });
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function retryAfter(close) {
|
|
68
|
+
return (close.code === 1012 || close.code === 1013) && /^\s*\d+(\.\d+)?\s*$/.test(close.reason) ? Number(close.reason) * 1e3 : void 0;
|
|
69
|
+
}
|
|
35
70
|
const READY_GRACE = 5e3;
|
|
36
71
|
function createDeferred() {
|
|
37
72
|
let resolve;
|
|
@@ -76,6 +111,10 @@ function isThenable(value) {
|
|
|
76
111
|
* because the hooks entry imports the client from the `core` entry at runtime.
|
|
77
112
|
*/
|
|
78
113
|
const ON_TEARDOWN = Symbol.for("react-websocket.onTeardown");
|
|
114
|
+
/** @internal Method key on a socket that sends `volatile` messages as datagrams (WebTransport). */
|
|
115
|
+
const DATAGRAM = Symbol.for("react-websocket.datagram");
|
|
116
|
+
/** @internal Method key for what `react-websocket/devtools` shows that the snapshot doesn't. */
|
|
117
|
+
const INSPECT = Symbol.for("react-websocket.inspect");
|
|
79
118
|
function createClient(url, options = {}) {
|
|
80
119
|
const onTeardown = options[ON_TEARDOWN];
|
|
81
120
|
const log = options.logger ?? (() => {});
|
|
@@ -83,7 +122,9 @@ function createClient(url, options = {}) {
|
|
|
83
122
|
...RECONNECT_DEFAULTS,
|
|
84
123
|
...typeof options.reconnect === "object" ? options.reconnect : {}
|
|
85
124
|
};
|
|
86
|
-
const
|
|
125
|
+
const queue = typeof options.queue === "object" ? options.queue : {};
|
|
126
|
+
const queueLimit = options.queue === false ? 0 : Math.max(0, queue.limit ?? QUEUE_LIMIT);
|
|
127
|
+
const queueMaxAge = queue.maxAge ?? Infinity;
|
|
87
128
|
let snapshot = {
|
|
88
129
|
status: "idle",
|
|
89
130
|
lastMessage: void 0,
|
|
@@ -97,9 +138,13 @@ function createClient(url, options = {}) {
|
|
|
97
138
|
error: /* @__PURE__ */ new Set()
|
|
98
139
|
};
|
|
99
140
|
let ws = null;
|
|
141
|
+
let incoming = null;
|
|
142
|
+
let replaced = null;
|
|
143
|
+
let moved;
|
|
100
144
|
let wanted = false;
|
|
101
145
|
let refs = 0;
|
|
102
146
|
let pinned = false;
|
|
147
|
+
let unheldStop = false;
|
|
103
148
|
let pending = [];
|
|
104
149
|
let ready = null;
|
|
105
150
|
let opened = false;
|
|
@@ -108,8 +153,12 @@ function createClient(url, options = {}) {
|
|
|
108
153
|
let inbox = null;
|
|
109
154
|
let generation = 0;
|
|
110
155
|
let windowBound = false;
|
|
156
|
+
let failures = 0;
|
|
157
|
+
let serverWait = false;
|
|
111
158
|
let retryTimer;
|
|
159
|
+
let migrateTimer;
|
|
112
160
|
let teardownTimer;
|
|
161
|
+
let stableTimer;
|
|
113
162
|
let heartbeatTimer;
|
|
114
163
|
let pongTimer;
|
|
115
164
|
function set(patch) {
|
|
@@ -132,54 +181,62 @@ function createClient(url, options = {}) {
|
|
|
132
181
|
}
|
|
133
182
|
function getImpl() {
|
|
134
183
|
if (options.WebSocket) return options.WebSocket;
|
|
135
|
-
return typeof window === "undefined" ? void 0 : globalThis.WebSocket;
|
|
184
|
+
return typeof window === "undefined" && typeof WorkerGlobalScope === "undefined" ? void 0 : globalThis.WebSocket;
|
|
136
185
|
}
|
|
137
186
|
function rejectReady(reason) {
|
|
138
187
|
if (!ready || ready.settled) ready = createDeferred();
|
|
139
188
|
ready.reject(reason);
|
|
140
189
|
}
|
|
141
|
-
|
|
190
|
+
/** The network or the tab came back: retry now, or check that an open connection still works. */
|
|
191
|
+
function wake() {
|
|
142
192
|
if (retryTimer !== void 0) {
|
|
193
|
+
if (serverWait) return;
|
|
143
194
|
log("network or visibility change, retrying now");
|
|
144
195
|
open();
|
|
196
|
+
} else if (heartbeatTimer !== void 0 && pongTimer === void 0) {
|
|
197
|
+
log("network or visibility change, checking the connection");
|
|
198
|
+
ping(ws);
|
|
145
199
|
}
|
|
146
200
|
}
|
|
147
201
|
function onVisibilityChange() {
|
|
148
|
-
if (document.visibilityState === "visible")
|
|
202
|
+
if (document.visibilityState === "visible") wake();
|
|
149
203
|
}
|
|
150
204
|
function bindWindow() {
|
|
151
|
-
if (windowBound || typeof
|
|
205
|
+
if (windowBound || typeof addEventListener !== "function") return;
|
|
152
206
|
windowBound = true;
|
|
153
|
-
|
|
154
|
-
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
207
|
+
addEventListener("online", wake);
|
|
208
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibilityChange);
|
|
155
209
|
}
|
|
156
210
|
function unbindWindow() {
|
|
157
211
|
if (!windowBound) return;
|
|
158
212
|
windowBound = false;
|
|
159
|
-
|
|
160
|
-
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
213
|
+
removeEventListener("online", wake);
|
|
214
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
161
215
|
}
|
|
162
|
-
|
|
216
|
+
/** Send a heartbeat ping and, with a `timeout`, close the socket if nothing comes back. */
|
|
217
|
+
function ping(socket) {
|
|
163
218
|
const heartbeat = options.heartbeat;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
socket.send(heartbeat.message ?? "ping");
|
|
220
|
+
if (heartbeat.timeout && pongTimer === void 0) pongTimer = setTimeout(() => {
|
|
221
|
+
log("heartbeat timeout");
|
|
222
|
+
detach(socket, false);
|
|
223
|
+
socket.close(4e3, "heartbeat timeout");
|
|
224
|
+
handleClose({
|
|
225
|
+
code: 4e3,
|
|
226
|
+
reason: "heartbeat timeout",
|
|
227
|
+
wasClean: false
|
|
228
|
+
});
|
|
229
|
+
}, heartbeat.timeout);
|
|
230
|
+
}
|
|
231
|
+
function startHeartbeat(socket) {
|
|
232
|
+
if (options.heartbeat) heartbeatTimer = setInterval(ping, options.heartbeat.interval, socket);
|
|
178
233
|
}
|
|
179
|
-
|
|
234
|
+
/** Stop the timers that belong to the current open socket. */
|
|
235
|
+
function stopSocketTimers() {
|
|
180
236
|
clearInterval(heartbeatTimer);
|
|
181
237
|
clearTimeout(pongTimer);
|
|
182
|
-
|
|
238
|
+
clearTimeout(stableTimer);
|
|
239
|
+
heartbeatTimer = pongTimer = stableTimer = void 0;
|
|
183
240
|
}
|
|
184
241
|
/** Stop tracking `socket`. Its eventual close event is still reported unless `reportClose` is false. */
|
|
185
242
|
function detach(socket, reportClose) {
|
|
@@ -191,65 +248,100 @@ function createClient(url, options = {}) {
|
|
|
191
248
|
wasClean: event.wasClean
|
|
192
249
|
}) : null;
|
|
193
250
|
}
|
|
194
|
-
|
|
251
|
+
/** The delay before attempt `n`; `slow` starts at `maxDelay`, for a server shedding load. */
|
|
252
|
+
function backoff(attempt, slow) {
|
|
195
253
|
const { minDelay, maxDelay, factor, jitter } = retry;
|
|
196
|
-
const base = Math.min(maxDelay, minDelay * factor ** (attempt - 1));
|
|
254
|
+
const base = slow ? maxDelay : Math.min(maxDelay, minDelay * factor ** (attempt - 1));
|
|
197
255
|
return Math.round(base * (1 - jitter * Math.random()));
|
|
198
256
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
257
|
+
/** Stop a `migrate()` that hasn't switched yet. */
|
|
258
|
+
function dropIncoming() {
|
|
259
|
+
if (incoming) {
|
|
260
|
+
detach(incoming, false);
|
|
261
|
+
incoming.close();
|
|
262
|
+
incoming = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Start a connection attempt. A `handoff` (from `migrate()`) opens beside the current
|
|
267
|
+
* connection, which keeps working until the new one opens.
|
|
268
|
+
*/
|
|
269
|
+
function open(handoff) {
|
|
270
|
+
if (!handoff) {
|
|
271
|
+
clearTimeout(retryTimer);
|
|
272
|
+
retryTimer = void 0;
|
|
273
|
+
serverWait = false;
|
|
274
|
+
dropIncoming();
|
|
275
|
+
}
|
|
202
276
|
const Impl = getImpl();
|
|
203
277
|
if (!Impl) return;
|
|
204
278
|
const id = ++generation;
|
|
205
|
-
set({ status: snapshot.attempt === 0 ? "connecting" : "reconnecting" });
|
|
279
|
+
if (!handoff) set({ status: snapshot.attempt === 0 ? "connecting" : "reconnecting" });
|
|
280
|
+
const source = moved ?? url;
|
|
206
281
|
let target;
|
|
207
282
|
try {
|
|
208
|
-
target = typeof
|
|
283
|
+
target = typeof source === "string" ? source : source();
|
|
209
284
|
} catch (error) {
|
|
210
|
-
urlFailed(error);
|
|
285
|
+
urlFailed(error, handoff);
|
|
211
286
|
return;
|
|
212
287
|
}
|
|
213
288
|
if (typeof target === "string") {
|
|
214
|
-
start(Impl, target);
|
|
289
|
+
start(Impl, target, handoff);
|
|
215
290
|
return;
|
|
216
291
|
}
|
|
217
292
|
Promise.resolve(target).then((resolved) => {
|
|
218
|
-
if (id === generation && wanted) start(Impl, resolved);
|
|
293
|
+
if (id === generation && wanted) start(Impl, resolved, handoff);
|
|
219
294
|
}, (error) => {
|
|
220
|
-
if (id === generation && wanted) urlFailed(error);
|
|
295
|
+
if (id === generation && wanted) urlFailed(error, handoff);
|
|
221
296
|
});
|
|
222
297
|
}
|
|
223
|
-
/**
|
|
224
|
-
|
|
298
|
+
/**
|
|
299
|
+
* `url()` threw or rejected (e.g. a token refresh failed): count it as a failed attempt,
|
|
300
|
+
* unless it was for a handoff, which leaves the current connection in place.
|
|
301
|
+
*/
|
|
302
|
+
function urlFailed(error, handoff) {
|
|
225
303
|
const reason = error instanceof Error ? error : new Error(String(error));
|
|
226
304
|
log("url() failed", reason);
|
|
227
305
|
emit("error", reason);
|
|
228
|
-
retryOrFail({
|
|
306
|
+
if (!handoff) retryOrFail({
|
|
229
307
|
code: 1006,
|
|
230
308
|
reason: "url() failed",
|
|
231
309
|
wasClean: false
|
|
232
310
|
}, reason);
|
|
233
311
|
}
|
|
234
|
-
function start(Impl, target) {
|
|
312
|
+
function start(Impl, target, handoff) {
|
|
235
313
|
let socket;
|
|
236
314
|
try {
|
|
237
315
|
socket = new Impl(target, options.protocols);
|
|
238
316
|
} catch (error) {
|
|
239
317
|
emit("error", error);
|
|
240
|
-
fail(error);
|
|
318
|
+
if (!handoff) fail(error);
|
|
241
319
|
return;
|
|
242
320
|
}
|
|
243
321
|
if (options.binaryType) socket.binaryType = options.binaryType;
|
|
244
|
-
|
|
322
|
+
if (handoff) incoming = socket;
|
|
323
|
+
else ws = socket;
|
|
245
324
|
log("connecting", target);
|
|
246
325
|
socket.onopen = (event) => {
|
|
326
|
+
if (handoff) {
|
|
327
|
+
incoming = null;
|
|
328
|
+
clearTimeout(retryTimer);
|
|
329
|
+
retryTimer = void 0;
|
|
330
|
+
stopSocketTimers();
|
|
331
|
+
if (ws) {
|
|
332
|
+
const old = replaced = ws;
|
|
333
|
+
detach(old, true);
|
|
334
|
+
old.close(1e3, "migrated");
|
|
335
|
+
}
|
|
336
|
+
ws = socket;
|
|
337
|
+
}
|
|
247
338
|
log("open");
|
|
248
339
|
opened = true;
|
|
249
340
|
set({
|
|
250
341
|
status: "open",
|
|
251
342
|
attempt: 0
|
|
252
343
|
});
|
|
344
|
+
if (retry) stableTimer = setTimeout(() => failures = 0, retry.minUptime);
|
|
253
345
|
startHeartbeat(socket);
|
|
254
346
|
ready?.resolve();
|
|
255
347
|
if (refs === 0 && !pinned) {
|
|
@@ -260,11 +352,20 @@ function createClient(url, options = {}) {
|
|
|
260
352
|
if (ws !== socket) return;
|
|
261
353
|
const queued = pending;
|
|
262
354
|
pending = [];
|
|
263
|
-
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
for (const entry of queued) if (entry.expires >= now) socket.send(entry.data);
|
|
357
|
+
for (const entry of queued) if (entry.expires < now) expired(entry);
|
|
264
358
|
};
|
|
265
359
|
socket.onmessage = (event) => receive(event, socket);
|
|
266
|
-
socket.onerror = (event) =>
|
|
360
|
+
socket.onerror = (event) => {
|
|
361
|
+
if (ws === socket) emit("error", event);
|
|
362
|
+
};
|
|
267
363
|
socket.onclose = (event) => {
|
|
364
|
+
if (ws !== socket) {
|
|
365
|
+
incoming = null;
|
|
366
|
+
log("migrate() failed", event.code);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
268
369
|
detach(socket, false);
|
|
269
370
|
handleClose({
|
|
270
371
|
code: event.code,
|
|
@@ -274,15 +375,19 @@ function createClient(url, options = {}) {
|
|
|
274
375
|
};
|
|
275
376
|
}
|
|
276
377
|
function handleClose(info) {
|
|
277
|
-
|
|
378
|
+
replaced = null;
|
|
379
|
+
stopSocketTimers();
|
|
278
380
|
log("closed", info);
|
|
279
381
|
retryOrFail(info);
|
|
280
382
|
emit("close", info);
|
|
281
383
|
}
|
|
282
384
|
function retryOrFail(info, error) {
|
|
283
|
-
if (wanted && retry &&
|
|
284
|
-
const attempt =
|
|
285
|
-
const
|
|
385
|
+
if (wanted && retry && failures < retry.maxAttempts && (retry.shouldReconnect ?? retryableClose)(info)) {
|
|
386
|
+
const attempt = ++failures;
|
|
387
|
+
const asked = (retry.retryAfter ?? retryAfter)(info);
|
|
388
|
+
const named = asked >= 0;
|
|
389
|
+
const delay = Math.min(MAX_TIMEOUT, named ? asked + backoff(attempt, false) : backoff(attempt, info.code === 1013));
|
|
390
|
+
serverWait = named || info.code === 1013;
|
|
286
391
|
set({
|
|
287
392
|
status: "reconnecting",
|
|
288
393
|
attempt
|
|
@@ -291,12 +396,34 @@ function createClient(url, options = {}) {
|
|
|
291
396
|
retryTimer = setTimeout(open, delay);
|
|
292
397
|
} else fail(error ?? /* @__PURE__ */ new Error(`WebSocket closed (code ${info.code}${info.reason ? `: ${info.reason}` : ""})`));
|
|
293
398
|
}
|
|
399
|
+
/** Forget `migrate()`: its timer, its connection and the URL it moved to. */
|
|
400
|
+
function stopMigrating() {
|
|
401
|
+
clearTimeout(migrateTimer);
|
|
402
|
+
migrateTimer = void 0;
|
|
403
|
+
dropIncoming();
|
|
404
|
+
moved = void 0;
|
|
405
|
+
}
|
|
406
|
+
function migrate({ url: to, within } = {}) {
|
|
407
|
+
clearTimeout(migrateTimer);
|
|
408
|
+
dropIncoming();
|
|
409
|
+
migrateTimer = setTimeout(() => {
|
|
410
|
+
migrateTimer = void 0;
|
|
411
|
+
if (!wanted) return;
|
|
412
|
+
if (to !== void 0) moved = to;
|
|
413
|
+
if (snapshot.status === "open") open(true);
|
|
414
|
+
}, Math.min(MAX_TIMEOUT, Math.random() * (within ?? 0)));
|
|
415
|
+
}
|
|
294
416
|
function fail(reason) {
|
|
295
417
|
wanted = false;
|
|
296
418
|
opened = false;
|
|
419
|
+
unheldStop = refs === 0;
|
|
297
420
|
unbindWindow();
|
|
421
|
+
stopMigrating();
|
|
298
422
|
pending = [];
|
|
299
|
-
set({
|
|
423
|
+
set({
|
|
424
|
+
status: "closed",
|
|
425
|
+
attempt: failures
|
|
426
|
+
});
|
|
300
427
|
rejectReady(reason);
|
|
301
428
|
}
|
|
302
429
|
function receive(event, socket) {
|
|
@@ -344,7 +471,7 @@ function createClient(url, options = {}) {
|
|
|
344
471
|
else deliver(result.value, event, socket);
|
|
345
472
|
}
|
|
346
473
|
function deliver(message, event, socket) {
|
|
347
|
-
if (ws !== socket) return;
|
|
474
|
+
if (ws !== socket && replaced !== socket) return;
|
|
348
475
|
set({ lastMessage: message });
|
|
349
476
|
emit("message", message, event);
|
|
350
477
|
}
|
|
@@ -356,7 +483,9 @@ function createClient(url, options = {}) {
|
|
|
356
483
|
log("dropped message: send buffer above maxBuffered");
|
|
357
484
|
return false;
|
|
358
485
|
}
|
|
359
|
-
ws
|
|
486
|
+
const datagram = sendOptions.volatile ? ws[DATAGRAM] : void 0;
|
|
487
|
+
if (datagram) datagram.call(ws, data);
|
|
488
|
+
else ws.send(data);
|
|
360
489
|
return true;
|
|
361
490
|
}
|
|
362
491
|
if (queueLimit === 0 || sendOptions.volatile) {
|
|
@@ -367,18 +496,18 @@ function createClient(url, options = {}) {
|
|
|
367
496
|
log("dropped message: no WebSocket implementation available");
|
|
368
497
|
return false;
|
|
369
498
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
const before = pending;
|
|
501
|
+
pending = before.filter((entry) => entry.expires >= now && (key === void 0 || entry.key !== key));
|
|
502
|
+
for (const entry of before) if (!pending.includes(entry)) (entry.expires < now ? expired : dropped)(entry);
|
|
375
503
|
let head = pending.findIndex((entry) => !entry.priority);
|
|
376
504
|
if (head < 0) head = pending.length;
|
|
377
505
|
const entry = {
|
|
378
506
|
data,
|
|
379
507
|
key,
|
|
380
508
|
priority,
|
|
381
|
-
onDrop: sendOptions.onDrop
|
|
509
|
+
onDrop: sendOptions.onDrop,
|
|
510
|
+
expires: now + (sendOptions.maxAge ?? queueMaxAge)
|
|
382
511
|
};
|
|
383
512
|
if (priority) pending.splice(head++, 0, entry);
|
|
384
513
|
else pending.push(entry);
|
|
@@ -388,6 +517,10 @@ function createClient(url, options = {}) {
|
|
|
388
517
|
}
|
|
389
518
|
return false;
|
|
390
519
|
}
|
|
520
|
+
function expired(entry) {
|
|
521
|
+
log("dropped queued message: older than maxAge");
|
|
522
|
+
dropped(entry);
|
|
523
|
+
}
|
|
391
524
|
function dropped(entry) {
|
|
392
525
|
try {
|
|
393
526
|
entry.onDrop?.();
|
|
@@ -397,10 +530,12 @@ function createClient(url, options = {}) {
|
|
|
397
530
|
}
|
|
398
531
|
function begin() {
|
|
399
532
|
wanted = true;
|
|
533
|
+
unheldStop = false;
|
|
400
534
|
if (snapshot.status === "idle" || snapshot.status === "closed") {
|
|
401
535
|
opened = false;
|
|
402
536
|
if (ready?.settled) ready = null;
|
|
403
537
|
}
|
|
538
|
+
failures = 0;
|
|
404
539
|
snapshot = {
|
|
405
540
|
...snapshot,
|
|
406
541
|
attempt: 0
|
|
@@ -418,23 +553,25 @@ function createClient(url, options = {}) {
|
|
|
418
553
|
ensureConnecting();
|
|
419
554
|
}
|
|
420
555
|
function stop(code, reason) {
|
|
556
|
+
const socket = ws;
|
|
557
|
+
socket?.close(code, reason);
|
|
421
558
|
wanted = false;
|
|
422
559
|
generation++;
|
|
423
560
|
clearTimeout(retryTimer);
|
|
424
561
|
retryTimer = void 0;
|
|
425
|
-
|
|
562
|
+
failures = 0;
|
|
563
|
+
stopSocketTimers();
|
|
426
564
|
unbindWindow();
|
|
565
|
+
stopMigrating();
|
|
427
566
|
pending = [];
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
detach(socket, true);
|
|
431
|
-
socket.close(code, reason);
|
|
432
|
-
}
|
|
567
|
+
replaced = null;
|
|
568
|
+
if (socket) detach(socket, true);
|
|
433
569
|
}
|
|
434
570
|
function close(code = 1e3, reason) {
|
|
571
|
+
stop(code, reason);
|
|
435
572
|
pinned = false;
|
|
436
573
|
opened = false;
|
|
437
|
-
|
|
574
|
+
unheldStop = refs === 0;
|
|
438
575
|
set({
|
|
439
576
|
status: "closed",
|
|
440
577
|
attempt: 0
|
|
@@ -445,7 +582,7 @@ function createClient(url, options = {}) {
|
|
|
445
582
|
if (!getImpl()) return;
|
|
446
583
|
clearTimeout(retryTimer);
|
|
447
584
|
retryTimer = void 0;
|
|
448
|
-
|
|
585
|
+
stopSocketTimers();
|
|
449
586
|
if (ws) {
|
|
450
587
|
const socket = ws;
|
|
451
588
|
detach(socket, true);
|
|
@@ -468,7 +605,7 @@ function createClient(url, options = {}) {
|
|
|
468
605
|
function acquire() {
|
|
469
606
|
refs++;
|
|
470
607
|
clearTimeout(teardownTimer);
|
|
471
|
-
if (snapshot.status === "idle") ensureConnecting();
|
|
608
|
+
if (snapshot.status === "idle" || snapshot.status === "closed" && unheldStop) ensureConnecting();
|
|
472
609
|
let released = false;
|
|
473
610
|
return () => {
|
|
474
611
|
if (released) return;
|
|
@@ -522,7 +659,9 @@ function createClient(url, options = {}) {
|
|
|
522
659
|
connect,
|
|
523
660
|
close,
|
|
524
661
|
reconnect,
|
|
525
|
-
|
|
662
|
+
migrate,
|
|
663
|
+
acquire,
|
|
664
|
+
[INSPECT]: () => ({ queued: pending.length })
|
|
526
665
|
};
|
|
527
666
|
}
|
|
528
667
|
/**
|
|
@@ -548,6 +687,175 @@ function createWebSocket(url, options) {
|
|
|
548
687
|
return createClient(url, options);
|
|
549
688
|
}
|
|
550
689
|
//#endregion
|
|
690
|
+
//#region src/topics.ts
|
|
691
|
+
/** @internal Option key for a callback run when nothing holds a topic anymore. Used by feeds. */
|
|
692
|
+
const ON_RELEASE = Symbol("react-websocket.onRelease");
|
|
693
|
+
/**
|
|
694
|
+
* Manage topic subscriptions that survive reconnects.
|
|
695
|
+
*
|
|
696
|
+
* - The first holder of a topic sends `subscribe(topic)`. Later holders share it.
|
|
697
|
+
* - A tick after the last holder releases it, `unsubscribe(topic)` is sent, so StrictMode
|
|
698
|
+
* and route transitions don't churn subscriptions.
|
|
699
|
+
* - After every reconnect, every held topic is subscribed again on the new socket, before
|
|
700
|
+
* messages queued while disconnected are sent.
|
|
701
|
+
*
|
|
702
|
+
* Subscriptions are only sent while the socket is open; nothing is queued for them.
|
|
703
|
+
*
|
|
704
|
+
* @example
|
|
705
|
+
* ```ts
|
|
706
|
+
* export const topics = createWebSocketTopics(live, {
|
|
707
|
+
* subscribe: topic => ({ type: 'subscribe', topic }),
|
|
708
|
+
* unsubscribe: topic => ({ type: 'unsubscribe', topic })
|
|
709
|
+
* });
|
|
710
|
+
*
|
|
711
|
+
* // In a component:
|
|
712
|
+
* useWebSocketTopic(topics, `prices:${symbol}`);
|
|
713
|
+
* ```
|
|
714
|
+
*/
|
|
715
|
+
function createWebSocketTopics(client, options) {
|
|
716
|
+
const counts = /* @__PURE__ */ new Map();
|
|
717
|
+
const pendingRemoval = /* @__PURE__ */ new Map();
|
|
718
|
+
let sentOn = null;
|
|
719
|
+
let sent = /* @__PURE__ */ new Set();
|
|
720
|
+
function isOpen() {
|
|
721
|
+
return client.socket?.readyState === 1;
|
|
722
|
+
}
|
|
723
|
+
function subscribe(topic) {
|
|
724
|
+
if (!isOpen()) return;
|
|
725
|
+
if (client.socket !== sentOn) {
|
|
726
|
+
sentOn = client.socket;
|
|
727
|
+
sent = /* @__PURE__ */ new Set();
|
|
728
|
+
}
|
|
729
|
+
if (sent.has(topic)) return;
|
|
730
|
+
sent.add(topic);
|
|
731
|
+
client.send(options.subscribe(topic));
|
|
732
|
+
}
|
|
733
|
+
function unsubscribe(topic) {
|
|
734
|
+
if (!isOpen() || client.socket !== sentOn || !sent.delete(topic)) return;
|
|
735
|
+
if (options.unsubscribe) client.send(options.unsubscribe(topic));
|
|
736
|
+
}
|
|
737
|
+
const off = client.on("open", () => {
|
|
738
|
+
sentOn = client.socket;
|
|
739
|
+
sent = /* @__PURE__ */ new Set();
|
|
740
|
+
for (const topic of counts.keys()) subscribe(topic);
|
|
741
|
+
});
|
|
742
|
+
return {
|
|
743
|
+
client,
|
|
744
|
+
acquire(topic) {
|
|
745
|
+
const releaseConnection = client.acquire();
|
|
746
|
+
clearTimeout(pendingRemoval.get(topic));
|
|
747
|
+
pendingRemoval.delete(topic);
|
|
748
|
+
counts.set(topic, (counts.get(topic) ?? 0) + 1);
|
|
749
|
+
subscribe(topic);
|
|
750
|
+
let released = false;
|
|
751
|
+
return () => {
|
|
752
|
+
if (released) return;
|
|
753
|
+
released = true;
|
|
754
|
+
const remaining = (counts.get(topic) ?? 1) - 1;
|
|
755
|
+
if (remaining > 0) counts.set(topic, remaining);
|
|
756
|
+
else {
|
|
757
|
+
counts.delete(topic);
|
|
758
|
+
pendingRemoval.set(topic, setTimeout(() => {
|
|
759
|
+
pendingRemoval.delete(topic);
|
|
760
|
+
if (!counts.has(topic)) {
|
|
761
|
+
unsubscribe(topic);
|
|
762
|
+
options[ON_RELEASE]?.(topic);
|
|
763
|
+
}
|
|
764
|
+
}, 0));
|
|
765
|
+
}
|
|
766
|
+
releaseConnection();
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
active: () => [...counts.keys()],
|
|
770
|
+
dispose() {
|
|
771
|
+
off();
|
|
772
|
+
for (const timer of pendingRemoval.values()) clearTimeout(timer);
|
|
773
|
+
pendingRemoval.clear();
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/feeds.ts
|
|
779
|
+
/**
|
|
780
|
+
* Subscribe to topics whose messages carry a sequence number, and get each message once, in
|
|
781
|
+
* order, across reconnects:
|
|
782
|
+
*
|
|
783
|
+
* - On every new connection, each held topic is subscribed again with `after`, the last
|
|
784
|
+
* number received, so the server sends what was missed instead of everything.
|
|
785
|
+
* - A message numbered at or below the last one is a duplicate and is dropped.
|
|
786
|
+
* - A message that skips numbers is held back, and the topic asks for what's missing (`resume`)
|
|
787
|
+
* once, so nothing is delivered out of order.
|
|
788
|
+
* - A server that can't replay sends a `snapshot`, and the numbering goes on from it.
|
|
789
|
+
*
|
|
790
|
+
* Listen with `feeds.on('message', …)`, or pass `feeds` to `createWebSocketState` in place of
|
|
791
|
+
* the client. Hold topics with `useWebSocketTopic(feeds, topic)` or `feeds.acquire(topic)`.
|
|
792
|
+
*
|
|
793
|
+
* @example
|
|
794
|
+
* ```ts
|
|
795
|
+
* export const feeds = createWebSocketFeeds(live, {
|
|
796
|
+
* subscribe: (topic, after) => ({ type: 'subscribe', topic, after }),
|
|
797
|
+
* unsubscribe: topic => ({ type: 'unsubscribe', topic }),
|
|
798
|
+
* sequence: message => (message.type === 'event' ? message : undefined),
|
|
799
|
+
* snapshot: message => (message.type === 'snapshot' ? message : undefined)
|
|
800
|
+
* });
|
|
801
|
+
*
|
|
802
|
+
* export const chat = createWebSocketState(feeds, { initialState: [], reduce: addMessage });
|
|
803
|
+
* ```
|
|
804
|
+
*/
|
|
805
|
+
function createWebSocketFeeds(client, options) {
|
|
806
|
+
const last = /* @__PURE__ */ new Map();
|
|
807
|
+
const resuming = /* @__PURE__ */ new Set();
|
|
808
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
809
|
+
const topics = createWebSocketTopics(client, {
|
|
810
|
+
subscribe: (topic) => options.subscribe(topic, last.get(topic)),
|
|
811
|
+
unsubscribe: options.unsubscribe,
|
|
812
|
+
[ON_RELEASE]: (topic) => {
|
|
813
|
+
last.delete(topic);
|
|
814
|
+
resuming.delete(topic);
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
const offs = [client.on("open", () => resuming.clear()), client.on("message", (message, event) => {
|
|
818
|
+
const snapshot = options.snapshot?.(message);
|
|
819
|
+
const position = snapshot ?? options.sequence(message);
|
|
820
|
+
if (position) {
|
|
821
|
+
const { topic, seq } = position;
|
|
822
|
+
const prev = last.get(topic);
|
|
823
|
+
if (prev !== void 0 && !snapshot && seq !== prev + 1) {
|
|
824
|
+
if (seq > prev && !resuming.has(topic)) {
|
|
825
|
+
resuming.add(topic);
|
|
826
|
+
options.onGap?.(topic, prev, seq);
|
|
827
|
+
if (client.socket?.readyState === 1) client.send((options.resume ?? options.subscribe)(topic, prev));
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (prev !== void 0 || topics.active().includes(topic)) last.set(topic, seq);
|
|
832
|
+
resuming.delete(topic);
|
|
833
|
+
}
|
|
834
|
+
for (const listener of [...listeners]) try {
|
|
835
|
+
listener(message, event);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
queueMicrotask(() => {
|
|
838
|
+
throw error;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
})];
|
|
842
|
+
return {
|
|
843
|
+
...topics,
|
|
844
|
+
on(_type, listener) {
|
|
845
|
+
const entry = (message, event) => listener(message, event);
|
|
846
|
+
listeners.add(entry);
|
|
847
|
+
return () => {
|
|
848
|
+
listeners.delete(entry);
|
|
849
|
+
};
|
|
850
|
+
},
|
|
851
|
+
position: (topic) => last.get(topic),
|
|
852
|
+
dispose() {
|
|
853
|
+
topics.dispose();
|
|
854
|
+
for (const off of offs) off();
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
//#endregion
|
|
551
859
|
//#region src/latency.ts
|
|
552
860
|
const EMPTY = Object.freeze({
|
|
553
861
|
rtt: void 0,
|
|
@@ -657,9 +965,118 @@ function createWebSocketLatency(client, options) {
|
|
|
657
965
|
};
|
|
658
966
|
}
|
|
659
967
|
//#endregion
|
|
968
|
+
//#region src/presence.ts
|
|
969
|
+
const VOLATILE$1 = { volatile: true };
|
|
970
|
+
/**
|
|
971
|
+
* Share per-peer state that expires, for presence, live cursors and selections.
|
|
972
|
+
*
|
|
973
|
+
* - `set(state)` shares this client's state, throttled to `maxRate` sends per second with the
|
|
974
|
+
* newest winning, and sent again on every new connection and every `refresh`, so it's never
|
|
975
|
+
* queued stale while disconnected.
|
|
976
|
+
* - Other peers' states come from `receive`. A peer is forgotten when it leaves (`state: null`)
|
|
977
|
+
* or when nothing has arrived from it for `timeout`.
|
|
978
|
+
*
|
|
979
|
+
* The server relays each client's state to the others, tagged with a peer id, and says
|
|
980
|
+
* `state: null` when a client disconnects.
|
|
981
|
+
*
|
|
982
|
+
* @example
|
|
983
|
+
* ```ts
|
|
984
|
+
* export const cursors = createWebSocketPresence(room, {
|
|
985
|
+
* send: (cursor: { x: number; y: number }) => ({ type: 'cursor', cursor }),
|
|
986
|
+
* leave: () => ({ type: 'leave' }),
|
|
987
|
+
* receive: message => (message.type === 'cursor' ? { peer: message.peer, state: message.cursor } : undefined)
|
|
988
|
+
* });
|
|
989
|
+
*
|
|
990
|
+
* window.addEventListener('pointermove', event => cursors.set({ x: event.clientX, y: event.clientY }));
|
|
991
|
+
* ```
|
|
992
|
+
*/
|
|
993
|
+
function createWebSocketPresence(client, options) {
|
|
994
|
+
const timeout = options.timeout ?? 3e4;
|
|
995
|
+
const interval = 1e3 / (options.maxRate ?? 20);
|
|
996
|
+
const peers = /* @__PURE__ */ new Map();
|
|
997
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
998
|
+
let snapshot = [];
|
|
999
|
+
let own;
|
|
1000
|
+
let lastSent = -Infinity;
|
|
1001
|
+
let sendTimer;
|
|
1002
|
+
let refreshTimer;
|
|
1003
|
+
let expiryTimer;
|
|
1004
|
+
function changed() {
|
|
1005
|
+
snapshot = [...peers.values()];
|
|
1006
|
+
for (const listener of [...listeners]) listener();
|
|
1007
|
+
}
|
|
1008
|
+
function sendNow() {
|
|
1009
|
+
clearTimeout(sendTimer);
|
|
1010
|
+
sendTimer = void 0;
|
|
1011
|
+
if (!own) return;
|
|
1012
|
+
lastSent = Date.now();
|
|
1013
|
+
client.send(options.send(own.state), VOLATILE$1);
|
|
1014
|
+
}
|
|
1015
|
+
function expire() {
|
|
1016
|
+
expiryTimer = void 0;
|
|
1017
|
+
const now = Date.now();
|
|
1018
|
+
let next = Infinity;
|
|
1019
|
+
let removed = false;
|
|
1020
|
+
for (const [peer, entry] of peers) {
|
|
1021
|
+
const at = entry.updatedAt + timeout;
|
|
1022
|
+
if (at <= now) removed = peers.delete(peer);
|
|
1023
|
+
else next = Math.min(next, at);
|
|
1024
|
+
}
|
|
1025
|
+
if (next < Infinity) expiryTimer = setTimeout(expire, next - now);
|
|
1026
|
+
if (removed) changed();
|
|
1027
|
+
}
|
|
1028
|
+
const offs = [client.on("open", sendNow), client.on("message", (message) => {
|
|
1029
|
+
const update = options.receive(message);
|
|
1030
|
+
if (!update) return;
|
|
1031
|
+
if (update.state === null) {
|
|
1032
|
+
if (!peers.delete(update.peer)) return;
|
|
1033
|
+
} else {
|
|
1034
|
+
peers.set(update.peer, {
|
|
1035
|
+
peer: update.peer,
|
|
1036
|
+
state: update.state,
|
|
1037
|
+
updatedAt: Date.now()
|
|
1038
|
+
});
|
|
1039
|
+
expiryTimer ??= setTimeout(expire, timeout);
|
|
1040
|
+
}
|
|
1041
|
+
changed();
|
|
1042
|
+
})];
|
|
1043
|
+
return {
|
|
1044
|
+
client,
|
|
1045
|
+
set(state) {
|
|
1046
|
+
own = { state };
|
|
1047
|
+
refreshTimer ??= setInterval(sendNow, options.refresh ?? timeout / 3);
|
|
1048
|
+
if (sendTimer !== void 0) return;
|
|
1049
|
+
const wait = lastSent + interval - Date.now();
|
|
1050
|
+
if (wait <= 0) sendNow();
|
|
1051
|
+
else sendTimer = setTimeout(sendNow, wait);
|
|
1052
|
+
},
|
|
1053
|
+
leave() {
|
|
1054
|
+
own = void 0;
|
|
1055
|
+
clearTimeout(sendTimer);
|
|
1056
|
+
sendTimer = void 0;
|
|
1057
|
+
clearInterval(refreshTimer);
|
|
1058
|
+
refreshTimer = void 0;
|
|
1059
|
+
if (options.leave) client.send(options.leave(), VOLATILE$1);
|
|
1060
|
+
},
|
|
1061
|
+
getSnapshot: () => snapshot,
|
|
1062
|
+
subscribe(listener) {
|
|
1063
|
+
listeners.add(listener);
|
|
1064
|
+
return () => {
|
|
1065
|
+
listeners.delete(listener);
|
|
1066
|
+
};
|
|
1067
|
+
},
|
|
1068
|
+
dispose() {
|
|
1069
|
+
own = void 0;
|
|
1070
|
+
clearTimeout(sendTimer);
|
|
1071
|
+
clearInterval(refreshTimer);
|
|
1072
|
+
clearTimeout(expiryTimer);
|
|
1073
|
+
for (const off of offs) off();
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
//#endregion
|
|
660
1078
|
//#region src/requests.ts
|
|
661
1079
|
const lastIds = /* @__PURE__ */ new WeakMap();
|
|
662
|
-
const VOLATILE$2 = { volatile: true };
|
|
663
1080
|
const noop = () => {};
|
|
664
1081
|
/**
|
|
665
1082
|
* Send requests and await their replies, matched by an id the library generates.
|
|
@@ -713,6 +1130,10 @@ function createWebSocketRequests(client, options) {
|
|
|
713
1130
|
done(true, signal.reason);
|
|
714
1131
|
return noop;
|
|
715
1132
|
}
|
|
1133
|
+
if (client.getSnapshot().status === "closed") {
|
|
1134
|
+
done(true, /* @__PURE__ */ new Error("WebSocket is closed: request not sent"));
|
|
1135
|
+
return noop;
|
|
1136
|
+
}
|
|
716
1137
|
const id = (lastIds.get(client) ?? 0) + 1;
|
|
717
1138
|
lastIds.set(client, id);
|
|
718
1139
|
const key = String(id);
|
|
@@ -737,7 +1158,8 @@ function createWebSocketRequests(client, options) {
|
|
|
737
1158
|
release();
|
|
738
1159
|
done(failed, reason);
|
|
739
1160
|
if (cancel && options.cancel) try {
|
|
740
|
-
client.send(options.cancel(id),
|
|
1161
|
+
if (!entry.socket) client.send(options.cancel(id), { priority });
|
|
1162
|
+
else if (entry.socket === client.socket && entry.socket.readyState === 1) client.send(options.cancel(id));
|
|
741
1163
|
} catch (error) {
|
|
742
1164
|
queueMicrotask(() => {
|
|
743
1165
|
throw error;
|
|
@@ -748,8 +1170,7 @@ function createWebSocketRequests(client, options) {
|
|
|
748
1170
|
waiting.set(key, entry);
|
|
749
1171
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
750
1172
|
arm();
|
|
751
|
-
|
|
752
|
-
if (status === "closed" || status === "idle") entry.end(true, /* @__PURE__ */ new Error(`WebSocket is ${status}: request not sent`));
|
|
1173
|
+
if (client.getSnapshot().status === "idle") entry.end(true, /* @__PURE__ */ new Error("WebSocket is idle: request not sent"));
|
|
753
1174
|
else try {
|
|
754
1175
|
if (client.send(options.send(message, id), {
|
|
755
1176
|
...requestOptions,
|
|
@@ -820,7 +1241,6 @@ function createWebSocketRequests(client, options) {
|
|
|
820
1241
|
}
|
|
821
1242
|
//#endregion
|
|
822
1243
|
//#region src/responder.ts
|
|
823
|
-
const VOLATILE$1 = { volatile: true };
|
|
824
1244
|
const abortError = (message) => new DOMException(message, "AbortError");
|
|
825
1245
|
/**
|
|
826
1246
|
* Answer requests the server sends: run a handler for tools that only exist in the browser
|
|
@@ -858,12 +1278,16 @@ function createWebSocketResponder(client, options) {
|
|
|
858
1278
|
waiting = next;
|
|
859
1279
|
for (const listener of [...listeners]) listener();
|
|
860
1280
|
}
|
|
861
|
-
/**
|
|
1281
|
+
/**
|
|
1282
|
+
* Send a reply, or an `error` reply if building or encoding it throws. Only while open: never
|
|
1283
|
+
* queued, and not `volatile`, which can be a datagram that never arrives.
|
|
1284
|
+
*/
|
|
862
1285
|
function reply(id, build) {
|
|
1286
|
+
if (client.socket?.readyState !== 1) return false;
|
|
863
1287
|
try {
|
|
864
|
-
return client.send(build()
|
|
1288
|
+
return client.send(build());
|
|
865
1289
|
} catch (error) {
|
|
866
|
-
return client.send(options.error(id, error)
|
|
1290
|
+
return client.send(options.error(id, error));
|
|
867
1291
|
}
|
|
868
1292
|
}
|
|
869
1293
|
function receive(incoming) {
|
|
@@ -962,6 +1386,221 @@ function createWebSocketResponder(client, options) {
|
|
|
962
1386
|
};
|
|
963
1387
|
}
|
|
964
1388
|
//#endregion
|
|
1389
|
+
//#region src/runs.ts
|
|
1390
|
+
function randomId() {
|
|
1391
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1392
|
+
}
|
|
1393
|
+
const CLAIM_WAIT = 200;
|
|
1394
|
+
function defaultStorage() {
|
|
1395
|
+
try {
|
|
1396
|
+
return typeof sessionStorage === "undefined" ? null : sessionStorage;
|
|
1397
|
+
} catch {
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Stream runs, such as an AI agent's answer, that survive reconnects and page reloads.
|
|
1403
|
+
*
|
|
1404
|
+
* - `start(request)` sends `start(request, runId)` and follows the run's events, folding them
|
|
1405
|
+
* into its value with `reduce`.
|
|
1406
|
+
* - Each event carries an `offset`. On a new connection, a streaming run sends
|
|
1407
|
+
* `resume(runId, after)` with the last offset received, so the server sends what was missed,
|
|
1408
|
+
* and duplicates are dropped.
|
|
1409
|
+
* - Streaming runs are remembered in `sessionStorage`. After a reload they're followed again from
|
|
1410
|
+
* their first event, rebuilding the value, so `list()` or `useAgentRun` can show them.
|
|
1411
|
+
* - A run whose policy is `cancel` is aborted when its connection drops, and `cancel` is sent
|
|
1412
|
+
* first thing on the next one.
|
|
1413
|
+
*
|
|
1414
|
+
* Built on `createWebSocketFeeds`, with each run a topic. While runs are streaming, it holds
|
|
1415
|
+
* the client's connection.
|
|
1416
|
+
*
|
|
1417
|
+
* @example
|
|
1418
|
+
* ```ts
|
|
1419
|
+
* export const runs = createWebSocketRuns(agent, {
|
|
1420
|
+
* name: 'chat',
|
|
1421
|
+
* start: (prompt: string, runId) => ({ type: 'run', runId, prompt }),
|
|
1422
|
+
* resume: (runId, after) => ({ type: 'resume', runId, after }),
|
|
1423
|
+
* cancel: runId => ({ type: 'cancel', runId }),
|
|
1424
|
+
* event: message => (message.type === 'token' || message.type === 'done' ? message : undefined),
|
|
1425
|
+
* done: message => message.type === 'done',
|
|
1426
|
+
* initial: '',
|
|
1427
|
+
* reduce: (text, message) => text + message.text
|
|
1428
|
+
* });
|
|
1429
|
+
*
|
|
1430
|
+
* const runId = runs.start('Summarize this thread');
|
|
1431
|
+
* ```
|
|
1432
|
+
*/
|
|
1433
|
+
function createWebSocketRuns(client, options) {
|
|
1434
|
+
const storage = options.storage === void 0 ? defaultStorage() : options.storage;
|
|
1435
|
+
const key = `react-websocket-runs:${options.name}`;
|
|
1436
|
+
const runs = /* @__PURE__ */ new Map();
|
|
1437
|
+
const starting = /* @__PURE__ */ new Map();
|
|
1438
|
+
const policies = /* @__PURE__ */ new Map();
|
|
1439
|
+
const releases = /* @__PURE__ */ new Map();
|
|
1440
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1441
|
+
let list = [];
|
|
1442
|
+
const feeds = createWebSocketFeeds(client, {
|
|
1443
|
+
subscribe: (runId, after) => after === void 0 && starting.has(runId) ? options.start(starting.get(runId), runId) : options.resume(runId, after),
|
|
1444
|
+
sequence: (message) => {
|
|
1445
|
+
const event = options.event(message);
|
|
1446
|
+
return event && {
|
|
1447
|
+
topic: event.runId,
|
|
1448
|
+
seq: event.offset
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
function read() {
|
|
1453
|
+
try {
|
|
1454
|
+
return JSON.parse(storage?.getItem(key) ?? "{}");
|
|
1455
|
+
} catch {
|
|
1456
|
+
return {};
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
function remember(runId, policy) {
|
|
1460
|
+
if (!storage) return;
|
|
1461
|
+
const stored = read();
|
|
1462
|
+
if (policy) stored[runId] = policy;
|
|
1463
|
+
else delete stored[runId];
|
|
1464
|
+
try {
|
|
1465
|
+
if (Object.keys(stored).length) storage.setItem(key, JSON.stringify(stored));
|
|
1466
|
+
else storage.removeItem(key);
|
|
1467
|
+
} catch {}
|
|
1468
|
+
}
|
|
1469
|
+
function set(runId, patch) {
|
|
1470
|
+
const run = runs.get(runId);
|
|
1471
|
+
runs.set(runId, {
|
|
1472
|
+
...run ?? {
|
|
1473
|
+
runId,
|
|
1474
|
+
status: "streaming",
|
|
1475
|
+
value: options.initial,
|
|
1476
|
+
error: void 0
|
|
1477
|
+
},
|
|
1478
|
+
...patch
|
|
1479
|
+
});
|
|
1480
|
+
list = [...runs.values()];
|
|
1481
|
+
for (const listener of [...listeners]) listener();
|
|
1482
|
+
}
|
|
1483
|
+
/** Stop following a run that ended. */
|
|
1484
|
+
function end(runId, patch) {
|
|
1485
|
+
releases.get(runId)?.();
|
|
1486
|
+
releases.delete(runId);
|
|
1487
|
+
starting.delete(runId);
|
|
1488
|
+
policies.delete(runId);
|
|
1489
|
+
remember(runId, void 0);
|
|
1490
|
+
set(runId, patch);
|
|
1491
|
+
}
|
|
1492
|
+
function follow(runId, policy, value = options.initial) {
|
|
1493
|
+
policies.set(runId, policy);
|
|
1494
|
+
remember(runId, policy);
|
|
1495
|
+
set(runId, { value });
|
|
1496
|
+
releases.set(runId, feeds.acquire(runId));
|
|
1497
|
+
}
|
|
1498
|
+
function cancel(runId) {
|
|
1499
|
+
const message = options.cancel;
|
|
1500
|
+
if (!message) return;
|
|
1501
|
+
if (client.socket?.readyState === 1) return void client.send(message(runId));
|
|
1502
|
+
const release = client.acquire();
|
|
1503
|
+
const off = client.on("open", () => {
|
|
1504
|
+
off();
|
|
1505
|
+
client.send(message(runId));
|
|
1506
|
+
release();
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
function abort(runId) {
|
|
1510
|
+
if (runs.get(runId)?.status !== "streaming") return;
|
|
1511
|
+
cancel(runId);
|
|
1512
|
+
end(runId, { status: "aborted" });
|
|
1513
|
+
}
|
|
1514
|
+
let status = client.getSnapshot().status;
|
|
1515
|
+
const offs = [
|
|
1516
|
+
feeds.on("message", (message) => {
|
|
1517
|
+
const event = options.event(message);
|
|
1518
|
+
if (!event || runs.get(event.runId)?.status !== "streaming") return;
|
|
1519
|
+
const { runId } = event;
|
|
1520
|
+
starting.delete(runId);
|
|
1521
|
+
const error = options.error?.(message);
|
|
1522
|
+
if (error !== void 0) return end(runId, {
|
|
1523
|
+
status: "error",
|
|
1524
|
+
error
|
|
1525
|
+
});
|
|
1526
|
+
const value = options.reduce(runs.get(runId).value, message);
|
|
1527
|
+
if (options.done?.(message)) end(runId, {
|
|
1528
|
+
status: "done",
|
|
1529
|
+
value
|
|
1530
|
+
});
|
|
1531
|
+
else set(runId, { value });
|
|
1532
|
+
}),
|
|
1533
|
+
client.subscribe(() => {
|
|
1534
|
+
const previous = status;
|
|
1535
|
+
status = client.getSnapshot().status;
|
|
1536
|
+
if (previous !== "open" || status === "open") return;
|
|
1537
|
+
for (const [runId, policy] of policies) if (policy === "cancel") abort(runId);
|
|
1538
|
+
}),
|
|
1539
|
+
feeds.dispose
|
|
1540
|
+
];
|
|
1541
|
+
const channel = storage && typeof window !== "undefined" && typeof BroadcastChannel === "function" ? new BroadcastChannel(key) : void 0;
|
|
1542
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1543
|
+
if (channel) {
|
|
1544
|
+
channel.onmessage = ({ data }) => {
|
|
1545
|
+
if (data?.mine) claimed.add(data.mine);
|
|
1546
|
+
else if (data?.who && policies.has(data.who)) channel.postMessage({ mine: data.who });
|
|
1547
|
+
};
|
|
1548
|
+
offs.push(() => channel.close());
|
|
1549
|
+
}
|
|
1550
|
+
const stale = [];
|
|
1551
|
+
for (const [runId, policy] of Object.entries(read())) if (policy !== "cancel") follow(runId, policy);
|
|
1552
|
+
else if (channel) {
|
|
1553
|
+
stale.push(runId);
|
|
1554
|
+
channel.postMessage({ who: runId });
|
|
1555
|
+
} else {
|
|
1556
|
+
cancel(runId);
|
|
1557
|
+
remember(runId, void 0);
|
|
1558
|
+
}
|
|
1559
|
+
if (stale.length) {
|
|
1560
|
+
const timer = setTimeout(() => {
|
|
1561
|
+
for (const runId of stale) {
|
|
1562
|
+
if (claimed.has(runId) || policies.has(runId)) continue;
|
|
1563
|
+
cancel(runId);
|
|
1564
|
+
remember(runId, void 0);
|
|
1565
|
+
}
|
|
1566
|
+
}, CLAIM_WAIT);
|
|
1567
|
+
offs.push(() => clearTimeout(timer));
|
|
1568
|
+
}
|
|
1569
|
+
return {
|
|
1570
|
+
client,
|
|
1571
|
+
start(request, { runId = randomId(), policy = options.policy ?? "keep", initial } = {}) {
|
|
1572
|
+
if (runs.has(runId)) return runId;
|
|
1573
|
+
starting.set(runId, request);
|
|
1574
|
+
follow(runId, policy, initial);
|
|
1575
|
+
return runId;
|
|
1576
|
+
},
|
|
1577
|
+
attach(runId, { policy = options.policy ?? "keep", initial } = {}) {
|
|
1578
|
+
if (!runs.has(runId)) follow(runId, policy, initial);
|
|
1579
|
+
},
|
|
1580
|
+
abort,
|
|
1581
|
+
get: (runId) => runs.get(runId),
|
|
1582
|
+
list: () => list,
|
|
1583
|
+
subscribe(listener) {
|
|
1584
|
+
listeners.add(listener);
|
|
1585
|
+
return () => {
|
|
1586
|
+
listeners.delete(listener);
|
|
1587
|
+
};
|
|
1588
|
+
},
|
|
1589
|
+
delete(runId) {
|
|
1590
|
+
abort(runId);
|
|
1591
|
+
if (runs.delete(runId)) {
|
|
1592
|
+
list = [...runs.values()];
|
|
1593
|
+
for (const listener of [...listeners]) listener();
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
1596
|
+
dispose() {
|
|
1597
|
+
for (const release of releases.values()) release();
|
|
1598
|
+
releases.clear();
|
|
1599
|
+
for (const off of offs) off();
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
//#endregion
|
|
965
1604
|
//#region src/sendrate.ts
|
|
966
1605
|
const VOLATILE = { volatile: true };
|
|
967
1606
|
/**
|
|
@@ -1085,6 +1724,9 @@ function createWebSocketSendRate(client, options = {}) {
|
|
|
1085
1724
|
* It starts listening immediately and keeps the state across reconnects; it doesn't open
|
|
1086
1725
|
* the connection by itself.
|
|
1087
1726
|
*
|
|
1727
|
+
* Pass feeds from `createWebSocketFeeds` in place of the client to reduce their messages
|
|
1728
|
+
* instead: numbered ones once each and in order.
|
|
1729
|
+
*
|
|
1088
1730
|
* @example
|
|
1089
1731
|
* ```ts
|
|
1090
1732
|
* const prices = createWebSocket<Tick>('wss://api.example.com/prices', { json: true });
|
|
@@ -1098,7 +1740,8 @@ function createWebSocketSendRate(client, options = {}) {
|
|
|
1098
1740
|
* const acme = useWebSocketState(priceState, state => state.ACME);
|
|
1099
1741
|
* ```
|
|
1100
1742
|
*/
|
|
1101
|
-
function createWebSocketState(
|
|
1743
|
+
function createWebSocketState(source, options) {
|
|
1744
|
+
const client = "client" in source ? source.client : source;
|
|
1102
1745
|
const { initialState, reduce, batch } = options;
|
|
1103
1746
|
let state = initialState;
|
|
1104
1747
|
let notified = state;
|
|
@@ -1136,93 +1779,10 @@ function createWebSocketState(client, options) {
|
|
|
1136
1779
|
},
|
|
1137
1780
|
setState: (change) => update(change),
|
|
1138
1781
|
reset: () => update(initialState),
|
|
1139
|
-
dispose:
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
//#endregion
|
|
1143
|
-
//#region src/topics.ts
|
|
1144
|
-
/**
|
|
1145
|
-
* Manage topic subscriptions that survive reconnects.
|
|
1146
|
-
*
|
|
1147
|
-
* - The first holder of a topic sends `subscribe(topic)`. Later holders share it.
|
|
1148
|
-
* - A tick after the last holder releases it, `unsubscribe(topic)` is sent, so StrictMode
|
|
1149
|
-
* and route transitions don't churn subscriptions.
|
|
1150
|
-
* - After every reconnect, every held topic is subscribed again on the new socket, before
|
|
1151
|
-
* messages queued while disconnected are sent.
|
|
1152
|
-
*
|
|
1153
|
-
* Subscriptions are only sent while the socket is open; nothing is queued for them.
|
|
1154
|
-
*
|
|
1155
|
-
* @example
|
|
1156
|
-
* ```ts
|
|
1157
|
-
* export const topics = createWebSocketTopics(live, {
|
|
1158
|
-
* subscribe: topic => ({ type: 'subscribe', topic }),
|
|
1159
|
-
* unsubscribe: topic => ({ type: 'unsubscribe', topic })
|
|
1160
|
-
* });
|
|
1161
|
-
*
|
|
1162
|
-
* // In a component:
|
|
1163
|
-
* useWebSocketTopic(topics, `prices:${symbol}`);
|
|
1164
|
-
* ```
|
|
1165
|
-
*/
|
|
1166
|
-
function createWebSocketTopics(client, options) {
|
|
1167
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1168
|
-
const pendingRemoval = /* @__PURE__ */ new Map();
|
|
1169
|
-
let sentOn = null;
|
|
1170
|
-
let sent = /* @__PURE__ */ new Set();
|
|
1171
|
-
function isOpen() {
|
|
1172
|
-
return client.getSnapshot().status === "open" && client.socket !== null;
|
|
1173
|
-
}
|
|
1174
|
-
function subscribe(topic) {
|
|
1175
|
-
if (!isOpen()) return;
|
|
1176
|
-
if (client.socket !== sentOn) {
|
|
1177
|
-
sentOn = client.socket;
|
|
1178
|
-
sent = /* @__PURE__ */ new Set();
|
|
1179
|
-
}
|
|
1180
|
-
if (sent.has(topic)) return;
|
|
1181
|
-
sent.add(topic);
|
|
1182
|
-
client.send(options.subscribe(topic));
|
|
1183
|
-
}
|
|
1184
|
-
function unsubscribe(topic) {
|
|
1185
|
-
if (!isOpen() || client.socket !== sentOn || !sent.delete(topic)) return;
|
|
1186
|
-
if (options.unsubscribe) client.send(options.unsubscribe(topic));
|
|
1187
|
-
}
|
|
1188
|
-
const off = client.on("open", () => {
|
|
1189
|
-
sentOn = client.socket;
|
|
1190
|
-
sent = /* @__PURE__ */ new Set();
|
|
1191
|
-
for (const topic of counts.keys()) subscribe(topic);
|
|
1192
|
-
});
|
|
1193
|
-
return {
|
|
1194
|
-
client,
|
|
1195
|
-
acquire(topic) {
|
|
1196
|
-
const releaseConnection = client.acquire();
|
|
1197
|
-
clearTimeout(pendingRemoval.get(topic));
|
|
1198
|
-
pendingRemoval.delete(topic);
|
|
1199
|
-
counts.set(topic, (counts.get(topic) ?? 0) + 1);
|
|
1200
|
-
subscribe(topic);
|
|
1201
|
-
let released = false;
|
|
1202
|
-
return () => {
|
|
1203
|
-
if (released) return;
|
|
1204
|
-
released = true;
|
|
1205
|
-
const remaining = (counts.get(topic) ?? 1) - 1;
|
|
1206
|
-
if (remaining > 0) counts.set(topic, remaining);
|
|
1207
|
-
else {
|
|
1208
|
-
counts.delete(topic);
|
|
1209
|
-
pendingRemoval.set(topic, setTimeout(() => {
|
|
1210
|
-
pendingRemoval.delete(topic);
|
|
1211
|
-
if (!counts.has(topic)) unsubscribe(topic);
|
|
1212
|
-
}, 0));
|
|
1213
|
-
}
|
|
1214
|
-
releaseConnection();
|
|
1215
|
-
};
|
|
1216
|
-
},
|
|
1217
|
-
active: () => [...counts.keys()],
|
|
1218
|
-
dispose() {
|
|
1219
|
-
off();
|
|
1220
|
-
for (const timer of pendingRemoval.values()) clearTimeout(timer);
|
|
1221
|
-
pendingRemoval.clear();
|
|
1222
|
-
}
|
|
1782
|
+
dispose: source.on("message", (message) => update((current) => reduce(current, message), batch === "frame"))
|
|
1223
1783
|
};
|
|
1224
1784
|
}
|
|
1225
1785
|
//#endregion
|
|
1226
|
-
export { MessageError, createWebSocket, createWebSocketLatency, createWebSocketRequests, createWebSocketResponder, createWebSocketSendRate, createWebSocketState, createWebSocketTopics };
|
|
1786
|
+
export { MessageError, createWebSocket, createWebSocketFeeds, createWebSocketLatency, createWebSocketPresence, createWebSocketRequests, createWebSocketResponder, createWebSocketRuns, createWebSocketSendRate, createWebSocketState, createWebSocketTopics, retryAfter, retryableClose };
|
|
1227
1787
|
|
|
1228
1788
|
//# sourceMappingURL=core.js.map
|