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