@voxevolv/client 0.3.6 → 0.3.8
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 +39 -0
- package/README.md +80 -21
- 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 +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -93
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +10 -2
- package/dist/protocol.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
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
|
-
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.8";
|
|
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;
|
|
@@ -88,11 +88,14 @@ export class RealtimeSession {
|
|
|
88
88
|
hasConnectedValue = false;
|
|
89
89
|
sessionEnded = false;
|
|
90
90
|
pendingEndEvent = null;
|
|
91
|
+
agentDisconnected = false;
|
|
92
|
+
terminalSessionError = null;
|
|
91
93
|
endNotificationDelivered = false;
|
|
92
94
|
disconnectMode = null;
|
|
93
95
|
activeRecovery = null;
|
|
94
96
|
autoRejoinPromise = null;
|
|
95
97
|
autoRejoinController = null;
|
|
98
|
+
manualRejoinController = null;
|
|
96
99
|
startupConnectController = null;
|
|
97
100
|
startupAttemptController = null;
|
|
98
101
|
useManagedMicrophone = true;
|
|
@@ -107,6 +110,10 @@ export class RealtimeSession {
|
|
|
107
110
|
lifecycleCredentials;
|
|
108
111
|
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
109
112
|
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
113
|
+
onDiagnosticVisibilityChange = () => {
|
|
114
|
+
if (globalThis.document?.visibilityState === "hidden")
|
|
115
|
+
this.flushDiagnostics("page_hidden");
|
|
116
|
+
};
|
|
110
117
|
constructor(credentials, options = {}) {
|
|
111
118
|
validateSessionCredentials(credentials);
|
|
112
119
|
this.id = credentials.id;
|
|
@@ -331,7 +338,7 @@ export class RealtimeSession {
|
|
|
331
338
|
.includes(this.stateValue)) {
|
|
332
339
|
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
333
340
|
}
|
|
334
|
-
this.
|
|
341
|
+
this.cancelRecovery();
|
|
335
342
|
const controller = new AbortController();
|
|
336
343
|
this.startupConnectController = controller;
|
|
337
344
|
try {
|
|
@@ -344,7 +351,11 @@ export class RealtimeSession {
|
|
|
344
351
|
}
|
|
345
352
|
}
|
|
346
353
|
catch (error) {
|
|
347
|
-
if (this.startupConnectController === controller &&
|
|
354
|
+
if (this.startupConnectController === controller && this.terminalSessionError) {
|
|
355
|
+
this.sessionEnded = true;
|
|
356
|
+
this.setState("failed");
|
|
357
|
+
}
|
|
358
|
+
else if (this.startupConnectController === controller && !this.sessionEnded) {
|
|
348
359
|
this.setState("failed");
|
|
349
360
|
}
|
|
350
361
|
throw error;
|
|
@@ -362,10 +373,13 @@ export class RealtimeSession {
|
|
|
362
373
|
const startupOptions = this.startupRetryOptions;
|
|
363
374
|
if (!startupOptions)
|
|
364
375
|
return;
|
|
376
|
+
const startupStartedAt = performance.now();
|
|
365
377
|
const timeout = globalThis.setTimeout(() => {
|
|
366
378
|
controller.abort(new Error(`Session startup did not complete within ${startupOptions.timeoutMs}ms`));
|
|
367
379
|
}, startupOptions.timeoutMs);
|
|
368
380
|
let lastError = new Error("Session startup failed");
|
|
381
|
+
let startupRegistered = this.hasConnectedValue;
|
|
382
|
+
this.apiBase = normalizeApiBase(options.apiBase ?? this.connectDefaults.apiBase ?? this.apiBase);
|
|
369
383
|
try {
|
|
370
384
|
for (const [index, attempt] of startupOptions.attempts.entries()) {
|
|
371
385
|
const attemptNumber = index + 1;
|
|
@@ -378,6 +392,11 @@ export class RealtimeSession {
|
|
|
378
392
|
this.startupAttemptController = attemptController;
|
|
379
393
|
this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
|
|
380
394
|
try {
|
|
395
|
+
if (!startupRegistered) {
|
|
396
|
+
const remainingMs = startupOptions.timeoutMs - (performance.now() - startupStartedAt);
|
|
397
|
+
await this.registerStartupRecovery(remainingMs, attemptController.signal);
|
|
398
|
+
startupRegistered = true;
|
|
399
|
+
}
|
|
381
400
|
if (index === 0) {
|
|
382
401
|
await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
|
|
383
402
|
}
|
|
@@ -389,6 +408,7 @@ export class RealtimeSession {
|
|
|
389
408
|
markFailureTerminal: false,
|
|
390
409
|
});
|
|
391
410
|
}
|
|
411
|
+
throwIfAborted(controller.signal);
|
|
392
412
|
this.notifyStartupAttempt("succeeded", attemptNumber, attempt.readyTimeoutMs);
|
|
393
413
|
return;
|
|
394
414
|
}
|
|
@@ -414,6 +434,38 @@ export class RealtimeSession {
|
|
|
414
434
|
globalThis.clearTimeout(timeout);
|
|
415
435
|
}
|
|
416
436
|
}
|
|
437
|
+
async registerStartupRecovery(timeoutMs, signal) {
|
|
438
|
+
const reserveMs = Math.min(5_000, timeoutMs / 10);
|
|
439
|
+
const controller = createLinkedAbortController(signal);
|
|
440
|
+
const requestTimeout = globalThis.setTimeout(() => {
|
|
441
|
+
controller.abort(new Error("Startup registration timed out"));
|
|
442
|
+
}, Math.max(1, Math.min(3_000, reserveMs)));
|
|
443
|
+
try {
|
|
444
|
+
// Leave room for registration latency and final readiness before the
|
|
445
|
+
// browser's existing overall deadline. Rejoins never renew this window.
|
|
446
|
+
const send = () => raceWithAbort(this.fetcher(`${this.apiBase}/sessions/${this.id}/startup`, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json" },
|
|
449
|
+
credentials: this.lifecycleCredentials,
|
|
450
|
+
body: JSON.stringify({ timeout_ms: Math.max(1, Math.floor(Math.min(timeoutMs - reserveMs, 85_000))) }),
|
|
451
|
+
signal: controller.signal,
|
|
452
|
+
}), controller.signal);
|
|
453
|
+
let response = await send();
|
|
454
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
455
|
+
await raceWithAbort(this.refreshCredentials(), controller.signal);
|
|
456
|
+
response = await send();
|
|
457
|
+
}
|
|
458
|
+
if (!response.ok)
|
|
459
|
+
throw await httpError(response, "Could not register session startup");
|
|
460
|
+
const body = await raceWithAbort(response.json(), controller.signal);
|
|
461
|
+
if (typeof body.remaining_ms !== "number" || body.remaining_ms <= 0) {
|
|
462
|
+
throw new Error("Session startup window has expired");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
globalThis.clearTimeout(requestTimeout);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
417
469
|
notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
|
|
418
470
|
const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
|
|
419
471
|
this.notifyListeners(this.startupAttemptListeners, {
|
|
@@ -451,6 +503,8 @@ export class RealtimeSession {
|
|
|
451
503
|
this.agentStreamId = null;
|
|
452
504
|
this.reconnectStartedAt = null;
|
|
453
505
|
this.pendingEndEvent = null;
|
|
506
|
+
this.agentDisconnected = false;
|
|
507
|
+
this.terminalSessionError = null;
|
|
454
508
|
this.endNotificationDelivered = false;
|
|
455
509
|
this.setState("connecting");
|
|
456
510
|
}
|
|
@@ -479,7 +533,7 @@ export class RealtimeSession {
|
|
|
479
533
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
480
534
|
throw new Error("Connection restoration interrupted");
|
|
481
535
|
}
|
|
482
|
-
|
|
536
|
+
this.resumeContextDelivery(recovering);
|
|
483
537
|
throwIfAborted(signal);
|
|
484
538
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
485
539
|
throw new Error("Connection restoration interrupted");
|
|
@@ -541,28 +595,42 @@ export class RealtimeSession {
|
|
|
541
595
|
});
|
|
542
596
|
}
|
|
543
597
|
async rejoin(apiBase = this.apiBase) {
|
|
598
|
+
if (this.sessionEnded) {
|
|
599
|
+
throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
|
|
600
|
+
}
|
|
544
601
|
if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
545
602
|
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
546
603
|
}
|
|
547
|
-
this.
|
|
604
|
+
this.cancelRecovery();
|
|
548
605
|
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
549
606
|
const recovering = this.hasConnectedValue;
|
|
607
|
+
const controller = new AbortController();
|
|
608
|
+
this.manualRejoinController = controller;
|
|
550
609
|
if (recovering) {
|
|
551
610
|
this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
|
|
552
611
|
this.setState("resuming");
|
|
553
612
|
}
|
|
554
613
|
try {
|
|
555
|
-
await this.performRejoin(normalizedApiBase, { recovering });
|
|
614
|
+
await this.performRejoin(normalizedApiBase, { recovering, signal: controller.signal });
|
|
615
|
+
if (this.sessionEnded)
|
|
616
|
+
return;
|
|
556
617
|
if (recovering)
|
|
557
618
|
this.completeRecovery();
|
|
558
619
|
}
|
|
559
620
|
catch (error) {
|
|
621
|
+
if (controller.signal.aborted && !this.terminalSessionError)
|
|
622
|
+
return;
|
|
560
623
|
const normalizedError = toError(error);
|
|
561
624
|
if (recovering)
|
|
562
625
|
this.failRecovery(normalizedError);
|
|
563
626
|
this.setState("failed");
|
|
564
627
|
throw error;
|
|
565
628
|
}
|
|
629
|
+
finally {
|
|
630
|
+
if (this.manualRejoinController === controller) {
|
|
631
|
+
this.manualRejoinController = null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
566
634
|
}
|
|
567
635
|
async performRejoin(apiBase, options = {}) {
|
|
568
636
|
const { signal, recovering = true, connect = {}, markFailureTerminal = true, } = options;
|
|
@@ -575,7 +643,15 @@ export class RealtimeSession {
|
|
|
575
643
|
throwIfAborted(signal);
|
|
576
644
|
response = await this.fetchRoomToken(apiBase, signal);
|
|
577
645
|
}
|
|
646
|
+
// A live ending may arrive while token renewal is in flight.
|
|
647
|
+
if (this.sessionEnded) {
|
|
648
|
+
if (this.terminalSessionError)
|
|
649
|
+
throw this.terminalSessionError;
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
578
652
|
if (!response.ok) {
|
|
653
|
+
if (response.status === 409 && await this.recoverSessionEnd(apiBase, signal))
|
|
654
|
+
return;
|
|
579
655
|
throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
|
|
580
656
|
}
|
|
581
657
|
const payload = (await raceWithAbort(response.json(), signal));
|
|
@@ -583,7 +659,7 @@ export class RealtimeSession {
|
|
|
583
659
|
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
584
660
|
this.disconnectMode = "rejoin";
|
|
585
661
|
try {
|
|
586
|
-
await this.room.disconnect();
|
|
662
|
+
await raceWithAbort(this.room.disconnect(), signal);
|
|
587
663
|
}
|
|
588
664
|
finally {
|
|
589
665
|
this.disconnectMode = null;
|
|
@@ -633,11 +709,15 @@ export class RealtimeSession {
|
|
|
633
709
|
context_key: update.key,
|
|
634
710
|
payload_bytes: payloadBytes,
|
|
635
711
|
});
|
|
712
|
+
const uploading = "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update);
|
|
713
|
+
const transferSettled = uploading.then(() => { }, () => { });
|
|
636
714
|
let transferTimeout;
|
|
637
715
|
const transferExpired = new Promise((_resolve, reject) => {
|
|
638
716
|
transferTimeout = globalThis.setTimeout(() => {
|
|
639
717
|
transferTimedOut = true;
|
|
640
|
-
const error = new
|
|
718
|
+
const error = new ContextDeliveryError(`Context transfer timed out after ${timeoutMs}ms`, {
|
|
719
|
+
retryAfter: transferSettled,
|
|
720
|
+
});
|
|
641
721
|
this.recordDiagnostic({
|
|
642
722
|
operation: "context_transfer",
|
|
643
723
|
status: "timed_out",
|
|
@@ -648,19 +728,13 @@ export class RealtimeSession {
|
|
|
648
728
|
timeout_ms: timeoutMs,
|
|
649
729
|
});
|
|
650
730
|
this.flushDiagnostics("context_transfer_timeout");
|
|
651
|
-
this.startAutoRejoin(error, {
|
|
652
|
-
trigger: "context_transfer_timeout",
|
|
653
|
-
operation: "context_transfer",
|
|
654
|
-
contextKey: update.key,
|
|
655
|
-
timeoutMs,
|
|
656
|
-
});
|
|
657
731
|
reject(error);
|
|
658
732
|
}, timeoutMs);
|
|
659
733
|
});
|
|
660
734
|
let transfer;
|
|
661
735
|
try {
|
|
662
736
|
transfer = await Promise.race([
|
|
663
|
-
|
|
737
|
+
uploading,
|
|
664
738
|
transferExpired,
|
|
665
739
|
]);
|
|
666
740
|
}
|
|
@@ -676,7 +750,9 @@ export class RealtimeSession {
|
|
|
676
750
|
});
|
|
677
751
|
this.flushDiagnostics("command_failed");
|
|
678
752
|
}
|
|
679
|
-
|
|
753
|
+
if (error instanceof TypeError || error instanceof ContextDeliveryError)
|
|
754
|
+
throw error;
|
|
755
|
+
throw new ContextDeliveryError(toError(error).message, { cause: error });
|
|
680
756
|
}
|
|
681
757
|
finally {
|
|
682
758
|
globalThis.clearTimeout(transferTimeout);
|
|
@@ -700,6 +776,11 @@ export class RealtimeSession {
|
|
|
700
776
|
const message = createClientMessage(this.connectionId, ++this.clientSeq, "context.update", { ...payload });
|
|
701
777
|
return this.sendTrackedCommand(message, timeoutMs, transfer.resend);
|
|
702
778
|
}
|
|
779
|
+
resumeContextDelivery(restore) {
|
|
780
|
+
// Audio readiness owns connection recovery. Context delivery has its own
|
|
781
|
+
// deadlines and retries, and may be interrupted by a later disconnect.
|
|
782
|
+
void this.replacementContext.resume(restore).catch(() => { });
|
|
783
|
+
}
|
|
703
784
|
async uploadTextContext(update) {
|
|
704
785
|
const data = new TextEncoder().encode(update.text);
|
|
705
786
|
if (data.byteLength === 0)
|
|
@@ -782,7 +863,8 @@ export class RealtimeSession {
|
|
|
782
863
|
async disconnect() {
|
|
783
864
|
this.abortStartupConnect(new Error("Session disconnected"));
|
|
784
865
|
this.startupConnectController = null;
|
|
785
|
-
this.
|
|
866
|
+
this.failRecovery(new Error("Session disconnected during recovery"));
|
|
867
|
+
this.cancelRecovery();
|
|
786
868
|
this.contextGeneration += 1;
|
|
787
869
|
this.replacementContext.clear(new Error("Session disconnected"));
|
|
788
870
|
if (this.stateValue === "disconnected")
|
|
@@ -801,7 +883,8 @@ export class RealtimeSession {
|
|
|
801
883
|
async end(options = {}) {
|
|
802
884
|
this.abortStartupConnect(new Error("Session ended"));
|
|
803
885
|
this.startupConnectController = null;
|
|
804
|
-
this.
|
|
886
|
+
this.failRecovery(new Error("Session ended during recovery"));
|
|
887
|
+
this.cancelRecovery();
|
|
805
888
|
this.sessionEnded = true;
|
|
806
889
|
const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
|
|
807
890
|
const request = options.request ?? {};
|
|
@@ -844,11 +927,29 @@ export class RealtimeSession {
|
|
|
844
927
|
if (this.stateValue === state)
|
|
845
928
|
return;
|
|
846
929
|
this.stateValue = state;
|
|
930
|
+
if (state === "connecting" || state === "resuming") {
|
|
931
|
+
globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
932
|
+
}
|
|
933
|
+
if (state === "disconnected" || state === "failed") {
|
|
934
|
+
globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
935
|
+
this.flushDiagnostics("session_finished");
|
|
936
|
+
const error = new Error(`Session ${state}`);
|
|
937
|
+
this.contextGeneration += 1;
|
|
938
|
+
this.replacementContext.clear(error);
|
|
939
|
+
this.rejectPendingCommands(error);
|
|
940
|
+
}
|
|
847
941
|
this.notifyListeners(this.stateListeners, state);
|
|
848
942
|
}
|
|
849
943
|
bindRoomEvents() {
|
|
850
944
|
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
851
945
|
if (participant.isAgent && !this.disconnectMode) {
|
|
946
|
+
this.agentDisconnected = true;
|
|
947
|
+
if (this.terminalSessionError)
|
|
948
|
+
return;
|
|
949
|
+
if (this.sessionEnded) {
|
|
950
|
+
this.finishPendingEnd();
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
852
953
|
this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
|
|
853
954
|
}
|
|
854
955
|
});
|
|
@@ -861,6 +962,8 @@ export class RealtimeSession {
|
|
|
861
962
|
this.handleLiveKitDisconnected(reason);
|
|
862
963
|
});
|
|
863
964
|
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
|
965
|
+
if (this.sessionEnded || this.terminalSessionError)
|
|
966
|
+
return;
|
|
864
967
|
if (state === LiveKitConnectionState.Connected) {
|
|
865
968
|
const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
|
|
866
969
|
this.setState(this.agentReady
|
|
@@ -989,7 +1092,7 @@ export class RealtimeSession {
|
|
|
989
1092
|
await this.waitForRemoteAudioTrack(deadline);
|
|
990
1093
|
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
991
1094
|
return;
|
|
992
|
-
|
|
1095
|
+
this.resumeContextDelivery(true);
|
|
993
1096
|
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
994
1097
|
return;
|
|
995
1098
|
this.setState("connected");
|
|
@@ -1029,6 +1132,8 @@ export class RealtimeSession {
|
|
|
1029
1132
|
this.setState("disconnected");
|
|
1030
1133
|
return;
|
|
1031
1134
|
}
|
|
1135
|
+
if (this.terminalSessionError)
|
|
1136
|
+
return;
|
|
1032
1137
|
if (this.shouldAutoRejoin(reason)) {
|
|
1033
1138
|
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
|
|
1034
1139
|
return;
|
|
@@ -1048,6 +1153,37 @@ export class RealtimeSession {
|
|
|
1048
1153
|
this.pendingEndEvent = null;
|
|
1049
1154
|
this.notifyEnded({ ...ending, type: "session.ended" });
|
|
1050
1155
|
}
|
|
1156
|
+
finishPendingEnd() {
|
|
1157
|
+
this.deliverPendingEndEvent();
|
|
1158
|
+
// The agent can leave while the browser remains in the room. Close that
|
|
1159
|
+
// transport ourselves, after notifying the application of the true reason.
|
|
1160
|
+
void this.disconnect().catch(() => { });
|
|
1161
|
+
}
|
|
1162
|
+
async recoverSessionEnd(apiBase, signal) {
|
|
1163
|
+
const controller = new AbortController();
|
|
1164
|
+
const cancel = () => controller.abort(signal?.reason);
|
|
1165
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1166
|
+
if (signal?.aborted)
|
|
1167
|
+
cancel();
|
|
1168
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), 5_000);
|
|
1169
|
+
try {
|
|
1170
|
+
// A later live event may have advanced the cursor past a missed ending.
|
|
1171
|
+
// Replay from the start; event IDs suppress already delivered messages.
|
|
1172
|
+
await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
// A 409 also covers failed sessions. Without an authoritative ending,
|
|
1176
|
+
// retain the original HTTP failure rather than invent a normal end.
|
|
1177
|
+
}
|
|
1178
|
+
finally {
|
|
1179
|
+
globalThis.clearTimeout(timeout);
|
|
1180
|
+
signal?.removeEventListener("abort", cancel);
|
|
1181
|
+
}
|
|
1182
|
+
if (!this.sessionEnded || this.terminalSessionError)
|
|
1183
|
+
return false;
|
|
1184
|
+
this.finishPendingEnd();
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1051
1187
|
notifyEnded(event) {
|
|
1052
1188
|
if (this.endNotificationDelivered)
|
|
1053
1189
|
return;
|
|
@@ -1156,6 +1292,8 @@ export class RealtimeSession {
|
|
|
1156
1292
|
throwIfAborted(signal);
|
|
1157
1293
|
this.beginRecovery("rejoin", attempt, details);
|
|
1158
1294
|
await this.performRejoin(this.apiBase, { signal });
|
|
1295
|
+
if (this.sessionEnded)
|
|
1296
|
+
return;
|
|
1159
1297
|
throwIfAborted(signal);
|
|
1160
1298
|
this.reconnectStartedAt = null;
|
|
1161
1299
|
this.completeRecovery();
|
|
@@ -1173,9 +1311,11 @@ export class RealtimeSession {
|
|
|
1173
1311
|
this.replacementContext.clear(lastError);
|
|
1174
1312
|
this.setState("failed");
|
|
1175
1313
|
}
|
|
1176
|
-
|
|
1314
|
+
cancelRecovery() {
|
|
1177
1315
|
this.autoRejoinController?.abort();
|
|
1178
1316
|
this.autoRejoinController = null;
|
|
1317
|
+
this.manualRejoinController?.abort();
|
|
1318
|
+
this.manualRejoinController = null;
|
|
1179
1319
|
this.autoRejoinPromise = null;
|
|
1180
1320
|
this.activeRecovery = null;
|
|
1181
1321
|
}
|
|
@@ -1242,44 +1382,76 @@ export class RealtimeSession {
|
|
|
1242
1382
|
this.accessToken = refreshed.access_token;
|
|
1243
1383
|
this.credentials = refreshed.connection;
|
|
1244
1384
|
}
|
|
1385
|
+
async finishSessionFailure(error) {
|
|
1386
|
+
this.sessionEnded = true;
|
|
1387
|
+
this.contextGeneration += 1;
|
|
1388
|
+
this.rejectPendingCommands(error);
|
|
1389
|
+
this.replacementContext.clear(error);
|
|
1390
|
+
this.failRecovery(error);
|
|
1391
|
+
this.cancelRecovery();
|
|
1392
|
+
this.disconnectMode = "connect_failure";
|
|
1393
|
+
try {
|
|
1394
|
+
await this.room.disconnect();
|
|
1395
|
+
}
|
|
1396
|
+
finally {
|
|
1397
|
+
this.disconnectMode = null;
|
|
1398
|
+
this.setState("failed");
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1245
1401
|
observeConvenienceEvents(event) {
|
|
1246
1402
|
if (event.type === "session.error") {
|
|
1403
|
+
// Teardown errors must not overwrite an already announced normal ending.
|
|
1404
|
+
if (this.sessionEnded)
|
|
1405
|
+
return;
|
|
1247
1406
|
const error = new SessionErrorEventError(event);
|
|
1248
|
-
|
|
1249
|
-
|
|
1407
|
+
// Retryability applies to an active startup attempt. Once connected,
|
|
1408
|
+
// a durable session.error means the server has failed the session.
|
|
1409
|
+
if (error.retryable && this.startupAttemptController) {
|
|
1410
|
+
this.startupAttemptController.abort(error);
|
|
1250
1411
|
}
|
|
1251
1412
|
else {
|
|
1413
|
+
this.terminalSessionError = error;
|
|
1252
1414
|
this.abortStartupConnect(error);
|
|
1415
|
+
// Startup owns its aborted attempt's cleanup. An established session
|
|
1416
|
+
// needs to release media and settle recovery immediately on failure.
|
|
1417
|
+
if (!this.startupConnectController) {
|
|
1418
|
+
void this.finishSessionFailure(error).catch(() => { });
|
|
1419
|
+
}
|
|
1253
1420
|
}
|
|
1254
1421
|
}
|
|
1255
1422
|
if (event.type === "session.ending" || event.type === "session.ended") {
|
|
1423
|
+
// A worker shutdown event can follow a durable session.error. The server
|
|
1424
|
+
// retains FAILED in that case, so it must not become a normal completion.
|
|
1425
|
+
if (this.terminalSessionError)
|
|
1426
|
+
return;
|
|
1256
1427
|
this.abortStartupConnect(new Error("Session ended during startup"));
|
|
1257
1428
|
this.contextGeneration += 1;
|
|
1258
1429
|
this.rejectPendingCommands(new Error("Session ended"));
|
|
1430
|
+
this.failRecovery(new Error("Session ended during recovery"));
|
|
1259
1431
|
}
|
|
1260
1432
|
if (event.type === "session.ending") {
|
|
1261
1433
|
// The server announced an intentional hang-up before tearing down the
|
|
1262
1434
|
// room, so the coming disconnect must not fail recovery or auto-rejoin.
|
|
1263
1435
|
this.sessionEnded = true;
|
|
1264
|
-
this.
|
|
1436
|
+
this.cancelRecovery();
|
|
1265
1437
|
this.replacementContext.clear(new Error("Session ended"));
|
|
1266
|
-
|
|
1438
|
+
// Match the server's first terminal outcome, even if teardown announces
|
|
1439
|
+
// another ending before the agent has left the room.
|
|
1440
|
+
this.pendingEndEvent ??= event;
|
|
1441
|
+
if (!this.hasConnectedValue
|
|
1442
|
+
|| this.agentDisconnected
|
|
1443
|
+
|| this.room.state === LiveKitConnectionState.Disconnected) {
|
|
1444
|
+
this.finishPendingEnd();
|
|
1445
|
+
}
|
|
1267
1446
|
}
|
|
1268
1447
|
if (event.type === "session.ended") {
|
|
1448
|
+
const ending = this.pendingEndEvent;
|
|
1269
1449
|
this.pendingEndEvent = null;
|
|
1270
|
-
const recoveryWasActive = (this.autoRejoinPromise !== null
|
|
1271
|
-
|| this.activeRecovery !== null
|
|
1272
|
-
|| this.stateValue === "reconnecting"
|
|
1273
|
-
|| this.stateValue === "resuming");
|
|
1274
1450
|
this.sessionEnded = true;
|
|
1275
1451
|
this.replacementContext.clear(new Error("Session ended"));
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
this.cancelAutoRejoin();
|
|
1280
|
-
this.notifyEnded(event);
|
|
1281
|
-
if (recoveryWasActive)
|
|
1282
|
-
this.setState("disconnected");
|
|
1452
|
+
this.cancelRecovery();
|
|
1453
|
+
this.notifyEnded(ending ? { ...ending, type: "session.ended" } : event);
|
|
1454
|
+
void this.disconnect().catch(() => { });
|
|
1283
1455
|
}
|
|
1284
1456
|
if (event.type.endsWith(".error"))
|
|
1285
1457
|
this.notifyListeners(this.errorListeners, event);
|
|
@@ -1344,10 +1516,13 @@ export class RealtimeSession {
|
|
|
1344
1516
|
const timeout = globalThis.setTimeout(() => {
|
|
1345
1517
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1346
1518
|
if (pending)
|
|
1347
|
-
globalThis.
|
|
1519
|
+
globalThis.clearTimeout(pending.retry);
|
|
1348
1520
|
pending?.markSettled();
|
|
1349
1521
|
this.pendingCommands.delete(message.message_id);
|
|
1350
|
-
const
|
|
1522
|
+
const detail = `Command ${message.type} timed out after ${timeoutMs}ms`;
|
|
1523
|
+
const error = message.type === "context.update"
|
|
1524
|
+
? new ContextDeliveryError(detail, { retryAfter: pending?.transferSettled })
|
|
1525
|
+
: new Error(detail);
|
|
1351
1526
|
this.recordDiagnostic({
|
|
1352
1527
|
operation: "command_send",
|
|
1353
1528
|
status: "timed_out",
|
|
@@ -1366,21 +1541,7 @@ export class RealtimeSession {
|
|
|
1366
1541
|
});
|
|
1367
1542
|
this.flushDiagnostics("command_timeout");
|
|
1368
1543
|
reject(error);
|
|
1369
|
-
if (message.type === "context.update") {
|
|
1370
|
-
this.startAutoRejoin(error, {
|
|
1371
|
-
trigger: "context_command_timeout",
|
|
1372
|
-
operation: "command_send",
|
|
1373
|
-
messageId: message.message_id,
|
|
1374
|
-
contextKey: contextKeyFromMessage(message),
|
|
1375
|
-
timeoutMs,
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
1544
|
}, timeoutMs);
|
|
1379
|
-
const retry = globalThis.setInterval(() => {
|
|
1380
|
-
void this.publishCommandAttempt(message, true).catch(() => {
|
|
1381
|
-
// A reconnect may temporarily reject writes; retry until timeout.
|
|
1382
|
-
});
|
|
1383
|
-
}, 500);
|
|
1384
1545
|
this.pendingCommands.set(message.message_id, {
|
|
1385
1546
|
commandType: message.type,
|
|
1386
1547
|
message,
|
|
@@ -1392,25 +1553,58 @@ export class RealtimeSession {
|
|
|
1392
1553
|
resolve,
|
|
1393
1554
|
reject,
|
|
1394
1555
|
timeout,
|
|
1395
|
-
retry,
|
|
1396
1556
|
settled,
|
|
1397
1557
|
markSettled,
|
|
1398
|
-
retryTransfer
|
|
1399
|
-
? async () => {
|
|
1400
|
-
await retryTransfer();
|
|
1401
|
-
await this.publishCommandAttempt(message, true);
|
|
1402
|
-
}
|
|
1403
|
-
: undefined,
|
|
1558
|
+
retryTransfer,
|
|
1404
1559
|
transferRetrying: false,
|
|
1560
|
+
transferRetryRequested: false,
|
|
1405
1561
|
});
|
|
1562
|
+
this.scheduleCommandRetry(message);
|
|
1406
1563
|
});
|
|
1407
1564
|
void this.publishCommandAttempt(message, false).catch(() => {
|
|
1408
1565
|
// The reliable data channel may still be warming immediately after a
|
|
1409
|
-
// reconnect. Keep the command pending so the retry
|
|
1566
|
+
// reconnect. Keep the command pending so the retry timer can publish
|
|
1410
1567
|
// it once the transport accepts writes, or reject at the normal timeout.
|
|
1411
1568
|
});
|
|
1412
1569
|
return response;
|
|
1413
1570
|
}
|
|
1571
|
+
scheduleCommandRetry(message) {
|
|
1572
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1573
|
+
if (!pending)
|
|
1574
|
+
return;
|
|
1575
|
+
const delayMs = message.type === "context.update"
|
|
1576
|
+
? contextRetryDelay(pending.retryCount + 1)
|
|
1577
|
+
: 500;
|
|
1578
|
+
pending.retry = globalThis.setTimeout(() => {
|
|
1579
|
+
void this.retryCommand(message).finally(() => this.scheduleCommandRetry(message));
|
|
1580
|
+
}, delayMs);
|
|
1581
|
+
}
|
|
1582
|
+
async retryCommand(message) {
|
|
1583
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1584
|
+
if (!pending)
|
|
1585
|
+
return;
|
|
1586
|
+
if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue)))
|
|
1587
|
+
return;
|
|
1588
|
+
try {
|
|
1589
|
+
if (pending.transferRetryRequested && pending.retryTransfer) {
|
|
1590
|
+
pending.transferRetryRequested = false;
|
|
1591
|
+
pending.transferRetrying = true;
|
|
1592
|
+
const transfer = pending.retryTransfer();
|
|
1593
|
+
pending.transferSettled = transfer.then(() => { }, () => { });
|
|
1594
|
+
if (pending.contextKey) {
|
|
1595
|
+
this.replacementContext.blockUntilTransferSettles(pending.contextKey, pending.transferSettled);
|
|
1596
|
+
}
|
|
1597
|
+
await Promise.race([transfer, pending.settled]);
|
|
1598
|
+
}
|
|
1599
|
+
await this.publishCommandAttempt(message, true);
|
|
1600
|
+
}
|
|
1601
|
+
catch {
|
|
1602
|
+
// Failed writes retain their original deadline and retry with backoff.
|
|
1603
|
+
}
|
|
1604
|
+
finally {
|
|
1605
|
+
pending.transferRetrying = false;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1414
1608
|
publishCommandAttempt(message, retrying) {
|
|
1415
1609
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1416
1610
|
if (!pending || pending.publishQueued)
|
|
@@ -1520,8 +1714,8 @@ export class RealtimeSession {
|
|
|
1520
1714
|
}, this.diagnosticReportTimeoutMs);
|
|
1521
1715
|
});
|
|
1522
1716
|
try {
|
|
1523
|
-
const
|
|
1524
|
-
this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
|
|
1717
|
+
const upload = async () => {
|
|
1718
|
+
const response = await this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
|
|
1525
1719
|
method: "POST",
|
|
1526
1720
|
headers: {
|
|
1527
1721
|
Authorization: `Bearer ${this.accessToken}`,
|
|
@@ -1530,11 +1724,22 @@ export class RealtimeSession {
|
|
|
1530
1724
|
credentials: this.lifecycleCredentials,
|
|
1531
1725
|
body: JSON.stringify(report),
|
|
1532
1726
|
signal: controller.signal,
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1727
|
+
keepalive: true,
|
|
1728
|
+
});
|
|
1729
|
+
const body = await response.json().catch(() => null);
|
|
1730
|
+
if (!response.ok) {
|
|
1731
|
+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
1732
|
+
const headerDelay = Number(response.headers.get("Retry-After")) * 1000;
|
|
1733
|
+
const retryAfterMs = typeof body?.retry_after_ms === "number"
|
|
1734
|
+
? body.retry_after_ms : headerDelay;
|
|
1735
|
+
throw new DiagnosticDeliveryError(`Diagnostic report failed (${response.status})`, retryable, Number.isFinite(retryAfterMs) ? Math.max(retryAfterMs, 0) : 0);
|
|
1736
|
+
}
|
|
1737
|
+
if (body?.report_id !== report.report_id) {
|
|
1738
|
+
throw new DiagnosticDeliveryError("Diagnostic report was not acknowledged");
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
// The receipt body is part of the timeout too, not just the response headers.
|
|
1742
|
+
await Promise.race([upload(), timeoutExpired]);
|
|
1538
1743
|
}
|
|
1539
1744
|
finally {
|
|
1540
1745
|
if (timeout !== undefined)
|
|
@@ -1608,10 +1813,11 @@ export class RealtimeSession {
|
|
|
1608
1813
|
return;
|
|
1609
1814
|
}
|
|
1610
1815
|
this.agentStreamId = event.stream_id;
|
|
1816
|
+
this.agentDisconnected = false;
|
|
1611
1817
|
const wasReady = this.agentReady;
|
|
1612
1818
|
this.agentReady = true;
|
|
1613
|
-
// connect/reconnect owns the transition after hello
|
|
1614
|
-
// restoration
|
|
1819
|
+
// connect/reconnect owns the transition after hello and audio are ready.
|
|
1820
|
+
// Context restoration proceeds independently in the background.
|
|
1615
1821
|
this.readyResolve?.();
|
|
1616
1822
|
if (!wasReady)
|
|
1617
1823
|
this.notifyListeners(this.readyListeners, event);
|
|
@@ -1628,7 +1834,7 @@ export class RealtimeSession {
|
|
|
1628
1834
|
if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
|
|
1629
1835
|
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1630
1836
|
globalThis.clearTimeout(pending.timeout);
|
|
1631
|
-
globalThis.
|
|
1837
|
+
globalThis.clearTimeout(pending.retry);
|
|
1632
1838
|
pending.markSettled();
|
|
1633
1839
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1634
1840
|
pending.resolve(event.payload);
|
|
@@ -1637,7 +1843,7 @@ export class RealtimeSession {
|
|
|
1637
1843
|
if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
|
|
1638
1844
|
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1639
1845
|
globalThis.clearTimeout(pending.timeout);
|
|
1640
|
-
globalThis.
|
|
1846
|
+
globalThis.clearTimeout(pending.retry);
|
|
1641
1847
|
pending.markSettled();
|
|
1642
1848
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1643
1849
|
pending.resolve(event.payload);
|
|
@@ -1648,18 +1854,13 @@ export class RealtimeSession {
|
|
|
1648
1854
|
this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
|
|
1649
1855
|
this.flushDiagnostics("command_rejected");
|
|
1650
1856
|
if (event.type === "context.rejected" && event.payload.retryable) {
|
|
1651
|
-
if (pending.retryTransfer && !pending.transferRetrying) {
|
|
1652
|
-
pending.
|
|
1653
|
-
void pending.retryTransfer()
|
|
1654
|
-
.catch(() => {
|
|
1655
|
-
// The command timeout remains authoritative while reconnecting.
|
|
1656
|
-
})
|
|
1657
|
-
.finally(() => { pending.transferRetrying = false; });
|
|
1857
|
+
if (event.payload.code === "stream_unavailable" && pending.retryTransfer && !pending.transferRetrying) {
|
|
1858
|
+
pending.transferRetryRequested = true;
|
|
1658
1859
|
}
|
|
1659
1860
|
return;
|
|
1660
1861
|
}
|
|
1661
1862
|
globalThis.clearTimeout(pending.timeout);
|
|
1662
|
-
globalThis.
|
|
1863
|
+
globalThis.clearTimeout(pending.retry);
|
|
1663
1864
|
pending.markSettled();
|
|
1664
1865
|
this.pendingCommands.delete(event.payload.message_id);
|
|
1665
1866
|
pending.reject(new CommandError(event.payload));
|
|
@@ -1671,9 +1872,11 @@ export class RealtimeSession {
|
|
|
1671
1872
|
continue;
|
|
1672
1873
|
this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
|
|
1673
1874
|
globalThis.clearTimeout(pending.timeout);
|
|
1674
|
-
globalThis.
|
|
1875
|
+
globalThis.clearTimeout(pending.retry);
|
|
1675
1876
|
pending.markSettled();
|
|
1676
|
-
pending.reject(
|
|
1877
|
+
pending.reject(pending.commandType === "context.update"
|
|
1878
|
+
? new ContextDeliveryError(error.message, { cause: error, retryAfter: pending.transferSettled })
|
|
1879
|
+
: error);
|
|
1677
1880
|
this.pendingCommands.delete(messageId);
|
|
1678
1881
|
}
|
|
1679
1882
|
}
|
|
@@ -1695,23 +1898,31 @@ export class RealtimeSession {
|
|
|
1695
1898
|
if (durationMs > 2_000)
|
|
1696
1899
|
this.flushDiagnostics("slow_operation");
|
|
1697
1900
|
}
|
|
1698
|
-
async fetchReplay(apiBase, afterSeq) {
|
|
1901
|
+
async fetchReplay(apiBase, afterSeq, options = {}) {
|
|
1902
|
+
const { signal, terminalOnly = false } = options;
|
|
1699
1903
|
let nextAfter = afterSeq;
|
|
1700
1904
|
while (true) {
|
|
1701
|
-
|
|
1905
|
+
throwIfAborted(signal);
|
|
1906
|
+
const url = `${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`
|
|
1907
|
+
+ (terminalOnly ? "&terminal_only=true" : "");
|
|
1908
|
+
let response = await raceWithAbort(this.fetcher(url, {
|
|
1702
1909
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1703
1910
|
credentials: this.lifecycleCredentials,
|
|
1704
|
-
|
|
1911
|
+
signal,
|
|
1912
|
+
}), signal);
|
|
1705
1913
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
1706
|
-
await this.refreshCredentials();
|
|
1707
|
-
response = await this.fetcher(
|
|
1914
|
+
await raceWithAbort(this.refreshCredentials(), signal);
|
|
1915
|
+
response = await raceWithAbort(this.fetcher(url, {
|
|
1708
1916
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1709
1917
|
credentials: this.lifecycleCredentials,
|
|
1710
|
-
|
|
1918
|
+
signal,
|
|
1919
|
+
}), signal);
|
|
1711
1920
|
}
|
|
1712
|
-
if (!response.ok)
|
|
1713
|
-
throw await httpError(response, "Could not replay session events");
|
|
1714
|
-
|
|
1921
|
+
if (!response.ok) {
|
|
1922
|
+
throw await raceWithAbort(httpError(response, "Could not replay session events"), signal);
|
|
1923
|
+
}
|
|
1924
|
+
const page = (await raceWithAbort(response.json(), signal));
|
|
1925
|
+
throwIfAborted(signal);
|
|
1715
1926
|
for (const stored of page.items) {
|
|
1716
1927
|
nextAfter = Math.max(nextAfter, stored.server_seq);
|
|
1717
1928
|
const event = {
|