@reticlehq/browser 2.13.1 → 2.14.0

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 (196) hide show
  1. package/dist/actions/actions-dom.d.ts +0 -20
  2. package/dist/actions/actions-dom.js +0 -66
  3. package/dist/actions/actions-press.d.ts +10 -0
  4. package/dist/actions/actions-press.js +77 -0
  5. package/dist/actions/actions.d.ts +0 -89
  6. package/dist/actions/actions.js +24 -358
  7. package/dist/actions/appeared-text.d.ts +0 -20
  8. package/dist/actions/appeared-text.js +0 -36
  9. package/dist/actions/danger-context.d.ts +3 -0
  10. package/dist/actions/danger-context.js +39 -0
  11. package/dist/actions/scroll.d.ts +0 -9
  12. package/dist/actions/scroll.js +0 -7
  13. package/dist/actions/synthetic-input.d.ts +0 -7
  14. package/dist/actions/synthetic-input.js +0 -27
  15. package/dist/actions/value-input.d.ts +0 -30
  16. package/dist/actions/value-input.js +0 -33
  17. package/dist/commands/commands.d.ts +0 -2
  18. package/dist/commands/commands.js +0 -67
  19. package/dist/connect-options.d.ts +2 -96
  20. package/dist/dom/a11y.d.ts +0 -29
  21. package/dist/dom/a11y.js +1 -88
  22. package/dist/dom/auto-anchor.d.ts +0 -40
  23. package/dist/dom/auto-anchor.js +0 -33
  24. package/dist/dom/chart.d.ts +0 -61
  25. package/dist/dom/chart.js +0 -60
  26. package/dist/dom/desktop-capture.d.ts +0 -30
  27. package/dist/dom/desktop-capture.js +0 -72
  28. package/dist/dom/dom-ignore.d.ts +0 -30
  29. package/dist/dom/dom-ignore.js +0 -47
  30. package/dist/dom/occlusion.d.ts +0 -7
  31. package/dist/dom/occlusion.js +0 -7
  32. package/dist/dom/query.d.ts +0 -14
  33. package/dist/dom/query.js +48 -214
  34. package/dist/dom/realm.d.ts +0 -20
  35. package/dist/dom/realm.js +0 -28
  36. package/dist/dom/refs.d.ts +0 -76
  37. package/dist/dom/refs.js +2 -133
  38. package/dist/dom/shadow-registry.d.ts +0 -33
  39. package/dist/dom/shadow-registry.js +2 -72
  40. package/dist/dom/snapshot.d.ts +2 -43
  41. package/dist/dom/snapshot.js +26 -151
  42. package/dist/dom/source.d.ts +0 -44
  43. package/dist/dom/source.js +0 -45
  44. package/dist/dom/theme.d.ts +0 -37
  45. package/dist/dom/theme.js +2 -52
  46. package/dist/dom/virtualized.d.ts +0 -17
  47. package/dist/dom/virtualized.js +0 -27
  48. package/dist/edit-epoch.d.ts +0 -22
  49. package/dist/edit-epoch.js +0 -52
  50. package/dist/index.js +1 -23
  51. package/dist/observers/animation.d.ts +0 -1
  52. package/dist/observers/animation.js +0 -3
  53. package/dist/observers/blind-spots.d.ts +0 -26
  54. package/dist/observers/blind-spots.js +2 -61
  55. package/dist/observers/console.d.ts +1 -2
  56. package/dist/observers/console.js +41 -30
  57. package/dist/observers/context-open.d.ts +1 -11
  58. package/dist/observers/context-open.js +10 -14
  59. package/dist/observers/departure.d.ts +3 -0
  60. package/dist/observers/departure.js +52 -0
  61. package/dist/observers/dialogs.d.ts +2 -0
  62. package/dist/observers/dialogs.js +39 -0
  63. package/dist/observers/dom.js +0 -44
  64. package/dist/observers/download.d.ts +1 -2
  65. package/dist/observers/download.js +38 -55
  66. package/dist/observers/focus.d.ts +0 -6
  67. package/dist/observers/focus.js +0 -7
  68. package/dist/observers/frames.d.ts +0 -9
  69. package/dist/observers/frames.js +1 -28
  70. package/dist/observers/health.d.ts +0 -5
  71. package/dist/observers/health.js +1 -33
  72. package/dist/observers/install-all.d.ts +2 -0
  73. package/dist/observers/install-all.js +26 -27
  74. package/dist/observers/ipc.d.ts +0 -25
  75. package/dist/observers/ipc.js +0 -65
  76. package/dist/observers/navigation.d.ts +2 -0
  77. package/dist/observers/navigation.js +33 -0
  78. package/dist/observers/network-body.d.ts +5 -28
  79. package/dist/observers/network-body.js +11 -108
  80. package/dist/observers/network-redact.d.ts +1 -20
  81. package/dist/observers/network-redact.js +1 -80
  82. package/dist/observers/network-stream.d.ts +0 -21
  83. package/dist/observers/network-stream.js +1 -44
  84. package/dist/observers/network.d.ts +2 -26
  85. package/dist/observers/network.js +177 -294
  86. package/dist/observers/perf.d.ts +0 -10
  87. package/dist/observers/perf.js +0 -16
  88. package/dist/observers/route.d.ts +1 -2
  89. package/dist/observers/route.js +13 -19
  90. package/dist/observers/scroll.d.ts +0 -1
  91. package/dist/observers/scroll.js +0 -9
  92. package/dist/observers/sdk-failure.d.ts +1 -9
  93. package/dist/observers/sdk-failure.js +1 -30
  94. package/dist/observers/state.d.ts +1 -11
  95. package/dist/observers/state.js +13 -41
  96. package/dist/observers/storage.d.ts +0 -14
  97. package/dist/observers/storage.js +10 -34
  98. package/dist/observers/types.d.ts +0 -15
  99. package/dist/observers/types.js +0 -14
  100. package/dist/patching/capture-method.d.ts +0 -24
  101. package/dist/patching/capture-method.js +0 -26
  102. package/dist/presenter/overlay.d.ts +0 -1
  103. package/dist/presenter/overlay.js +0 -1
  104. package/dist/presenter/presenter-brand.d.ts +0 -19
  105. package/dist/presenter/presenter-brand.js +0 -21
  106. package/dist/presenter/presenter-config.d.ts +1 -105
  107. package/dist/presenter/presenter-config.js +2 -91
  108. package/dist/presenter/presenter-controls.d.ts +0 -29
  109. package/dist/presenter/presenter-controls.js +3 -131
  110. package/dist/presenter/presenter-dock-layout.d.ts +0 -3
  111. package/dist/presenter/presenter-dock-layout.js +0 -4
  112. package/dist/presenter/presenter-drag.d.ts +2 -24
  113. package/dist/presenter/presenter-drag.js +0 -26
  114. package/dist/presenter/presenter-effects.d.ts +0 -9
  115. package/dist/presenter/presenter-effects.js +0 -9
  116. package/dist/presenter/presenter-glow.d.ts +0 -21
  117. package/dist/presenter/presenter-glow.js +1 -27
  118. package/dist/presenter/presenter-heroicons-data.d.ts +0 -8
  119. package/dist/presenter/presenter-heroicons-data.js +0 -8
  120. package/dist/presenter/presenter-hud-chrome.d.ts +0 -10
  121. package/dist/presenter/presenter-hud-chrome.js +0 -10
  122. package/dist/presenter/presenter-icons.d.ts +0 -6
  123. package/dist/presenter/presenter-icons.js +0 -6
  124. package/dist/presenter/presenter-log.d.ts +0 -38
  125. package/dist/presenter/presenter-log.js +1 -45
  126. package/dist/presenter/presenter-report-copy.d.ts +5 -66
  127. package/dist/presenter/presenter-report-copy.js +5 -66
  128. package/dist/presenter/presenter-report-styles.d.ts +0 -6
  129. package/dist/presenter/presenter-report-styles.js +6 -6
  130. package/dist/presenter/presenter-report.d.ts +4 -7
  131. package/dist/presenter/presenter-report.js +18 -76
  132. package/dist/presenter/presenter-run-state.d.ts +0 -7
  133. package/dist/presenter/presenter-run-state.js +0 -5
  134. package/dist/presenter/presenter-settings-styles.d.ts +0 -1
  135. package/dist/presenter/presenter-settings-styles.js +0 -1
  136. package/dist/presenter/presenter-settings.d.ts +1 -23
  137. package/dist/presenter/presenter-settings.js +1 -31
  138. package/dist/presenter/presenter-shell-styles.js +2 -5
  139. package/dist/presenter/presenter-shell.d.ts +2 -18
  140. package/dist/presenter/presenter-shell.js +1 -73
  141. package/dist/presenter/presenter-styles.d.ts +0 -9
  142. package/dist/presenter/presenter-styles.js +0 -9
  143. package/dist/presenter/presenter-tally.d.ts +0 -5
  144. package/dist/presenter/presenter-tally.js +0 -4
  145. package/dist/presenter/presenter-test-helpers.d.ts +0 -2
  146. package/dist/presenter/presenter-test-helpers.js +0 -2
  147. package/dist/presenter/presenter-verbs.js +0 -7
  148. package/dist/presenter/presenter-workspace.d.ts +0 -4
  149. package/dist/presenter/presenter-workspace.js +1 -8
  150. package/dist/presenter/presenter.d.ts +0 -60
  151. package/dist/presenter/presenter.js +4 -118
  152. package/dist/recorder/recorder-styles.d.ts +0 -6
  153. package/dist/recorder/recorder-styles.js +0 -6
  154. package/dist/recorder/recorder.d.ts +0 -34
  155. package/dist/recorder/recorder.js +0 -49
  156. package/dist/registry/adapters.d.ts +0 -10
  157. package/dist/registry/adapters.js +0 -10
  158. package/dist/registry/auto-testids.d.ts +0 -19
  159. package/dist/registry/auto-testids.js +1 -21
  160. package/dist/registry/capabilities.d.ts +0 -31
  161. package/dist/registry/capabilities.js +1 -46
  162. package/dist/registry/commit-and-signal.d.ts +0 -6
  163. package/dist/registry/commit-and-signal.js +1 -7
  164. package/dist/registry/domains.d.ts +0 -8
  165. package/dist/registry/domains.js +0 -7
  166. package/dist/registry/emitter.d.ts +0 -14
  167. package/dist/registry/emitter.js +0 -10
  168. package/dist/registry/store-adapters.d.ts +0 -186
  169. package/dist/registry/store-adapters.js +0 -163
  170. package/dist/registry/stores.d.ts +0 -33
  171. package/dist/registry/stores.js +0 -59
  172. package/dist/reticle-presenter-helpers.d.ts +0 -13
  173. package/dist/reticle-presenter-helpers.js +0 -14
  174. package/dist/reticle.d.ts +1 -52
  175. package/dist/reticle.js +20 -145
  176. package/dist/review/annotator-styles.js +0 -7
  177. package/dist/review/annotator.d.ts +2 -13
  178. package/dist/review/annotator.js +0 -40
  179. package/dist/review/mark-anchor.d.ts +0 -11
  180. package/dist/review/mark-anchor.js +0 -8
  181. package/dist/security/serialization.d.ts +0 -20
  182. package/dist/security/serialization.js +1 -66
  183. package/dist/session-continuity.d.ts +0 -16
  184. package/dist/session-continuity.js +1 -20
  185. package/dist/timers/clock.d.ts +1 -14
  186. package/dist/timers/clock.js +59 -60
  187. package/dist/timers/native-console.js +0 -4
  188. package/dist/timers/native-timers.d.ts +0 -25
  189. package/dist/timers/native-timers.js +3 -33
  190. package/dist/transport/transport.d.ts +0 -51
  191. package/dist/transport/transport.js +3 -129
  192. package/dist/transport/unreachable-message.d.ts +0 -17
  193. package/dist/transport/unreachable-message.js +0 -17
  194. package/dist/util/captured-method.d.ts +0 -35
  195. package/dist/util/captured-method.js +0 -35
  196. package/package.json +2 -2
@@ -1,13 +1,11 @@
1
1
  import { BlindSpotKind, EventType, RETICLE_WS_PATH, StreamTransport, StreamDirection, } from '@reticlehq/core';
2
2
  import { captureMethod } from '../patching/capture-method.js';
3
- import { isCapturableType, projectBody, withBodyDeadline } from './network-body.js';
3
+ import { observeSafely, observeValue } from './types.js';
4
+ import { isCapturableType, projectBody, projectErrorBody, shouldCaptureErrorBody, withBodyDeadline, } from './network-body.js';
4
5
  import { redactUrl, netUrlFields } from './network-redact.js';
5
6
  import { watchStreamedBody } from './network-stream.js';
6
7
  import { requireCapturedMethod } from '../util/captured-method.js';
7
- // Redaction moved to its own cohesive module (network.ts is at its line cap); re-exported so callers
8
- // and the existing test suite keep importing it from here.
9
8
  export { redactUrl, netUrlFields };
10
- /** The byte size of a binary frame (ArrayBuffer / Blob / typed-array view), or undefined if unknown. */
11
9
  function binaryFrameBytes(data) {
12
10
  if (data instanceof ArrayBuffer)
13
11
  return data.byteLength;
@@ -17,8 +15,6 @@ function binaryFrameBytes(data) {
17
15
  return data.byteLength;
18
16
  return undefined;
19
17
  }
20
- /** Project one SSE/WebSocket frame: byte size + shape; the (capped, redacted) payload only when body
21
- * capture is on and it is a string frame. Binary frames report their byte size, not just a bare type. */
22
18
  function frameFields(data, captureBodies) {
23
19
  if (typeof data !== 'string') {
24
20
  const bytes = binaryFrameBytes(data);
@@ -35,11 +31,6 @@ function frameFields(data, captureBodies) {
35
31
  }
36
32
  return out;
37
33
  }
38
- /**
39
- * The request body for the transcript. Plain strings and URLSearchParams are captured (redacted, capped);
40
- * FormData/Blob/ArrayBuffer/stream aren't text so they get a `requestBodyType` marker (the agent still
41
- * learns a body existed) rather than being silently dropped. Shared by the fetch and XHR paths.
42
- */
43
34
  function projectRequestBody(body, captureBodies) {
44
35
  if (!captureBodies)
45
36
  return {};
@@ -61,28 +52,9 @@ function projectRequestBody(body, captureBodies) {
61
52
  const { body: out, truncated } = projectBody(text, contentType);
62
53
  return truncated ? { requestBody: out, requestBodyTruncated: true } : { requestBody: out };
63
54
  }
64
- /**
65
- * Did this response succeed? A REDIRECT DID.
66
- *
67
- * The one rule both transports must share. `Response.ok` is true only for 200-299, so reading it
68
- * straight through stamped every 3xx as a failed request, while the XHR path computed the same field
69
- * itself and called the identical status a success. Downstream, `ok` is authoritative when present —
70
- * so a POST-redirect-GET login, the first flow anyone verifies in an app with auth, produced a
71
- * `ui-advanced-request-failed` contradiction citing its own success path, and the agent had to
72
- * overrule the verdict by hand. A verdict that gets manually overruled is not deciding anything.
73
- *
74
- * 3xx is normal successful navigation. An opaque (no-cors) response reports status 0 and is correctly
75
- * excluded here, exactly as `Response.ok` excluded it.
76
- */
77
55
  function statusIsOk(status) {
78
56
  return status >= 200 && status < 400;
79
57
  }
80
- /**
81
- * Response metadata that needs no body capture: HTTP status text, content-type, and byte size (from
82
- * content-length when the server sent it). Lets an agent tell an HTML error page served as 200 from
83
- * real JSON, and spot empty/oversized responses. Fields are omitted when absent so a clean call stays
84
- * token-flat.
85
- */
86
58
  function netResponseMeta(statusText, contentType, contentLength) {
87
59
  const out = {};
88
60
  if (statusText !== '')
@@ -108,41 +80,22 @@ function methodOf(input, init) {
108
80
  return input.method.toUpperCase();
109
81
  return 'GET';
110
82
  }
111
- /**
112
- * The first app-code frame that fired a request — the in-page answer to a CDP initiator, as file:line.
113
- * Captured from a fresh Error.stack at call time, skipping Reticle's own wrapper frames. Feeds the
114
- * causal chain (which code path made this request). Capped; undefined when no stack is available.
115
- * ponytail: one stack unwind per request — cheap next to fetch itself; revisit only if a profiler flags it.
116
- */
117
- /**
118
- * Frames to skip: Reticle's own wrappers + engine-internal frames with no app source location.
119
- * Anchored to Reticle's actual module paths (`@reticlehq/…`, the SDK's own `.ts` files) — a bare
120
- * `reticle` alternative matched ANY app whose bundle URL merely contained the word (including this
121
- * repo's own dogfood fixtures served under `/reticle/`), silently dropping their initiatorStack.
122
- */
123
83
  const NON_APP_FRAME = /@reticlehq|reticle\.ts|network\.ts|transport\.ts|<anonymous>|new Promise|node:internal/i;
124
- /** Pure: the first real app-code frame in a stack string, capped. Exported for unit testing. */
84
+ const DEPENDENCY_FRAME = /node_modules|\/\.vite\/deps\//i;
125
85
  export function firstAppFrame(stack) {
126
86
  if (stack === undefined)
127
87
  return undefined;
128
- for (const line of stack.split('\n').slice(1)) {
129
- if (NON_APP_FRAME.test(line))
130
- continue;
131
- const trimmed = line.trim();
132
- if (0 === trimmed.length)
133
- continue;
134
- return trimmed.slice(0, 300);
135
- }
136
- return undefined;
88
+ const frames = stack
89
+ .split('\n')
90
+ .slice(1)
91
+ .map((line) => line.trim())
92
+ .filter((line) => line.length > 0 && !NON_APP_FRAME.test(line));
93
+ const app = frames.find((line) => !DEPENDENCY_FRAME.test(line));
94
+ return (app ?? frames[0])?.slice(0, 300);
137
95
  }
138
96
  function initiatorFrame() {
139
97
  return firstAppFrame(new Error().stack);
140
98
  }
141
- /**
142
- * Pure: derive TTFB + transfer size from a PerformanceResourceTiming entry. TTFB = responseStart -
143
- * requestStart; cross-origin responses zero those (Timing-Allow-Origin), so we omit rather than report a
144
- * bogus 0. Exported for unit testing (jsdom doesn't post real resource entries).
145
- */
146
99
  export function extractTiming(entry) {
147
100
  if (entry === undefined)
148
101
  return {};
@@ -154,7 +107,6 @@ export function extractTiming(entry) {
154
107
  timing.transferSize = entry.transferSize;
155
108
  return timing;
156
109
  }
157
- /** Best-effort lookup of the most-recent resource-timing entry for a URL. */
158
110
  function resourceTiming(rawUrl) {
159
111
  try {
160
112
  const entries = performance.getEntriesByName(rawUrl, 'resource');
@@ -164,16 +116,6 @@ function resourceTiming(rawUrl) {
164
116
  return {};
165
117
  }
166
118
  }
167
- /** Patch fetch + XMLHttpRequest to emit net.request events. Fully reversible. */
168
- /**
169
- * True for Reticle's OWN bridge connection, which must never be instrumented.
170
- *
171
- * The transport resolves `WebSocket` at call time, so a reconnect after instrumentation constructs a
172
- * PATCHED socket. From then on emitting an event calls transport.send -> the patched send emits a
173
- * NET_STREAM event -> which calls transport.send again, until the stack blows and the page is dead.
174
- * Keyed on the bridge PATH rather than the host: an app's own socket on the same origin must still be
175
- * observed, otherwise this guard would blind the stream category it exists to protect.
176
- */
177
119
  function isBridgeSocket(url) {
178
120
  try {
179
121
  return new URL(url, location.href).pathname === RETICLE_WS_PATH;
@@ -182,21 +124,6 @@ function isBridgeSocket(url) {
182
124
  return url.includes(RETICLE_WS_PATH);
183
125
  }
184
126
  }
185
- /**
186
- * Whether a function is the platform's own fetch rather than someone's wrapper.
187
- *
188
- * `Function.prototype.toString` on a built-in yields "[native code]"; a JS wrapper yields its source.
189
- * Called off the prototype deliberately, so a wrapper cannot hide behind its own `toString`.
190
- *
191
- * Two known limits, both stated in tests rather than left to be discovered:
192
- * - a BOUND wrapper (`window.fetch = mine.bind(x)`) also reports "[native code]", so it is missed.
193
- * Nothing in the platform separates the two.
194
- * - a POLYFILLED fetch reads as wrapped and IS reported — correctly, since a polyfill is a layer we
195
- * cannot see through, though it will look like a false positive to anyone triaging it.
196
- *
197
- * Both failure modes are safe: we under-report on the first and over-report on the second, and
198
- * over-reporting a coverage caveat is the direction this library errs in everywhere else.
199
- */
200
127
  function isNativeFetch(fn) {
201
128
  try {
202
129
  return Function.prototype.toString.call(fn).includes('native code');
@@ -205,40 +132,20 @@ function isNativeFetch(fn) {
205
132
  return false;
206
133
  }
207
134
  }
208
- /** Wrappers this module installed, so a re-install does not report itself as a foreign wrapper. */
209
135
  const OURS = new WeakSet();
210
136
  export function installNetwork(emit, opts = {}) {
211
137
  const captureBodies = true === opts.captureBodies;
138
+ const captureErrorBodies = false !== opts.captureErrorBodies;
212
139
  const reinterpret = opts.reinterpret;
213
- // Keep the true original for teardown identity, plus a window-bound copy to invoke
214
- // (fetch throws "Illegal invocation" if called with the wrong `this`).
215
140
  const origFetch = requireCapturedMethod(window, 'fetch');
216
141
  const callFetch = origFetch.bind(window);
217
- // Declare it if we are not the first to wrap fetch.
218
- //
219
- // Wrappers chain outermost-first, and we record `init.body` when OUR wrapper runs — so anything
220
- // installed EARLIER sits below us and mutates the request after we have read it. That is a real
221
- // blind spot (an auth/analytics interceptor started before connect(), a polyfill, a service worker
222
- // is worse still) and it is not fixable from in here: there is no "patch last" primitive, and
223
- // racing for outermost loses to whatever loads after us.
224
- //
225
- // So it is reported rather than hidden, on the same contract as the cross-origin-iframe sensor —
226
- // say what we cannot see, so a green verdict never implies we saw it. `isOurs` keeps a re-install
227
- // from blaming the app for our own wrapper.
228
142
  if (!isNativeFetch(origFetch) && !OURS.has(origFetch)) {
229
143
  emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.WRAPPED_NETWORK, count: 1 });
230
144
  }
231
- // Correlation id so a NET_PENDING (emitted at request START) can be matched to its
232
- // NET_REQUEST completion. A request that never completes leaves an unmatched NET_PENDING —
233
- // that is how a hung/in-flight request becomes observable (it never resolves, so the old
234
- // completion-only emit saw nothing).
235
145
  let seq = 0;
236
146
  const nextId = () => `n${++seq}`;
237
147
  window.fetch = async (input, init) => {
238
148
  const rawUrl = urlOf(input);
239
- // Reticle's own call: pass it straight through, observed by nobody. Emitting even the PENDING
240
- // half would be wrong twice over — it lands in the agent's network evidence, and `settle` would
241
- // wait on the SDK's own screenshot as if the app had a request in flight.
242
149
  if (true === opts.ignore?.(rawUrl))
243
150
  return callFetch(input, init);
244
151
  const id = nextId();
@@ -246,86 +153,75 @@ export function installNetwork(emit, opts = {}) {
246
153
  const method = methodOf(input, init);
247
154
  const urlFields = netUrlFields(rawUrl);
248
155
  const url = urlFields.url;
249
- const initiatorStack = initiatorFrame();
156
+ const initiatorStack = observeValue(() => initiatorFrame());
250
157
  const initiatorFields = initiatorStack === undefined ? {} : { initiatorStack };
251
- emit(EventType.NET_PENDING, {
252
- id,
253
- method,
254
- ...urlFields,
255
- initiator: 'fetch',
256
- ...initiatorFields,
158
+ observeSafely(() => {
159
+ emit(EventType.NET_PENDING, {
160
+ id,
161
+ method,
162
+ ...urlFields,
163
+ initiator: 'fetch',
164
+ ...initiatorFields,
165
+ });
257
166
  });
258
167
  try {
259
168
  const res = await callFetch(input, init);
260
- // The app's fetch resolves HERE — at headers — like a native fetch. durationMs is measured to
261
- // headers-received, so it stays honest whether or not we read the body.
262
169
  const headersAt = performance.now();
263
- const contentType = res.headers.get('content-type');
264
- // The request is done; the BODY may not be. Watch it so settle cannot pass mid-stream.
265
- watchStreamedBody(emit, res, id, url, contentType, res.headers.get('content-length'));
170
+ const contentType = observeValue(() => res.headers.get('content-type')) ?? null;
171
+ observeSafely(() => {
172
+ watchStreamedBody(emit, res, id, url, contentType, res.headers.get('content-length'));
173
+ });
266
174
  reportedNetUrls.add(rawUrl);
267
175
  const emitRequest = (responseBodyFields) => {
268
- emit(EventType.NET_REQUEST, {
269
- id,
270
- method,
271
- ...urlFields,
272
- status: res.status,
273
- ok: statusIsOk(res.status),
274
- durationMs: Math.round(headersAt - start),
275
- initiator: 'fetch',
276
- ...initiatorFields,
277
- ...resourceTiming(rawUrl),
278
- ...netResponseMeta(res.statusText, contentType, res.headers.get('content-length')),
279
- ...projectRequestBody(init?.body, captureBodies),
280
- ...responseBodyFields,
281
- // Applied LAST so a reinterpreted verdict wins over the transport's own fields — a Tauri
282
- // command that returned Err still travelled down a fetch that answered HTTP 200.
283
- ...(reinterpret?.(url, (name) => res.headers.get(name)) ?? {}),
176
+ observeSafely(() => {
177
+ emit(EventType.NET_REQUEST, {
178
+ id,
179
+ method,
180
+ ...urlFields,
181
+ status: res.status,
182
+ ok: statusIsOk(res.status),
183
+ durationMs: Math.round(headersAt - start),
184
+ initiator: 'fetch',
185
+ ...initiatorFields,
186
+ ...resourceTiming(rawUrl),
187
+ ...netResponseMeta(res.statusText, contentType, res.headers.get('content-length')),
188
+ ...projectRequestBody(init?.body, captureBodies),
189
+ ...responseBodyFields,
190
+ ...(reinterpret?.(url, (name) => res.headers.get(name)) ?? {}),
191
+ });
284
192
  });
285
193
  };
286
- if (captureBodies && isCapturableType(contentType)) {
287
- // ONLY the body read can make the app wait (a chunked response with no content-length can be
288
- // arbitrarily long). Clone synchronously so the app's stream is untouched, then read + emit from
289
- // a DETACHED promise — the app already has res, so our bounded read is invisible to it. Without
290
- // body capture (the DEFAULT) we emit synchronously below, exactly as before: no latency, no
291
- // deferral, NET_REQUEST in order.
292
- //
293
- // Tradeoff, opt-in path only: under captureBodies the NET_REQUEST for THIS call lands after the
294
- // bounded body read (≤ the deadline), so it can arrive out of order vs a later request's
295
- // NET_PENDING, and a BARE `assert { net, count }` fired the instant the app's fetch resolves may
296
- // see the count not-yet-incremented. Use `wait_for` for count/settle assertions when body
297
- // capture is on (it already polls until the event lands). The default path has neither caveat.
194
+ const wantErrorBody = shouldCaptureErrorBody(res.status, captureErrorBodies);
195
+ if ((captureBodies || wantErrorBody) && isCapturableType(contentType)) {
298
196
  let clone;
299
197
  try {
300
198
  clone = res.clone();
301
199
  }
302
200
  catch {
303
- /* already consumed/locked — emit the envelope with no body */
304
201
  }
305
- // The whole IIFE is guarded: emit is the SDK's already-try/catch'd sink today, but a detached
306
- // promise must never be able to surface an unhandledrejection into the host page even if a
307
- // future caller passes a throwing emit. A body we can't read is simply dropped.
308
202
  void (async () => {
309
203
  let responseBodyFields = {};
310
204
  if (clone !== undefined) {
311
205
  try {
312
206
  const text = await withBodyDeadline(clone.text());
313
207
  if (text !== undefined) {
314
- const { body, truncated } = projectBody(text, contentType);
315
- responseBodyFields = truncated
316
- ? { responseBody: body, responseBodyTruncated: true }
317
- : { responseBody: body };
208
+ const { body, truncated } = captureBodies
209
+ ? projectBody(text, contentType)
210
+ : projectErrorBody(text, contentType);
211
+ responseBodyFields = {
212
+ responseBody: body,
213
+ ...(truncated ? { responseBodyTruncated: true } : {}),
214
+ ...(captureBodies ? {} : { responseBodyReason: 'error-status' }),
215
+ };
318
216
  }
319
217
  }
320
218
  catch {
321
- /* body not readable — skip, keep the envelope */
322
219
  }
323
220
  }
324
221
  try {
325
222
  emitRequest(responseBodyFields);
326
223
  }
327
224
  catch {
328
- /* observation is best-effort; never reject into the page */
329
225
  }
330
226
  })().catch(() => undefined);
331
227
  }
@@ -335,22 +231,22 @@ export function installNetwork(emit, opts = {}) {
335
231
  return res;
336
232
  }
337
233
  catch (error) {
338
- emit(EventType.NET_REQUEST, {
339
- id,
340
- method,
341
- url,
342
- status: 0,
343
- ok: false,
344
- error: error instanceof Error ? error.message : String(error),
345
- durationMs: Math.round(performance.now() - start),
346
- initiator: 'fetch',
347
- ...initiatorFields,
234
+ observeSafely(() => {
235
+ emit(EventType.NET_REQUEST, {
236
+ id,
237
+ method,
238
+ url,
239
+ status: 0,
240
+ ok: false,
241
+ error: error instanceof Error ? error.message : String(error),
242
+ durationMs: Math.round(performance.now() - start),
243
+ initiator: 'fetch',
244
+ ...initiatorFields,
245
+ });
348
246
  });
349
247
  throw error;
350
248
  }
351
249
  };
352
- // Remember it, so a later install recognises our own wrapper instead of reporting the app.
353
- // Our own wrapper, read the same way — it is a value we stored a line ago, not a method call.
354
250
  const patchedFetch = requireCapturedMethod(window, 'fetch');
355
251
  OURS.add(patchedFetch);
356
252
  const meta = new WeakMap();
@@ -359,78 +255,84 @@ export function installNetwork(emit, opts = {}) {
359
255
  const origSend = captureMethod(proto, 'send');
360
256
  const callOpen = origOpen;
361
257
  proto.open = function (method, url, ...rest) {
362
- meta.set(this, {
363
- id: nextId(),
364
- method: method.toUpperCase(),
365
- url: redactUrl(String(url)),
366
- rawUrl: String(url),
367
- start: 0,
258
+ observeSafely(() => {
259
+ meta.set(this, {
260
+ id: nextId(),
261
+ method: method.toUpperCase(),
262
+ url: redactUrl(String(url)),
263
+ rawUrl: String(url),
264
+ start: 0,
265
+ });
368
266
  });
369
267
  callOpen.call(this, method, url, ...rest);
370
268
  };
371
269
  const patchedOpen = captureMethod(proto, 'open');
372
- // A reused XHR calls send repeatedly; attach the completion listener ONCE per instance and read the
373
- // request identity from `meta` at fire time. Adding a fresh closure each send would leave stale
374
- // listeners that re-fire on later completions, emitting duplicate, mislabeled events.
375
270
  const listenerAttached = new WeakSet();
376
271
  proto.send = function (body) {
377
- const m = meta.get(this);
378
- if (m !== undefined) {
379
- m.start = performance.now();
380
- m.reqBody = body ?? null;
381
- m.initiatorStack = initiatorFrame(); // the app's xhr.send call site
382
- const initiatorFields = m.initiatorStack === undefined ? {} : { initiatorStack: m.initiatorStack };
383
- emit(EventType.NET_PENDING, {
384
- id: m.id,
385
- method: m.method,
386
- ...netUrlFields(m.rawUrl),
387
- initiator: 'xhr',
388
- ...initiatorFields,
389
- });
390
- if (!listenerAttached.has(this)) {
391
- listenerAttached.add(this);
392
- this.addEventListener('loadend', () => {
393
- const cur = meta.get(this);
394
- if (cur === undefined)
395
- return;
396
- reportedNetUrls.add(cur.rawUrl);
397
- const xhrContentType = this.getResponseHeader('content-type');
398
- let responseBodyFields = {};
399
- // responseText throws unless responseType is '' or 'text' — guard before reading.
400
- const textReadable = '' === this.responseType || 'text' === this.responseType;
401
- if (captureBodies && textReadable && isCapturableType(xhrContentType)) {
402
- try {
403
- const { body: rb, truncated } = projectBody(this.responseText, xhrContentType);
404
- responseBodyFields = truncated
405
- ? { responseBody: rb, responseBodyTruncated: true }
406
- : { responseBody: rb };
407
- }
408
- catch {
409
- /* unreadable body — skip */
410
- }
411
- }
412
- emit(EventType.NET_REQUEST, {
413
- id: cur.id,
414
- method: cur.method,
415
- ...netUrlFields(cur.rawUrl),
416
- status: this.status,
417
- ok: statusIsOk(this.status),
418
- durationMs: Math.round(performance.now() - cur.start),
419
- initiator: 'xhr',
420
- ...(cur.initiatorStack === undefined ? {} : { initiatorStack: cur.initiatorStack }),
421
- ...resourceTiming(cur.rawUrl),
422
- ...netResponseMeta(this.statusText, xhrContentType, this.getResponseHeader('content-length')),
423
- ...projectRequestBody(cur.reqBody, captureBodies),
424
- ...responseBodyFields,
425
- });
272
+ observeSafely(() => {
273
+ const m = meta.get(this);
274
+ if (m !== undefined) {
275
+ m.start = performance.now();
276
+ m.reqBody = body ?? null;
277
+ m.initiatorStack = initiatorFrame();
278
+ const initiatorFields = m.initiatorStack === undefined ? {} : { initiatorStack: m.initiatorStack };
279
+ emit(EventType.NET_PENDING, {
280
+ id: m.id,
281
+ method: m.method,
282
+ ...netUrlFields(m.rawUrl),
283
+ initiator: 'xhr',
284
+ ...initiatorFields,
426
285
  });
286
+ if (!listenerAttached.has(this)) {
287
+ listenerAttached.add(this);
288
+ this.addEventListener('loadend', () => {
289
+ observeSafely(() => {
290
+ const cur = meta.get(this);
291
+ if (cur === undefined)
292
+ return;
293
+ reportedNetUrls.add(cur.rawUrl);
294
+ const xhrContentType = this.getResponseHeader('content-type');
295
+ let responseBodyFields = {};
296
+ const textReadable = '' === this.responseType || 'text' === this.responseType;
297
+ const wantXhrErrorBody = shouldCaptureErrorBody(this.status, captureErrorBodies);
298
+ if ((captureBodies || wantXhrErrorBody) &&
299
+ textReadable &&
300
+ isCapturableType(xhrContentType)) {
301
+ try {
302
+ const { body: rb, truncated } = captureBodies
303
+ ? projectBody(this.responseText, xhrContentType)
304
+ : projectErrorBody(this.responseText, xhrContentType);
305
+ responseBodyFields = {
306
+ responseBody: rb,
307
+ ...(truncated ? { responseBodyTruncated: true } : {}),
308
+ ...(captureBodies ? {} : { responseBodyReason: 'error-status' }),
309
+ };
310
+ }
311
+ catch {
312
+ }
313
+ }
314
+ emit(EventType.NET_REQUEST, {
315
+ id: cur.id,
316
+ method: cur.method,
317
+ ...netUrlFields(cur.rawUrl),
318
+ status: this.status,
319
+ ok: statusIsOk(this.status),
320
+ durationMs: Math.round(performance.now() - cur.start),
321
+ initiator: 'xhr',
322
+ ...(cur.initiatorStack === undefined ? {} : { initiatorStack: cur.initiatorStack }),
323
+ ...resourceTiming(cur.rawUrl),
324
+ ...netResponseMeta(this.statusText, xhrContentType, this.getResponseHeader('content-length')),
325
+ ...projectRequestBody(cur.reqBody, captureBodies),
326
+ ...responseBodyFields,
327
+ });
328
+ });
329
+ });
330
+ }
427
331
  }
428
- }
332
+ });
429
333
  origSend.call(this, body ?? null);
430
334
  };
431
335
  const patchedSend = captureMethod(proto, 'send');
432
- // SSE + WebSocket frame capture — gated behind body capture, since a chatty stream is the
433
- // high-volume case. Subclass the native constructors so the app's own usage is unchanged.
434
336
  const origEventSource = window.EventSource;
435
337
  const origWebSocket = window.WebSocket;
436
338
  let patchedEventSource;
@@ -439,18 +341,22 @@ export function installNetwork(emit, opts = {}) {
439
341
  window.EventSource = class extends origEventSource {
440
342
  constructor(u, init) {
441
343
  super(u, init);
442
- const urlFields = netUrlFields(String(u));
443
- emit(EventType.NET_STREAM, {
444
- transport: StreamTransport.SSE,
445
- direction: StreamDirection.OPEN,
446
- ...urlFields,
447
- });
448
- this.addEventListener('message', (ev) => {
344
+ observeSafely(() => {
345
+ const urlFields = netUrlFields(String(u));
449
346
  emit(EventType.NET_STREAM, {
450
347
  transport: StreamTransport.SSE,
451
- direction: StreamDirection.IN,
348
+ direction: StreamDirection.OPEN,
452
349
  ...urlFields,
453
- ...frameFields(ev.data, captureBodies),
350
+ });
351
+ this.addEventListener('message', (ev) => {
352
+ observeSafely(() => {
353
+ emit(EventType.NET_STREAM, {
354
+ transport: StreamTransport.SSE,
355
+ direction: StreamDirection.IN,
356
+ ...urlFields,
357
+ ...frameFields(ev.data, captureBodies),
358
+ });
359
+ });
454
360
  });
455
361
  });
456
362
  }
@@ -459,89 +365,75 @@ export function installNetwork(emit, opts = {}) {
459
365
  }
460
366
  if (captureBodies && 'function' === typeof origWebSocket) {
461
367
  window.WebSocket = class extends origWebSocket {
462
- /** Reticle's own bridge socket is never observed — see isBridgeSocket. */
463
368
  #isBridge;
464
369
  constructor(u, protocols) {
465
370
  super(u, protocols);
466
371
  this.#isBridge = isBridgeSocket(String(u));
467
372
  if (this.#isBridge)
468
373
  return;
469
- const urlFields = netUrlFields(String(u));
470
- emit(EventType.NET_STREAM, {
471
- transport: StreamTransport.WS,
472
- direction: StreamDirection.OPEN,
473
- ...urlFields,
474
- });
475
- this.addEventListener('message', (ev) => {
374
+ observeSafely(() => {
375
+ const urlFields = netUrlFields(String(u));
476
376
  emit(EventType.NET_STREAM, {
477
377
  transport: StreamTransport.WS,
478
- direction: StreamDirection.IN,
378
+ direction: StreamDirection.OPEN,
479
379
  ...urlFields,
480
- ...frameFields(ev.data, captureBodies),
380
+ });
381
+ this.addEventListener('message', (ev) => {
382
+ observeSafely(() => {
383
+ emit(EventType.NET_STREAM, {
384
+ transport: StreamTransport.WS,
385
+ direction: StreamDirection.IN,
386
+ ...urlFields,
387
+ ...frameFields(ev.data, captureBodies),
388
+ });
389
+ });
481
390
  });
482
391
  });
483
392
  }
484
- // `override` is required, not decorative: this shadows WebSocket.prototype.send, and without
485
- // the keyword a rename or signature drift in the base class would silently turn this from an
486
- // override into a NEW method — the observer would stop intercepting and report no WebSocket
487
- // traffic at all, which reads as "the app sends none". Caught by noImplicitOverride.
488
393
  send(data) {
489
394
  if (this.#isBridge) {
490
395
  super.send(data);
491
396
  return;
492
397
  }
493
- emit(EventType.NET_STREAM, {
494
- transport: StreamTransport.WS,
495
- direction: StreamDirection.OUT,
496
- ...netUrlFields(this.url),
497
- ...frameFields(data, captureBodies),
498
- });
499
398
  super.send(data);
399
+ observeSafely(() => {
400
+ emit(EventType.NET_STREAM, {
401
+ transport: StreamTransport.WS,
402
+ direction: StreamDirection.OUT,
403
+ ...netUrlFields(this.url),
404
+ ...frameFields(data, captureBodies),
405
+ });
406
+ });
500
407
  }
501
408
  };
502
409
  patchedWebSocket = window.WebSocket;
503
410
  }
504
- // navigator.sendBeacon — fire-and-forget analytics/telemetry, invisible to fetch/XHR wrapping. A page
505
- // that beacons a "checkout completed" event should show it. Patch the instance's prototype (via
506
- // getPrototypeOf, robust whether or not the Navigator global exists); the descriptor read avoids an
507
- // unbound-method access; the send is synchronous so we emit one completed NET_REQUEST with its result.
508
411
  const navProto = (typeof navigator !== 'undefined' ? Object.getPrototypeOf(navigator) : null);
509
412
  const origBeacon = (null === navProto ? undefined : Object.getOwnPropertyDescriptor(navProto, 'sendBeacon')?.value);
510
413
  let patchedBeacon;
511
414
  if (navProto !== null && origBeacon !== undefined) {
512
415
  patchedBeacon = function (url, data) {
513
416
  const id = nextId();
514
- const urlFields = netUrlFields(String(url));
515
- const initiatorStack = initiatorFrame();
417
+ const urlFields = observeValue(() => netUrlFields(String(url)));
418
+ const initiatorStack = observeValue(() => initiatorFrame());
516
419
  const sent = origBeacon.call(this, url, data);
517
- // sendBeacon returns whether the payload was QUEUED, not an HTTP result — the response never
518
- // surfaces to JS. Fabricating status 200 lied to an agent asserting on status. Report status 0
519
- // (no HTTP response observed) and carry the real signal in `queued`.
520
- emit(EventType.NET_REQUEST, {
521
- id,
522
- method: 'POST',
523
- ...urlFields,
524
- status: 0,
525
- ok: sent,
526
- queued: sent,
527
- durationMs: 0,
528
- initiator: 'beacon',
529
- ...(initiatorStack === undefined ? {} : { initiatorStack }),
420
+ observeSafely(() => {
421
+ emit(EventType.NET_REQUEST, {
422
+ id,
423
+ method: 'POST',
424
+ ...urlFields,
425
+ status: 0,
426
+ ok: sent,
427
+ queued: sent,
428
+ durationMs: 0,
429
+ initiator: 'beacon',
430
+ ...(initiatorStack === undefined ? {} : { initiatorStack }),
431
+ });
530
432
  });
531
433
  return sent;
532
434
  };
533
435
  navProto.sendBeacon = patchedBeacon;
534
436
  }
535
- // Document-initiated subresources (link/css/img/script/manifest): fetch and XHR patches never see
536
- // these, so a `{net}` predicate over a favicon or manifest used to read `assertion_failed` — "your
537
- // change is broken" — when the truthful answer is "not observable". A PerformanceObserver over
538
- // `resource` entries reports them with no CDP. Status is the known gap: entries carry one only on
539
- // newer Chromium, so the field is emitted ONLY when readable and the evaluation seam downgrades
540
- // status assertions it cannot verify instead of guessing.
541
- //
542
- // Dedup: any URL the patched transports already reported (or later report) is skipped — resource
543
- // timing also records fetch/XHR loads. The seen-set is filled eagerly here AND at every
544
- // NET_REQUEST emit below, so both orders of arrival stay single-reported.
545
437
  const reportedNetUrls = new Set();
546
438
  let subresourceEvents = 0;
547
439
  const SUBRESOURCE_CAP = 200;
@@ -554,7 +446,6 @@ export function installNetwork(emit, opts = {}) {
554
446
  if (subresourceEvents >= SUBRESOURCE_CAP)
555
447
  return;
556
448
  const type = entry.initiatorType || 'other';
557
- // Patched transports own their records; only document-initiated types are new here.
558
449
  if ('fetch' === type || 'xmlhttprequest' === type)
559
450
  continue;
560
451
  const rawUrl = entry.name;
@@ -569,8 +460,6 @@ export function installNetwork(emit, opts = {}) {
569
460
  durationMs: Math.round(entry.duration),
570
461
  initiator: type,
571
462
  ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
572
- // responseStatus is Chromium-only; omitted entirely when unreadable so the wire never
573
- // carries a guessed status. Its absence is what the server-side seam reads as unknown.
574
463
  ...((entry.responseStatus ?? 0) > 0
575
464
  ? {
576
465
  status: entry.responseStatus,
@@ -582,18 +471,12 @@ export function installNetwork(emit, opts = {}) {
582
471
  });
583
472
  subresourceObserver = observer;
584
473
  observer.observe({ type: 'resource', buffered: true });
585
- // Disconnected in the disposer below.
586
474
  }
587
475
  catch {
588
- // No observer support: document-initiated loads stay unobserved, stated rather than guessed.
589
476
  }
590
477
  }
591
478
  return () => {
592
479
  subresourceObserver?.disconnect();
593
- // Restore each slot ONLY if it still holds OUR wrapper. Between connect() and disconnect() the app
594
- // (or Sentry/analytics/a router) may have wrapped fetch/XHR/EventSource/WebSocket/sendBeacon ON TOP
595
- // of ours; blindly writing the original back would silently uninstall their instrumentation — the
596
- // dev-only SDK breaking the app it only meant to observe.
597
480
  if (window.fetch === patchedFetch)
598
481
  window.fetch = origFetch;
599
482
  if (captureMethod(proto, 'open') === patchedOpen)