@reticlehq/browser 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/actions-dom.d.ts +0 -20
- package/dist/actions/actions-dom.js +0 -66
- package/dist/actions/actions-press.d.ts +10 -0
- package/dist/actions/actions-press.js +77 -0
- package/dist/actions/actions.d.ts +0 -89
- package/dist/actions/actions.js +24 -358
- package/dist/actions/appeared-text.d.ts +0 -20
- package/dist/actions/appeared-text.js +0 -36
- package/dist/actions/danger-context.d.ts +3 -0
- package/dist/actions/danger-context.js +39 -0
- package/dist/actions/scroll.d.ts +0 -9
- package/dist/actions/scroll.js +0 -7
- package/dist/actions/synthetic-input.d.ts +0 -7
- package/dist/actions/synthetic-input.js +0 -27
- 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 +0 -67
- package/dist/connect-options.d.ts +2 -96
- package/dist/dom/a11y.d.ts +0 -29
- package/dist/dom/a11y.js +1 -88
- package/dist/dom/auto-anchor.d.ts +0 -40
- package/dist/dom/auto-anchor.js +0 -33
- 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 +0 -72
- package/dist/dom/dom-ignore.d.ts +0 -30
- package/dist/dom/dom-ignore.js +0 -47
- package/dist/dom/occlusion.d.ts +0 -7
- package/dist/dom/occlusion.js +0 -7
- package/dist/dom/query.d.ts +0 -14
- package/dist/dom/query.js +48 -214
- package/dist/dom/realm.d.ts +0 -20
- package/dist/dom/realm.js +0 -28
- package/dist/dom/refs.d.ts +0 -76
- package/dist/dom/refs.js +2 -133
- 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 +26 -151
- package/dist/dom/source.d.ts +0 -44
- package/dist/dom/source.js +0 -45
- 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 +0 -22
- package/dist/edit-epoch.js +0 -52
- package/dist/index.js +1 -23
- package/dist/observers/animation.d.ts +0 -1
- package/dist/observers/animation.js +0 -3
- package/dist/observers/blind-spots.d.ts +0 -26
- package/dist/observers/blind-spots.js +2 -61
- 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 +0 -44
- package/dist/observers/download.d.ts +1 -2
- package/dist/observers/download.js +38 -55
- 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 +1 -33
- 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/network-body.d.ts +5 -28
- package/dist/observers/network-body.js +11 -108
- package/dist/observers/network-redact.d.ts +1 -20
- package/dist/observers/network-redact.js +1 -80
- package/dist/observers/network-stream.d.ts +0 -21
- package/dist/observers/network-stream.js +1 -44
- package/dist/observers/network.d.ts +2 -26
- package/dist/observers/network.js +177 -294
- 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 +0 -9
- 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/overlay.d.ts +0 -1
- package/dist/presenter/overlay.js +0 -1
- package/dist/presenter/presenter-brand.d.ts +0 -19
- package/dist/presenter/presenter-brand.js +0 -21
- package/dist/presenter/presenter-config.d.ts +1 -105
- package/dist/presenter/presenter-config.js +2 -91
- package/dist/presenter/presenter-controls.d.ts +0 -29
- package/dist/presenter/presenter-controls.js +3 -131
- package/dist/presenter/presenter-dock-layout.d.ts +0 -3
- package/dist/presenter/presenter-dock-layout.js +0 -4
- package/dist/presenter/presenter-drag.d.ts +2 -24
- package/dist/presenter/presenter-drag.js +0 -26
- package/dist/presenter/presenter-effects.d.ts +0 -9
- package/dist/presenter/presenter-effects.js +0 -9
- package/dist/presenter/presenter-glow.d.ts +0 -21
- package/dist/presenter/presenter-glow.js +1 -27
- package/dist/presenter/presenter-heroicons-data.d.ts +0 -8
- package/dist/presenter/presenter-heroicons-data.js +0 -8
- package/dist/presenter/presenter-hud-chrome.d.ts +0 -10
- package/dist/presenter/presenter-hud-chrome.js +0 -10
- package/dist/presenter/presenter-icons.d.ts +0 -6
- package/dist/presenter/presenter-icons.js +0 -6
- package/dist/presenter/presenter-log.d.ts +0 -38
- package/dist/presenter/presenter-log.js +1 -45
- package/dist/presenter/presenter-report-copy.d.ts +5 -66
- package/dist/presenter/presenter-report-copy.js +5 -66
- package/dist/presenter/presenter-report-styles.d.ts +0 -6
- package/dist/presenter/presenter-report-styles.js +6 -6
- package/dist/presenter/presenter-report.d.ts +4 -7
- package/dist/presenter/presenter-report.js +18 -76
- package/dist/presenter/presenter-run-state.d.ts +0 -7
- package/dist/presenter/presenter-run-state.js +0 -5
- package/dist/presenter/presenter-settings-styles.d.ts +0 -1
- package/dist/presenter/presenter-settings-styles.js +0 -1
- package/dist/presenter/presenter-settings.d.ts +1 -23
- package/dist/presenter/presenter-settings.js +1 -31
- package/dist/presenter/presenter-shell-styles.js +2 -5
- package/dist/presenter/presenter-shell.d.ts +2 -18
- package/dist/presenter/presenter-shell.js +1 -73
- package/dist/presenter/presenter-styles.d.ts +0 -9
- package/dist/presenter/presenter-styles.js +0 -9
- package/dist/presenter/presenter-tally.d.ts +0 -5
- package/dist/presenter/presenter-tally.js +0 -4
- package/dist/presenter/presenter-test-helpers.d.ts +0 -2
- package/dist/presenter/presenter-test-helpers.js +0 -2
- package/dist/presenter/presenter-verbs.js +0 -7
- package/dist/presenter/presenter-workspace.d.ts +0 -4
- package/dist/presenter/presenter-workspace.js +1 -8
- package/dist/presenter/presenter.d.ts +0 -60
- package/dist/presenter/presenter.js +4 -118
- 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 +0 -49
- package/dist/registry/adapters.d.ts +0 -10
- package/dist/registry/adapters.js +0 -10
- 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/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 +0 -10
- package/dist/registry/store-adapters.d.ts +0 -186
- package/dist/registry/store-adapters.js +0 -163
- package/dist/registry/stores.d.ts +0 -33
- package/dist/registry/stores.js +0 -59
- package/dist/reticle-presenter-helpers.d.ts +0 -13
- package/dist/reticle-presenter-helpers.js +0 -14
- package/dist/reticle.d.ts +1 -52
- package/dist/reticle.js +20 -145
- package/dist/review/annotator-styles.js +0 -7
- package/dist/review/annotator.d.ts +2 -13
- package/dist/review/annotator.js +0 -40
- package/dist/review/mark-anchor.d.ts +0 -11
- package/dist/review/mark-anchor.js +0 -8
- 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/native-console.js +0 -4
- package/dist/timers/native-timers.d.ts +0 -25
- package/dist/timers/native-timers.js +3 -33
- package/dist/transport/transport.d.ts +0 -51
- package/dist/transport/transport.js +3 -129
- 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 +2 -2
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
|
+
}
|
|
@@ -1,34 +1,11 @@
|
|
|
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
1
|
export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
|
|
18
|
-
/**
|
|
19
|
-
* Resolve with the body text, or `undefined` if it takes too long.
|
|
20
|
-
*
|
|
21
|
-
* The app is already awaiting our patched fetch, so any unbounded read here is a hang in the host app.
|
|
22
|
-
* Losing an observation is always preferable to freezing the page being observed.
|
|
23
|
-
*/
|
|
24
2
|
export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
|
|
25
3
|
export declare function isCapturableType(contentType: string | null): boolean;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*/
|
|
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
|
+
};
|
|
32
9
|
export declare function projectBody(rawText: string, contentType: string | null): {
|
|
33
10
|
body: string;
|
|
34
11
|
truncated: boolean;
|
|
@@ -1,74 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Response/request BODY projection for the network observer.
|
|
3
|
-
*
|
|
4
|
-
* Split out of network.ts, which had grown to hold URL redaction, body projection, fetch, XHR, SSE,
|
|
5
|
-
* WebSocket and sendBeacon at once. This module owns exactly one question: given a body, what is safe
|
|
6
|
-
* and affordable to report?
|
|
7
|
-
*/
|
|
8
1
|
import { REDACTED_VALUE } from '@reticlehq/core';
|
|
9
2
|
import { isSensitiveKey, safeStringify, scrubKnownSecrets } from '../security/serialization.js';
|
|
10
3
|
import { nativeClearTimeout, nativeSetTimeout } from '../timers/native-timers.js';
|
|
11
|
-
/** Only text-like bodies are worth capturing; binary (images/fonts/octet-stream) is skipped. */
|
|
12
4
|
const CAPTURABLE_CONTENT = /application\/json|text\/|application\/xml|x-www-form-urlencoded|graphql/i;
|
|
13
|
-
/**
|
|
14
|
-
* Content types that are STREAMS, never complete bodies.
|
|
15
|
-
*
|
|
16
|
-
* `text/event-stream` matches `text/` in CAPTURABLE_CONTENT, and body capture awaited
|
|
17
|
-
* `res.clone.text` BEFORE returning the response to the app. A clone of a stream only settles when
|
|
18
|
-
* the stream ends — and an SSE stream does not end — so the app's `await fetch...)` never resolved.
|
|
19
|
-
* Enabling body capture silently hung every streaming endpoint (SSE, and the chunked `application/json`
|
|
20
|
-
* every token-streaming model API uses), leaving a permanently loading UI with no reason to suspect the
|
|
21
|
-
* observability SDK. Never read these; the frame observer covers SSE properly.
|
|
22
|
-
*/
|
|
23
5
|
const STREAMING_CONTENT = /event-stream|x-ndjson|application\/stream/i;
|
|
24
|
-
/**
|
|
25
|
-
* Content types that stay OPEN by design — watching them would make every first settle pay the full
|
|
26
|
-
* watch bound for a stream doing exactly its job. Real SSE is covered by the EventSource observer.
|
|
27
|
-
*/
|
|
28
6
|
const ENDLESS_BY_DESIGN = /event-stream/i;
|
|
29
|
-
/**
|
|
30
|
-
* Is this response body still arriving after the request itself resolved?
|
|
31
|
-
*
|
|
32
|
-
* STRUCTURAL, not a content-type allowlist. The first version listed `x-component|x-ndjson|
|
|
33
|
-
* application/stream` — which is to say "Next.js RSC, plus two guesses". It closed the exact defect
|
|
34
|
-
* it was written against and left every other streamed response as invisible as before: a chunked
|
|
35
|
-
* `application/json` from a token API, a streamed HTML document, an export assembled server-side.
|
|
36
|
-
* Fixing one app's symptom and calling the class solved is how a false green comes back wearing
|
|
37
|
-
* different clothes.
|
|
38
|
-
*
|
|
39
|
-
* The real signal is on the wire: a response with a BODY and no `content-length` is being sent
|
|
40
|
-
* chunked, which is the definition of "the server is still writing". `content-length` present means
|
|
41
|
-
* the size was known up front and the body cannot grow.
|
|
42
|
-
*
|
|
43
|
-
* Deliberately independent of content type, with one exception above for streams that never end.
|
|
44
|
-
*/
|
|
45
7
|
export function isStreamingBody(contentType, contentLength) {
|
|
46
8
|
if (contentType !== null && ENDLESS_BY_DESIGN.test(contentType))
|
|
47
9
|
return false;
|
|
48
10
|
return null === contentLength;
|
|
49
11
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Longest the SDK will wait on a response-body clone before emitting without it.
|
|
52
|
-
*
|
|
53
|
-
* Two layers protect the app. `text/event-stream` and friends are skipped outright, which covers the
|
|
54
|
-
* common streaming case at zero cost. This deadline is the backstop for a body that streams WITHOUT
|
|
55
|
-
* announcing it in its content type — chunked `application/json` from a token-streaming API. Gating on
|
|
56
|
-
* `content-length` instead was tried and rejected: plenty of complete responses omit it (gzip, HTTP/2),
|
|
57
|
-
* so that would silently stop capturing bodies for ordinary apps. A bounded half-second on a rare path
|
|
58
|
-
* beats an unbounded hang, and beats losing capture everywhere.
|
|
59
|
-
*/
|
|
60
12
|
const BODY_READ_TIMEOUT_MS = 500;
|
|
61
|
-
/**
|
|
62
|
-
* Resolve with the body text, or `undefined` if it takes too long.
|
|
63
|
-
*
|
|
64
|
-
* The app is already awaiting our patched fetch, so any unbounded read here is a hang in the host app.
|
|
65
|
-
* Losing an observation is always preferable to freezing the page being observed.
|
|
66
|
-
*/
|
|
67
13
|
export async function withBodyDeadline(read) {
|
|
68
|
-
// The timer is CLEARED on the winning path. Left uncleared, every captured body leaves a live timer
|
|
69
|
-
// and a retained closure behind — harmless individually, but `boundedFrame` in this same package
|
|
70
|
-
// already races-with-cleanup correctly, so not reusing that shape was an oversight rather than a
|
|
71
|
-
// choice. It also keeps test workers from being held open by pending handles.
|
|
72
14
|
let timer;
|
|
73
15
|
try {
|
|
74
16
|
return await Promise.race([
|
|
@@ -90,79 +32,40 @@ export function isCapturableType(contentType) {
|
|
|
90
32
|
return false;
|
|
91
33
|
return CAPTURABLE_CONTENT.test(contentType);
|
|
92
34
|
}
|
|
93
|
-
/**
|
|
94
|
-
* Per-body character cap. Bodies ride the same ring buffer as the DOM/route/console timeline, so an
|
|
95
|
-
* uncapped body could evict the whole behavioral history; the small cap keeps a few large responses
|
|
96
|
-
* from starving the timeline (the separate-budget concern in the scalability audit).
|
|
97
|
-
*/
|
|
98
35
|
const MAX_BODY_CHARS = 8192;
|
|
99
|
-
/**
|
|
100
|
-
* Hard bound on how much of a body is ever PARSED or SCANNED, independent of how much is reported.
|
|
101
|
-
*
|
|
102
|
-
* Only twice the output cap, and that ceiling is measured rather than guessed. The redaction pass uses
|
|
103
|
-
* `[A-Za-z0-9_.-]+` followed by a delimiter, which backtracks quadratically on a long run without one.
|
|
104
|
-
* Timed on this machine: 8 KB → 36 ms, 16 KB → 136 ms, 64 KB → 2067 ms. A first attempt at 8× the
|
|
105
|
-
* output cap therefore still froze the main thread for seconds, so the bound is set where the cost
|
|
106
|
-
* stays bounded AND redaction keeps enough context to catch a secret straddling the reported edge.
|
|
107
|
-
* Scanning far past the output cap is wasted work in any case — only 8 KB is ever reported.
|
|
108
|
-
*/
|
|
109
36
|
const MAX_BODY_SCAN_CHARS = MAX_BODY_CHARS * 2;
|
|
110
|
-
/**
|
|
111
|
-
* An `Authorization: Bearer …` / `Basic …` credential. The token side requires credential shape — 16+
|
|
112
|
-
* chars AND at least one digit or symbol — so ordinary prose ("Basic subscription includes…", "Bearer
|
|
113
|
-
* capacity exceeded") is NOT mangled, only actual opaque tokens are.
|
|
114
|
-
*/
|
|
115
37
|
const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Za-z0-9._~+/=-]*[\d._~+/=-])[A-Za-z0-9._~+/=-]+/gi;
|
|
116
|
-
/**
|
|
117
|
-
* Redact credentials in ANY text body (form-urlencoded, text/plain, xml, or JSON that failed to parse).
|
|
118
|
-
* sanitizeForTransport only redacts KEYS inside a parsed object, so a `password=secret` form post or a
|
|
119
|
-
* `Bearer <token>` string would otherwise ship verbatim. Redacts the value of any `key=value` / `key: value`
|
|
120
|
-
* pair whose key is sensitive, plus credential-shaped auth-scheme tokens.
|
|
121
|
-
*/
|
|
122
38
|
function redactText(text) {
|
|
123
39
|
return (text
|
|
124
|
-
// Auth-scheme tokens FIRST: `Authorization: Bearer <token>` — the key/value rule below would
|
|
125
|
-
// otherwise consume just "Bearer" as Authorization's value (it stops at whitespace) and leave the
|
|
126
|
-
// token behind, so the scheme rule must run before it.
|
|
127
40
|
.replace(AUTH_SCHEME_TOKEN, (_m, scheme) => `${scheme} ${REDACTED_VALUE}`)
|
|
128
41
|
.replace(/([A-Za-z0-9_.-]+)(\s*[=:]\s*"?)([^&\s,;"}]+)/g, (match, key, sep) => isSensitiveKey(key) ? `${key}${sep}${REDACTED_VALUE}` : match));
|
|
129
42
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
53
|
export function projectBody(rawText, contentType) {
|
|
137
|
-
// Bound the INPUT before any parsing or scanning. The cap used to be applied only at the end, so a
|
|
138
|
-
// large body was JSON.parsed in full, deep-walked, re-stringified, and swept by two global regexes
|
|
139
|
-
// before all but 8 KB of the result was thrown away — seconds of synchronous main-thread work for a
|
|
140
|
-
// fixed-size output. A 30 MB CSV export or a big HTML error page froze the app, which is the same
|
|
141
|
-
// class of host-app damage as the streaming hang, with the SDK still the cause.
|
|
142
|
-
//
|
|
143
|
-
// The slice is generous (a multiple of the output cap) so redaction still sees enough context to
|
|
144
|
-
// recognise a secret that straddles the boundary, while the work stays bounded regardless of body size.
|
|
145
54
|
const oversized = rawText.length > MAX_BODY_SCAN_CHARS;
|
|
146
55
|
const text = oversized ? rawText.slice(0, MAX_BODY_SCAN_CHARS) : rawText;
|
|
147
56
|
let out;
|
|
148
57
|
if (contentType !== null && /json|graphql/i.test(contentType)) {
|
|
149
58
|
try {
|
|
150
|
-
// safeStringify already sanitizes internally — the extra sanitizeForTransport here re-walked
|
|
151
|
-
// every captured JSON body a second time (~21% of the per-response cost) for an identical result.
|
|
152
59
|
out = safeStringify(JSON.parse(text));
|
|
153
60
|
}
|
|
154
61
|
catch {
|
|
155
|
-
out = redactText(text);
|
|
62
|
+
out = redactText(text);
|
|
156
63
|
}
|
|
157
64
|
}
|
|
158
65
|
else {
|
|
159
66
|
out = redactText(text);
|
|
160
67
|
}
|
|
161
|
-
// Key-based redaction can't see a secret sitting in a VALUE under a benign key — scan the projected
|
|
162
|
-
// text for high-confidence secret shapes (JWTs, provider keys) as a backstop, JSON or not.
|
|
163
68
|
out = scrubKnownSecrets(out);
|
|
164
|
-
// Truncated if the projection overflows the cap OR the input itself was clipped above — a body the
|
|
165
|
-
// SDK never fully read must not be reported as complete.
|
|
166
69
|
const truncated = oversized || out.length > MAX_BODY_CHARS;
|
|
167
70
|
return { body: out.length > MAX_BODY_CHARS ? out.slice(0, MAX_BODY_CHARS) : out, truncated };
|
|
168
71
|
}
|
|
@@ -1,20 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Redact credential-bearing values so they don't leak into the agent transcript / flow / run
|
|
3
|
-
* artifacts: query params (`?access_token=…`, signed-URL keys) via the shared `isSensitiveKey` regex,
|
|
4
|
-
* AND path-embedded tokens (`/reset/<token>`, `/invite/<token>`) that live in the path, not the query.
|
|
5
|
-
* The hash is preserved and the URL is returned byte-for-byte when nothing matched.
|
|
6
|
-
*/
|
|
7
|
-
export declare function redactUrl(raw: string): string;
|
|
8
|
-
/**
|
|
9
|
-
* Displayed URL plus, when redaction rewrote it, the raw request for graders.
|
|
10
|
-
*
|
|
11
|
-
* `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
|
|
12
|
-
* path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
|
|
13
|
-
* changed, so an ordinary request pays nothing.
|
|
14
|
-
*/
|
|
15
|
-
export declare function netUrlFields(raw: string): {
|
|
16
|
-
url: string;
|
|
17
|
-
} | {
|
|
18
|
-
url: string;
|
|
19
|
-
urlRaw: string;
|
|
20
|
-
};
|
|
1
|
+
export { netUrlFields, redactUrl } from '@reticlehq/core';
|
|
@@ -1,80 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { isSensitiveKey } from '../security/serialization.js';
|
|
3
|
-
/** A path segment name that is typically followed by a single-use secret token in the NEXT segment. */
|
|
4
|
-
const SENSITIVE_PATH_SEGMENT = /^(reset|verify|verification|confirm|activate|invite|magic|magiclink|token|key|oauth|unsubscribe|password)$/i;
|
|
5
|
-
/** Only mask a following segment that looks token-like — short ids/words (`reset/form`) are left alone. */
|
|
6
|
-
const PATH_TOKEN_MIN_LENGTH = 12;
|
|
7
|
-
/**
|
|
8
|
-
* Redact credential-bearing values so they don't leak into the agent transcript / flow / run
|
|
9
|
-
* artifacts: query params (`?access_token=…`, signed-URL keys) via the shared `isSensitiveKey` regex,
|
|
10
|
-
* AND path-embedded tokens (`/reset/<token>`, `/invite/<token>`) that live in the path, not the query.
|
|
11
|
-
* The hash is preserved and the URL is returned byte-for-byte when nothing matched.
|
|
12
|
-
*/
|
|
13
|
-
export function redactUrl(raw) {
|
|
14
|
-
const hashStart = raw.indexOf('#');
|
|
15
|
-
const hash = -1 === hashStart ? '' : raw.slice(hashStart);
|
|
16
|
-
const beforeHash = -1 === hashStart ? raw : raw.slice(0, hashStart);
|
|
17
|
-
const queryStart = beforeHash.indexOf('?');
|
|
18
|
-
const pathPart = -1 === queryStart ? beforeHash : beforeHash.slice(0, queryStart);
|
|
19
|
-
const query = -1 === queryStart ? '' : beforeHash.slice(queryStart + 1);
|
|
20
|
-
let changed = false;
|
|
21
|
-
// Credentials in the authority (`scheme://user:pass@host`) never belong in a transcript.
|
|
22
|
-
// Match to the LAST `@` before the path (`[^/]*@`, greedy), not the first — a password containing
|
|
23
|
-
// `@` (`user:p@ss@host`) otherwise left its tail (`ss@host`) in the clear.
|
|
24
|
-
let authority = pathPart;
|
|
25
|
-
const userinfo = /^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i.exec(pathPart);
|
|
26
|
-
if (userinfo !== null) {
|
|
27
|
-
authority = `${userinfo[1] ?? ''}${REDACTED_VALUE}@${pathPart.slice(userinfo[0].length)}`;
|
|
28
|
-
changed = true;
|
|
29
|
-
}
|
|
30
|
-
let newQuery = query;
|
|
31
|
-
if (query !== '') {
|
|
32
|
-
const params = new URLSearchParams(query);
|
|
33
|
-
let queryChanged = false;
|
|
34
|
-
for (const key of [...params.keys()]) {
|
|
35
|
-
if (isSensitiveKey(key)) {
|
|
36
|
-
params.set(key, REDACTED_VALUE);
|
|
37
|
-
queryChanged = true;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
if (queryChanged) {
|
|
41
|
-
newQuery = params.toString();
|
|
42
|
-
changed = true;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
const segments = authority.split('/');
|
|
46
|
-
for (let i = 0; i + 1 < segments.length; i++) {
|
|
47
|
-
const name = segments[i];
|
|
48
|
-
const next = segments[i + 1];
|
|
49
|
-
if (name !== undefined &&
|
|
50
|
-
next !== undefined &&
|
|
51
|
-
next.length >= PATH_TOKEN_MIN_LENGTH &&
|
|
52
|
-
SENSITIVE_PATH_SEGMENT.test(name)) {
|
|
53
|
-
segments[i + 1] = REDACTED_VALUE;
|
|
54
|
-
changed = true;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
// OAuth implicit flow puts the access_token in the FRAGMENT (`#access_token=…`), and hash-routers carry
|
|
58
|
-
// `?token=…` in the hash — redact sensitive params there too, leaving plain anchors (`#section`) alone.
|
|
59
|
-
let newHash = hash;
|
|
60
|
-
if (hash.length > 1) {
|
|
61
|
-
newHash = hash.replace(/([A-Za-z0-9_.-]+)=([^&\s]+)/g, (m, key) => isSensitiveKey(key) ? `${key}=${REDACTED_VALUE}` : m);
|
|
62
|
-
if (newHash !== hash)
|
|
63
|
-
changed = true;
|
|
64
|
-
}
|
|
65
|
-
if (!changed)
|
|
66
|
-
return raw;
|
|
67
|
-
const queryOut = -1 === queryStart ? '' : `?${newQuery}`;
|
|
68
|
-
return `${segments.join('/')}${queryOut}${newHash}`;
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Displayed URL plus, when redaction rewrote it, the raw request for graders.
|
|
72
|
-
*
|
|
73
|
-
* `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
|
|
74
|
-
* path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
|
|
75
|
-
* changed, so an ordinary request pays nothing.
|
|
76
|
-
*/
|
|
77
|
-
export function netUrlFields(raw) {
|
|
78
|
-
const url = redactUrl(raw);
|
|
79
|
-
return url === raw ? { url } : { url, [URL_RAW]: raw };
|
|
80
|
-
}
|
|
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
2
|
import { isStreamingBody } from './network-body.js';
|
|
9
3
|
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
|
-
*/
|
|
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)
|
|
@@ -1,41 +1,17 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Emit, type Teardown } from './types.js';
|
|
2
2
|
import { redactUrl, netUrlFields } from './network-redact.js';
|
|
3
3
|
export { redactUrl, netUrlFields };
|
|
4
|
-
/** Config for the network observer. Body capture is OFF by default and dev-only opt-in. */
|
|
5
|
-
/**
|
|
6
|
-
* Reinterpret a completed request's fields from its response.
|
|
7
|
-
*
|
|
8
|
-
* The seam exists so this observer stays ignorant of desktop IPC. A Tauri `invoke` arrives here as an
|
|
9
|
-
* ordinary fetch to an `ipc://` protocol, and the header that says whether the Rust command
|
|
10
|
-
* succeeded means nothing to a network observer — so the knowledge lives in the IPC observer and is
|
|
11
|
-
* passed IN, rather than this module importing it. Returns undefined to leave the record untouched.
|
|
12
|
-
*/
|
|
13
4
|
type NetResponseReinterpreter = (url: string, header: (name: string) => string | null) => Record<string, unknown> | undefined;
|
|
14
5
|
interface NetworkOptions {
|
|
15
|
-
/** Capture request/response bodies (text-like content only, redacted, per-body capped). */
|
|
16
6
|
captureBodies?: boolean;
|
|
17
|
-
|
|
7
|
+
captureErrorBodies?: boolean;
|
|
18
8
|
reinterpret?: NetResponseReinterpreter;
|
|
19
|
-
/**
|
|
20
|
-
* Requests Reticle itself makes, which must never reach the app's evidence.
|
|
21
|
-
*
|
|
22
|
-
* Same seam and same reason as `reinterpret`: this observer knows nothing about desktop IPC, and
|
|
23
|
-
* the SDK's own Tauri screenshot travels as an ordinary fetch through this very patch. Passed in
|
|
24
|
-
* rather than imported, so the knowledge stays in the IPC observer.
|
|
25
|
-
*/
|
|
26
9
|
ignore?: (url: string) => boolean;
|
|
27
10
|
}
|
|
28
|
-
/** Pure: the first real app-code frame in a stack string, capped. Exported for unit testing. */
|
|
29
11
|
export declare function firstAppFrame(stack: string | undefined): string | undefined;
|
|
30
|
-
/** The timing fields we lift from a resource entry — TTFB is the perf signal a duration alone hides. */
|
|
31
12
|
interface NetTiming {
|
|
32
13
|
ttfbMs?: number;
|
|
33
14
|
transferSize?: number;
|
|
34
15
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Pure: derive TTFB + transfer size from a PerformanceResourceTiming entry. TTFB = responseStart -
|
|
37
|
-
* requestStart; cross-origin responses zero those (Timing-Allow-Origin), so we omit rather than report a
|
|
38
|
-
* bogus 0. Exported for unit testing (jsdom doesn't post real resource entries).
|
|
39
|
-
*/
|
|
40
16
|
export declare function extractTiming(entry: PerformanceResourceTiming | undefined): NetTiming;
|
|
41
17
|
export declare function installNetwork(emit: Emit, opts?: NetworkOptions): Teardown;
|