@crouton-kit/capture 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -14,68 +14,151 @@
14
14
  import * as fs from 'fs';
15
15
  import * as net from 'net';
16
16
  import * as path from 'path';
17
- import { getBrowserClient } from '../targets.js';
17
+ import { getBrowserClient, findTabById } from '../targets.js';
18
18
  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
- class EventBroker {
26
- private queues = new Map<string, unknown[]>();
27
- private waiters = new Map<string, EventWaiter[]>();
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
+
37
+ /**
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.
43
+ */
44
+ export class EventBroker {
45
+ private waiters = new Map<string, Map<string | undefined, Set<EventWaiter>>>();
28
46
  private listening = new Set<string>();
29
47
 
30
- constructor(private client: CDPClient) {}
48
+ constructor(private client: Pick<CDPClient, 'on'>) {}
31
49
 
32
50
  private ensureListening(eventName: string): void {
33
51
  if (this.listening.has(eventName)) return;
34
52
  this.listening.add(eventName);
35
- this.client.on(eventName, (params) => {
36
- const waiters = this.waiters.get(eventName);
37
- if (waiters && waiters.length > 0) {
38
- waiters.shift()!.resolve(params);
39
- return;
40
- }
41
- const q = this.queues.get(eventName) ?? [];
42
- q.push(params);
43
- // Cap the buffer so an event nobody ever asks for can't leak memory.
44
- if (q.length > 50) q.shift();
45
- 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);
46
64
  });
47
65
  }
48
66
 
49
- wait(eventName: string, timeoutMs: number): Promise<unknown> {
67
+ wait(eventName: string, sessionId: string | undefined, timeoutMs: number): EventWait {
50
68
  this.ensureListening(eventName);
51
- const q = this.queues.get(eventName);
52
- if (q && q.length > 0) {
53
- 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);
73
+ }
74
+ let matching = bySession.get(sessionId);
75
+ if (!matching) {
76
+ matching = new Set();
77
+ bySession.set(sessionId, matching);
54
78
  }
55
- return new Promise((resolve, reject) => {
56
- const list = this.waiters.get(eventName) ?? [];
57
- const entry: EventWaiter = {
58
- resolve: (v) => {
59
- clearTimeout(timer);
60
- resolve(v);
61
- },
62
- };
63
- const timer = setTimeout(() => {
64
- const current = this.waiters.get(eventName);
65
- if (current) {
66
- const idx = current.indexOf(entry);
67
- if (idx >= 0) current.splice(idx, 1);
68
- }
69
- reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`));
70
- }, timeoutMs);
71
- list.push(entry);
72
- this.waiters.set(eventName, list);
79
+
80
+ let settleOutcome!: (outcome: EventWaitOutcome) => void;
81
+ const outcome = new Promise<EventWaitOutcome>((resolve) => {
82
+ settleOutcome = resolve;
73
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
+ };
74
156
  }
75
157
  }
76
158
 
77
159
  export async function runBridgeServer(socketPath: string, port?: number): Promise<void> {
78
- const { client } = await getBrowserClient(await resolvePort(port));
160
+ const resolvedPort = await resolvePort(port);
161
+ const { client } = await getBrowserClient(resolvedPort);
79
162
  const events = new EventBroker(client);
80
163
 
81
164
  // Cache flattened Target.attachToTarget sessions so repeated requests
@@ -85,19 +168,78 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
85
168
  async function attach(targetId: string): Promise<string> {
86
169
  const cached = targetSessions.get(targetId);
87
170
  if (cached) return cached;
88
- const result = (await client.send('Target.attachToTarget', { targetId, flatten: true })) as {
171
+ // Accept the same 8-char-prefix targeting every other capture command
172
+ // promises (see the top-level --help TARGETING section) instead of
173
+ // requiring the full 32-char target id here.
174
+ const tab = await findTabById(resolvedPort, targetId);
175
+ if (!tab) {
176
+ throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture tab list" to see available tabs.`);
177
+ }
178
+ const result = (await client.send('Target.attachToTarget', { targetId: tab.id, flatten: true })) as {
89
179
  sessionId: string;
90
180
  };
91
181
  targetSessions.set(targetId, result.sessionId);
92
182
  return result.sessionId;
93
183
  }
94
184
 
185
+ async function handleLine(line: string, socket: net.Socket): Promise<void> {
186
+ let req: BridgeRequest;
187
+ try {
188
+ req = JSON.parse(line);
189
+ } catch {
190
+ return;
191
+ }
192
+ const resp = await handleBridgeRequest(req, client, events, attach);
193
+ socket.write(JSON.stringify(resp) + '\n');
194
+ }
195
+
196
+ const server = await listenNdjsonSocket(socketPath, handleLine);
197
+
198
+ const cleanup = (): void => {
199
+ closeNdjsonSocket(server, socketPath);
200
+ try {
201
+ client.close();
202
+ } catch {
203
+ // Already closed.
204
+ }
205
+ };
206
+ installProcessCleanup(cleanup, client);
207
+ // Intentionally does not resolve further work here: the open server and
208
+ // the live websocket keep the event loop (and this detached process) alive
209
+ // until `stopBridge()` sends SIGTERM.
210
+ }
211
+
212
+ /**
213
+ * Ensures `socketPath`'s parent directory exists and removes any stale
214
+ * socket file left at that path (a previous bridge process that died
215
+ * without cleaning up). Shared preparation step before binding a new
216
+ * `net.Server` there.
217
+ */
218
+ export function prepareSocketPath(socketPath: string): void {
95
219
  fs.mkdirSync(path.dirname(socketPath), { recursive: true });
96
220
  try {
97
221
  fs.unlinkSync(socketPath);
98
222
  } catch {
99
223
  // No stale socket to remove.
100
224
  }
225
+ }
226
+
227
+ /**
228
+ * Binds a Unix-domain `net.Server` at `socketPath` that frames each
229
+ * connection's input as newline-delimited JSON, invoking `handleLine` once
230
+ * per complete line. This is the wire framing every bridge mode (plain
231
+ * browser-level bridge, recorder bridge) shares — "one request per
232
+ * connection, one response, then the socket closes" is a convention the
233
+ * caller's `handleLine` implements by writing exactly one response per
234
+ * line and letting the client end the connection; the server itself is
235
+ * agnostic to that convention and would happily frame a connection that
236
+ * sends multiple lines.
237
+ */
238
+ export async function listenNdjsonSocket(
239
+ socketPath: string,
240
+ handleLine: (line: string, socket: net.Socket) => void | Promise<void>,
241
+ ): Promise<net.Server> {
242
+ prepareSocketPath(socketPath);
101
243
 
102
244
  const server = net.createServer((socket) => {
103
245
  let buffer = '';
@@ -112,43 +254,36 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
112
254
  });
113
255
  });
114
256
 
115
- async function handleLine(line: string, socket: net.Socket): Promise<void> {
116
- let req: BridgeRequest;
117
- try {
118
- req = JSON.parse(line);
119
- } catch {
120
- return;
121
- }
122
- let resp: BridgeResponse;
123
- try {
124
- const sessionId = req.targetId ? await attach(req.targetId) : undefined;
125
- const eventPromise = req.waitEvent ? events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
126
- const result = req.method ? await client.send(req.method, req.params ?? {}, 60000, sessionId) : undefined;
127
- const event = eventPromise ? await eventPromise : undefined;
128
- resp = { reqId: req.reqId, ok: true, result, event };
129
- } catch (err) {
130
- resp = { reqId: req.reqId, ok: false, error: err instanceof Error ? err.message : String(err) };
131
- }
132
- socket.write(JSON.stringify(resp) + '\n');
257
+ await new Promise<void>((resolve, reject) => {
258
+ server.on('error', reject);
259
+ server.listen(socketPath, () => resolve());
260
+ });
261
+ return server;
262
+ }
263
+
264
+ /** Closes the socket server and best-effort unlinks the socket file. */
265
+ export function closeNdjsonSocket(server: net.Server, socketPath: string): void {
266
+ try {
267
+ server.close();
268
+ } catch {
269
+ // Already closed.
133
270
  }
271
+ try {
272
+ fs.unlinkSync(socketPath);
273
+ } catch {
274
+ // Already gone.
275
+ }
276
+ }
134
277
 
135
- const cleanup = (): void => {
136
- try {
137
- server.close();
138
- } catch {
139
- // Already closed.
140
- }
141
- try {
142
- client.close();
143
- } catch {
144
- // Already closed.
145
- }
146
- try {
147
- fs.unlinkSync(socketPath);
148
- } catch {
149
- // Already gone.
150
- }
151
- };
278
+ /**
279
+ * Wires SIGTERM/SIGINT and the held client's disconnect into one `cleanup`
280
+ * call followed by `process.exit(0)` — the shutdown sequence every
281
+ * detached bridge process (plain or recorder) uses.
282
+ */
283
+ export function installProcessCleanup(
284
+ cleanup: () => void,
285
+ client: Pick<CDPClient, 'onDisconnect'>,
286
+ ): void {
152
287
  process.on('SIGTERM', () => {
153
288
  cleanup();
154
289
  process.exit(0);
@@ -162,14 +297,6 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
162
297
  cleanup();
163
298
  process.exit(0);
164
299
  });
165
-
166
- await new Promise<void>((resolve, reject) => {
167
- server.on('error', reject);
168
- server.listen(socketPath, () => resolve());
169
- });
170
- // Intentionally does not resolve further work here: the open server and
171
- // the live websocket keep the event loop (and this detached process) alive
172
- // until `stopBridge()` sends SIGTERM.
173
300
  }
174
301
 
175
302
  async function resolvePort(port?: number): Promise<number> {
@@ -5,13 +5,47 @@
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
 
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
+ */
45
+ export function recorderSocketPath(recDir: string): string {
46
+ return path.join(recorderSocketDir(), `${shortSocketName(recDir)}.sock`);
47
+ }
48
+
15
49
  export async function startBridge(
16
50
  sessionDir: string,
17
51
  port: number,
@@ -36,9 +70,13 @@ export async function startBridge(
36
70
  const start = Date.now();
37
71
  while (!fs.existsSync(socketPath)) {
38
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);
39
77
  throw new Error(
40
78
  `CDP bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
41
- `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}).`,
42
80
  );
43
81
  }
44
82
  await new Promise((r) => setTimeout(r, 50));
@@ -63,3 +101,74 @@ export function stopBridge(pid: number | null | undefined, socketPath: string |
63
101
  }
64
102
  }
65
103
  }
104
+
105
+ /**
106
+ * Spawns the same detached `__bridge-serve` entrypoint in recorder mode:
107
+ * it connects to one tab (`targetId`) instead of the browser level, and
108
+ * drives `capture motion rec`'s screencast/tracing/observers instead of
109
+ * proxying arbitrary `--browser` CDP calls. Mirrors `startBridge()`'s
110
+ * spawn + "wait for the socket file to appear" pattern; the caller
111
+ * (U14's lifecycle routing) tears it down with the same `stopBridge()`
112
+ * used for the plain held bridge.
113
+ *
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.
128
+ */
129
+ export async function startRecorderBridge(
130
+ socketPath: string,
131
+ port: number,
132
+ targetId: string,
133
+ recDir: string,
134
+ harId: string,
135
+ timeoutMs = 5000,
136
+ ): Promise<{ socketPath: string; pid: number }> {
137
+ const scriptPath = process.argv[1];
138
+ const child = spawn(
139
+ process.execPath,
140
+ [
141
+ scriptPath,
142
+ '__bridge-serve',
143
+ '--socket',
144
+ socketPath,
145
+ '--port',
146
+ String(port),
147
+ '--target',
148
+ targetId,
149
+ 'recorder',
150
+ recDir,
151
+ harId,
152
+ ],
153
+ { detached: true, stdio: 'ignore' },
154
+ );
155
+ child.unref();
156
+ const pid = child.pid;
157
+ if (!pid) {
158
+ throw new Error('Failed to spawn recorder bridge process.');
159
+ }
160
+
161
+ const start = Date.now();
162
+ while (!fs.existsSync(socketPath)) {
163
+ if (Date.now() - start > timeoutMs) {
164
+ stopBridge(pid, socketPath);
165
+ throw new Error(
166
+ `Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
167
+ `Check that target "${targetId}" is reachable on port ${port}.`,
168
+ );
169
+ }
170
+ await new Promise((r) => setTimeout(r, 50));
171
+ }
172
+
173
+ return { socketPath, pid };
174
+ }
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,16 +1,47 @@
1
1
  import { runBridgeServer } from '../bridge/server.js';
2
+ import { runRecorderBridge } from '../recorder-bridge.js';
3
+ import { invalidInput } from '../../errors.js';
2
4
  import { type ParsedArgs } from '../types.js';
3
5
 
4
6
  /**
5
7
  * Hidden entrypoint spawned detached by `capture session start --hold`
6
- * (see `bridge/spawn.ts`). Not documented in `capture --help` \u2014 nothing
7
- * calls this directly.
8
+ * (see `bridge/spawn.ts`'s `startBridge()`) or by `capture motion rec
9
+ * --start` in recorder mode (`startRecorderBridge()`). Not documented in
10
+ * `capture --help` — nothing calls this directly.
11
+ *
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.
8
20
  */
9
21
  export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
10
22
  if (!parsed.socket) {
11
- console.error('Usage: capture __bridge-serve --socket <path> [--port <cdpPort>]');
12
- process.exit(1);
23
+ throw invalidInput(
24
+ 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir> <harId>]',
25
+ 'bridge_serve_usage',
26
+ );
13
27
  }
28
+
29
+ if (parsed.positional[0] === 'recorder') {
30
+ const recDir = parsed.positional[1];
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',
36
+ );
37
+ }
38
+ await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir, harId });
39
+ // Deliberately does not exit: the open Unix socket server and the live
40
+ // tab websocket keep this detached process alive until an authenticated
41
+ // `rec-stop` completes (self-exit) or the caller escalates with SIGTERM.
42
+ return;
43
+ }
44
+
14
45
  await runBridgeServer(parsed.socket, parsed.port);
15
46
  // Deliberately does not exit: the open Unix socket server and the live
16
47
  // browser websocket keep this detached process alive until `session stop`