@voxevolv/client 0.3.7 → 0.3.9
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 +38 -0
- package/README.md +88 -23
- 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/context-queue.d.ts +13 -1
- package/dist/context-queue.d.ts.map +1 -1
- package/dist/context-queue.js +66 -11
- package/dist/context-queue.js.map +1 -1
- package/dist/diagnostics.d.ts +30 -7
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +125 -39
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +230 -82
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
1
|
+
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, LocalAudioTrack, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
2
2
|
import { createClientMessage, CommandError, EventCursor, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
3
|
-
import { ReplacementContextQueue } from "./context-queue.js";
|
|
4
|
-
import { ClientDiagnosticBuffer, } from "./diagnostics.js";
|
|
3
|
+
import { ContextDeliveryError, contextRetryDelay, ReplacementContextQueue } from "./context-queue.js";
|
|
4
|
+
import { ClientDiagnosticBuffer, DiagnosticDeliveryError, } from "./diagnostics.js";
|
|
5
5
|
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.9";
|
|
10
10
|
const CLIENT_HELLO_TIMEOUT_MS = 30_000;
|
|
11
11
|
const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
|
|
12
12
|
const DEFAULT_STARTUP_ATTEMPTS = [
|
|
13
13
|
{ delayBeforeMs: 0, readyTimeoutMs: 15_000 },
|
|
14
14
|
{ delayBeforeMs: 1_000, readyTimeoutMs: 20_000 },
|
|
15
|
-
{ delayBeforeMs: 3_000, readyTimeoutMs:
|
|
15
|
+
{ delayBeforeMs: 3_000, readyTimeoutMs: 50_000 },
|
|
16
16
|
];
|
|
17
|
-
const DEFAULT_STARTUP_TIMEOUT_MS =
|
|
17
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 90_000;
|
|
18
18
|
export class RealtimeSessionHttpError extends Error {
|
|
19
19
|
status;
|
|
20
20
|
code;
|
|
@@ -99,9 +99,12 @@ export class RealtimeSession {
|
|
|
99
99
|
startupConnectController = null;
|
|
100
100
|
startupAttemptController = null;
|
|
101
101
|
useManagedMicrophone = true;
|
|
102
|
-
|
|
102
|
+
microphoneEnabled = true;
|
|
103
103
|
applicationAudioTrack = null;
|
|
104
|
+
localApplicationAudioTrack = null;
|
|
104
105
|
connectDefaults;
|
|
106
|
+
recoveryReadiness;
|
|
107
|
+
credentialRefreshGeneration = 0;
|
|
105
108
|
fetcher;
|
|
106
109
|
endEndpoint;
|
|
107
110
|
refreshCredentialsCallback;
|
|
@@ -110,6 +113,10 @@ export class RealtimeSession {
|
|
|
110
113
|
lifecycleCredentials;
|
|
111
114
|
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
112
115
|
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
116
|
+
onDiagnosticVisibilityChange = () => {
|
|
117
|
+
if (globalThis.document?.visibilityState === "hidden")
|
|
118
|
+
this.flushDiagnostics("page_hidden");
|
|
119
|
+
};
|
|
113
120
|
constructor(credentials, options = {}) {
|
|
114
121
|
validateSessionCredentials(credentials);
|
|
115
122
|
this.id = credentials.id;
|
|
@@ -119,6 +126,10 @@ export class RealtimeSession {
|
|
|
119
126
|
? (options.connect ?? {})
|
|
120
127
|
: { ...options.connect, apiBase: normalizeApiBase(options.connect.apiBase) };
|
|
121
128
|
this.apiBase = this.connectDefaults.apiBase ?? this.apiBase;
|
|
129
|
+
this.recoveryReadiness = {
|
|
130
|
+
readyTimeoutMs: this.connectDefaults.readyTimeoutMs ?? 15_000,
|
|
131
|
+
waitForRemoteAudio: this.connectDefaults.waitForRemoteAudio ?? true,
|
|
132
|
+
};
|
|
122
133
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
123
134
|
this.endEndpoint = options.endEndpoint ?? null;
|
|
124
135
|
this.refreshCredentialsCallback = options.refreshCredentials ?? null;
|
|
@@ -330,6 +341,9 @@ export class RealtimeSession {
|
|
|
330
341
|
};
|
|
331
342
|
}
|
|
332
343
|
async connect(options = {}) {
|
|
344
|
+
if (this.startupConnectController || this.manualRejoinController) {
|
|
345
|
+
throw new Error("A session connection attempt is already in progress");
|
|
346
|
+
}
|
|
333
347
|
if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
|
|
334
348
|
.includes(this.stateValue)) {
|
|
335
349
|
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
@@ -337,6 +351,17 @@ export class RealtimeSession {
|
|
|
337
351
|
this.cancelRecovery();
|
|
338
352
|
const controller = new AbortController();
|
|
339
353
|
this.startupConnectController = controller;
|
|
354
|
+
this.credentialRefreshGeneration += 1;
|
|
355
|
+
const resolved = { ...this.connectDefaults, ...options };
|
|
356
|
+
// Keep caller preferences separate from temporary startup-attempt budgets.
|
|
357
|
+
this.recoveryReadiness = {
|
|
358
|
+
readyTimeoutMs: resolved.readyTimeoutMs ?? 15_000,
|
|
359
|
+
waitForRemoteAudio: resolved.waitForRemoteAudio ?? true,
|
|
360
|
+
};
|
|
361
|
+
this.microphoneEnabled = resolved.audioTrack
|
|
362
|
+
? resolved.audioTrack.enabled !== false
|
|
363
|
+
: resolved.microphone ?? true;
|
|
364
|
+
this.setState("connecting");
|
|
340
365
|
try {
|
|
341
366
|
if (this.startupRetryOptions) {
|
|
342
367
|
await this.connectWithStartupRetry(options, controller);
|
|
@@ -369,10 +394,13 @@ export class RealtimeSession {
|
|
|
369
394
|
const startupOptions = this.startupRetryOptions;
|
|
370
395
|
if (!startupOptions)
|
|
371
396
|
return;
|
|
397
|
+
const startupStartedAt = performance.now();
|
|
372
398
|
const timeout = globalThis.setTimeout(() => {
|
|
373
399
|
controller.abort(new Error(`Session startup did not complete within ${startupOptions.timeoutMs}ms`));
|
|
374
400
|
}, startupOptions.timeoutMs);
|
|
375
401
|
let lastError = new Error("Session startup failed");
|
|
402
|
+
let startupRegistered = this.hasConnectedValue;
|
|
403
|
+
this.apiBase = normalizeApiBase(options.apiBase ?? this.connectDefaults.apiBase ?? this.apiBase);
|
|
376
404
|
try {
|
|
377
405
|
for (const [index, attempt] of startupOptions.attempts.entries()) {
|
|
378
406
|
const attemptNumber = index + 1;
|
|
@@ -385,6 +413,12 @@ export class RealtimeSession {
|
|
|
385
413
|
this.startupAttemptController = attemptController;
|
|
386
414
|
this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
|
|
387
415
|
try {
|
|
416
|
+
if (!startupRegistered) {
|
|
417
|
+
const remainingMs = startupOptions.timeoutMs - (performance.now() - startupStartedAt);
|
|
418
|
+
await this.registerStartupRecovery(remainingMs, attemptController.signal);
|
|
419
|
+
startupRegistered = true;
|
|
420
|
+
}
|
|
421
|
+
throwIfAborted(attemptController.signal);
|
|
388
422
|
if (index === 0) {
|
|
389
423
|
await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
|
|
390
424
|
}
|
|
@@ -422,6 +456,38 @@ export class RealtimeSession {
|
|
|
422
456
|
globalThis.clearTimeout(timeout);
|
|
423
457
|
}
|
|
424
458
|
}
|
|
459
|
+
async registerStartupRecovery(timeoutMs, signal) {
|
|
460
|
+
const reserveMs = Math.min(5_000, timeoutMs / 10);
|
|
461
|
+
const controller = createLinkedAbortController(signal);
|
|
462
|
+
const requestTimeout = globalThis.setTimeout(() => {
|
|
463
|
+
controller.abort(new Error("Startup registration timed out"));
|
|
464
|
+
}, Math.max(1, reserveMs));
|
|
465
|
+
try {
|
|
466
|
+
// Leave room for registration latency and final readiness before the
|
|
467
|
+
// browser's existing overall deadline. Rejoins never renew this window.
|
|
468
|
+
const send = () => raceWithAbort(this.fetcher(`${this.apiBase}/sessions/${this.id}/startup`, {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json" },
|
|
471
|
+
credentials: this.lifecycleCredentials,
|
|
472
|
+
body: JSON.stringify({ timeout_ms: Math.max(1, Math.floor(Math.min(timeoutMs - reserveMs, 85_000))) }),
|
|
473
|
+
signal: controller.signal,
|
|
474
|
+
}), controller.signal);
|
|
475
|
+
let response = await send();
|
|
476
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
477
|
+
await this.refreshCredentials(controller.signal);
|
|
478
|
+
response = await send();
|
|
479
|
+
}
|
|
480
|
+
if (!response.ok)
|
|
481
|
+
throw await httpError(response, "Could not register session startup");
|
|
482
|
+
const body = await raceWithAbort(response.json(), controller.signal);
|
|
483
|
+
if (typeof body.remaining_ms !== "number" || body.remaining_ms <= 0) {
|
|
484
|
+
throw new Error("Session startup window has expired");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
globalThis.clearTimeout(requestTimeout);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
425
491
|
notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
|
|
426
492
|
const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
|
|
427
493
|
this.notifyListeners(this.startupAttemptListeners, {
|
|
@@ -433,6 +499,7 @@ export class RealtimeSession {
|
|
|
433
499
|
});
|
|
434
500
|
}
|
|
435
501
|
async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
|
|
502
|
+
throwIfAborted(signal);
|
|
436
503
|
const blockedStates = recovering
|
|
437
504
|
? ["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
438
505
|
: ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
|
|
@@ -451,8 +518,6 @@ export class RealtimeSession {
|
|
|
451
518
|
const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
|
|
452
519
|
this.validateAudioOptions(microphone, audioTrack);
|
|
453
520
|
this.useManagedMicrophone = microphone;
|
|
454
|
-
if (!recovering)
|
|
455
|
-
this.managedMicrophoneEnabled = microphone;
|
|
456
521
|
this.applicationAudioTrack = audioTrack;
|
|
457
522
|
if (!recovering) {
|
|
458
523
|
this.sessionEnded = false;
|
|
@@ -476,7 +541,7 @@ export class RealtimeSession {
|
|
|
476
541
|
await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
|
|
477
542
|
}
|
|
478
543
|
else if (microphone) {
|
|
479
|
-
await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.
|
|
544
|
+
await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.microphoneEnabled), signal);
|
|
480
545
|
}
|
|
481
546
|
throwIfAborted(signal);
|
|
482
547
|
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
@@ -489,7 +554,7 @@ export class RealtimeSession {
|
|
|
489
554
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
490
555
|
throw new Error("Connection restoration interrupted");
|
|
491
556
|
}
|
|
492
|
-
|
|
557
|
+
this.resumeContextDelivery(recovering);
|
|
493
558
|
throwIfAborted(signal);
|
|
494
559
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
495
560
|
throw new Error("Connection restoration interrupted");
|
|
@@ -551,10 +616,13 @@ export class RealtimeSession {
|
|
|
551
616
|
});
|
|
552
617
|
}
|
|
553
618
|
async rejoin(apiBase = this.apiBase) {
|
|
619
|
+
if (this.startupConnectController || this.manualRejoinController) {
|
|
620
|
+
throw new Error("A session connection attempt is already in progress");
|
|
621
|
+
}
|
|
554
622
|
if (this.sessionEnded) {
|
|
555
623
|
throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
|
|
556
624
|
}
|
|
557
|
-
if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
625
|
+
if (["connecting", "connected_not_ready", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
558
626
|
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
559
627
|
}
|
|
560
628
|
this.cancelRecovery();
|
|
@@ -589,13 +657,13 @@ export class RealtimeSession {
|
|
|
589
657
|
}
|
|
590
658
|
}
|
|
591
659
|
async performRejoin(apiBase, options = {}) {
|
|
592
|
-
const { signal, recovering = true, connect
|
|
660
|
+
const { signal, recovering = true, connect, markFailureTerminal = true, } = options;
|
|
593
661
|
this.contextGeneration += 1;
|
|
594
662
|
this.replacementContext.pause();
|
|
595
663
|
this.rejectPendingCommands(new Error("Restoring the session"));
|
|
596
664
|
let response = await this.fetchRoomToken(apiBase, signal);
|
|
597
665
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
598
|
-
await
|
|
666
|
+
await this.refreshCredentials(signal);
|
|
599
667
|
throwIfAborted(signal);
|
|
600
668
|
response = await this.fetchRoomToken(apiBase, signal);
|
|
601
669
|
}
|
|
@@ -611,6 +679,7 @@ export class RealtimeSession {
|
|
|
611
679
|
throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
|
|
612
680
|
}
|
|
613
681
|
const payload = (await raceWithAbort(response.json(), signal));
|
|
682
|
+
throwIfAborted(signal);
|
|
614
683
|
this.credentials = payload.connection;
|
|
615
684
|
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
616
685
|
this.disconnectMode = "rejoin";
|
|
@@ -629,16 +698,28 @@ export class RealtimeSession {
|
|
|
629
698
|
duration_ms: Date.now() - this.reconnectStartedAt,
|
|
630
699
|
};
|
|
631
700
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
701
|
+
// Startup retries resolve the original caller options with constructor defaults.
|
|
702
|
+
// A later rejoin restores the audio settings saved by the previous connection.
|
|
703
|
+
const connectionOptions = connect ?? {
|
|
704
|
+
...this.recoveryReadiness,
|
|
635
705
|
microphone: this.useManagedMicrophone,
|
|
636
706
|
...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
|
|
637
|
-
}
|
|
707
|
+
};
|
|
708
|
+
await this.connectWithMode({ ...connectionOptions, apiBase }, recovering, signal, markFailureTerminal);
|
|
638
709
|
}
|
|
639
710
|
async setMicrophoneEnabled(enabled) {
|
|
711
|
+
// Recovery must see the user's latest intent while the media operation waits.
|
|
712
|
+
this.microphoneEnabled = enabled;
|
|
713
|
+
if (this.applicationAudioTrack) {
|
|
714
|
+
// The room may not know this track yet while publication is pending.
|
|
715
|
+
const track = this.localApplicationAudioTrack;
|
|
716
|
+
if (enabled)
|
|
717
|
+
await track?.unmute();
|
|
718
|
+
else
|
|
719
|
+
await track?.mute();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
640
722
|
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
641
|
-
this.managedMicrophoneEnabled = enabled;
|
|
642
723
|
}
|
|
643
724
|
async sendCommand(type, payload, timeoutMs = 10_000) {
|
|
644
725
|
const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
|
|
@@ -665,11 +746,15 @@ export class RealtimeSession {
|
|
|
665
746
|
context_key: update.key,
|
|
666
747
|
payload_bytes: payloadBytes,
|
|
667
748
|
});
|
|
749
|
+
const uploading = "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update);
|
|
750
|
+
const transferSettled = uploading.then(() => { }, () => { });
|
|
668
751
|
let transferTimeout;
|
|
669
752
|
const transferExpired = new Promise((_resolve, reject) => {
|
|
670
753
|
transferTimeout = globalThis.setTimeout(() => {
|
|
671
754
|
transferTimedOut = true;
|
|
672
|
-
const error = new
|
|
755
|
+
const error = new ContextDeliveryError(`Context transfer timed out after ${timeoutMs}ms`, {
|
|
756
|
+
retryAfter: transferSettled,
|
|
757
|
+
});
|
|
673
758
|
this.recordDiagnostic({
|
|
674
759
|
operation: "context_transfer",
|
|
675
760
|
status: "timed_out",
|
|
@@ -680,19 +765,13 @@ export class RealtimeSession {
|
|
|
680
765
|
timeout_ms: timeoutMs,
|
|
681
766
|
});
|
|
682
767
|
this.flushDiagnostics("context_transfer_timeout");
|
|
683
|
-
this.startAutoRejoin(error, {
|
|
684
|
-
trigger: "context_transfer_timeout",
|
|
685
|
-
operation: "context_transfer",
|
|
686
|
-
contextKey: update.key,
|
|
687
|
-
timeoutMs,
|
|
688
|
-
});
|
|
689
768
|
reject(error);
|
|
690
769
|
}, timeoutMs);
|
|
691
770
|
});
|
|
692
771
|
let transfer;
|
|
693
772
|
try {
|
|
694
773
|
transfer = await Promise.race([
|
|
695
|
-
|
|
774
|
+
uploading,
|
|
696
775
|
transferExpired,
|
|
697
776
|
]);
|
|
698
777
|
}
|
|
@@ -708,7 +787,9 @@ export class RealtimeSession {
|
|
|
708
787
|
});
|
|
709
788
|
this.flushDiagnostics("command_failed");
|
|
710
789
|
}
|
|
711
|
-
|
|
790
|
+
if (error instanceof TypeError || error instanceof ContextDeliveryError)
|
|
791
|
+
throw error;
|
|
792
|
+
throw new ContextDeliveryError(toError(error).message, { cause: error });
|
|
712
793
|
}
|
|
713
794
|
finally {
|
|
714
795
|
globalThis.clearTimeout(transferTimeout);
|
|
@@ -732,6 +813,11 @@ export class RealtimeSession {
|
|
|
732
813
|
const message = createClientMessage(this.connectionId, ++this.clientSeq, "context.update", { ...payload });
|
|
733
814
|
return this.sendTrackedCommand(message, timeoutMs, transfer.resend);
|
|
734
815
|
}
|
|
816
|
+
resumeContextDelivery(restore) {
|
|
817
|
+
// Audio readiness owns connection recovery. Context delivery has its own
|
|
818
|
+
// deadlines and retries, and may be interrupted by a later disconnect.
|
|
819
|
+
void this.replacementContext.resume(restore).catch(() => { });
|
|
820
|
+
}
|
|
735
821
|
async uploadTextContext(update) {
|
|
736
822
|
const data = new TextEncoder().encode(update.text);
|
|
737
823
|
if (data.byteLength === 0)
|
|
@@ -817,6 +903,7 @@ export class RealtimeSession {
|
|
|
817
903
|
this.failRecovery(new Error("Session disconnected during recovery"));
|
|
818
904
|
this.cancelRecovery();
|
|
819
905
|
this.contextGeneration += 1;
|
|
906
|
+
this.localApplicationAudioTrack = null;
|
|
820
907
|
this.replacementContext.clear(new Error("Session disconnected"));
|
|
821
908
|
if (this.stateValue === "disconnected")
|
|
822
909
|
return;
|
|
@@ -832,6 +919,7 @@ export class RealtimeSession {
|
|
|
832
919
|
}
|
|
833
920
|
}
|
|
834
921
|
async end(options = {}) {
|
|
922
|
+
this.credentialRefreshGeneration += 1;
|
|
835
923
|
this.abortStartupConnect(new Error("Session ended"));
|
|
836
924
|
this.startupConnectController = null;
|
|
837
925
|
this.failRecovery(new Error("Session ended during recovery"));
|
|
@@ -851,7 +939,7 @@ export class RealtimeSession {
|
|
|
851
939
|
credentials: request.credentials ?? this.lifecycleCredentials,
|
|
852
940
|
});
|
|
853
941
|
if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
|
|
854
|
-
await this.refreshCredentials();
|
|
942
|
+
await this.refreshCredentials(request.signal ?? undefined);
|
|
855
943
|
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
856
944
|
response = await this.fetcher(endpoint, {
|
|
857
945
|
...request,
|
|
@@ -878,6 +966,17 @@ export class RealtimeSession {
|
|
|
878
966
|
if (this.stateValue === state)
|
|
879
967
|
return;
|
|
880
968
|
this.stateValue = state;
|
|
969
|
+
if (state === "connecting" || state === "resuming") {
|
|
970
|
+
globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
971
|
+
}
|
|
972
|
+
if (state === "disconnected" || state === "failed") {
|
|
973
|
+
globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
974
|
+
this.flushDiagnostics("session_finished");
|
|
975
|
+
const error = new Error(`Session ${state}`);
|
|
976
|
+
this.contextGeneration += 1;
|
|
977
|
+
this.replacementContext.clear(error);
|
|
978
|
+
this.rejectPendingCommands(error);
|
|
979
|
+
}
|
|
881
980
|
this.notifyListeners(this.stateListeners, state);
|
|
882
981
|
}
|
|
883
982
|
bindRoomEvents() {
|
|
@@ -1026,13 +1125,13 @@ export class RealtimeSession {
|
|
|
1026
1125
|
this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
|
|
1027
1126
|
try {
|
|
1028
1127
|
await this.sendHello();
|
|
1029
|
-
const deadline = Date.now() +
|
|
1128
|
+
const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
|
|
1030
1129
|
await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
|
|
1031
|
-
if (this.
|
|
1130
|
+
if (this.recoveryReadiness.waitForRemoteAudio)
|
|
1032
1131
|
await this.waitForRemoteAudioTrack(deadline);
|
|
1033
1132
|
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
1034
1133
|
return;
|
|
1035
|
-
|
|
1134
|
+
this.resumeContextDelivery(true);
|
|
1036
1135
|
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
1037
1136
|
return;
|
|
1038
1137
|
this.setState("connected");
|
|
@@ -1311,10 +1410,18 @@ export class RealtimeSession {
|
|
|
1311
1410
|
signal?.removeEventListener("abort", cancel);
|
|
1312
1411
|
}
|
|
1313
1412
|
}
|
|
1314
|
-
async refreshCredentials() {
|
|
1413
|
+
async refreshCredentials(signal) {
|
|
1315
1414
|
if (!this.refreshCredentialsCallback)
|
|
1316
1415
|
return;
|
|
1317
|
-
|
|
1416
|
+
throwIfAborted(signal);
|
|
1417
|
+
const connectionGeneration = this.contextGeneration;
|
|
1418
|
+
const refreshGeneration = ++this.credentialRefreshGeneration;
|
|
1419
|
+
const refreshed = await raceWithAbort(this.refreshCredentialsCallback(), signal);
|
|
1420
|
+
throwIfAborted(signal);
|
|
1421
|
+
if (connectionGeneration !== this.contextGeneration
|
|
1422
|
+
|| refreshGeneration !== this.credentialRefreshGeneration) {
|
|
1423
|
+
throw new Error("Credential refresh was superseded");
|
|
1424
|
+
}
|
|
1318
1425
|
validateSessionCredentials(refreshed);
|
|
1319
1426
|
if (refreshed.id !== this.id) {
|
|
1320
1427
|
throw new Error("Refreshed credentials belong to a different session");
|
|
@@ -1409,15 +1516,26 @@ export class RealtimeSession {
|
|
|
1409
1516
|
}
|
|
1410
1517
|
}
|
|
1411
1518
|
async publishApplicationAudioTrack(audioTrack) {
|
|
1412
|
-
const
|
|
1519
|
+
const clonedTrack = audioTrack.clone();
|
|
1520
|
+
let publishedTrack = null;
|
|
1413
1521
|
try {
|
|
1522
|
+
publishedTrack = new LocalAudioTrack(clonedTrack, undefined, true);
|
|
1523
|
+
this.localApplicationAudioTrack = publishedTrack;
|
|
1524
|
+
if (!this.microphoneEnabled) {
|
|
1525
|
+
// LiveKit can re-enable a raw track when wrapping it. Mute the wrapper
|
|
1526
|
+
// before publication so recovery cannot briefly transmit muted input.
|
|
1527
|
+
await publishedTrack.mute();
|
|
1528
|
+
}
|
|
1414
1529
|
await this.room.localParticipant.publishTrack(publishedTrack, {
|
|
1415
1530
|
source: Track.Source.Microphone,
|
|
1416
1531
|
name: "voxevolv-application-audio",
|
|
1417
1532
|
});
|
|
1418
1533
|
}
|
|
1419
1534
|
catch (error) {
|
|
1420
|
-
publishedTrack
|
|
1535
|
+
if (this.localApplicationAudioTrack === publishedTrack) {
|
|
1536
|
+
this.localApplicationAudioTrack = null;
|
|
1537
|
+
}
|
|
1538
|
+
clonedTrack.stop();
|
|
1421
1539
|
throw error;
|
|
1422
1540
|
}
|
|
1423
1541
|
}
|
|
@@ -1456,10 +1574,13 @@ export class RealtimeSession {
|
|
|
1456
1574
|
const timeout = globalThis.setTimeout(() => {
|
|
1457
1575
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1458
1576
|
if (pending)
|
|
1459
|
-
globalThis.
|
|
1577
|
+
globalThis.clearTimeout(pending.retry);
|
|
1460
1578
|
pending?.markSettled();
|
|
1461
1579
|
this.pendingCommands.delete(message.message_id);
|
|
1462
|
-
const
|
|
1580
|
+
const detail = `Command ${message.type} timed out after ${timeoutMs}ms`;
|
|
1581
|
+
const error = message.type === "context.update"
|
|
1582
|
+
? new ContextDeliveryError(detail, { retryAfter: pending?.transferSettled })
|
|
1583
|
+
: new Error(detail);
|
|
1463
1584
|
this.recordDiagnostic({
|
|
1464
1585
|
operation: "command_send",
|
|
1465
1586
|
status: "timed_out",
|
|
@@ -1478,21 +1599,7 @@ export class RealtimeSession {
|
|
|
1478
1599
|
});
|
|
1479
1600
|
this.flushDiagnostics("command_timeout");
|
|
1480
1601
|
reject(error);
|
|
1481
|
-
if (message.type === "context.update") {
|
|
1482
|
-
this.startAutoRejoin(error, {
|
|
1483
|
-
trigger: "context_command_timeout",
|
|
1484
|
-
operation: "command_send",
|
|
1485
|
-
messageId: message.message_id,
|
|
1486
|
-
contextKey: contextKeyFromMessage(message),
|
|
1487
|
-
timeoutMs,
|
|
1488
|
-
});
|
|
1489
|
-
}
|
|
1490
1602
|
}, timeoutMs);
|
|
1491
|
-
const retry = globalThis.setInterval(() => {
|
|
1492
|
-
void this.publishCommandAttempt(message, true).catch(() => {
|
|
1493
|
-
// A reconnect may temporarily reject writes; retry until timeout.
|
|
1494
|
-
});
|
|
1495
|
-
}, 500);
|
|
1496
1603
|
this.pendingCommands.set(message.message_id, {
|
|
1497
1604
|
commandType: message.type,
|
|
1498
1605
|
message,
|
|
@@ -1504,25 +1611,58 @@ export class RealtimeSession {
|
|
|
1504
1611
|
resolve,
|
|
1505
1612
|
reject,
|
|
1506
1613
|
timeout,
|
|
1507
|
-
retry,
|
|
1508
1614
|
settled,
|
|
1509
1615
|
markSettled,
|
|
1510
|
-
retryTransfer
|
|
1511
|
-
? async () => {
|
|
1512
|
-
await retryTransfer();
|
|
1513
|
-
await this.publishCommandAttempt(message, true);
|
|
1514
|
-
}
|
|
1515
|
-
: undefined,
|
|
1616
|
+
retryTransfer,
|
|
1516
1617
|
transferRetrying: false,
|
|
1618
|
+
transferRetryRequested: false,
|
|
1517
1619
|
});
|
|
1620
|
+
this.scheduleCommandRetry(message);
|
|
1518
1621
|
});
|
|
1519
1622
|
void this.publishCommandAttempt(message, false).catch(() => {
|
|
1520
1623
|
// The reliable data channel may still be warming immediately after a
|
|
1521
|
-
// reconnect. Keep the command pending so the retry
|
|
1624
|
+
// reconnect. Keep the command pending so the retry timer can publish
|
|
1522
1625
|
// it once the transport accepts writes, or reject at the normal timeout.
|
|
1523
1626
|
});
|
|
1524
1627
|
return response;
|
|
1525
1628
|
}
|
|
1629
|
+
scheduleCommandRetry(message) {
|
|
1630
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1631
|
+
if (!pending)
|
|
1632
|
+
return;
|
|
1633
|
+
const delayMs = message.type === "context.update"
|
|
1634
|
+
? contextRetryDelay(pending.retryCount + 1)
|
|
1635
|
+
: 500;
|
|
1636
|
+
pending.retry = globalThis.setTimeout(() => {
|
|
1637
|
+
void this.retryCommand(message).finally(() => this.scheduleCommandRetry(message));
|
|
1638
|
+
}, delayMs);
|
|
1639
|
+
}
|
|
1640
|
+
async retryCommand(message) {
|
|
1641
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1642
|
+
if (!pending)
|
|
1643
|
+
return;
|
|
1644
|
+
if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue)))
|
|
1645
|
+
return;
|
|
1646
|
+
try {
|
|
1647
|
+
if (pending.transferRetryRequested && pending.retryTransfer) {
|
|
1648
|
+
pending.transferRetryRequested = false;
|
|
1649
|
+
pending.transferRetrying = true;
|
|
1650
|
+
const transfer = pending.retryTransfer();
|
|
1651
|
+
pending.transferSettled = transfer.then(() => { }, () => { });
|
|
1652
|
+
if (pending.contextKey) {
|
|
1653
|
+
this.replacementContext.blockUntilTransferSettles(pending.contextKey, pending.transferSettled);
|
|
1654
|
+
}
|
|
1655
|
+
await Promise.race([transfer, pending.settled]);
|
|
1656
|
+
}
|
|
1657
|
+
await this.publishCommandAttempt(message, true);
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
// Failed writes retain their original deadline and retry with backoff.
|
|
1661
|
+
}
|
|
1662
|
+
finally {
|
|
1663
|
+
pending.transferRetrying = false;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1526
1666
|
publishCommandAttempt(message, retrying) {
|
|
1527
1667
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1528
1668
|
if (!pending || pending.publishQueued)
|
|
@@ -1632,8 +1772,8 @@ export class RealtimeSession {
|
|
|
1632
1772
|
}, this.diagnosticReportTimeoutMs);
|
|
1633
1773
|
});
|
|
1634
1774
|
try {
|
|
1635
|
-
const
|
|
1636
|
-
this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
|
|
1775
|
+
const upload = async () => {
|
|
1776
|
+
const response = await this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
|
|
1637
1777
|
method: "POST",
|
|
1638
1778
|
headers: {
|
|
1639
1779
|
Authorization: `Bearer ${this.accessToken}`,
|
|
@@ -1642,11 +1782,22 @@ export class RealtimeSession {
|
|
|
1642
1782
|
credentials: this.lifecycleCredentials,
|
|
1643
1783
|
body: JSON.stringify(report),
|
|
1644
1784
|
signal: controller.signal,
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1785
|
+
keepalive: true,
|
|
1786
|
+
});
|
|
1787
|
+
const body = await response.json().catch(() => null);
|
|
1788
|
+
if (!response.ok) {
|
|
1789
|
+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
1790
|
+
const headerDelay = Number(response.headers.get("Retry-After")) * 1000;
|
|
1791
|
+
const retryAfterMs = typeof body?.retry_after_ms === "number"
|
|
1792
|
+
? body.retry_after_ms : headerDelay;
|
|
1793
|
+
throw new DiagnosticDeliveryError(`Diagnostic report failed (${response.status})`, retryable, Number.isFinite(retryAfterMs) ? Math.max(retryAfterMs, 0) : 0);
|
|
1794
|
+
}
|
|
1795
|
+
if (body?.report_id !== report.report_id) {
|
|
1796
|
+
throw new DiagnosticDeliveryError("Diagnostic report was not acknowledged");
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
// The receipt body is part of the timeout too, not just the response headers.
|
|
1800
|
+
await Promise.race([upload(), timeoutExpired]);
|
|
1650
1801
|
}
|
|
1651
1802
|
finally {
|
|
1652
1803
|
if (timeout !== undefined)
|
|
@@ -1723,8 +1874,8 @@ export class RealtimeSession {
|
|
|
1723
1874
|
this.agentDisconnected = false;
|
|
1724
1875
|
const wasReady = this.agentReady;
|
|
1725
1876
|
this.agentReady = true;
|
|
1726
|
-
// connect/reconnect owns the transition after hello
|
|
1727
|
-
// restoration
|
|
1877
|
+
// connect/reconnect owns the transition after hello and audio are ready.
|
|
1878
|
+
// Context restoration proceeds independently in the background.
|
|
1728
1879
|
this.readyResolve?.();
|
|
1729
1880
|
if (!wasReady)
|
|
1730
1881
|
this.notifyListeners(this.readyListeners, event);
|
|
@@ -1741,7 +1892,7 @@ export class RealtimeSession {
|
|
|
1741
1892
|
if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
|
|
1742
1893
|
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1743
1894
|
globalThis.clearTimeout(pending.timeout);
|
|
1744
|
-
globalThis.
|
|
1895
|
+
globalThis.clearTimeout(pending.retry);
|
|
1745
1896
|
pending.markSettled();
|
|
1746
1897
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1747
1898
|
pending.resolve(event.payload);
|
|
@@ -1750,7 +1901,7 @@ export class RealtimeSession {
|
|
|
1750
1901
|
if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
|
|
1751
1902
|
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1752
1903
|
globalThis.clearTimeout(pending.timeout);
|
|
1753
|
-
globalThis.
|
|
1904
|
+
globalThis.clearTimeout(pending.retry);
|
|
1754
1905
|
pending.markSettled();
|
|
1755
1906
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1756
1907
|
pending.resolve(event.payload);
|
|
@@ -1761,18 +1912,13 @@ export class RealtimeSession {
|
|
|
1761
1912
|
this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
|
|
1762
1913
|
this.flushDiagnostics("command_rejected");
|
|
1763
1914
|
if (event.type === "context.rejected" && event.payload.retryable) {
|
|
1764
|
-
if (pending.retryTransfer && !pending.transferRetrying) {
|
|
1765
|
-
pending.
|
|
1766
|
-
void pending.retryTransfer()
|
|
1767
|
-
.catch(() => {
|
|
1768
|
-
// The command timeout remains authoritative while reconnecting.
|
|
1769
|
-
})
|
|
1770
|
-
.finally(() => { pending.transferRetrying = false; });
|
|
1915
|
+
if (event.payload.code === "stream_unavailable" && pending.retryTransfer && !pending.transferRetrying) {
|
|
1916
|
+
pending.transferRetryRequested = true;
|
|
1771
1917
|
}
|
|
1772
1918
|
return;
|
|
1773
1919
|
}
|
|
1774
1920
|
globalThis.clearTimeout(pending.timeout);
|
|
1775
|
-
globalThis.
|
|
1921
|
+
globalThis.clearTimeout(pending.retry);
|
|
1776
1922
|
pending.markSettled();
|
|
1777
1923
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1778
1924
|
pending.reject(new CommandError(event.payload));
|
|
@@ -1784,9 +1930,11 @@ export class RealtimeSession {
|
|
|
1784
1930
|
continue;
|
|
1785
1931
|
this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
|
|
1786
1932
|
globalThis.clearTimeout(pending.timeout);
|
|
1787
|
-
globalThis.
|
|
1933
|
+
globalThis.clearTimeout(pending.retry);
|
|
1788
1934
|
pending.markSettled();
|
|
1789
|
-
pending.reject(
|
|
1935
|
+
pending.reject(pending.commandType === "context.update"
|
|
1936
|
+
? new ContextDeliveryError(error.message, { cause: error, retryAfter: pending.transferSettled })
|
|
1937
|
+
: error);
|
|
1790
1938
|
this.pendingCommands.delete(messageId);
|
|
1791
1939
|
}
|
|
1792
1940
|
}
|
|
@@ -1821,7 +1969,7 @@ export class RealtimeSession {
|
|
|
1821
1969
|
signal,
|
|
1822
1970
|
}), signal);
|
|
1823
1971
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
1824
|
-
await
|
|
1972
|
+
await this.refreshCredentials(signal);
|
|
1825
1973
|
response = await raceWithAbort(this.fetcher(url, {
|
|
1826
1974
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1827
1975
|
credentials: this.lifecycleCredentials,
|