@reticlehq/browser 2.2.1 → 2.4.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 (273) hide show
  1. package/dist/actions/actions-dom.d.ts +0 -1
  2. package/dist/actions/actions-dom.js +0 -1
  3. package/dist/actions/actions.d.ts +3 -1
  4. package/dist/actions/actions.js +55 -36
  5. package/dist/actions/scroll.d.ts +0 -1
  6. package/dist/actions/scroll.js +0 -1
  7. package/dist/actions/value-input.d.ts +33 -0
  8. package/dist/actions/value-input.js +63 -0
  9. package/dist/commands/commands.d.ts +0 -1
  10. package/dist/commands/commands.js +27 -7
  11. package/dist/connect-options.d.ts +97 -0
  12. package/dist/connect-options.js +1 -0
  13. package/dist/dom/a11y.d.ts +0 -2
  14. package/dist/dom/a11y.js +54 -24
  15. package/dist/dom/auto-anchor.d.ts +0 -1
  16. package/dist/dom/auto-anchor.js +0 -1
  17. package/dist/dom/blind-spots.d.ts +0 -1
  18. package/dist/dom/blind-spots.js +0 -1
  19. package/dist/dom/chart.d.ts +0 -1
  20. package/dist/dom/chart.js +0 -1
  21. package/dist/dom/desktop-capture.d.ts +36 -0
  22. package/dist/dom/desktop-capture.js +113 -0
  23. package/dist/dom/dom-ignore.d.ts +7 -1
  24. package/dist/dom/dom-ignore.js +1 -2
  25. package/dist/dom/occlusion.d.ts +0 -1
  26. package/dist/dom/occlusion.js +0 -1
  27. package/dist/dom/query.d.ts +0 -1
  28. package/dist/dom/query.js +61 -13
  29. package/dist/dom/realm.d.ts +30 -0
  30. package/dist/dom/realm.js +58 -0
  31. package/dist/dom/refs.d.ts +0 -1
  32. package/dist/dom/refs.js +0 -1
  33. package/dist/dom/shadow-registry.d.ts +40 -0
  34. package/dist/dom/shadow-registry.js +165 -0
  35. package/dist/dom/snapshot.d.ts +2 -2
  36. package/dist/dom/snapshot.js +21 -4
  37. package/dist/dom/source.d.ts +0 -1
  38. package/dist/dom/source.js +0 -1
  39. package/dist/dom/theme.d.ts +0 -1
  40. package/dist/dom/theme.js +0 -1
  41. package/dist/dom/virtualized.d.ts +19 -0
  42. package/dist/dom/virtualized.js +80 -0
  43. package/dist/index.d.ts +3 -4
  44. package/dist/index.js +2 -3
  45. package/dist/observers/animation.d.ts +0 -1
  46. package/dist/observers/animation.js +0 -1
  47. package/dist/observers/blind-spots.d.ts +16 -4
  48. package/dist/observers/blind-spots.js +65 -11
  49. package/dist/observers/console.d.ts +0 -1
  50. package/dist/observers/console.js +0 -1
  51. package/dist/observers/dom.d.ts +0 -2
  52. package/dist/observers/dom.js +67 -9
  53. package/dist/observers/download.d.ts +6 -0
  54. package/dist/observers/download.js +103 -0
  55. package/dist/observers/focus.d.ts +0 -1
  56. package/dist/observers/focus.js +0 -1
  57. package/dist/observers/frames.d.ts +12 -0
  58. package/dist/observers/frames.js +67 -0
  59. package/dist/observers/health.d.ts +0 -1
  60. package/dist/observers/health.js +30 -2
  61. package/dist/observers/install-all.d.ts +5 -0
  62. package/dist/observers/install-all.js +63 -0
  63. package/dist/observers/ipc.d.ts +22 -0
  64. package/dist/observers/ipc.js +121 -0
  65. package/dist/observers/network-body.d.ts +17 -1
  66. package/dist/observers/network-body.js +26 -1
  67. package/dist/observers/network-redact.d.ts +0 -1
  68. package/dist/observers/network-redact.js +6 -3
  69. package/dist/observers/network-stream.d.ts +23 -0
  70. package/dist/observers/network-stream.js +102 -0
  71. package/dist/observers/network.d.ts +11 -1
  72. package/dist/observers/network.js +7 -1
  73. package/dist/observers/perf.d.ts +0 -1
  74. package/dist/observers/perf.js +0 -1
  75. package/dist/observers/route.d.ts +0 -1
  76. package/dist/observers/route.js +0 -1
  77. package/dist/observers/scroll.d.ts +0 -1
  78. package/dist/observers/scroll.js +0 -1
  79. package/dist/observers/sdk-failure.d.ts +28 -0
  80. package/dist/observers/sdk-failure.js +60 -0
  81. package/dist/observers/state.d.ts +0 -1
  82. package/dist/observers/state.js +0 -1
  83. package/dist/observers/storage.d.ts +0 -1
  84. package/dist/observers/storage.js +0 -1
  85. package/dist/observers/types.d.ts +0 -1
  86. package/dist/observers/types.js +0 -1
  87. package/dist/patching/capture-method.d.ts +0 -1
  88. package/dist/patching/capture-method.js +0 -1
  89. package/dist/presenter/overlay.d.ts +0 -1
  90. package/dist/presenter/overlay.js +0 -1
  91. package/dist/presenter/presenter-config.d.ts +0 -1
  92. package/dist/presenter/presenter-config.js +0 -1
  93. package/dist/presenter/presenter-controls.d.ts +0 -1
  94. package/dist/presenter/presenter-controls.js +0 -1
  95. package/dist/presenter/presenter-effects.d.ts +0 -1
  96. package/dist/presenter/presenter-effects.js +0 -1
  97. package/dist/presenter/presenter-glow.d.ts +0 -1
  98. package/dist/presenter/presenter-glow.js +0 -1
  99. package/dist/presenter/presenter-log.d.ts +0 -1
  100. package/dist/presenter/presenter-log.js +0 -1
  101. package/dist/presenter/presenter-run-state.d.ts +0 -1
  102. package/dist/presenter/presenter-run-state.js +0 -1
  103. package/dist/presenter/presenter-styles.d.ts +0 -1
  104. package/dist/presenter/presenter-styles.js +0 -1
  105. package/dist/presenter/presenter-tally.d.ts +0 -1
  106. package/dist/presenter/presenter-tally.js +0 -1
  107. package/dist/presenter/presenter-test-helpers.d.ts +0 -1
  108. package/dist/presenter/presenter-test-helpers.js +0 -1
  109. package/dist/presenter/presenter-verbs.d.ts +0 -1
  110. package/dist/presenter/presenter-verbs.js +0 -1
  111. package/dist/presenter/presenter.d.ts +0 -1
  112. package/dist/presenter/presenter.js +0 -1
  113. package/dist/recorder/recorder-styles.d.ts +0 -1
  114. package/dist/recorder/recorder-styles.js +0 -1
  115. package/dist/recorder/recorder.d.ts +0 -1
  116. package/dist/recorder/recorder.js +0 -1
  117. package/dist/registry/adapters.d.ts +0 -1
  118. package/dist/registry/adapters.js +0 -1
  119. package/dist/registry/capabilities.d.ts +2 -1
  120. package/dist/registry/capabilities.js +16 -1
  121. package/dist/registry/commit-and-signal.d.ts +0 -1
  122. package/dist/registry/commit-and-signal.js +0 -1
  123. package/dist/registry/domains.d.ts +0 -1
  124. package/dist/registry/domains.js +0 -1
  125. package/dist/registry/emitter.d.ts +0 -1
  126. package/dist/registry/emitter.js +0 -1
  127. package/dist/registry/store-adapters.d.ts +141 -7
  128. package/dist/registry/store-adapters.js +178 -1
  129. package/dist/registry/stores.d.ts +0 -1
  130. package/dist/registry/stores.js +8 -1
  131. package/dist/reticle-presenter-helpers.d.ts +0 -1
  132. package/dist/reticle-presenter-helpers.js +0 -1
  133. package/dist/reticle.d.ts +5 -59
  134. package/dist/reticle.js +37 -33
  135. package/dist/review/annotator.d.ts +0 -1
  136. package/dist/review/annotator.js +0 -1
  137. package/dist/review/mark-anchor.d.ts +0 -1
  138. package/dist/review/mark-anchor.js +0 -1
  139. package/dist/security/serialization.d.ts +0 -1
  140. package/dist/security/serialization.js +0 -1
  141. package/dist/timers/clock.d.ts +0 -1
  142. package/dist/timers/clock.js +0 -1
  143. package/dist/timers/native-console.d.ts +0 -1
  144. package/dist/timers/native-console.js +0 -1
  145. package/dist/timers/native-timers.d.ts +0 -1
  146. package/dist/timers/native-timers.js +0 -1
  147. package/dist/transport/transport.d.ts +12 -6
  148. package/dist/transport/transport.js +107 -17
  149. package/package.json +13 -3
  150. package/dist/actions/actions-dom.d.ts.map +0 -1
  151. package/dist/actions/actions-dom.js.map +0 -1
  152. package/dist/actions/actions.d.ts.map +0 -1
  153. package/dist/actions/actions.js.map +0 -1
  154. package/dist/actions/scroll.d.ts.map +0 -1
  155. package/dist/actions/scroll.js.map +0 -1
  156. package/dist/commands/commands.d.ts.map +0 -1
  157. package/dist/commands/commands.js.map +0 -1
  158. package/dist/dom/a11y.d.ts.map +0 -1
  159. package/dist/dom/a11y.js.map +0 -1
  160. package/dist/dom/auto-anchor.d.ts.map +0 -1
  161. package/dist/dom/auto-anchor.js.map +0 -1
  162. package/dist/dom/blind-spots.d.ts.map +0 -1
  163. package/dist/dom/blind-spots.js.map +0 -1
  164. package/dist/dom/chart.d.ts.map +0 -1
  165. package/dist/dom/chart.js.map +0 -1
  166. package/dist/dom/dom-ignore.d.ts.map +0 -1
  167. package/dist/dom/dom-ignore.js.map +0 -1
  168. package/dist/dom/occlusion.d.ts.map +0 -1
  169. package/dist/dom/occlusion.js.map +0 -1
  170. package/dist/dom/query.d.ts.map +0 -1
  171. package/dist/dom/query.js.map +0 -1
  172. package/dist/dom/refs.d.ts.map +0 -1
  173. package/dist/dom/refs.js.map +0 -1
  174. package/dist/dom/snapshot.d.ts.map +0 -1
  175. package/dist/dom/snapshot.js.map +0 -1
  176. package/dist/dom/source.d.ts.map +0 -1
  177. package/dist/dom/source.js.map +0 -1
  178. package/dist/dom/theme.d.ts.map +0 -1
  179. package/dist/dom/theme.js.map +0 -1
  180. package/dist/index.d.ts.map +0 -1
  181. package/dist/index.js.map +0 -1
  182. package/dist/observers/animation.d.ts.map +0 -1
  183. package/dist/observers/animation.js.map +0 -1
  184. package/dist/observers/blind-spots.d.ts.map +0 -1
  185. package/dist/observers/blind-spots.js.map +0 -1
  186. package/dist/observers/console.d.ts.map +0 -1
  187. package/dist/observers/console.js.map +0 -1
  188. package/dist/observers/dom.d.ts.map +0 -1
  189. package/dist/observers/dom.js.map +0 -1
  190. package/dist/observers/focus.d.ts.map +0 -1
  191. package/dist/observers/focus.js.map +0 -1
  192. package/dist/observers/health.d.ts.map +0 -1
  193. package/dist/observers/health.js.map +0 -1
  194. package/dist/observers/network-body.d.ts.map +0 -1
  195. package/dist/observers/network-body.js.map +0 -1
  196. package/dist/observers/network-redact.d.ts.map +0 -1
  197. package/dist/observers/network-redact.js.map +0 -1
  198. package/dist/observers/network.d.ts.map +0 -1
  199. package/dist/observers/network.js.map +0 -1
  200. package/dist/observers/perf.d.ts.map +0 -1
  201. package/dist/observers/perf.js.map +0 -1
  202. package/dist/observers/route.d.ts.map +0 -1
  203. package/dist/observers/route.js.map +0 -1
  204. package/dist/observers/scroll.d.ts.map +0 -1
  205. package/dist/observers/scroll.js.map +0 -1
  206. package/dist/observers/state.d.ts.map +0 -1
  207. package/dist/observers/state.js.map +0 -1
  208. package/dist/observers/storage.d.ts.map +0 -1
  209. package/dist/observers/storage.js.map +0 -1
  210. package/dist/observers/types.d.ts.map +0 -1
  211. package/dist/observers/types.js.map +0 -1
  212. package/dist/patching/capture-method.d.ts.map +0 -1
  213. package/dist/patching/capture-method.js.map +0 -1
  214. package/dist/presenter/overlay.d.ts.map +0 -1
  215. package/dist/presenter/overlay.js.map +0 -1
  216. package/dist/presenter/presenter-config.d.ts.map +0 -1
  217. package/dist/presenter/presenter-config.js.map +0 -1
  218. package/dist/presenter/presenter-controls.d.ts.map +0 -1
  219. package/dist/presenter/presenter-controls.js.map +0 -1
  220. package/dist/presenter/presenter-effects.d.ts.map +0 -1
  221. package/dist/presenter/presenter-effects.js.map +0 -1
  222. package/dist/presenter/presenter-glow.d.ts.map +0 -1
  223. package/dist/presenter/presenter-glow.js.map +0 -1
  224. package/dist/presenter/presenter-log.d.ts.map +0 -1
  225. package/dist/presenter/presenter-log.js.map +0 -1
  226. package/dist/presenter/presenter-run-state.d.ts.map +0 -1
  227. package/dist/presenter/presenter-run-state.js.map +0 -1
  228. package/dist/presenter/presenter-styles.d.ts.map +0 -1
  229. package/dist/presenter/presenter-styles.js.map +0 -1
  230. package/dist/presenter/presenter-tally.d.ts.map +0 -1
  231. package/dist/presenter/presenter-tally.js.map +0 -1
  232. package/dist/presenter/presenter-test-helpers.d.ts.map +0 -1
  233. package/dist/presenter/presenter-test-helpers.js.map +0 -1
  234. package/dist/presenter/presenter-verbs.d.ts.map +0 -1
  235. package/dist/presenter/presenter-verbs.js.map +0 -1
  236. package/dist/presenter/presenter.d.ts.map +0 -1
  237. package/dist/presenter/presenter.js.map +0 -1
  238. package/dist/recorder/recorder-styles.d.ts.map +0 -1
  239. package/dist/recorder/recorder-styles.js.map +0 -1
  240. package/dist/recorder/recorder.d.ts.map +0 -1
  241. package/dist/recorder/recorder.js.map +0 -1
  242. package/dist/registry/adapters.d.ts.map +0 -1
  243. package/dist/registry/adapters.js.map +0 -1
  244. package/dist/registry/capabilities.d.ts.map +0 -1
  245. package/dist/registry/capabilities.js.map +0 -1
  246. package/dist/registry/commit-and-signal.d.ts.map +0 -1
  247. package/dist/registry/commit-and-signal.js.map +0 -1
  248. package/dist/registry/domains.d.ts.map +0 -1
  249. package/dist/registry/domains.js.map +0 -1
  250. package/dist/registry/emitter.d.ts.map +0 -1
  251. package/dist/registry/emitter.js.map +0 -1
  252. package/dist/registry/store-adapters.d.ts.map +0 -1
  253. package/dist/registry/store-adapters.js.map +0 -1
  254. package/dist/registry/stores.d.ts.map +0 -1
  255. package/dist/registry/stores.js.map +0 -1
  256. package/dist/reticle-presenter-helpers.d.ts.map +0 -1
  257. package/dist/reticle-presenter-helpers.js.map +0 -1
  258. package/dist/reticle.d.ts.map +0 -1
  259. package/dist/reticle.js.map +0 -1
  260. package/dist/review/annotator.d.ts.map +0 -1
  261. package/dist/review/annotator.js.map +0 -1
  262. package/dist/review/mark-anchor.d.ts.map +0 -1
  263. package/dist/review/mark-anchor.js.map +0 -1
  264. package/dist/security/serialization.d.ts.map +0 -1
  265. package/dist/security/serialization.js.map +0 -1
  266. package/dist/timers/clock.d.ts.map +0 -1
  267. package/dist/timers/clock.js.map +0 -1
  268. package/dist/timers/native-console.d.ts.map +0 -1
  269. package/dist/timers/native-console.js.map +0 -1
  270. package/dist/timers/native-timers.d.ts.map +0 -1
  271. package/dist/timers/native-timers.js.map +0 -1
  272. package/dist/transport/transport.d.ts.map +0 -1
  273. package/dist/transport/transport.js.map +0 -1
@@ -8,13 +8,16 @@ import { refs } from '../dom/refs.js';
8
8
  * container persists across ticks, so its testid (or ref) is the identity that does accumulate.
9
9
  */
10
10
  function regionKeyOf(target) {
11
- const el = target instanceof Element ? target : null;
11
+ const el = isElement(target) ? target : null;
12
12
  if (el === null)
13
13
  return undefined;
14
14
  const labelled = el.closest('[data-testid]');
15
15
  return labelled?.getAttribute('data-testid') ?? refs.refFor(el);
16
16
  }
17
17
  import { isReticleOverlay } from '../dom/dom-ignore.js';
18
+ import { capturedRoots, installShadowRegistry, onShadowRoot } from '../dom/shadow-registry.js';
19
+ import { isElement } from '../dom/realm.js';
20
+ import { observeSameOriginFrames } from './frames.js';
18
21
  const WATCHED_ATTRS = [
19
22
  'class',
20
23
  'hidden',
@@ -46,7 +49,30 @@ function isMeaningful(role, name) {
46
49
  return role !== 'generic' || name.length > 0;
47
50
  }
48
51
  /** Observe DOM mutations and emit semantic (not raw) events. */
52
+ /**
53
+ * The trimmed text carried by a node list, or undefined when it carries none.
54
+ *
55
+ * Whitespace-only nodes are ignored deliberately: a re-render that reshuffles indentation would
56
+ * otherwise emit a text change on every layout node, which is noise the timeline cannot afford.
57
+ */
58
+ const TEXT_NODE = 3;
59
+ function textOf(nodes) {
60
+ let out = '';
61
+ for (const node of nodes) {
62
+ // The numeric nodeType, not `Node.TEXT_NODE`: the global `Node` constructor is not guaranteed to
63
+ // exist wherever this runs, and referencing it threw a ReferenceError rather than degrading.
64
+ if (node.nodeType !== TEXT_NODE)
65
+ continue;
66
+ out += node.nodeValue ?? '';
67
+ }
68
+ const trimmed = out.trim();
69
+ return trimmed.length > 0 ? trimmed : undefined;
70
+ }
49
71
  export function installDom(emit) {
72
+ // Installed here rather than in the bootstrap list because this observer is the registry's reason
73
+ // for existing, and the ordering matters: a root attached between the two would be missed by both.
74
+ // It is also the only chance to capture a CLOSED root, which attachShadow returns exactly once.
75
+ const stopRegistry = installShadowRegistry();
50
76
  const observer = new MutationObserver((records) => {
51
77
  let added = 0;
52
78
  let removed = 0;
@@ -58,9 +84,7 @@ export function installDom(emit) {
58
84
  for (const record of records) {
59
85
  if (record.type === 'attributes') {
60
86
  const target = record.target;
61
- if (target instanceof Element &&
62
- record.attributeName !== null &&
63
- !isReticleOverlay(target)) {
87
+ if (isElement(target) && record.attributeName !== null && !isReticleOverlay(target)) {
64
88
  if (changed >= MAX_PER_BATCH) {
65
89
  dropped += 1;
66
90
  continue;
@@ -93,8 +117,31 @@ export function installDom(emit) {
93
117
  }
94
118
  continue;
95
119
  }
120
+ // A TEXT node arriving or leaving is a text change, and it is the ordinary one. `characterData`
121
+ // fires only when an EXISTING text node's `data` is edited in place; `el.textContent = x`,
122
+ // `innerText`, and React replacing a child all REPLACE the node instead — a childList mutation
123
+ // carrying a Text node, which the Element-only loops below skip. Measured on a live page:
124
+ // `firstChild.data = x` emitted `dom.text`, while `textContent`, `innerText` and
125
+ // `appendChild(createTextNode())` emitted NOTHING. So the most common visible change an app
126
+ // makes — updating text — produced no event at all, `reticle_observe` reported "nothing
127
+ // happened" over a screen that had visibly changed, and `settled` went quiet immediately.
128
+ if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
129
+ const parent = isElement(record.target) ? record.target : null;
130
+ if (parent !== null && !isReticleOverlay(parent)) {
131
+ if (changed >= MAX_PER_BATCH)
132
+ dropped += 1;
133
+ else {
134
+ changed += 1;
135
+ // The element's text AFTER the mutation, so a cleared node reports '' rather than the
136
+ // text that just left — the reader wants what is on screen now.
137
+ const text = (parent.textContent ?? '').trim().slice(0, 80);
138
+ const old = capValue(textOf(record.removedNodes) ?? null);
139
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
140
+ }
141
+ }
142
+ }
96
143
  for (const node of record.addedNodes) {
97
- if (!(node instanceof Element))
144
+ if (!isElement(node))
98
145
  continue;
99
146
  if (added >= MAX_PER_BATCH) {
100
147
  dropped += 1;
@@ -117,7 +164,7 @@ export function installDom(emit) {
117
164
  }
118
165
  }
119
166
  for (const node of record.removedNodes) {
120
- if (!(node instanceof Element))
167
+ if (!isElement(node))
121
168
  continue;
122
169
  if (removed >= MAX_PER_BATCH) {
123
170
  dropped += 1;
@@ -139,7 +186,7 @@ export function installDom(emit) {
139
186
  if (dropped > 0)
140
187
  emit(EventType.TRUNCATED, { channel: TruncationChannel.DOM, dropped });
141
188
  });
142
- observer.observe(document.documentElement, {
189
+ const options = {
143
190
  subtree: true,
144
191
  childList: true,
145
192
  attributes: true,
@@ -147,9 +194,20 @@ export function installDom(emit) {
147
194
  attributeOldValue: true,
148
195
  characterData: true,
149
196
  characterDataOldValue: true,
150
- });
197
+ };
198
+ observer.observe(document.documentElement, options);
199
+ // A MutationObserver does NOT cross a shadow boundary, so every root needs its own observation.
200
+ // Without this, a click that changed text inside a web component produced an empty observe window —
201
+ // the same shape as an app that ignored the click entirely.
202
+ for (const root of capturedRoots())
203
+ observer.observe(root, options);
204
+ const unsubscribe = onShadowRoot((root) => observer.observe(root, options));
205
+ // Same-origin frame documents are separate node trees for the same reason, with the same symptom.
206
+ const stopFrames = observeSameOriginFrames((body) => observer.observe(body, options));
151
207
  return () => {
208
+ stopFrames();
209
+ unsubscribe();
210
+ stopRegistry();
152
211
  observer.disconnect();
153
212
  };
154
213
  }
155
- //# sourceMappingURL=dom.js.map
@@ -0,0 +1,6 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export interface DownloadOptions {
3
+ /** Include a content preview. Gated for the same reason request bodies are: exports hold PII. */
4
+ capturePreview?: boolean;
5
+ }
6
+ export declare function installDownload(emit: Emit, opts?: DownloadOptions): Teardown;
@@ -0,0 +1,103 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ import { captureMethod } from '../patching/capture-method.js';
3
+ import { projectBody } from './network-body.js';
4
+ /**
5
+ * Files the app PRODUCES — the one artifact class an outside-the-browser tool cannot inspect.
6
+ *
7
+ * A generated export never crosses the network: the app builds a Blob, hands it to
8
+ * `URL.createObjectURL`, and clicks an anchor. There is no request to intercept, and Playwright MCP
9
+ * has no tool that returns a downloaded file at all. Reticle runs inside the page, where the Blob is
10
+ * constructed, so the export is observable before it reaches the disk.
11
+ *
12
+ * Measured on a real merchant dashboard: one Export click produced a CSV wrong three ways at once —
13
+ * 25 data rows in a file named `transactions-128-rows.csv`, an unquoted `3 Aug, 10:00 pm` that
14
+ * shifts every column after it, and an `amount_inr` header over rows denominated in USD — while the
15
+ * click itself reported a clean success and every other channel agreed. The benchmark's ground truth
16
+ * records those three as missed by BOTH toolchains, for the same reason: nobody opened the file.
17
+ */
18
+ /** Text-like types worth counting lines in and previewing. Anything else reports type + size only. */
19
+ const TEXTUAL = /text\/|json|csv|xml|ndjson|javascript/i;
20
+ /** Lines are reported for text files; the preview is capped and redacted by projectBody. */
21
+ const PREVIEW_CHARS = 2000;
22
+ export function installDownload(emit, opts = {}) {
23
+ // jsdom does not implement `createObjectURL`, and plenty of users run this SDK under jsdom in their
24
+ // own test suite. Reaching for `.bind` on an absent function threw during `connect()` — an
25
+ // observability layer taking the host app down with it, which is the one failure mode it may never
26
+ // have. No object URLs means no generated files to observe, so this simply stands down.
27
+ if (typeof URL.createObjectURL !== 'function')
28
+ return () => undefined;
29
+ const objectUrl = URL.createObjectURL.bind(URL);
30
+ const known = new Map();
31
+ /**
32
+ * `createObjectURL` is where the file becomes real. The filename is NOT here — it lives on the
33
+ * anchor that gets clicked — so the blob is remembered against its URL and reported when either
34
+ * the anchor fires or, failing that, on a microtask so a download that is never clicked (a
35
+ * generated preview, an <img> src) is still visible.
36
+ */
37
+ const originalCreate = captureMethod(URL, 'createObjectURL');
38
+ URL.createObjectURL = function reticleObservedObjectUrl(obj) {
39
+ const url = objectUrl(obj);
40
+ if (typeof Blob !== 'undefined' && obj instanceof Blob) {
41
+ // Started here, awaited at report time — never blocking the app's own download path.
42
+ const text = TEXTUAL.test(obj.type)
43
+ ? obj.text().catch(() => undefined)
44
+ : Promise.resolve(undefined);
45
+ known.set(url, { type: obj.type, size: obj.size, text });
46
+ }
47
+ return url;
48
+ };
49
+ const report = (url, filename) => {
50
+ const record = known.get(url);
51
+ if (record === undefined)
52
+ return;
53
+ known.delete(url);
54
+ void record.text.then((text) => {
55
+ emitDownload(record, filename, text);
56
+ });
57
+ };
58
+ const emitDownload = (record, filename, text) => {
59
+ const data = { mimeType: record.type, bytes: record.size };
60
+ if (filename !== undefined && filename.length > 0)
61
+ data['filename'] = filename;
62
+ if (text !== undefined) {
63
+ // The LINE COUNT is reported even without preview capture: it is a number, not content, and it
64
+ // is the whole tell for "25 rows in a file the toast calls 128".
65
+ data['lines'] = text.split('\n').filter((l) => l.length > 0).length;
66
+ if (opts.capturePreview === true) {
67
+ const { body, truncated } = projectBody(text.slice(0, PREVIEW_CHARS), record.type);
68
+ data['preview'] = body;
69
+ if (truncated)
70
+ data['previewTruncated'] = true;
71
+ }
72
+ }
73
+ emit(EventType.DOWNLOAD, data);
74
+ };
75
+ /**
76
+ * An anchor click carries the NAME. Captured at the document level so it sees the click whether the
77
+ * app dispatches it programmatically or a human does, and whether or not the anchor is in the DOM.
78
+ */
79
+ const onClick = (event) => {
80
+ const target = event.target;
81
+ const anchor = target instanceof Element ? target.closest('a[download]') : null;
82
+ if (anchor === null)
83
+ return;
84
+ report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
85
+ };
86
+ document.addEventListener('click', onClick, true);
87
+ // A programmatic `a.click()` on an anchor never inserted into the document dispatches no bubbling
88
+ // event this listener can see, so the anchor path is patched too.
89
+ const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
90
+ HTMLAnchorElement.prototype.click = function reticleObservedAnchorClick() {
91
+ if (this.hasAttribute('download')) {
92
+ report(this.getAttribute('href') ?? '', this.getAttribute('download') ?? undefined);
93
+ }
94
+ originalClick.call(this);
95
+ };
96
+ return () => {
97
+ // A REAL restore, so teardown leaves the page exactly as it was found.
98
+ document.removeEventListener('click', onClick, true);
99
+ URL.createObjectURL = originalCreate;
100
+ HTMLAnchorElement.prototype.click = originalClick;
101
+ known.clear();
102
+ };
103
+ }
@@ -7,4 +7,3 @@ export declare function focusLabel(el: EventTarget | null): string | undefined;
7
7
  * FOCUS_CHANGE {to, from, toBody}. Reversible.
8
8
  */
9
9
  export declare function installFocus(emit: Emit): Teardown;
10
- //# sourceMappingURL=focus.d.ts.map
@@ -46,4 +46,3 @@ export function installFocus(emit) {
46
46
  document.removeEventListener('focusout', onFocusOut);
47
47
  };
48
48
  }
49
- //# sourceMappingURL=focus.js.map
@@ -0,0 +1,12 @@
1
+ import type { Teardown } from './types.js';
2
+ /** Every same-origin frame body at or beneath `root`, in document order. */
3
+ 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
+ export declare function observeSameOriginFrames(attach: (body: HTMLElement) => void): Teardown;
@@ -0,0 +1,67 @@
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
+ const DEPTH_MAX = 3;
19
+ function bodyOf(frame) {
20
+ try {
21
+ return frame.contentDocument?.body ?? null;
22
+ }
23
+ catch {
24
+ return null; // cross-origin — not ours to read
25
+ }
26
+ }
27
+ /** Every same-origin frame body at or beneath `root`, in document order. */
28
+ export function sameOriginFrameBodies(root) {
29
+ const found = [];
30
+ const walk = (node, depth) => {
31
+ if (depth >= DEPTH_MAX)
32
+ return;
33
+ for (const frame of node.querySelectorAll('iframe')) {
34
+ const body = bodyOf(frame);
35
+ if (body === null)
36
+ continue;
37
+ found.push(body);
38
+ walk(body, depth + 1);
39
+ }
40
+ };
41
+ walk(root, 0);
42
+ return found;
43
+ }
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
+ export function observeSameOriginFrames(attach) {
53
+ const attachAll = () => {
54
+ for (const body of sameOriginFrameBodies(document))
55
+ attach(body);
56
+ };
57
+ attachAll();
58
+ // `load` does not bubble, but it DOES reach a capturing listener on the document.
59
+ const onLoad = (event) => {
60
+ if (event.target instanceof HTMLIFrameElement)
61
+ attachAll();
62
+ };
63
+ document.addEventListener('load', onLoad, true);
64
+ return () => {
65
+ document.removeEventListener('load', onLoad, true);
66
+ };
67
+ }
@@ -5,4 +5,3 @@ import type { Emit, Teardown } from './types.js';
5
5
  * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
6
6
  */
7
7
  export declare function installHealth(emit: Emit): Teardown;
8
- //# sourceMappingURL=health.d.ts.map
@@ -1,9 +1,38 @@
1
- import { EventType, HealthReason, SESSION_HEALTH } from '@reticlehq/core';
1
+ import { EventType, HealthReason, RETICLE_IPC_GLOBAL, SESSION_HEALTH } from '@reticlehq/core';
2
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
+ */
9
+ function detectRuntime() {
10
+ const w = window;
11
+ if (w['__TAURI_INTERNALS__'] !== undefined || w['__TAURI__'] !== undefined)
12
+ return 'tauri';
13
+ if (navigator.userAgent.includes('Electron') || w[RETICLE_IPC_GLOBAL] !== undefined)
14
+ return 'electron';
15
+ return 'web';
16
+ }
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
+ function detectEngine() {
23
+ const ua = navigator.userAgent;
24
+ if (/Chrome|Chromium|Edg\//.test(ua))
25
+ return 'blink';
26
+ if (/Gecko\/|Firefox/.test(ua))
27
+ return 'gecko';
28
+ return 'webkit';
29
+ }
3
30
  function snapshotHealth() {
4
31
  return {
5
32
  hidden: document.visibilityState === 'hidden',
6
33
  focused: document.hasFocus(),
34
+ runtime: detectRuntime(),
35
+ engine: detectEngine(),
7
36
  };
8
37
  }
9
38
  /**
@@ -30,4 +59,3 @@ export function installHealth(emit) {
30
59
  window.removeEventListener('blur', onBlur);
31
60
  };
32
61
  }
33
- //# sourceMappingURL=health.js.map
@@ -0,0 +1,5 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export interface InstallOptions {
3
+ captureBodies: boolean;
4
+ }
5
+ export declare function installAllObservers(emit: Emit, options: InstallOptions): Teardown[];
@@ -0,0 +1,63 @@
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
+ import { installDom } from './dom.js';
15
+ import { installStorage } from './storage.js';
16
+ import { installStoreState } from './state.js';
17
+ import { installFocus } from './focus.js';
18
+ import { installBlindSpots } from './blind-spots.js';
19
+ import { installDownload } from './download.js';
20
+ import { installNetwork } from './network.js';
21
+ import { installIpc, ipcNetOverrides } from './ipc.js';
22
+ import { installPerf } from './perf.js';
23
+ import { installRoute } from './route.js';
24
+ import { installConsole } from './console.js';
25
+ import { installAnimation } from './animation.js';
26
+ import { installScroll } from './scroll.js';
27
+ import { installHealth } from './health.js';
28
+ import { reportSdkFailure, SdkSite } from './sdk-failure.js';
29
+ /** Run one install, reporting rather than throwing if it cannot start. */
30
+ function guard(emit, site, install) {
31
+ try {
32
+ return install();
33
+ }
34
+ catch (error) {
35
+ reportSdkFailure(emit, site, error);
36
+ return () => { }; // nothing was installed, so there is nothing to tear down
37
+ }
38
+ }
39
+ export function installAllObservers(emit, options) {
40
+ return [
41
+ // Composition happens HERE, not inside the network observer: the network observer knows
42
+ // nothing about desktop IPC, and the IPC observer knows nothing about fetch plumbing.
43
+ guard(emit, SdkSite.NETWORK_OBSERVER, () => installNetwork(emit, {
44
+ captureBodies: options.captureBodies,
45
+ reinterpret: ipcNetOverrides,
46
+ })),
47
+ // Desktop backends are reached over IPC, not HTTP — inert on a plain web page.
48
+ guard(emit, SdkSite.IPC_OBSERVER, () => installIpc(emit, { captureBodies: options.captureBodies })),
49
+ guard(emit, SdkSite.ANIMATION_OBSERVER, () => installPerf(emit)),
50
+ guard(emit, SdkSite.ROUTER_OBSERVER, () => installRoute(emit)),
51
+ guard(emit, SdkSite.CONSOLE_OBSERVER, () => installConsole(emit)),
52
+ guard(emit, SdkSite.ANIMATION_OBSERVER, () => installAnimation(emit)),
53
+ guard(emit, SdkSite.DOM_OBSERVER, () => installScroll(emit)),
54
+ guard(emit, SdkSite.DOM_OBSERVER, () => installDom(emit)),
55
+ guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)), // storage WRITES → STORAGE_CHANGE diffs (pull remains the fallback)
56
+ guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)), // subscribed-store mutations → STATE_CHANGE path diffs
57
+ guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)), // element focus movement → FOCUS_CHANGE (focus-to-body = a regression)
58
+ // Files the app PRODUCES — never cross the network, so no outside-the-page tool can see them.
59
+ guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
60
+ guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)), // cross-origin iframes the SDK can't see → BLIND_SPOT (coverage: partial)
61
+ guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)), // page visibility/focus health + heartbeat
62
+ ];
63
+ }
@@ -0,0 +1,22 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ /**
3
+ * How Tauri v2 reports a command's verdict. The transport answers HTTP 200 whether the Rust command
4
+ * returned Ok or Err — the verdict is in this response header, which Tauri explicitly CORS-exposes so
5
+ * JS may read it. Without translating it, every failed command is recorded as a successful request.
6
+ */
7
+ export declare const TAURI_RESPONSE_HEADER_NAME = "Tauri-Response";
8
+ /**
9
+ * Fields to override on a NET_REQUEST that turned out to be a Tauri IPC call, or null when the
10
+ * request was ordinary HTTP. Pure — the network observer calls this with the response header it
11
+ * already holds, so IPC knowledge stays in this file.
12
+ */
13
+ export declare function ipcNetOverrides(url: string, header: (name: string) => string | null): Record<string, unknown> | undefined;
14
+ /**
15
+ * Subscribe to the Electron preload shim, if this app installed it. Inert on Tauri (whose calls the
16
+ * network observer handles) and on a plain web page.
17
+ */
18
+ export interface IpcOptions {
19
+ /** Forward the IPC payloads the preload recorded. Off by default, like the HTTP body capture. */
20
+ captureBodies?: boolean;
21
+ }
22
+ export declare function installIpc(emit: Emit, options?: IpcOptions): Teardown;
@@ -0,0 +1,121 @@
1
+ import { BlindSpotKind, EventType, IPC_URL_SCHEME, IpcStatus, NetInitiator, RETICLE_IPC_GLOBAL, } from '@reticlehq/core';
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
+ export const TAURI_RESPONSE_HEADER_NAME = 'Tauri-Response';
9
+ const TAURI_RESPONSE_ERROR = 'error';
10
+ /** Rust/IPC vocabulary rather than HTTP's, so the text never claims a transport status it lacks. */
11
+ 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
+ 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
+ export function ipcNetOverrides(url, header) {
31
+ const match = TAURI_IPC_URL.exec(url);
32
+ const command = match?.[1];
33
+ if (command === undefined)
34
+ return undefined;
35
+ const ok = header(TAURI_RESPONSE_HEADER_NAME) !== TAURI_RESPONSE_ERROR;
36
+ 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
+ url: `${IPC_URL_SCHEME}${command}`,
40
+ initiator: NetInitiator.IPC,
41
+ method: NetInitiator.IPC,
42
+ ok,
43
+ 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
+ statusText: ok ? IPC_STATUS_TEXT.OK : IPC_STATUS_TEXT.ERROR,
49
+ };
50
+ }
51
+ /**
52
+ * Is this page an Electron renderer? Read off the user agent, which Electron stamps and which is
53
+ * available whether or not the preload ran — the whole point is to recognise a renderer that has
54
+ * NOT been instrumented.
55
+ */
56
+ function isElectronRenderer() {
57
+ return navigator.userAgent.includes('Electron');
58
+ }
59
+ export function installIpc(emit, options = {}) {
60
+ const channel = window[RETICLE_IPC_GLOBAL];
61
+ if (typeof channel?.subscribe !== 'function') {
62
+ // No shim — but on Electron that is not "nothing to observe", it is "everything unobserved".
63
+ // Tauri needs no shim (its invoke travels as a fetch) and a web page has no IPC at all, so only
64
+ // Electron gets the declaration. Saying nothing here is what let a missing preload line read as
65
+ // a clean, empty network view for a whole app.
66
+ if (isElectronRenderer())
67
+ emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.UNOBSERVED_IPC, count: 1 });
68
+ return () => undefined;
69
+ }
70
+ const token = channel.subscribe((record) => {
71
+ observeSafely(() => {
72
+ const url = `${IPC_URL_SCHEME}${record.channel}`;
73
+ if (record.phase === 'start') {
74
+ emit(EventType.NET_PENDING, {
75
+ id: record.id,
76
+ method: NetInitiator.IPC,
77
+ url,
78
+ initiator: NetInitiator.IPC,
79
+ });
80
+ return;
81
+ }
82
+ // A one-way `send` that did not throw has NO verdict — the renderer never learns whether the
83
+ // main process handled it. Emitting `ok/status` anyway would manufacture the success nobody
84
+ // reported, so both are omitted and `oneWay` says why. `reticle_network { ok }` then matches
85
+ // such a record in neither direction, which is the honest answer to "did it work".
86
+ const verdictless = record.oneWay === true && record.ok === undefined;
87
+ // Declared, not inferred: the server cannot tell "no verdict" from "not yet settled" without
88
+ // being told, and a send with nothing to observe must not read as a clean green.
89
+ if (verdictless)
90
+ emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.VERDICTLESS_SEND, count: 1 });
91
+ const ok = record.ok === true;
92
+ emit(EventType.NET_REQUEST, {
93
+ id: record.id,
94
+ method: NetInitiator.IPC,
95
+ url,
96
+ ...(verdictless ? {} : { ok, status: ok ? IpcStatus.OK : IpcStatus.ERROR }),
97
+ ...(record.oneWay === true ? { oneWay: true } : {}),
98
+ durationMs: record.durationMs ?? 0,
99
+ initiator: NetInitiator.IPC,
100
+ ...(record.error === undefined ? {} : { error: record.error }),
101
+ // Size travels ALWAYS, bodies only on opt-in. The split matters: the verdict layer reports "a
102
+ // write returned ok and its payload went unread" from the size alone, and it cannot infer that
103
+ // from an absent field — an unread payload and an empty one would look identical.
104
+ ...(record.responseSize === undefined ? {} : { responseSize: record.responseSize }),
105
+ ...(options.captureBodies === true
106
+ ? {
107
+ ...(record.requestBody === undefined ? {} : { requestBody: record.requestBody }),
108
+ ...(record.responseBody === undefined ? {} : { responseBody: record.responseBody }),
109
+ ...(record.responseBodyTruncated === true ? { responseBodyTruncated: true } : {}),
110
+ }
111
+ : {}),
112
+ });
113
+ });
114
+ });
115
+ // A REAL removal, so teardown leaves the app as it found it. The preload's `invoke` patch stays
116
+ // deliberately — it is a pass-through with no effect once no subscriber remains, and un-patching
117
+ // mid-flight would strand any call already inside the wrapper.
118
+ return () => {
119
+ channel.unsubscribe?.(token);
120
+ };
121
+ }
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Is this response body still arriving after the request itself resolved?
3
+ *
4
+ * STRUCTURAL, not a content-type allowlist. The first version listed `x-component|x-ndjson|
5
+ * application/stream` — which is to say "Next.js RSC, plus two guesses". It closed the exact defect
6
+ * it was written against and left every other streamed response as invisible as before: a chunked
7
+ * `application/json` from a token API, a streamed HTML document, an export assembled server-side.
8
+ * Fixing one app's symptom and calling the class solved is how a false green comes back wearing
9
+ * different clothes.
10
+ *
11
+ * The real signal is on the wire: a response with a BODY and no `content-length` is being sent
12
+ * chunked, which is the definition of "the server is still writing". `content-length` present means
13
+ * the size was known up front and the body cannot grow.
14
+ *
15
+ * Deliberately independent of content type, with one exception above for streams that never end.
16
+ */
17
+ export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
1
18
  /**
2
19
  * Resolve with the body text, or `undefined` if it takes too long.
3
20
  *
@@ -16,4 +33,3 @@ export declare function projectBody(rawText: string, contentType: string | null)
16
33
  body: string;
17
34
  truncated: boolean;
18
35
  };
19
- //# sourceMappingURL=network-body.d.ts.map