@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/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.4";
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
- await this.connectWithMode(options, false);
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 connectWithMode(options, recovering, signal) {
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
- if (blockedStates.includes(this.stateValue)) {
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.room.connect(this.credentials.url, this.credentials.token);
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, undefined, recovering);
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, signal, recovering = true) {
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
- const payload = (await response.json());
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 TypeError("microphone and audioTrack cannot both be enabled");
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 TypeError("audioTrack must be an audio MediaStreamTrack");
1209
+ throw new SessionConfigurationError("audioTrack must be an audio MediaStreamTrack");
1043
1210
  }
1044
1211
  if (audioTrack.readyState === "ended") {
1045
- throw new TypeError("audioTrack has already ended");
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
- try {
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
- async publishCommandAttempt(message, retrying) {
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
- await this.publishCommand(message);
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 TypeError("Session credentials must be an object");
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 TypeError("Session credentials are incomplete");
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 TypeError("Session connection credentials are incomplete");
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 TypeError("apiBase must be a non-root relative path or an absolute URL");
1991
+ throw new SessionConfigurationError("apiBase must be a non-root relative path or an absolute URL");
1690
1992
  }
1691
1993
  return normalized;
1692
1994
  }