@crouton-kit/capture 1.3.8 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -17,7 +17,7 @@ export interface BridgeRequest {
17
17
  * domains (`Browser.*`, `Target.*`).
18
18
  */
19
19
  targetId?: string;
20
- /** CDP event name to wait for (consumes the next occurrence, FIFO, buffered if it already fired). */
20
+ /** CDP event name to observe after this request is armed. Observation is future-only and scoped to this request's actual flattened target session (`undefined` without `targetId`); one event broadcasts to all already-armed same-key requests. */
21
21
  waitEvent?: string;
22
22
  timeoutMs?: number;
23
23
  }
@@ -47,10 +47,26 @@ export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
47
47
  // the plain bridge.
48
48
  // ---------------------------------------------------------------------------
49
49
 
50
- /** One synchronized (performance.now, wall-clock) pair, captured when the recorder arms. */
50
+ /**
51
+ * The three-way clock baseline the design's "Recorder timing model" requires to convert
52
+ * screencast-frame and Tracing timestamps into the authoritative `performance.now()` domain.
53
+ * `performanceNowMs`/`wallClockMs` are read synchronously when the recorder arms (`rec-start`);
54
+ * `firstScreencastTimestampSec`/`firstTraceEventTsUs` cannot be — no frame or trace batch has
55
+ * necessarily arrived yet at that instant — so they are filled in opportunistically as the
56
+ * recorder's own screencast/tracing handlers observe the first occurrence of each, and stay
57
+ * `null` (with `baselinesPending: true`) until then. `rec-stop`'s response carries the same
58
+ * object read again at stop time — the flush path a caller uses to get the completed baselines
59
+ * for `markers.json` even when `rec-start`'s own response returned it still pending.
60
+ */
51
61
  export interface RecorderClockBaselines {
52
62
  performanceNowMs: number;
53
63
  wallClockMs: number;
64
+ /** First `Page.screencastFrame`'s `metadata.timestamp` (wall-clock seconds), or `null` if none had arrived yet at the moment this snapshot was taken. */
65
+ firstScreencastTimestampSec: number | null;
66
+ /** First `Tracing.dataCollected` batch's earliest event `ts` (trace-clock microseconds), or `null` if none had arrived yet at the moment this snapshot was taken. */
67
+ firstTraceEventTsUs: number | null;
68
+ /** `true` while either timestamp above is still `null`. */
69
+ baselinesPending: boolean;
54
70
  }
55
71
 
56
72
  /**
@@ -58,10 +74,21 @@ export interface RecorderClockBaselines {
58
74
  * domains, starts `Page.startScreencast` + `Tracing`, injects the
59
75
  * Mutation/Resize/Performance observers, and captures the clock baseline.
60
76
  * A recorder process only accepts one `rec-start` while `state==="idle"`.
77
+ *
78
+ * `nonce` (here and on every other recorder request shape) is the
79
+ * per-recording control-socket admission token: the recorder bridge process
80
+ * generates it at boot (64 lowercase hex chars, 256 bits) and hands it back
81
+ * to its starter over a private boot file; every request line must carry it
82
+ * verbatim, and the server constant-time-compares it BEFORE any handler
83
+ * runs. There is no unauthenticated request shape and no compatibility
84
+ * lane — a missing or mismatched nonce is answered `ok:false error:
85
+ * 'unauthorized'` with no side effects.
61
86
  */
62
87
  export interface RecStartRequest {
63
88
  reqId: number;
64
89
  type: 'rec-start';
90
+ /** Per-recording control-socket admission token — see the doc comment above. */
91
+ nonce: string;
65
92
  }
66
93
 
67
94
  export interface RecStartResponseOk {
@@ -81,6 +108,8 @@ export interface RecStartResponseOk {
81
108
  export interface RecStopRequest {
82
109
  reqId: number;
83
110
  type: 'rec-stop';
111
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
112
+ nonce: string;
84
113
  }
85
114
 
86
115
  export interface RecStopResponseOk {
@@ -90,6 +119,8 @@ export interface RecStopResponseOk {
90
119
  frameCount: number;
91
120
  eventCount: number;
92
121
  durationMs: number;
122
+ /** The clock baselines re-read at stop time — the flush path for a `rec-start` response whose baselines were still pending. */
123
+ markers: RecorderClockBaselines;
93
124
  }
94
125
 
95
126
  /**
@@ -102,11 +133,15 @@ export interface RecStopResponseOk {
102
133
  * timeline — this is the "marked CDP request message" the protocol
103
134
  * supports. Omit `mark` for a plain passthrough call (still observed
104
135
  * natively by the recorder's own event subscriptions, just not logged as a
105
- * distinct input landmark).
136
+ * distinct input landmark). Omit `method` for a wait-event-only request;
137
+ * the bridge arms a future-only wait on the recorder tab websocket's
138
+ * unscoped event envelope and returns the matched event in one response.
106
139
  */
107
- export interface RecCdpRequest {
140
+ export interface RecCdpDispatchRequest {
108
141
  reqId: number;
109
142
  type: 'cdp';
143
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
144
+ nonce: string;
110
145
  method: string;
111
146
  params?: Record<string, unknown>;
112
147
  mark?: string;
@@ -114,15 +149,66 @@ export interface RecCdpRequest {
114
149
  timeoutMs?: number;
115
150
  }
116
151
 
152
+ export interface RecCdpWaitEventRequest {
153
+ reqId: number;
154
+ type: 'cdp';
155
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
156
+ nonce: string;
157
+ method?: undefined;
158
+ waitEvent: string;
159
+ timeoutMs?: number;
160
+ }
161
+
162
+ export type RecCdpRequest = RecCdpDispatchRequest | RecCdpWaitEventRequest;
163
+
117
164
  export interface RecCdpResponseOk {
118
165
  reqId: number;
119
166
  ok: true;
120
167
  type: 'cdp';
121
168
  result?: unknown;
122
169
  event?: unknown;
170
+ /**
171
+ * Present iff this request armed a `waitEvent` AND carried a `method`. It
172
+ * reports the load-wait outcome SEPARATELY from the method result: a method
173
+ * dispatch that succeeds is always `ok:true` with its `result` preserved,
174
+ * regardless of whether the paired wait observed its event
175
+ * (`'observed'`, with `event` set) or the wait's own bounded timer elapsed
176
+ * first (`'bounded-timeout'`, no `event`). A wait-event-ONLY request (no
177
+ * `method`) whose wait times out is still `ok:false` — that path has no
178
+ * method result to preserve, so its timeout is a genuine failure.
179
+ */
180
+ waitOutcome?: 'observed' | 'bounded-timeout';
181
+ }
182
+
183
+ /**
184
+ * The action-return HAR flush/health barrier. A recorder-routed page action
185
+ * (`click`, `type`, `scroll`, `exec`, `navigate`) sends this after its settle
186
+ * window and before claiming success, so every HAR entry/body/append the
187
+ * recorder's streaming collector had already completed by then is durably in
188
+ * the owning session's live HAR first. Deliberately NON-attributing: the
189
+ * response reports store health only — `ok:true` means "all work completed at
190
+ * request time has appended and no fatal store error is latched"; it never
191
+ * says which entries belong to which action and carries no counts (the
192
+ * collector cannot truthfully attribute concurrent traffic to one action).
193
+ * Once the terminal `rec-stop` latch is claimed, HAR finalization belongs to
194
+ * that one owner and a later `har-flush` is answered with a deterministic
195
+ * `ok:false` rejection instead of sharing or re-driving the drain.
196
+ */
197
+ export interface RecHarFlushRequest {
198
+ reqId: number;
199
+ type: 'har-flush';
200
+ /** Per-recording control-socket admission token — see `RecStartRequest`. */
201
+ nonce: string;
202
+ }
203
+
204
+ /** Health-only: success carries no result fields beyond the envelope — see `RecHarFlushRequest`. */
205
+ export interface RecHarFlushResponseOk {
206
+ reqId: number;
207
+ ok: true;
208
+ type: 'har-flush';
123
209
  }
124
210
 
125
- export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest;
211
+ export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest | RecHarFlushRequest;
126
212
 
127
213
  export interface RecorderResponseErr {
128
214
  reqId: number;
@@ -134,4 +220,5 @@ export interface RecorderResponseErr {
134
220
  export type RecStartResponse = RecStartResponseOk | RecorderResponseErr;
135
221
  export type RecStopResponse = RecStopResponseOk | RecorderResponseErr;
136
222
  export type RecCdpResponse = RecCdpResponseOk | RecorderResponseErr;
137
- export type RecorderResponse = RecStartResponse | RecStopResponse | RecCdpResponse;
223
+ export type RecHarFlushResponse = RecHarFlushResponseOk | RecorderResponseErr;
224
+ export type RecorderResponse = RecStartResponse | RecStopResponse | RecCdpResponse | RecHarFlushResponse;
@@ -19,20 +19,30 @@ import { type CDPClient } from '../client.js';
19
19
  import { type BridgeRequest, type BridgeResponse } from './protocol.js';
20
20
 
21
21
  interface EventWaiter {
22
- resolve: (v: unknown) => void;
22
+ resolve: (value: unknown) => void;
23
23
  }
24
24
 
25
+ export interface EventWait {
26
+ /** Returns the event or throws its timeout. The broker observes timeouts internally until this is called, so a slow triggering method cannot create an unhandled rejection. */
27
+ result(): Promise<unknown>;
28
+ /** Removes and settles this waiter without affecting any other waiter. */
29
+ cancel(): void;
30
+ }
31
+
32
+ type EventWaitOutcome =
33
+ | { kind: 'event'; value: unknown }
34
+ | { kind: 'timeout'; error: Error }
35
+ | { kind: 'cancelled' };
36
+
25
37
  /**
26
- * Buffers CDP events by name and resolves `wait()` callers FIFO, either
27
- * immediately (from the buffer) or when the next matching event arrives.
28
- * Shared by the plain browser-level bridge and the recorder bridge
29
- * (`../recorder-bridge.ts`) — both hold one long-lived `CDPClient` and need
30
- * the same "consume the next occurrence of this event" primitive for
31
- * `waitEvent`-bearing requests.
38
+ * Observes future CDP events on one long-lived client. Waiters are scoped by
39
+ * the event envelope's actual flattened session id (including `undefined`
40
+ * for an unscoped event). An event is dropped when no exact-key waiter is
41
+ * armed, and otherwise broadcasts to every waiter already armed for that
42
+ * key. The broker never retains event history.
32
43
  */
33
44
  export class EventBroker {
34
- private queues = new Map<string, unknown[]>();
35
- private waiters = new Map<string, EventWaiter[]>();
45
+ private waiters = new Map<string, Map<string | undefined, Set<EventWaiter>>>();
36
46
  private listening = new Set<string>();
37
47
 
38
48
  constructor(private client: Pick<CDPClient, 'on'>) {}
@@ -40,45 +50,109 @@ export class EventBroker {
40
50
  private ensureListening(eventName: string): void {
41
51
  if (this.listening.has(eventName)) return;
42
52
  this.listening.add(eventName);
43
- this.client.on(eventName, (params) => {
44
- const waiters = this.waiters.get(eventName);
45
- if (waiters && waiters.length > 0) {
46
- waiters.shift()!.resolve(params);
47
- return;
48
- }
49
- const q = this.queues.get(eventName) ?? [];
50
- q.push(params);
51
- // Cap the buffer so an event nobody ever asks for can't leak memory.
52
- if (q.length > 50) q.shift();
53
- this.queues.set(eventName, q);
53
+ this.client.on(eventName, (params, actualSessionId) => {
54
+ const bySession = this.waiters.get(eventName);
55
+ const matching = bySession?.get(actualSessionId);
56
+ if (!matching || matching.size === 0) return;
57
+
58
+ // Remove the exact bucket before resolving its snapshot. A waiter armed
59
+ // by a resolution continuation is therefore future-only and cannot
60
+ // observe the event currently being broadcast.
61
+ bySession!.delete(actualSessionId);
62
+ if (bySession!.size === 0) this.waiters.delete(eventName);
63
+ for (const waiter of matching) waiter.resolve(params);
54
64
  });
55
65
  }
56
66
 
57
- wait(eventName: string, timeoutMs: number): Promise<unknown> {
67
+ wait(eventName: string, sessionId: string | undefined, timeoutMs: number): EventWait {
58
68
  this.ensureListening(eventName);
59
- const q = this.queues.get(eventName);
60
- if (q && q.length > 0) {
61
- return Promise.resolve(q.shift());
69
+ let bySession = this.waiters.get(eventName);
70
+ if (!bySession) {
71
+ bySession = new Map();
72
+ this.waiters.set(eventName, bySession);
62
73
  }
63
- return new Promise((resolve, reject) => {
64
- const list = this.waiters.get(eventName) ?? [];
65
- const entry: EventWaiter = {
66
- resolve: (v) => {
67
- clearTimeout(timer);
68
- resolve(v);
69
- },
70
- };
71
- const timer = setTimeout(() => {
72
- const current = this.waiters.get(eventName);
73
- if (current) {
74
- const idx = current.indexOf(entry);
75
- if (idx >= 0) current.splice(idx, 1);
76
- }
77
- reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`));
78
- }, timeoutMs);
79
- list.push(entry);
80
- this.waiters.set(eventName, list);
74
+ let matching = bySession.get(sessionId);
75
+ if (!matching) {
76
+ matching = new Set();
77
+ bySession.set(sessionId, matching);
78
+ }
79
+
80
+ let settleOutcome!: (outcome: EventWaitOutcome) => void;
81
+ const outcome = new Promise<EventWaitOutcome>((resolve) => {
82
+ settleOutcome = resolve;
81
83
  });
84
+ let settled = false;
85
+ const removeOnlyThisWaiter = (): void => {
86
+ const currentBySession = this.waiters.get(eventName);
87
+ const current = currentBySession?.get(sessionId);
88
+ if (!current) return;
89
+ current.delete(waiter);
90
+ if (current.size === 0) currentBySession!.delete(sessionId);
91
+ if (currentBySession!.size === 0) this.waiters.delete(eventName);
92
+ };
93
+ const waiter: EventWaiter = {
94
+ resolve: (value) => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ settleOutcome({ kind: 'event', value });
99
+ },
100
+ };
101
+ matching.add(waiter);
102
+ const timer = setTimeout(() => {
103
+ if (settled) return;
104
+ settled = true;
105
+ removeOnlyThisWaiter();
106
+ settleOutcome({
107
+ kind: 'timeout',
108
+ error: new Error(
109
+ `Timed out after ${timeoutMs}ms waiting for event "${eventName}"` +
110
+ (sessionId === undefined ? ' without a session' : ` in session "${sessionId}"`),
111
+ ),
112
+ });
113
+ }, timeoutMs);
114
+
115
+ return {
116
+ result: async () => {
117
+ const settledOutcome = await outcome;
118
+ if (settledOutcome.kind === 'timeout') throw settledOutcome.error;
119
+ return settledOutcome.kind === 'event' ? settledOutcome.value : undefined;
120
+ },
121
+ cancel: () => {
122
+ if (settled) return;
123
+ settled = true;
124
+ clearTimeout(timer);
125
+ removeOnlyThisWaiter();
126
+ settleOutcome({ kind: 'cancelled' });
127
+ },
128
+ };
129
+ }
130
+ }
131
+
132
+ export async function handleBridgeRequest(
133
+ req: BridgeRequest,
134
+ client: Pick<CDPClient, 'send'>,
135
+ events: EventBroker,
136
+ attach: (targetId: string) => Promise<string>,
137
+ ): Promise<BridgeResponse> {
138
+ let eventWait: EventWait | undefined;
139
+ try {
140
+ const sessionId = req.targetId ? await attach(req.targetId) : undefined;
141
+ eventWait = req.waitEvent
142
+ ? events.wait(req.waitEvent, sessionId, req.timeoutMs ?? 10000)
143
+ : undefined;
144
+ const result = req.method
145
+ ? await client.send(req.method, req.params ?? {}, 60000, sessionId)
146
+ : undefined;
147
+ const event = eventWait ? await eventWait.result() : undefined;
148
+ return { reqId: req.reqId, ok: true, result, event };
149
+ } catch (err) {
150
+ eventWait?.cancel();
151
+ return {
152
+ reqId: req.reqId,
153
+ ok: false,
154
+ error: err instanceof Error ? err.message : String(err),
155
+ };
82
156
  }
83
157
  }
84
158
 
@@ -99,7 +173,7 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
99
173
  // requiring the full 32-char target id here.
100
174
  const tab = await findTabById(resolvedPort, targetId);
101
175
  if (!tab) {
102
- throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture list" to see available tabs.`);
176
+ throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture tab list" to see available tabs.`);
103
177
  }
104
178
  const result = (await client.send('Target.attachToTarget', { targetId: tab.id, flatten: true })) as {
105
179
  sessionId: string;
@@ -115,16 +189,7 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
115
189
  } catch {
116
190
  return;
117
191
  }
118
- let resp: BridgeResponse;
119
- try {
120
- const sessionId = req.targetId ? await attach(req.targetId) : undefined;
121
- const eventPromise = req.waitEvent ? events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
122
- const result = req.method ? await client.send(req.method, req.params ?? {}, 60000, sessionId) : undefined;
123
- const event = eventPromise ? await eventPromise : undefined;
124
- resp = { reqId: req.reqId, ok: true, result, event };
125
- } catch (err) {
126
- resp = { reqId: req.reqId, ok: false, error: err instanceof Error ? err.message : String(err) };
127
- }
192
+ const resp = await handleBridgeRequest(req, client, events, attach);
128
193
  socket.write(JSON.stringify(resp) + '\n');
129
194
  }
130
195
 
@@ -5,16 +5,45 @@
5
5
  */
6
6
 
7
7
  import { spawn } from 'child_process';
8
+ import * as crypto from 'crypto';
8
9
  import * as fs from 'fs';
9
10
  import * as path from 'path';
11
+ import { CAPTURE_ROOT, ensurePrivateDir } from '../../session/artifacts.js';
10
12
 
11
13
  export function bridgeSocketPath(sessionDir: string): string {
12
14
  return path.join(sessionDir, 'bridge.sock');
13
15
  }
14
16
 
15
- /** The recorder bridge's own socket, scoped to its recording dir so it can't collide with a session's plain `--hold` bridge. */
17
+ /**
18
+ * Fixed, short, private (`0700`) directory every recorder socket lives in — flat, and
19
+ * deliberately NOT nested under a recording's (long) artifact dir. macOS caps `AF_UNIX`
20
+ * pathnames at ~104 bytes; a real recording dir (`{CAPTURE_ROOT}/{session}/motion/recs/{recId}`)
21
+ * combined with `os.tmpdir()`'s own (often long, per-user) prefix can already exceed that before
22
+ * adding a filename, so the socket can never live inside `recDir` — see `recorderSocketPath()`.
23
+ */
24
+ function recorderSocketDir(): string {
25
+ return ensurePrivateDir(path.join(CAPTURE_ROOT, 'sock'));
26
+ }
27
+
28
+ /**
29
+ * A short, fixed-length filename deterministically derived from `recDir` (not from `recDir`'s
30
+ * own length/depth), so the resulting socket path stays bounded regardless of how deep the
31
+ * recording's session/motion/recs nesting is.
32
+ */
33
+ function shortSocketName(recDir: string): string {
34
+ return crypto.createHash('sha1').update(path.resolve(recDir)).digest('hex').slice(0, 16);
35
+ }
36
+
37
+ /**
38
+ * The recorder bridge's own socket path. Lives in the short, flat `recorderSocketDir()` — NOT
39
+ * inside `recDir` (the long, deep artifact directory `frames`/`events.jsonl`/etc. write into) —
40
+ * so binding it never risks exceeding the platform's `AF_UNIX` pathname limit. `recDir` is still
41
+ * required by every recorder-mode caller (spawn args, artifact writers); this function only
42
+ * derives the socket's own location from it, deterministically, without embedding `recDir` in
43
+ * the path itself.
44
+ */
16
45
  export function recorderSocketPath(recDir: string): string {
17
- return path.join(recDir, 'recorder.sock');
46
+ return path.join(recorderSocketDir(), `${shortSocketName(recDir)}.sock`);
18
47
  }
19
48
 
20
49
  export async function startBridge(
@@ -41,9 +70,13 @@ export async function startBridge(
41
70
  const start = Date.now();
42
71
  while (!fs.existsSync(socketPath)) {
43
72
  if (Date.now() - start > timeoutMs) {
73
+ // Own the child we spawned: a bridge that never signalled readiness is
74
+ // ours to reap before we reject, so no orphaned process outlives the
75
+ // failed start. Mirrors startRecorderBridge()'s ownership discipline.
76
+ stopBridge(pid, socketPath);
44
77
  throw new Error(
45
78
  `CDP bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
46
- `Check that a browser is reachable on port ${port} (capture detect).`,
79
+ `Check that a browser is reachable on port ${port} (capture tab list --port ${port}).`,
47
80
  );
48
81
  }
49
82
  await new Promise((r) => setTimeout(r, 50));
@@ -78,15 +111,27 @@ export function stopBridge(pid: number | null | undefined, socketPath: string |
78
111
  * (U14's lifecycle routing) tears it down with the same `stopBridge()`
79
112
  * used for the plain held bridge.
80
113
  *
81
- * Recorder mode is selected via a positional (`recorder <recDir>`) rather
82
- * than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is not
83
- * owned by this unit, but `positional` is already generic passthrough.
114
+ * Recorder mode is selected via a positional (`recorder <recDir> <harId>`)
115
+ * rather than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is
116
+ * not owned by this unit, but `positional` is already generic passthrough.
117
+ *
118
+ * `harId` is the owning session's live HAR recording id (`.session.json`'s
119
+ * `harId`) — the recorder bridge installs a streaming `HARRecorder` on its
120
+ * held tab connection and appends network evidence to that store, so the id
121
+ * must travel to the spawned process. Required: a session-backed recorder
122
+ * always has one (the caller throws before spawning if it doesn't).
123
+ *
124
+ * The socket-file readiness poll below doubles as the nonce-boot-file
125
+ * guarantee: the recorder writes `{recDir}/recorder-nonce.json` BEFORE it
126
+ * binds the socket (see `../recorder-bridge.ts`), so once this resolves the
127
+ * caller can read the nonce without racing the child.
84
128
  */
85
129
  export async function startRecorderBridge(
86
130
  socketPath: string,
87
131
  port: number,
88
132
  targetId: string,
89
133
  recDir: string,
134
+ harId: string,
90
135
  timeoutMs = 5000,
91
136
  ): Promise<{ socketPath: string; pid: number }> {
92
137
  const scriptPath = process.argv[1];
@@ -103,6 +148,7 @@ export async function startRecorderBridge(
103
148
  targetId,
104
149
  'recorder',
105
150
  recDir,
151
+ harId,
106
152
  ],
107
153
  { detached: true, stdio: 'ignore' },
108
154
  );
@@ -115,6 +161,7 @@ export async function startRecorderBridge(
115
161
  const start = Date.now();
116
162
  while (!fs.existsSync(socketPath)) {
117
163
  if (Date.now() - start > timeoutMs) {
164
+ stopBridge(pid, socketPath);
118
165
  throw new Error(
119
166
  `Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
120
167
  `Check that target "${targetId}" is reachable on port ${port}.`,
package/src/cdp/client.ts CHANGED
@@ -8,7 +8,7 @@ export class CDPClient {
8
8
  { resolve: (v: unknown) => void; reject: (e: Error) => void }
9
9
  >();
10
10
  private pendingTimeouts = new Map<number, NodeJS.Timeout>();
11
- private eventHandlers = new Map<string, Array<(params: unknown) => void>>();
11
+ private eventHandlers = new Map<string, Array<(params: unknown, sessionId?: string) => void>>();
12
12
  private ready: Promise<void>;
13
13
 
14
14
  constructor(wsUrl: string, connectTimeout = 5000) {
@@ -47,7 +47,7 @@ export class CDPClient {
47
47
  }
48
48
  } else if (msg.method) {
49
49
  const handlers = this.eventHandlers.get(msg.method);
50
- handlers?.forEach((h) => h(msg.params));
50
+ handlers?.forEach((handler) => handler(msg.params, msg.sessionId));
51
51
  }
52
52
  });
53
53
  }
@@ -80,13 +80,23 @@ export class CDPClient {
80
80
  });
81
81
  }
82
82
 
83
- on(event: string, handler: (params: unknown) => void): void {
83
+ /** Registers an event callback. The second argument is the actual optional `sessionId` from a flattened CDP event envelope; it is never inferred from the method or params. */
84
+ on(event: string, handler: (params: unknown, sessionId?: string) => void): void {
84
85
  if (!this.eventHandlers.has(event)) {
85
86
  this.eventHandlers.set(event, []);
86
87
  }
87
88
  this.eventHandlers.get(event)!.push(handler);
88
89
  }
89
90
 
91
+ /** Unregisters a handler previously added with {@link on}. Required so one-shot event consumers (e.g. the `LayerTree.layerTreeDidChange` collector) can remove their listener on settle/timeout instead of leaking a retained closure for the connection's lifetime. */
92
+ off(event: string, handler: (params: unknown, sessionId?: string) => void): void {
93
+ const handlers = this.eventHandlers.get(event);
94
+ if (!handlers) return;
95
+ const index = handlers.indexOf(handler);
96
+ if (index !== -1) handlers.splice(index, 1);
97
+ if (handlers.length === 0) this.eventHandlers.delete(event);
98
+ }
99
+
90
100
  /** Fires when the underlying websocket goes away (browser closed, crashed, CDP endpoint dropped). */
91
101
  onDisconnect(handler: () => void): void {
92
102
  this.ws.on('close', handler);
@@ -1,5 +1,6 @@
1
1
  import { runBridgeServer } from '../bridge/server.js';
2
2
  import { runRecorderBridge } from '../recorder-bridge.js';
3
+ import { invalidInput } from '../../errors.js';
3
4
  import { type ParsedArgs } from '../types.js';
4
5
 
5
6
  /**
@@ -8,31 +9,36 @@ import { type ParsedArgs } from '../types.js';
8
9
  * --start` in recorder mode (`startRecorderBridge()`). Not documented in
9
10
  * `capture --help` — nothing calls this directly.
10
11
  *
11
- * Recorder mode is selected by a `recorder <recDir>` positional rather
12
- * than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't owned by
13
- * this unit but `parsed.positional` is already generic passthrough:
14
- * capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>
12
+ * Recorder mode is selected by a `recorder <recDir> <harId>` positional
13
+ * rather than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't
14
+ * owned by this unit but `parsed.positional` is already generic passthrough:
15
+ * capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>
16
+ *
17
+ * Usage errors throw the repo's structured `CaptureError` — the root
18
+ * boundary in `src/capture.ts` renders it via `failureResult()` and exits
19
+ * nonzero, same as every other command leaf.
15
20
  */
16
21
  export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
17
22
  if (!parsed.socket) {
18
- console.error(
19
- 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir>]',
23
+ throw invalidInput(
24
+ 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir> <harId>]',
25
+ 'bridge_serve_usage',
20
26
  );
21
- process.exit(1);
22
27
  }
23
28
 
24
29
  if (parsed.positional[0] === 'recorder') {
25
30
  const recDir = parsed.positional[1];
26
- if (!parsed.target || !recDir) {
27
- console.error(
28
- 'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>',
31
+ const harId = parsed.positional[2];
32
+ if (!parsed.target || !recDir || !harId) {
33
+ throw invalidInput(
34
+ 'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>',
35
+ 'bridge_serve_usage',
29
36
  );
30
- process.exit(1);
31
37
  }
32
- await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir });
38
+ await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir, harId });
33
39
  // Deliberately does not exit: the open Unix socket server and the live
34
- // tab websocket keep this detached process alive until the caller sends
35
- // it SIGTERM (same teardown as the plain held bridge).
40
+ // tab websocket keep this detached process alive until an authenticated
41
+ // `rec-stop` completes (self-exit) or the caller escalates with SIGTERM.
36
42
  return;
37
43
  }
38
44