@voxevolv/client 0.3.4 → 0.3.5
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 +18 -0
- package/README.md +61 -4
- 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/index.d.ts +29 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +336 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,9 +6,15 @@ import { TranscriptStore } from "./transcripts.js";
|
|
|
6
6
|
export { TranscriptStore } from "./transcripts.js";
|
|
7
7
|
export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
8
8
|
export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
|
|
9
|
-
export const SDK_VERSION = "0.3.
|
|
9
|
+
export const SDK_VERSION = "0.3.5";
|
|
10
10
|
const CLIENT_HELLO_TIMEOUT_MS = 30_000;
|
|
11
11
|
const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
|
|
12
|
+
const DEFAULT_STARTUP_ATTEMPTS = [
|
|
13
|
+
{ delayBeforeMs: 0, readyTimeoutMs: 15_000 },
|
|
14
|
+
{ delayBeforeMs: 1_000, readyTimeoutMs: 20_000 },
|
|
15
|
+
{ delayBeforeMs: 3_000, readyTimeoutMs: 25_000 },
|
|
16
|
+
];
|
|
17
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 65_000;
|
|
12
18
|
export class RealtimeSessionHttpError extends Error {
|
|
13
19
|
status;
|
|
14
20
|
code;
|
|
@@ -23,6 +29,20 @@ export class RealtimeSessionHttpError extends Error {
|
|
|
23
29
|
this.details = options.details ?? [];
|
|
24
30
|
}
|
|
25
31
|
}
|
|
32
|
+
class SessionConfigurationError extends TypeError {
|
|
33
|
+
}
|
|
34
|
+
class SessionErrorEventError extends Error {
|
|
35
|
+
retryable;
|
|
36
|
+
constructor(event) {
|
|
37
|
+
const payload = event.payload;
|
|
38
|
+
const message = typeof payload.message === "string"
|
|
39
|
+
? payload.message
|
|
40
|
+
: "VoxEvolv reported a session error";
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "SessionErrorEventError";
|
|
43
|
+
this.retryable = payload.retryable === true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
26
46
|
export class RealtimeSession {
|
|
27
47
|
id;
|
|
28
48
|
room;
|
|
@@ -34,6 +54,7 @@ export class RealtimeSession {
|
|
|
34
54
|
gapListeners = new Set();
|
|
35
55
|
connectionQualityListeners = new Set();
|
|
36
56
|
recoveryListeners = new Set();
|
|
57
|
+
startupAttemptListeners = new Set();
|
|
37
58
|
remoteAudioTrackListeners = new Set();
|
|
38
59
|
remoteAudioTrackUnsubscribedListeners = new Set();
|
|
39
60
|
readyListeners = new Set();
|
|
@@ -61,6 +82,7 @@ export class RealtimeSession {
|
|
|
61
82
|
readyResolve = null;
|
|
62
83
|
replayPromise = null;
|
|
63
84
|
pendingCommands = new Map();
|
|
85
|
+
commandPublishTail = Promise.resolve();
|
|
64
86
|
toolHandlers = new Map();
|
|
65
87
|
apiBase = "/v1";
|
|
66
88
|
hasConnectedValue = false;
|
|
@@ -71,6 +93,8 @@ export class RealtimeSession {
|
|
|
71
93
|
activeRecovery = null;
|
|
72
94
|
autoRejoinPromise = null;
|
|
73
95
|
autoRejoinController = null;
|
|
96
|
+
startupConnectController = null;
|
|
97
|
+
startupAttemptController = null;
|
|
74
98
|
useManagedMicrophone = true;
|
|
75
99
|
managedMicrophoneEnabled = true;
|
|
76
100
|
applicationAudioTrack = null;
|
|
@@ -79,6 +103,7 @@ export class RealtimeSession {
|
|
|
79
103
|
endEndpoint;
|
|
80
104
|
refreshCredentialsCallback;
|
|
81
105
|
autoRejoinOptions;
|
|
106
|
+
startupRetryOptions;
|
|
82
107
|
lifecycleCredentials;
|
|
83
108
|
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
84
109
|
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
@@ -95,6 +120,7 @@ export class RealtimeSession {
|
|
|
95
120
|
this.endEndpoint = options.endEndpoint ?? null;
|
|
96
121
|
this.refreshCredentialsCallback = options.refreshCredentials ?? null;
|
|
97
122
|
this.autoRejoinOptions = resolveAutoRejoinOptions(options.autoRejoin);
|
|
123
|
+
this.startupRetryOptions = resolveStartupRetryOptions(options.startupRetry);
|
|
98
124
|
this.lifecycleCredentials = options.lifecycleCredentials ?? "same-origin";
|
|
99
125
|
this.room = new Room({ adaptiveStream: true, dynacast: true });
|
|
100
126
|
for (const [name, handler] of Object.entries(options.tools ?? {})) {
|
|
@@ -194,6 +220,10 @@ export class RealtimeSession {
|
|
|
194
220
|
this.recoveryListeners.add(listener);
|
|
195
221
|
return () => this.recoveryListeners.delete(listener);
|
|
196
222
|
}
|
|
223
|
+
onStartupAttempt(listener) {
|
|
224
|
+
this.startupAttemptListeners.add(listener);
|
|
225
|
+
return () => this.startupAttemptListeners.delete(listener);
|
|
226
|
+
}
|
|
197
227
|
waitForState(states, timeoutMs, options = {}) {
|
|
198
228
|
if (!states.length)
|
|
199
229
|
throw new TypeError("states must contain at least one connection state");
|
|
@@ -302,13 +332,106 @@ export class RealtimeSession {
|
|
|
302
332
|
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
303
333
|
}
|
|
304
334
|
this.cancelAutoRejoin();
|
|
305
|
-
|
|
335
|
+
const controller = new AbortController();
|
|
336
|
+
this.startupConnectController = controller;
|
|
337
|
+
try {
|
|
338
|
+
if (this.startupRetryOptions) {
|
|
339
|
+
await this.connectWithStartupRetry(options, controller);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
this.startupAttemptController = controller;
|
|
343
|
+
await this.connectWithMode(options, false, controller.signal, false);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (this.startupConnectController === controller && !this.sessionEnded) {
|
|
348
|
+
this.setState("failed");
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
if (this.startupAttemptController === controller) {
|
|
354
|
+
this.startupAttemptController = null;
|
|
355
|
+
}
|
|
356
|
+
if (this.startupConnectController === controller) {
|
|
357
|
+
this.startupConnectController = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
306
360
|
}
|
|
307
|
-
async
|
|
361
|
+
async connectWithStartupRetry(options, controller) {
|
|
362
|
+
const startupOptions = this.startupRetryOptions;
|
|
363
|
+
if (!startupOptions)
|
|
364
|
+
return;
|
|
365
|
+
const timeout = globalThis.setTimeout(() => {
|
|
366
|
+
controller.abort(new Error(`Session startup did not complete within ${startupOptions.timeoutMs}ms`));
|
|
367
|
+
}, startupOptions.timeoutMs);
|
|
368
|
+
let lastError = new Error("Session startup failed");
|
|
369
|
+
try {
|
|
370
|
+
for (const [index, attempt] of startupOptions.attempts.entries()) {
|
|
371
|
+
const attemptNumber = index + 1;
|
|
372
|
+
if (attempt.delayBeforeMs > 0) {
|
|
373
|
+
this.setState("connecting");
|
|
374
|
+
await abortableDelay(attempt.delayBeforeMs, controller.signal);
|
|
375
|
+
}
|
|
376
|
+
throwIfAborted(controller.signal);
|
|
377
|
+
const attemptController = createLinkedAbortController(controller.signal);
|
|
378
|
+
this.startupAttemptController = attemptController;
|
|
379
|
+
this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
|
|
380
|
+
try {
|
|
381
|
+
if (index === 0) {
|
|
382
|
+
await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
await this.performRejoin(this.apiBase, {
|
|
386
|
+
signal: attemptController.signal,
|
|
387
|
+
recovering: false,
|
|
388
|
+
connect: { ...options, readyTimeoutMs: attempt.readyTimeoutMs },
|
|
389
|
+
markFailureTerminal: false,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
this.notifyStartupAttempt("succeeded", attemptNumber, attempt.readyTimeoutMs);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
lastError = toError(error);
|
|
397
|
+
this.notifyStartupAttempt("failed", attemptNumber, attempt.readyTimeoutMs, lastError);
|
|
398
|
+
if (controller.signal.aborted)
|
|
399
|
+
throw abortError(controller.signal);
|
|
400
|
+
if (isTerminalStartupError(error) || attemptNumber === startupOptions.attempts.length) {
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
this.setState("connecting");
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
if (this.startupAttemptController === attemptController) {
|
|
407
|
+
this.startupAttemptController = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
throw lastError;
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
globalThis.clearTimeout(timeout);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
|
|
418
|
+
const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
|
|
419
|
+
this.notifyListeners(this.startupAttemptListeners, {
|
|
420
|
+
status,
|
|
421
|
+
attempt,
|
|
422
|
+
maxAttempts,
|
|
423
|
+
readyTimeoutMs,
|
|
424
|
+
...(error ? { error } : {}),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
|
|
308
428
|
const blockedStates = recovering
|
|
309
429
|
? ["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
310
430
|
: ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
|
|
311
|
-
|
|
431
|
+
const continuingStartupRetry = (!recovering
|
|
432
|
+
&& !markFailureTerminal
|
|
433
|
+
&& this.stateValue === "connecting");
|
|
434
|
+
if (blockedStates.includes(this.stateValue) && !continuingStartupRetry) {
|
|
312
435
|
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
313
436
|
}
|
|
314
437
|
const generation = this.contextGeneration;
|
|
@@ -337,17 +460,17 @@ export class RealtimeSession {
|
|
|
337
460
|
this.apiBase = apiBase;
|
|
338
461
|
this.agentReady = false;
|
|
339
462
|
try {
|
|
340
|
-
await this.
|
|
463
|
+
await this.connectRoom(signal, recovering);
|
|
341
464
|
throwIfAborted(signal);
|
|
342
465
|
if (audioTrack) {
|
|
343
|
-
await this.publishApplicationAudioTrack(audioTrack);
|
|
466
|
+
await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
|
|
344
467
|
}
|
|
345
468
|
else if (microphone) {
|
|
346
|
-
await this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled);
|
|
469
|
+
await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled), signal);
|
|
347
470
|
}
|
|
348
471
|
throwIfAborted(signal);
|
|
349
472
|
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
350
|
-
await this.sendHello();
|
|
473
|
+
await raceWithAbort(this.sendHello(), signal);
|
|
351
474
|
const readyDeadline = Date.now() + readyTimeoutMs;
|
|
352
475
|
await this.waitForAgentReady(readyTimeoutMs, signal);
|
|
353
476
|
if (waitForRemoteAudio)
|
|
@@ -356,7 +479,7 @@ export class RealtimeSession {
|
|
|
356
479
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
357
480
|
throw new Error("Connection restoration interrupted");
|
|
358
481
|
}
|
|
359
|
-
await this.replacementContext.resume(recovering);
|
|
482
|
+
await raceWithAbort(this.replacementContext.resume(recovering), signal);
|
|
360
483
|
throwIfAborted(signal);
|
|
361
484
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
362
485
|
throw new Error("Connection restoration interrupted");
|
|
@@ -365,6 +488,8 @@ export class RealtimeSession {
|
|
|
365
488
|
this.setState("connected");
|
|
366
489
|
}
|
|
367
490
|
catch (error) {
|
|
491
|
+
if (signal?.aborted)
|
|
492
|
+
this.rejectPendingCommands(toError(abortError(signal)));
|
|
368
493
|
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
369
494
|
try {
|
|
370
495
|
await this.room.disconnect();
|
|
@@ -375,11 +500,46 @@ export class RealtimeSession {
|
|
|
375
500
|
finally {
|
|
376
501
|
this.disconnectMode = null;
|
|
377
502
|
}
|
|
378
|
-
if (!recovering)
|
|
503
|
+
if (!recovering && markFailureTerminal)
|
|
379
504
|
this.setState("failed");
|
|
380
505
|
throw error;
|
|
381
506
|
}
|
|
382
507
|
}
|
|
508
|
+
connectRoom(signal, recovering) {
|
|
509
|
+
throwIfAborted(signal);
|
|
510
|
+
const connection = this.room.connect(this.credentials.url, this.credentials.token);
|
|
511
|
+
if (!signal)
|
|
512
|
+
return connection;
|
|
513
|
+
return new Promise((resolve, reject) => {
|
|
514
|
+
let settled = false;
|
|
515
|
+
const resolveOnce = () => {
|
|
516
|
+
if (settled)
|
|
517
|
+
return;
|
|
518
|
+
settled = true;
|
|
519
|
+
signal.removeEventListener("abort", onAbort);
|
|
520
|
+
resolve();
|
|
521
|
+
};
|
|
522
|
+
const rejectOnce = (error) => {
|
|
523
|
+
if (settled)
|
|
524
|
+
return;
|
|
525
|
+
settled = true;
|
|
526
|
+
signal.removeEventListener("abort", onAbort);
|
|
527
|
+
reject(error);
|
|
528
|
+
};
|
|
529
|
+
const onAbort = () => {
|
|
530
|
+
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
531
|
+
void this.room.disconnect().catch(() => { });
|
|
532
|
+
rejectOnce(abortError(signal));
|
|
533
|
+
};
|
|
534
|
+
if (signal.aborted) {
|
|
535
|
+
onAbort();
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
539
|
+
}
|
|
540
|
+
connection.then(resolveOnce, rejectOnce);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
383
543
|
async rejoin(apiBase = this.apiBase) {
|
|
384
544
|
if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
385
545
|
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
@@ -392,7 +552,7 @@ export class RealtimeSession {
|
|
|
392
552
|
this.setState("resuming");
|
|
393
553
|
}
|
|
394
554
|
try {
|
|
395
|
-
await this.performRejoin(normalizedApiBase,
|
|
555
|
+
await this.performRejoin(normalizedApiBase, { recovering });
|
|
396
556
|
if (recovering)
|
|
397
557
|
this.completeRecovery();
|
|
398
558
|
}
|
|
@@ -404,19 +564,21 @@ export class RealtimeSession {
|
|
|
404
564
|
throw error;
|
|
405
565
|
}
|
|
406
566
|
}
|
|
407
|
-
async performRejoin(apiBase,
|
|
567
|
+
async performRejoin(apiBase, options = {}) {
|
|
568
|
+
const { signal, recovering = true, connect = {}, markFailureTerminal = true, } = options;
|
|
408
569
|
this.contextGeneration += 1;
|
|
409
570
|
this.replacementContext.pause();
|
|
410
571
|
this.rejectPendingCommands(new Error("Restoring the session"));
|
|
411
572
|
let response = await this.fetchRoomToken(apiBase, signal);
|
|
412
573
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
413
|
-
await this.refreshCredentials();
|
|
574
|
+
await raceWithAbort(this.refreshCredentials(), signal);
|
|
414
575
|
throwIfAborted(signal);
|
|
415
576
|
response = await this.fetchRoomToken(apiBase, signal);
|
|
416
577
|
}
|
|
417
|
-
if (!response.ok)
|
|
418
|
-
throw await httpError(response, "Could not renew room token");
|
|
419
|
-
|
|
578
|
+
if (!response.ok) {
|
|
579
|
+
throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
|
|
580
|
+
}
|
|
581
|
+
const payload = (await raceWithAbort(response.json(), signal));
|
|
420
582
|
this.credentials = payload.connection;
|
|
421
583
|
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
422
584
|
this.disconnectMode = "rejoin";
|
|
@@ -436,10 +598,11 @@ export class RealtimeSession {
|
|
|
436
598
|
};
|
|
437
599
|
}
|
|
438
600
|
await this.connectWithMode({
|
|
601
|
+
...connect,
|
|
439
602
|
apiBase,
|
|
440
603
|
microphone: this.useManagedMicrophone,
|
|
441
604
|
...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
|
|
442
|
-
}, recovering, signal);
|
|
605
|
+
}, recovering, signal, markFailureTerminal);
|
|
443
606
|
}
|
|
444
607
|
async setMicrophoneEnabled(enabled) {
|
|
445
608
|
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
@@ -617,6 +780,8 @@ export class RealtimeSession {
|
|
|
617
780
|
};
|
|
618
781
|
}
|
|
619
782
|
async disconnect() {
|
|
783
|
+
this.abortStartupConnect(new Error("Session disconnected"));
|
|
784
|
+
this.startupConnectController = null;
|
|
620
785
|
this.cancelAutoRejoin();
|
|
621
786
|
this.contextGeneration += 1;
|
|
622
787
|
this.replacementContext.clear(new Error("Session disconnected"));
|
|
@@ -634,6 +799,8 @@ export class RealtimeSession {
|
|
|
634
799
|
}
|
|
635
800
|
}
|
|
636
801
|
async end(options = {}) {
|
|
802
|
+
this.abortStartupConnect(new Error("Session ended"));
|
|
803
|
+
this.startupConnectController = null;
|
|
637
804
|
this.cancelAutoRejoin();
|
|
638
805
|
this.sessionEnded = true;
|
|
639
806
|
const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
|
|
@@ -988,7 +1155,7 @@ export class RealtimeSession {
|
|
|
988
1155
|
}
|
|
989
1156
|
throwIfAborted(signal);
|
|
990
1157
|
this.beginRecovery("rejoin", attempt, details);
|
|
991
|
-
await this.performRejoin(this.apiBase, signal);
|
|
1158
|
+
await this.performRejoin(this.apiBase, { signal });
|
|
992
1159
|
throwIfAborted(signal);
|
|
993
1160
|
this.reconnectStartedAt = null;
|
|
994
1161
|
this.completeRecovery();
|
|
@@ -1034,15 +1201,15 @@ export class RealtimeSession {
|
|
|
1034
1201
|
}
|
|
1035
1202
|
validateAudioOptions(microphone, audioTrack) {
|
|
1036
1203
|
if (microphone && audioTrack) {
|
|
1037
|
-
throw new
|
|
1204
|
+
throw new SessionConfigurationError("microphone and audioTrack cannot both be enabled");
|
|
1038
1205
|
}
|
|
1039
1206
|
if (!audioTrack)
|
|
1040
1207
|
return;
|
|
1041
1208
|
if (audioTrack.kind !== "audio") {
|
|
1042
|
-
throw new
|
|
1209
|
+
throw new SessionConfigurationError("audioTrack must be an audio MediaStreamTrack");
|
|
1043
1210
|
}
|
|
1044
1211
|
if (audioTrack.readyState === "ended") {
|
|
1045
|
-
throw new
|
|
1212
|
+
throw new SessionConfigurationError("audioTrack has already ended");
|
|
1046
1213
|
}
|
|
1047
1214
|
}
|
|
1048
1215
|
async fetchRoomToken(apiBase, signal) {
|
|
@@ -1052,12 +1219,12 @@ export class RealtimeSession {
|
|
|
1052
1219
|
const timeout = globalThis.setTimeout(() => controller.abort(), 10_000);
|
|
1053
1220
|
try {
|
|
1054
1221
|
throwIfAborted(signal);
|
|
1055
|
-
return await this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
1222
|
+
return await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
1056
1223
|
method: "POST",
|
|
1057
1224
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1058
1225
|
credentials: this.lifecycleCredentials,
|
|
1059
1226
|
signal: controller.signal,
|
|
1060
|
-
});
|
|
1227
|
+
}), controller.signal);
|
|
1061
1228
|
}
|
|
1062
1229
|
finally {
|
|
1063
1230
|
globalThis.clearTimeout(timeout);
|
|
@@ -1076,7 +1243,17 @@ export class RealtimeSession {
|
|
|
1076
1243
|
this.credentials = refreshed.connection;
|
|
1077
1244
|
}
|
|
1078
1245
|
observeConvenienceEvents(event) {
|
|
1246
|
+
if (event.type === "session.error") {
|
|
1247
|
+
const error = new SessionErrorEventError(event);
|
|
1248
|
+
if (error.retryable) {
|
|
1249
|
+
this.startupAttemptController?.abort(error);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
this.abortStartupConnect(error);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1079
1255
|
if (event.type === "session.ending" || event.type === "session.ended") {
|
|
1256
|
+
this.abortStartupConnect(new Error("Session ended during startup"));
|
|
1080
1257
|
this.contextGeneration += 1;
|
|
1081
1258
|
this.rejectPendingCommands(new Error("Session ended"));
|
|
1082
1259
|
}
|
|
@@ -1114,6 +1291,11 @@ export class RealtimeSession {
|
|
|
1114
1291
|
return;
|
|
1115
1292
|
this.notifyListeners(transcript.role === "user" ? this.userTranscriptListeners : this.agentTranscriptListeners, transcript);
|
|
1116
1293
|
}
|
|
1294
|
+
abortStartupConnect(error) {
|
|
1295
|
+
if (!this.startupConnectController?.signal.aborted) {
|
|
1296
|
+
this.startupConnectController?.abort(error);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1117
1299
|
async publishApplicationAudioTrack(audioTrack) {
|
|
1118
1300
|
const publishedTrack = audioTrack.clone();
|
|
1119
1301
|
try {
|
|
@@ -1156,11 +1338,14 @@ export class RealtimeSession {
|
|
|
1156
1338
|
timeout_ms: timeoutMs,
|
|
1157
1339
|
event_type: diagnosticEventType(message.type),
|
|
1158
1340
|
});
|
|
1341
|
+
let markSettled = () => { };
|
|
1342
|
+
const settled = new Promise((resolve) => { markSettled = resolve; });
|
|
1159
1343
|
const response = new Promise((resolve, reject) => {
|
|
1160
1344
|
const timeout = globalThis.setTimeout(() => {
|
|
1161
1345
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1162
1346
|
if (pending)
|
|
1163
1347
|
globalThis.clearInterval(pending.retry);
|
|
1348
|
+
pending?.markSettled();
|
|
1164
1349
|
this.pendingCommands.delete(message.message_id);
|
|
1165
1350
|
const error = new Error(`Command ${message.type} timed out after ${timeoutMs}ms`);
|
|
1166
1351
|
this.recordDiagnostic({
|
|
@@ -1203,10 +1388,13 @@ export class RealtimeSession {
|
|
|
1203
1388
|
contextKey,
|
|
1204
1389
|
payloadBytes,
|
|
1205
1390
|
retryCount: 0,
|
|
1391
|
+
publishQueued: false,
|
|
1206
1392
|
resolve,
|
|
1207
1393
|
reject,
|
|
1208
1394
|
timeout,
|
|
1209
1395
|
retry,
|
|
1396
|
+
settled,
|
|
1397
|
+
markSettled,
|
|
1210
1398
|
retryTransfer: retryTransfer
|
|
1211
1399
|
? async () => {
|
|
1212
1400
|
await retryTransfer();
|
|
@@ -1216,17 +1404,32 @@ export class RealtimeSession {
|
|
|
1216
1404
|
transferRetrying: false,
|
|
1217
1405
|
});
|
|
1218
1406
|
});
|
|
1219
|
-
|
|
1220
|
-
await this.publishCommandAttempt(message, false);
|
|
1221
|
-
}
|
|
1222
|
-
catch {
|
|
1407
|
+
void this.publishCommandAttempt(message, false).catch(() => {
|
|
1223
1408
|
// The reliable data channel may still be warming immediately after a
|
|
1224
1409
|
// reconnect. Keep the command pending so the retry interval can publish
|
|
1225
1410
|
// it once the transport accepts writes, or reject at the normal timeout.
|
|
1226
|
-
}
|
|
1411
|
+
});
|
|
1227
1412
|
return response;
|
|
1228
1413
|
}
|
|
1229
|
-
|
|
1414
|
+
publishCommandAttempt(message, retrying) {
|
|
1415
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1416
|
+
if (!pending || pending.publishQueued)
|
|
1417
|
+
return Promise.resolve();
|
|
1418
|
+
pending.publishQueued = true;
|
|
1419
|
+
// Context keys drain independently, but client_seq is global. Serialize the
|
|
1420
|
+
// asynchronous LiveKit publish boundary so a later sequence cannot overtake
|
|
1421
|
+
// an earlier one before the reliable data channel receives it.
|
|
1422
|
+
const attempt = this.commandPublishTail
|
|
1423
|
+
.then(() => this.publishCommandAttemptNow(message, retrying))
|
|
1424
|
+
.finally(() => {
|
|
1425
|
+
const current = this.pendingCommands.get(message.message_id);
|
|
1426
|
+
if (current === pending)
|
|
1427
|
+
current.publishQueued = false;
|
|
1428
|
+
});
|
|
1429
|
+
this.commandPublishTail = attempt.catch(() => { });
|
|
1430
|
+
return attempt;
|
|
1431
|
+
}
|
|
1432
|
+
async publishCommandAttemptNow(message, retrying) {
|
|
1230
1433
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1231
1434
|
if (!pending)
|
|
1232
1435
|
return;
|
|
@@ -1246,7 +1449,11 @@ export class RealtimeSession {
|
|
|
1246
1449
|
}
|
|
1247
1450
|
const attemptStartedAt = Date.now();
|
|
1248
1451
|
try {
|
|
1249
|
-
|
|
1452
|
+
let publishFinished = false;
|
|
1453
|
+
const publish = this.publishCommand(message).then(() => { publishFinished = true; });
|
|
1454
|
+
await Promise.race([publish, pending.settled]);
|
|
1455
|
+
if (!publishFinished)
|
|
1456
|
+
return;
|
|
1250
1457
|
}
|
|
1251
1458
|
catch (error) {
|
|
1252
1459
|
this.recordDiagnostic({
|
|
@@ -1422,6 +1629,7 @@ export class RealtimeSession {
|
|
|
1422
1629
|
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1423
1630
|
globalThis.clearTimeout(pending.timeout);
|
|
1424
1631
|
globalThis.clearInterval(pending.retry);
|
|
1632
|
+
pending.markSettled();
|
|
1425
1633
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1426
1634
|
pending.resolve(event.payload);
|
|
1427
1635
|
return;
|
|
@@ -1430,6 +1638,7 @@ export class RealtimeSession {
|
|
|
1430
1638
|
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1431
1639
|
globalThis.clearTimeout(pending.timeout);
|
|
1432
1640
|
globalThis.clearInterval(pending.retry);
|
|
1641
|
+
pending.markSettled();
|
|
1433
1642
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1434
1643
|
pending.resolve(event.payload);
|
|
1435
1644
|
return;
|
|
@@ -1451,6 +1660,7 @@ export class RealtimeSession {
|
|
|
1451
1660
|
}
|
|
1452
1661
|
globalThis.clearTimeout(pending.timeout);
|
|
1453
1662
|
globalThis.clearInterval(pending.retry);
|
|
1663
|
+
pending.markSettled();
|
|
1454
1664
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1455
1665
|
pending.reject(new CommandError(event.payload));
|
|
1456
1666
|
}
|
|
@@ -1462,6 +1672,7 @@ export class RealtimeSession {
|
|
|
1462
1672
|
this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
|
|
1463
1673
|
globalThis.clearTimeout(pending.timeout);
|
|
1464
1674
|
globalThis.clearInterval(pending.retry);
|
|
1675
|
+
pending.markSettled();
|
|
1465
1676
|
pending.reject(error);
|
|
1466
1677
|
this.pendingCommands.delete(messageId);
|
|
1467
1678
|
}
|
|
@@ -1549,6 +1760,31 @@ function resolveAutoRejoinOptions(value) {
|
|
|
1549
1760
|
}
|
|
1550
1761
|
return resolved;
|
|
1551
1762
|
}
|
|
1763
|
+
function resolveStartupRetryOptions(value) {
|
|
1764
|
+
if (!value)
|
|
1765
|
+
return null;
|
|
1766
|
+
const options = value === true ? {} : value;
|
|
1767
|
+
const attempts = (options.attempts ?? DEFAULT_STARTUP_ATTEMPTS).map((attempt) => ({
|
|
1768
|
+
readyTimeoutMs: attempt.readyTimeoutMs,
|
|
1769
|
+
delayBeforeMs: attempt.delayBeforeMs ?? 0,
|
|
1770
|
+
}));
|
|
1771
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
1772
|
+
if (attempts.length === 0) {
|
|
1773
|
+
throw new TypeError("startupRetry.attempts must contain at least one attempt");
|
|
1774
|
+
}
|
|
1775
|
+
for (const attempt of attempts) {
|
|
1776
|
+
if (!Number.isFinite(attempt.readyTimeoutMs) || attempt.readyTimeoutMs <= 0) {
|
|
1777
|
+
throw new TypeError("startupRetry.attempts readyTimeoutMs must be a positive finite number");
|
|
1778
|
+
}
|
|
1779
|
+
if (!Number.isFinite(attempt.delayBeforeMs) || attempt.delayBeforeMs < 0) {
|
|
1780
|
+
throw new TypeError("startupRetry.attempts delayBeforeMs must be a non-negative finite number");
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
1784
|
+
throw new TypeError("startupRetry.timeoutMs must be a positive finite number");
|
|
1785
|
+
}
|
|
1786
|
+
return { attempts, timeoutMs };
|
|
1787
|
+
}
|
|
1552
1788
|
function isTerminalDisconnectReason(reason) {
|
|
1553
1789
|
return reason !== undefined && [
|
|
1554
1790
|
DisconnectReason.CLIENT_INITIATED,
|
|
@@ -1569,16 +1805,82 @@ function isTerminalRejoinError(error) {
|
|
|
1569
1805
|
return error instanceof RealtimeSessionHttpError
|
|
1570
1806
|
&& [401, 403, 409].includes(error.status);
|
|
1571
1807
|
}
|
|
1808
|
+
function isTerminalStartupError(error) {
|
|
1809
|
+
if (error instanceof SessionErrorEventError)
|
|
1810
|
+
return !error.retryable;
|
|
1811
|
+
if (error instanceof RealtimeSessionHttpError) {
|
|
1812
|
+
return error.status >= 400
|
|
1813
|
+
&& error.status < 500
|
|
1814
|
+
&& ![408, 425, 429].includes(error.status);
|
|
1815
|
+
}
|
|
1816
|
+
// Browser fetch also rejects network failures with TypeError. Only our
|
|
1817
|
+
// explicit configuration errors are terminal before transport is established.
|
|
1818
|
+
if (error instanceof SessionConfigurationError)
|
|
1819
|
+
return true;
|
|
1820
|
+
return error instanceof Error && [
|
|
1821
|
+
"ConstraintNotSatisfiedError",
|
|
1822
|
+
"DevicesNotFoundError",
|
|
1823
|
+
"InvalidStateError",
|
|
1824
|
+
"NotAllowedError",
|
|
1825
|
+
"NotFoundError",
|
|
1826
|
+
"NotReadableError",
|
|
1827
|
+
"NotSupportedError",
|
|
1828
|
+
"OverconstrainedError",
|
|
1829
|
+
"SecurityError",
|
|
1830
|
+
"TrackStartError",
|
|
1831
|
+
].includes(error.name);
|
|
1832
|
+
}
|
|
1572
1833
|
function toError(error) {
|
|
1573
1834
|
return error instanceof Error ? error : new Error(String(error));
|
|
1574
1835
|
}
|
|
1575
1836
|
function abortError(signal) {
|
|
1576
1837
|
return signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
1577
1838
|
}
|
|
1839
|
+
function createLinkedAbortController(parentSignal) {
|
|
1840
|
+
const controller = new AbortController();
|
|
1841
|
+
if (parentSignal.aborted) {
|
|
1842
|
+
controller.abort(abortError(parentSignal));
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
parentSignal.addEventListener("abort", () => controller.abort(abortError(parentSignal)), { once: true });
|
|
1846
|
+
}
|
|
1847
|
+
return controller;
|
|
1848
|
+
}
|
|
1578
1849
|
function throwIfAborted(signal) {
|
|
1579
1850
|
if (signal?.aborted)
|
|
1580
1851
|
throw abortError(signal);
|
|
1581
1852
|
}
|
|
1853
|
+
function raceWithAbort(promise, signal) {
|
|
1854
|
+
if (!signal)
|
|
1855
|
+
return promise;
|
|
1856
|
+
return new Promise((resolve, reject) => {
|
|
1857
|
+
let settled = false;
|
|
1858
|
+
const resolveOnce = (value) => {
|
|
1859
|
+
if (settled)
|
|
1860
|
+
return;
|
|
1861
|
+
settled = true;
|
|
1862
|
+
signal.removeEventListener("abort", onAbort);
|
|
1863
|
+
resolve(value);
|
|
1864
|
+
};
|
|
1865
|
+
const rejectOnce = (error) => {
|
|
1866
|
+
if (settled)
|
|
1867
|
+
return;
|
|
1868
|
+
settled = true;
|
|
1869
|
+
signal.removeEventListener("abort", onAbort);
|
|
1870
|
+
reject(error);
|
|
1871
|
+
};
|
|
1872
|
+
const onAbort = () => rejectOnce(abortError(signal));
|
|
1873
|
+
if (signal.aborted) {
|
|
1874
|
+
onAbort();
|
|
1875
|
+
}
|
|
1876
|
+
else {
|
|
1877
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1878
|
+
}
|
|
1879
|
+
// Always observe the operation, including when it aborted the signal before
|
|
1880
|
+
// returning its promise, so a later rejection cannot escape to the caller.
|
|
1881
|
+
promise.then(resolveOnce, rejectOnce);
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1582
1884
|
function abortableDelay(delayMs, signal) {
|
|
1583
1885
|
throwIfAborted(signal);
|
|
1584
1886
|
if (delayMs <= 0)
|
|
@@ -1671,22 +1973,22 @@ function diagnosticEventType(type) {
|
|
|
1671
1973
|
}
|
|
1672
1974
|
function validateSessionCredentials(value) {
|
|
1673
1975
|
if (!value || typeof value !== "object") {
|
|
1674
|
-
throw new
|
|
1976
|
+
throw new SessionConfigurationError("Session credentials must be an object");
|
|
1675
1977
|
}
|
|
1676
1978
|
if (!value.id || !value.access_token || !value.access_token_expires_at) {
|
|
1677
|
-
throw new
|
|
1979
|
+
throw new SessionConfigurationError("Session credentials are incomplete");
|
|
1678
1980
|
}
|
|
1679
1981
|
if (!value.connection
|
|
1680
1982
|
|| !value.connection.url
|
|
1681
1983
|
|| !value.connection.token
|
|
1682
1984
|
|| !value.connection.room_name) {
|
|
1683
|
-
throw new
|
|
1985
|
+
throw new SessionConfigurationError("Session connection credentials are incomplete");
|
|
1684
1986
|
}
|
|
1685
1987
|
}
|
|
1686
1988
|
function normalizeApiBase(value) {
|
|
1687
1989
|
const normalized = value.trim().replace(/\/+$/, "");
|
|
1688
1990
|
if (!normalized) {
|
|
1689
|
-
throw new
|
|
1991
|
+
throw new SessionConfigurationError("apiBase must be a non-root relative path or an absolute URL");
|
|
1690
1992
|
}
|
|
1691
1993
|
return normalized;
|
|
1692
1994
|
}
|