@agent-native/core 0.163.1 → 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.
- package/dist/client/session-replay.js +349 -100
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- 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(
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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" ||
|
|
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 (
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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.
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
17
17
|
id?: undefined;
|
|
18
18
|
provider?: undefined;
|
|
19
19
|
} | {
|
|
20
|
-
error?: undefined;
|
|
21
20
|
configured?: undefined;
|
|
22
21
|
connectPath?: undefined;
|
|
23
22
|
url: string;
|
|
24
23
|
id: string;
|
|
25
24
|
provider: string;
|
|
25
|
+
error?: undefined;
|
|
26
26
|
}>;
|
|
27
27
|
export default _default;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -34,37 +34,37 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
|
|
|
34
34
|
/** POST /_agent-native/secrets/:key — write a secret. */
|
|
35
35
|
export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
36
36
|
error: string;
|
|
37
|
-
ok?: undefined;
|
|
38
37
|
status?: undefined;
|
|
38
|
+
ok?: undefined;
|
|
39
39
|
} | {
|
|
40
|
-
error?: undefined;
|
|
41
40
|
ok: boolean;
|
|
42
41
|
status: string;
|
|
42
|
+
error?: undefined;
|
|
43
43
|
} | {
|
|
44
|
-
ok?: undefined;
|
|
45
44
|
error: string;
|
|
46
45
|
removed?: undefined;
|
|
46
|
+
ok?: undefined;
|
|
47
47
|
} | {
|
|
48
|
-
error?: undefined;
|
|
49
48
|
ok: boolean;
|
|
50
49
|
removed: boolean;
|
|
50
|
+
error?: undefined;
|
|
51
51
|
}>>;
|
|
52
52
|
/**
|
|
53
53
|
* POST /_agent-native/secrets/:key/test — validate an optional candidate value
|
|
54
54
|
* or the current stored value without changing anything.
|
|
55
55
|
*/
|
|
56
56
|
export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
57
|
-
ok?: undefined;
|
|
58
57
|
error: string;
|
|
59
58
|
note?: undefined;
|
|
59
|
+
ok?: undefined;
|
|
60
60
|
} | {
|
|
61
|
-
error?: undefined;
|
|
62
61
|
ok: boolean;
|
|
63
62
|
note?: undefined;
|
|
64
|
-
} | {
|
|
65
63
|
error?: undefined;
|
|
64
|
+
} | {
|
|
66
65
|
ok: boolean;
|
|
67
66
|
note: string;
|
|
67
|
+
error?: undefined;
|
|
68
68
|
} | {
|
|
69
69
|
note?: undefined;
|
|
70
70
|
ok: boolean;
|
|
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
|
|
|
95
95
|
export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
|
|
96
96
|
error: string;
|
|
97
97
|
} | {
|
|
98
|
-
error?: undefined;
|
|
99
98
|
ok: boolean;
|
|
100
99
|
key: string;
|
|
101
|
-
} | {
|
|
102
100
|
error?: undefined;
|
|
101
|
+
} | {
|
|
103
102
|
ok: boolean;
|
|
104
103
|
removed: boolean;
|
|
104
|
+
error?: undefined;
|
|
105
105
|
}>>;
|
|
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
|
|
|
27
27
|
export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
28
28
|
error: any;
|
|
29
29
|
} | {
|
|
30
|
-
error?: undefined;
|
|
31
30
|
ok: boolean;
|
|
32
31
|
key: string;
|
|
33
32
|
baseUrlKey?: string;
|
|
34
33
|
scope: AgentEngineApiKeyScope;
|
|
34
|
+
error?: undefined;
|
|
35
35
|
}>>;
|
|
36
36
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.163.
|
|
3
|
+
"version": "0.163.2",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|