@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.
- package/dist/actions/actions-dom.d.ts +0 -1
- package/dist/actions/actions-dom.js +0 -1
- package/dist/actions/actions.d.ts +3 -1
- package/dist/actions/actions.js +55 -36
- package/dist/actions/scroll.d.ts +0 -1
- package/dist/actions/scroll.js +0 -1
- package/dist/actions/value-input.d.ts +33 -0
- package/dist/actions/value-input.js +63 -0
- package/dist/commands/commands.d.ts +0 -1
- package/dist/commands/commands.js +27 -7
- package/dist/connect-options.d.ts +97 -0
- package/dist/connect-options.js +1 -0
- package/dist/dom/a11y.d.ts +0 -2
- package/dist/dom/a11y.js +54 -24
- package/dist/dom/auto-anchor.d.ts +0 -1
- package/dist/dom/auto-anchor.js +0 -1
- package/dist/dom/blind-spots.d.ts +0 -1
- package/dist/dom/blind-spots.js +0 -1
- package/dist/dom/chart.d.ts +0 -1
- package/dist/dom/chart.js +0 -1
- package/dist/dom/desktop-capture.d.ts +36 -0
- package/dist/dom/desktop-capture.js +113 -0
- package/dist/dom/dom-ignore.d.ts +7 -1
- package/dist/dom/dom-ignore.js +1 -2
- package/dist/dom/occlusion.d.ts +0 -1
- package/dist/dom/occlusion.js +0 -1
- package/dist/dom/query.d.ts +0 -1
- package/dist/dom/query.js +61 -13
- package/dist/dom/realm.d.ts +30 -0
- package/dist/dom/realm.js +58 -0
- package/dist/dom/refs.d.ts +0 -1
- package/dist/dom/refs.js +0 -1
- package/dist/dom/shadow-registry.d.ts +40 -0
- package/dist/dom/shadow-registry.js +165 -0
- package/dist/dom/snapshot.d.ts +2 -2
- package/dist/dom/snapshot.js +21 -4
- package/dist/dom/source.d.ts +0 -1
- package/dist/dom/source.js +0 -1
- package/dist/dom/theme.d.ts +0 -1
- package/dist/dom/theme.js +0 -1
- package/dist/dom/virtualized.d.ts +19 -0
- package/dist/dom/virtualized.js +80 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -3
- package/dist/observers/animation.d.ts +0 -1
- package/dist/observers/animation.js +0 -1
- package/dist/observers/blind-spots.d.ts +16 -4
- package/dist/observers/blind-spots.js +65 -11
- package/dist/observers/console.d.ts +0 -1
- package/dist/observers/console.js +0 -1
- package/dist/observers/dom.d.ts +0 -2
- package/dist/observers/dom.js +67 -9
- package/dist/observers/download.d.ts +6 -0
- package/dist/observers/download.js +103 -0
- package/dist/observers/focus.d.ts +0 -1
- package/dist/observers/focus.js +0 -1
- package/dist/observers/frames.d.ts +12 -0
- package/dist/observers/frames.js +67 -0
- package/dist/observers/health.d.ts +0 -1
- package/dist/observers/health.js +30 -2
- package/dist/observers/install-all.d.ts +5 -0
- package/dist/observers/install-all.js +63 -0
- package/dist/observers/ipc.d.ts +22 -0
- package/dist/observers/ipc.js +121 -0
- package/dist/observers/network-body.d.ts +17 -1
- package/dist/observers/network-body.js +26 -1
- package/dist/observers/network-redact.d.ts +0 -1
- package/dist/observers/network-redact.js +6 -3
- package/dist/observers/network-stream.d.ts +23 -0
- package/dist/observers/network-stream.js +102 -0
- package/dist/observers/network.d.ts +11 -1
- package/dist/observers/network.js +7 -1
- package/dist/observers/perf.d.ts +0 -1
- package/dist/observers/perf.js +0 -1
- package/dist/observers/route.d.ts +0 -1
- package/dist/observers/route.js +0 -1
- package/dist/observers/scroll.d.ts +0 -1
- package/dist/observers/scroll.js +0 -1
- package/dist/observers/sdk-failure.d.ts +28 -0
- package/dist/observers/sdk-failure.js +60 -0
- package/dist/observers/state.d.ts +0 -1
- package/dist/observers/state.js +0 -1
- package/dist/observers/storage.d.ts +0 -1
- package/dist/observers/storage.js +0 -1
- package/dist/observers/types.d.ts +0 -1
- package/dist/observers/types.js +0 -1
- package/dist/patching/capture-method.d.ts +0 -1
- package/dist/patching/capture-method.js +0 -1
- package/dist/presenter/overlay.d.ts +0 -1
- package/dist/presenter/overlay.js +0 -1
- package/dist/presenter/presenter-config.d.ts +0 -1
- package/dist/presenter/presenter-config.js +0 -1
- package/dist/presenter/presenter-controls.d.ts +0 -1
- package/dist/presenter/presenter-controls.js +0 -1
- package/dist/presenter/presenter-effects.d.ts +0 -1
- package/dist/presenter/presenter-effects.js +0 -1
- package/dist/presenter/presenter-glow.d.ts +0 -1
- package/dist/presenter/presenter-glow.js +0 -1
- package/dist/presenter/presenter-log.d.ts +0 -1
- package/dist/presenter/presenter-log.js +0 -1
- package/dist/presenter/presenter-run-state.d.ts +0 -1
- package/dist/presenter/presenter-run-state.js +0 -1
- package/dist/presenter/presenter-styles.d.ts +0 -1
- package/dist/presenter/presenter-styles.js +0 -1
- package/dist/presenter/presenter-tally.d.ts +0 -1
- package/dist/presenter/presenter-tally.js +0 -1
- package/dist/presenter/presenter-test-helpers.d.ts +0 -1
- package/dist/presenter/presenter-test-helpers.js +0 -1
- package/dist/presenter/presenter-verbs.d.ts +0 -1
- package/dist/presenter/presenter-verbs.js +0 -1
- package/dist/presenter/presenter.d.ts +0 -1
- package/dist/presenter/presenter.js +0 -1
- package/dist/recorder/recorder-styles.d.ts +0 -1
- package/dist/recorder/recorder-styles.js +0 -1
- package/dist/recorder/recorder.d.ts +0 -1
- package/dist/recorder/recorder.js +0 -1
- package/dist/registry/adapters.d.ts +0 -1
- package/dist/registry/adapters.js +0 -1
- package/dist/registry/capabilities.d.ts +2 -1
- package/dist/registry/capabilities.js +16 -1
- package/dist/registry/commit-and-signal.d.ts +0 -1
- package/dist/registry/commit-and-signal.js +0 -1
- package/dist/registry/domains.d.ts +0 -1
- package/dist/registry/domains.js +0 -1
- package/dist/registry/emitter.d.ts +0 -1
- package/dist/registry/emitter.js +0 -1
- package/dist/registry/store-adapters.d.ts +141 -7
- package/dist/registry/store-adapters.js +178 -1
- package/dist/registry/stores.d.ts +0 -1
- package/dist/registry/stores.js +8 -1
- package/dist/reticle-presenter-helpers.d.ts +0 -1
- package/dist/reticle-presenter-helpers.js +0 -1
- package/dist/reticle.d.ts +5 -59
- package/dist/reticle.js +37 -33
- package/dist/review/annotator.d.ts +0 -1
- package/dist/review/annotator.js +0 -1
- package/dist/review/mark-anchor.d.ts +0 -1
- package/dist/review/mark-anchor.js +0 -1
- package/dist/security/serialization.d.ts +0 -1
- package/dist/security/serialization.js +0 -1
- package/dist/timers/clock.d.ts +0 -1
- package/dist/timers/clock.js +0 -1
- package/dist/timers/native-console.d.ts +0 -1
- package/dist/timers/native-console.js +0 -1
- package/dist/timers/native-timers.d.ts +0 -1
- package/dist/timers/native-timers.js +0 -1
- package/dist/transport/transport.d.ts +12 -6
- package/dist/transport/transport.js +107 -17
- package/package.json +13 -3
- package/dist/actions/actions-dom.d.ts.map +0 -1
- package/dist/actions/actions-dom.js.map +0 -1
- package/dist/actions/actions.d.ts.map +0 -1
- package/dist/actions/actions.js.map +0 -1
- package/dist/actions/scroll.d.ts.map +0 -1
- package/dist/actions/scroll.js.map +0 -1
- package/dist/commands/commands.d.ts.map +0 -1
- package/dist/commands/commands.js.map +0 -1
- package/dist/dom/a11y.d.ts.map +0 -1
- package/dist/dom/a11y.js.map +0 -1
- package/dist/dom/auto-anchor.d.ts.map +0 -1
- package/dist/dom/auto-anchor.js.map +0 -1
- package/dist/dom/blind-spots.d.ts.map +0 -1
- package/dist/dom/blind-spots.js.map +0 -1
- package/dist/dom/chart.d.ts.map +0 -1
- package/dist/dom/chart.js.map +0 -1
- package/dist/dom/dom-ignore.d.ts.map +0 -1
- package/dist/dom/dom-ignore.js.map +0 -1
- package/dist/dom/occlusion.d.ts.map +0 -1
- package/dist/dom/occlusion.js.map +0 -1
- package/dist/dom/query.d.ts.map +0 -1
- package/dist/dom/query.js.map +0 -1
- package/dist/dom/refs.d.ts.map +0 -1
- package/dist/dom/refs.js.map +0 -1
- package/dist/dom/snapshot.d.ts.map +0 -1
- package/dist/dom/snapshot.js.map +0 -1
- package/dist/dom/source.d.ts.map +0 -1
- package/dist/dom/source.js.map +0 -1
- package/dist/dom/theme.d.ts.map +0 -1
- package/dist/dom/theme.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/observers/animation.d.ts.map +0 -1
- package/dist/observers/animation.js.map +0 -1
- package/dist/observers/blind-spots.d.ts.map +0 -1
- package/dist/observers/blind-spots.js.map +0 -1
- package/dist/observers/console.d.ts.map +0 -1
- package/dist/observers/console.js.map +0 -1
- package/dist/observers/dom.d.ts.map +0 -1
- package/dist/observers/dom.js.map +0 -1
- package/dist/observers/focus.d.ts.map +0 -1
- package/dist/observers/focus.js.map +0 -1
- package/dist/observers/health.d.ts.map +0 -1
- package/dist/observers/health.js.map +0 -1
- package/dist/observers/network-body.d.ts.map +0 -1
- package/dist/observers/network-body.js.map +0 -1
- package/dist/observers/network-redact.d.ts.map +0 -1
- package/dist/observers/network-redact.js.map +0 -1
- package/dist/observers/network.d.ts.map +0 -1
- package/dist/observers/network.js.map +0 -1
- package/dist/observers/perf.d.ts.map +0 -1
- package/dist/observers/perf.js.map +0 -1
- package/dist/observers/route.d.ts.map +0 -1
- package/dist/observers/route.js.map +0 -1
- package/dist/observers/scroll.d.ts.map +0 -1
- package/dist/observers/scroll.js.map +0 -1
- package/dist/observers/state.d.ts.map +0 -1
- package/dist/observers/state.js.map +0 -1
- package/dist/observers/storage.d.ts.map +0 -1
- package/dist/observers/storage.js.map +0 -1
- package/dist/observers/types.d.ts.map +0 -1
- package/dist/observers/types.js.map +0 -1
- package/dist/patching/capture-method.d.ts.map +0 -1
- package/dist/patching/capture-method.js.map +0 -1
- package/dist/presenter/overlay.d.ts.map +0 -1
- package/dist/presenter/overlay.js.map +0 -1
- package/dist/presenter/presenter-config.d.ts.map +0 -1
- package/dist/presenter/presenter-config.js.map +0 -1
- package/dist/presenter/presenter-controls.d.ts.map +0 -1
- package/dist/presenter/presenter-controls.js.map +0 -1
- package/dist/presenter/presenter-effects.d.ts.map +0 -1
- package/dist/presenter/presenter-effects.js.map +0 -1
- package/dist/presenter/presenter-glow.d.ts.map +0 -1
- package/dist/presenter/presenter-glow.js.map +0 -1
- package/dist/presenter/presenter-log.d.ts.map +0 -1
- package/dist/presenter/presenter-log.js.map +0 -1
- package/dist/presenter/presenter-run-state.d.ts.map +0 -1
- package/dist/presenter/presenter-run-state.js.map +0 -1
- package/dist/presenter/presenter-styles.d.ts.map +0 -1
- package/dist/presenter/presenter-styles.js.map +0 -1
- package/dist/presenter/presenter-tally.d.ts.map +0 -1
- package/dist/presenter/presenter-tally.js.map +0 -1
- package/dist/presenter/presenter-test-helpers.d.ts.map +0 -1
- package/dist/presenter/presenter-test-helpers.js.map +0 -1
- package/dist/presenter/presenter-verbs.d.ts.map +0 -1
- package/dist/presenter/presenter-verbs.js.map +0 -1
- package/dist/presenter/presenter.d.ts.map +0 -1
- package/dist/presenter/presenter.js.map +0 -1
- package/dist/recorder/recorder-styles.d.ts.map +0 -1
- package/dist/recorder/recorder-styles.js.map +0 -1
- package/dist/recorder/recorder.d.ts.map +0 -1
- package/dist/recorder/recorder.js.map +0 -1
- package/dist/registry/adapters.d.ts.map +0 -1
- package/dist/registry/adapters.js.map +0 -1
- package/dist/registry/capabilities.d.ts.map +0 -1
- package/dist/registry/capabilities.js.map +0 -1
- package/dist/registry/commit-and-signal.d.ts.map +0 -1
- package/dist/registry/commit-and-signal.js.map +0 -1
- package/dist/registry/domains.d.ts.map +0 -1
- package/dist/registry/domains.js.map +0 -1
- package/dist/registry/emitter.d.ts.map +0 -1
- package/dist/registry/emitter.js.map +0 -1
- package/dist/registry/store-adapters.d.ts.map +0 -1
- package/dist/registry/store-adapters.js.map +0 -1
- package/dist/registry/stores.d.ts.map +0 -1
- package/dist/registry/stores.js.map +0 -1
- package/dist/reticle-presenter-helpers.d.ts.map +0 -1
- package/dist/reticle-presenter-helpers.js.map +0 -1
- package/dist/reticle.d.ts.map +0 -1
- package/dist/reticle.js.map +0 -1
- package/dist/review/annotator.d.ts.map +0 -1
- package/dist/review/annotator.js.map +0 -1
- package/dist/review/mark-anchor.d.ts.map +0 -1
- package/dist/review/mark-anchor.js.map +0 -1
- package/dist/security/serialization.d.ts.map +0 -1
- package/dist/security/serialization.js.map +0 -1
- package/dist/timers/clock.d.ts.map +0 -1
- package/dist/timers/clock.js.map +0 -1
- package/dist/timers/native-console.d.ts.map +0 -1
- package/dist/timers/native-console.js.map +0 -1
- package/dist/timers/native-timers.d.ts.map +0 -1
- package/dist/timers/native-timers.js.map +0 -1
- package/dist/transport/transport.d.ts.map +0 -1
- package/dist/transport/transport.js.map +0 -1
package/dist/observers/dom.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/observers/focus.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/observers/health.js
CHANGED
|
@@ -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,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
|