@crouton-kit/capture 1.3.7 → 1.3.9
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/bin/capture +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1894 -0
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +302 -34
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -77
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,1894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recorder bridge: a specialized instance of capture's held bridge
|
|
3
|
+
* (`./bridge/server.ts`) that owns ONE tab-level CDP connection for the
|
|
4
|
+
* lifetime of a `capture motion rec` recording instead of a browser-level
|
|
5
|
+
* one. Same NDJSON-over-unix-socket wire style, same detached-process
|
|
6
|
+
* shape as `session start --hold` — this file adds no new IPC or process
|
|
7
|
+
* model, only the recorder-specific request handling and CDP driving.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle (see `./bridge/protocol.ts` for the wire messages):
|
|
10
|
+
* - `rec-start` — enables the motion-rec CDP domains, starts
|
|
11
|
+
* `Page.startScreencast` + `Tracing`, injects the Mutation/Resize/
|
|
12
|
+
* PerformanceObserver script, captures the clock baseline, and returns
|
|
13
|
+
* it for the caller's `markers.json` (this module does not write that
|
|
14
|
+
* file — see U14's lifecycle routing).
|
|
15
|
+
* - `cdp` — a CDP request routed through the held tab connection, used by
|
|
16
|
+
* intervening session commands during a composed recording. An optional
|
|
17
|
+
* `mark` brackets the dispatch with two performance.now() reads and
|
|
18
|
+
* appends a labeled input-landmark record to `events.jsonl`.
|
|
19
|
+
* - `rec-stop` — stops screencast + tracing, flushes/tears down the
|
|
20
|
+
* injected observers, and returns frame/event counts + duration for the
|
|
21
|
+
* caller's `meta.json` (this module does not write that file either).
|
|
22
|
+
* - `har-flush` — the non-attributing action-return HAR health barrier: a
|
|
23
|
+
* recorder-routed page action awaits it before claiming success, so every
|
|
24
|
+
* HAR entry/body/append the streaming collector had already completed is
|
|
25
|
+
* durably in the owning session's live HAR first (health only, no counts
|
|
26
|
+
* — see `./bridge/protocol.ts`'s `RecHarFlushRequest`). Served directly in
|
|
27
|
+
* the wire lane (`runRecorderBridge`'s `handleLine`), because the collector
|
|
28
|
+
* is owned by this process body, not by `RecorderSession`.
|
|
29
|
+
*
|
|
30
|
+
* Frames land as PNGs under `{recDir}/frames/`; per-frame element rects
|
|
31
|
+
* append to `{recDir}/rects.jsonl`; trace batches, observer entries, input
|
|
32
|
+
* landmarks, and best-effort errors append to `{recDir}/events.jsonl` — all
|
|
33
|
+
* writes go through the secure artifact helpers (`../session/artifacts.js`,
|
|
34
|
+
* U03), never ad-hoc `fs.writeFile`.
|
|
35
|
+
*
|
|
36
|
+
* Navigation: a `navigate` mid-recording destroys the page's JS world (and
|
|
37
|
+
* therefore the injected observers) same as any full navigation would.
|
|
38
|
+
* `Page.startScreencast`/`Tracing` continue across it unaffected (they are
|
|
39
|
+
* CDP-session-scoped, not page-scoped), but the recorder listens for
|
|
40
|
+
* `Page.frameNavigated` on the main frame and re-creates the isolated world
|
|
41
|
+
* + re-injects the observer script best-effort afterward (the binding, scoped
|
|
42
|
+
* to the world name, auto-reattaches to the recreated world without being
|
|
43
|
+
* re-issued), recording a `navigation-gap` marker in `events.jsonl` so
|
|
44
|
+
* downstream consumers know the Mutation/Resize/PerformanceObserver stream
|
|
45
|
+
* has a gap around that point.
|
|
46
|
+
*
|
|
47
|
+
* Binding channel: the page→host `Runtime.addBinding` channel is untrusted
|
|
48
|
+
* input — every payload must carry a per-recording unguessable nonce
|
|
49
|
+
* (embedded in the injected script's closure), is whitelisted by `kind` and
|
|
50
|
+
* field, and is length/size/rate-capped; anything else is dropped and
|
|
51
|
+
* tallied into a `binding-dropped` summary event rather than trusted or
|
|
52
|
+
* written verbatim.
|
|
53
|
+
*
|
|
54
|
+
* Rect sampling: the per-frame `Runtime.evaluate` element-rect result is the same
|
|
55
|
+
* hostile-page threat class as the binding channel, via a different path — the
|
|
56
|
+
* host re-validates it (element-count cap, finite-coordinate checks, tag/id/class
|
|
57
|
+
* string caps, and a total serialized-byte budget) before it ever reaches
|
|
58
|
+
* `rects.jsonl`, tallying anything dropped/truncated into a `rect-sample-dropped`
|
|
59
|
+
* summary event rather than trusting the in-page cap alone. Each frame's rect
|
|
60
|
+
* elements, and each `resize` binding event's targets, carry a real `backendNodeId`
|
|
61
|
+
* (I-3), resolved via a nonce-scoped follow-up CDP bridge: the by-value rect/resize
|
|
62
|
+
* data is read by one `Runtime.evaluate`, then a SECOND `Runtime.evaluate` drains the
|
|
63
|
+
* same elements (stashed page-side into a nonce-scoped queue keyed by frame index or a
|
|
64
|
+
* page-assigned `seq`) as held remote objects and bridges each to a `backendNodeId`
|
|
65
|
+
* via `resolveIndexedObjectIds`/`describeBackendNodeId` (the same identity-bridge
|
|
66
|
+
* primitives `geometry.ts`/`hittest.ts` use), capped per-frame/per-event to bound
|
|
67
|
+
* CDP round-trip cost. Any element whose identity did not resolve or was capped
|
|
68
|
+
* carries `backendNodeId: null, identityUnresolved: true` — never a fabricated or
|
|
69
|
+
* omitted backendNodeId. The ENTIRE injected observer script (MutationObserver/
|
|
70
|
+
* ResizeObserver/PerformanceObserver plus the rect/resize stash-and-drain queues) runs
|
|
71
|
+
* inside a CDP isolated execution world (`Page.createIsolatedWorld`), a JS global scope
|
|
72
|
+
* page main-world code cannot enumerate, read, or monkey-patch — only the DOM itself,
|
|
73
|
+
* not JS globals, is shared between worlds. This closes the page-tamperable identity
|
|
74
|
+
* handoff for both the rect and resize paths with one mechanism: `Runtime.addBinding`
|
|
75
|
+
* is scoped to the nonce-named isolated world via `executionContextName`, so
|
|
76
|
+
* `window.captureRecorderEmit` exists ONLY inside that world (never the page main world,
|
|
77
|
+
* which can therefore neither detect nor call it) and auto-reattaches to the world each
|
|
78
|
+
* time it is recreated after navigation. `emit()`'s `window[BINDING](...)` calls from
|
|
79
|
+
* inside the isolated world reach the host; the host additionally rejects any
|
|
80
|
+
* `Runtime.bindingCalled` not originating from the active isolated context before it
|
|
81
|
+
* touches the shared rate budget.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import * as crypto from 'crypto';
|
|
85
|
+
import * as net from 'net';
|
|
86
|
+
import * as path from 'path';
|
|
87
|
+
import { CDPClient } from './client.js';
|
|
88
|
+
import { findTabByIdAcrossEndpoints } from './targets.js';
|
|
89
|
+
import { enableDomainsForMotionRec } from './domains.js';
|
|
90
|
+
import { readTraceClockBaseline, withDocumentPerformanceNow } from './timing.js';
|
|
91
|
+
import {
|
|
92
|
+
EventBroker,
|
|
93
|
+
listenNdjsonSocket,
|
|
94
|
+
closeNdjsonSocket,
|
|
95
|
+
installProcessCleanup,
|
|
96
|
+
} from './bridge/server.js';
|
|
97
|
+
import {
|
|
98
|
+
type RecorderRequest,
|
|
99
|
+
type RecorderResponse,
|
|
100
|
+
type RecorderClockBaselines,
|
|
101
|
+
type RecCdpRequest,
|
|
102
|
+
type RecStopRequest,
|
|
103
|
+
type RecHarFlushRequest,
|
|
104
|
+
} from './bridge/protocol.js';
|
|
105
|
+
import { ensurePrivateDir, appendNdjsonPrivate, writeBinaryPrivate, writeJsonPrivate } from '../session/artifacts.js';
|
|
106
|
+
import { resolveIndexedObjectIds, describeBackendNodeId } from './measure/collectors/geometry.js';
|
|
107
|
+
import { HARRecorder } from './har-recorder.js';
|
|
108
|
+
import { appendToHarRecording } from '../har-manager.js';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The subset of `RecCdpRequest` that `RecorderSession.handleCdp` actually reads. The one-shot
|
|
112
|
+
* `rec --do` lane (`commands/motion/rec.ts`'s `recorderLiveClient`) drives `handleCdp` in-proc —
|
|
113
|
+
* genuinely socket-less, intentionally ungated (the nonce gate lives on the wire in
|
|
114
|
+
* `runRecorderBridge`'s `handleLine`, which that lane never touches) — so it has no
|
|
115
|
+
* `reqId`/`type`/`nonce` envelope to supply. This type lets that lane's call sites stay honestly
|
|
116
|
+
* typed without fabricating envelope fields `handleCdp` never reads, while the wire lane
|
|
117
|
+
* (`handleRecorderRequest`) keeps passing a full `RecCdpRequest` unchanged.
|
|
118
|
+
*/
|
|
119
|
+
export interface RecCdpCall {
|
|
120
|
+
method?: string;
|
|
121
|
+
params?: Record<string, unknown>;
|
|
122
|
+
mark?: string;
|
|
123
|
+
waitEvent?: string;
|
|
124
|
+
timeoutMs?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Artifact record shapes
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface SampledRect {
|
|
132
|
+
/** Descriptive label only — the cross-artifact join key is {@link backendNodeId}. */
|
|
133
|
+
tag: string;
|
|
134
|
+
id: string | null;
|
|
135
|
+
classes: string | null;
|
|
136
|
+
x: number;
|
|
137
|
+
y: number;
|
|
138
|
+
width: number;
|
|
139
|
+
height: number;
|
|
140
|
+
/** The cross-artifact join key, resolved via `DOM.describeNode` off this element's bridged CDP
|
|
141
|
+
* objectId (see `RecorderSession.resolveRectIdentity`) — `null` only when identity resolution
|
|
142
|
+
* failed or was capped for this frame (see {@link identityUnresolved}). */
|
|
143
|
+
backendNodeId: number | null;
|
|
144
|
+
/** `true` when {@link backendNodeId} is `null` because identity resolution failed, was
|
|
145
|
+
* unavailable, or was capped by `MAX_RECT_IDENTITY_RESOLUTIONS` for this frame — never omit
|
|
146
|
+
* this alongside a `null` backendNodeId. Absent (not `false`) when identity resolved. */
|
|
147
|
+
identityUnresolved?: true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One line of `rects.jsonl` — element rects sampled at one screencast frame.
|
|
152
|
+
*
|
|
153
|
+
* These are `getBoundingClientRect()` values, not CDP quads: a single axis-aligned box per
|
|
154
|
+
* element, with no transform decomposition, clip-path/overflow clipping, or iframe/shadow-root
|
|
155
|
+
* coordinate stitching. Downstream consumers (`motion timeline` and friends) must present this
|
|
156
|
+
* as bounding-box geometry, not quad-accurate geometry — quad-accurate geometry
|
|
157
|
+
* (`DOM.getContentQuads`, frame/shadow stitching) is `measure snap`'s `geometry.json`, a
|
|
158
|
+
* separate substrate this recorder does not produce.
|
|
159
|
+
*/
|
|
160
|
+
export interface FrameCssToDeviceTransform {
|
|
161
|
+
/** Exact per-frame scale from top visual viewport CSS pixels to PNG device pixels. */
|
|
162
|
+
scaleX: number;
|
|
163
|
+
scaleY: number;
|
|
164
|
+
/** The page-reported device-pixel ratio, retained independently of raster scale. */
|
|
165
|
+
devicePixelRatio: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface FrameRectsRecord {
|
|
169
|
+
frame: number;
|
|
170
|
+
file: string;
|
|
171
|
+
/** Per-frame CSS-to-device transform used by motion-mask DOM joins. */
|
|
172
|
+
cssToDevice: FrameCssToDeviceTransform | null;
|
|
173
|
+
/** `Page.screencastFrame`'s own `metadata.timestamp` (wall-clock seconds), raw — not baseline-converted. */
|
|
174
|
+
screencastTimestamp: number | null;
|
|
175
|
+
/**
|
|
176
|
+
* Honesty label for `screencastTimestamp`: it is `Page.screencastFrame.metadata.timestamp`, a
|
|
177
|
+
* wall-clock seconds value whose effective precision is bounded by the screencast frame cadence
|
|
178
|
+
* (≈±1 frame) — NOT a sub-ms exact instrument reading, and not baseline-converted.
|
|
179
|
+
*/
|
|
180
|
+
screencastTimestampPrecision: 'frame-metadata';
|
|
181
|
+
recordedAtWallClockMs: number;
|
|
182
|
+
elements: SampledRect[];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One line of `events.jsonl`. `kind` is `'input'` for a marked CDP
|
|
187
|
+
* dispatch, `'trace'` for a `Tracing.dataCollected` batch, `'error'` for a
|
|
188
|
+
* best-effort recorder failure, or one of the injected observer's own kinds
|
|
189
|
+
* (`'mutation'` / `'resize'` / `'performance'`) — those carry whatever
|
|
190
|
+
* fields the in-page emitter sent, hence the index signature.
|
|
191
|
+
*/
|
|
192
|
+
export interface RecorderEventRecord {
|
|
193
|
+
kind: string;
|
|
194
|
+
recordedAtWallClockMs: number;
|
|
195
|
+
[key: string]: unknown;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// RecorderSession — the CDP driving + artifact-writing core, injectable for
|
|
200
|
+
// tests (accepts anything shaped like the public surface of `CDPClient`).
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
export type RecorderCdpClient = Pick<CDPClient, 'send' | 'on' | 'onDisconnect' | 'close'>;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `'starting'` covers `start()`'s initialization window — from the top of `start()` until the
|
|
207
|
+
* observer is installed in the latest main-frame context and the streams are live. It exists so a
|
|
208
|
+
* main-frame navigation arriving mid-initialization is handled (the isolated world is recreated and
|
|
209
|
+
* the observer reinstalled in the newest context) instead of leaving `start()` bound to a destroyed
|
|
210
|
+
* world. `'stopping'` covers the whole `stop()` teardown window: it flips at the very top of
|
|
211
|
+
* `stop()` before any await, so every state-sensitive guard (`handleFrameNavigated`, `handleCdp`)
|
|
212
|
+
* stops treating an in-flight stop as still `'recording'`. `'stopped'` is only reached once teardown
|
|
213
|
+
* fully completes.
|
|
214
|
+
*/
|
|
215
|
+
export type RecorderState = 'idle' | 'starting' | 'recording' | 'stopping' | 'stopped';
|
|
216
|
+
|
|
217
|
+
export interface RecorderSessionOptions {
|
|
218
|
+
client: RecorderCdpClient;
|
|
219
|
+
/** Absolute path to `motion/recs/{recId}` — must resolve under `CAPTURE_ROOT`. */
|
|
220
|
+
recDir: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface RecorderStopSummary {
|
|
224
|
+
frameCount: number;
|
|
225
|
+
eventCount: number;
|
|
226
|
+
durationMs: number;
|
|
227
|
+
/** The clock baselines re-read at stop time — the flush path (see `RecorderClockBaselines`). */
|
|
228
|
+
markers: RecorderClockBaselines;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const RECORDER_BINDING_NAME = 'captureRecorderEmit';
|
|
232
|
+
|
|
233
|
+
/** The in-page observer script returns this sentinel from its injection IIFE once every observer
|
|
234
|
+
* is wired and the nonce-scoped bridge global is installed. `injectObserverScript` requires it
|
|
235
|
+
* (alongside an absent `exceptionDetails`) before publishing the world's context id — a
|
|
236
|
+
* `Runtime.evaluate` whose JS throws resolves with `exceptionDetails` instead of rejecting, and an
|
|
237
|
+
* install that silently no-ops returns something else, so neither is ever mistaken for a live
|
|
238
|
+
* bridge behind a usable-looking context id. */
|
|
239
|
+
export const OBSERVER_INSTALLED_SENTINEL = '__captureRecorderInstalled__';
|
|
240
|
+
|
|
241
|
+
const TRACE_CATEGORIES =
|
|
242
|
+
'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,loading,blink.user_timing';
|
|
243
|
+
|
|
244
|
+
interface ScreencastFrameParams {
|
|
245
|
+
data: string;
|
|
246
|
+
metadata?: { timestamp?: number; [key: string]: unknown };
|
|
247
|
+
sessionId?: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Binding-channel hardening — every `Runtime.bindingCalled` payload
|
|
252
|
+
// is untrusted page-controlled input: it must carry this recording's nonce,
|
|
253
|
+
// its `kind` must be one of the observer's own emitted kinds, its fields are
|
|
254
|
+
// schema-validated without rewriting admitted strings or arrays, and the channel itself is rate-limited. Anything
|
|
255
|
+
// that fails a check is dropped (never parsed further/written) and tallied
|
|
256
|
+
// for a single summarizing `binding-dropped` event per reason at `rec-stop`.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/** Raw `payload` string length cap — checked before `JSON.parse`, so an oversized payload is never even parsed. */
|
|
260
|
+
const MAX_BINDING_PAYLOAD_BYTES = 8 * 1024;
|
|
261
|
+
const BINDING_RATE_LIMIT_PER_SECOND = 200;
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Rect-sampling hardening — `sampleRects()`'s `Runtime.evaluate` result is the SAME
|
|
265
|
+
// hostile-page threat class as the binding channel above, via a different path: it is
|
|
266
|
+
// page-controlled DOM data (element tag/id/className strings, and an array length) read in the
|
|
267
|
+
// isolated world and returned once per screencast frame, so it must be re-validated host-side
|
|
268
|
+
// rather than trusted because the injected script capped it. The in-page cap
|
|
269
|
+
// (`buildSampleRectsExpression`'s `max = 2000`) is an optimization only — a hostile page can
|
|
270
|
+
// corrupt or bypass in-page JS (e.g. clobbering `Array.prototype` before the script runs),
|
|
271
|
+
// so every one of these limits is re-enforced here, on the host, before a sample is ever
|
|
272
|
+
// appended to `rects.jsonl`.
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** Mirrors the in-page cap, re-enforced host-side regardless of what the page script actually returned. */
|
|
276
|
+
const MAX_RECT_ELEMENTS = 2000;
|
|
277
|
+
const MAX_RECT_TAG_LENGTH = 32;
|
|
278
|
+
const MAX_RECT_STRING_LENGTH = 256;
|
|
279
|
+
/** Total serialized-byte budget for one frame's sanitized rect array, independent of the element-count cap. */
|
|
280
|
+
const MAX_RECTS_SERIALIZED_BYTES = 256 * 1024;
|
|
281
|
+
/** Bounds per-frame `DOM.describeNode` round-trip cost for the rect sampler's identity bridge —
|
|
282
|
+
* mirrors `hittest.ts`'s `MAX_BRIDGE_ELEMENTS` cap, tuned far lower because this bridge runs on
|
|
283
|
+
* EVERY screencast frame instead of once per snapshot. Elements past this cap are left
|
|
284
|
+
* `identityUnresolved: true` rather than uncapping per-frame CDP cost. */
|
|
285
|
+
const MAX_RECT_IDENTITY_RESOLUTIONS = 300;
|
|
286
|
+
|
|
287
|
+
/** Collects every remote-object handle materialized anywhere in a CDP response. A
|
|
288
|
+
* `Runtime.getProperties` response can carry handles outside numeric property values, including
|
|
289
|
+
* accessor, symbol, private-property, and `[[Prototype]]` descriptors; all belong to this property
|
|
290
|
+
* walk and must be released along with the indexed element handles. */
|
|
291
|
+
function collectRemoteObjectIds(value: unknown, objectIds: Set<string>, seen = new Set<object>()): void {
|
|
292
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return;
|
|
293
|
+
seen.add(value);
|
|
294
|
+
for (const [key, child] of Object.entries(value)) {
|
|
295
|
+
if (key === 'objectId' && typeof child === 'string') objectIds.add(child);
|
|
296
|
+
else collectRemoteObjectIds(child, objectIds, seen);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Rect-identity variant of `geometry.ts`'s `resolveIndexedObjectIds`: returns the capped indexed
|
|
302
|
+
* element handles `resolveRectIdentity` describes plus every remote-object handle materialized by
|
|
303
|
+
* `Runtime.getProperties`. The caller releases the complete handle set, while `resolveCount`
|
|
304
|
+
* independently bounds the `DOM.describeNode` follow-ups.
|
|
305
|
+
*/
|
|
306
|
+
async function resolveCappedRectObjectIds(
|
|
307
|
+
client: CDPClient,
|
|
308
|
+
arrayObjectId: string,
|
|
309
|
+
resolveCount: number,
|
|
310
|
+
): Promise<{ objectIds: Array<string | undefined>; allMaterializedObjectIds: string[] }> {
|
|
311
|
+
const objectIds = new Array<string | undefined>(resolveCount).fill(undefined);
|
|
312
|
+
const propsResult = (await client.send('Runtime.getProperties', {
|
|
313
|
+
objectId: arrayObjectId,
|
|
314
|
+
ownProperties: true,
|
|
315
|
+
})) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
|
|
316
|
+
const allMaterializedObjectIds = new Set<string>();
|
|
317
|
+
collectRemoteObjectIds(propsResult, allMaterializedObjectIds);
|
|
318
|
+
for (const prop of propsResult.result ?? []) {
|
|
319
|
+
if (!/^\d+$/.test(prop.name)) continue;
|
|
320
|
+
const objectId = prop.value?.objectId;
|
|
321
|
+
if (!objectId) continue;
|
|
322
|
+
const idx = Number(prop.name);
|
|
323
|
+
if (idx >= 0 && idx < resolveCount) objectIds[idx] = objectId;
|
|
324
|
+
}
|
|
325
|
+
return { objectIds, allMaterializedObjectIds: [...allMaterializedObjectIds] };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Trace-batch bounds — trace events are preserved as received. Their exact
|
|
330
|
+
// JSON encoding sizes enforce event-count and serialized-byte caps without
|
|
331
|
+
// deleting individual fields such as `args`, URLs, or names.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
const MAX_TRACE_EVENTS_PER_BATCH = 500;
|
|
335
|
+
const MAX_TRACE_SERIALIZED_BYTES = 256 * 1024;
|
|
336
|
+
|
|
337
|
+
type BindingFieldSanitizer = (value: unknown) => unknown;
|
|
338
|
+
|
|
339
|
+
function sanitizeFiniteNumber(value: unknown): number | undefined {
|
|
340
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function sanitizeRectString(value: unknown, maxLength: number): string | undefined {
|
|
344
|
+
if (typeof value !== 'string') return undefined;
|
|
345
|
+
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function sanitizeBoolean(value: unknown): boolean | undefined {
|
|
349
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Preserves an admitted observer string verbatim in its source artifact. */
|
|
353
|
+
function preserveObserverString(value: unknown): string | undefined {
|
|
354
|
+
return typeof value === 'string' ? value : undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function preserveObserverStringArray(value: unknown): string[] | undefined {
|
|
358
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) return undefined;
|
|
359
|
+
return value as string[];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function preserveResizeTargets(value: unknown): Array<{ tag?: string; width?: number; height?: number }> | undefined {
|
|
363
|
+
if (!Array.isArray(value)) return undefined;
|
|
364
|
+
const out: Array<{ tag?: string; width?: number; height?: number }> = [];
|
|
365
|
+
for (const item of value) {
|
|
366
|
+
if (!item || typeof item !== 'object') return undefined;
|
|
367
|
+
const record = item as Record<string, unknown>;
|
|
368
|
+
const target: { tag?: string; width?: number; height?: number } = {};
|
|
369
|
+
const tag = preserveObserverString(record.tag);
|
|
370
|
+
const width = sanitizeFiniteNumber(record.width);
|
|
371
|
+
const height = sanitizeFiniteNumber(record.height);
|
|
372
|
+
if (tag !== undefined) target.tag = tag;
|
|
373
|
+
if (width !== undefined) target.width = width;
|
|
374
|
+
if (height !== undefined) target.height = height;
|
|
375
|
+
out.push(target);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whitelisted `kind` values the page-side observer script may emit, and the field-by-field
|
|
382
|
+
* sanitizer for each. A `kind` not present here (including the host-only kinds `input`, `trace`,
|
|
383
|
+
* `error`, `navigation-gap`, `binding-dropped`, which this channel must never be able to forge)
|
|
384
|
+
* is dropped outright.
|
|
385
|
+
*/
|
|
386
|
+
const BINDING_FIELD_SANITIZERS: Record<string, Record<string, BindingFieldSanitizer>> = {
|
|
387
|
+
mutation: {
|
|
388
|
+
count: sanitizeFiniteNumber,
|
|
389
|
+
types: preserveObserverStringArray,
|
|
390
|
+
},
|
|
391
|
+
resize: {
|
|
392
|
+
count: sanitizeFiniteNumber,
|
|
393
|
+
targets: preserveResizeTargets,
|
|
394
|
+
seq: sanitizeFiniteNumber,
|
|
395
|
+
},
|
|
396
|
+
performance: {
|
|
397
|
+
entryType: preserveObserverString,
|
|
398
|
+
name: preserveObserverString,
|
|
399
|
+
startTime: sanitizeFiniteNumber,
|
|
400
|
+
duration: sanitizeFiniteNumber,
|
|
401
|
+
value: sanitizeFiniteNumber,
|
|
402
|
+
hadRecentInput: sanitizeBoolean,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* `domains.ts`/`timing.ts` declare their parameter as the concrete
|
|
408
|
+
* `CDPClient` class (private-field members, so TS won't structurally
|
|
409
|
+
* accept a plain stub there); both only ever call `.send()` on it (see
|
|
410
|
+
* their own doc comments), so this cast is the deliberate, documented seam
|
|
411
|
+
* that keeps `RecorderSession` testable with a stub client while still
|
|
412
|
+
* reusing those shared helpers unchanged.
|
|
413
|
+
*/
|
|
414
|
+
function asCDPClient(client: RecorderCdpClient): CDPClient {
|
|
415
|
+
return client as unknown as CDPClient;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export class RecorderSession {
|
|
419
|
+
readonly recDir: string;
|
|
420
|
+
readonly framesDir: string;
|
|
421
|
+
readonly eventsPath: string;
|
|
422
|
+
readonly rectsPath: string;
|
|
423
|
+
state: RecorderState = 'idle';
|
|
424
|
+
|
|
425
|
+
private client: RecorderCdpClient;
|
|
426
|
+
private events: EventBroker;
|
|
427
|
+
private frameCount = 0;
|
|
428
|
+
private eventCount = 0;
|
|
429
|
+
private startedAtWallClockMs = 0;
|
|
430
|
+
/** Per-recording unguessable token, embedded in the injected observer script's closure and required on every `Runtime.bindingCalled` payload — see the binding-hardening helpers above. */
|
|
431
|
+
private readonly bindingNonce = crypto.randomBytes(16).toString('hex');
|
|
432
|
+
/** The CDP isolated world's `worldName`, derived from the nonce. The binding is scoped to this
|
|
433
|
+
* name via `Runtime.addBinding({ executionContextName })`, and every `Page.createIsolatedWorld`
|
|
434
|
+
* (initial + per-navigation) creates a world of this name — so the binding auto-attaches to the
|
|
435
|
+
* recreated world after navigation without being re-issued, and is never exposed to the page main
|
|
436
|
+
* world. */
|
|
437
|
+
private readonly isolatedWorldName = `captureRecorder_${this.bindingNonce}`;
|
|
438
|
+
private baselines: RecorderClockBaselines | null = null;
|
|
439
|
+
/** Screencast-frame handling in flight, tracked so `stop()` can await every write/rect-sample before returning. Each promise removes itself on settle. */
|
|
440
|
+
private pendingFrames = new Set<Promise<void>>();
|
|
441
|
+
/** Post-navigation re-arms in flight, tracked so `stop()` can await ALL of them before tearing down — mirrors `pendingFrames` below: each `Page.frameNavigated` handler adds its own rearm promise here and removes only that promise on settle, so two overlapping rearms (a second navigation firing while the first rearm's `Runtime.evaluate` is still pending) are both awaited by `stop()` rather than one clobbering the other. */
|
|
442
|
+
private pendingRearm = new Set<Promise<void>>();
|
|
443
|
+
/** Resize-target identity-bridge follow-up calls in flight, tracked so `stop()` can await every
|
|
444
|
+
* one before tearing down the observer script — mirrors `pendingFrames`/`pendingRearm`. Each
|
|
445
|
+
* promise removes itself on settle. */
|
|
446
|
+
private pendingBindingResolution = new Set<Promise<void>>();
|
|
447
|
+
/** Flips to `false` right after `Page.stopScreencast` is issued in `stop()`; a screencast frame event arriving after that is ignored rather than starting new frame work. */
|
|
448
|
+
private acceptingFrames = true;
|
|
449
|
+
private bindingDropCounts = new Map<string, number>();
|
|
450
|
+
private bindingWindowStartedAtMs = 0;
|
|
451
|
+
private bindingWindowCount = 0;
|
|
452
|
+
/** Tallies of rect-sample elements dropped/truncated by host-side sanitization, by reason — flushed as `rect-sample-dropped` summary events at stop(), same style as `bindingDropCounts`. */
|
|
453
|
+
private rectDropCounts = new Map<string, number>();
|
|
454
|
+
/** Tallies of trace events dropped/truncated by host-side sanitization, by reason — flushed as `trace-dropped` summary events at stop(), same style as `rectDropCounts`. */
|
|
455
|
+
private traceDropCounts = new Map<string, number>();
|
|
456
|
+
/** The main frame's CDP `frameId`, resolved once via `Page.getFrameTree` on the first
|
|
457
|
+
* `injectObserverScript()` call, then kept current from `Page.frameNavigated`'s own `frame.id`
|
|
458
|
+
* on every subsequent rearm — avoids one `Page.getFrameTree` round trip per navigation. */
|
|
459
|
+
private mainFrameId: string | undefined;
|
|
460
|
+
/** The isolated world's `Runtime.evaluate` `contextId`, (re-)created by every
|
|
461
|
+
* `injectObserverScript()` call — every OTHER evaluate that touches the bridge
|
|
462
|
+
* (`sampleRects`, `resolveRectIdentity`, `resolveAndAppendResizeIdentity`, the stop-time
|
|
463
|
+
* teardown call) must scope itself to this same context, never create its own world. */
|
|
464
|
+
private isolatedWorldContextId: number | undefined;
|
|
465
|
+
/** Monotonic main-frame navigation counter, incremented on every main-frame `Page.frameNavigated`
|
|
466
|
+
* (and read as the generation the initial `start()` injection installs for). `injectObserverScript`
|
|
467
|
+
* stamps the generation it is installing for and publishes its context id only if no newer
|
|
468
|
+
* navigation has superseded it — so `isolatedWorldContextId` always tracks the latest document's
|
|
469
|
+
* world, never a stale one from a navigation that has already been overtaken. */
|
|
470
|
+
private navGeneration = 0;
|
|
471
|
+
/** The navGeneration whose isolated-world context is currently published in
|
|
472
|
+
* `isolatedWorldContextId`. `undefined` whenever no live world is active — before the first
|
|
473
|
+
* install, and from the moment a navigation destroys the old world until a rearm republishes.
|
|
474
|
+
* Lets `start()` and the bridge evaluates tell "the latest navigation actually installed a
|
|
475
|
+
* world" apart from "a rearm merely settled". */
|
|
476
|
+
private activeWorldGeneration: number | undefined;
|
|
477
|
+
/** Flips to `true` immediately before the final `flushPendingBindingResolutions()` drain in
|
|
478
|
+
* `stop()`, closing acceptance of NEW resize-identity resolvers — see `handleBindingCalled`'s
|
|
479
|
+
* `kind === 'resize'` branch. Mutation/performance/input events are intentionally still
|
|
480
|
+
* captured throughout the rest of `'stopping'`; only the async resize path is a real
|
|
481
|
+
* post-stop-append race target. */
|
|
482
|
+
private resizeResolutionClosed = false;
|
|
483
|
+
|
|
484
|
+
constructor(opts: RecorderSessionOptions) {
|
|
485
|
+
this.client = opts.client;
|
|
486
|
+
this.recDir = ensurePrivateDir(opts.recDir);
|
|
487
|
+
this.framesDir = ensurePrivateDir(path.join(this.recDir, 'frames'));
|
|
488
|
+
this.eventsPath = path.join(this.recDir, 'events.jsonl');
|
|
489
|
+
this.rectsPath = path.join(this.recDir, 'rects.jsonl');
|
|
490
|
+
this.events = new EventBroker(this.client);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async start(): Promise<RecorderClockBaselines> {
|
|
494
|
+
if (this.state !== 'idle') {
|
|
495
|
+
throw new Error(`recorder cannot start from state "${this.state}"`);
|
|
496
|
+
}
|
|
497
|
+
// Enter 'starting' before any await so the `Page.frameNavigated` listener registered below
|
|
498
|
+
// treats a navigation during initialization as live — recreating the isolated world and
|
|
499
|
+
// reinstalling the observer in the newest context rather than ignoring it.
|
|
500
|
+
this.state = 'starting';
|
|
501
|
+
|
|
502
|
+
await enableDomainsForMotionRec(asCDPClient(this.client));
|
|
503
|
+
|
|
504
|
+
this.client.on('Page.screencastFrame', (params) => {
|
|
505
|
+
this.onScreencastFrameEvent(params as ScreencastFrameParams);
|
|
506
|
+
});
|
|
507
|
+
this.client.on('Tracing.dataCollected', (params) => {
|
|
508
|
+
this.handleTraceData(params as { value: unknown[] });
|
|
509
|
+
});
|
|
510
|
+
this.client.on('Runtime.bindingCalled', (params) => {
|
|
511
|
+
this.handleBindingCalled(params as { name: string; payload: string; executionContextId?: number });
|
|
512
|
+
});
|
|
513
|
+
this.client.on('Page.frameNavigated', (params) => {
|
|
514
|
+
const rearm = this.handleFrameNavigated(params as { frame?: { id?: string; parentId?: string; url?: string } });
|
|
515
|
+
this.pendingRearm.add(rearm);
|
|
516
|
+
void rearm.finally(() => {
|
|
517
|
+
this.pendingRearm.delete(rearm);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
await this.ensureBinding();
|
|
522
|
+
await this.injectObserverScript(this.navGeneration);
|
|
523
|
+
|
|
524
|
+
// The first-frame/first-trace latch storage MUST exist before either stream is started:
|
|
525
|
+
// `Page.screencastFrame`/`Tracing.dataCollected` can only fire after `Page.startScreencast`/
|
|
526
|
+
// `Tracing.start` are issued, so reading the (performanceNowMs, wallClockMs) anchor and
|
|
527
|
+
// creating `this.baselines` here — before those two sends — closes the window where a
|
|
528
|
+
// frame/trace arriving immediately after the stream starts would otherwise hit
|
|
529
|
+
// `this.baselines === null` in the handler and have its real first timestamp discarded.
|
|
530
|
+
const clock = await readTraceClockBaseline(asCDPClient(this.client));
|
|
531
|
+
this.baselines = {
|
|
532
|
+
performanceNowMs: clock.performanceNowMs,
|
|
533
|
+
wallClockMs: clock.wallClockMs,
|
|
534
|
+
firstScreencastTimestampSec: null,
|
|
535
|
+
firstTraceEventTsUs: null,
|
|
536
|
+
baselinesPending: true,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
await this.client.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });
|
|
540
|
+
await this.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
|
|
541
|
+
|
|
542
|
+
// A main-frame navigation anywhere in the initialization window above spawned a rearm that
|
|
543
|
+
// recreated the isolated world in the newest context; wait for every one to settle so start()
|
|
544
|
+
// never returns with the bridge bound to a world a startup navigation already destroyed.
|
|
545
|
+
await this.drainPendingRearms();
|
|
546
|
+
|
|
547
|
+
// Refuse to enter recording unless the LATEST navigation generation actually installed and
|
|
548
|
+
// published its isolated world. A startup navigation that overtook the gen-0 install and then
|
|
549
|
+
// failed its own rearm leaves no live context — entering recording here would let bridge
|
|
550
|
+
// evaluates run with an undefined contextId (i.e. in the page main world), breaking the
|
|
551
|
+
// isolated-world boundary. Aborting propagates to `handleRecorderRequest` as an `ok:false`
|
|
552
|
+
// rec-start response.
|
|
553
|
+
if (this.isolatedWorldContextId === undefined || this.activeWorldGeneration !== this.navGeneration) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
'recorder start aborted: observer script was not installed in the latest main-frame context',
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.startedAtWallClockMs = Date.now();
|
|
560
|
+
this.state = 'recording';
|
|
561
|
+
return { ...this.baselines };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Handles a `type: 'cdp'` request — plain passthrough, or bracketed +
|
|
566
|
+
* logged as a labeled input landmark in `events.jsonl` when `mark` is set
|
|
567
|
+
* (the mark never touches the page — see `../timing.ts`'s
|
|
568
|
+
* `withDocumentPerformanceNow`), or a wait-event-ONLY request when
|
|
569
|
+
* `method` is omitted. Rejects outright once the recorder has left
|
|
570
|
+
* `'recording'` (i.e. is `'stopping'` or `'stopped'`) rather than dispatching
|
|
571
|
+
* against a connection that is mid-teardown.
|
|
572
|
+
*
|
|
573
|
+
* `req` arrives as parsed-JSON cast to `RecCdpRequest` (the wire lane, via
|
|
574
|
+
* `handleRecorderRequest`) or as a bare `RecCdpCall` (the one-shot `rec
|
|
575
|
+
* --do` lane in `commands/motion/rec.ts`, which drives this method in-proc
|
|
576
|
+
* — genuinely socket-less, with no `reqId`/`type`/`nonce` to supply and
|
|
577
|
+
* nothing here to fabricate: the nonce gate lives on the wire in
|
|
578
|
+
* `runRecorderBridge`'s `handleLine`, which that lane never touches).
|
|
579
|
+
* Neither shape is validated at parse time, so the type-level guarantee
|
|
580
|
+
* that `RecCdpWaitEventRequest` always carries a nonempty string
|
|
581
|
+
* `waitEvent` is NOT enforced at runtime. Validate here, before any
|
|
582
|
+
* dispatch: a request must carry EITHER a nonempty string `method`
|
|
583
|
+
* (dispatch, optionally awaiting `waitEvent` too) OR a nonempty string
|
|
584
|
+
* `waitEvent` (wait-event-only) — anything else (both absent, or a
|
|
585
|
+
* present-but-empty/non-string field) is an explicit protocol error, not a
|
|
586
|
+
* silent no-op "ok" response.
|
|
587
|
+
*
|
|
588
|
+
* Method result and wait outcome are SEPARATE. When a request carries both a
|
|
589
|
+
* `method` and a `waitEvent`, a successful method dispatch is never undone by
|
|
590
|
+
* the paired wait's own timeout: the wait is armed before the send (so a fast
|
|
591
|
+
* action-caused event cannot be missed), and if it later times out the method
|
|
592
|
+
* `result` (e.g. `Page.navigate`'s `loaderId`) is still returned, tagged
|
|
593
|
+
* `waitOutcome: 'bounded-timeout'` with no `event`; when the event is observed
|
|
594
|
+
* the outcome is `'observed'` with the `event`. Only a method dispatch
|
|
595
|
+
* FAILURE cancels the wait and throws. A wait-event-ONLY request (no method)
|
|
596
|
+
* that times out still rejects — there is no method result to preserve.
|
|
597
|
+
*/
|
|
598
|
+
async handleCdp(req: RecCdpRequest | RecCdpCall): Promise<{ result?: unknown; event?: unknown; waitOutcome?: 'observed' | 'bounded-timeout' }> {
|
|
599
|
+
if (this.state === 'stopping' || this.state === 'stopped') {
|
|
600
|
+
throw new Error(`cannot dispatch cdp request in state "${this.state}"`);
|
|
601
|
+
}
|
|
602
|
+
const hasMethod = typeof req.method === 'string' && req.method.length > 0;
|
|
603
|
+
const hasWaitEvent = typeof req.waitEvent === 'string' && req.waitEvent.length > 0;
|
|
604
|
+
if (!hasMethod && !hasWaitEvent) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
'Invalid cdp request: requires a nonempty string "method" (to dispatch) or "waitEvent" (to wait only) — got neither.',
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// This recorder owns a direct tab websocket, so its actual CDP event
|
|
611
|
+
// envelope scope is `undefined` (there is no flattened attach session).
|
|
612
|
+
// Arm synchronously before any triggering send below.
|
|
613
|
+
const eventWait = hasWaitEvent
|
|
614
|
+
? this.events.wait(req.waitEvent as string, undefined, req.timeoutMs ?? 10000)
|
|
615
|
+
: undefined;
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
if (!hasMethod) {
|
|
619
|
+
// Wait-event-ONLY request (`RecCdpWaitEventRequest`) — there is no CDP
|
|
620
|
+
// call to dispatch, so `client.send` must never be reached here (it
|
|
621
|
+
// would otherwise send `method: undefined` over the real websocket). A
|
|
622
|
+
// timeout here rejects (no method result to preserve).
|
|
623
|
+
const event = eventWait ? await eventWait.result() : undefined;
|
|
624
|
+
return { event };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let result: unknown;
|
|
628
|
+
if (req.mark) {
|
|
629
|
+
const internalMark = structuralMarkLabel(req.mark);
|
|
630
|
+
const bracket = await withDocumentPerformanceNow(asCDPClient(this.client), () =>
|
|
631
|
+
this.client.send(req.method!, req.params ?? {}, req.timeoutMs ?? 60000),
|
|
632
|
+
);
|
|
633
|
+
this.appendEvent({
|
|
634
|
+
kind: 'input',
|
|
635
|
+
action: req.mark,
|
|
636
|
+
mark: internalMark,
|
|
637
|
+
method: req.method,
|
|
638
|
+
startPerformanceNow: bracket.startPerformanceNow,
|
|
639
|
+
endPerformanceNow: bracket.endPerformanceNow,
|
|
640
|
+
});
|
|
641
|
+
result = bracket.result;
|
|
642
|
+
} else {
|
|
643
|
+
// `hasMethod` above already proved `req.method` is a nonempty string for this branch
|
|
644
|
+
// (mirrors the `req.method!` a few lines up in the `req.mark` branch); widening
|
|
645
|
+
// `handleCdp`'s parameter to accept the in-proc `RecCdpCall` shape too (see that type's
|
|
646
|
+
// doc comment) adds a third union member whose `method` is independently optional, which
|
|
647
|
+
// is enough to stop TS's aliased-condition narrowing from collapsing `req.method` to
|
|
648
|
+
// `string` here on its own.
|
|
649
|
+
result = await this.client.send(req.method!, req.params ?? {}, req.timeoutMs ?? 60000);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// The method dispatch succeeded. Resolve the paired wait WITHOUT letting
|
|
653
|
+
// its own timeout destroy the method result: an observed event tags
|
|
654
|
+
// `'observed'`; the wait's bounded timer elapsing tags `'bounded-timeout'`
|
|
655
|
+
// and preserves `result` with no `event`.
|
|
656
|
+
if (!eventWait) return { result };
|
|
657
|
+
const settled = await eventWait.result().then(
|
|
658
|
+
(event) => ({ event, waitOutcome: 'observed' as const }),
|
|
659
|
+
() => ({ waitOutcome: 'bounded-timeout' as const }),
|
|
660
|
+
);
|
|
661
|
+
return { result, ...settled };
|
|
662
|
+
} catch (err) {
|
|
663
|
+
eventWait?.cancel();
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async stop(): Promise<RecorderStopSummary> {
|
|
669
|
+
if (this.state !== 'recording') {
|
|
670
|
+
throw new Error(`cannot stop recorder in state "${this.state}"`);
|
|
671
|
+
}
|
|
672
|
+
// Flips away from 'recording' before any other await in this method, so every
|
|
673
|
+
// state-sensitive guard (`handleFrameNavigated`, `handleCdp`) already sees the recorder
|
|
674
|
+
// as no longer recording for the rest of this teardown.
|
|
675
|
+
this.state = 'stopping';
|
|
676
|
+
|
|
677
|
+
// Every rearm already committed to running at the exact recording/stopping boundary must
|
|
678
|
+
// finish before teardown proceeds — otherwise it races the CDP sends below. There can be more
|
|
679
|
+
// than one: two navigations firing while the first rearm's Runtime.evaluate is still pending
|
|
680
|
+
// both land in `pendingRearm`, and every one of them must settle before teardown continues.
|
|
681
|
+
await Promise.all(this.pendingRearm);
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
await this.client.send('Page.stopScreencast');
|
|
685
|
+
} catch {
|
|
686
|
+
// Best-effort — the tab/browser may already be gone.
|
|
687
|
+
}
|
|
688
|
+
// No new screencast-frame work starts past this point; whatever was already
|
|
689
|
+
// dispatched (including anything queued in the brief race window before this
|
|
690
|
+
// flips) is awaited below instead of discarded.
|
|
691
|
+
this.acceptingFrames = false;
|
|
692
|
+
await this.flushPendingFrames();
|
|
693
|
+
|
|
694
|
+
const tracingCompleteWait = this.events.wait('Tracing.tracingComplete', undefined, 5000);
|
|
695
|
+
try {
|
|
696
|
+
await this.client.send('Tracing.end');
|
|
697
|
+
await tracingCompleteWait.result().catch(() => undefined);
|
|
698
|
+
} catch {
|
|
699
|
+
// Best-effort, including removal of the now-ownerless event wait.
|
|
700
|
+
tracingCompleteWait.cancel();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Every resize-target identity-bridge follow-up already committed to running must finish
|
|
704
|
+
// before teardown removes the observer script it depends on. Closing acceptance immediately
|
|
705
|
+
// before this quiescence drain guarantees the set can only shrink: existing work settles, and
|
|
706
|
+
// later resize bindings are tallied as drops instead of spawning untracked append work.
|
|
707
|
+
this.resizeResolutionClosed = true;
|
|
708
|
+
await this.flushPendingBindingResolutions();
|
|
709
|
+
|
|
710
|
+
// Skip the in-page teardown entirely when no isolated world is live (a navigation destroyed it
|
|
711
|
+
// and no rearm republished) — the world's globals are already gone, and sending this evaluate
|
|
712
|
+
// with an undefined contextId would run it in the page main world.
|
|
713
|
+
if (this.isolatedWorldContextId !== undefined) {
|
|
714
|
+
try {
|
|
715
|
+
await this.client.send('Runtime.evaluate', {
|
|
716
|
+
expression: `(function(){var k='__captureRecorder_'+${JSON.stringify(this.bindingNonce)};return window[k]&&window[k].teardown();})()`,
|
|
717
|
+
contextId: this.requireIsolatedContextId(),
|
|
718
|
+
});
|
|
719
|
+
} catch {
|
|
720
|
+
// Best-effort — the page may already be gone.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
await this.client.send('Runtime.removeBinding', { name: RECORDER_BINDING_NAME });
|
|
725
|
+
} catch {
|
|
726
|
+
// Best-effort.
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
this.flushBindingDropSummary();
|
|
730
|
+
this.flushRectDropSummary();
|
|
731
|
+
this.flushTraceDropSummary();
|
|
732
|
+
|
|
733
|
+
this.state = 'stopped';
|
|
734
|
+
return {
|
|
735
|
+
frameCount: this.frameCount,
|
|
736
|
+
eventCount: this.eventCount,
|
|
737
|
+
durationMs: Date.now() - this.startedAtWallClockMs,
|
|
738
|
+
// this.baselines is always set by the time stop() is reachable — start() sets it before
|
|
739
|
+
// flipping state to 'recording', and stop() only runs from state 'recording'.
|
|
740
|
+
markers: { ...this.baselines! },
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Adds the CDP binding once, called only from `start()`, scoped to this recording's isolated world
|
|
746
|
+
* via `executionContextName` so `window.captureRecorderEmit` is exposed ONLY inside the nonce-named
|
|
747
|
+
* world and is absent from the page main world — closing the page-observable/page-callable side
|
|
748
|
+
* channel. The `executionContextName` form auto-attaches the binding to the world every time
|
|
749
|
+
* `Page.createIsolatedWorld` recreates it under the same name (i.e. after navigation), so the
|
|
750
|
+
* binding is never re-issued from the rearm path. Defensively idempotent: an "already
|
|
751
|
+
* exists"-shaped error from the single call is swallowed rather than thrown.
|
|
752
|
+
*/
|
|
753
|
+
private async ensureBinding(): Promise<void> {
|
|
754
|
+
try {
|
|
755
|
+
await this.client.send('Runtime.addBinding', {
|
|
756
|
+
name: RECORDER_BINDING_NAME,
|
|
757
|
+
executionContextName: this.isolatedWorldName,
|
|
758
|
+
});
|
|
759
|
+
} catch (err) {
|
|
760
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
761
|
+
if (!/already exists/i.test(message)) throw err;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** The active isolated-world context id, or a thrown error when none is live — before the first
|
|
766
|
+
* install, and from the moment a navigation destroys the world until a rearm republishes. Every
|
|
767
|
+
* bridge-touching evaluate routes its `contextId` through this so it can never fall into the page
|
|
768
|
+
* main world by sending an `undefined` contextId (CDP omits an undefined param, and
|
|
769
|
+
* `Runtime.evaluate` with no contextId runs in the page main world). */
|
|
770
|
+
private requireIsolatedContextId(): number {
|
|
771
|
+
if (this.isolatedWorldContextId === undefined) {
|
|
772
|
+
throw new Error('recorder bridge has no active isolated world context');
|
|
773
|
+
}
|
|
774
|
+
return this.isolatedWorldContextId;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Returns `this.mainFrameId` if already known; otherwise resolves it once via
|
|
778
|
+
* `Page.getFrameTree`. Only the very first call (from `start()`, via `injectObserverScript()`)
|
|
779
|
+
* hits `Page.getFrameTree` — every subsequent rearm already has `mainFrameId` set from
|
|
780
|
+
* `Page.frameNavigated`'s own event (see `handleFrameNavigated`), so no extra CDP round trip
|
|
781
|
+
* per navigation. */
|
|
782
|
+
private async resolveMainFrameId(): Promise<string> {
|
|
783
|
+
if (this.mainFrameId) return this.mainFrameId;
|
|
784
|
+
const tree = (await this.client.send('Page.getFrameTree', {})) as { frameTree?: { frame?: { id?: string } } };
|
|
785
|
+
const frameId = tree.frameTree?.frame?.id;
|
|
786
|
+
if (!frameId) throw new Error('recorder bridge could not resolve the main frame id via Page.getFrameTree');
|
|
787
|
+
this.mainFrameId = frameId;
|
|
788
|
+
return frameId;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* (Re-)injects the observer script into a FRESH CDP isolated world of this recording's stable
|
|
793
|
+
* `isolatedWorldName`, and publishes its execution context id as the active bridge context ONLY
|
|
794
|
+
* after the in-page install is confirmed. Runs once from `start()` and again on every
|
|
795
|
+
* post-navigation rearm — a navigation wipes the page's JS world and destroys the isolated world
|
|
796
|
+
* along with it, so a fresh world must be created every time, never reused (the binding, scoped to
|
|
797
|
+
* the world name, auto-reattaches — see `ensureBinding()`).
|
|
798
|
+
*
|
|
799
|
+
* `Runtime.evaluate` resolves (does NOT reject) when the injected JS throws, reporting it in
|
|
800
|
+
* `exceptionDetails`; a script that installs cleanly returns `OBSERVER_INSTALLED_SENTINEL`. Both
|
|
801
|
+
* are checked here: an install that throws or fails to confirm the sentinel is fatal (propagates)
|
|
802
|
+
* and NO context id is published, so a failed install never leaves a usable-looking context with
|
|
803
|
+
* no bridge behind it. A failed rearm surfaces as an `error` event via `handleFrameNavigated`'s
|
|
804
|
+
* try/catch; a failed initial install aborts `start()`.
|
|
805
|
+
*
|
|
806
|
+
* `generation` is the navigation generation this install belongs to. The context id is published
|
|
807
|
+
* only if no newer main-frame navigation has bumped `navGeneration` in the meantime, so
|
|
808
|
+
* concurrent/overlapping rearms always leave `isolatedWorldContextId` pointing at the latest
|
|
809
|
+
* document's world regardless of the order their evaluates complete.
|
|
810
|
+
*/
|
|
811
|
+
private async injectObserverScript(generation: number): Promise<void> {
|
|
812
|
+
const frameId = await this.resolveMainFrameId();
|
|
813
|
+
const created = (await this.client.send('Page.createIsolatedWorld', {
|
|
814
|
+
frameId,
|
|
815
|
+
worldName: this.isolatedWorldName,
|
|
816
|
+
grantUniveralAccess: false,
|
|
817
|
+
})) as { executionContextId?: number };
|
|
818
|
+
const contextId = created.executionContextId;
|
|
819
|
+
if (typeof contextId !== 'number') {
|
|
820
|
+
throw new Error('recorder bridge isolated world creation returned no execution context id');
|
|
821
|
+
}
|
|
822
|
+
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
823
|
+
expression: buildObserverScript(RECORDER_BINDING_NAME, this.bindingNonce),
|
|
824
|
+
contextId,
|
|
825
|
+
returnByValue: true,
|
|
826
|
+
})) as { result?: { value?: unknown }; exceptionDetails?: unknown };
|
|
827
|
+
if (evaluation.exceptionDetails) {
|
|
828
|
+
throw new Error(`recorder observer script install threw: ${JSON.stringify(evaluation.exceptionDetails)}`);
|
|
829
|
+
}
|
|
830
|
+
if (evaluation.result?.value !== OBSERVER_INSTALLED_SENTINEL) {
|
|
831
|
+
throw new Error('recorder observer script install did not confirm the installed sentinel');
|
|
832
|
+
}
|
|
833
|
+
// Publish only if this install is still for the latest navigation generation — a newer
|
|
834
|
+
// navigation that bumped navGeneration owns a fresher world and will publish its own id.
|
|
835
|
+
// Both fields move together so `activeWorldGeneration` always names the generation whose
|
|
836
|
+
// context is live in `isolatedWorldContextId`.
|
|
837
|
+
if (generation === this.navGeneration) {
|
|
838
|
+
this.isolatedWorldContextId = contextId;
|
|
839
|
+
this.activeWorldGeneration = generation;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* `Page.frameNavigated` handler: a main-frame navigation destroys the page's JS world, silently
|
|
845
|
+
* dropping the injected observers (the binding itself survives — see `ensureBinding()`).
|
|
846
|
+
* Recreates the isolated world, re-injects the observer script best-effort, and records a
|
|
847
|
+
* `navigation-gap` marker so downstream consumers know the Mutation/Resize/PerformanceObserver
|
|
848
|
+
* stream has a gap around this point (the binding, scoped to the world name, auto-reattaches to
|
|
849
|
+
* the recreated world — see `ensureBinding()`). Every main-frame navigation bumps `navGeneration`,
|
|
850
|
+
* so a rearm publishes its context id only if no newer navigation has overtaken it. Runs both
|
|
851
|
+
* while `'recording'` and while `'starting'` (a navigation during `start()` initialization must
|
|
852
|
+
* still land the observer in the newest context). Sub-frame navigations (`frame.parentId` set)
|
|
853
|
+
* don't affect the top document's world and are ignored. Bails once the recorder has left those
|
|
854
|
+
* phases (`'stopping'`/`'stopped'`) so a navigation arriving mid-teardown doesn't start new CDP
|
|
855
|
+
* work concurrent with it — `stop()` awaits every rearm still in flight at that boundary (tracked
|
|
856
|
+
* in `pendingRearm`, which can hold more than one entry when navigations overlap).
|
|
857
|
+
*/
|
|
858
|
+
private async handleFrameNavigated(params: { frame?: { id?: string; parentId?: string; url?: string } }): Promise<void> {
|
|
859
|
+
if (this.state !== 'recording' && this.state !== 'starting') return;
|
|
860
|
+
const frame = params.frame;
|
|
861
|
+
if (!frame || frame.parentId) return;
|
|
862
|
+
const generation = ++this.navGeneration;
|
|
863
|
+
// The navigation destroyed the current isolated world; drop the now-dead context id
|
|
864
|
+
// immediately so no bridge evaluate targets it while the rearm is in flight — or if the
|
|
865
|
+
// rearm fails and never republishes.
|
|
866
|
+
this.isolatedWorldContextId = undefined;
|
|
867
|
+
this.activeWorldGeneration = undefined;
|
|
868
|
+
if (frame.id) this.mainFrameId = frame.id;
|
|
869
|
+
this.appendEvent({ kind: 'navigation-gap', url: preserveObserverString(frame.url) ?? null });
|
|
870
|
+
try {
|
|
871
|
+
await this.injectObserverScript(generation);
|
|
872
|
+
} catch (err) {
|
|
873
|
+
this.appendEvent({
|
|
874
|
+
kind: 'error',
|
|
875
|
+
message: `observer re-arm after navigation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private appendEvent(record: Omit<RecorderEventRecord, 'recordedAtWallClockMs'>): void {
|
|
881
|
+
// Defense-in-depth: nothing may append once fully stopped. Scoped to `'stopped'` specifically,
|
|
882
|
+
// NOT `'stopping'` — mutation/performance/trace/input events are intentionally still captured
|
|
883
|
+
// throughout most of `'stopping'`. Every existing call to `appendEvent` from within `stop()`
|
|
884
|
+
// itself (the drop-summary flushes) runs before `this.state = 'stopped'` is set, so this guard
|
|
885
|
+
// never fires on a legitimate call.
|
|
886
|
+
if (this.state === 'stopped') return;
|
|
887
|
+
this.eventCount++;
|
|
888
|
+
appendNdjsonPrivate(this.eventsPath, {
|
|
889
|
+
...record,
|
|
890
|
+
recordedAtWallClockMs: Date.now(),
|
|
891
|
+
} satisfies RecorderEventRecord);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** `Page.screencastFrame` listener body: tracks the handler's promise so `stop()` can flush it, and stops admitting new frame work once `acceptingFrames` is false. */
|
|
895
|
+
private onScreencastFrameEvent(params: ScreencastFrameParams): void {
|
|
896
|
+
if (!this.acceptingFrames) return;
|
|
897
|
+
const handled = this.handleScreencastFrame(params);
|
|
898
|
+
this.pendingFrames.add(handled);
|
|
899
|
+
void handled.finally(() => {
|
|
900
|
+
this.pendingFrames.delete(handled);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Drains every in-flight post-navigation rearm, looping because a rearm can itself spawn while
|
|
905
|
+
* the set is being awaited (a navigation firing during another rearm). `start()` uses it to
|
|
906
|
+
* guarantee the observer is installed in the latest context before recording begins. */
|
|
907
|
+
private async drainPendingRearms(): Promise<void> {
|
|
908
|
+
while (this.pendingRearm.size > 0) {
|
|
909
|
+
await Promise.allSettled(Array.from(this.pendingRearm));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private async flushPendingFrames(): Promise<void> {
|
|
914
|
+
// Loop rather than a single snapshot-and-await: a frame event already queued in the event
|
|
915
|
+
// loop when `acceptingFrames` flips can still add one more promise after we've read the set.
|
|
916
|
+
while (this.pendingFrames.size > 0) {
|
|
917
|
+
await Promise.allSettled(Array.from(this.pendingFrames));
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
private async handleScreencastFrame(params: ScreencastFrameParams): Promise<void> {
|
|
922
|
+
const frameIndex = this.frameCount++;
|
|
923
|
+
try {
|
|
924
|
+
if (params.sessionId !== undefined) {
|
|
925
|
+
await this.client.send('Page.screencastFrameAck', { sessionId: params.sessionId });
|
|
926
|
+
}
|
|
927
|
+
} catch {
|
|
928
|
+
// Best-effort ack — losing one ack just risks the browser pausing the stream.
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (this.baselines && this.baselines.firstScreencastTimestampSec === null && typeof params.metadata?.timestamp === 'number') {
|
|
932
|
+
this.baselines.firstScreencastTimestampSec = params.metadata.timestamp;
|
|
933
|
+
this.updateBaselinesPending();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const frameName = `frame-${String(frameIndex).padStart(6, '0')}.png`;
|
|
937
|
+
writeBinaryPrivate(path.join(this.framesDir, frameName), Buffer.from(params.data, 'base64'));
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
const sample = await this.sampleRects(frameIndex);
|
|
941
|
+
const elements = this.sanitizeRectSample(sample.facts, sample.backendNodeIds);
|
|
942
|
+
appendNdjsonPrivate(this.rectsPath, {
|
|
943
|
+
frame: frameIndex,
|
|
944
|
+
file: frameName,
|
|
945
|
+
cssToDevice: cssToDeviceTransform(sample.viewport, framePngDimensions(params.data)),
|
|
946
|
+
screencastTimestamp: params.metadata?.timestamp ?? null,
|
|
947
|
+
screencastTimestampPrecision: 'frame-metadata',
|
|
948
|
+
recordedAtWallClockMs: Date.now(),
|
|
949
|
+
elements,
|
|
950
|
+
} satisfies FrameRectsRecord);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
// A failed rect sample for one frame shouldn't take the recorder down;
|
|
953
|
+
// the frame PNG above is already written regardless.
|
|
954
|
+
this.appendEvent({
|
|
955
|
+
kind: 'error',
|
|
956
|
+
message: `rect sample failed for frame ${frameIndex}: ${err instanceof Error ? err.message : String(err)}`,
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private handleTraceData(params: { value: unknown[] }): void {
|
|
962
|
+
// Once fully stopped, discard — a trace batch can legitimately still arrive during the
|
|
963
|
+
// 'stopping' window (between `Tracing.end` and `tracingComplete`) and must still be captured.
|
|
964
|
+
if (this.state === 'stopped') return;
|
|
965
|
+
// Capture the earliest event timestamp as the trace baseline before recording this batch.
|
|
966
|
+
this.captureFirstTraceEventTs(params.value);
|
|
967
|
+
const events = this.sanitizeTraceEvents(Array.isArray(params.value) ? params.value : []);
|
|
968
|
+
this.appendEvent({ kind: 'trace', events });
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Preserves each JSON-shaped `Tracing.dataCollected` event in full while
|
|
973
|
+
* enforcing batch event-count and serialized-byte caps. `JSON.stringify`
|
|
974
|
+
* measures the same encoding later written to NDJSON; the already-decoded
|
|
975
|
+
* CDP object itself is retained without a redundant parse clone.
|
|
976
|
+
*/
|
|
977
|
+
private sanitizeTraceEvents(raw: unknown[]): Array<Record<string, unknown>> {
|
|
978
|
+
const events: Array<Record<string, unknown>> = [];
|
|
979
|
+
let serializedBytes = 0;
|
|
980
|
+
for (let i = 0; i < raw.length; i++) {
|
|
981
|
+
if (events.length >= MAX_TRACE_EVENTS_PER_BATCH) {
|
|
982
|
+
this.recordTraceDrop('event-cap', raw.length - i);
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
const item = raw[i];
|
|
986
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
987
|
+
this.recordTraceDrop('invalid-shape');
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let encoded: string;
|
|
992
|
+
try {
|
|
993
|
+
encoded = JSON.stringify(item);
|
|
994
|
+
} catch {
|
|
995
|
+
this.recordTraceDrop('invalid-shape');
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const sizeBytes = Buffer.byteLength(encoded, 'utf-8');
|
|
999
|
+
if (serializedBytes + sizeBytes > MAX_TRACE_SERIALIZED_BYTES) {
|
|
1000
|
+
this.recordTraceDrop('byte-budget', raw.length - i);
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
serializedBytes += sizeBytes;
|
|
1004
|
+
events.push(item as Record<string, unknown>);
|
|
1005
|
+
}
|
|
1006
|
+
return events;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private recordTraceDrop(reason: string, count = 1): void {
|
|
1010
|
+
this.traceDropCounts.set(reason, (this.traceDropCounts.get(reason) ?? 0) + count);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Writes one summarizing `trace-dropped` event per drop reason instead of one per dropped/truncated trace event. */
|
|
1014
|
+
private flushTraceDropSummary(): void {
|
|
1015
|
+
for (const [reason, count] of this.traceDropCounts) {
|
|
1016
|
+
this.appendEvent({ kind: 'trace-dropped', reason, count });
|
|
1017
|
+
}
|
|
1018
|
+
this.traceDropCounts.clear();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private captureFirstTraceEventTs(events: unknown[]): void {
|
|
1022
|
+
if (!this.baselines || this.baselines.firstTraceEventTsUs !== null) return;
|
|
1023
|
+
for (const event of events) {
|
|
1024
|
+
const ts = (event as { ts?: unknown } | undefined)?.ts;
|
|
1025
|
+
if (typeof ts === 'number' && Number.isFinite(ts)) {
|
|
1026
|
+
this.baselines.firstTraceEventTsUs = ts;
|
|
1027
|
+
this.updateBaselinesPending();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private updateBaselinesPending(): void {
|
|
1034
|
+
if (!this.baselines) return;
|
|
1035
|
+
this.baselines.baselinesPending =
|
|
1036
|
+
this.baselines.firstScreencastTimestampSec === null || this.baselines.firstTraceEventTsUs === null;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* `Runtime.bindingCalled` handler — the page→host channel is untrusted input. The binding is
|
|
1041
|
+
* scoped to the isolated world (see `ensureBinding()`), so a legitimate call originates from that
|
|
1042
|
+
* world's execution context and carries this recording's nonce in its payload. Both origin checks
|
|
1043
|
+
* run BEFORE the per-second rate limit is consumed — a call from any other execution context is
|
|
1044
|
+
* dropped as `wrong-origin`, and a payload missing the nonce as `bad-nonce`, without touching the
|
|
1045
|
+
* rate budget — so no foreign-context flood can starve legitimate isolated-world events out of the
|
|
1046
|
+
* shared budget. A payload must also be `<= MAX_BINDING_PAYLOAD_BYTES` (UTF-8, checked before
|
|
1047
|
+
* parsing) and carry a whitelisted `kind`; only that kind's schema fields are retained, with
|
|
1048
|
+
* admitted strings and arrays preserved verbatim. Anything that fails a check is dropped and tallied, never parsed further or written
|
|
1049
|
+
* verbatim. Discards outright once fully `'stopped'`; an observer emission can legitimately still
|
|
1050
|
+
* arrive during the `'stopping'` window and must still be captured.
|
|
1051
|
+
*/
|
|
1052
|
+
private handleBindingCalled(params: { name: string; payload: string; executionContextId?: number }): void {
|
|
1053
|
+
if (this.state === 'stopped') return;
|
|
1054
|
+
if (params.name !== RECORDER_BINDING_NAME) return;
|
|
1055
|
+
|
|
1056
|
+
// Origin gate — before the rate limit. Only the active isolated world's context may drive the
|
|
1057
|
+
// channel; anything from another context is dropped without consuming the shared per-second
|
|
1058
|
+
// budget, so a foreign-context flood cannot starve legitimate isolated-world events.
|
|
1059
|
+
if (this.isolatedWorldContextId === undefined || params.executionContextId !== this.isolatedWorldContextId) {
|
|
1060
|
+
this.recordBindingDrop('wrong-origin');
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (typeof params.payload !== 'string' || Buffer.byteLength(params.payload, 'utf8') > MAX_BINDING_PAYLOAD_BYTES) {
|
|
1065
|
+
this.recordBindingDrop('oversized-payload');
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
let payload: unknown;
|
|
1070
|
+
try {
|
|
1071
|
+
payload = JSON.parse(params.payload);
|
|
1072
|
+
} catch {
|
|
1073
|
+
this.recordBindingDrop('invalid-json');
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (!payload || typeof payload !== 'object') {
|
|
1077
|
+
this.recordBindingDrop('invalid-shape');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const record = payload as Record<string, unknown>;
|
|
1082
|
+
// Nonce is the second origin proof, also checked before the rate limit, so a bad-nonce flood
|
|
1083
|
+
// (even from the right context) cannot consume the budget either.
|
|
1084
|
+
if (record.nonce !== this.bindingNonce) {
|
|
1085
|
+
this.recordBindingDrop('bad-nonce');
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Only a correctly-origined, correctly-nonced emission consumes the rate budget.
|
|
1090
|
+
if (!this.checkBindingRateLimit()) {
|
|
1091
|
+
this.recordBindingDrop('rate-limited');
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const kind = typeof record.kind === 'string' ? record.kind : '';
|
|
1096
|
+
const sanitizers = BINDING_FIELD_SANITIZERS[kind];
|
|
1097
|
+
if (!sanitizers) {
|
|
1098
|
+
this.recordBindingDrop('unknown-kind');
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const sanitized: Omit<RecorderEventRecord, 'recordedAtWallClockMs'> = { kind };
|
|
1103
|
+
const perfNow = sanitizeFiniteNumber(record.performanceNowMs);
|
|
1104
|
+
if (perfNow !== undefined) sanitized.performanceNowMs = perfNow;
|
|
1105
|
+
for (const [field, sanitize] of Object.entries(sanitizers)) {
|
|
1106
|
+
const value = sanitize(record[field]);
|
|
1107
|
+
if (value !== undefined) sanitized[field] = value;
|
|
1108
|
+
}
|
|
1109
|
+
if (kind === 'resize') {
|
|
1110
|
+
if (this.resizeResolutionClosed) {
|
|
1111
|
+
this.recordBindingDrop('resize-resolution-closed');
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const resolving = this.resolveAndAppendResizeIdentity(sanitized);
|
|
1115
|
+
this.pendingBindingResolution.add(resolving);
|
|
1116
|
+
void resolving.finally(() => {
|
|
1117
|
+
this.pendingBindingResolution.delete(resolving);
|
|
1118
|
+
});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
this.appendEvent(sanitized);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Async continuation for a `kind:'resize'` binding event: resolves each target's `backendNodeId`
|
|
1126
|
+
* via a follow-up CDP round trip (the page-assigned `seq` — see `buildObserverScript` — keys
|
|
1127
|
+
* into the SAME nonce-scoped element queue the rect sampler's `takeRectElements` sibling uses),
|
|
1128
|
+
* then appends the event. Runs async (unlike every other binding kind, which appends
|
|
1129
|
+
* synchronously) because backendNodeId resolution requires its own `Runtime.evaluate` +
|
|
1130
|
+
* `DOM.describeNode` round trips — tracked in `pendingBindingResolution` so `stop()` can await it
|
|
1131
|
+
* before tearing down the observer script. Best-effort: any CDP failure along the way leaves
|
|
1132
|
+
* every target `identityUnresolved: true` rather than throwing out of a binding-event handler.
|
|
1133
|
+
*/
|
|
1134
|
+
private async resolveAndAppendResizeIdentity(
|
|
1135
|
+
sanitized: Omit<RecorderEventRecord, 'recordedAtWallClockMs'>,
|
|
1136
|
+
): Promise<void> {
|
|
1137
|
+
const targets = Array.isArray(sanitized.targets) ? (sanitized.targets as Array<Record<string, unknown>>) : [];
|
|
1138
|
+
const seq = sanitized.seq;
|
|
1139
|
+
const backendNodeIds = new Array<number | undefined>(targets.length).fill(undefined);
|
|
1140
|
+
|
|
1141
|
+
if (typeof seq === 'number' && targets.length > 0) {
|
|
1142
|
+
let arrayObjectId: string | undefined;
|
|
1143
|
+
let objectIds: Array<string | undefined> = [];
|
|
1144
|
+
try {
|
|
1145
|
+
const client = asCDPClient(this.client);
|
|
1146
|
+
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
1147
|
+
expression: buildTakeResizeTargetsExpression(this.bindingNonce, seq),
|
|
1148
|
+
returnByValue: false,
|
|
1149
|
+
contextId: this.requireIsolatedContextId(),
|
|
1150
|
+
})) as { result: { objectId?: string }; exceptionDetails?: unknown };
|
|
1151
|
+
arrayObjectId = evaluation.exceptionDetails ? undefined : evaluation.result.objectId;
|
|
1152
|
+
if (arrayObjectId) {
|
|
1153
|
+
objectIds = await resolveIndexedObjectIds(client, arrayObjectId, targets.length);
|
|
1154
|
+
await Promise.all(
|
|
1155
|
+
objectIds.map(async (objectId, idx) => {
|
|
1156
|
+
if (!objectId) return;
|
|
1157
|
+
backendNodeIds[idx] = await describeBackendNodeId(client, objectId);
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
} catch {
|
|
1162
|
+
// Best-effort — see doc comment above.
|
|
1163
|
+
} finally {
|
|
1164
|
+
const releaseIds = [arrayObjectId, ...objectIds].filter((id): id is string => Boolean(id));
|
|
1165
|
+
for (const id of releaseIds) {
|
|
1166
|
+
try {
|
|
1167
|
+
await this.client.send('Runtime.releaseObject', { objectId: id });
|
|
1168
|
+
} catch {
|
|
1169
|
+
// Best-effort.
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
sanitized.targets = targets.map((t, idx) => {
|
|
1176
|
+
const backendNodeId = backendNodeIds[idx];
|
|
1177
|
+
return backendNodeId === undefined
|
|
1178
|
+
? { ...t, backendNodeId: null, identityUnresolved: true }
|
|
1179
|
+
: { ...t, backendNodeId };
|
|
1180
|
+
});
|
|
1181
|
+
delete sanitized.seq; // internal correlation token only — not part of the public event shape
|
|
1182
|
+
this.appendEvent(sanitized);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private async flushPendingBindingResolutions(): Promise<void> {
|
|
1186
|
+
while (this.pendingBindingResolution.size > 0) {
|
|
1187
|
+
await Promise.allSettled(Array.from(this.pendingBindingResolution));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private checkBindingRateLimit(): boolean {
|
|
1192
|
+
const now = Date.now();
|
|
1193
|
+
if (now - this.bindingWindowStartedAtMs >= 1000) {
|
|
1194
|
+
this.bindingWindowStartedAtMs = now;
|
|
1195
|
+
this.bindingWindowCount = 0;
|
|
1196
|
+
}
|
|
1197
|
+
this.bindingWindowCount++;
|
|
1198
|
+
return this.bindingWindowCount <= BINDING_RATE_LIMIT_PER_SECOND;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private recordBindingDrop(reason: string): void {
|
|
1202
|
+
this.bindingDropCounts.set(reason, (this.bindingDropCounts.get(reason) ?? 0) + 1);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** Writes one summarizing `binding-dropped` event per drop reason instead of one per dropped payload. */
|
|
1206
|
+
private flushBindingDropSummary(): void {
|
|
1207
|
+
for (const [reason, count] of this.bindingDropCounts) {
|
|
1208
|
+
this.appendEvent({ kind: 'binding-dropped', reason, count });
|
|
1209
|
+
}
|
|
1210
|
+
this.bindingDropCounts.clear();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Returns the RAW rect facts (`getBoundingClientRect` data read over the page DOM inside the
|
|
1215
|
+
* isolated world — untyped and untrusted, since the DOM it reads is page-controlled;
|
|
1216
|
+
* `sanitizeRectSample()` is the host-side guard that turns them into `SampledRect[]`, and this
|
|
1217
|
+
* method must never hand back that data typed as if it were already safe) alongside this frame's
|
|
1218
|
+
* resolved `backendNodeId`s, one per fact in the same order.
|
|
1219
|
+
*/
|
|
1220
|
+
private async sampleRects(frameIndex: number): Promise<{ facts: unknown; viewport: unknown; backendNodeIds: Array<number | undefined> }> {
|
|
1221
|
+
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
1222
|
+
expression: buildSampleRectsExpression(this.bindingNonce, frameIndex),
|
|
1223
|
+
returnByValue: true,
|
|
1224
|
+
contextId: this.requireIsolatedContextId(),
|
|
1225
|
+
})) as { result: { value?: unknown }; exceptionDetails?: unknown };
|
|
1226
|
+
if (evaluation.exceptionDetails) {
|
|
1227
|
+
throw new Error(`rect sampling failed: ${JSON.stringify(evaluation.exceptionDetails)}`);
|
|
1228
|
+
}
|
|
1229
|
+
const value = evaluation.result.value as { elements?: unknown; viewport?: unknown } | unknown[] | undefined;
|
|
1230
|
+
// Older recording seams returned the raw array; new recordings return the
|
|
1231
|
+
// array plus viewport facts needed for the transform.
|
|
1232
|
+
const facts = Array.isArray(value) ? value : value?.elements;
|
|
1233
|
+
const count = Array.isArray(facts) ? facts.length : 0;
|
|
1234
|
+
const backendNodeIds = await this.resolveRectIdentity(frameIndex, count);
|
|
1235
|
+
return { facts, viewport: Array.isArray(value) ? undefined : value?.viewport, backendNodeIds };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Follow-up identity bridge for the SAME frame's rect sample: drains the elements
|
|
1240
|
+
* `buildSampleRectsExpression` stashed page-side (via `stashRectElements`) into a nonce-scoped
|
|
1241
|
+
* queue keyed by `frameIndex`, as a held remote array, then bridges each to a `backendNodeId`
|
|
1242
|
+
* via `describeBackendNodeId` (the same identity-bridge primitive `geometry.ts`/`hittest.ts`
|
|
1243
|
+
* use). Bounded by `MAX_RECT_IDENTITY_RESOLUTIONS`; elements past the cap, or any element whose
|
|
1244
|
+
* `DOM.describeNode` fails, are left `undefined` here (mapped to `identityUnresolved: true` by
|
|
1245
|
+
* `sanitizeRectSample`). Uses the local `resolveCappedRectObjectIds` so the release loop below
|
|
1246
|
+
* releases every remote-object handle the `Runtime.getProperties` response materialized,
|
|
1247
|
+
* including descriptor handles outside the capped numeric slice; `resolveCount` bounds only the
|
|
1248
|
+
* `DOM.describeNode` follow-ups. Releases every held objectId before
|
|
1249
|
+
* returning, regardless of outcome — this runs every frame, so an unreleased handle here
|
|
1250
|
+
* accumulates for the WHOLE recording, unlike a one-shot measure collector.
|
|
1251
|
+
*/
|
|
1252
|
+
private async resolveRectIdentity(frameIndex: number, count: number): Promise<Array<number | undefined>> {
|
|
1253
|
+
const backendNodeIds = new Array<number | undefined>(count).fill(undefined);
|
|
1254
|
+
if (count <= 0) return backendNodeIds;
|
|
1255
|
+
const resolveCount = Math.min(count, MAX_RECT_IDENTITY_RESOLUTIONS);
|
|
1256
|
+
|
|
1257
|
+
let arrayObjectId: string | undefined;
|
|
1258
|
+
let allMaterializedObjectIds: string[] = [];
|
|
1259
|
+
try {
|
|
1260
|
+
const client = asCDPClient(this.client);
|
|
1261
|
+
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
1262
|
+
expression: buildTakeRectElementsExpression(this.bindingNonce, frameIndex),
|
|
1263
|
+
returnByValue: false,
|
|
1264
|
+
contextId: this.requireIsolatedContextId(),
|
|
1265
|
+
})) as { result: { objectId?: string }; exceptionDetails?: unknown };
|
|
1266
|
+
arrayObjectId = evaluation.exceptionDetails ? undefined : evaluation.result.objectId;
|
|
1267
|
+
if (arrayObjectId) {
|
|
1268
|
+
const resolved = await resolveCappedRectObjectIds(client, arrayObjectId, resolveCount);
|
|
1269
|
+
allMaterializedObjectIds = resolved.allMaterializedObjectIds;
|
|
1270
|
+
await Promise.all(
|
|
1271
|
+
resolved.objectIds.map(async (objectId, idx) => {
|
|
1272
|
+
if (!objectId) return;
|
|
1273
|
+
backendNodeIds[idx] = await describeBackendNodeId(client, objectId);
|
|
1274
|
+
}),
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
} catch {
|
|
1278
|
+
// Best-effort — see doc comment above.
|
|
1279
|
+
} finally {
|
|
1280
|
+
const releaseIds = [arrayObjectId, ...allMaterializedObjectIds].filter((id): id is string => Boolean(id));
|
|
1281
|
+
for (const id of releaseIds) {
|
|
1282
|
+
try {
|
|
1283
|
+
await this.client.send('Runtime.releaseObject', { objectId: id });
|
|
1284
|
+
} catch {
|
|
1285
|
+
// Best-effort.
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return backendNodeIds;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Host-side sanitizer for one frame's rect sample (page-controlled DOM data read in the isolated
|
|
1294
|
+
* world) — the real guard behind the
|
|
1295
|
+
* in-page `buildSampleRectsExpression` cap (see the constants above). Caps
|
|
1296
|
+
* the element count, requires finite numeric coordinates, length-caps `tag`/`id`/`classes`,
|
|
1297
|
+
* and enforces a total serialized-byte budget for the frame; anything dropped is tallied by
|
|
1298
|
+
* reason into `rectDropCounts` (flushed as `rect-sample-dropped` summaries at stop(), same
|
|
1299
|
+
* style as the binding channel's drop tally) rather than silently discarded or trusted.
|
|
1300
|
+
* `backendNodeIds` is this frame's identity-bridge result (see `resolveRectIdentity`), aligned
|
|
1301
|
+
* by index to `raw` — `undefined` (never fabricated) becomes `backendNodeId: null,
|
|
1302
|
+
* identityUnresolved: true` on the emitted element (I-3/I-5).
|
|
1303
|
+
*/
|
|
1304
|
+
private sanitizeRectSample(raw: unknown, backendNodeIds: ReadonlyArray<number | undefined>): SampledRect[] {
|
|
1305
|
+
const items = Array.isArray(raw) ? raw : [];
|
|
1306
|
+
const elements: SampledRect[] = [];
|
|
1307
|
+
let serializedBytes = 0;
|
|
1308
|
+
for (let i = 0; i < items.length; i++) {
|
|
1309
|
+
if (elements.length >= MAX_RECT_ELEMENTS) {
|
|
1310
|
+
this.recordRectDrop('element-cap', items.length - i);
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
const item = items[i];
|
|
1314
|
+
if (!item || typeof item !== 'object') {
|
|
1315
|
+
this.recordRectDrop('invalid-shape');
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const record = item as Record<string, unknown>;
|
|
1319
|
+
const x = sanitizeFiniteNumber(record.x);
|
|
1320
|
+
const y = sanitizeFiniteNumber(record.y);
|
|
1321
|
+
const width = sanitizeFiniteNumber(record.width);
|
|
1322
|
+
const height = sanitizeFiniteNumber(record.height);
|
|
1323
|
+
if (x === undefined || y === undefined || width === undefined || height === undefined) {
|
|
1324
|
+
this.recordRectDrop('non-finite-coords');
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
const backendNodeId = backendNodeIds[i];
|
|
1328
|
+
const sanitized: SampledRect = {
|
|
1329
|
+
tag: sanitizeRectString(record.tag, MAX_RECT_TAG_LENGTH) ?? '',
|
|
1330
|
+
id: record.id === null ? null : (sanitizeRectString(record.id, MAX_RECT_STRING_LENGTH) ?? null),
|
|
1331
|
+
classes: record.classes === null ? null : (sanitizeRectString(record.classes, MAX_RECT_STRING_LENGTH) ?? null),
|
|
1332
|
+
x,
|
|
1333
|
+
y,
|
|
1334
|
+
width,
|
|
1335
|
+
height,
|
|
1336
|
+
...(backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId }),
|
|
1337
|
+
};
|
|
1338
|
+
const sizeBytes = Buffer.byteLength(JSON.stringify(sanitized), 'utf-8');
|
|
1339
|
+
if (serializedBytes + sizeBytes > MAX_RECTS_SERIALIZED_BYTES) {
|
|
1340
|
+
this.recordRectDrop('byte-budget', items.length - i);
|
|
1341
|
+
break;
|
|
1342
|
+
}
|
|
1343
|
+
serializedBytes += sizeBytes;
|
|
1344
|
+
elements.push(sanitized);
|
|
1345
|
+
}
|
|
1346
|
+
return elements;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
private recordRectDrop(reason: string, count = 1): void {
|
|
1350
|
+
this.rectDropCounts.set(reason, (this.rectDropCounts.get(reason) ?? 0) + count);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Writes one summarizing `rect-sample-dropped` event per drop reason instead of one per dropped/truncated element. */
|
|
1354
|
+
private flushRectDropSummary(): void {
|
|
1355
|
+
for (const [reason, count] of this.rectDropCounts) {
|
|
1356
|
+
this.appendEvent({ kind: 'rect-sample-dropped', reason, count });
|
|
1357
|
+
}
|
|
1358
|
+
this.rectDropCounts.clear();
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** A structural-safe implementation mark is deliberately distinct from the
|
|
1363
|
+
* verbatim action identity retained in the adjacent `action` field. */
|
|
1364
|
+
function structuralMarkLabel(action: string): string {
|
|
1365
|
+
return `mark-${crypto.createHash('sha256').update(action).digest('hex')}`;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// ---------------------------------------------------------------------------
|
|
1369
|
+
// Wire dispatch — the same "one request per connection, one response"
|
|
1370
|
+
// convention the plain bridge uses (see ./bridge/server.ts).
|
|
1371
|
+
// ---------------------------------------------------------------------------
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Session-scoped request dispatch. `har-flush` is deliberately absent from
|
|
1375
|
+
* the accepted union: the streaming HAR collector lives in `runRecorderBridge`'s
|
|
1376
|
+
* process body, so `handleLine` serves that request there and never forwards
|
|
1377
|
+
* it here.
|
|
1378
|
+
*/
|
|
1379
|
+
export async function handleRecorderRequest(
|
|
1380
|
+
session: RecorderSession,
|
|
1381
|
+
req: Exclude<RecorderRequest, RecHarFlushRequest>,
|
|
1382
|
+
): Promise<RecorderResponse> {
|
|
1383
|
+
try {
|
|
1384
|
+
switch (req.type) {
|
|
1385
|
+
case 'rec-start': {
|
|
1386
|
+
const markers = await session.start();
|
|
1387
|
+
return { reqId: req.reqId, ok: true, type: 'rec-start', markers };
|
|
1388
|
+
}
|
|
1389
|
+
case 'rec-stop': {
|
|
1390
|
+
const summary = await session.stop();
|
|
1391
|
+
return { reqId: req.reqId, ok: true, type: 'rec-stop', ...summary };
|
|
1392
|
+
}
|
|
1393
|
+
case 'cdp': {
|
|
1394
|
+
const { result, event, waitOutcome } = await session.handleCdp(req);
|
|
1395
|
+
return { reqId: req.reqId, ok: true, type: 'cdp', result, event, waitOutcome };
|
|
1396
|
+
}
|
|
1397
|
+
default: {
|
|
1398
|
+
const exhaustive: never = req;
|
|
1399
|
+
throw new Error(`Unknown recorder request: ${JSON.stringify(exhaustive)}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
return {
|
|
1404
|
+
reqId: req.reqId,
|
|
1405
|
+
ok: false,
|
|
1406
|
+
type: req.type,
|
|
1407
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// ---------------------------------------------------------------------------
|
|
1413
|
+
// Process entrypoint — spawned detached by `./bridge/spawn.ts`'s
|
|
1414
|
+
// `startRecorderBridge()`, invoked via `capture __bridge-serve ... recorder
|
|
1415
|
+
// <recDir>` (see ./commands/bridge-serve.ts).
|
|
1416
|
+
// ---------------------------------------------------------------------------
|
|
1417
|
+
|
|
1418
|
+
export interface RunRecorderBridgeOptions {
|
|
1419
|
+
socketPath: string;
|
|
1420
|
+
targetId: string;
|
|
1421
|
+
recDir: string;
|
|
1422
|
+
/** The owning session's live HAR recording id — the streaming `HARRecorder`
|
|
1423
|
+
* installed on the held tab connection appends its evidence there (via
|
|
1424
|
+
* `har-manager.ts`'s `appendToHarRecording`; each batch is one `O_APPEND`
|
|
1425
|
+
* no-follow write of one NDJSON record, so cross-process writers interleave
|
|
1426
|
+
* whole records without a lock — nothing else is needed here). */
|
|
1427
|
+
harId: string;
|
|
1428
|
+
port?: number;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Filename of the nonce boot file the recorder writes into `recDir` BEFORE
|
|
1432
|
+
* binding its control socket — the private back-channel that hands the
|
|
1433
|
+
* server-generated admission nonce to the starter. The starter reads,
|
|
1434
|
+
* validates, persists (into `recorder.json`), and DELETES it; `recorder.json`
|
|
1435
|
+
* is the one persistent authority for the nonce afterward. */
|
|
1436
|
+
export const RECORDER_NONCE_BOOT_FILE = 'recorder-nonce.json';
|
|
1437
|
+
|
|
1438
|
+
/** Constant-time nonce comparison (same pattern as `session/log-tailer.ts`'s
|
|
1439
|
+
* gate): length check + `crypto.timingSafeEqual`, so a probing client learns
|
|
1440
|
+
* nothing about the expected token from response timing. */
|
|
1441
|
+
function nonceMatches(expected: string, candidate: unknown): boolean {
|
|
1442
|
+
if (typeof candidate !== 'string') return false;
|
|
1443
|
+
const left = Buffer.from(expected);
|
|
1444
|
+
const right = Buffer.from(candidate);
|
|
1445
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
interface RecorderBridgeDeps {
|
|
1449
|
+
createClient: (webSocketDebuggerUrl: string) => CDPClient;
|
|
1450
|
+
findTab: typeof findTabByIdAcrossEndpoints;
|
|
1451
|
+
/** Process-exit seam — injectable so in-proc tests of the authenticated
|
|
1452
|
+
* `rec-stop` self-exit path don't kill the test runner. */
|
|
1453
|
+
exit: (code: number) => void;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const defaultRecorderBridgeDeps: RecorderBridgeDeps = {
|
|
1457
|
+
createClient: (url) => new CDPClient(url),
|
|
1458
|
+
findTab: findTabByIdAcrossEndpoints,
|
|
1459
|
+
exit: (code) => process.exit(code),
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
let recorderBridgeDeps = defaultRecorderBridgeDeps;
|
|
1463
|
+
|
|
1464
|
+
/** Focused tests inject the CDP/tab/exit boundary; production always uses the
|
|
1465
|
+
* real client, resolver, and `process.exit`. Returns a restore function. */
|
|
1466
|
+
export function __setRecorderBridgeDepsForTest(overrides: Partial<RecorderBridgeDeps>): () => void {
|
|
1467
|
+
const previous = recorderBridgeDeps;
|
|
1468
|
+
recorderBridgeDeps = { ...recorderBridgeDeps, ...overrides };
|
|
1469
|
+
return () => { recorderBridgeDeps = previous; };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Joins the terminal rec-stop's two possible failures into the one wire
|
|
1474
|
+
* error message the caller sees. A drain-only failure surfaces the exact
|
|
1475
|
+
* drain error unchanged; when `RecorderSession.stop()` ALSO failed, its
|
|
1476
|
+
* failure is never replaced wholesale — both messages are joined so both
|
|
1477
|
+
* stay distinguishable. Module-level (not a bridge closure) so the focused
|
|
1478
|
+
* settle-join regression test exercises exactly the production join.
|
|
1479
|
+
*/
|
|
1480
|
+
export function joinTerminalStopFailure(stopResp: RecorderResponse, drainError: unknown): string {
|
|
1481
|
+
const drainMessage = drainError instanceof Error ? drainError.message : String(drainError);
|
|
1482
|
+
if (stopResp.ok) return drainMessage;
|
|
1483
|
+
return `rec-stop teardown failed: ${stopResp.error}; HAR drain failed: ${drainMessage}`;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
export async function runRecorderBridge(opts: RunRecorderBridgeOptions): Promise<void> {
|
|
1487
|
+
const port = opts.port ?? (await resolvePort());
|
|
1488
|
+
const resolved = await recorderBridgeDeps.findTab(opts.targetId, port);
|
|
1489
|
+
if (!resolved?.tab.webSocketDebuggerUrl) {
|
|
1490
|
+
throw new Error(`No tab found for target "${opts.targetId}" on port ${port}.`);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const client = recorderBridgeDeps.createClient(resolved.tab.webSocketDebuggerUrl);
|
|
1494
|
+
await client.waitReady();
|
|
1495
|
+
|
|
1496
|
+
// HAR evidence streams from the moment the held connection is ready —
|
|
1497
|
+
// BEFORE the control socket exists, so network activity between recorder
|
|
1498
|
+
// boot and the caller's `rec-start` is already captured. Exactly-once
|
|
1499
|
+
// delivery is the HARRecorder's own contract; cross-process file safety is
|
|
1500
|
+
// har-manager's append-only store (one O_APPEND write per batch, no lock).
|
|
1501
|
+
const har = new HARRecorder(client, (batch) => appendToHarRecording(opts.harId, batch));
|
|
1502
|
+
await har.start();
|
|
1503
|
+
|
|
1504
|
+
const session = new RecorderSession({ client, recDir: opts.recDir });
|
|
1505
|
+
|
|
1506
|
+
// Server-generated control-socket admission token. Written to the private
|
|
1507
|
+
// boot file BEFORE the socket is bound: spawn.ts's socket-file readiness
|
|
1508
|
+
// poll therefore guarantees the starter can read it without racing us.
|
|
1509
|
+
const nonce = crypto.randomBytes(32).toString('hex');
|
|
1510
|
+
writeJsonPrivate(path.join(opts.recDir, RECORDER_NONCE_BOOT_FILE), { nonce });
|
|
1511
|
+
|
|
1512
|
+
let server: net.Server | null = null;
|
|
1513
|
+
const cleanup = (): void => {
|
|
1514
|
+
if (server) closeNdjsonSocket(server, opts.socketPath);
|
|
1515
|
+
try {
|
|
1516
|
+
client.close();
|
|
1517
|
+
} catch {
|
|
1518
|
+
// Already closed.
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
// One-owner terminal-rec-stop latch (U11c). `listenNdjsonSocket` fire-and-
|
|
1523
|
+
// forgets every line (`void handleLine(...)`) and does not serialize them,
|
|
1524
|
+
// so more than one authenticated `rec-stop` line can reach `handleLine`
|
|
1525
|
+
// before the first has finished. This flag is read and set in the SAME
|
|
1526
|
+
// synchronous prefix as the nonce gate and `session.state` check below (no
|
|
1527
|
+
// `await` between the read and the set), so exactly one authenticated,
|
|
1528
|
+
// stoppable-state `rec-stop` ever claims the HAR admission cut, drain
|
|
1529
|
+
// settlement, response, cleanup, and exit. Every other rec-stop line (a
|
|
1530
|
+
// repeat, a concurrent loser, or one that arrives while the session is
|
|
1531
|
+
// already `idle`/`stopping`) falls through to the ordinary
|
|
1532
|
+
// `handleRecorderRequest` path below, which resolves it through
|
|
1533
|
+
// `RecorderSession.stop()`'s own state guard into the existing
|
|
1534
|
+
// deterministic `ok:false` rejection — never a second drain/exit.
|
|
1535
|
+
let terminalStopClaimed = false;
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Settles the one authenticated, stoppable-state `rec-stop` that claimed
|
|
1539
|
+
* the terminal latch. `RecorderSession.stop()` (screencast/tracing/observer
|
|
1540
|
+
* teardown, best-effort and independent of the HAR store) runs to
|
|
1541
|
+
* completion first; only then is the already-initiated `drainSettlement`
|
|
1542
|
+
* awaited, so the response is withheld until every pre-cut HAR body fetch,
|
|
1543
|
+
* the exactly-once frozen-lifecycle finalization, and every U02 append have
|
|
1544
|
+
* settled (U11c's success ordering). A latched fatal HAR drain (sink/store
|
|
1545
|
+
* rejection, or any unexpected assembly/validation failure) is terminal and
|
|
1546
|
+
* authoritative: it overrides even a successful `RecorderSession.stop()`
|
|
1547
|
+
* with an explicit `ok:false` response carrying the exact primary error —
|
|
1548
|
+
* the recorder never claims success once its evidence store is fatally
|
|
1549
|
+
* broken, and the HAR store itself is never reset — and when the stop
|
|
1550
|
+
* teardown ALSO failed, the drain error never replaces it wholesale: both
|
|
1551
|
+
* messages are joined (`joinTerminalStopFailure`) so both failures stay
|
|
1552
|
+
* distinguishable in the one response. Response is flushed
|
|
1553
|
+
* (write completion callback) before the socket ends, cleanup runs, and the
|
|
1554
|
+
* injected exit seam fires non-zero on fatal / zero on success.
|
|
1555
|
+
*/
|
|
1556
|
+
async function settleTerminalRecStop(req: RecStopRequest, socket: net.Socket, drainSettlement: Promise<void>): Promise<void> {
|
|
1557
|
+
const stopResp = await handleRecorderRequest(session, req);
|
|
1558
|
+
let finalResp: RecorderResponse = stopResp;
|
|
1559
|
+
try {
|
|
1560
|
+
await drainSettlement;
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
finalResp = {
|
|
1563
|
+
reqId: req.reqId,
|
|
1564
|
+
ok: false,
|
|
1565
|
+
type: 'rec-stop',
|
|
1566
|
+
error: joinTerminalStopFailure(stopResp, err),
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
await new Promise<void>((resolveWrite) => {
|
|
1570
|
+
socket.write(JSON.stringify(finalResp) + '\n', () => resolveWrite());
|
|
1571
|
+
});
|
|
1572
|
+
socket.end();
|
|
1573
|
+
cleanup();
|
|
1574
|
+
recorderBridgeDeps.exit(finalResp.ok ? 0 : 1);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async function handleLine(line: string, socket: net.Socket): Promise<void> {
|
|
1578
|
+
let req: RecorderRequest;
|
|
1579
|
+
try {
|
|
1580
|
+
req = JSON.parse(line);
|
|
1581
|
+
} catch {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// A syntactically valid JSON line can still parse to a non-object
|
|
1585
|
+
// (`null`, a number, a string, a boolean, an array) — `JSON.parse` alone
|
|
1586
|
+
// does not guarantee `RecorderRequest` shape. Drop those the same way as
|
|
1587
|
+
// a parse failure: any property read below (`raw.nonce`, `raw.reqId`,
|
|
1588
|
+
// `raw.type`) on `null`/a primitive would throw inside this async
|
|
1589
|
+
// handler, and `listenNdjsonSocket` invokes handlers as a fire-and-forget
|
|
1590
|
+
// `void handleLine(...)` (src/cdp/bridge/server.ts), so an uncaught throw
|
|
1591
|
+
// here becomes an unhandled rejection that kills the detached bridge
|
|
1592
|
+
// process and orphans the live recording.
|
|
1593
|
+
if (req === null || typeof req !== 'object' || Array.isArray(req)) return;
|
|
1594
|
+
// Admission gate — constant-time nonce check on EVERY request, before any
|
|
1595
|
+
// handler runs. No request type is exempt and there is no compat lane: a
|
|
1596
|
+
// missing/mismatched nonce is answered `unauthorized` with zero side
|
|
1597
|
+
// effects (reqId/type coerced defensively — the line is untrusted input).
|
|
1598
|
+
const raw = req as unknown as Record<string, unknown>;
|
|
1599
|
+
if (!nonceMatches(nonce, raw.nonce)) {
|
|
1600
|
+
const reqId = typeof raw.reqId === 'number' ? (raw.reqId as number) : 0;
|
|
1601
|
+
const type: RecorderRequest['type'] =
|
|
1602
|
+
raw.type === 'rec-start' || raw.type === 'rec-stop' || raw.type === 'cdp' || raw.type === 'har-flush'
|
|
1603
|
+
? raw.type
|
|
1604
|
+
: 'cdp';
|
|
1605
|
+
socket.write(JSON.stringify({ reqId, ok: false, type, error: 'unauthorized' }) + '\n');
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
// U12 har-flush health barrier — served here, in the wire lane, because
|
|
1609
|
+
// the streaming HAR collector (`har`) is owned by this process body, not
|
|
1610
|
+
// by RecorderSession. Non-attributing: `har.flush()` waits for every body
|
|
1611
|
+
// fetch in flight and every sink append queued at call time, then throws
|
|
1612
|
+
// the latched fatal store error if any of that work failed — so `ok:true`
|
|
1613
|
+
// is a health/completion fact, never an action entry count. It creates no
|
|
1614
|
+
// collector and takes no ownership: `har` stays the one collector for the
|
|
1615
|
+
// bridge's whole life regardless of how many flush sockets open and close.
|
|
1616
|
+
// A latched fatal answers `ok:false` with the exact primary error but does
|
|
1617
|
+
// NOT tear the bridge down — terminal authority (drain, final response,
|
|
1618
|
+
// cleanup, exit) belongs exclusively to the rec-stop latch below, and once
|
|
1619
|
+
// that latch is claimed a later flush gets a deterministic rejection
|
|
1620
|
+
// rather than sharing or re-driving the drain.
|
|
1621
|
+
if (req.type === 'har-flush') {
|
|
1622
|
+
let resp: RecorderResponse;
|
|
1623
|
+
if (terminalStopClaimed) {
|
|
1624
|
+
resp = {
|
|
1625
|
+
reqId: req.reqId,
|
|
1626
|
+
ok: false,
|
|
1627
|
+
type: 'har-flush',
|
|
1628
|
+
error: 'recorder is stopping — HAR finalization is owned by the terminal rec-stop',
|
|
1629
|
+
};
|
|
1630
|
+
} else {
|
|
1631
|
+
try {
|
|
1632
|
+
await har.flush();
|
|
1633
|
+
resp = { reqId: req.reqId, ok: true, type: 'har-flush' };
|
|
1634
|
+
} catch (err) {
|
|
1635
|
+
resp = {
|
|
1636
|
+
reqId: req.reqId,
|
|
1637
|
+
ok: false,
|
|
1638
|
+
type: 'har-flush',
|
|
1639
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
socket.write(JSON.stringify(resp) + '\n');
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
// Terminal admission cut (U11c). `har.drain()`'s own first synchronous
|
|
1647
|
+
// line closes Network-event admission (see har-recorder.ts's `drain()`),
|
|
1648
|
+
// so initiating it HERE — after the nonce gate and `rec-stop` recognition,
|
|
1649
|
+
// synchronously before `handleRecorderRequest`'s first internal await —
|
|
1650
|
+
// guarantees no Network event arriving after this authenticated rec-stop
|
|
1651
|
+
// can allocate, mutate, or append. The cut only fires for a rec-stop that
|
|
1652
|
+
// will actually proceed (`session.state === 'recording'`) and only for the
|
|
1653
|
+
// first such request (`terminalStopClaimed`); an idle/already-stopping
|
|
1654
|
+
// rec-stop and a losing concurrent/repeat rec-stop take zero side effects
|
|
1655
|
+
// here and fall through to the ordinary dispatch below. A rejection
|
|
1656
|
+
// handler is attached to `drainSettlement` in this same synchronous frame
|
|
1657
|
+
// so a fatal drain can never surface as an unhandled rejection through
|
|
1658
|
+
// `listenNdjsonSocket`'s fire-and-forget `void handleLine(...)`,
|
|
1659
|
+
// regardless of when the awaited settlement inside
|
|
1660
|
+
// `settleTerminalRecStop` actually runs.
|
|
1661
|
+
if (req.type === 'rec-stop' && session.state === 'recording' && !terminalStopClaimed) {
|
|
1662
|
+
terminalStopClaimed = true;
|
|
1663
|
+
const drainSettlement = har.drain();
|
|
1664
|
+
drainSettlement.catch(() => {
|
|
1665
|
+
// Handled below via settleTerminalRecStop's own await; this no-op
|
|
1666
|
+
// catch only prevents Node from ever treating this promise as an
|
|
1667
|
+
// unhandled rejection.
|
|
1668
|
+
});
|
|
1669
|
+
await settleTerminalRecStop(req, socket, drainSettlement);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const resp = await handleRecorderRequest(session, req);
|
|
1674
|
+
socket.write(JSON.stringify(resp) + '\n');
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
server = await listenNdjsonSocket(opts.socketPath, handleLine);
|
|
1678
|
+
installProcessCleanup(cleanup, client);
|
|
1679
|
+
// Intentionally does not resolve further: the open server and the live tab
|
|
1680
|
+
// websocket keep this detached process alive until an authenticated
|
|
1681
|
+
// `rec-stop` completes (the self-exit above) — the caller waits for that
|
|
1682
|
+
// verified exit and only escalates to an identity-checked SIGTERM
|
|
1683
|
+
// (`stopBridge()`) on timeout.
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async function resolvePort(): Promise<number> {
|
|
1687
|
+
const { detectCdpPort } = await import('./detect.js');
|
|
1688
|
+
return detectCdpPort();
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// ---------------------------------------------------------------------------
|
|
1692
|
+
// Injected in-page instrumentation
|
|
1693
|
+
// ---------------------------------------------------------------------------
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Builds the IIFE injected at `rec-start` (and re-injected best-effort after every main-frame
|
|
1697
|
+
* navigation, see `RecorderSession.handleFrameNavigated`) via `Runtime.evaluate`. Sets up
|
|
1698
|
+
* MutationObserver/ResizeObserver/PerformanceObserver on the document, each emitting
|
|
1699
|
+
* NDJSON-ready records to the host over the CDP binding (`Runtime.addBinding` +
|
|
1700
|
+
* `Runtime.bindingCalled`) — the standard CDP page-to-host channel, not a new IPC mechanism.
|
|
1701
|
+
* `window['__captureRecorder_' + NONCE]` exposes `teardown()`, called at `rec-stop` to disconnect
|
|
1702
|
+
* the observers, drop both identity-bridge queues, and delete the nonce-scoped global (releasing its
|
|
1703
|
+
* retained DOM references) from the isolated world. The global's property name is scoped by
|
|
1704
|
+
* this recording's 128-bit `nonce` precisely so a page cannot predefine it: a page cannot preseed
|
|
1705
|
+
* or trap the installation guard (`if (window[KEY]) return;`) without first predicting an
|
|
1706
|
+
* unguessable per-recording token, and a second recording (a new nonce) on the same
|
|
1707
|
+
* never-navigated world always installs fresh instead of seeing a leftover global from a prior
|
|
1708
|
+
* nonce. Every emitted record ALSO carries `nonce` in its payload — the same token, closed over
|
|
1709
|
+
* — which the host (`RecorderSession.handleBindingCalled`) requires on every binding-channel
|
|
1710
|
+
* payload before trusting it; that is a separate check from the global's key and stops a hostile
|
|
1711
|
+
* page from forging binding-channel events by calling `window[BINDING]` directly.
|
|
1712
|
+
*/
|
|
1713
|
+
function buildObserverScript(bindingName: string, nonce: string): string {
|
|
1714
|
+
return `(function() {
|
|
1715
|
+
var BINDING = ${JSON.stringify(bindingName)};
|
|
1716
|
+
var NONCE = ${JSON.stringify(nonce)};
|
|
1717
|
+
var KEY = '__captureRecorder_' + NONCE;
|
|
1718
|
+
if (window[KEY]) return ${JSON.stringify(OBSERVER_INSTALLED_SENTINEL)};
|
|
1719
|
+
var resizeSeq = 0;
|
|
1720
|
+
var resizeQueue = {};
|
|
1721
|
+
var rectQueue = {};
|
|
1722
|
+
function pruneQueue(q, maxKeys) {
|
|
1723
|
+
var keys = Object.keys(q);
|
|
1724
|
+
if (keys.length <= maxKeys) return;
|
|
1725
|
+
keys.sort(function(a, b) { return Number(a) - Number(b); });
|
|
1726
|
+
for (var i = 0; i < keys.length - maxKeys; i++) delete q[keys[i]];
|
|
1727
|
+
}
|
|
1728
|
+
function emit(kind, payload) {
|
|
1729
|
+
try {
|
|
1730
|
+
var record = Object.assign({}, payload, { kind: kind, performanceNowMs: performance.now(), nonce: NONCE });
|
|
1731
|
+
window[BINDING](JSON.stringify(record));
|
|
1732
|
+
} catch (e) {}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
var mutationObserver = new MutationObserver(function(records) {
|
|
1736
|
+
emit('mutation', {
|
|
1737
|
+
count: records.length,
|
|
1738
|
+
types: records.map(function(r) { return r.type; }),
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
mutationObserver.observe(document.documentElement, {
|
|
1742
|
+
childList: true, subtree: true, attributes: true, characterData: true,
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
var resizeObserver = new ResizeObserver(function(entries) {
|
|
1746
|
+
var seq = ++resizeSeq;
|
|
1747
|
+
resizeQueue[seq] = entries.map(function(e) { return e.target; });
|
|
1748
|
+
pruneQueue(resizeQueue, 40);
|
|
1749
|
+
emit('resize', {
|
|
1750
|
+
seq: seq,
|
|
1751
|
+
count: entries.length,
|
|
1752
|
+
targets: entries.map(function(e) {
|
|
1753
|
+
var rect = e.contentRect;
|
|
1754
|
+
return { tag: e.target && e.target.tagName, width: rect.width, height: rect.height };
|
|
1755
|
+
}),
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
resizeObserver.observe(document.documentElement);
|
|
1759
|
+
|
|
1760
|
+
var perfObservers = [];
|
|
1761
|
+
['longtask', 'layout-shift', 'paint', 'mark', 'measure'].forEach(function(type) {
|
|
1762
|
+
try {
|
|
1763
|
+
if (!window.PerformanceObserver || !PerformanceObserver.supportedEntryTypes ||
|
|
1764
|
+
PerformanceObserver.supportedEntryTypes.indexOf(type) === -1) return;
|
|
1765
|
+
var po = new PerformanceObserver(function(list) {
|
|
1766
|
+
list.getEntries().forEach(function(entry) {
|
|
1767
|
+
emit('performance', {
|
|
1768
|
+
entryType: entry.entryType,
|
|
1769
|
+
name: entry.name,
|
|
1770
|
+
startTime: entry.startTime,
|
|
1771
|
+
duration: entry.duration,
|
|
1772
|
+
value: entry.entryType === 'layout-shift' ? entry.value : undefined,
|
|
1773
|
+
hadRecentInput: entry.entryType === 'layout-shift' ? entry.hadRecentInput : undefined,
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
});
|
|
1777
|
+
po.observe({ type: type, buffered: true });
|
|
1778
|
+
perfObservers.push(po);
|
|
1779
|
+
} catch (e) {}
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
window[KEY] = {
|
|
1783
|
+
teardown: function() {
|
|
1784
|
+
try { mutationObserver.disconnect(); } catch (e) {}
|
|
1785
|
+
try { resizeObserver.disconnect(); } catch (e) {}
|
|
1786
|
+
perfObservers.forEach(function(po) {
|
|
1787
|
+
try { po.disconnect(); } catch (e) {}
|
|
1788
|
+
});
|
|
1789
|
+
resizeQueue = {};
|
|
1790
|
+
rectQueue = {};
|
|
1791
|
+
try { delete window[KEY]; } catch (e) {}
|
|
1792
|
+
},
|
|
1793
|
+
takeResizeTargets: function(seq) {
|
|
1794
|
+
var t = resizeQueue[seq];
|
|
1795
|
+
delete resizeQueue[seq];
|
|
1796
|
+
return t || [];
|
|
1797
|
+
},
|
|
1798
|
+
stashRectElements: function(frameIndex, els) {
|
|
1799
|
+
rectQueue[frameIndex] = els;
|
|
1800
|
+
pruneQueue(rectQueue, 10);
|
|
1801
|
+
},
|
|
1802
|
+
takeRectElements: function(frameIndex) {
|
|
1803
|
+
var e = rectQueue[frameIndex];
|
|
1804
|
+
delete rectQueue[frameIndex];
|
|
1805
|
+
return e || [];
|
|
1806
|
+
},
|
|
1807
|
+
};
|
|
1808
|
+
return ${JSON.stringify(OBSERVER_INSTALLED_SENTINEL)};
|
|
1809
|
+
})()`;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* Single round-trip element-rect sample, evaluated once per screencast frame. Uses
|
|
1814
|
+
* `getBoundingClientRect()` over every element (capped) rather than one CDP `DOM.getBoxModel`
|
|
1815
|
+
* round trip per element — the cheap approximation this mechanism unit ships with; quad-accurate
|
|
1816
|
+
* geometry (transforms, clipping, frame/shadow stitching) is the `measure snap` substrate's job
|
|
1817
|
+
* (`geometry.json`), not the motion recorder's. Also stashes the live element handles it walked
|
|
1818
|
+
* into this recording's nonce-scoped queue (`window[KEY].stashRectElements`), keyed by
|
|
1819
|
+
* `frameIndex`, so a SEPARATE follow-up evaluate (`buildTakeRectElementsExpression`) can drain
|
|
1820
|
+
* them as held remote objects and bridge each to a `backendNodeId`. The stashed `els` array stops
|
|
1821
|
+
* growing once it reaches `identityCap` (mirrors `MAX_RECT_IDENTITY_RESOLUTIONS`) while `out`
|
|
1822
|
+
* keeps collecting up to `max` — both pushes happen in the same loop iteration while under the
|
|
1823
|
+
* cap, so `els[k]`/`out[k]` stay index-aligned for every `k` the identity bridge ever resolves;
|
|
1824
|
+
* this bounds the real CDP round-trip cost of the follow-up `Runtime.getProperties` walk to the
|
|
1825
|
+
* cap, rather than relying on the host-side release loop alone to bound it.
|
|
1826
|
+
*/
|
|
1827
|
+
function buildSampleRectsExpression(nonce: string, frameIndex: number): string {
|
|
1828
|
+
return `(function() {
|
|
1829
|
+
var out = [];
|
|
1830
|
+
var els = [];
|
|
1831
|
+
var all = document.querySelectorAll('*');
|
|
1832
|
+
var max = 2000;
|
|
1833
|
+
var identityCap = ${JSON.stringify(MAX_RECT_IDENTITY_RESOLUTIONS)};
|
|
1834
|
+
for (var i = 0; i < all.length && out.length < max; i++) {
|
|
1835
|
+
var el = all[i];
|
|
1836
|
+
var r = el.getBoundingClientRect();
|
|
1837
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
1838
|
+
out.push({
|
|
1839
|
+
tag: el.tagName.toLowerCase(),
|
|
1840
|
+
id: el.id || null,
|
|
1841
|
+
classes: typeof el.className === 'string' ? el.className : null,
|
|
1842
|
+
x: r.x, y: r.y, width: r.width, height: r.height,
|
|
1843
|
+
});
|
|
1844
|
+
if (els.length < identityCap) els.push(el);
|
|
1845
|
+
}
|
|
1846
|
+
var k = '__captureRecorder_' + ${JSON.stringify(nonce)};
|
|
1847
|
+
var host = window[k];
|
|
1848
|
+
if (host && host.stashRectElements) host.stashRectElements(${JSON.stringify(frameIndex)}, els);
|
|
1849
|
+
var viewport = window.visualViewport;
|
|
1850
|
+
return {
|
|
1851
|
+
elements: out,
|
|
1852
|
+
viewport: {
|
|
1853
|
+
width: viewport ? viewport.width : window.innerWidth,
|
|
1854
|
+
height: viewport ? viewport.height : window.innerHeight,
|
|
1855
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
1856
|
+
},
|
|
1857
|
+
};
|
|
1858
|
+
})()`;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function framePngDimensions(base64: string): { width: number; height: number } | null {
|
|
1862
|
+
try {
|
|
1863
|
+
const header = Buffer.from(base64, 'base64').subarray(0, 24);
|
|
1864
|
+
if (header.length < 24 || header.toString('ascii', 12, 16) !== 'IHDR') return null;
|
|
1865
|
+
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
|
|
1866
|
+
} catch {
|
|
1867
|
+
return null;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function cssToDeviceTransform(viewport: unknown, raster: { width: number; height: number } | null): FrameCssToDeviceTransform | null {
|
|
1872
|
+
if (!viewport || typeof viewport !== 'object' || !raster) return null;
|
|
1873
|
+
const record = viewport as Record<string, unknown>;
|
|
1874
|
+
const width = record.width;
|
|
1875
|
+
const height = record.height;
|
|
1876
|
+
const devicePixelRatio = record.devicePixelRatio;
|
|
1877
|
+
if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0
|
|
1878
|
+
|| typeof height !== 'number' || !Number.isFinite(height) || height <= 0
|
|
1879
|
+
|| typeof devicePixelRatio !== 'number' || !Number.isFinite(devicePixelRatio) || devicePixelRatio <= 0) return null;
|
|
1880
|
+
return { scaleX: raster.width / width, scaleY: raster.height / height, devicePixelRatio };
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Drains this frame's rect-sampler element queue (stashed by `buildSampleRectsExpression`) as a
|
|
1884
|
+
* held remote array — `RecorderSession.resolveRectIdentity`'s bridge follow-up. */
|
|
1885
|
+
function buildTakeRectElementsExpression(nonce: string, frameIndex: number): string {
|
|
1886
|
+
return `(function(){var k='__captureRecorder_'+${JSON.stringify(nonce)};var h=window[k];return h&&h.takeRectElements?h.takeRectElements(${JSON.stringify(frameIndex)}):[];})()`;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** Drains a resize-observer callback batch's target-element queue (stashed by `buildObserverScript`'s
|
|
1890
|
+
* `ResizeObserver` callback, keyed by its own page-assigned `seq`) as a held remote array —
|
|
1891
|
+
* `RecorderSession.resolveAndAppendResizeIdentity`'s bridge follow-up. */
|
|
1892
|
+
function buildTakeResizeTargetsExpression(nonce: string, seq: number): string {
|
|
1893
|
+
return `(function(){var k='__captureRecorder_'+${JSON.stringify(nonce)};var h=window[k];return h&&h.takeResizeTargets?h.takeResizeTargets(${JSON.stringify(seq)}):[];})()`;
|
|
1894
|
+
}
|