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