@voxevolv/client 0.2.0 → 0.3.1
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/CHANGELOG.md +35 -0
- package/README.md +46 -11
- package/dist/browser/index.js +6 -6
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/index.js.sha384 +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/generated/protocol-codecs.d.ts.map +1 -1
- package/dist/generated/protocol-codecs.js +41 -0
- package/dist/generated/protocol-codecs.js.map +1 -1
- package/dist/index.d.ts +54 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +529 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
1
|
+
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
2
2
|
import { createClientMessage, CommandError, EventCursor, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
3
3
|
export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
4
4
|
export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
|
|
5
|
-
export const SDK_VERSION = "0.
|
|
5
|
+
export const SDK_VERSION = "0.3.1";
|
|
6
6
|
export class RealtimeSessionHttpError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
code;
|
|
@@ -26,6 +26,8 @@ export class RealtimeSession {
|
|
|
26
26
|
listeners = new Set();
|
|
27
27
|
stateListeners = new Set();
|
|
28
28
|
gapListeners = new Set();
|
|
29
|
+
connectionQualityListeners = new Set();
|
|
30
|
+
recoveryListeners = new Set();
|
|
29
31
|
remoteAudioTrackListeners = new Set();
|
|
30
32
|
remoteAudioTrackUnsubscribedListeners = new Set();
|
|
31
33
|
readyListeners = new Set();
|
|
@@ -42,18 +44,31 @@ export class RealtimeSession {
|
|
|
42
44
|
connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
43
45
|
clientSeq = 0;
|
|
44
46
|
lastHelloMessageId = null;
|
|
47
|
+
reconnectStartedAt = null;
|
|
48
|
+
pendingReconnect = null;
|
|
45
49
|
agentReady = false;
|
|
46
50
|
readyResolve = null;
|
|
47
51
|
replayPromise = null;
|
|
48
52
|
pendingCommands = new Map();
|
|
49
53
|
toolHandlers = new Map();
|
|
50
54
|
apiBase = "/v1";
|
|
55
|
+
hasConnectedValue = false;
|
|
56
|
+
sessionEnded = false;
|
|
57
|
+
pendingEndEvent = null;
|
|
58
|
+
endNotificationDelivered = false;
|
|
59
|
+
disconnectMode = null;
|
|
60
|
+
activeRecovery = null;
|
|
61
|
+
autoRejoinPromise = null;
|
|
62
|
+
autoRejoinController = null;
|
|
51
63
|
useManagedMicrophone = true;
|
|
64
|
+
managedMicrophoneEnabled = true;
|
|
52
65
|
applicationAudioTrack = null;
|
|
53
66
|
connectDefaults;
|
|
54
67
|
fetcher;
|
|
55
68
|
endEndpoint;
|
|
56
69
|
refreshCredentialsCallback;
|
|
70
|
+
autoRejoinOptions;
|
|
71
|
+
lifecycleCredentials;
|
|
57
72
|
constructor(credentials, options = {}) {
|
|
58
73
|
validateSessionCredentials(credentials);
|
|
59
74
|
this.id = credentials.id;
|
|
@@ -66,6 +81,8 @@ export class RealtimeSession {
|
|
|
66
81
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
67
82
|
this.endEndpoint = options.endEndpoint ?? null;
|
|
68
83
|
this.refreshCredentialsCallback = options.refreshCredentials ?? null;
|
|
84
|
+
this.autoRejoinOptions = resolveAutoRejoinOptions(options.autoRejoin);
|
|
85
|
+
this.lifecycleCredentials = options.lifecycleCredentials ?? "same-origin";
|
|
69
86
|
this.room = new Room({ adaptiveStream: true, dynacast: true });
|
|
70
87
|
for (const [name, handler] of Object.entries(options.tools ?? {})) {
|
|
71
88
|
this.registerTool(name, handler);
|
|
@@ -130,6 +147,9 @@ export class RealtimeSession {
|
|
|
130
147
|
get state() {
|
|
131
148
|
return this.stateValue;
|
|
132
149
|
}
|
|
150
|
+
get hasConnectedBefore() {
|
|
151
|
+
return this.hasConnectedValue;
|
|
152
|
+
}
|
|
133
153
|
/**
|
|
134
154
|
* Stable stream holding the agent's current remote audio tracks. The stream
|
|
135
155
|
* object never changes across track arrival, reconnects, or rejoin; tracks
|
|
@@ -152,6 +172,57 @@ export class RealtimeSession {
|
|
|
152
172
|
this.gapListeners.add(listener);
|
|
153
173
|
return () => this.gapListeners.delete(listener);
|
|
154
174
|
}
|
|
175
|
+
onConnectionQuality(listener) {
|
|
176
|
+
this.connectionQualityListeners.add(listener);
|
|
177
|
+
this.notifyListeners(new Set([listener]), this.room.localParticipant.connectionQuality);
|
|
178
|
+
return () => this.connectionQualityListeners.delete(listener);
|
|
179
|
+
}
|
|
180
|
+
onRecovery(listener) {
|
|
181
|
+
this.recoveryListeners.add(listener);
|
|
182
|
+
return () => this.recoveryListeners.delete(listener);
|
|
183
|
+
}
|
|
184
|
+
waitForState(states, timeoutMs, options = {}) {
|
|
185
|
+
if (!states.length)
|
|
186
|
+
throw new TypeError("states must contain at least one connection state");
|
|
187
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
188
|
+
throw new TypeError("timeoutMs must be a non-negative finite number");
|
|
189
|
+
}
|
|
190
|
+
const targets = new Set(states);
|
|
191
|
+
if (targets.has(this.stateValue))
|
|
192
|
+
return Promise.resolve(this.stateValue);
|
|
193
|
+
if (options.signal?.aborted) {
|
|
194
|
+
return Promise.reject(options.signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
195
|
+
}
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
let settled = false;
|
|
198
|
+
const finish = (state, error) => {
|
|
199
|
+
if (settled)
|
|
200
|
+
return;
|
|
201
|
+
settled = true;
|
|
202
|
+
globalThis.clearTimeout(timeout);
|
|
203
|
+
this.stateListeners.delete(onState);
|
|
204
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
205
|
+
if (error !== undefined) {
|
|
206
|
+
reject(error);
|
|
207
|
+
}
|
|
208
|
+
else if (state !== null) {
|
|
209
|
+
resolve(state);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const onState = (state) => {
|
|
213
|
+
if (targets.has(state))
|
|
214
|
+
finish(state);
|
|
215
|
+
};
|
|
216
|
+
const onAbort = () => {
|
|
217
|
+
finish(null, options.signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
218
|
+
};
|
|
219
|
+
const timeout = globalThis.setTimeout(() => {
|
|
220
|
+
finish(null, new Error(`Session did not reach ${states.join(" or ")} within ${timeoutMs}ms`));
|
|
221
|
+
}, timeoutMs);
|
|
222
|
+
this.stateListeners.add(onState);
|
|
223
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
224
|
+
});
|
|
225
|
+
}
|
|
155
226
|
onRemoteAudioTrack(listener) {
|
|
156
227
|
this.remoteAudioTrackListeners.add(listener);
|
|
157
228
|
return () => this.remoteAudioTrackListeners.delete(listener);
|
|
@@ -189,7 +260,8 @@ export class RealtimeSession {
|
|
|
189
260
|
return () => this.audioPlaybackBlockedListeners.delete(listener);
|
|
190
261
|
}
|
|
191
262
|
registerTool(name, handler) {
|
|
192
|
-
if (["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
263
|
+
if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
|
|
264
|
+
.includes(this.stateValue)) {
|
|
193
265
|
throw new Error("Browser tools must be registered before connect()");
|
|
194
266
|
}
|
|
195
267
|
if (!/^[a-z][a-z0-9_]{0,79}$/.test(name)) {
|
|
@@ -204,7 +276,18 @@ export class RealtimeSession {
|
|
|
204
276
|
};
|
|
205
277
|
}
|
|
206
278
|
async connect(options = {}) {
|
|
207
|
-
if (["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
279
|
+
if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
|
|
280
|
+
.includes(this.stateValue)) {
|
|
281
|
+
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
282
|
+
}
|
|
283
|
+
this.cancelAutoRejoin();
|
|
284
|
+
await this.connectWithMode(options, false);
|
|
285
|
+
}
|
|
286
|
+
async connectWithMode(options, recovering, signal) {
|
|
287
|
+
const blockedStates = recovering
|
|
288
|
+
? ["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
289
|
+
: ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
|
|
290
|
+
if (blockedStates.includes(this.stateValue)) {
|
|
208
291
|
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
209
292
|
}
|
|
210
293
|
const resolved = { ...this.connectDefaults, ...options };
|
|
@@ -215,62 +298,110 @@ export class RealtimeSession {
|
|
|
215
298
|
const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
|
|
216
299
|
this.validateAudioOptions(microphone, audioTrack);
|
|
217
300
|
this.useManagedMicrophone = microphone;
|
|
301
|
+
if (!recovering)
|
|
302
|
+
this.managedMicrophoneEnabled = microphone;
|
|
218
303
|
this.applicationAudioTrack = audioTrack;
|
|
219
|
-
|
|
304
|
+
if (!recovering) {
|
|
305
|
+
this.sessionEnded = false;
|
|
306
|
+
this.pendingEndEvent = null;
|
|
307
|
+
this.endNotificationDelivered = false;
|
|
308
|
+
this.setState("connecting");
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
this.setState("resuming");
|
|
312
|
+
}
|
|
220
313
|
this.apiBase = apiBase;
|
|
221
314
|
this.agentReady = false;
|
|
222
315
|
try {
|
|
223
316
|
await this.room.connect(this.credentials.url, this.credentials.token);
|
|
317
|
+
throwIfAborted(signal);
|
|
224
318
|
if (audioTrack) {
|
|
225
319
|
await this.publishApplicationAudioTrack(audioTrack);
|
|
226
320
|
}
|
|
227
321
|
else if (microphone) {
|
|
228
|
-
await this.room.localParticipant.setMicrophoneEnabled(
|
|
322
|
+
await this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled);
|
|
229
323
|
}
|
|
230
|
-
|
|
324
|
+
throwIfAborted(signal);
|
|
325
|
+
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
231
326
|
await this.sendHello();
|
|
232
327
|
const readyDeadline = Date.now() + readyTimeoutMs;
|
|
233
|
-
await this.waitForAgentReady(readyTimeoutMs);
|
|
328
|
+
await this.waitForAgentReady(readyTimeoutMs, signal);
|
|
234
329
|
if (waitForRemoteAudio)
|
|
235
|
-
await this.waitForRemoteAudioTrack(readyDeadline);
|
|
330
|
+
await this.waitForRemoteAudioTrack(readyDeadline, signal);
|
|
331
|
+
throwIfAborted(signal);
|
|
332
|
+
this.hasConnectedValue = true;
|
|
236
333
|
this.setState("connected");
|
|
237
334
|
}
|
|
238
335
|
catch (error) {
|
|
336
|
+
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
239
337
|
try {
|
|
240
338
|
await this.room.disconnect();
|
|
241
339
|
}
|
|
242
340
|
catch {
|
|
243
341
|
// Preserve the original connection/readiness failure.
|
|
244
342
|
}
|
|
245
|
-
|
|
343
|
+
finally {
|
|
344
|
+
this.disconnectMode = null;
|
|
345
|
+
}
|
|
346
|
+
if (!recovering)
|
|
347
|
+
this.setState("failed");
|
|
246
348
|
throw error;
|
|
247
349
|
}
|
|
248
350
|
}
|
|
249
351
|
async rejoin(apiBase = this.apiBase) {
|
|
250
|
-
if (["connecting", "reconnecting"].includes(this.stateValue)) {
|
|
352
|
+
if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
251
353
|
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
252
354
|
}
|
|
355
|
+
this.cancelAutoRejoin();
|
|
253
356
|
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
254
|
-
|
|
357
|
+
const recovering = this.hasConnectedValue;
|
|
358
|
+
if (recovering) {
|
|
359
|
+
this.beginRecovery("rejoin", 1);
|
|
360
|
+
this.setState("resuming");
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
await this.performRejoin(normalizedApiBase, undefined, recovering);
|
|
364
|
+
if (recovering)
|
|
365
|
+
this.completeRecovery();
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
const normalizedError = toError(error);
|
|
369
|
+
if (recovering)
|
|
370
|
+
this.failRecovery(normalizedError);
|
|
371
|
+
this.setState("failed");
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async performRejoin(apiBase, signal, recovering = true) {
|
|
376
|
+
let response = await this.fetchRoomToken(apiBase, signal);
|
|
255
377
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
256
378
|
await this.refreshCredentials();
|
|
257
|
-
|
|
379
|
+
throwIfAborted(signal);
|
|
380
|
+
response = await this.fetchRoomToken(apiBase, signal);
|
|
258
381
|
}
|
|
259
382
|
if (!response.ok)
|
|
260
383
|
throw await httpError(response, "Could not renew room token");
|
|
261
384
|
const payload = (await response.json());
|
|
262
385
|
this.credentials = payload.connection;
|
|
263
|
-
if (
|
|
264
|
-
|
|
386
|
+
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
387
|
+
this.disconnectMode = "rejoin";
|
|
388
|
+
try {
|
|
389
|
+
await this.room.disconnect();
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
this.disconnectMode = null;
|
|
393
|
+
}
|
|
265
394
|
}
|
|
266
|
-
|
|
267
|
-
|
|
395
|
+
throwIfAborted(signal);
|
|
396
|
+
await this.connectWithMode({
|
|
397
|
+
apiBase,
|
|
268
398
|
microphone: this.useManagedMicrophone,
|
|
269
399
|
...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
|
|
270
|
-
});
|
|
400
|
+
}, recovering, signal);
|
|
271
401
|
}
|
|
272
402
|
async setMicrophoneEnabled(enabled) {
|
|
273
403
|
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
404
|
+
this.managedMicrophoneEnabled = enabled;
|
|
274
405
|
}
|
|
275
406
|
async sendCommand(type, payload, timeoutMs = 10_000) {
|
|
276
407
|
const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
|
|
@@ -367,13 +498,23 @@ export class RealtimeSession {
|
|
|
367
498
|
};
|
|
368
499
|
}
|
|
369
500
|
async disconnect() {
|
|
501
|
+
this.cancelAutoRejoin();
|
|
370
502
|
if (this.stateValue === "disconnected")
|
|
371
503
|
return;
|
|
372
504
|
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
373
|
-
|
|
374
|
-
|
|
505
|
+
this.disconnectMode = "intentional";
|
|
506
|
+
try {
|
|
507
|
+
await this.room.disconnect();
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
this.disconnectMode = null;
|
|
511
|
+
this.activeRecovery = null;
|
|
512
|
+
this.setState("disconnected");
|
|
513
|
+
}
|
|
375
514
|
}
|
|
376
515
|
async end(options = {}) {
|
|
516
|
+
this.cancelAutoRejoin();
|
|
517
|
+
this.sessionEnded = true;
|
|
377
518
|
const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
|
|
378
519
|
const request = options.request ?? {};
|
|
379
520
|
const headers = new Headers(request.headers);
|
|
@@ -385,6 +526,7 @@ export class RealtimeSession {
|
|
|
385
526
|
...request,
|
|
386
527
|
method: request.method ?? "DELETE",
|
|
387
528
|
headers,
|
|
529
|
+
credentials: request.credentials ?? this.lifecycleCredentials,
|
|
388
530
|
});
|
|
389
531
|
if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
|
|
390
532
|
await this.refreshCredentials();
|
|
@@ -393,6 +535,7 @@ export class RealtimeSession {
|
|
|
393
535
|
...request,
|
|
394
536
|
method: request.method ?? "DELETE",
|
|
395
537
|
headers,
|
|
538
|
+
credentials: request.credentials ?? this.lifecycleCredentials,
|
|
396
539
|
});
|
|
397
540
|
}
|
|
398
541
|
if (!response.ok)
|
|
@@ -416,32 +559,29 @@ export class RealtimeSession {
|
|
|
416
559
|
this.notifyListeners(this.stateListeners, state);
|
|
417
560
|
}
|
|
418
561
|
bindRoomEvents() {
|
|
419
|
-
this.room.on(RoomEvent.
|
|
420
|
-
|
|
421
|
-
this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
|
|
422
|
-
this.setState("reconnecting");
|
|
423
|
-
});
|
|
562
|
+
this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
|
|
563
|
+
this.room.on(RoomEvent.Reconnecting, () => this.handleLiveKitReconnecting());
|
|
424
564
|
this.room.on(RoomEvent.Reconnected, () => {
|
|
425
|
-
this.
|
|
426
|
-
this.clientSeq = 0;
|
|
427
|
-
void this.sendHello().catch(() => this.setState("failed"));
|
|
428
|
-
this.setState("connected_not_ready");
|
|
565
|
+
void this.handleLiveKitReconnected();
|
|
429
566
|
});
|
|
430
|
-
this.room.on(RoomEvent.Disconnected, () => {
|
|
431
|
-
this.
|
|
432
|
-
// Drop any remaining remote tracks so a stale, ended track cannot
|
|
433
|
-
// satisfy the next connect()'s wait for agent audio.
|
|
434
|
-
this.outputMediaStream?.getTracks().forEach((track) => {
|
|
435
|
-
this.outputMediaStream?.removeTrack(track);
|
|
436
|
-
});
|
|
437
|
-
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
438
|
-
this.setState("disconnected");
|
|
567
|
+
this.room.on(RoomEvent.Disconnected, (reason) => {
|
|
568
|
+
this.handleLiveKitDisconnected(reason);
|
|
439
569
|
});
|
|
440
570
|
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
|
441
571
|
if (state === LiveKitConnectionState.Connected) {
|
|
442
|
-
this.
|
|
572
|
+
const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
|
|
573
|
+
this.setState(this.agentReady
|
|
574
|
+
? "connected"
|
|
575
|
+
: recovering
|
|
576
|
+
? "resuming"
|
|
577
|
+
: "connected_not_ready");
|
|
443
578
|
}
|
|
444
579
|
});
|
|
580
|
+
this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
|
581
|
+
if (!participant.isLocal)
|
|
582
|
+
return;
|
|
583
|
+
this.notifyListeners(this.connectionQualityListeners, quality);
|
|
584
|
+
});
|
|
445
585
|
this.room.on(RoomEvent.TrackSubscribed, (track) => {
|
|
446
586
|
if (track.kind !== Track.Kind.Audio)
|
|
447
587
|
return;
|
|
@@ -507,6 +647,192 @@ export class RealtimeSession {
|
|
|
507
647
|
this.notifyListeners(this.listeners, value);
|
|
508
648
|
});
|
|
509
649
|
}
|
|
650
|
+
handleLiveKitReconnecting() {
|
|
651
|
+
this.agentReady = false;
|
|
652
|
+
// Once full rejoin owns recovery, late LiveKit reconnect notifications
|
|
653
|
+
// from the old transport must not move the wrapper back to reconnecting.
|
|
654
|
+
// Doing so makes connectWithMode() reject the replacement connection.
|
|
655
|
+
if (this.autoRejoinPromise !== null
|
|
656
|
+
|| this.activeRecovery?.strategy === "rejoin"
|
|
657
|
+
|| this.disconnectMode === "rejoin") {
|
|
658
|
+
this.setState("resuming");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
this.reconnectStartedAt ??= Date.now();
|
|
662
|
+
this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
|
|
663
|
+
if (this.activeRecovery === null) {
|
|
664
|
+
this.beginRecovery("livekit", 1);
|
|
665
|
+
}
|
|
666
|
+
this.setState("reconnecting");
|
|
667
|
+
}
|
|
668
|
+
async handleLiveKitReconnected() {
|
|
669
|
+
const recoveredAt = Date.now();
|
|
670
|
+
if (this.reconnectStartedAt !== null) {
|
|
671
|
+
this.pendingReconnect = {
|
|
672
|
+
started_at: new Date(this.reconnectStartedAt).toISOString(),
|
|
673
|
+
recovered_at: new Date(recoveredAt).toISOString(),
|
|
674
|
+
duration_ms: Math.max(recoveredAt - this.reconnectStartedAt, 0),
|
|
675
|
+
};
|
|
676
|
+
this.reconnectStartedAt = null;
|
|
677
|
+
}
|
|
678
|
+
if (this.hasConnectedValue && this.activeRecovery === null) {
|
|
679
|
+
this.beginRecovery("livekit", 1);
|
|
680
|
+
}
|
|
681
|
+
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
682
|
+
this.clientSeq = 0;
|
|
683
|
+
this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
|
|
684
|
+
try {
|
|
685
|
+
await this.sendHello();
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
const normalizedError = toError(error);
|
|
689
|
+
if (this.shouldAutoRejoin()) {
|
|
690
|
+
this.startAutoRejoin(normalizedError);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
this.failRecovery(normalizedError);
|
|
694
|
+
this.setState("failed");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
handleLiveKitDisconnected(reason) {
|
|
699
|
+
this.agentReady = false;
|
|
700
|
+
this.reconnectStartedAt = null;
|
|
701
|
+
this.pendingReconnect = null;
|
|
702
|
+
// Drop any remaining remote tracks so a stale, ended track cannot
|
|
703
|
+
// satisfy the next connect()'s wait for agent audio.
|
|
704
|
+
this.outputMediaStream?.getTracks().forEach((track) => {
|
|
705
|
+
this.outputMediaStream?.removeTrack(track);
|
|
706
|
+
});
|
|
707
|
+
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
708
|
+
if (this.disconnectMode === "rejoin")
|
|
709
|
+
return;
|
|
710
|
+
if (this.disconnectMode === "connect_failure")
|
|
711
|
+
return;
|
|
712
|
+
if (this.disconnectMode === "intentional") {
|
|
713
|
+
this.activeRecovery = null;
|
|
714
|
+
this.deliverPendingEndEvent();
|
|
715
|
+
this.setState("disconnected");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (this.shouldAutoRejoin(reason)) {
|
|
719
|
+
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (this.activeRecovery !== null) {
|
|
723
|
+
this.failRecovery(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
|
|
724
|
+
}
|
|
725
|
+
this.deliverPendingEndEvent();
|
|
726
|
+
this.setState("disconnected");
|
|
727
|
+
}
|
|
728
|
+
// The terminal session.ended event is recorded durably after room teardown,
|
|
729
|
+
// so turn the earlier live announcement into the browser completion signal.
|
|
730
|
+
deliverPendingEndEvent() {
|
|
731
|
+
const ending = this.pendingEndEvent;
|
|
732
|
+
if (!ending)
|
|
733
|
+
return;
|
|
734
|
+
this.pendingEndEvent = null;
|
|
735
|
+
this.notifyEnded({ ...ending, type: "session.ended" });
|
|
736
|
+
}
|
|
737
|
+
notifyEnded(event) {
|
|
738
|
+
if (this.endNotificationDelivered)
|
|
739
|
+
return;
|
|
740
|
+
this.endNotificationDelivered = true;
|
|
741
|
+
this.notifyListeners(this.endedListeners, event);
|
|
742
|
+
}
|
|
743
|
+
beginRecovery(strategy, attempt) {
|
|
744
|
+
this.activeRecovery = { strategy, attempt };
|
|
745
|
+
this.notifyListeners(this.recoveryListeners, {
|
|
746
|
+
status: "started",
|
|
747
|
+
strategy,
|
|
748
|
+
attempt,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
completeRecovery() {
|
|
752
|
+
const recovery = this.activeRecovery;
|
|
753
|
+
if (!recovery)
|
|
754
|
+
return;
|
|
755
|
+
this.activeRecovery = null;
|
|
756
|
+
this.notifyListeners(this.recoveryListeners, {
|
|
757
|
+
status: "succeeded",
|
|
758
|
+
strategy: recovery.strategy,
|
|
759
|
+
attempt: recovery.attempt,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
failRecovery(error) {
|
|
763
|
+
const recovery = this.activeRecovery;
|
|
764
|
+
if (!recovery)
|
|
765
|
+
return;
|
|
766
|
+
this.activeRecovery = null;
|
|
767
|
+
this.notifyListeners(this.recoveryListeners, {
|
|
768
|
+
status: "failed",
|
|
769
|
+
strategy: recovery.strategy,
|
|
770
|
+
attempt: recovery.attempt,
|
|
771
|
+
error,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
shouldAutoRejoin(reason) {
|
|
775
|
+
return (this.autoRejoinOptions !== null
|
|
776
|
+
&& this.hasConnectedValue
|
|
777
|
+
&& !this.sessionEnded
|
|
778
|
+
&& !isTerminalDisconnectReason(reason));
|
|
779
|
+
}
|
|
780
|
+
startAutoRejoin(initialError) {
|
|
781
|
+
if (this.autoRejoinPromise || !this.shouldAutoRejoin())
|
|
782
|
+
return;
|
|
783
|
+
const controller = new AbortController();
|
|
784
|
+
this.autoRejoinController = controller;
|
|
785
|
+
const promise = this.runAutoRejoin(initialError, controller.signal)
|
|
786
|
+
.finally(() => {
|
|
787
|
+
if (this.autoRejoinController === controller) {
|
|
788
|
+
this.autoRejoinController = null;
|
|
789
|
+
this.autoRejoinPromise = null;
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
this.autoRejoinPromise = promise;
|
|
793
|
+
void promise;
|
|
794
|
+
}
|
|
795
|
+
async runAutoRejoin(initialError, signal) {
|
|
796
|
+
const options = this.autoRejoinOptions;
|
|
797
|
+
if (!options)
|
|
798
|
+
return;
|
|
799
|
+
let lastError = initialError;
|
|
800
|
+
if (this.activeRecovery?.strategy === "livekit") {
|
|
801
|
+
this.failRecovery(initialError);
|
|
802
|
+
}
|
|
803
|
+
this.setState("resuming");
|
|
804
|
+
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
|
805
|
+
try {
|
|
806
|
+
await waitUntilOnline(signal);
|
|
807
|
+
if (attempt > 1) {
|
|
808
|
+
const exponentialDelay = options.baseDelayMs * (2 ** (attempt - 2));
|
|
809
|
+
const delayMs = Math.min(exponentialDelay, options.maxDelayMs);
|
|
810
|
+
await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
|
|
811
|
+
}
|
|
812
|
+
throwIfAborted(signal);
|
|
813
|
+
this.beginRecovery("rejoin", attempt);
|
|
814
|
+
await this.performRejoin(this.apiBase, signal);
|
|
815
|
+
throwIfAborted(signal);
|
|
816
|
+
this.completeRecovery();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
if (signal.aborted)
|
|
821
|
+
return;
|
|
822
|
+
lastError = toError(error);
|
|
823
|
+
if (isTerminalRejoinError(error))
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
this.failRecovery(lastError);
|
|
828
|
+
this.setState("failed");
|
|
829
|
+
}
|
|
830
|
+
cancelAutoRejoin() {
|
|
831
|
+
this.autoRejoinController?.abort();
|
|
832
|
+
this.autoRejoinController = null;
|
|
833
|
+
this.autoRejoinPromise = null;
|
|
834
|
+
this.activeRecovery = null;
|
|
835
|
+
}
|
|
510
836
|
notifyListeners(listeners, value) {
|
|
511
837
|
for (const listener of listeners) {
|
|
512
838
|
try {
|
|
@@ -540,10 +866,12 @@ export class RealtimeSession {
|
|
|
540
866
|
throw new TypeError("audioTrack has already ended");
|
|
541
867
|
}
|
|
542
868
|
}
|
|
543
|
-
async fetchRoomToken(apiBase) {
|
|
869
|
+
async fetchRoomToken(apiBase, signal) {
|
|
544
870
|
return this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
545
871
|
method: "POST",
|
|
546
872
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
873
|
+
credentials: this.lifecycleCredentials,
|
|
874
|
+
signal,
|
|
547
875
|
});
|
|
548
876
|
}
|
|
549
877
|
async refreshCredentials() {
|
|
@@ -558,8 +886,28 @@ export class RealtimeSession {
|
|
|
558
886
|
this.credentials = refreshed.connection;
|
|
559
887
|
}
|
|
560
888
|
observeConvenienceEvents(event) {
|
|
561
|
-
if (event.type === "session.
|
|
562
|
-
|
|
889
|
+
if (event.type === "session.ending") {
|
|
890
|
+
// The server announced an intentional hang-up before tearing down the
|
|
891
|
+
// room, so the coming disconnect must not fail recovery or auto-rejoin.
|
|
892
|
+
this.sessionEnded = true;
|
|
893
|
+
this.cancelAutoRejoin();
|
|
894
|
+
this.pendingEndEvent = event;
|
|
895
|
+
}
|
|
896
|
+
if (event.type === "session.ended") {
|
|
897
|
+
this.pendingEndEvent = null;
|
|
898
|
+
const recoveryWasActive = (this.autoRejoinPromise !== null
|
|
899
|
+
|| this.activeRecovery !== null
|
|
900
|
+
|| this.stateValue === "reconnecting"
|
|
901
|
+
|| this.stateValue === "resuming");
|
|
902
|
+
this.sessionEnded = true;
|
|
903
|
+
if (this.activeRecovery !== null) {
|
|
904
|
+
this.failRecovery(new Error("Session ended during recovery"));
|
|
905
|
+
}
|
|
906
|
+
this.cancelAutoRejoin();
|
|
907
|
+
this.notifyEnded(event);
|
|
908
|
+
if (recoveryWasActive)
|
|
909
|
+
this.setState("disconnected");
|
|
910
|
+
}
|
|
563
911
|
if (event.type.endsWith(".error"))
|
|
564
912
|
this.notifyListeners(this.errorListeners, event);
|
|
565
913
|
const transcript = transcriptEvent(event);
|
|
@@ -581,13 +929,17 @@ export class RealtimeSession {
|
|
|
581
929
|
}
|
|
582
930
|
}
|
|
583
931
|
async sendHello() {
|
|
932
|
+
const reconnect = this.pendingReconnect;
|
|
584
933
|
const message = createClientMessage(this.connectionId, ++this.clientSeq, "client.hello", {
|
|
585
934
|
last_server_seq: this.cursor.lastServerSeq,
|
|
586
935
|
sdk: `@voxevolv/client@${SDK_VERSION}`,
|
|
587
936
|
tool_handlers: [...this.toolHandlers.keys()].sort(),
|
|
937
|
+
...(reconnect ? { reconnect } : {}),
|
|
588
938
|
});
|
|
589
939
|
this.lastHelloMessageId = message.message_id;
|
|
590
940
|
await this.sendTrackedCommand(message, 10_000);
|
|
941
|
+
if (this.pendingReconnect === reconnect)
|
|
942
|
+
this.pendingReconnect = null;
|
|
591
943
|
}
|
|
592
944
|
async sendTrackedCommand(message, timeoutMs, retryTransfer) {
|
|
593
945
|
const response = new Promise((resolve, reject) => {
|
|
@@ -621,49 +973,65 @@ export class RealtimeSession {
|
|
|
621
973
|
try {
|
|
622
974
|
await this.publishCommand(message);
|
|
623
975
|
}
|
|
624
|
-
catch
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
globalThis.clearInterval(pending.retry);
|
|
629
|
-
this.pendingCommands.delete(message.message_id);
|
|
630
|
-
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
631
|
-
}
|
|
976
|
+
catch {
|
|
977
|
+
// The reliable data channel may still be warming immediately after a
|
|
978
|
+
// reconnect. Keep the command pending so the retry interval can publish
|
|
979
|
+
// it once the transport accepts writes, or reject at the normal timeout.
|
|
632
980
|
}
|
|
633
981
|
return response;
|
|
634
982
|
}
|
|
635
983
|
async publishCommand(message) {
|
|
636
984
|
await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
|
|
637
985
|
}
|
|
638
|
-
waitForRemoteAudioTrack(deadline) {
|
|
986
|
+
waitForRemoteAudioTrack(deadline, signal) {
|
|
639
987
|
if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
|
|
640
988
|
return Promise.resolve();
|
|
989
|
+
throwIfAborted(signal);
|
|
641
990
|
return new Promise((resolve, reject) => {
|
|
642
|
-
const
|
|
991
|
+
const cleanup = () => {
|
|
992
|
+
globalThis.clearTimeout(timeout);
|
|
993
|
+
signal?.removeEventListener("abort", onAbort);
|
|
643
994
|
this.remoteAudioTrackResolve = null;
|
|
995
|
+
};
|
|
996
|
+
const timeout = globalThis.setTimeout(() => {
|
|
997
|
+
cleanup();
|
|
644
998
|
reject(new Error("Agent audio track did not arrive within the ready timeout;"
|
|
645
999
|
+ " pass waitForRemoteAudio: false to resolve connect() on signaling readiness alone"));
|
|
646
1000
|
}, Math.max(deadline - Date.now(), 0));
|
|
1001
|
+
const onAbort = () => {
|
|
1002
|
+
cleanup();
|
|
1003
|
+
reject(abortError(signal));
|
|
1004
|
+
};
|
|
647
1005
|
this.remoteAudioTrackResolve = () => {
|
|
648
|
-
|
|
649
|
-
this.remoteAudioTrackResolve = null;
|
|
1006
|
+
cleanup();
|
|
650
1007
|
resolve();
|
|
651
1008
|
};
|
|
1009
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
652
1010
|
});
|
|
653
1011
|
}
|
|
654
|
-
waitForAgentReady(timeoutMs) {
|
|
1012
|
+
waitForAgentReady(timeoutMs, signal) {
|
|
655
1013
|
if (this.agentReady)
|
|
656
1014
|
return Promise.resolve();
|
|
1015
|
+
throwIfAborted(signal);
|
|
657
1016
|
return new Promise((resolve, reject) => {
|
|
658
|
-
const
|
|
1017
|
+
const cleanup = () => {
|
|
1018
|
+
globalThis.clearTimeout(timeout);
|
|
1019
|
+
signal?.removeEventListener("abort", onAbort);
|
|
659
1020
|
this.readyResolve = null;
|
|
1021
|
+
};
|
|
1022
|
+
const timeout = globalThis.setTimeout(() => {
|
|
1023
|
+
cleanup();
|
|
660
1024
|
reject(new Error(`Agent did not become ready within ${timeoutMs}ms`));
|
|
661
1025
|
}, timeoutMs);
|
|
1026
|
+
const onAbort = () => {
|
|
1027
|
+
cleanup();
|
|
1028
|
+
reject(abortError(signal));
|
|
1029
|
+
};
|
|
662
1030
|
this.readyResolve = () => {
|
|
663
|
-
|
|
664
|
-
this.readyResolve = null;
|
|
1031
|
+
cleanup();
|
|
665
1032
|
resolve();
|
|
666
1033
|
};
|
|
1034
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
667
1035
|
});
|
|
668
1036
|
}
|
|
669
1037
|
observeReadiness(event) {
|
|
@@ -676,8 +1044,12 @@ export class RealtimeSession {
|
|
|
676
1044
|
return;
|
|
677
1045
|
const wasReady = this.agentReady;
|
|
678
1046
|
this.agentReady = true;
|
|
679
|
-
if (this.room.state === LiveKitConnectionState.Connected)
|
|
1047
|
+
if (this.room.state === LiveKitConnectionState.Connected) {
|
|
1048
|
+
this.hasConnectedValue = true;
|
|
680
1049
|
this.setState("connected");
|
|
1050
|
+
if (this.activeRecovery?.strategy === "livekit")
|
|
1051
|
+
this.completeRecovery();
|
|
1052
|
+
}
|
|
681
1053
|
this.readyResolve?.();
|
|
682
1054
|
if (!wasReady)
|
|
683
1055
|
this.notifyListeners(this.readyListeners, event);
|
|
@@ -737,10 +1109,16 @@ export class RealtimeSession {
|
|
|
737
1109
|
async fetchReplay(apiBase, afterSeq) {
|
|
738
1110
|
let nextAfter = afterSeq;
|
|
739
1111
|
while (true) {
|
|
740
|
-
let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
|
|
1112
|
+
let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
|
|
1113
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1114
|
+
credentials: this.lifecycleCredentials,
|
|
1115
|
+
});
|
|
741
1116
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
742
1117
|
await this.refreshCredentials();
|
|
743
|
-
response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
|
|
1118
|
+
response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
|
|
1119
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1120
|
+
credentials: this.lifecycleCredentials,
|
|
1121
|
+
});
|
|
744
1122
|
}
|
|
745
1123
|
if (!response.ok)
|
|
746
1124
|
throw await httpError(response, "Could not replay session events");
|
|
@@ -769,6 +1147,96 @@ export class RealtimeSession {
|
|
|
769
1147
|
}
|
|
770
1148
|
}
|
|
771
1149
|
}
|
|
1150
|
+
function resolveAutoRejoinOptions(value) {
|
|
1151
|
+
if (!value)
|
|
1152
|
+
return null;
|
|
1153
|
+
const options = value === true ? {} : value;
|
|
1154
|
+
const resolved = {
|
|
1155
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
1156
|
+
baseDelayMs: options.baseDelayMs ?? 750,
|
|
1157
|
+
maxDelayMs: options.maxDelayMs ?? 5_000,
|
|
1158
|
+
};
|
|
1159
|
+
if (!Number.isInteger(resolved.maxAttempts) || resolved.maxAttempts < 1) {
|
|
1160
|
+
throw new TypeError("autoRejoin.maxAttempts must be a positive integer");
|
|
1161
|
+
}
|
|
1162
|
+
if (!Number.isFinite(resolved.baseDelayMs) || resolved.baseDelayMs < 0) {
|
|
1163
|
+
throw new TypeError("autoRejoin.baseDelayMs must be a non-negative finite number");
|
|
1164
|
+
}
|
|
1165
|
+
if (!Number.isFinite(resolved.maxDelayMs) || resolved.maxDelayMs < 0) {
|
|
1166
|
+
throw new TypeError("autoRejoin.maxDelayMs must be a non-negative finite number");
|
|
1167
|
+
}
|
|
1168
|
+
return resolved;
|
|
1169
|
+
}
|
|
1170
|
+
function isTerminalDisconnectReason(reason) {
|
|
1171
|
+
return reason !== undefined && [
|
|
1172
|
+
DisconnectReason.CLIENT_INITIATED,
|
|
1173
|
+
DisconnectReason.DUPLICATE_IDENTITY,
|
|
1174
|
+
DisconnectReason.PARTICIPANT_REMOVED,
|
|
1175
|
+
DisconnectReason.ROOM_DELETED,
|
|
1176
|
+
DisconnectReason.ROOM_CLOSED,
|
|
1177
|
+
DisconnectReason.USER_UNAVAILABLE,
|
|
1178
|
+
DisconnectReason.USER_REJECTED,
|
|
1179
|
+
].includes(reason);
|
|
1180
|
+
}
|
|
1181
|
+
function disconnectReasonName(reason) {
|
|
1182
|
+
if (reason === undefined)
|
|
1183
|
+
return "unknown";
|
|
1184
|
+
return DisconnectReason[reason] ?? String(reason);
|
|
1185
|
+
}
|
|
1186
|
+
function isTerminalRejoinError(error) {
|
|
1187
|
+
return error instanceof RealtimeSessionHttpError
|
|
1188
|
+
&& [401, 403, 409].includes(error.status);
|
|
1189
|
+
}
|
|
1190
|
+
function toError(error) {
|
|
1191
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1192
|
+
}
|
|
1193
|
+
function abortError(signal) {
|
|
1194
|
+
return signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
1195
|
+
}
|
|
1196
|
+
function throwIfAborted(signal) {
|
|
1197
|
+
if (signal?.aborted)
|
|
1198
|
+
throw abortError(signal);
|
|
1199
|
+
}
|
|
1200
|
+
function abortableDelay(delayMs, signal) {
|
|
1201
|
+
throwIfAborted(signal);
|
|
1202
|
+
if (delayMs <= 0)
|
|
1203
|
+
return Promise.resolve();
|
|
1204
|
+
return new Promise((resolve, reject) => {
|
|
1205
|
+
const timeout = globalThis.setTimeout(() => {
|
|
1206
|
+
signal.removeEventListener("abort", onAbort);
|
|
1207
|
+
resolve();
|
|
1208
|
+
}, delayMs);
|
|
1209
|
+
const onAbort = () => {
|
|
1210
|
+
globalThis.clearTimeout(timeout);
|
|
1211
|
+
reject(abortError(signal));
|
|
1212
|
+
};
|
|
1213
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
function waitUntilOnline(signal) {
|
|
1217
|
+
throwIfAborted(signal);
|
|
1218
|
+
if (typeof navigator === "undefined" || navigator.onLine !== false) {
|
|
1219
|
+
return Promise.resolve();
|
|
1220
|
+
}
|
|
1221
|
+
if (typeof globalThis.addEventListener !== "function")
|
|
1222
|
+
return Promise.resolve();
|
|
1223
|
+
return new Promise((resolve, reject) => {
|
|
1224
|
+
const cleanup = () => {
|
|
1225
|
+
globalThis.removeEventListener("online", onOnline);
|
|
1226
|
+
signal.removeEventListener("abort", onAbort);
|
|
1227
|
+
};
|
|
1228
|
+
const onOnline = () => {
|
|
1229
|
+
cleanup();
|
|
1230
|
+
resolve();
|
|
1231
|
+
};
|
|
1232
|
+
const onAbort = () => {
|
|
1233
|
+
cleanup();
|
|
1234
|
+
reject(abortError(signal));
|
|
1235
|
+
};
|
|
1236
|
+
globalThis.addEventListener("online", onOnline, { once: true });
|
|
1237
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
772
1240
|
async function sha256Hex(data) {
|
|
773
1241
|
if (!globalThis.crypto?.subtle)
|
|
774
1242
|
throw new Error("Web Crypto SHA-256 is unavailable");
|