@reticlehq/browser 2.13.1 → 3.1.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 (244) hide show
  1. package/dist/actions/actions-dom.d.ts +8 -20
  2. package/dist/actions/actions-dom.js +70 -69
  3. package/dist/actions/actions-press.d.ts +14 -0
  4. package/dist/actions/actions-press.js +123 -0
  5. package/dist/actions/actions.d.ts +1 -89
  6. package/dist/actions/actions.js +64 -365
  7. package/dist/actions/appeared-text.d.ts +0 -20
  8. package/dist/actions/appeared-text.js +6 -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 +8 -10
  12. package/dist/actions/scroll.js +28 -17
  13. package/dist/actions/synthetic/synthetic-input.d.ts +2 -0
  14. package/dist/actions/synthetic/synthetic-input.js +13 -0
  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 +10 -70
  19. package/dist/connect-options.d.ts +2 -96
  20. package/dist/dom/a11y.d.ts +0 -29
  21. package/dist/dom/a11y.js +3 -90
  22. package/dist/dom/addressing/auto-anchor.d.ts +25 -0
  23. package/dist/dom/addressing/auto-anchor.js +41 -0
  24. package/dist/dom/addressing/refs.d.ts +14 -0
  25. package/dist/dom/addressing/refs.js +114 -0
  26. package/dist/dom/addressing/source.d.ts +9 -0
  27. package/dist/dom/addressing/source.js +29 -0
  28. package/dist/dom/chart.d.ts +0 -61
  29. package/dist/dom/chart.js +0 -60
  30. package/dist/dom/desktop-capture.d.ts +0 -30
  31. package/dist/dom/desktop-capture.js +1 -73
  32. package/dist/dom/dom-ignore.d.ts +0 -30
  33. package/dist/dom/dom-ignore.js +1 -48
  34. package/dist/dom/occlusion.d.ts +0 -7
  35. package/dist/dom/occlusion.js +31 -12
  36. package/dist/dom/paint-context.d.ts +6 -0
  37. package/dist/dom/paint-context.js +38 -0
  38. package/dist/dom/query.d.ts +0 -14
  39. package/dist/dom/query.js +50 -216
  40. package/dist/dom/realm.d.ts +0 -20
  41. package/dist/dom/realm.js +0 -28
  42. package/dist/dom/shadow-registry.d.ts +0 -33
  43. package/dist/dom/shadow-registry.js +2 -72
  44. package/dist/dom/snapshot.d.ts +2 -43
  45. package/dist/dom/snapshot.js +27 -152
  46. package/dist/dom/theme.d.ts +0 -37
  47. package/dist/dom/theme.js +2 -52
  48. package/dist/dom/virtualized.d.ts +0 -17
  49. package/dist/dom/virtualized.js +0 -27
  50. package/dist/edit-epoch.d.ts +1 -23
  51. package/dist/edit-epoch.js +1 -53
  52. package/dist/index.d.ts +4 -5
  53. package/dist/index.js +4 -28
  54. package/dist/observers/animation.d.ts +0 -1
  55. package/dist/observers/animation.js +1 -4
  56. package/dist/observers/blind-spots.d.ts +0 -26
  57. package/dist/observers/blind-spots.js +3 -62
  58. package/dist/observers/console.d.ts +1 -2
  59. package/dist/observers/console.js +41 -30
  60. package/dist/observers/context-open.d.ts +1 -11
  61. package/dist/observers/context-open.js +10 -14
  62. package/dist/observers/departure.d.ts +3 -0
  63. package/dist/observers/departure.js +52 -0
  64. package/dist/observers/dialogs.d.ts +2 -0
  65. package/dist/observers/dialogs.js +39 -0
  66. package/dist/observers/dom.js +28 -52
  67. package/dist/observers/download.d.ts +1 -2
  68. package/dist/observers/download.js +39 -56
  69. package/dist/observers/focus.d.ts +0 -6
  70. package/dist/observers/focus.js +0 -7
  71. package/dist/observers/frames.d.ts +0 -9
  72. package/dist/observers/frames.js +1 -28
  73. package/dist/observers/health.d.ts +0 -5
  74. package/dist/observers/health.js +2 -34
  75. package/dist/observers/install-all.d.ts +2 -0
  76. package/dist/observers/install-all.js +26 -27
  77. package/dist/observers/ipc.d.ts +0 -25
  78. package/dist/observers/ipc.js +0 -65
  79. package/dist/observers/navigation.d.ts +2 -0
  80. package/dist/observers/navigation.js +33 -0
  81. package/dist/observers/net-detail/network-body.d.ts +12 -0
  82. package/dist/observers/net-detail/network-body.js +71 -0
  83. package/dist/observers/net-detail/network-redact.d.ts +1 -0
  84. package/dist/observers/net-detail/network-redact.js +1 -0
  85. package/dist/observers/network-stream.d.ts +0 -21
  86. package/dist/observers/network-stream.js +3 -46
  87. package/dist/observers/network.d.ts +3 -27
  88. package/dist/observers/network.js +178 -295
  89. package/dist/observers/perf.d.ts +0 -10
  90. package/dist/observers/perf.js +0 -16
  91. package/dist/observers/route.d.ts +1 -2
  92. package/dist/observers/route.js +13 -19
  93. package/dist/observers/scroll.d.ts +0 -1
  94. package/dist/observers/scroll.js +2 -11
  95. package/dist/observers/sdk-failure.d.ts +1 -9
  96. package/dist/observers/sdk-failure.js +1 -30
  97. package/dist/observers/state.d.ts +1 -11
  98. package/dist/observers/state.js +13 -41
  99. package/dist/observers/storage.d.ts +0 -14
  100. package/dist/observers/storage.js +10 -34
  101. package/dist/observers/types.d.ts +0 -15
  102. package/dist/observers/types.js +0 -14
  103. package/dist/patching/capture-method.d.ts +0 -24
  104. package/dist/patching/capture-method.js +0 -26
  105. package/dist/presenter/chrome/layers.d.ts +2 -0
  106. package/dist/presenter/chrome/layers.js +2 -0
  107. package/dist/presenter/chrome/log-kinds.d.ts +12 -0
  108. package/dist/presenter/chrome/log-kinds.js +7 -0
  109. package/dist/presenter/{overlay.d.ts → chrome/overlay.d.ts} +0 -1
  110. package/dist/presenter/{overlay.js → chrome/overlay.js} +0 -1
  111. package/dist/presenter/chrome/presenter-brand.d.ts +3 -0
  112. package/dist/presenter/{presenter-brand.js → chrome/presenter-brand.js} +0 -21
  113. package/dist/presenter/chrome/presenter-effects.d.ts +4 -0
  114. package/dist/presenter/{presenter-effects.js → chrome/presenter-effects.js} +1 -10
  115. package/dist/presenter/chrome/presenter-hud-chrome.d.ts +7 -0
  116. package/dist/presenter/{presenter-hud-chrome.js → chrome/presenter-hud-chrome.js} +0 -10
  117. package/dist/presenter/chrome/presenter-log.d.ts +17 -0
  118. package/dist/presenter/{presenter-log.js → chrome/presenter-log.js} +5 -54
  119. package/dist/presenter/chrome/presenter-report-copy.d.ts +51 -0
  120. package/dist/presenter/chrome/presenter-report-copy.js +95 -0
  121. package/dist/presenter/chrome/presenter-safe-html.d.ts +2 -0
  122. package/dist/presenter/chrome/presenter-safe-html.js +16 -0
  123. package/dist/presenter/{presenter-tally.d.ts → chrome/presenter-tally.d.ts} +0 -5
  124. package/dist/presenter/{presenter-tally.js → chrome/presenter-tally.js} +1 -5
  125. package/dist/presenter/{presenter-verbs.js → chrome/presenter-verbs.js} +0 -7
  126. package/dist/presenter/{presenter-heroicons-data.d.ts → icons/presenter-heroicons-data.d.ts} +1 -8
  127. package/dist/presenter/{presenter-heroicons-data.js → icons/presenter-heroicons-data.js} +1 -8
  128. package/dist/presenter/{presenter-icons.d.ts → icons/presenter-icons.d.ts} +1 -6
  129. package/dist/presenter/{presenter-icons.js → icons/presenter-icons.js} +1 -6
  130. package/dist/presenter/presenter-account-styles.d.ts +1 -0
  131. package/dist/presenter/presenter-account-styles.js +99 -0
  132. package/dist/presenter/presenter-account.d.ts +40 -0
  133. package/dist/presenter/presenter-account.js +178 -0
  134. package/dist/presenter/presenter-config.d.ts +10 -107
  135. package/dist/presenter/presenter-config.js +5 -92
  136. package/dist/presenter/presenter-controls.d.ts +1 -35
  137. package/dist/presenter/presenter-controls.js +10 -133
  138. package/dist/presenter/presenter-dock-layout.d.ts +0 -3
  139. package/dist/presenter/presenter-dock-layout.js +1 -5
  140. package/dist/presenter/presenter-drag.d.ts +2 -42
  141. package/dist/presenter/presenter-drag.js +3 -76
  142. package/dist/presenter/presenter-glow.d.ts +0 -21
  143. package/dist/presenter/presenter-glow.js +2 -28
  144. package/dist/presenter/presenter-hud-position.d.ts +18 -0
  145. package/dist/presenter/presenter-hud-position.js +50 -0
  146. package/dist/presenter/presenter-report-styles.d.ts +0 -6
  147. package/dist/presenter/presenter-report-styles.js +22 -7
  148. package/dist/presenter/presenter-report.d.ts +5 -7
  149. package/dist/presenter/presenter-report.js +44 -95
  150. package/dist/presenter/presenter-run-state.d.ts +1 -8
  151. package/dist/presenter/presenter-run-state.js +1 -6
  152. package/dist/presenter/presenter-settings-styles.d.ts +0 -1
  153. package/dist/presenter/presenter-settings-styles.js +2 -2
  154. package/dist/presenter/presenter-settings.d.ts +5 -23
  155. package/dist/presenter/presenter-settings.js +21 -33
  156. package/dist/presenter/presenter-shell-styles.js +23 -20
  157. package/dist/presenter/presenter-shell.d.ts +6 -18
  158. package/dist/presenter/presenter-shell.js +26 -77
  159. package/dist/presenter/presenter-styles.d.ts +2 -10
  160. package/dist/presenter/presenter-styles.js +6 -12
  161. package/dist/presenter/presenter-workspace.d.ts +4 -4
  162. package/dist/presenter/presenter-workspace.js +30 -10
  163. package/dist/presenter/presenter.d.ts +3 -63
  164. package/dist/presenter/presenter.js +29 -132
  165. package/dist/presenter/tour/tour-view.d.ts +33 -0
  166. package/dist/presenter/tour/tour-view.js +188 -0
  167. package/dist/presenter/tour/tour.d.ts +25 -0
  168. package/dist/presenter/tour/tour.js +245 -0
  169. package/dist/recorder/recorder-styles.d.ts +0 -6
  170. package/dist/recorder/recorder-styles.js +0 -6
  171. package/dist/recorder/recorder.d.ts +0 -34
  172. package/dist/recorder/recorder.js +6 -49
  173. package/dist/registry/auto-testids.d.ts +0 -19
  174. package/dist/registry/auto-testids.js +1 -21
  175. package/dist/registry/capabilities.d.ts +0 -31
  176. package/dist/registry/capabilities.js +1 -46
  177. package/dist/registry/commit-and-signal.d.ts +0 -6
  178. package/dist/registry/commit-and-signal.js +1 -7
  179. package/dist/registry/declared-channels.d.ts +2 -0
  180. package/dist/registry/declared-channels.js +14 -0
  181. package/dist/registry/declared-platform.d.ts +4 -0
  182. package/dist/registry/declared-platform.js +34 -0
  183. package/dist/registry/domains.d.ts +0 -8
  184. package/dist/registry/domains.js +0 -7
  185. package/dist/registry/emitter.d.ts +0 -14
  186. package/dist/registry/emitter.js +1 -11
  187. package/dist/registry/store-adapters.d.ts +0 -186
  188. package/dist/registry/store-adapters.js +1 -164
  189. package/dist/registry/{adapters.d.ts → stores/adapters.d.ts} +0 -10
  190. package/dist/registry/{adapters.js → stores/adapters.js} +0 -10
  191. package/dist/registry/stores.d.ts +0 -33
  192. package/dist/registry/stores.js +1 -60
  193. package/dist/reticle-presenter-helpers.d.ts +0 -13
  194. package/dist/reticle-presenter-helpers.js +10 -20
  195. package/dist/reticle-singleton.d.ts +2 -0
  196. package/dist/reticle-singleton.js +3 -0
  197. package/dist/reticle.d.ts +1 -52
  198. package/dist/reticle.js +115 -180
  199. package/dist/review/annotator-styles.js +1 -8
  200. package/dist/review/annotator.d.ts +2 -13
  201. package/dist/review/annotator.js +2 -42
  202. package/dist/review/mark-anchor.d.ts +0 -11
  203. package/dist/review/mark-anchor.js +3 -11
  204. package/dist/security/serialization.d.ts +0 -20
  205. package/dist/security/serialization.js +1 -66
  206. package/dist/session-continuity.d.ts +0 -16
  207. package/dist/session-continuity.js +1 -20
  208. package/dist/timers/clock.d.ts +1 -14
  209. package/dist/timers/clock.js +59 -60
  210. package/dist/timers/console/native-console.js +5 -0
  211. package/dist/timers/native/native-timers.d.ts +11 -0
  212. package/dist/timers/native/native-timers.js +48 -0
  213. package/dist/transport/transport.d.ts +0 -51
  214. package/dist/transport/transport.js +5 -131
  215. package/dist/transport/unreachable-message.d.ts +0 -17
  216. package/dist/transport/unreachable-message.js +0 -17
  217. package/dist/util/captured-method.d.ts +0 -35
  218. package/dist/util/captured-method.js +0 -35
  219. package/package.json +5 -4
  220. package/dist/actions/synthetic-input.d.ts +0 -9
  221. package/dist/actions/synthetic-input.js +0 -40
  222. package/dist/dom/auto-anchor.d.ts +0 -65
  223. package/dist/dom/auto-anchor.js +0 -74
  224. package/dist/dom/refs.d.ts +0 -90
  225. package/dist/dom/refs.js +0 -245
  226. package/dist/dom/source.d.ts +0 -53
  227. package/dist/dom/source.js +0 -74
  228. package/dist/observers/network-body.d.ts +0 -35
  229. package/dist/observers/network-body.js +0 -168
  230. package/dist/observers/network-redact.d.ts +0 -20
  231. package/dist/observers/network-redact.js +0 -80
  232. package/dist/presenter/presenter-brand.d.ts +0 -22
  233. package/dist/presenter/presenter-effects.d.ts +0 -13
  234. package/dist/presenter/presenter-hud-chrome.d.ts +0 -17
  235. package/dist/presenter/presenter-log.d.ts +0 -64
  236. package/dist/presenter/presenter-report-copy.d.ts +0 -112
  237. package/dist/presenter/presenter-report-copy.js +0 -156
  238. package/dist/presenter/presenter-test-helpers.d.ts +0 -16
  239. package/dist/presenter/presenter-test-helpers.js +0 -41
  240. package/dist/timers/native-console.js +0 -9
  241. package/dist/timers/native-timers.d.ts +0 -36
  242. package/dist/timers/native-timers.js +0 -78
  243. /package/dist/presenter/{presenter-verbs.d.ts → chrome/presenter-verbs.d.ts} +0 -0
  244. /package/dist/timers/{native-console.d.ts → console/native-console.d.ts} +0 -0
@@ -1,12 +1,3 @@
1
1
  import type { Teardown } from './types.js';
2
- /** Every same-origin frame body at or beneath `root`, in document order. */
3
2
  export declare function sameOriginFrameBodies(root: ParentNode): HTMLElement[];
4
- /**
5
- * Call `attach` for every same-origin frame body now and after each frame load.
6
- *
7
- * A frame that navigates gets a BRAND NEW document, so the old observation is dead and re-attaching on
8
- * `load` is not optional — without it, observation silently stops the first time an embedded panel
9
- * routes. Attaching twice to the same node is harmless (MutationObserver de-duplicates), so no
10
- * bookkeeping is kept.
11
- */
12
3
  export declare function observeSameOriginFrames(attach: (body: HTMLElement) => void): Teardown;
@@ -1,30 +1,12 @@
1
- /**
2
- * Same-origin `<iframe>` documents, wired into the top document's observation.
3
- *
4
- * A `MutationObserver` is scoped to the node it is given, and a frame's document is a DIFFERENT node
5
- * tree — so a click inside an embedded panel changed its text and `observe` reported an empty window.
6
- * Measured on a console with an embedded SLA panel: the click landed, the panel updated, and the
7
- * observe window held zero DOM events. That is the false-green shape exactly — "nothing happened" is
8
- * what an agent concludes, and it is wrong.
9
- *
10
- * A MutationObserver CAN observe a node in another same-origin document, so the fix is to hand it the
11
- * frame bodies. Cross-origin frames stay unreachable and keep their existing blind-spot declaration.
12
- *
13
- * Network is NOT covered here. A frame's `fetch` lives in the frame's own realm and the top realm's
14
- * patch never sees it, so requests it makes are invisible — declared as a blind spot rather than
15
- * partially instrumented, because a half-seen request channel is worse than an admitted one.
16
- */
17
- /** Frames-inside-frames, bounded. Real embeds nest once, occasionally twice. */
18
1
  const DEPTH_MAX = 3;
19
2
  function bodyOf(frame) {
20
3
  try {
21
4
  return frame.contentDocument?.body ?? null;
22
5
  }
23
6
  catch {
24
- return null; // cross-origin — not ours to read
7
+ return null;
25
8
  }
26
9
  }
27
- /** Every same-origin frame body at or beneath `root`, in document order. */
28
10
  export function sameOriginFrameBodies(root) {
29
11
  const found = [];
30
12
  const walk = (node, depth) => {
@@ -41,21 +23,12 @@ export function sameOriginFrameBodies(root) {
41
23
  walk(root, 0);
42
24
  return found;
43
25
  }
44
- /**
45
- * Call `attach` for every same-origin frame body now and after each frame load.
46
- *
47
- * A frame that navigates gets a BRAND NEW document, so the old observation is dead and re-attaching on
48
- * `load` is not optional — without it, observation silently stops the first time an embedded panel
49
- * routes. Attaching twice to the same node is harmless (MutationObserver de-duplicates), so no
50
- * bookkeeping is kept.
51
- */
52
26
  export function observeSameOriginFrames(attach) {
53
27
  const attachAll = () => {
54
28
  for (const body of sameOriginFrameBodies(document))
55
29
  attach(body);
56
30
  };
57
31
  attachAll();
58
- // `load` does not bubble, but it DOES reach a capturing listener on the document.
59
32
  const onLoad = (event) => {
60
33
  if (event.target instanceof HTMLIFrameElement)
61
34
  attachAll();
@@ -1,7 +1,2 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- /**
3
- * Report page visibility/focus immediately on change + a lightweight native heartbeat.
4
- * Lets the bridge know whether the tab is foregrounded so the agent never drives a throttled
5
- * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
6
- */
7
2
  export declare function installHealth(emit: Emit): Teardown;
@@ -1,11 +1,5 @@
1
1
  import { BrowserBrand, EventType, HealthReason, RETICLE_IPC_GLOBAL, SESSION_HEALTH, } from '@reticlehq/core';
2
- import { nativeSetInterval } from '../timers/native-timers.js';
3
- /**
4
- * Which shell this page runs in. Reported with health because it is what makes a timeout
5
- * diagnosable: an occluded WKWebView is SUSPENDED by macOS, so the session stays connected while
6
- * every command times out — and a URL cannot tell a Tauri dev server from a plain localhost app, so
7
- * the runtime has to come from the page itself.
8
- */
2
+ import { nativeSetInterval } from '../timers/native/native-timers.js';
9
3
  function detectRuntime() {
10
4
  const w = window;
11
5
  if (w['__TAURI_INTERNALS__'] !== undefined || w['__TAURI__'] !== undefined)
@@ -14,11 +8,6 @@ function detectRuntime() {
14
8
  return 'electron';
15
9
  return 'web';
16
10
  }
17
- /**
18
- * The rendering engine, coarse on purpose. Three buckets — never the UA string itself, which is
19
- * high-cardinality and semi-identifying. Order matters: every Chromium UA also contains "Safari", and
20
- * Gecko is the only engine that reports a real `Gecko/` build token.
21
- */
22
11
  function detectEngine() {
23
12
  const ua = navigator.userAgent;
24
13
  if (/Chrome|Chromium|Edg\//.test(ua))
@@ -27,11 +16,6 @@ function detectEngine() {
27
16
  return 'gecko';
28
17
  return 'webkit';
29
18
  }
30
- /**
31
- * Brand names as the Chromium family and the UA string spell them, mapped to our closed vocabulary.
32
- * Matched on the WHOLE lowercased name, never a substring: brand strings are attacker-influenced
33
- * free text, and a substring rule is how "Search Browser" becomes `arc`.
34
- */
35
19
  const BRAND_BY_NAME = {
36
20
  'google chrome': BrowserBrand.CHROME,
37
21
  chrome: BrowserBrand.CHROME,
@@ -44,19 +28,9 @@ const BRAND_BY_NAME = {
44
28
  firefox: BrowserBrand.FIREFOX,
45
29
  safari: BrowserBrand.SAFARI,
46
30
  };
47
- /** Chromium's own filler entries: the greased `Not_A Brand` (spelt differently every release) and the generic engine name. */
48
31
  function isFiller(name) {
49
32
  return 'chromium' === name || (name.includes('not') && name.includes('brand'));
50
33
  }
51
- /**
52
- * The brand, from `navigator.userAgentData.brands` where it exists and the UA string where it does
53
- * not (Firefox and Safari expose no userAgentData at all). Every fork claims `Google Chrome`
54
- * alongside its own entry, so a non-Chrome match always wins; a Chromium that names nothing but
55
- * filler is `other`, because calling it Chrome would be a guess.
56
- *
57
- * Never throws — a desktop webview may expose a `userAgentData` of any shape, and health reporting
58
- * must survive it.
59
- */
60
34
  function detectBrand() {
61
35
  try {
62
36
  const nav = navigator;
@@ -79,7 +53,6 @@ function detectBrand() {
79
53
  }
80
54
  }
81
55
  catch {
82
- /* a health report must never depend on an exotic userAgentData */
83
56
  }
84
57
  const ua = navigator.userAgent;
85
58
  if (/Edg\//.test(ua))
@@ -103,11 +76,6 @@ function snapshotHealth() {
103
76
  brand: detectBrand(),
104
77
  };
105
78
  }
106
- /**
107
- * Report page visibility/focus immediately on change + a lightweight native heartbeat.
108
- * Lets the bridge know whether the tab is foregrounded so the agent never drives a throttled
109
- * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
110
- */
111
79
  export function installHealth(emit) {
112
80
  const ac = new AbortController();
113
81
  const { signal } = ac;
@@ -117,7 +85,7 @@ export function installHealth(emit) {
117
85
  document.addEventListener('visibilitychange', () => report(HealthReason.VISIBILITY), { signal });
118
86
  window.addEventListener('focus', () => report(HealthReason.FOCUS), { signal });
119
87
  window.addEventListener('blur', () => report(HealthReason.BLUR), { signal });
120
- report(HealthReason.INITIAL); // baseline so the server knows state before the first change
88
+ report(HealthReason.INITIAL);
121
89
  const stopHeartbeat = nativeSetInterval(() => report(HealthReason.HEARTBEAT), SESSION_HEALTH.HEARTBEAT_MS);
122
90
  return () => {
123
91
  stopHeartbeat();
@@ -1,6 +1,8 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
2
  interface InstallOptions {
3
3
  captureBodies: boolean;
4
+ captureErrorBodies?: boolean;
4
5
  }
6
+ export declare function runTeardowns(emit: Emit, teardowns: readonly Teardown[]): void;
5
7
  export declare function installAllObservers(emit: Emit, options: InstallOptions): Teardown[];
6
8
  export {};
@@ -1,16 +1,3 @@
1
- /**
2
- * Install every page observer, guarded.
3
- *
4
- * Extracted from `reticle.ts` because it is one job with one rule, and because that file is the SDK's
5
- * public surface — the more of this that lived there, the harder it was to see what `connect()`
6
- * actually does.
7
- *
8
- * THE RULE: one observer failing must never stop the others. This array used to be built bare, so a
9
- * single `install*` that threw took the whole `connect()` down with it — one bad patch on one exotic
10
- * page and the app got no instrumentation at all, silently, because the SDK\'s own catch blocks
11
- * ensured nobody ever found out. Each install is now wrapped: the healthy observers still run, the
12
- * failure is reported over the bridge, and the caller gets a teardown list of the same length.
13
- */
14
1
  import { installDom } from './dom.js';
15
2
  import { installStorage } from './storage.js';
16
3
  import { installStoreState } from './state.js';
@@ -22,47 +9,59 @@ import { installNetwork } from './network.js';
22
9
  import { installIpc, ipcNetOverrides, isReticleOwnIpc } from './ipc.js';
23
10
  import { installPerf } from './perf.js';
24
11
  import { installRoute } from './route.js';
12
+ import { installNavigation } from './navigation.js';
13
+ import { installDeparture } from './departure.js';
25
14
  import { installConsole } from './console.js';
15
+ import { installDialogs } from './dialogs.js';
26
16
  import { installAnimation } from './animation.js';
27
17
  import { installScroll } from './scroll.js';
28
18
  import { installHealth } from './health.js';
29
19
  import { reportSdkFailure, SdkSite } from './sdk-failure.js';
30
- /** Run one install, reporting rather than throwing if it cannot start. */
31
20
  function guard(emit, site, install) {
32
21
  try {
33
22
  return install();
34
23
  }
35
24
  catch (error) {
36
25
  reportSdkFailure(emit, site, error);
37
- return () => { }; // nothing was installed, so there is nothing to tear down
26
+ return () => { };
27
+ }
28
+ }
29
+ export function runTeardowns(emit, teardowns) {
30
+ for (const teardown of teardowns) {
31
+ try {
32
+ teardown();
33
+ }
34
+ catch (error) {
35
+ reportSdkFailure(emit, SdkSite.TEARDOWN, error);
36
+ }
38
37
  }
39
38
  }
40
39
  export function installAllObservers(emit, options) {
41
40
  return [
42
- // Composition happens HERE, not inside the network observer: the network observer knows
43
- // nothing about desktop IPC, and the IPC observer knows nothing about fetch plumbing.
44
41
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installNetwork(emit, {
45
42
  captureBodies: options.captureBodies,
43
+ ...(options.captureErrorBodies === undefined
44
+ ? {}
45
+ : { captureErrorBodies: options.captureErrorBodies }),
46
46
  reinterpret: ipcNetOverrides,
47
- // The SDK's own Tauri screenshot is a fetch like any other — skip it, or the observer
48
- // reports its own captures as the app's writes.
49
47
  ignore: isReticleOwnIpc,
50
48
  })),
51
- // Desktop backends are reached over IPC, not HTTP — inert on a plain web page.
52
49
  guard(emit, SdkSite.IPC_OBSERVER, () => installIpc(emit, { captureBodies: options.captureBodies })),
53
50
  guard(emit, SdkSite.ANIMATION_OBSERVER, () => installPerf(emit)),
54
51
  guard(emit, SdkSite.ROUTER_OBSERVER, () => installRoute(emit)),
52
+ guard(emit, SdkSite.NETWORK_OBSERVER, () => installNavigation(emit)),
53
+ guard(emit, SdkSite.NETWORK_OBSERVER, () => installDeparture(emit)),
55
54
  guard(emit, SdkSite.CONSOLE_OBSERVER, () => installConsole(emit)),
55
+ guard(emit, SdkSite.CONSOLE_OBSERVER, () => installDialogs(emit)),
56
56
  guard(emit, SdkSite.ANIMATION_OBSERVER, () => installAnimation(emit)),
57
57
  guard(emit, SdkSite.DOM_OBSERVER, () => installScroll(emit)),
58
58
  guard(emit, SdkSite.DOM_OBSERVER, () => installDom(emit)),
59
- guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)), // storage WRITES → STORAGE_CHANGE diffs (pull remains the fallback)
60
- guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)), // subscribed-store mutations → STATE_CHANGE path diffs
61
- guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)), // element focus movement → FOCUS_CHANGE (focus-to-body = a regression)
62
- // Files the app PRODUCES — never cross the network, so no outside-the-page tool can see them.
59
+ guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)),
60
+ guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)),
61
+ guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)),
63
62
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
64
- guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)), // cross-origin iframes the SDK can't see → BLIND_SPOT (coverage: partial)
65
- guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)), // page visibility/focus health + heartbeat
66
- guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)), // window.open → CONTEXT_OPENED (the consequence may live in another context)
63
+ guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)),
64
+ guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)),
65
+ guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)),
67
66
  ];
68
67
  }
@@ -1,32 +1,7 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- /**
3
- * Fields to override on a NET_REQUEST that turned out to be a Tauri IPC call, or null when the
4
- * request was ordinary HTTP. Pure — the network observer calls this with the response header it
5
- * already holds, so IPC knowledge stays in this file.
6
- */
7
2
  export declare function ipcNetOverrides(url: string, header: (name: string) => string | null): Record<string, unknown> | undefined;
8
- /**
9
- * Is this fetch Reticle's OWN desktop call rather than the app's?
10
- *
11
- * Tauri has no preload stage, so `reticle_screenshot` reaches the shell the only way it can: an
12
- * `invoke` — and an invoke IS a fetch to the `ipc://` protocol, straight through the patch the
13
- * network observer installed. So the observer recorded its own screenshots as app IPC calls, and
14
- * because `IPC` counts as a mutating method, three concurrent captures inside one action window came
15
- * back to the agent as `duplicate-request`: "the same write fired 3 times", against an app that had
16
- * never made the call. Accusing a correct app of a double submit is the most damaging thing a
17
- * verification tool can do, and here the tool was the one submitting.
18
- *
19
- * Electron never had this problem — its preload captures through the UNPATCHED invoke for exactly
20
- * this reason — and the bridge's own WebSocket is skipped on the same principle (`isBridgeSocket`).
21
- * The capture command is the SDK's whole Tauri-side surface, so matching it is the whole rule.
22
- */
23
3
  export declare function isReticleOwnIpc(url: string): boolean;
24
- /**
25
- * Subscribe to the Electron preload shim, if this app installed it. Inert on Tauri (whose calls the
26
- * network observer handles) and on a plain web page.
27
- */
28
4
  interface IpcOptions {
29
- /** Forward the IPC payloads the preload recorded. Off by default, like the HTTP body capture. */
30
5
  captureBodies?: boolean;
31
6
  }
32
7
  export declare function installIpc(emit: Emit, options?: IpcOptions): Teardown;
@@ -1,32 +1,9 @@
1
1
  import { BlindSpotKind, EventType, IPC_URL_SCHEME, IpcStatus, NetInitiator, RETICLE_IPC_GLOBAL, RETICLE_TAURI_CAPTURE_COMMAND, } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
- /**
4
- * How Tauri v2 reports a command's verdict. The transport answers HTTP 200 whether the Rust command
5
- * returned Ok or Err — the verdict is in this response header, which Tauri explicitly CORS-exposes so
6
- * JS may read it. Without translating it, every failed command is recorded as a successful request.
7
- */
8
3
  const TAURI_RESPONSE_HEADER_NAME = 'Tauri-Response';
9
4
  const TAURI_RESPONSE_ERROR = 'error';
10
- /** Rust/IPC vocabulary rather than HTTP's, so the text never claims a transport status it lacks. */
11
5
  const IPC_STATUS_TEXT = { OK: 'Ok', ERROR: 'Err' };
12
- /**
13
- * Tauri's IPC endpoint. The last path segment is the command name.
14
- *
15
- * TWO spellings, because Tauri v2 uses a different one per platform and matching only the first is a
16
- * silent false green on Windows: macOS/Linux get the custom scheme `ipc://localhost/archive_todo`,
17
- * while Windows (and Android) need a real http origin and get
18
- * `http://ipc.localhost/archive_todo` — the hostname `desktop-doctor` already tells people to put in
19
- * their CSP. Unmatched, a Windows `invoke` was recorded as an ordinary HTTP request, so the
20
- * `Tauri-Response` translation never ran and every command that returned `Err` was banked as a
21
- * successful 200. `.localhost` is reserved for loopback by RFC 6761, so this can never match a
22
- * remote host.
23
- */
24
6
  const TAURI_IPC_URL = /^(?:ipc:\/\/[^/]*|https?:\/\/ipc\.localhost(?::\d+)?)\/(.+)$/;
25
- /**
26
- * Fields to override on a NET_REQUEST that turned out to be a Tauri IPC call, or null when the
27
- * request was ordinary HTTP. Pure — the network observer calls this with the response header it
28
- * already holds, so IPC knowledge stays in this file.
29
- */
30
7
  export function ipcNetOverrides(url, header) {
31
8
  const match = TAURI_IPC_URL.exec(url);
32
9
  const command = match?.[1];
@@ -34,53 +11,23 @@ export function ipcNetOverrides(url, header) {
34
11
  return undefined;
35
12
  const ok = header(TAURI_RESPONSE_HEADER_NAME) !== TAURI_RESPONSE_ERROR;
36
13
  return {
37
- // Normalize `ipc://localhost/archive_todo` to `ipc://archive_todo`, so a Tauri command and an
38
- // Electron channel read identically to the agent and to a saved flow's assertions.
39
14
  url: `${IPC_URL_SCHEME}${command}`,
40
15
  initiator: NetInitiator.IPC,
41
16
  method: NetInitiator.IPC,
42
17
  ok,
43
18
  status: ok ? IpcStatus.OK : IpcStatus.ERROR,
44
- // Overwrite the TRANSPORT's statusText. Tauri's fetch genuinely answered 200/"OK", so letting it
45
- // through beside a synthetic 500 produced a record that contradicted itself — `status: 500,
46
- // statusText: "OK"` reads as nonsense to anyone seeing it cold. The record must tell one story,
47
- // and the story that matters is the command's verdict, not the pipe it travelled down.
48
19
  statusText: ok ? IPC_STATUS_TEXT.OK : IPC_STATUS_TEXT.ERROR,
49
20
  };
50
21
  }
51
- /**
52
- * Is this fetch Reticle's OWN desktop call rather than the app's?
53
- *
54
- * Tauri has no preload stage, so `reticle_screenshot` reaches the shell the only way it can: an
55
- * `invoke` — and an invoke IS a fetch to the `ipc://` protocol, straight through the patch the
56
- * network observer installed. So the observer recorded its own screenshots as app IPC calls, and
57
- * because `IPC` counts as a mutating method, three concurrent captures inside one action window came
58
- * back to the agent as `duplicate-request`: "the same write fired 3 times", against an app that had
59
- * never made the call. Accusing a correct app of a double submit is the most damaging thing a
60
- * verification tool can do, and here the tool was the one submitting.
61
- *
62
- * Electron never had this problem — its preload captures through the UNPATCHED invoke for exactly
63
- * this reason — and the bridge's own WebSocket is skipped on the same principle (`isBridgeSocket`).
64
- * The capture command is the SDK's whole Tauri-side surface, so matching it is the whole rule.
65
- */
66
22
  export function isReticleOwnIpc(url) {
67
23
  return TAURI_IPC_URL.exec(url)?.[1] === RETICLE_TAURI_CAPTURE_COMMAND;
68
24
  }
69
- /**
70
- * Is this page an Electron renderer? Read off the user agent, which Electron stamps and which is
71
- * available whether or not the preload ran — the whole point is to recognise a renderer that has
72
- * NOT been instrumented.
73
- */
74
25
  function isElectronRenderer() {
75
26
  return navigator.userAgent.includes('Electron');
76
27
  }
77
28
  export function installIpc(emit, options = {}) {
78
29
  const channel = window[RETICLE_IPC_GLOBAL];
79
30
  if (typeof channel?.subscribe !== 'function') {
80
- // No shim — but on Electron that is not "nothing to observe", it is "everything unobserved".
81
- // Tauri needs no shim (its invoke travels as a fetch) and a web page has no IPC at all, so only
82
- // Electron gets the declaration. Saying nothing here is what let a missing preload line read as
83
- // a clean, empty network view for a whole app.
84
31
  if (isElectronRenderer())
85
32
  emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.UNOBSERVED_IPC, count: 1 });
86
33
  return () => undefined;
@@ -97,13 +44,7 @@ export function installIpc(emit, options = {}) {
97
44
  });
98
45
  return;
99
46
  }
100
- // A one-way `send` that did not throw has NO verdict — the renderer never learns whether the
101
- // main process handled it. Emitting `ok/status` anyway would manufacture the success nobody
102
- // reported, so both are omitted and `oneWay` says why. `reticle_network { ok }` then matches
103
- // such a record in neither direction, which is the honest answer to "did it work".
104
47
  const verdictless = true === record.oneWay && record.ok === undefined;
105
- // Declared, not inferred: the server cannot tell "no verdict" from "not yet settled" without
106
- // being told, and a send with nothing to observe must not read as a clean green.
107
48
  if (verdictless)
108
49
  emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.VERDICTLESS_SEND, count: 1 });
109
50
  const ok = true === record.ok;
@@ -116,9 +57,6 @@ export function installIpc(emit, options = {}) {
116
57
  durationMs: record.durationMs ?? 0,
117
58
  initiator: NetInitiator.IPC,
118
59
  ...(record.error === undefined ? {} : { error: record.error }),
119
- // Size travels ALWAYS, bodies only on opt-in. The split matters: the verdict layer reports "a
120
- // write returned ok and its payload went unread" from the size alone, and it cannot infer that
121
- // from an absent field — an unread payload and an empty one would look identical.
122
60
  ...(record.responseSize === undefined ? {} : { responseSize: record.responseSize }),
123
61
  ...(true === options.captureBodies
124
62
  ? {
@@ -130,9 +68,6 @@ export function installIpc(emit, options = {}) {
130
68
  });
131
69
  });
132
70
  });
133
- // A REAL removal, so teardown leaves the app as it found it. The preload's `invoke` patch stays
134
- // deliberately — it is a pass-through with no effect once no subscriber remains, and un-patching
135
- // mid-flight would strand any call already inside the wrapper.
136
71
  return () => {
137
72
  channel.unsubscribe?.(token);
138
73
  };
@@ -0,0 +1,2 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export declare function installNavigation(emit: Emit): Teardown;
@@ -0,0 +1,33 @@
1
+ import { EventType, NetInitiator } from '@reticlehq/core';
2
+ const REPORTED_TYPES = new Set(['navigate', 'reload', 'back_forward']);
3
+ function navigationEntry() {
4
+ try {
5
+ const [first] = performance.getEntriesByType('navigation');
6
+ return first;
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ export function installNavigation(emit) {
13
+ const entry = navigationEntry();
14
+ const type = entry?.type;
15
+ const url = entry?.name;
16
+ if (entry === undefined || 'string' !== typeof url || 0 === url.length)
17
+ return () => undefined;
18
+ if ('string' !== typeof type || !REPORTED_TYPES.has(type))
19
+ return () => undefined;
20
+ const status = entry.responseStatus;
21
+ const known = 'number' === typeof status && status > 0;
22
+ emit(EventType.NET_REQUEST, {
23
+ id: `doc-${String(Math.round(entry.duration ?? 0))}`,
24
+ method: 'GET',
25
+ url,
26
+ initiator: NetInitiator.DOCUMENT,
27
+ durationMs: Math.round(entry.duration ?? 0),
28
+ ...(known ? { status, ok: status < 400 } : {}),
29
+ navigationType: type,
30
+ ...('number' === typeof entry.transferSize ? { transferSize: entry.transferSize } : {}),
31
+ });
32
+ return () => undefined;
33
+ }
@@ -0,0 +1,12 @@
1
+ export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
2
+ export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
3
+ export declare function isCapturableType(contentType: string | null): boolean;
4
+ export declare function shouldCaptureErrorBody(status: number, enabled: boolean): boolean;
5
+ export declare function projectErrorBody(rawText: string, contentType: string | null): {
6
+ body: string;
7
+ truncated: boolean;
8
+ };
9
+ export declare function projectBody(rawText: string, contentType: string | null): {
10
+ body: string;
11
+ truncated: boolean;
12
+ };
@@ -0,0 +1,71 @@
1
+ import { REDACTED_VALUE } from '@reticlehq/core';
2
+ import { isSensitiveKey, safeStringify, scrubKnownSecrets } from '../../security/serialization.js';
3
+ import { nativeClearTimeout, nativeSetTimeout } from '../../timers/native/native-timers.js';
4
+ const CAPTURABLE_CONTENT = /application\/json|text\/|application\/xml|x-www-form-urlencoded|graphql/i;
5
+ const STREAMING_CONTENT = /event-stream|x-ndjson|application\/stream/i;
6
+ const ENDLESS_BY_DESIGN = /event-stream/i;
7
+ export function isStreamingBody(contentType, contentLength) {
8
+ if (contentType !== null && ENDLESS_BY_DESIGN.test(contentType))
9
+ return false;
10
+ return null === contentLength;
11
+ }
12
+ const BODY_READ_TIMEOUT_MS = 500;
13
+ export async function withBodyDeadline(read) {
14
+ let timer;
15
+ try {
16
+ return await Promise.race([
17
+ read,
18
+ new Promise((resolve) => {
19
+ timer = nativeSetTimeout(() => resolve(undefined), BODY_READ_TIMEOUT_MS);
20
+ }),
21
+ ]);
22
+ }
23
+ finally {
24
+ if (timer !== undefined)
25
+ nativeClearTimeout(timer);
26
+ }
27
+ }
28
+ export function isCapturableType(contentType) {
29
+ if (null === contentType)
30
+ return false;
31
+ if (STREAMING_CONTENT.test(contentType))
32
+ return false;
33
+ return CAPTURABLE_CONTENT.test(contentType);
34
+ }
35
+ const MAX_BODY_CHARS = 8192;
36
+ const MAX_BODY_SCAN_CHARS = MAX_BODY_CHARS * 2;
37
+ const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Za-z0-9._~+/=-]*[\d._~+/=-])[A-Za-z0-9._~+/=-]+/gi;
38
+ function redactText(text) {
39
+ return (text
40
+ .replace(AUTH_SCHEME_TOKEN, (_m, scheme) => `${scheme} ${REDACTED_VALUE}`)
41
+ .replace(/([A-Za-z0-9_.-]+)(\s*[=:]\s*"?)([^&\s,;"}]+)/g, (match, key, sep) => isSensitiveKey(key) ? `${key}${sep}${REDACTED_VALUE}` : match));
42
+ }
43
+ const ERROR_BODY_CHARS = 4096;
44
+ export function shouldCaptureErrorBody(status, enabled) {
45
+ return enabled && status >= 400;
46
+ }
47
+ export function projectErrorBody(rawText, contentType) {
48
+ const projected = projectBody(rawText, contentType);
49
+ if (projected.body.length <= ERROR_BODY_CHARS)
50
+ return projected;
51
+ return { body: projected.body.slice(0, ERROR_BODY_CHARS), truncated: true };
52
+ }
53
+ export function projectBody(rawText, contentType) {
54
+ const oversized = rawText.length > MAX_BODY_SCAN_CHARS;
55
+ const text = oversized ? rawText.slice(0, MAX_BODY_SCAN_CHARS) : rawText;
56
+ let out;
57
+ if (contentType !== null && /json|graphql/i.test(contentType)) {
58
+ try {
59
+ out = safeStringify(JSON.parse(text));
60
+ }
61
+ catch {
62
+ out = redactText(text);
63
+ }
64
+ }
65
+ else {
66
+ out = redactText(text);
67
+ }
68
+ out = scrubKnownSecrets(out);
69
+ const truncated = oversized || out.length > MAX_BODY_CHARS;
70
+ return { body: out.length > MAX_BODY_CHARS ? out.slice(0, MAX_BODY_CHARS) : out, truncated };
71
+ }
@@ -0,0 +1 @@
1
+ export { netUrlFields, redactUrl } from '@reticlehq/core';
@@ -0,0 +1 @@
1
+ export { netUrlFields, redactUrl } from '@reticlehq/core';
@@ -1,23 +1,2 @@
1
1
  import type { Emit } from './types.js';
2
- /**
3
- * Watch a STREAMED response body to its end, so `settled` cannot pass while the server is still
4
- * writing.
5
- *
6
- * `fetch` resolves at HEADERS. On a Next.js App Router page the RSC payload's NET_REQUEST landed
7
- * 16 ms in, the shell rendered at 3013, and the Suspense boundary's rows arrived at 3902 — 889 ms
8
- * in which NOTHING was observed. Quiet is exactly what settle tests for, so it passed with the
9
- * fallback still on screen and zero rows rendered: a green on every streaming boundary.
10
- *
11
- * STRUCTURAL, not an allowlist: a response with a body and no `content-length` is being sent
12
- * chunked, which is the wire's own way of saying the server is still writing. An earlier version
13
- * gated on a content-type list containing `x-component` — "Next.js RSC, plus two guesses" — which
14
- * closed the one defect it was written against and left every other streamed response as invisible
15
- * as before. The clone is DRAINED as it arrives rather than buffered, so watching costs a second
16
- * read of bytes already in flight, not a second copy held in memory.
17
- *
18
- * BOUNDED both ways: an endless stream (SSE over fetch, a live feed) must not hold `settled` open
19
- * forever, so the watch gives up after STREAM_WATCH_MS and closes the record anyway, marked
20
- * `gaveUp`. That is the safe direction — a settle that eventually proceeds beats one that never
21
- * does — and it says which happened rather than pretending the stream ended.
22
- */
23
2
  export declare function watchStreamedBody(emit: Emit, res: Response, id: string, url: string, contentType: string | null, contentLength: string | null): void;
@@ -1,46 +1,8 @@
1
- /**
2
- * Watching a STREAMED response body to its end, so `settled` cannot pass mid-stream.
3
- *
4
- * Split out of network.ts, which was already at its line cap. One question: when did this response
5
- * actually finish arriving?
6
- */
7
1
  import { EventType, StreamDirection, StreamTransport } from '@reticlehq/core';
8
- import { isStreamingBody } from './network-body.js';
9
- import { nativeClearTimeout, nativeSetTimeout } from '../timers/native-timers.js';
10
- /**
11
- * Longest a streamed response body is watched before the record is closed anyway.
12
- *
13
- * An endless stream must never hold `settled` open forever. Ten seconds is longer than any
14
- * page-render stream and short enough that a genuinely endless one costs one settle window, not the
15
- * session.
16
- */
2
+ import { isStreamingBody } from './net-detail/network-body.js';
3
+ import { nativeClearTimeout, nativeSetTimeout } from '../timers/native/native-timers.js';
17
4
  const STREAM_WATCH_MS = 10_000;
18
- /**
19
- * Watch a STREAMED response body to its end, so `settled` cannot pass while the server is still
20
- * writing.
21
- *
22
- * `fetch` resolves at HEADERS. On a Next.js App Router page the RSC payload's NET_REQUEST landed
23
- * 16 ms in, the shell rendered at 3013, and the Suspense boundary's rows arrived at 3902 — 889 ms
24
- * in which NOTHING was observed. Quiet is exactly what settle tests for, so it passed with the
25
- * fallback still on screen and zero rows rendered: a green on every streaming boundary.
26
- *
27
- * STRUCTURAL, not an allowlist: a response with a body and no `content-length` is being sent
28
- * chunked, which is the wire's own way of saying the server is still writing. An earlier version
29
- * gated on a content-type list containing `x-component` — "Next.js RSC, plus two guesses" — which
30
- * closed the one defect it was written against and left every other streamed response as invisible
31
- * as before. The clone is DRAINED as it arrives rather than buffered, so watching costs a second
32
- * read of bytes already in flight, not a second copy held in memory.
33
- *
34
- * BOUNDED both ways: an endless stream (SSE over fetch, a live feed) must not hold `settled` open
35
- * forever, so the watch gives up after STREAM_WATCH_MS and closes the record anyway, marked
36
- * `gaveUp`. That is the safe direction — a settle that eventually proceeds beats one that never
37
- * does — and it says which happened rather than pretending the stream ended.
38
- */
39
5
  export function watchStreamedBody(emit, res, id, url, contentType, contentLength) {
40
- // `=== null` is not enough: jsdom's Response has no `body` at all, and older/polyfilled
41
- // implementations return undefined. Reaching for `.getReader()` on that threw an unhandled
42
- // rejection out of a detached promise — an observability layer throwing into the host page, from
43
- // code the host never called. Duck-type the reader instead of trusting the shape.
44
6
  if (!isStreamingBody(contentType, contentLength))
45
7
  return;
46
8
  if (typeof res.body?.getReader !== 'function')
@@ -50,7 +12,7 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
50
12
  clone = res.clone();
51
13
  }
52
14
  catch {
53
- return; // already consumed or locked — the app owns it; observing it would take it away
15
+ return;
54
16
  }
55
17
  const body = clone.body;
56
18
  if (null === body)
@@ -64,10 +26,6 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
64
26
  void (async () => {
65
27
  const reader = body.getReader();
66
28
  let gaveUp = false;
67
- // The deadline must be a TIMER, not a check after each read. An idle stream parks inside
68
- // `read()` waiting for a chunk that may never come, so a post-read check never runs and the
69
- // record never closes — which would hold `settled` open forever on exactly the endless streams
70
- // the bound exists to protect against.
71
29
  let timer;
72
30
  const expired = new Promise((resolve) => {
73
31
  timer = nativeSetTimeout(() => resolve('expired'), STREAM_WATCH_MS);
@@ -85,7 +43,6 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
85
43
  }
86
44
  }
87
45
  catch {
88
- /* aborted or errored — the stream is over either way, which is what we report */
89
46
  }
90
47
  finally {
91
48
  if (timer !== undefined)