@crouton-kit/capture 1.3.8 → 1.3.10

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -19,6 +19,13 @@
19
19
  * - `rec-stop` — stops screencast + tracing, flushes/tears down the
20
20
  * injected observers, and returns frame/event counts + duration for the
21
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`.
22
29
  *
23
30
  * Frames land as PNGs under `{recDir}/frames/`; per-frame element rects
24
31
  * append to `{recDir}/rects.jsonl`; trace batches, observer entries, input
@@ -26,17 +33,55 @@
26
33
  * writes go through the secure artifact helpers (`../session/artifacts.js`,
27
34
  * U03), never ad-hoc `fs.writeFile`.
28
35
  *
29
- * Known limitation: the injected observer script and `Runtime.addBinding`
30
- * are only armed once, at `rec-start`. A `navigate` mid-recording wipes the
31
- * page's JS world (and therefore the observers) same as any full
32
- * navigation would; re-arming after `Page.frameNavigated` is not built
33
- * here. `Page.startScreencast`/`Tracing` continue across navigation (they
34
- * are CDP-session-scoped, not page-scoped), so frames/trace events are
35
- * unaffected — only the Mutation/Resize/PerformanceObserver stream would
36
- * gap across a navigate. Flagged for whichever unit drives `motion rec`
37
- * across a `navigate` command.
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.
38
82
  */
39
83
 
84
+ import * as crypto from 'crypto';
40
85
  import * as net from 'net';
41
86
  import * as path from 'path';
42
87
  import { CDPClient } from './client.js';
@@ -54,14 +99,37 @@ import {
54
99
  type RecorderResponse,
55
100
  type RecorderClockBaselines,
56
101
  type RecCdpRequest,
102
+ type RecStopRequest,
103
+ type RecHarFlushRequest,
57
104
  } from './bridge/protocol.js';
58
- import { ensurePrivateDir, appendNdjsonPrivate, writeBinaryPrivate } from '../session/artifacts.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
+ }
59
126
 
60
127
  // ---------------------------------------------------------------------------
61
128
  // Artifact record shapes
62
129
  // ---------------------------------------------------------------------------
63
130
 
64
131
  export interface SampledRect {
132
+ /** Descriptive label only — the cross-artifact join key is {@link backendNodeId}. */
65
133
  tag: string;
66
134
  id: string | null;
67
135
  classes: string | null;
@@ -69,14 +137,47 @@ export interface SampledRect {
69
137
  y: number;
70
138
  width: number;
71
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;
72
166
  }
73
167
 
74
- /** One line of `rects.jsonl` — element quads sampled at one screencast frame. */
75
168
  export interface FrameRectsRecord {
76
169
  frame: number;
77
170
  file: string;
171
+ /** Per-frame CSS-to-device transform used by motion-mask DOM joins. */
172
+ cssToDevice: FrameCssToDeviceTransform | null;
78
173
  /** `Page.screencastFrame`'s own `metadata.timestamp` (wall-clock seconds), raw — not baseline-converted. */
79
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';
80
181
  recordedAtWallClockMs: number;
81
182
  elements: SampledRect[];
82
183
  }
@@ -101,7 +202,17 @@ export interface RecorderEventRecord {
101
202
 
102
203
  export type RecorderCdpClient = Pick<CDPClient, 'send' | 'on' | 'onDisconnect' | 'close'>;
103
204
 
104
- export type RecorderState = 'idle' | 'recording' | 'stopped';
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';
105
216
 
106
217
  export interface RecorderSessionOptions {
107
218
  client: RecorderCdpClient;
@@ -113,10 +224,20 @@ export interface RecorderStopSummary {
113
224
  frameCount: number;
114
225
  eventCount: number;
115
226
  durationMs: number;
227
+ /** The clock baselines re-read at stop time — the flush path (see `RecorderClockBaselines`). */
228
+ markers: RecorderClockBaselines;
116
229
  }
117
230
 
118
231
  const RECORDER_BINDING_NAME = 'captureRecorderEmit';
119
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
+
120
241
  const TRACE_CATEGORIES =
121
242
  'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,loading,blink.user_timing';
122
243
 
@@ -126,6 +247,162 @@ interface ScreencastFrameParams {
126
247
  sessionId?: number;
127
248
  }
128
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
+
129
406
  /**
130
407
  * `domains.ts`/`timing.ts` declare their parameter as the concrete
131
408
  * `CDPClient` class (private-field members, so TS won't structurally
@@ -150,6 +427,59 @@ export class RecorderSession {
150
427
  private frameCount = 0;
151
428
  private eventCount = 0;
152
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;
153
483
 
154
484
  constructor(opts: RecorderSessionOptions) {
155
485
  this.client = opts.client;
@@ -164,80 +494,231 @@ export class RecorderSession {
164
494
  if (this.state !== 'idle') {
165
495
  throw new Error(`recorder cannot start from state "${this.state}"`);
166
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';
167
501
 
168
502
  await enableDomainsForMotionRec(asCDPClient(this.client));
169
503
 
170
504
  this.client.on('Page.screencastFrame', (params) => {
171
- void this.handleScreencastFrame(params as ScreencastFrameParams);
505
+ this.onScreencastFrameEvent(params as ScreencastFrameParams);
172
506
  });
173
507
  this.client.on('Tracing.dataCollected', (params) => {
174
508
  this.handleTraceData(params as { value: unknown[] });
175
509
  });
176
510
  this.client.on('Runtime.bindingCalled', (params) => {
177
- this.handleBindingCalled(params as { name: string; payload: string });
511
+ this.handleBindingCalled(params as { name: string; payload: string; executionContextId?: number });
178
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);
179
523
 
180
- await this.client.send('Runtime.addBinding', { name: RECORDER_BINDING_NAME });
181
- await this.client.send('Runtime.evaluate', { expression: buildObserverScript(RECORDER_BINDING_NAME) });
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
+ };
182
538
 
183
539
  await this.client.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });
184
540
  await this.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
185
541
 
186
- const baseline = await readTraceClockBaseline(asCDPClient(this.client));
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
+
187
559
  this.startedAtWallClockMs = Date.now();
188
560
  this.state = 'recording';
189
- return baseline;
561
+ return { ...this.baselines };
190
562
  }
191
563
 
192
- /** Handles a `type: 'cdp'` request — plain passthrough, or bracketed + logged when `mark` is set. */
193
- async handleCdp(req: RecCdpRequest): Promise<{ result?: unknown; event?: unknown }> {
194
- const eventPromise = req.waitEvent ? this.events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
195
-
196
- if (req.mark) {
197
- const bracket = await withDocumentPerformanceNow(asCDPClient(this.client), () =>
198
- this.client.send(req.method, req.params ?? {}, req.timeoutMs ?? 60000),
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.',
199
607
  );
200
- this.appendEvent({
201
- kind: 'input',
202
- mark: req.mark,
203
- method: req.method,
204
- startPerformanceNow: bracket.startPerformanceNow,
205
- endPerformanceNow: bracket.endPerformanceNow,
206
- });
207
- const event = eventPromise ? await eventPromise : undefined;
208
- return { result: bracket.result, event };
209
608
  }
210
609
 
211
- const result = await this.client.send(req.method, req.params ?? {}, req.timeoutMs ?? 60000);
212
- const event = eventPromise ? await eventPromise : undefined;
213
- return { result, event };
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
+ }
214
666
  }
215
667
 
216
668
  async stop(): Promise<RecorderStopSummary> {
217
669
  if (this.state !== 'recording') {
218
670
  throw new Error(`cannot stop recorder in state "${this.state}"`);
219
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);
220
682
 
221
683
  try {
222
684
  await this.client.send('Page.stopScreencast');
223
685
  } catch {
224
686
  // Best-effort — the tab/browser may already be gone.
225
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();
226
693
 
227
- const tracingComplete = this.events.wait('Tracing.tracingComplete', 5000).catch(() => undefined);
694
+ const tracingCompleteWait = this.events.wait('Tracing.tracingComplete', undefined, 5000);
228
695
  try {
229
696
  await this.client.send('Tracing.end');
697
+ await tracingCompleteWait.result().catch(() => undefined);
230
698
  } catch {
231
- // Best-effort.
699
+ // Best-effort, including removal of the now-ownerless event wait.
700
+ tracingCompleteWait.cancel();
232
701
  }
233
- await tracingComplete;
234
702
 
235
- try {
236
- await this.client.send('Runtime.evaluate', {
237
- expression: 'window.__captureRecorder && window.__captureRecorder.teardown()',
238
- });
239
- } catch {
240
- // Best-effort — the page may already be gone.
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
+ }
241
722
  }
242
723
  try {
243
724
  await this.client.send('Runtime.removeBinding', { name: RECORDER_BINDING_NAME });
@@ -245,15 +726,164 @@ export class RecorderSession {
245
726
  // Best-effort.
246
727
  }
247
728
 
729
+ this.flushBindingDropSummary();
730
+ this.flushRectDropSummary();
731
+ this.flushTraceDropSummary();
732
+
248
733
  this.state = 'stopped';
249
734
  return {
250
735
  frameCount: this.frameCount,
251
736
  eventCount: this.eventCount,
252
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! },
253
741
  };
254
742
  }
255
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
+
256
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;
257
887
  this.eventCount++;
258
888
  appendNdjsonPrivate(this.eventsPath, {
259
889
  ...record,
@@ -261,6 +891,33 @@ export class RecorderSession {
261
891
  } satisfies RecorderEventRecord);
262
892
  }
263
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
+
264
921
  private async handleScreencastFrame(params: ScreencastFrameParams): Promise<void> {
265
922
  const frameIndex = this.frameCount++;
266
923
  try {
@@ -271,15 +928,23 @@ export class RecorderSession {
271
928
  // Best-effort ack — losing one ack just risks the browser pausing the stream.
272
929
  }
273
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
+
274
936
  const frameName = `frame-${String(frameIndex).padStart(6, '0')}.png`;
275
937
  writeBinaryPrivate(path.join(this.framesDir, frameName), Buffer.from(params.data, 'base64'));
276
938
 
277
939
  try {
278
- const elements = await this.sampleRects();
940
+ const sample = await this.sampleRects(frameIndex);
941
+ const elements = this.sanitizeRectSample(sample.facts, sample.backendNodeIds);
279
942
  appendNdjsonPrivate(this.rectsPath, {
280
943
  frame: frameIndex,
281
944
  file: frameName,
945
+ cssToDevice: cssToDeviceTransform(sample.viewport, framePngDimensions(params.data)),
282
946
  screencastTimestamp: params.metadata?.timestamp ?? null,
947
+ screencastTimestampPrecision: 'frame-metadata',
283
948
  recordedAtWallClockMs: Date.now(),
284
949
  elements,
285
950
  } satisfies FrameRectsRecord);
@@ -294,41 +959,426 @@ export class RecorderSession {
294
959
  }
295
960
 
296
961
  private handleTraceData(params: { value: unknown[] }): void {
297
- this.appendEvent({ kind: 'trace', events: params.value });
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 });
298
969
  }
299
970
 
300
- private handleBindingCalled(params: { name: string; payload: string }): void {
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;
301
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
+
302
1069
  let payload: unknown;
303
1070
  try {
304
1071
  payload = JSON.parse(params.payload);
305
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');
306
1092
  return;
307
1093
  }
308
- if (!payload || typeof payload !== 'object') return;
309
- this.appendEvent(payload as Omit<RecorderEventRecord, 'recordedAtWallClockMs'>);
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();
310
1211
  }
311
1212
 
312
- private async sampleRects(): Promise<SampledRect[]> {
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> }> {
313
1221
  const evaluation = (await this.client.send('Runtime.evaluate', {
314
- expression: SAMPLE_RECTS_EXPRESSION,
1222
+ expression: buildSampleRectsExpression(this.bindingNonce, frameIndex),
315
1223
  returnByValue: true,
1224
+ contextId: this.requireIsolatedContextId(),
316
1225
  })) as { result: { value?: unknown }; exceptionDetails?: unknown };
317
1226
  if (evaluation.exceptionDetails) {
318
1227
  throw new Error(`rect sampling failed: ${JSON.stringify(evaluation.exceptionDetails)}`);
319
1228
  }
320
- return Array.isArray(evaluation.result.value) ? (evaluation.result.value as SampledRect[]) : [];
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();
321
1359
  }
322
1360
  }
323
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
+
324
1368
  // ---------------------------------------------------------------------------
325
1369
  // Wire dispatch — the same "one request per connection, one response"
326
1370
  // convention the plain bridge uses (see ./bridge/server.ts).
327
1371
  // ---------------------------------------------------------------------------
328
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
+ */
329
1379
  export async function handleRecorderRequest(
330
1380
  session: RecorderSession,
331
- req: RecorderRequest,
1381
+ req: Exclude<RecorderRequest, RecHarFlushRequest>,
332
1382
  ): Promise<RecorderResponse> {
333
1383
  try {
334
1384
  switch (req.type) {
@@ -341,8 +1391,8 @@ export async function handleRecorderRequest(
341
1391
  return { reqId: req.reqId, ok: true, type: 'rec-stop', ...summary };
342
1392
  }
343
1393
  case 'cdp': {
344
- const { result, event } = await session.handleCdp(req);
345
- return { reqId: req.reqId, ok: true, type: 'cdp', result, event };
1394
+ const { result, event, waitOutcome } = await session.handleCdp(req);
1395
+ return { reqId: req.reqId, ok: true, type: 'cdp', result, event, waitOutcome };
346
1396
  }
347
1397
  default: {
348
1398
  const exhaustive: never = req;
@@ -369,21 +1419,161 @@ export interface RunRecorderBridgeOptions {
369
1419
  socketPath: string;
370
1420
  targetId: string;
371
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;
372
1428
  port?: number;
373
1429
  }
374
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
+
375
1486
  export async function runRecorderBridge(opts: RunRecorderBridgeOptions): Promise<void> {
376
1487
  const port = opts.port ?? (await resolvePort());
377
- const resolved = await findTabByIdAcrossEndpoints(opts.targetId, port);
1488
+ const resolved = await recorderBridgeDeps.findTab(opts.targetId, port);
378
1489
  if (!resolved?.tab.webSocketDebuggerUrl) {
379
1490
  throw new Error(`No tab found for target "${opts.targetId}" on port ${port}.`);
380
1491
  }
381
1492
 
382
- const client = new CDPClient(resolved.tab.webSocketDebuggerUrl);
1493
+ const client = recorderBridgeDeps.createClient(resolved.tab.webSocketDebuggerUrl);
383
1494
  await client.waitReady();
384
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
+
385
1504
  const session = new RecorderSession({ client, recDir: opts.recDir });
386
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
+
387
1577
  async function handleLine(line: string, socket: net.Socket): Promise<void> {
388
1578
  let req: RecorderRequest;
389
1579
  try {
@@ -391,24 +1581,106 @@ export async function runRecorderBridge(opts: RunRecorderBridgeOptions): Promise
391
1581
  } catch {
392
1582
  return;
393
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
+
394
1673
  const resp = await handleRecorderRequest(session, req);
395
1674
  socket.write(JSON.stringify(resp) + '\n');
396
1675
  }
397
1676
 
398
- const server = await listenNdjsonSocket(opts.socketPath, handleLine);
399
- const cleanup = (): void => {
400
- closeNdjsonSocket(server, opts.socketPath);
401
- try {
402
- client.close();
403
- } catch {
404
- // Already closed.
405
- }
406
- };
1677
+ server = await listenNdjsonSocket(opts.socketPath, handleLine);
407
1678
  installProcessCleanup(cleanup, client);
408
- // Intentionally does not resolve further: the open server and the live
409
- // tab websocket keep this detached process alive until the caller (U14's
410
- // lifecycle routing) sends SIGTERM via the same `stopBridge()` used for
411
- // the plain held bridge.
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.
412
1684
  }
413
1685
 
414
1686
  async function resolvePort(): Promise<number> {
@@ -421,20 +1693,41 @@ async function resolvePort(): Promise<number> {
421
1693
  // ---------------------------------------------------------------------------
422
1694
 
423
1695
  /**
424
- * Builds the IIFE injected once at `rec-start` via `Runtime.evaluate`. Sets
425
- * up MutationObserver/ResizeObserver/PerformanceObserver on the document,
426
- * each emitting NDJSON-ready records to the host over the CDP binding
427
- * (`Runtime.addBinding` + `Runtime.bindingCalled`) — the standard CDP
428
- * page-to-host channel, not a new IPC mechanism. `window.__captureRecorder`
429
- * exposes `teardown()`, called at `rec-stop` to disconnect the observers.
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.
430
1712
  */
431
- function buildObserverScript(bindingName: string): string {
1713
+ function buildObserverScript(bindingName: string, nonce: string): string {
432
1714
  return `(function() {
433
- if (window.__captureRecorder) return;
434
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
+ }
435
1728
  function emit(kind, payload) {
436
1729
  try {
437
- var record = Object.assign({ kind: kind, performanceNowMs: performance.now() }, payload);
1730
+ var record = Object.assign({}, payload, { kind: kind, performanceNowMs: performance.now(), nonce: NONCE });
438
1731
  window[BINDING](JSON.stringify(record));
439
1732
  } catch (e) {}
440
1733
  }
@@ -450,9 +1743,13 @@ function buildObserverScript(bindingName: string): string {
450
1743
  });
451
1744
 
452
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);
453
1749
  emit('resize', {
1750
+ seq: seq,
454
1751
  count: entries.length,
455
- targets: entries.slice(0, 20).map(function(e) {
1752
+ targets: entries.map(function(e) {
456
1753
  var rect = e.contentRect;
457
1754
  return { tag: e.target && e.target.tagName, width: rect.width, height: rect.height };
458
1755
  }),
@@ -482,40 +1779,116 @@ function buildObserverScript(bindingName: string): string {
482
1779
  } catch (e) {}
483
1780
  });
484
1781
 
485
- window.__captureRecorder = {
1782
+ window[KEY] = {
486
1783
  teardown: function() {
487
1784
  try { mutationObserver.disconnect(); } catch (e) {}
488
1785
  try { resizeObserver.disconnect(); } catch (e) {}
489
1786
  perfObservers.forEach(function(po) {
490
1787
  try { po.disconnect(); } catch (e) {}
491
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 || [];
492
1806
  },
493
1807
  };
1808
+ return ${JSON.stringify(OBSERVER_INSTALLED_SENTINEL)};
494
1809
  })()`;
495
1810
  }
496
1811
 
497
1812
  /**
498
- * Single round-trip element-rect sample, evaluated once per screencast
499
- * frame. Uses `getBoundingClientRect()` over every element (capped) rather
500
- * than one CDP `DOM.getBoxModel` round trip per element — the cheap
501
- * approximation this mechanism unit ships with; quad-accurate geometry
502
- * (transforms, clipping, frame/shadow stitching) is the `measure snap`
503
- * substrate's job (`geometry.json`), not the motion recorder's.
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.
504
1826
  */
505
- const SAMPLE_RECTS_EXPRESSION = `(function() {
506
- var out = [];
507
- var all = document.querySelectorAll('*');
508
- var max = 2000;
509
- for (var i = 0; i < all.length && out.length < max; i++) {
510
- var el = all[i];
511
- var r = el.getBoundingClientRect();
512
- if (r.width === 0 && r.height === 0) continue;
513
- out.push({
514
- tag: el.tagName.toLowerCase(),
515
- id: el.id || null,
516
- classes: typeof el.className === 'string' ? el.className : null,
517
- x: r.x, y: r.y, width: r.width, height: r.height,
518
- });
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;
519
1868
  }
520
- return out;
521
- })()`;
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
+ }