@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.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. 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
+ }