@agent-native/core 0.163.0 → 0.163.2

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.
Files changed (43) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/server/plugins/collab.ts +1 -0
  3. package/corpus/templates/slides/DEVELOPING.md +17 -0
  4. package/corpus/templates/slides/actions/get-deck-access-status.ts +67 -0
  5. package/corpus/templates/slides/actions/request-deck-access.ts +569 -0
  6. package/corpus/templates/slides/agent-native.json +3 -0
  7. package/corpus/templates/slides/app/components/layout/Header.tsx +2 -0
  8. package/corpus/templates/slides/app/hooks/use-deck-access.ts +51 -0
  9. package/corpus/templates/slides/app/i18n/en-US.ts +12 -0
  10. package/corpus/templates/slides/app/lib/deck-editor-loading.ts +7 -0
  11. package/corpus/templates/slides/app/pages/DeckEditor.tsx +172 -32
  12. package/corpus/templates/slides/changelog/2026-08-18-private-deck-links-now-explain-access-and-let-viewers-notify.md +6 -0
  13. package/corpus/templates/slides/server/db/schema.ts +10 -0
  14. package/corpus/templates/slides/server/plugins/db.ts +17 -0
  15. package/dist/client/analytics.d.ts +1 -0
  16. package/dist/client/analytics.js +19 -5
  17. package/dist/client/session-replay.js +349 -100
  18. package/dist/collab/routes.d.ts +1 -1
  19. package/dist/collab/storage.d.ts +2 -0
  20. package/dist/collab/storage.js +7 -0
  21. package/dist/collab/struct-routes.d.ts +1 -1
  22. package/dist/config.js +11 -1
  23. package/dist/deploy/build.d.ts +1 -1
  24. package/dist/deploy/build.js +89 -12
  25. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  26. package/dist/observability/routes.d.ts +1 -1
  27. package/dist/observability/tracking-identity.js +2 -0
  28. package/dist/progress/routes.d.ts +1 -1
  29. package/dist/resources/handlers.d.ts +1 -1
  30. package/dist/secrets/routes.d.ts +9 -9
  31. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  32. package/dist/server/collab-plugin.d.ts +6 -0
  33. package/dist/server/collab-plugin.js +27 -6
  34. package/dist/server/deploy-environment.d.ts +1 -1
  35. package/dist/server/deploy-environment.js +40 -2
  36. package/dist/server/realtime-token.d.ts +1 -1
  37. package/dist/server/sentry-config.js +8 -4
  38. package/dist/server/sentry.js +4 -0
  39. package/dist/server/transcribe-voice.d.ts +1 -1
  40. package/dist/tracking/registry.js +8 -6
  41. package/dist/vite/client.js +5 -0
  42. package/docs/content/deployment.mdx +19 -5
  43. package/package.json +1 -1
@@ -136,6 +136,10 @@ function getState() {
136
136
  flushing: false,
137
137
  pendingFlushReason: null,
138
138
  pendingFlushWaiters: [],
139
+ pendingReplayUpload: null,
140
+ pendingReplayStart: null,
141
+ pendingReplayRecovery: null,
142
+ bfcacheRestored: false,
139
143
  awaitingFullSnapshot: false,
140
144
  stopRecorder: null,
141
145
  restoreUrlMonitor: null,
@@ -157,6 +161,10 @@ function getState() {
157
161
  state.restoreIframeBridge ??= null;
158
162
  state.pendingFlushReason ??= null;
159
163
  state.pendingFlushWaiters ??= [];
164
+ state.pendingReplayUpload ??= null;
165
+ state.pendingReplayStart ??= null;
166
+ state.pendingReplayRecovery ??= null;
167
+ state.bfcacheRestored ??= false;
160
168
  state.awaitingFullSnapshot ??= false;
161
169
  state.startGeneration ??= 0;
162
170
  state.replayLinkBaseUrl ??= null;
@@ -839,6 +847,17 @@ function enqueueReplayEvent(state, event) {
839
847
  if (!state.options)
840
848
  return;
841
849
  const eventType = typeof event.type === "number" ? event.type : null;
850
+ if (state.pendingReplayUpload) {
851
+ // A timed-out request has an uncertain server outcome. Keep at most the
852
+ // newest FullSnapshot as a bounded placeholder until the old request
853
+ // settles; never let an uncertain upload turn into a retry backlog.
854
+ if (eventType !== RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
855
+ return;
856
+ state.queue = [];
857
+ state.queuedBytes = 0;
858
+ state.retryBatches = [];
859
+ state.awaitingFullSnapshot = false;
860
+ }
842
861
  if (state.awaitingFullSnapshot) {
843
862
  if (eventType !== RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
844
863
  return;
@@ -1031,6 +1050,19 @@ class ReplayUploadHttpError extends Error {
1031
1050
  this.status = status;
1032
1051
  }
1033
1052
  }
1053
+ /**
1054
+ * The timeout won before the transport settled, so retrying the batch would
1055
+ * race the original request. The caller fences this request and waits for its
1056
+ * late outcome before allowing another upload for the replay sequence.
1057
+ */
1058
+ class ReplayUploadInFlightTimeoutError extends Error {
1059
+ request;
1060
+ constructor(request) {
1061
+ super("Session replay upload timed out while still in flight");
1062
+ this.name = "ReplayUploadInFlightTimeoutError";
1063
+ this.request = request;
1064
+ }
1065
+ }
1034
1066
  /** 4xx statuses where retrying the exact same batch can never succeed.
1035
1067
  * Keep this deliberately narrow: 401/403/404 can be temporary during auth or
1036
1068
  * deploy transitions, so they get a small retry budget before the rejected
@@ -1052,45 +1084,76 @@ function startReplayUploadTimeout() {
1052
1084
  // settles so completed uploads do not leave timeout work behind.
1053
1085
  const controller = typeof AbortController === "undefined" ? undefined : new AbortController();
1054
1086
  let rejectTimeout;
1087
+ let timedOut = false;
1088
+ const timeoutError = new Error("Session replay upload timed out");
1055
1089
  const promise = new Promise((_, reject) => {
1056
1090
  rejectTimeout = reject;
1057
1091
  });
1058
1092
  const timer = setTimeout(() => {
1093
+ timedOut = true;
1059
1094
  controller?.abort();
1060
- rejectTimeout(new Error("Session replay upload timed out"));
1095
+ rejectTimeout(timeoutError);
1061
1096
  }, REPLAY_UPLOAD_TIMEOUT_MS);
1062
1097
  return {
1063
1098
  signal: controller?.signal,
1064
1099
  promise,
1100
+ didTimeout: () => timedOut,
1065
1101
  done: () => clearTimeout(timer),
1066
1102
  };
1067
1103
  }
1068
1104
  function isTransientReplayUploadClientError(status) {
1069
1105
  return status === 401 || status === 403 || status === 404;
1070
1106
  }
1107
+ function awaitReplayUploadRequest(timeout, createRequest) {
1108
+ let request;
1109
+ try {
1110
+ request = createRequest();
1111
+ }
1112
+ catch (error) {
1113
+ // fetch can throw before awaitReplayUpload gets a chance to enter its
1114
+ // finally block. Clear the timer here so a synchronous request failure
1115
+ // cannot leave an orphaned timeout or rejected timeout promise behind.
1116
+ timeout.done();
1117
+ throw error;
1118
+ }
1119
+ return awaitReplayUpload(request, timeout);
1120
+ }
1121
+ async function awaitReplayUpload(request, timeout) {
1122
+ const checkedRequest = request.then((response) => {
1123
+ if (!response.ok) {
1124
+ throw new ReplayUploadHttpError(response.status);
1125
+ }
1126
+ });
1127
+ try {
1128
+ await Promise.race([checkedRequest, timeout.promise]);
1129
+ }
1130
+ catch (error) {
1131
+ // An abort signal only cancels this client's observation of fetch. A
1132
+ // keepalive request may already have reached the server, so every timeout
1133
+ // is uncertain until the original promise settles. Fence the old replay
1134
+ // identity instead of retrying its sequence.
1135
+ if (timeout.didTimeout()) {
1136
+ throw new ReplayUploadInFlightTimeoutError(checkedRequest);
1137
+ }
1138
+ throw error;
1139
+ }
1140
+ finally {
1141
+ timeout.done();
1142
+ }
1143
+ }
1071
1144
  async function sendReplayUpload(options, body, callbacks = {}) {
1072
1145
  if (isCrossOriginReplayEndpoint(options.endpoint)) {
1073
1146
  const canUseKeepalive = canUseReplayKeepalive(body);
1074
1147
  if (canUseKeepalive)
1075
1148
  callbacks.beforeKeepaliveUpload?.();
1076
1149
  const timeout = startReplayUploadTimeout();
1077
- let response;
1078
- try {
1079
- const request = fetch(options.endpoint, {
1080
- method: "POST",
1081
- body,
1082
- keepalive: canUseKeepalive,
1083
- headers: { "Content-Type": "text/plain;charset=UTF-8" },
1084
- signal: timeout.signal,
1085
- });
1086
- response = await Promise.race([request, timeout.promise]);
1087
- }
1088
- finally {
1089
- timeout.done();
1090
- }
1091
- if (!response.ok) {
1092
- throw new ReplayUploadHttpError(response.status);
1093
- }
1150
+ await awaitReplayUploadRequest(timeout, () => fetch(options.endpoint, {
1151
+ method: "POST",
1152
+ body,
1153
+ keepalive: canUseKeepalive,
1154
+ headers: { "Content-Type": "text/plain;charset=UTF-8" },
1155
+ signal: timeout.signal,
1156
+ }));
1094
1157
  return;
1095
1158
  }
1096
1159
  const upload = await buildReplayUploadBody(body);
@@ -1098,42 +1161,39 @@ async function sendReplayUpload(options, body, callbacks = {}) {
1098
1161
  if (canUseKeepalive)
1099
1162
  callbacks.beforeKeepaliveUpload?.();
1100
1163
  const timeout = startReplayUploadTimeout();
1101
- let response;
1102
- try {
1103
- const request = fetch(options.endpoint, {
1104
- method: "POST",
1105
- body: upload.body,
1106
- keepalive: canUseKeepalive,
1107
- headers: {
1108
- ...upload.headers,
1109
- "X-Agent-Native-Analytics-Key": options.publicKey,
1110
- },
1111
- signal: timeout.signal,
1112
- });
1113
- response = await Promise.race([request, timeout.promise]);
1114
- }
1115
- finally {
1116
- timeout.done();
1117
- }
1118
- if (!response.ok) {
1119
- throw new ReplayUploadHttpError(response.status);
1120
- }
1164
+ await awaitReplayUploadRequest(timeout, () => fetch(options.endpoint, {
1165
+ method: "POST",
1166
+ body: upload.body,
1167
+ keepalive: canUseKeepalive,
1168
+ headers: {
1169
+ ...upload.headers,
1170
+ "X-Agent-Native-Analytics-Key": options.publicKey,
1171
+ },
1172
+ signal: timeout.signal,
1173
+ }));
1121
1174
  }
1122
1175
  function isFinalFlushReason(reason) {
1123
1176
  return [
1124
1177
  "auth-cleared",
1125
1178
  "manual",
1126
1179
  "pagehide",
1180
+ "pagehide-persisted",
1127
1181
  "beforeunload",
1128
1182
  "url-blocked",
1129
1183
  "max-duration",
1130
1184
  ].includes(reason);
1131
1185
  }
1186
+ function isTerminalReplayFlushReason(reason) {
1187
+ return reason === "pagehide" || reason === "beforeunload";
1188
+ }
1132
1189
  function flushReasonPriority(reason) {
1133
1190
  // Unload reasons must retain their keepalive sequence reservation even when
1134
1191
  // another final request (for example, an explicit manual stop) is coalesced.
1135
- if (reason === "pagehide" || reason === "beforeunload")
1192
+ if (reason === "pagehide" ||
1193
+ reason === "pagehide-persisted" ||
1194
+ reason === "beforeunload") {
1136
1195
  return 3;
1196
+ }
1137
1197
  if (isFinalFlushReason(reason))
1138
1198
  return 2;
1139
1199
  if (reason === "visibility-hidden")
@@ -1149,6 +1209,7 @@ function mergePendingFlushReason(current, requested) {
1149
1209
  }
1150
1210
  function shouldReserveSequenceBeforeKeepalive(reason) {
1151
1211
  return (reason === "pagehide" ||
1212
+ reason === "pagehide-persisted" ||
1152
1213
  reason === "beforeunload" ||
1153
1214
  reason === "visibility-hidden");
1154
1215
  }
@@ -1169,6 +1230,8 @@ function flushQueuedReplayIfNeeded(state) {
1169
1230
  const options = state.options;
1170
1231
  if (!options)
1171
1232
  return;
1233
+ if (state.pendingReplayUpload)
1234
+ return;
1172
1235
  if (state.retryBatches.length > 0)
1173
1236
  return;
1174
1237
  if (!shouldFlushQueuedReplay(state))
@@ -1269,6 +1332,105 @@ function quarantinePendingReplayUntilFullSnapshot(state) {
1269
1332
  function restoreReplayEvents(state, events) {
1270
1333
  state.retryBatches.unshift(events);
1271
1334
  }
1335
+ function resumeAfterPendingReplayUpload(state, pending) {
1336
+ if (state.pendingReplayUpload !== pending && !state.pendingReplayRecovery) {
1337
+ return;
1338
+ }
1339
+ void recoverAfterPendingReplayUpload(state, pending);
1340
+ }
1341
+ function recoverAfterPendingReplayUpload(state, pending) {
1342
+ if (state.pendingReplayRecovery)
1343
+ return state.pendingReplayRecovery;
1344
+ if (state.pendingReplayUpload !== pending)
1345
+ return Promise.resolve();
1346
+ let recovery;
1347
+ recovery = recoverAfterPendingReplayUploadInternal(state, pending).finally(() => {
1348
+ if (state.pendingReplayRecovery === recovery) {
1349
+ state.pendingReplayRecovery = null;
1350
+ }
1351
+ for (const resolve of state.pendingFlushWaiters.splice(0))
1352
+ resolve();
1353
+ });
1354
+ state.pendingReplayRecovery = recovery;
1355
+ return recovery;
1356
+ }
1357
+ async function recoverAfterPendingReplayUploadInternal(state, pending) {
1358
+ state.bfcacheRestored = false;
1359
+ try {
1360
+ if (state.replayId !== pending.payload.replayId) {
1361
+ state.pendingReplayUpload = null;
1362
+ state.pendingReplayStart = null;
1363
+ return;
1364
+ }
1365
+ const wasActive = state.active;
1366
+ const suppressRestart = isTerminalReplayFlushReason(pending.reason) ||
1367
+ isTerminalReplayFlushReason(state.pendingFlushReason ?? "");
1368
+ // Never reuse the replay identity after a timeout. The server may have
1369
+ // accepted the old request even when this client observed an abort, so a
1370
+ // later FullSnapshot under the old identity could conflict at the same
1371
+ // sequence. Restarting rrweb also emits a fresh Meta + FullSnapshot pair.
1372
+ state.queue = [];
1373
+ state.queuedBytes = 0;
1374
+ state.retryBatches = [];
1375
+ state.awaitingFullSnapshot = false;
1376
+ removeStoredReplaySession(pending.payload.replayId);
1377
+ let stopCancelledRecovery = false;
1378
+ if (wasActive) {
1379
+ // stopSessionReplay increments startGeneration synchronously. If a
1380
+ // caller explicitly stops during its teardown await, that extra
1381
+ // generation change must cancel the implicit timeout restart rather
1382
+ // than allowing the captured old recorder options to start again.
1383
+ const expectedStopGeneration = state.startGeneration + 1;
1384
+ await stopSessionReplay("upload-timeout");
1385
+ stopCancelledRecovery =
1386
+ state.startGeneration !== expectedStopGeneration &&
1387
+ !state.pendingReplayStart;
1388
+ }
1389
+ // Teardown yields while it flushes the final capture restoration. A caller
1390
+ // can request a different replay configuration during that gap, so select
1391
+ // the pending request only after teardown has finished instead of restarting
1392
+ // with the stale request captured above.
1393
+ const restartRequest = suppressRestart || stopCancelledRecovery
1394
+ ? null
1395
+ : (state.pendingReplayStart ??
1396
+ (wasActive && state.options
1397
+ ? { options: state.options, sessionId: pending.payload.sessionId }
1398
+ : null));
1399
+ // Capture restoration during stop can emit one last event. Keep it bounded
1400
+ // and discard it before the fresh recorder emits its own Meta + FullSnapshot.
1401
+ state.queue = [];
1402
+ state.queuedBytes = 0;
1403
+ state.retryBatches = [];
1404
+ state.awaitingFullSnapshot = false;
1405
+ state.pendingFlushReason = null;
1406
+ state.pendingReplayUpload = null;
1407
+ state.pendingReplayStart = null;
1408
+ if (!restartRequest || state.active)
1409
+ return;
1410
+ await restartSessionReplayWithFreshIdentity(state, restartRequest.options, restartRequest.sessionId);
1411
+ }
1412
+ finally {
1413
+ // The wrapper owns clearing state.pendingReplayRecovery after every
1414
+ // recovery caller observes the same settled promise.
1415
+ }
1416
+ }
1417
+ function fenceTimedOutReplayUpload(state, pending) {
1418
+ void pending.request
1419
+ .then(() => undefined, (error) => {
1420
+ // The uncertain batch is intentionally not requeued. Once the old
1421
+ // promise settles, recovery rotates to a fresh replay identity instead
1422
+ // of guessing whether this error means the server rejected the write.
1423
+ const previousInternal = replayCaptureInternal;
1424
+ replayCaptureInternal = true;
1425
+ try {
1426
+ console.warn("[session-replay] timed-out upload settled without a retry; restarting the replay", error);
1427
+ }
1428
+ finally {
1429
+ replayCaptureInternal = previousInternal;
1430
+ }
1431
+ })
1432
+ .finally(() => resumeAfterPendingReplayUpload(state, pending));
1433
+ }
1272
1434
  function advanceReplaySequence(state, payload) {
1273
1435
  if (state.replayId !== payload.replayId)
1274
1436
  return;
@@ -1287,18 +1449,29 @@ export async function flushSessionReplay(reason = "manual") {
1287
1449
  const state = getState();
1288
1450
  if (!state.options)
1289
1451
  return;
1452
+ if (state.pendingReplayUpload) {
1453
+ // The fence blocks another upload, but it must not turn teardown into an
1454
+ // unbounded wait when the original transport never settles.
1455
+ state.pendingFlushReason = mergePendingFlushReason(state.pendingFlushReason, reason);
1456
+ return;
1457
+ }
1290
1458
  if (state.flushing) {
1291
1459
  state.pendingFlushReason = mergePendingFlushReason(state.pendingFlushReason, reason);
1292
1460
  return new Promise((resolve) => {
1293
1461
  state.pendingFlushWaiters.push(resolve);
1294
1462
  });
1295
1463
  }
1296
- if (!hasPendingReplayBatch(state))
1464
+ if (!hasPendingReplayBatch(state)) {
1465
+ if (reason === "pagehide-persisted")
1466
+ state.bfcacheRestored = false;
1297
1467
  return;
1468
+ }
1298
1469
  const events = state.retryBatches.shift() ?? takeQueuedReplayBatch(state);
1299
1470
  const payload = buildReplayBody(state, reason, events);
1300
1471
  if (!payload || !state.options) {
1301
1472
  restoreReplayEvents(state, events);
1473
+ if (reason === "pagehide-persisted")
1474
+ state.bfcacheRestored = false;
1302
1475
  return;
1303
1476
  }
1304
1477
  state.flushing = true;
@@ -1306,6 +1479,8 @@ export async function flushSessionReplay(reason = "manual") {
1306
1479
  let reservedSequence = false;
1307
1480
  let splitRejectedBatch = false;
1308
1481
  let droppedOversizedBatch = false;
1482
+ let fencedTimedOutUpload = false;
1483
+ let isDefinitiveClientError = false;
1309
1484
  let definitiveClientErrorStatus = null;
1310
1485
  try {
1311
1486
  await sendReplayUpload(state.options, payload.body, {
@@ -1323,63 +1498,90 @@ export async function flushSessionReplay(reason = "manual") {
1323
1498
  uploaded = true;
1324
1499
  }
1325
1500
  catch (error) {
1326
- if (reservedSequence)
1327
- rollbackReplaySequenceReservation(state, payload);
1328
- // A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
1329
- // never succeed by retrying the exact same batch -- requeuing it would
1330
- // just spin forever, blocking every later batch behind it (flushes are
1331
- // FIFO via `retryBatches`). Drop it and move on instead.
1332
- const rejectedStatus = error instanceof ReplayUploadHttpError ? error.status : null;
1333
- const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
1334
- const isUnsplittableOversizedBatch = rejectedStatus === 413 && splitBatch === null;
1335
- const isTransientClientError = rejectedStatus !== null &&
1336
- isTransientReplayUploadClientError(rejectedStatus);
1337
- if (isTransientClientError) {
1338
- state.transientClientErrorFailures += 1;
1339
- }
1340
- else {
1341
- state.transientClientErrorFailures = 0;
1342
- }
1343
- const exhaustedTransientClientRetries = isTransientClientError &&
1344
- state.transientClientErrorFailures >=
1345
- MAX_TRANSIENT_REPLAY_CLIENT_FAILURES;
1346
- const isDefinitiveClientError = error instanceof ReplayUploadHttpError &&
1347
- (isDefinitiveReplayUploadClientError(error.status) ||
1348
- exhaustedTransientClientRetries) &&
1349
- !splitBatch &&
1350
- !isUnsplittableOversizedBatch;
1351
- if (splitBatch) {
1352
- // A server or platform can enforce a stricter decompressed-body limit
1353
- // than the recorder's configured queue cap. Bisect in FIFO order and
1354
- // retry both halves at the same sequence; only successful halves advance
1355
- // it, so no event is duplicated or skipped.
1356
- state.retryBatches.unshift(...splitBatch);
1357
- splitRejectedBatch = true;
1358
- }
1359
- else if (isUnsplittableOversizedBatch) {
1360
- // This one event cannot be made any smaller. Drop only the rejected
1361
- // singleton and keep later retry halves/live batches in FIFO order. If
1362
- // it carried DOM structure, quarantine dependent mutations until a new
1363
- // FullSnapshot can safely re-anchor rrweb's node mirror.
1364
- droppedOversizedBatch = true;
1365
- if (replayBatchNeedsDomReset(events)) {
1366
- quarantinePendingReplayUntilFullSnapshot(state);
1367
- }
1368
- }
1369
- else if (isDefinitiveClientError) {
1370
- // Continuing after a checksum/sequence conflict would reuse the same
1371
- // rejected sequence forever. More importantly, advancing past it would
1372
- // append mutations to a replay whose DOM stream may belong to another
1373
- // tab. End this recorder and clear its persisted identity so the next
1374
- // start creates a clean replay instead of producing corrupt playback.
1501
+ if (error instanceof ReplayUploadInFlightTimeoutError) {
1502
+ const pending = {
1503
+ request: error.request,
1504
+ payload,
1505
+ reason,
1506
+ };
1507
+ state.pendingReplayUpload = pending;
1508
+ // The timed-out batch is deliberately fenced rather than requeued:
1509
+ // without transport cancellation, a late success could commit it after
1510
+ // a retry and create a duplicate sequence. Drop the accumulated live
1511
+ // backlog and wait for a new FullSnapshot to re-anchor safely.
1512
+ state.retryBatches = [];
1375
1513
  state.queue = [];
1376
1514
  state.queuedBytes = 0;
1377
- state.retryBatches = [];
1378
- removeStoredReplaySession(payload.replayId);
1379
- definitiveClientErrorStatus = error.status;
1515
+ state.awaitingFullSnapshot = true;
1516
+ fencedTimedOutUpload = true;
1517
+ fenceTimedOutReplayUpload(state, pending);
1518
+ if (state.bfcacheRestored) {
1519
+ // If pageshow happened before this timeout fired, there was no
1520
+ // pending upload for the pageshow handler to recover. Connect this
1521
+ // post-restore timeout directly to the same fresh-identity path.
1522
+ void recoverAfterPendingReplayUpload(state, pending);
1523
+ }
1380
1524
  }
1381
1525
  else {
1382
- restoreReplayEvents(state, events);
1526
+ if (reservedSequence)
1527
+ rollbackReplaySequenceReservation(state, payload);
1528
+ // A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
1529
+ // never succeed by retrying the exact same batch - requeuing it would
1530
+ // just spin forever, blocking every later batch behind it (flushes are
1531
+ // FIFO via `retryBatches`). Drop it and move on instead.
1532
+ const rejectedStatus = error instanceof ReplayUploadHttpError ? error.status : null;
1533
+ const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
1534
+ const isUnsplittableOversizedBatch = rejectedStatus === 413 && splitBatch === null;
1535
+ const isTransientClientError = rejectedStatus !== null &&
1536
+ isTransientReplayUploadClientError(rejectedStatus);
1537
+ if (isTransientClientError) {
1538
+ state.transientClientErrorFailures += 1;
1539
+ }
1540
+ else {
1541
+ state.transientClientErrorFailures = 0;
1542
+ }
1543
+ const exhaustedTransientClientRetries = isTransientClientError &&
1544
+ state.transientClientErrorFailures >=
1545
+ MAX_TRANSIENT_REPLAY_CLIENT_FAILURES;
1546
+ isDefinitiveClientError =
1547
+ error instanceof ReplayUploadHttpError &&
1548
+ (isDefinitiveReplayUploadClientError(error.status) ||
1549
+ exhaustedTransientClientRetries) &&
1550
+ !splitBatch &&
1551
+ !isUnsplittableOversizedBatch;
1552
+ if (splitBatch) {
1553
+ // A server or platform can enforce a stricter decompressed-body limit
1554
+ // than the recorder's configured queue cap. Bisect in FIFO order and
1555
+ // retry both halves at the same sequence; only successful halves advance
1556
+ // it, so no event is duplicated or skipped.
1557
+ state.retryBatches.unshift(...splitBatch);
1558
+ splitRejectedBatch = true;
1559
+ }
1560
+ else if (isUnsplittableOversizedBatch) {
1561
+ // This one event cannot be made any smaller. Drop only the rejected
1562
+ // singleton and keep later retry halves/live batches in FIFO order. If
1563
+ // it carried DOM structure, quarantine dependent mutations until a new
1564
+ // FullSnapshot can safely re-anchor rrweb's node mirror.
1565
+ droppedOversizedBatch = true;
1566
+ if (replayBatchNeedsDomReset(events)) {
1567
+ quarantinePendingReplayUntilFullSnapshot(state);
1568
+ }
1569
+ }
1570
+ else if (isDefinitiveClientError) {
1571
+ // Continuing after a checksum/sequence conflict would reuse the same
1572
+ // rejected sequence forever. More importantly, advancing past it would
1573
+ // append mutations to a replay whose DOM stream may belong to another
1574
+ // tab. End this recorder and clear its persisted identity so the next
1575
+ // start creates a clean replay instead of producing corrupt playback.
1576
+ state.queue = [];
1577
+ state.queuedBytes = 0;
1578
+ state.retryBatches = [];
1579
+ removeStoredReplaySession(payload.replayId);
1580
+ definitiveClientErrorStatus = rejectedStatus;
1581
+ }
1582
+ else {
1583
+ restoreReplayEvents(state, events);
1584
+ }
1383
1585
  }
1384
1586
  // Guard the recorder's own warning so console capture never records it
1385
1587
  // (a captured warning would enqueue an event and retrigger a flush).
@@ -1395,6 +1597,9 @@ export async function flushSessionReplay(reason = "manual") {
1395
1597
  else if (isDefinitiveClientError) {
1396
1598
  console.warn(`[session-replay] dropping upload (HTTP ${error.status})`, error);
1397
1599
  }
1600
+ else if (fencedTimedOutUpload) {
1601
+ console.warn("[session-replay] upload timed out; waiting for it to settle before restarting");
1602
+ }
1398
1603
  else {
1399
1604
  console.warn("[session-replay] upload failed", error);
1400
1605
  }
@@ -1406,6 +1611,11 @@ export async function flushSessionReplay(reason = "manual") {
1406
1611
  finally {
1407
1612
  state.flushing = false;
1408
1613
  }
1614
+ if (reason === "pagehide-persisted" && !fencedTimedOutUpload) {
1615
+ state.bfcacheRestored = false;
1616
+ }
1617
+ if (fencedTimedOutUpload)
1618
+ return;
1409
1619
  const coalescedReason = state.pendingFlushReason;
1410
1620
  const coalescedWaiters = coalescedReason
1411
1621
  ? state.pendingFlushWaiters.splice(0)
@@ -1457,7 +1667,7 @@ export async function flushSessionReplay(reason = "manual") {
1457
1667
  // configuration/input errors and must not create a restart loop.
1458
1668
  let restartResult = null;
1459
1669
  if (shouldRestartAfterConflict && rejectedOptions) {
1460
- restartResult = await restartSessionReplayAfterConflict(state, rejectedOptions, payload.sessionId);
1670
+ restartResult = await restartSessionReplayWithFreshIdentity(state, rejectedOptions, payload.sessionId);
1461
1671
  }
1462
1672
  // Rare recovery-path telemetry lets Analytics owners quantify conflicts
1463
1673
  // without recording the rejected replay id, URL, or any captured content.
@@ -1479,7 +1689,7 @@ export async function flushSessionReplay(reason = "manual") {
1479
1689
  resolve();
1480
1690
  }
1481
1691
  /**
1482
- * Restart a recorder whose replay identity was rejected without routing the
1692
+ * Restart a recorder under a fresh replay identity without routing the
1483
1693
  * already-normalized options back through the public start API.
1484
1694
  *
1485
1695
  * The original recording already passed whole-session sampling. Re-entering
@@ -1490,7 +1700,7 @@ export async function flushSessionReplay(reason = "manual") {
1490
1700
  * reuse the exact normalized console/network capture caps instead of relying
1491
1701
  * on internal option shapes continuing to round-trip through the public API.
1492
1702
  */
1493
- async function restartSessionReplayAfterConflict(state, options, sessionId) {
1703
+ async function restartSessionReplayWithFreshIdentity(state, options, sessionId) {
1494
1704
  // Share the public-start mutex. A consumer may call start in the await gap
1495
1705
  // after the rejected recorder stops; both paths must converge on one rrweb
1496
1706
  // instance rather than racing two fresh identities.
@@ -1498,14 +1708,14 @@ async function restartSessionReplayAfterConflict(state, options, sessionId) {
1498
1708
  return state.startPromise;
1499
1709
  const startGeneration = ++state.startGeneration;
1500
1710
  let startPromise;
1501
- startPromise = restartSessionReplayAfterConflictInternal(state, options, sessionId, startGeneration).finally(() => {
1711
+ startPromise = restartSessionReplayWithFreshIdentityInternal(state, options, sessionId, startGeneration).finally(() => {
1502
1712
  if (state.startPromise === startPromise)
1503
1713
  state.startPromise = null;
1504
1714
  });
1505
1715
  state.startPromise = startPromise;
1506
1716
  return startPromise;
1507
1717
  }
1508
- async function restartSessionReplayAfterConflictInternal(state, options, sessionId, startGeneration) {
1718
+ async function restartSessionReplayWithFreshIdentityInternal(state, options, sessionId, startGeneration) {
1509
1719
  if (options.shouldStart && !options.shouldStart()) {
1510
1720
  return { started: false, reason: "disabled", sessionId, sampled: true };
1511
1721
  }
@@ -1565,14 +1775,31 @@ function installLifecycleListeners(state) {
1565
1775
  void flushSessionReplay("visibility-hidden");
1566
1776
  }
1567
1777
  };
1568
- const flushOnUnload = () => {
1569
- void flushSessionReplay("pagehide");
1778
+ const flushOnUnload = (event) => {
1779
+ // pagehide also fires when the document enters BFCache. That page is
1780
+ // expected to resume capture on pageshow, so keep the timeout fence's
1781
+ // restart behavior for persisted pagehide while retaining terminal
1782
+ // unload semantics for an actual document teardown.
1783
+ state.bfcacheRestored = false;
1784
+ void flushSessionReplay(event.persisted ? "pagehide-persisted" : "pagehide");
1785
+ };
1786
+ const resumeFromBfcache = (event) => {
1787
+ if (!event.persisted)
1788
+ return;
1789
+ state.bfcacheRestored = true;
1790
+ const pending = state.pendingReplayUpload;
1791
+ if (pending)
1792
+ void recoverAfterPendingReplayUpload(state, pending);
1793
+ else if (!state.flushing)
1794
+ state.bfcacheRestored = false;
1570
1795
  };
1571
1796
  document.addEventListener("visibilitychange", flushOnHidden);
1572
1797
  window.addEventListener("pagehide", flushOnUnload);
1798
+ window.addEventListener("pageshow", resumeFromBfcache);
1573
1799
  state.removeLifecycleListeners = () => {
1574
1800
  document.removeEventListener("visibilitychange", flushOnHidden);
1575
1801
  window.removeEventListener("pagehide", flushOnUnload);
1802
+ window.removeEventListener("pageshow", resumeFromBfcache);
1576
1803
  state.removeLifecycleListeners = null;
1577
1804
  };
1578
1805
  }
@@ -2291,6 +2518,18 @@ export async function startSessionReplay(options = {}) {
2291
2518
  return { started: false, reason: "url-blocked", sessionId, sampled };
2292
2519
  }
2293
2520
  const state = getState();
2521
+ if (state.pendingReplayUpload) {
2522
+ state.pendingReplayStart = { options: normalized, sessionId };
2523
+ // Do not start a new recorder episode while the previous episode's
2524
+ // timed-out upload still owns an uncertain server outcome.
2525
+ return {
2526
+ started: false,
2527
+ reason: "already-active",
2528
+ replayId: state.replayId ?? undefined,
2529
+ sessionId,
2530
+ sampled,
2531
+ };
2532
+ }
2294
2533
  if (state.active && state.replayId) {
2295
2534
  return {
2296
2535
  started: true,
@@ -2392,6 +2631,8 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
2392
2631
  state.queue = [];
2393
2632
  state.queuedBytes = 0;
2394
2633
  state.retryBatches = [];
2634
+ state.pendingReplayUpload = null;
2635
+ state.pendingReplayStart = null;
2395
2636
  state.pendingFlushReason = null;
2396
2637
  for (const resolve of state.pendingFlushWaiters.splice(0))
2397
2638
  resolve();
@@ -2485,6 +2726,14 @@ export async function stopSessionReplay(reason = "manual") {
2485
2726
  // stop during the duplicated-tab claim window was a no-op and rrweb started
2486
2727
  // after the caller believed recording had been disabled.
2487
2728
  state.startGeneration += 1;
2729
+ // An explicit stop cancels a caller-requested restart that was queued behind
2730
+ // an uncertain upload. Recovery uses its private reason to preserve that
2731
+ // request while it tears down the old recorder.
2732
+ if (reason !== "upload-timeout")
2733
+ state.pendingReplayStart = null;
2734
+ if (reason !== "pagehide-persisted") {
2735
+ state.bfcacheRestored = false;
2736
+ }
2488
2737
  if (!state.active)
2489
2738
  return;
2490
2739
  // Restore console/fetch/XHR before tearing down the recorder: the restore
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- error: string;
30
29
  ok?: undefined;
30
+ error: string;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;