@agent-native/core 0.98.3 → 0.98.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.
Files changed (230) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +29 -0
  3. package/corpus/core/docs/content/durable-background-runs.mdx +37 -0
  4. package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +2 -0
  5. package/corpus/core/docs/content/locales/de-DE/tracking.mdx +2 -0
  6. package/corpus/core/docs/content/locales/es-ES/tracking.mdx +2 -0
  7. package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +4 -2
  8. package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +2 -0
  9. package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +2 -0
  10. package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +2 -0
  11. package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +2 -0
  12. package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +2 -0
  13. package/corpus/core/docs/content/locales/zh-TW/durable-background-runs.mdx +14 -0
  14. package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +2 -0
  15. package/corpus/core/docs/content/tracking.mdx +2 -0
  16. package/corpus/core/package.json +1 -1
  17. package/corpus/core/src/agent/durable-background.ts +16 -16
  18. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +25 -8
  19. package/corpus/core/src/agent/engine/anthropic-engine.ts +17 -3
  20. package/corpus/core/src/agent/engine/output-tokens.ts +6 -6
  21. package/corpus/core/src/agent/production-agent.ts +200 -27
  22. package/corpus/core/src/agent/run-manager.ts +81 -1
  23. package/corpus/core/src/agent/run-store.ts +301 -41
  24. package/corpus/core/src/client/AgentPanel.tsx +6 -2
  25. package/corpus/core/src/client/AssistantChat.tsx +364 -69
  26. package/corpus/core/src/client/agent-chat-adapter.ts +169 -1
  27. package/corpus/core/src/client/analytics.ts +12 -0
  28. package/corpus/core/src/client/chat/message-components.tsx +3 -0
  29. package/corpus/core/src/client/chat/tool-call-display.tsx +20 -36
  30. package/corpus/core/src/client/conversation/use-near-bottom-autoscroll.ts +7 -0
  31. package/corpus/core/src/client/extensions/AgentNativeExtensionFrame.tsx +2 -0
  32. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +2 -0
  33. package/corpus/core/src/client/extensions/ExtensionEditor.tsx +9 -2
  34. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +2 -0
  35. package/corpus/core/src/client/extensions/InlineExtensionFrame.tsx +2 -0
  36. package/corpus/core/src/client/extensions/portable-extension.ts +2 -0
  37. package/corpus/core/src/client/session-replay.ts +971 -53
  38. package/corpus/core/src/client/settings/DemoModeSection.tsx +3 -3
  39. package/corpus/core/src/client/sse-event-processor.ts +43 -1
  40. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  41. package/corpus/core/src/demo/config.ts +6 -6
  42. package/corpus/core/src/demo/fetch-interceptor.ts +34 -2
  43. package/corpus/core/src/demo/redact.ts +71 -158
  44. package/corpus/core/src/extensions/html-shell.ts +3 -0
  45. package/corpus/core/src/extensions/session-replay-iframe.ts +131 -0
  46. package/corpus/core/src/scripts/agent-engines/list-agent-engines.ts +1 -20
  47. package/corpus/core/src/session-replay-iframe-protocol.ts +58 -0
  48. package/corpus/core/src/shared/reasoning-effort.ts +35 -0
  49. package/corpus/core/src/styles/agent-conversation.css +26 -0
  50. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
  51. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +67 -16
  52. package/corpus/templates/analytics/AGENTS.md +12 -0
  53. package/corpus/templates/analytics/actions/get-error-issue.ts +1 -0
  54. package/corpus/templates/analytics/actions/get-session-replay-events.ts +1 -0
  55. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +1 -0
  56. package/corpus/templates/analytics/actions/list-error-issues.ts +1 -0
  57. package/corpus/templates/analytics/actions/list-session-recordings.ts +1 -0
  58. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -1
  59. package/corpus/templates/analytics/app/global.css +6 -64
  60. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -4
  61. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +143 -121
  62. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +34 -12
  63. package/corpus/templates/analytics/changelog/2026-07-12-analytics-chat-retries-before-showing-a-generic-no-data-mess.md +6 -0
  64. package/corpus/templates/analytics/changelog/2026-07-12-analytics-sessions-search-no-longer-loses-fast-keystrokes-an.md +6 -0
  65. package/corpus/templates/analytics/changelog/2026-07-12-chart-selection-respects-chat-state.md +6 -0
  66. package/corpus/templates/analytics/changelog/2026-07-12-demo-mode-anonymizes-error-reporting-emails.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-07-12-fixed-session-replays-that-appeared-ultra-wide-hid-recorded-.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-load-faster-in-demo-mode-while-visitor-email.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-show-iframe-content.md +6 -0
  70. package/corpus/templates/analytics/netlify.toml +0 -3
  71. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +14 -0
  72. package/corpus/templates/analytics/server/lib/error-capture.ts +59 -2
  73. package/corpus/templates/analytics/server/lib/real-data-actions.ts +24 -0
  74. package/corpus/templates/analytics/server/lib/session-replay.ts +2 -9
  75. package/corpus/templates/analytics/server/plugins/agent-chat.ts +39 -6
  76. package/corpus/templates/assets/app/global.css +1 -1
  77. package/corpus/templates/brain/app/global.css +2 -2
  78. package/corpus/templates/brain/changelog/2026-07-12-fix-long-response-progress.md +6 -0
  79. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +11 -2
  80. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +14 -2
  81. package/corpus/templates/calendar/changelog/2026-07-12-calendar-responses-can-now-be-saved-with-command-enter-or-ct.md +6 -0
  82. package/corpus/templates/forms/app/components/CloudUpgrade.tsx +1 -1
  83. package/corpus/templates/forms/app/components/ThemeToggle.tsx +22 -6
  84. package/corpus/templates/forms/app/components/builder/FieldPropertiesPanel.tsx +5 -5
  85. package/corpus/templates/forms/app/components/builder/FieldRenderer.tsx +3 -3
  86. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +16 -16
  87. package/corpus/templates/forms/app/global.css +110 -5
  88. package/corpus/templates/forms/app/pages/AskPage.tsx +3 -3
  89. package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +134 -52
  90. package/corpus/templates/forms/app/pages/FormFillPage.tsx +44 -11
  91. package/corpus/templates/forms/app/pages/FormsListPage.tsx +42 -24
  92. package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +28 -15
  93. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +38 -15
  94. package/corpus/templates/forms/app/routes/_app.settings.tsx +1 -1
  95. package/corpus/templates/forms/changelog/2026-07-12-form-builder-surfaces-are-calmer-fields-stay-stable-on-hover.md +6 -0
  96. package/corpus/templates/forms/changelog/2026-07-12-smoother-more-tactile-controls-throughout-animated-icon-and-.md +6 -0
  97. package/corpus/templates/mail/app/components/email/EmailThread.tsx +21 -13
  98. package/corpus/templates/mail/app/components/email/email-iframe-document.ts +18 -0
  99. package/dist/agent/durable-background.d.ts +10 -10
  100. package/dist/agent/durable-background.d.ts.map +1 -1
  101. package/dist/agent/durable-background.js +16 -16
  102. package/dist/agent/durable-background.js.map +1 -1
  103. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  104. package/dist/agent/engine/ai-sdk-engine.js +19 -8
  105. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  106. package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
  107. package/dist/agent/engine/anthropic-engine.js +11 -3
  108. package/dist/agent/engine/anthropic-engine.js.map +1 -1
  109. package/dist/agent/engine/output-tokens.js +6 -6
  110. package/dist/agent/engine/output-tokens.js.map +1 -1
  111. package/dist/agent/production-agent.d.ts +52 -0
  112. package/dist/agent/production-agent.d.ts.map +1 -1
  113. package/dist/agent/production-agent.js +154 -26
  114. package/dist/agent/production-agent.js.map +1 -1
  115. package/dist/agent/run-manager.d.ts +9 -0
  116. package/dist/agent/run-manager.d.ts.map +1 -1
  117. package/dist/agent/run-manager.js +71 -1
  118. package/dist/agent/run-manager.js.map +1 -1
  119. package/dist/agent/run-store.d.ts +9 -0
  120. package/dist/agent/run-store.d.ts.map +1 -1
  121. package/dist/agent/run-store.js +243 -48
  122. package/dist/agent/run-store.js.map +1 -1
  123. package/dist/client/AgentPanel.d.ts.map +1 -1
  124. package/dist/client/AgentPanel.js +6 -2
  125. package/dist/client/AgentPanel.js.map +1 -1
  126. package/dist/client/AssistantChat.d.ts +21 -0
  127. package/dist/client/AssistantChat.d.ts.map +1 -1
  128. package/dist/client/AssistantChat.js +298 -67
  129. package/dist/client/AssistantChat.js.map +1 -1
  130. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  131. package/dist/client/agent-chat-adapter.js +146 -1
  132. package/dist/client/agent-chat-adapter.js.map +1 -1
  133. package/dist/client/analytics.d.ts.map +1 -1
  134. package/dist/client/analytics.js +11 -0
  135. package/dist/client/analytics.js.map +1 -1
  136. package/dist/client/chat/message-components.d.ts.map +1 -1
  137. package/dist/client/chat/message-components.js +2 -1
  138. package/dist/client/chat/message-components.js.map +1 -1
  139. package/dist/client/chat/tool-call-display.d.ts +3 -1
  140. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  141. package/dist/client/chat/tool-call-display.js +13 -37
  142. package/dist/client/chat/tool-call-display.js.map +1 -1
  143. package/dist/client/conversation/use-near-bottom-autoscroll.d.ts +1 -0
  144. package/dist/client/conversation/use-near-bottom-autoscroll.d.ts.map +1 -1
  145. package/dist/client/conversation/use-near-bottom-autoscroll.js +6 -0
  146. package/dist/client/conversation/use-near-bottom-autoscroll.js.map +1 -1
  147. package/dist/client/extensions/AgentNativeExtensionFrame.d.ts.map +1 -1
  148. package/dist/client/extensions/AgentNativeExtensionFrame.js +2 -1
  149. package/dist/client/extensions/AgentNativeExtensionFrame.js.map +1 -1
  150. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  151. package/dist/client/extensions/EmbeddedExtension.js +2 -1
  152. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  153. package/dist/client/extensions/ExtensionEditor.d.ts.map +1 -1
  154. package/dist/client/extensions/ExtensionEditor.js +5 -2
  155. package/dist/client/extensions/ExtensionEditor.js.map +1 -1
  156. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  157. package/dist/client/extensions/ExtensionViewer.js +2 -1
  158. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  159. package/dist/client/extensions/InlineExtensionFrame.d.ts.map +1 -1
  160. package/dist/client/extensions/InlineExtensionFrame.js +2 -1
  161. package/dist/client/extensions/InlineExtensionFrame.js.map +1 -1
  162. package/dist/client/extensions/portable-extension.d.ts.map +1 -1
  163. package/dist/client/extensions/portable-extension.js +2 -0
  164. package/dist/client/extensions/portable-extension.js.map +1 -1
  165. package/dist/client/session-replay.d.ts +12 -3
  166. package/dist/client/session-replay.d.ts.map +1 -1
  167. package/dist/client/session-replay.js +793 -49
  168. package/dist/client/session-replay.js.map +1 -1
  169. package/dist/client/settings/DemoModeSection.js +1 -1
  170. package/dist/client/settings/DemoModeSection.js.map +1 -1
  171. package/dist/client/sse-event-processor.d.ts +5 -0
  172. package/dist/client/sse-event-processor.d.ts.map +1 -1
  173. package/dist/client/sse-event-processor.js +37 -1
  174. package/dist/client/sse-event-processor.js.map +1 -1
  175. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  176. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  177. package/dist/demo/config.js +6 -6
  178. package/dist/demo/config.js.map +1 -1
  179. package/dist/demo/fetch-interceptor.d.ts +1 -0
  180. package/dist/demo/fetch-interceptor.d.ts.map +1 -1
  181. package/dist/demo/fetch-interceptor.js +28 -2
  182. package/dist/demo/fetch-interceptor.js.map +1 -1
  183. package/dist/demo/redact.d.ts +14 -7
  184. package/dist/demo/redact.d.ts.map +1 -1
  185. package/dist/demo/redact.js +27 -155
  186. package/dist/demo/redact.js.map +1 -1
  187. package/dist/extensions/html-shell.d.ts.map +1 -1
  188. package/dist/extensions/html-shell.js +2 -0
  189. package/dist/extensions/html-shell.js.map +1 -1
  190. package/dist/extensions/session-replay-iframe.d.ts +10 -0
  191. package/dist/extensions/session-replay-iframe.d.ts.map +1 -0
  192. package/dist/extensions/session-replay-iframe.js +121 -0
  193. package/dist/extensions/session-replay-iframe.js.map +1 -0
  194. package/dist/notifications/routes.d.ts +1 -1
  195. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  196. package/dist/scripts/agent-engines/list-agent-engines.d.ts.map +1 -1
  197. package/dist/scripts/agent-engines/list-agent-engines.js +1 -19
  198. package/dist/scripts/agent-engines/list-agent-engines.js.map +1 -1
  199. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  200. package/dist/server/transcribe-voice.d.ts +1 -1
  201. package/dist/session-replay-iframe-protocol.d.ts +36 -0
  202. package/dist/session-replay-iframe-protocol.d.ts.map +1 -0
  203. package/dist/session-replay-iframe-protocol.js +24 -0
  204. package/dist/session-replay-iframe-protocol.js.map +1 -0
  205. package/dist/shared/reasoning-effort.d.ts +11 -0
  206. package/dist/shared/reasoning-effort.d.ts.map +1 -1
  207. package/dist/shared/reasoning-effort.js +36 -0
  208. package/dist/shared/reasoning-effort.js.map +1 -1
  209. package/dist/styles/agent-conversation.css +26 -0
  210. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
  211. package/docs/content/durable-background-runs.mdx +37 -0
  212. package/docs/content/locales/ar-SA/tracking.mdx +2 -0
  213. package/docs/content/locales/de-DE/tracking.mdx +2 -0
  214. package/docs/content/locales/es-ES/tracking.mdx +2 -0
  215. package/docs/content/locales/fr-FR/tracking.mdx +4 -2
  216. package/docs/content/locales/hi-IN/tracking.mdx +2 -0
  217. package/docs/content/locales/ja-JP/tracking.mdx +2 -0
  218. package/docs/content/locales/ko-KR/tracking.mdx +2 -0
  219. package/docs/content/locales/pt-BR/tracking.mdx +2 -0
  220. package/docs/content/locales/zh-CN/tracking.mdx +2 -0
  221. package/docs/content/locales/zh-TW/durable-background-runs.mdx +14 -0
  222. package/docs/content/locales/zh-TW/tracking.mdx +2 -0
  223. package/docs/content/tracking.mdx +2 -0
  224. package/package.json +2 -2
  225. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
  226. package/corpus/core/src/observability/hosted-model-experiment.ts +0 -118
  227. package/dist/observability/hosted-model-experiment.d.ts +0 -39
  228. package/dist/observability/hosted-model-experiment.d.ts.map +0 -1
  229. package/dist/observability/hosted-model-experiment.js +0 -90
  230. package/dist/observability/hosted-model-experiment.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { SESSION_REPLAY_IFRAME_ATTRIBUTE, SESSION_REPLAY_IFRAME_PROBE, SESSION_REPLAY_IFRAME_START, SESSION_REPLAY_IFRAME_STOP, } from "../session-replay-iframe-protocol.js";
1
2
  import { getOrCreateAnalyticsAnonymousId, getOrCreateAnalyticsSessionId, } from "./analytics-session.js";
2
3
  import { scrubUrl } from "./url-scrub.js";
3
4
  const DEFAULT_REPLAY_PATH = "/api/analytics/replay";
@@ -16,7 +17,9 @@ const DEFAULT_EVENT_SAMPLING = {
16
17
  };
17
18
  const SESSION_REPLAY_STATE_KEY = Symbol.for("agent-native.client.sessionReplay");
18
19
  const SESSION_REPLAY_ID_STORAGE_KEY = "agent-native.session_replay_id";
20
+ const SESSION_REPLAY_IFRAME_BLOCK_SELECTOR = `iframe[${SESSION_REPLAY_IFRAME_ATTRIBUTE}]`;
19
21
  const DEFAULT_BLOCK_SELECTOR = [
22
+ SESSION_REPLAY_IFRAME_BLOCK_SELECTOR,
20
23
  "[data-sensitive]",
21
24
  "[data-an-block]",
22
25
  "[data-an-private]",
@@ -36,12 +39,34 @@ const DEFAULT_BLOCK_SELECTOR = [
36
39
  const DEFAULT_IGNORE_SELECTOR = ".an-ignore, [data-an-ignore]";
37
40
  const DEFAULT_MASK_TEXT_CLASS = "an-mask";
38
41
  const DEFAULT_MASK_TEXT_SELECTOR = "[data-an-mask]";
42
+ const DEFAULT_MASK_INPUT_OPTIONS = {
43
+ color: true,
44
+ date: true,
45
+ "datetime-local": true,
46
+ email: true,
47
+ month: true,
48
+ number: true,
49
+ password: true,
50
+ range: true,
51
+ search: true,
52
+ tel: true,
53
+ text: true,
54
+ time: true,
55
+ url: true,
56
+ week: true,
57
+ };
39
58
  const DEFAULT_FLUSH_INTERVAL_MS = 5000;
40
59
  const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000;
41
60
  const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
42
61
  const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
43
62
  const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
63
+ const REPLAY_TEXT_ENCODER = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
44
64
  const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
65
+ /** Cross-tab channel name used by the duplicated-tab claim guard. */
66
+ const SESSION_REPLAY_BROADCAST_CHANNEL_NAME = "agent-native-session-replay";
67
+ /** How long a resuming tab waits for a "someone else already owns this
68
+ * replayId" reply before proceeding to record with the resumed id. */
69
+ const SESSION_REPLAY_CLAIM_TIMEOUT_MS = 150;
45
70
  /** rrweb custom-event tag for captured console/window-error entries. */
46
71
  export const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
47
72
  /** rrweb custom-event tag for captured fetch/XHR request summaries. */
@@ -104,33 +129,78 @@ function getState() {
104
129
  flushTimer: null,
105
130
  maxDurationTimer: null,
106
131
  flushing: false,
132
+ pendingFlushReason: null,
133
+ pendingFlushWaiters: [],
134
+ awaitingFullSnapshot: false,
107
135
  stopRecorder: null,
108
136
  restoreUrlMonitor: null,
109
137
  removeLifecycleListeners: null,
138
+ restoreIframeBridge: null,
110
139
  addCustomEvent: null,
111
140
  restoreCaptures: null,
112
141
  options: null,
113
142
  lastAuthenticatedProperties: null,
143
+ resourceNodes: new Map(),
144
+ automaticConflictRestartAttempted: false,
145
+ broadcastChannel: null,
114
146
  };
115
147
  }
116
- return g[SESSION_REPLAY_STATE_KEY];
117
- }
118
- function safeStorageGet(key) {
148
+ const state = g[SESSION_REPLAY_STATE_KEY];
149
+ // Keep Vite HMR safe when an older recorder state survives a module reload.
150
+ state.resourceNodes ??= new Map();
151
+ state.restoreIframeBridge ??= null;
152
+ state.pendingFlushReason ??= null;
153
+ state.pendingFlushWaiters ??= [];
154
+ state.awaitingFullSnapshot ??= false;
155
+ return state;
156
+ }
157
+ // The replay session record (replayId + sequence counter) lives in
158
+ // `sessionStorage`, not `localStorage`: `localStorage` is shared by every
159
+ // open tab of the origin, which would hand every tab the same `replayId` and
160
+ // the same sequence counter. See the guard comment above
161
+ // `getOrCreateReplaySession` for the corruption that causes.
162
+ function safeSessionStorageGet(key) {
119
163
  try {
120
- return window.localStorage.getItem(key);
164
+ return window.sessionStorage.getItem(key);
121
165
  }
122
166
  catch {
123
167
  return null;
124
168
  }
125
169
  }
126
- function safeStorageSet(key, value) {
170
+ function safeSessionStorageSet(key, value) {
171
+ try {
172
+ window.sessionStorage.setItem(key, value);
173
+ }
174
+ catch {
175
+ // private browsing / storage disabled -- replay still works for this page
176
+ }
177
+ }
178
+ function safeSessionStorageRemove(key) {
127
179
  try {
128
- window.localStorage.setItem(key, value);
180
+ window.sessionStorage.removeItem(key);
129
181
  }
130
182
  catch {
131
183
  // private browsing / storage disabled -- replay still works for this page
132
184
  }
133
185
  }
186
+ let legacyLocalStorageReplaySessionCleared = false;
187
+ /**
188
+ * Best-effort, one-time removal of the pre-fix replay session record that
189
+ * used to live in `localStorage`. Never read from it -- adopting its
190
+ * `replayId` would recreate the exact shared-identity bug this file now
191
+ * avoids by using `sessionStorage` instead.
192
+ */
193
+ function clearLegacyLocalStorageReplaySession() {
194
+ if (legacyLocalStorageReplaySessionCleared)
195
+ return;
196
+ legacyLocalStorageReplaySessionCleared = true;
197
+ try {
198
+ window.localStorage.removeItem(SESSION_REPLAY_ID_STORAGE_KEY);
199
+ }
200
+ catch {
201
+ // best-effort only -- a stray legacy record is harmless once ignored
202
+ }
203
+ }
134
204
  function generateReplayId() {
135
205
  try {
136
206
  if (typeof crypto !== "undefined" &&
@@ -144,7 +214,7 @@ function generateReplayId() {
144
214
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
145
215
  }
146
216
  function readStoredReplaySession() {
147
- const raw = safeStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
217
+ const raw = safeSessionStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
148
218
  if (!raw)
149
219
  return null;
150
220
  try {
@@ -158,9 +228,37 @@ function readStoredReplaySession() {
158
228
  }
159
229
  }
160
230
  function writeStoredReplaySession(value) {
161
- safeStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
231
+ safeSessionStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
232
+ }
233
+ function removeStoredReplaySession(replayId) {
234
+ if (readStoredReplaySession()?.replayId !== replayId)
235
+ return;
236
+ safeSessionStorageRemove(SESSION_REPLAY_ID_STORAGE_KEY);
162
237
  }
238
+ /**
239
+ * Per-tab replay identity is deliberate -- do not "fix" this by reading or
240
+ * writing the session record through `localStorage` again.
241
+ *
242
+ * `sessionStorage` is scoped to a single tab (and survives reloads/
243
+ * navigations within that tab, which is exactly the lifetime a recording
244
+ * needs). `localStorage` is shared by every open tab of the origin. If this
245
+ * record lived there, two tabs open to the same app would read/write the
246
+ * *same* `replayId` and the *same* sequence counter, so rrweb in each tab
247
+ * would record independently but upload chunks under one shared identity.
248
+ * The two interleaved DOM mutation streams get merged into a single
249
+ * recording server-side: mutations reference the other tab's node ids
250
+ * (broken CSS), the viewport/meta events reflect whichever tab resized last
251
+ * (wrong or ultra-wide viewport), and lost mousemove batches from the
252
+ * "other" tab's chunks read as a frozen cursor or a fake inactivity gap. A
253
+ * chunk-sequence collision with a different checksum gets rejected
254
+ * server-side (409) rather than merged, so one tab's batches are silently
255
+ * dropped -- there is no way to reconstruct or repair this at playback time.
256
+ * Keep this per-tab. A tab *duplicated* mid-session still shares a
257
+ * `sessionStorage` snapshot, which is what the `BroadcastChannel` claim
258
+ * check in `startSessionReplayRecorder` guards against.
259
+ */
163
260
  function getOrCreateReplaySession(sessionId) {
261
+ clearLegacyLocalStorageReplaySession();
164
262
  const parsed = readStoredReplaySession();
165
263
  if (parsed?.sessionId === sessionId && parsed.replayId) {
166
264
  const startedAtMs = typeof parsed.startedAtMs === "number" &&
@@ -173,12 +271,115 @@ function getOrCreateReplaySession(sessionId) {
173
271
  parsed.sequence >= 0
174
272
  ? Math.floor(parsed.sequence)
175
273
  : 0;
176
- return { replayId: parsed.replayId, startedAtMs, sequence };
274
+ return { replayId: parsed.replayId, startedAtMs, sequence, resumed: true };
177
275
  }
178
276
  const replayId = generateReplayId();
179
277
  const startedAtMs = Date.now();
180
278
  writeStoredReplaySession({ sessionId, replayId, startedAtMs, sequence: 0 });
181
- return { replayId, startedAtMs, sequence: 0 };
279
+ return { replayId, startedAtMs, sequence: 0, resumed: false };
280
+ }
281
+ /**
282
+ * Open the cross-tab claim channel used to detect a *duplicated* tab (a
283
+ * browser "duplicate tab" or same-origin `window.open` copies
284
+ * `sessionStorage`, so two tabs can legitimately start with the same
285
+ * resumed `replayId`). Returns `null` when `BroadcastChannel` is
286
+ * unavailable -- callers must treat that as "skip the guard", never as an
287
+ * error.
288
+ */
289
+ function openReplayBroadcastChannel() {
290
+ if (typeof BroadcastChannel === "undefined")
291
+ return null;
292
+ try {
293
+ return new BroadcastChannel(SESSION_REPLAY_BROADCAST_CHANNEL_NAME);
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ }
299
+ /**
300
+ * Wires up the duplicated-tab claim channel for one recorder lifetime.
301
+ * `respond` keeps listening for the whole life of the channel (any tab may
302
+ * later claim the `replayId` this tab is actively recording); `probeClaim`
303
+ * is used once, only when resuming a stored session, to ask "is anyone else
304
+ * already recording this id?" and wait up to
305
+ * `SESSION_REPLAY_CLAIM_TIMEOUT_MS` for a reply.
306
+ */
307
+ function createReplayClaimChannel(state, instanceNonce) {
308
+ const channel = openReplayBroadcastChannel();
309
+ if (!channel) {
310
+ return { channel: null, probeClaim: async () => false };
311
+ }
312
+ let pending = null;
313
+ channel.onmessage = (event) => {
314
+ const data = event?.data;
315
+ if (!data || typeof data !== "object")
316
+ return;
317
+ if (data.type === "an-replay-claim") {
318
+ if (data.instanceNonce === instanceNonce)
319
+ return;
320
+ const ownsReplayId = state.active && state.replayId === data.replayId;
321
+ const winsSimultaneousClaim = pending?.replayId === data.replayId &&
322
+ instanceNonce.localeCompare(data.instanceNonce) < 0;
323
+ if (!ownsReplayId && !winsSimultaneousClaim) {
324
+ // If both duplicated tabs start simultaneously, deterministically
325
+ // yield to the lower nonce rather than letting both probes time out
326
+ // and record under the copied replay id.
327
+ if (pending?.replayId === data.replayId &&
328
+ data.instanceNonce.localeCompare(instanceNonce) < 0) {
329
+ window.clearTimeout(pending.timer);
330
+ const resolve = pending.resolve;
331
+ pending = null;
332
+ resolve(true);
333
+ }
334
+ return;
335
+ }
336
+ try {
337
+ const reply = {
338
+ type: "an-replay-claim-taken",
339
+ replayId: data.replayId,
340
+ claimantNonce: data.instanceNonce,
341
+ };
342
+ channel.postMessage(reply);
343
+ }
344
+ catch {
345
+ // best-effort -- a lost reply just means the duplicate tab resumes
346
+ // recording under the shared id; later 409s still protect the
347
+ // stream from getting corrupted merges.
348
+ }
349
+ return;
350
+ }
351
+ if (data.type === "an-replay-claim-taken") {
352
+ if (pending &&
353
+ data.replayId === pending.replayId &&
354
+ (!data.claimantNonce || data.claimantNonce === instanceNonce)) {
355
+ window.clearTimeout(pending.timer);
356
+ const resolve = pending.resolve;
357
+ pending = null;
358
+ resolve(true);
359
+ }
360
+ }
361
+ };
362
+ const probeClaim = (replayId) => new Promise((resolve) => {
363
+ const timer = window.setTimeout(() => {
364
+ pending = null;
365
+ resolve(false);
366
+ }, SESSION_REPLAY_CLAIM_TIMEOUT_MS);
367
+ pending = { replayId, resolve, timer };
368
+ try {
369
+ const claim = {
370
+ type: "an-replay-claim",
371
+ replayId,
372
+ instanceNonce,
373
+ };
374
+ channel.postMessage(claim);
375
+ }
376
+ catch {
377
+ window.clearTimeout(timer);
378
+ pending = null;
379
+ resolve(false);
380
+ }
381
+ });
382
+ return { channel, probeClaim };
182
383
  }
183
384
  function persistReplaySequence(sessionId, replayId, startedAtMs, sequence) {
184
385
  writeStoredReplaySession({
@@ -349,22 +550,32 @@ function normalizeOptions(options) {
349
550
  checkoutEveryNth: options.checkoutEveryNth,
350
551
  checkoutEveryNms: options.checkoutEveryNms,
351
552
  inlineStylesheet: options.inlineStylesheet ?? true,
352
- blockSelector: options.blockSelector || DEFAULT_BLOCK_SELECTOR,
553
+ blockSelector: mergeReplayBlockSelector(options.blockSelector || DEFAULT_BLOCK_SELECTOR),
353
554
  ignoreSelector: options.ignoreSelector || DEFAULT_IGNORE_SELECTOR,
354
555
  maskTextClass: options.maskTextClass || DEFAULT_MASK_TEXT_CLASS,
355
556
  maskTextSelector: options.maskTextSelector || DEFAULT_MASK_TEXT_SELECTOR,
356
557
  maskAllInputs: options.maskAllInputs ?? true,
357
558
  recordCanvas: options.recordCanvas ?? false,
358
- recordCrossOriginIframes: options.recordCrossOriginIframes ?? false,
559
+ // A top-level app can safely aggregate cooperative child recorders. An
560
+ // app embedded by an unrelated cross-origin host must continue emitting
561
+ // and uploading its own events, so it only forwards to a parent when the
562
+ // caller explicitly opts in.
563
+ recordCrossOriginIframes: options.recordCrossOriginIframes ?? window.parent === window,
359
564
  collectFonts: options.collectFonts ?? false,
360
565
  inlineImages: options.inlineImages ?? false,
361
566
  eventSampling: options.eventSampling ?? DEFAULT_EVENT_SAMPLING,
362
567
  console: normalizeCaptureToggle(options.console, DEFAULT_MAX_CONSOLE_EVENTS),
363
568
  network: normalizeCaptureToggle(options.network, DEFAULT_MAX_NETWORK_EVENTS),
569
+ onUploadRejected: options.onUploadRejected,
364
570
  extraProperties: options.extraProperties,
365
571
  shouldStart: options.shouldStart,
366
572
  };
367
573
  }
574
+ function mergeReplayBlockSelector(blockSelector) {
575
+ return blockSelector.includes(SESSION_REPLAY_IFRAME_BLOCK_SELECTOR)
576
+ ? blockSelector
577
+ : `${blockSelector}, ${SESSION_REPLAY_IFRAME_BLOCK_SELECTOR}`;
578
+ }
368
579
  /**
369
580
  * Console/network capture default to ON whenever session replay records;
370
581
  * `false` disables a category, an object form overrides its caps. Network
@@ -424,17 +635,141 @@ function scrubReplayValue(value, key = "", depth = 0, seen = new WeakSet()) {
424
635
  * the scrub into the single serialization pass avoids a separate deep-clone of
425
636
  * every emitted event (FullSnapshots are large DOM trees) on the hot path.
426
637
  */
427
- function scrubReplayReplacer(key, value) {
428
- return typeof value === "string" ? scrubStringValue(key, value) : value;
638
+ const REPLAY_RESOURCE_LINK_RELS = new Set([
639
+ "stylesheet",
640
+ "icon",
641
+ "apple-touch-icon",
642
+ "mask-icon",
643
+ ]);
644
+ const REPLAY_RESOURCE_PRELOAD_TYPES = new Set([
645
+ "style",
646
+ "font",
647
+ "image",
648
+ "audio",
649
+ "video",
650
+ "track",
651
+ ]);
652
+ const REPLAY_RESOURCE_TAGS = new Set([
653
+ "img",
654
+ "source",
655
+ "video",
656
+ "audio",
657
+ "track",
658
+ "input",
659
+ "link",
660
+ ]);
661
+ const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set();
662
+ const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
663
+ const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
664
+ const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
665
+ const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
666
+ function replayAttributeString(attributes, key) {
667
+ return typeof attributes[key] === "string"
668
+ ? attributes[key].toLowerCase()
669
+ : "";
670
+ }
671
+ function updateReplayResourceNode(current, attributes) {
672
+ return {
673
+ tagName: current.tagName,
674
+ rel: Object.hasOwn(attributes, "rel")
675
+ ? replayAttributeString(attributes, "rel")
676
+ : current.rel,
677
+ as: Object.hasOwn(attributes, "as")
678
+ ? replayAttributeString(attributes, "as")
679
+ : current.as,
680
+ type: Object.hasOwn(attributes, "type")
681
+ ? replayAttributeString(attributes, "type")
682
+ : current.type,
683
+ };
684
+ }
685
+ function replayPreservedResourceAttributes(node) {
686
+ switch (node.tagName) {
687
+ case "img":
688
+ case "source":
689
+ return REPLAY_SRCSET_ATTRIBUTES;
690
+ case "video":
691
+ return REPLAY_VIDEO_ATTRIBUTES;
692
+ case "audio":
693
+ case "track":
694
+ return REPLAY_SRC_ATTRIBUTES;
695
+ case "input":
696
+ return node.type === "image"
697
+ ? REPLAY_SRC_ATTRIBUTES
698
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
699
+ case "link": {
700
+ const rels = node.rel.split(/\s+/);
701
+ const isLoadBearingResource = rels.some((rel) => REPLAY_RESOURCE_LINK_RELS.has(rel)) ||
702
+ (rels.includes("preload") &&
703
+ REPLAY_RESOURCE_PRELOAD_TYPES.has(node.as));
704
+ return isLoadBearingResource
705
+ ? REPLAY_HREF_ATTRIBUTES
706
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
707
+ }
708
+ default:
709
+ return NO_REPLAY_RESOURCE_ATTRIBUTES;
710
+ }
711
+ }
712
+ /**
713
+ * Build a path-aware replay serializer without cloning the rrweb event.
714
+ *
715
+ * Privacy still wins for Meta/navigation URLs, executable/embed URLs, anchor
716
+ * hrefs, and custom console/network diagnostics. The narrow exception is
717
+ * load-bearing stylesheet, font, image, and media attributes: changing those
718
+ * signed URLs makes rrweb rebuild a page that never existed. JSON.stringify
719
+ * calls a replacer for an `attributes` object before its children, so the
720
+ * WeakMap lets the child callback recognize only that bag.
721
+ */
722
+ function createReplayScrubReplacer(resourceNodes) {
723
+ const preservedAttributes = new WeakMap();
724
+ return function replayScrubReplacer(key, value) {
725
+ if (key === "attributes" && value && typeof value === "object") {
726
+ const attributes = value;
727
+ const holder = this && typeof this === "object"
728
+ ? this
729
+ : undefined;
730
+ const tagName = typeof holder?.tagName === "string" ? holder.tagName.toLowerCase() : "";
731
+ const nodeId = typeof holder?.id === "number" && Number.isFinite(holder.id)
732
+ ? holder.id
733
+ : undefined;
734
+ let resourceNode;
735
+ if (tagName && REPLAY_RESOURCE_TAGS.has(tagName)) {
736
+ resourceNode = updateReplayResourceNode({ tagName, rel: "", as: "", type: "" }, attributes);
737
+ }
738
+ else if (nodeId !== undefined && !tagName) {
739
+ const current = resourceNodes.get(nodeId);
740
+ if (current) {
741
+ resourceNode = updateReplayResourceNode(current, attributes);
742
+ }
743
+ }
744
+ if (nodeId !== undefined && resourceNode) {
745
+ resourceNodes.set(nodeId, resourceNode);
746
+ }
747
+ const resourceKeys = resourceNode
748
+ ? replayPreservedResourceAttributes(resourceNode)
749
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
750
+ if (resourceKeys.size > 0)
751
+ preservedAttributes.set(value, resourceKeys);
752
+ return value;
753
+ }
754
+ if (typeof value === "string" &&
755
+ this &&
756
+ typeof this === "object" &&
757
+ preservedAttributes.get(this)?.has(key.toLowerCase())) {
758
+ return value;
759
+ }
760
+ return typeof value === "string" ? scrubStringValue(key, value) : value;
761
+ };
429
762
  }
430
763
  /**
431
764
  * Serialize + scrub one event in a single pass. The resulting string is stored
432
765
  * directly on the queue and reused verbatim at flush, so each event is
433
766
  * stringified exactly once (was: deep-clone + size-stringify + flush-stringify).
434
767
  */
435
- function serializeReplayEvent(event) {
768
+ function serializeReplayEvent(event, resourceNodes) {
436
769
  try {
437
- return JSON.stringify(event, scrubReplayReplacer);
770
+ if (event.type === 2)
771
+ resourceNodes.clear();
772
+ return JSON.stringify(event, createReplayScrubReplacer(resourceNodes));
438
773
  }
439
774
  catch {
440
775
  return "";
@@ -455,18 +790,25 @@ function replayEventTimestampMs(event) {
455
790
  function enqueueReplayEvent(state, event) {
456
791
  if (!state.options)
457
792
  return;
458
- const serialized = serializeReplayEvent(event);
793
+ const eventType = typeof event.type === "number" ? event.type : null;
794
+ if (state.awaitingFullSnapshot) {
795
+ if (eventType !== RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
796
+ return;
797
+ state.awaitingFullSnapshot = false;
798
+ }
799
+ const serialized = serializeReplayEvent(event, state.resourceNodes);
459
800
  if (!serialized)
460
801
  return;
461
- const estimatedBytes = serialized.length;
802
+ const estimatedBytes = replaySerializedBytes(serialized);
462
803
  if (state.queue.length > 0 &&
463
804
  state.queuedBytes + estimatedBytes > state.options.maxBatchBytes) {
464
805
  void flushSessionReplay("max-bytes");
465
806
  }
466
807
  state.queue.push({
467
808
  json: serialized,
809
+ byteLength: estimatedBytes,
468
810
  timestampMs: replayEventTimestampMs(event),
469
- type: typeof event.type === "number" ? event.type : null,
811
+ type: eventType,
470
812
  });
471
813
  state.queuedBytes += estimatedBytes;
472
814
  flushQueuedReplayIfNeeded(state);
@@ -629,6 +971,25 @@ function replayUploadBodyBytes(body) {
629
971
  function canUseReplayKeepalive(body) {
630
972
  return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
631
973
  }
974
+ /** Thrown by `sendReplayUpload` on a non-ok HTTP response, carrying the
975
+ * status so `flushSessionReplay` can tell a permanent client rejection
976
+ * (e.g. a 409 checksum conflict, which can never succeed on retry) apart
977
+ * from a transient failure worth retrying. */
978
+ class ReplayUploadHttpError extends Error {
979
+ status;
980
+ constructor(status) {
981
+ super(`Session replay upload failed with HTTP ${status}`);
982
+ this.name = "ReplayUploadHttpError";
983
+ this.status = status;
984
+ }
985
+ }
986
+ /** 4xx statuses where retrying the exact same batch can never succeed --
987
+ * e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
988
+ * reject again. 408 (timeout) and 429 (rate limit) are excluded because a
989
+ * later retry can plausibly succeed. */
990
+ function isDefinitiveReplayUploadClientError(status) {
991
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
992
+ }
632
993
  async function sendReplayUpload(options, body, callbacks = {}) {
633
994
  if (isCrossOriginReplayEndpoint(options.endpoint)) {
634
995
  const canUseKeepalive = canUseReplayKeepalive(body);
@@ -641,7 +1002,7 @@ async function sendReplayUpload(options, body, callbacks = {}) {
641
1002
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
642
1003
  });
643
1004
  if (!response.ok) {
644
- throw new Error(`Session replay upload failed with HTTP ${response.status}`);
1005
+ throw new ReplayUploadHttpError(response.status);
645
1006
  }
646
1007
  return;
647
1008
  }
@@ -659,7 +1020,7 @@ async function sendReplayUpload(options, body, callbacks = {}) {
659
1020
  },
660
1021
  });
661
1022
  if (!response.ok) {
662
- throw new Error(`Session replay upload failed with HTTP ${response.status}`);
1023
+ throw new ReplayUploadHttpError(response.status);
663
1024
  }
664
1025
  }
665
1026
  function isFinalFlushReason(reason) {
@@ -672,6 +1033,24 @@ function isFinalFlushReason(reason) {
672
1033
  "max-duration",
673
1034
  ].includes(reason);
674
1035
  }
1036
+ function flushReasonPriority(reason) {
1037
+ // Unload reasons must retain their keepalive sequence reservation even when
1038
+ // another final request (for example, an explicit manual stop) is coalesced.
1039
+ if (reason === "pagehide" || reason === "beforeunload")
1040
+ return 3;
1041
+ if (isFinalFlushReason(reason))
1042
+ return 2;
1043
+ if (reason === "visibility-hidden")
1044
+ return 1;
1045
+ return 0;
1046
+ }
1047
+ function mergePendingFlushReason(current, requested) {
1048
+ if (!current)
1049
+ return requested;
1050
+ return flushReasonPriority(requested) > flushReasonPriority(current)
1051
+ ? requested
1052
+ : current;
1053
+ }
675
1054
  function shouldReserveSequenceBeforeKeepalive(reason) {
676
1055
  return (reason === "pagehide" ||
677
1056
  reason === "beforeunload" ||
@@ -706,7 +1085,90 @@ function flushQueuedReplayIfNeeded(state) {
706
1085
  void flushSessionReplay(reason);
707
1086
  }
708
1087
  function queuedReplayBytes(events) {
709
- return events.reduce((total, event) => total + event.json.length, 0);
1088
+ return events.reduce((total, event) => total + queuedReplayEventBytes(event), 0);
1089
+ }
1090
+ function replaySerializedBytes(value) {
1091
+ if (REPLAY_TEXT_ENCODER)
1092
+ return REPLAY_TEXT_ENCODER.encode(value).byteLength;
1093
+ if (typeof Blob !== "undefined")
1094
+ return new Blob([value]).size;
1095
+ return value.length;
1096
+ }
1097
+ function queuedReplayEventBytes(event) {
1098
+ return Number.isFinite(event.byteLength)
1099
+ ? event.byteLength
1100
+ : replaySerializedBytes(event.json);
1101
+ }
1102
+ /**
1103
+ * Remove one bounded FIFO prefix from the live queue.
1104
+ *
1105
+ * Threshold-triggered flushes may arrive while another upload is active. The
1106
+ * old `queue.splice(0)` drained that entire accumulated backlog on the next
1107
+ * flush, bypassing both byte and event caps. Keep FullSnapshots isolated and
1108
+ * always take at least one event so an individually large snapshot can still
1109
+ * make progress.
1110
+ */
1111
+ function takeQueuedReplayBatch(state) {
1112
+ const options = state.options;
1113
+ if (!options || state.queue.length === 0)
1114
+ return [];
1115
+ let count = 0;
1116
+ let bytes = 0;
1117
+ for (const event of state.queue) {
1118
+ if (count > 0 && event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
1119
+ break;
1120
+ if (count > 0 &&
1121
+ (count >= options.maxEventsPerBatch ||
1122
+ bytes + queuedReplayEventBytes(event) > options.maxBatchBytes)) {
1123
+ break;
1124
+ }
1125
+ count += 1;
1126
+ bytes += queuedReplayEventBytes(event);
1127
+ if (event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
1128
+ break;
1129
+ }
1130
+ const events = state.queue.splice(0, Math.max(1, count));
1131
+ state.queuedBytes = queuedReplayBytes(state.queue);
1132
+ return events;
1133
+ }
1134
+ function splitReplayBatch(events) {
1135
+ if (events.length < 2)
1136
+ return null;
1137
+ const targetBytes = queuedReplayBytes(events) / 2;
1138
+ let splitAt = 1;
1139
+ let bytes = events[0] ? queuedReplayEventBytes(events[0]) : 0;
1140
+ while (splitAt < events.length - 1 && bytes < targetBytes) {
1141
+ const event = events[splitAt];
1142
+ if (event)
1143
+ bytes += queuedReplayEventBytes(event);
1144
+ splitAt += 1;
1145
+ }
1146
+ return [events.slice(0, splitAt), events.slice(splitAt)];
1147
+ }
1148
+ function replayBatchNeedsDomReset(events) {
1149
+ return events.some((event) => {
1150
+ if (event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
1151
+ return true;
1152
+ if (event.type !== 3)
1153
+ return false;
1154
+ try {
1155
+ const parsed = JSON.parse(event.json);
1156
+ // rrweb IncrementalSource.Mutation is zero. Dropping one may leave later
1157
+ // node references dangling until another FullSnapshot resets the mirror.
1158
+ return parsed.data?.source === 0;
1159
+ }
1160
+ catch {
1161
+ return true;
1162
+ }
1163
+ });
1164
+ }
1165
+ function quarantinePendingReplayUntilFullSnapshot(state) {
1166
+ const pending = [...state.retryBatches.flat(), ...state.queue];
1167
+ const resetAt = pending.findIndex((event) => event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE);
1168
+ state.retryBatches = [];
1169
+ state.queue = resetAt >= 0 ? pending.slice(resetAt) : [];
1170
+ state.queuedBytes = queuedReplayBytes(state.queue);
1171
+ state.awaitingFullSnapshot = resetAt < 0;
710
1172
  }
711
1173
  function restoreReplayEvents(state, events) {
712
1174
  state.retryBatches.unshift(events);
@@ -727,10 +1189,17 @@ function rollbackReplaySequenceReservation(state, payload) {
727
1189
  }
728
1190
  export async function flushSessionReplay(reason = "manual") {
729
1191
  const state = getState();
730
- if (!state.options || !hasPendingReplayBatch(state) || state.flushing)
1192
+ if (!state.options)
731
1193
  return;
732
- const events = state.retryBatches.shift() ?? state.queue.splice(0);
733
- state.queuedBytes = queuedReplayBytes(state.queue);
1194
+ if (state.flushing) {
1195
+ state.pendingFlushReason = mergePendingFlushReason(state.pendingFlushReason, reason);
1196
+ return new Promise((resolve) => {
1197
+ state.pendingFlushWaiters.push(resolve);
1198
+ });
1199
+ }
1200
+ if (!hasPendingReplayBatch(state))
1201
+ return;
1202
+ const events = state.retryBatches.shift() ?? takeQueuedReplayBatch(state);
734
1203
  const payload = buildReplayBody(state, reason, events);
735
1204
  if (!payload || !state.options) {
736
1205
  restoreReplayEvents(state, events);
@@ -739,6 +1208,9 @@ export async function flushSessionReplay(reason = "manual") {
739
1208
  state.flushing = true;
740
1209
  let uploaded = false;
741
1210
  let reservedSequence = false;
1211
+ let splitRejectedBatch = false;
1212
+ let droppedOversizedBatch = false;
1213
+ let definitiveClientErrorStatus = null;
742
1214
  try {
743
1215
  await sendReplayUpload(state.options, payload.body, {
744
1216
  beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
@@ -750,18 +1222,73 @@ export async function flushSessionReplay(reason = "manual") {
750
1222
  });
751
1223
  if (!reservedSequence)
752
1224
  advanceReplaySequence(state, payload);
1225
+ state.automaticConflictRestartAttempted = false;
753
1226
  uploaded = true;
754
1227
  }
755
1228
  catch (error) {
756
1229
  if (reservedSequence)
757
1230
  rollbackReplaySequenceReservation(state, payload);
758
- restoreReplayEvents(state, events);
1231
+ // A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
1232
+ // never succeed by retrying the exact same batch -- requeuing it would
1233
+ // just spin forever, blocking every later batch behind it (flushes are
1234
+ // FIFO via `retryBatches`). Drop it and move on instead.
1235
+ const rejectedStatus = error instanceof ReplayUploadHttpError ? error.status : null;
1236
+ const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
1237
+ const isUnsplittableOversizedBatch = rejectedStatus === 413 && splitBatch === null;
1238
+ const isDefinitiveClientError = error instanceof ReplayUploadHttpError &&
1239
+ isDefinitiveReplayUploadClientError(error.status) &&
1240
+ !splitBatch &&
1241
+ !isUnsplittableOversizedBatch;
1242
+ if (splitBatch) {
1243
+ // A server or platform can enforce a stricter decompressed-body limit
1244
+ // than the recorder's configured queue cap. Bisect in FIFO order and
1245
+ // retry both halves at the same sequence; only successful halves advance
1246
+ // it, so no event is duplicated or skipped.
1247
+ state.retryBatches.unshift(...splitBatch);
1248
+ splitRejectedBatch = true;
1249
+ }
1250
+ else if (isUnsplittableOversizedBatch) {
1251
+ // This one event cannot be made any smaller. Drop only the rejected
1252
+ // singleton and keep later retry halves/live batches in FIFO order. If
1253
+ // it carried DOM structure, quarantine dependent mutations until a new
1254
+ // FullSnapshot can safely re-anchor rrweb's node mirror.
1255
+ droppedOversizedBatch = true;
1256
+ if (replayBatchNeedsDomReset(events)) {
1257
+ quarantinePendingReplayUntilFullSnapshot(state);
1258
+ }
1259
+ }
1260
+ else if (isDefinitiveClientError) {
1261
+ // Continuing after a checksum/sequence conflict would reuse the same
1262
+ // rejected sequence forever. More importantly, advancing past it would
1263
+ // append mutations to a replay whose DOM stream may belong to another
1264
+ // tab. End this recorder and clear its persisted identity so the next
1265
+ // start creates a clean replay instead of producing corrupt playback.
1266
+ state.queue = [];
1267
+ state.queuedBytes = 0;
1268
+ state.retryBatches = [];
1269
+ removeStoredReplaySession(payload.replayId);
1270
+ definitiveClientErrorStatus = error.status;
1271
+ }
1272
+ else {
1273
+ restoreReplayEvents(state, events);
1274
+ }
759
1275
  // Guard the recorder's own warning so console capture never records it
760
1276
  // (a captured warning would enqueue an event and retrigger a flush).
761
1277
  const previousInternal = replayCaptureInternal;
762
1278
  replayCaptureInternal = true;
763
1279
  try {
764
- console.warn("[session-replay] upload failed", error);
1280
+ if (splitRejectedBatch) {
1281
+ console.warn("[session-replay] splitting oversized upload (HTTP 413)", error);
1282
+ }
1283
+ else if (droppedOversizedBatch) {
1284
+ console.warn("[session-replay] dropping oversized replay event (HTTP 413)", error);
1285
+ }
1286
+ else if (isDefinitiveClientError) {
1287
+ console.warn(`[session-replay] dropping upload (HTTP ${error.status})`, error);
1288
+ }
1289
+ else {
1290
+ console.warn("[session-replay] upload failed", error);
1291
+ }
765
1292
  }
766
1293
  finally {
767
1294
  replayCaptureInternal = previousInternal;
@@ -770,9 +1297,112 @@ export async function flushSessionReplay(reason = "manual") {
770
1297
  finally {
771
1298
  state.flushing = false;
772
1299
  }
773
- if (uploaded && hasPendingReplayBatch(state)) {
774
- flushQueuedReplayIfNeeded(state);
1300
+ const coalescedReason = state.pendingFlushReason;
1301
+ const coalescedWaiters = coalescedReason
1302
+ ? state.pendingFlushWaiters.splice(0)
1303
+ : [];
1304
+ if (coalescedReason)
1305
+ state.pendingFlushReason = null;
1306
+ if (coalescedReason) {
1307
+ // A stop/unload request that arrived during this upload owns the tail.
1308
+ // Its higher-priority reason must replace threshold/internal reasons so a
1309
+ // small final batch is not stranded or mislabeled as active.
1310
+ await flushSessionReplay(coalescedReason);
1311
+ }
1312
+ else if (splitRejectedBatch || droppedOversizedBatch) {
1313
+ // Preserve final-status and unload semantics through every half. In
1314
+ // particular, pagehide/beforeunload retries must still reserve their
1315
+ // sequence before a keepalive fetch, and completed flushes must not be
1316
+ // rewritten to an internal recovery reason/status.
1317
+ await flushSessionReplay(reason);
1318
+ }
1319
+ else if (uploaded && hasPendingReplayBatch(state)) {
1320
+ const mustContinue = state.retryBatches.length > 0 ||
1321
+ isFinalFlushReason(reason) ||
1322
+ shouldFlushQueuedReplay(state);
1323
+ if (mustContinue) {
1324
+ if (isFinalFlushReason(reason)) {
1325
+ await flushSessionReplay(reason);
1326
+ }
1327
+ else
1328
+ void flushSessionReplay(reason);
1329
+ }
1330
+ }
1331
+ if (definitiveClientErrorStatus !== null &&
1332
+ state.replayId === payload.replayId) {
1333
+ const rejectedOptions = state.options;
1334
+ const shouldRestartAfterConflict = definitiveClientErrorStatus === 409 &&
1335
+ state.active &&
1336
+ !isFinalFlushReason(reason) &&
1337
+ !state.automaticConflictRestartAttempted;
1338
+ if (shouldRestartAfterConflict) {
1339
+ state.automaticConflictRestartAttempted = true;
1340
+ }
1341
+ await stopSessionReplay("upload-rejected");
1342
+ // A 409 means this replay identity can no longer append safely (usually a
1343
+ // duplicated tab that inherited sessionStorage, or an old shared identity
1344
+ // still open during rollout). Do not leave a long-lived SPA tab silently
1345
+ // unrecorded until its next page load: restart rrweb under a fresh per-tab
1346
+ // id so it emits a new Meta + FullSnapshot stream. Limit this to one retry
1347
+ // until an upload succeeds; other definitive 4xx responses usually reflect
1348
+ // configuration/input errors and must not create a restart loop.
1349
+ let restartResult = null;
1350
+ if (shouldRestartAfterConflict && rejectedOptions) {
1351
+ restartResult = await restartSessionReplayAfterConflict(state, rejectedOptions, payload.sessionId);
1352
+ }
1353
+ // Rare recovery-path telemetry lets Analytics owners quantify conflicts
1354
+ // without recording the rejected replay id, URL, or any captured content.
1355
+ try {
1356
+ rejectedOptions?.onUploadRejected?.({
1357
+ status: definitiveClientErrorStatus,
1358
+ restartAttempted: shouldRestartAfterConflict,
1359
+ restartSucceeded: restartResult?.started === true,
1360
+ ...(restartResult?.reason
1361
+ ? { restartReason: restartResult.reason }
1362
+ : {}),
1363
+ });
1364
+ }
1365
+ catch {
1366
+ // best-effort telemetry must never interfere with recording recovery
1367
+ }
775
1368
  }
1369
+ for (const resolve of coalescedWaiters)
1370
+ resolve();
1371
+ }
1372
+ /**
1373
+ * Restart a recorder whose replay identity was rejected without routing the
1374
+ * already-normalized options back through the public start API.
1375
+ *
1376
+ * The original recording already passed whole-session sampling. Re-entering
1377
+ * `startSessionReplay` here would ask `getOrCreateAnalyticsSessionId` again;
1378
+ * if the analytics session rotated while the upload was in flight, recovery
1379
+ * could be sampled out and leave a long-lived SPA tab silently unrecorded.
1380
+ * Keeping the original session id and accepted sampling decision also lets us
1381
+ * reuse the exact normalized console/network capture caps instead of relying
1382
+ * on internal option shapes continuing to round-trip through the public API.
1383
+ */
1384
+ async function restartSessionReplayAfterConflict(state, options, sessionId) {
1385
+ if (options.shouldStart && !options.shouldStart()) {
1386
+ return { started: false, reason: "disabled", sessionId, sampled: true };
1387
+ }
1388
+ const initialProperties = replayExtraProperties(options);
1389
+ if (options.requireSignedInUser && !replayUserEmail(initialProperties)) {
1390
+ return {
1391
+ started: false,
1392
+ reason: "missing-user-id",
1393
+ sessionId,
1394
+ sampled: true,
1395
+ };
1396
+ }
1397
+ if (!isUrlRecordable(window.location.href, options)) {
1398
+ return {
1399
+ started: false,
1400
+ reason: "url-blocked",
1401
+ sessionId,
1402
+ sampled: true,
1403
+ };
1404
+ }
1405
+ return startSessionReplayRecorder(state, options, sessionId, true, initialProperties);
776
1406
  }
777
1407
  function installUrlMonitor(state) {
778
1408
  if (!state.options || state.restoreUrlMonitor)
@@ -822,6 +1452,80 @@ function installLifecycleListeners(state) {
822
1452
  state.removeLifecycleListeners = null;
823
1453
  };
824
1454
  }
1455
+ function markedSessionReplayIframes() {
1456
+ if (typeof document.querySelectorAll !== "function")
1457
+ return [];
1458
+ return Array.from(document.querySelectorAll(`iframe[${SESSION_REPLAY_IFRAME_ATTRIBUTE}]`));
1459
+ }
1460
+ function markedSessionReplayIframeForSource(source) {
1461
+ if (!source)
1462
+ return null;
1463
+ return (markedSessionReplayIframes().find((iframe) => iframe.contentWindow === source) ?? null);
1464
+ }
1465
+ function sessionReplayIframePrivacyOptions(options) {
1466
+ return {
1467
+ blockSelector: options.blockSelector,
1468
+ ignoreSelector: options.ignoreSelector,
1469
+ maskTextClass: options.maskTextClass,
1470
+ maskTextSelector: options.maskTextSelector,
1471
+ maskAllInputs: options.maskAllInputs,
1472
+ maskInputOptions: DEFAULT_MASK_INPUT_OPTIONS,
1473
+ recordCanvas: options.recordCanvas,
1474
+ collectFonts: options.collectFonts,
1475
+ inlineImages: options.inlineImages,
1476
+ sampling: options.eventSampling,
1477
+ };
1478
+ }
1479
+ function postSessionReplayIframeMessage(iframe, message) {
1480
+ try {
1481
+ iframe.contentWindow?.postMessage(message, "*");
1482
+ }
1483
+ catch {
1484
+ // The frame may have navigated or detached between discovery and send.
1485
+ }
1486
+ }
1487
+ /**
1488
+ * Cooperative opaque/cross-origin iframe recording.
1489
+ *
1490
+ * rrweb cannot inspect these documents from the host. Framework-owned child
1491
+ * frames carry a marker and inject a tiny recorder that probes its direct
1492
+ * parent. Only an active host recorder responds, and only when the probe's
1493
+ * source is the contentWindow of a currently marked direct iframe.
1494
+ */
1495
+ function installSessionReplayIframeBridge(state, options) {
1496
+ if (!options.recordCrossOriginIframes || state.restoreIframeBridge)
1497
+ return;
1498
+ const startMessage = {
1499
+ type: SESSION_REPLAY_IFRAME_START,
1500
+ options: sessionReplayIframePrivacyOptions(options),
1501
+ };
1502
+ const stopMessage = {
1503
+ type: SESSION_REPLAY_IFRAME_STOP,
1504
+ };
1505
+ const sendStart = (iframe) => postSessionReplayIframeMessage(iframe, startMessage);
1506
+ const onMessage = (event) => {
1507
+ if (!state.active)
1508
+ return;
1509
+ if (!event.data ||
1510
+ typeof event.data !== "object" ||
1511
+ event.data.type !== SESSION_REPLAY_IFRAME_PROBE) {
1512
+ return;
1513
+ }
1514
+ const iframe = markedSessionReplayIframeForSource(event.source);
1515
+ if (iframe)
1516
+ sendStart(iframe);
1517
+ };
1518
+ window.addEventListener("message", onMessage);
1519
+ for (const iframe of markedSessionReplayIframes())
1520
+ sendStart(iframe);
1521
+ state.restoreIframeBridge = () => {
1522
+ window.removeEventListener("message", onMessage);
1523
+ for (const iframe of markedSessionReplayIframes()) {
1524
+ postSessionReplayIframeMessage(iframe, stopMessage);
1525
+ }
1526
+ state.restoreIframeBridge = null;
1527
+ };
1528
+ }
825
1529
  function truncateCaptureText(value, maxLength) {
826
1530
  return value.length > maxLength ? value.slice(0, maxLength) : value;
827
1531
  }
@@ -1503,7 +2207,31 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1503
2207
  if (normalized.shouldStart && !normalized.shouldStart()) {
1504
2208
  return { started: false, reason: "disabled", sessionId, sampled };
1505
2209
  }
1506
- const replaySession = getOrCreateReplaySession(sessionId);
2210
+ let replaySession = getOrCreateReplaySession(sessionId);
2211
+ const instanceNonce = generateReplayId();
2212
+ const { channel: replayChannel, probeClaim } = createReplayClaimChannel(state, instanceNonce);
2213
+ // Only a *resumed* id needs the duplicated-tab check -- a freshly minted
2214
+ // id can never collide with a recorder that's already running, so this
2215
+ // never adds startup latency for the common "new tab" case.
2216
+ if (replaySession.resumed && replayChannel) {
2217
+ const taken = await probeClaim(replaySession.replayId);
2218
+ if (taken) {
2219
+ const freshReplayId = generateReplayId();
2220
+ const freshStartedAtMs = Date.now();
2221
+ writeStoredReplaySession({
2222
+ sessionId,
2223
+ replayId: freshReplayId,
2224
+ startedAtMs: freshStartedAtMs,
2225
+ sequence: 0,
2226
+ });
2227
+ replaySession = {
2228
+ replayId: freshReplayId,
2229
+ startedAtMs: freshStartedAtMs,
2230
+ sequence: 0,
2231
+ resumed: false,
2232
+ };
2233
+ }
2234
+ }
1507
2235
  state.options = normalized;
1508
2236
  state.replayId = replaySession.replayId;
1509
2237
  state.startedAtMs = replaySession.startedAtMs;
@@ -1511,7 +2239,13 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1511
2239
  state.queue = [];
1512
2240
  state.queuedBytes = 0;
1513
2241
  state.retryBatches = [];
2242
+ state.pendingFlushReason = null;
2243
+ for (const resolve of state.pendingFlushWaiters.splice(0))
2244
+ resolve();
2245
+ state.awaitingFullSnapshot = false;
2246
+ state.resourceNodes.clear();
1514
2247
  state.stopRecorder = null;
2248
+ state.broadcastChannel = replayChannel;
1515
2249
  state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
1516
2250
  ? { ...initialProperties }
1517
2251
  : null;
@@ -1532,22 +2266,7 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1532
2266
  recordCrossOriginIframes: normalized.recordCrossOriginIframes,
1533
2267
  collectFonts: normalized.collectFonts,
1534
2268
  inlineImages: normalized.inlineImages,
1535
- maskInputOptions: {
1536
- color: true,
1537
- date: true,
1538
- "datetime-local": true,
1539
- email: true,
1540
- month: true,
1541
- number: true,
1542
- password: true,
1543
- range: true,
1544
- search: true,
1545
- tel: true,
1546
- text: true,
1547
- time: true,
1548
- url: true,
1549
- week: true,
1550
- },
2269
+ maskInputOptions: DEFAULT_MASK_INPUT_OPTIONS,
1551
2270
  });
1552
2271
  if (typeof stopRecorder !== "function") {
1553
2272
  state.active = false;
@@ -1555,6 +2274,13 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1555
2274
  state.replayId = null;
1556
2275
  state.startedAtMs = null;
1557
2276
  state.lastAuthenticatedProperties = null;
2277
+ try {
2278
+ state.broadcastChannel?.close();
2279
+ }
2280
+ catch {
2281
+ // best-effort cleanup
2282
+ }
2283
+ state.broadcastChannel = null;
1558
2284
  return { started: false, reason: "record-failed", sessionId, sampled };
1559
2285
  }
1560
2286
  state.stopRecorder = stopRecorder;
@@ -1562,6 +2288,7 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1562
2288
  state.maxDurationTimer = window.setTimeout(() => stopSessionReplay("max-duration"), normalized.maxDurationMs);
1563
2289
  installUrlMonitor(state);
1564
2290
  installLifecycleListeners(state);
2291
+ installSessionReplayIframeBridge(state, normalized);
1565
2292
  state.addCustomEvent =
1566
2293
  typeof rrweb.record.addCustomEvent === "function"
1567
2294
  ? rrweb.record.addCustomEvent
@@ -1582,12 +2309,20 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1582
2309
  // best-effort interceptor teardown
1583
2310
  }
1584
2311
  state.restoreCaptures = null;
2312
+ state.restoreIframeBridge?.();
1585
2313
  state.addCustomEvent = null;
1586
2314
  state.active = false;
1587
2315
  state.options = null;
1588
2316
  state.replayId = null;
1589
2317
  state.startedAtMs = null;
1590
2318
  state.lastAuthenticatedProperties = null;
2319
+ try {
2320
+ state.broadcastChannel?.close();
2321
+ }
2322
+ catch {
2323
+ // best-effort cleanup
2324
+ }
2325
+ state.broadcastChannel = null;
1591
2326
  return { started: false, reason: "record-failed", sessionId, sampled };
1592
2327
  }
1593
2328
  }
@@ -1605,6 +2340,7 @@ export async function stopSessionReplay(reason = "manual") {
1605
2340
  // best-effort interceptor teardown
1606
2341
  }
1607
2342
  state.restoreCaptures = null;
2343
+ state.restoreIframeBridge?.();
1608
2344
  state.active = false;
1609
2345
  state.addCustomEvent = null;
1610
2346
  try {
@@ -1624,6 +2360,13 @@ export async function stopSessionReplay(reason = "manual") {
1624
2360
  }
1625
2361
  state.restoreUrlMonitor?.();
1626
2362
  state.removeLifecycleListeners?.();
2363
+ try {
2364
+ state.broadcastChannel?.close();
2365
+ }
2366
+ catch {
2367
+ // best-effort cleanup
2368
+ }
2369
+ state.broadcastChannel = null;
1627
2370
  await flushSessionReplay(reason);
1628
2371
  }
1629
2372
  export function maybeStartSessionReplay(options = {}) {
@@ -1634,9 +2377,10 @@ export function isSessionReplayActive() {
1634
2377
  }
1635
2378
  /**
1636
2379
  * The active session replay id when a recording is running, or the last one
1637
- * persisted for this analytics session in `localStorage`. First-party error
1638
- * capture uses this to tie each captured exception to the replay it happened
1639
- * in, so triage can jump straight to `/sessions/<recordingId>`.
2380
+ * persisted for this analytics session in this tab's `sessionStorage`.
2381
+ * First-party error capture uses this to tie each captured exception to the
2382
+ * replay it happened in, so triage can jump straight to
2383
+ * `/sessions/<recordingId>`.
1640
2384
  */
1641
2385
  export function getSessionReplayId() {
1642
2386
  const state = getState();