@crouton-kit/capture 1.3.6 → 1.3.8

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 (43) hide show
  1. package/bin/capture +3269 -1281
  2. package/package.json +4 -1
  3. package/src/capture.ts +30 -0
  4. package/src/cdp/args.ts +113 -10
  5. package/src/cdp/bridge/client.ts +49 -0
  6. package/src/cdp/bridge/protocol.ts +137 -0
  7. package/src/cdp/bridge/server.ts +241 -0
  8. package/src/cdp/bridge/spawn.ts +127 -0
  9. package/src/cdp/client.ts +7 -1
  10. package/src/cdp/commands/bridge-serve.ts +43 -0
  11. package/src/cdp/commands/cdp.ts +203 -0
  12. package/src/cdp/commands/measure/census.ts +31 -0
  13. package/src/cdp/commands/measure/check.ts +35 -0
  14. package/src/cdp/commands/measure/diff.ts +32 -0
  15. package/src/cdp/commands/measure/explain.ts +31 -0
  16. package/src/cdp/commands/measure/index.ts +114 -0
  17. package/src/cdp/commands/measure/map-focus.ts +24 -0
  18. package/src/cdp/commands/measure/map-layers.ts +25 -0
  19. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  20. package/src/cdp/commands/measure/snap.ts +36 -0
  21. package/src/cdp/commands/measure/sweep.ts +30 -0
  22. package/src/cdp/commands/motion/index.ts +60 -0
  23. package/src/cdp/commands/motion/jank.ts +22 -0
  24. package/src/cdp/commands/motion/mask.ts +23 -0
  25. package/src/cdp/commands/motion/rec.ts +33 -0
  26. package/src/cdp/commands/motion/response.ts +26 -0
  27. package/src/cdp/commands/motion/timeline.ts +27 -0
  28. package/src/cdp/coordinates.ts +158 -0
  29. package/src/cdp/dispatch.ts +8 -0
  30. package/src/cdp/domains.ts +75 -0
  31. package/src/cdp/recorder-bridge.ts +521 -0
  32. package/src/cdp/source-map.ts +309 -0
  33. package/src/cdp/targets.ts +8 -1
  34. package/src/cdp/timing.ts +82 -0
  35. package/src/cdp/types.ts +71 -0
  36. package/src/cdp/usage.ts +9 -0
  37. package/src/output/artifact.ts +520 -0
  38. package/src/output/diff.ts +111 -0
  39. package/src/output/render.ts +593 -0
  40. package/src/output/selector.ts +185 -0
  41. package/src/session/artifacts.ts +229 -0
  42. package/src/session/commands.ts +183 -20
  43. package/src/session-context.ts +50 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
@@ -33,6 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "devDependencies": {
36
+ "@types/pngjs": "^6.0.5",
36
37
  "@types/ws": "^8.18.1",
37
38
  "esbuild": "^0.24.0",
38
39
  "tsx": "^4",
@@ -40,6 +41,8 @@
40
41
  "zod": "^4.3.5"
41
42
  },
42
43
  "dependencies": {
44
+ "pixelmatch": "7.2.0",
45
+ "pngjs": "7.0.0",
43
46
  "ws": "^8.19.0"
44
47
  }
45
48
  }
package/src/capture.ts CHANGED
@@ -72,6 +72,10 @@ async function main(): Promise<void> {
72
72
  case "lib":
73
73
  case "network":
74
74
  case "list":
75
+ case "cdp":
76
+ case "measure":
77
+ case "motion":
78
+ case "__bridge-serve":
75
79
  return cdpMain();
76
80
 
77
81
  default:
@@ -116,6 +120,14 @@ INTERACTION COMMANDS (work inside or outside a session)
116
120
  exec <code> | exec --file <path> Evaluate JS; expressions, return, and await are supported
117
121
  navigate <url> [--settle <ms>] Navigate the current tab + record HAR
118
122
 
123
+ MEASUREMENT & MOTION
124
+
125
+ measure snap|check|diff|census|explain|sweep|map Enriched snapshot substrate + read-only queries
126
+ motion rec|mask|timeline|jank|response Recorder lifecycle + read-only queries over a finalized recording
127
+
128
+ capture measure --help Full leaf reference (input constraints per leaf)
129
+ capture motion --help Full leaf reference (input constraints per leaf)
130
+
119
131
  DIAGNOSTICS & ONE-OFFS (no session needed)
120
132
 
121
133
  detect Find running CDP endpoints
@@ -126,6 +138,24 @@ DIAGNOSTICS & ONE-OFFS (no session needed)
126
138
  network <offline|online> Toggle connectivity for a tab
127
139
  har create | read [id] | delete <id> Manage standalone HAR recordings
128
140
 
141
+ RAW CDP ESCAPE HATCH
142
+
143
+ cdp <Domain.method> [--params json] Send any CDP command capture doesn't wrap
144
+ [--browser] Target the browser connection (Browser.*, ServiceWorker.*)
145
+ [--wait-event name] Wait for a CDP event, e.g. after ServiceWorker.enable
146
+
147
+ Browser-level state (permission grants, ServiceWorker enablement) reverts
148
+ the instant its connection closes. session start --hold keeps ONE browser
149
+ connection open across commands so that state survives between them:
150
+
151
+ capture session start --url http://localhost:3000 --hold
152
+ capture cdp Browser.grantPermissions --browser --params '{"origin":"...","permissions":["notifications"]}'
153
+ capture cdp ServiceWorker.enable --browser --target <pageTabId>
154
+ capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
155
+ capture session stop <session-id>
156
+
157
+ capture cdp --help Full reference
158
+
129
159
  LIBRARY (vault libs — dev checkout only)
130
160
 
131
161
  lib list List available libs
package/src/cdp/args.ts CHANGED
@@ -64,7 +64,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
64
64
  parsed.target = next;
65
65
  i++;
66
66
  } else if (arg === '--url' && next) {
67
+ // Kept last-wins for every existing command's single-target semantics.
68
+ // Also accumulated into `urls` so census/sweep-style leaves can accept
69
+ // the flag repeated for a multi-page set without disturbing that.
67
70
  parsed.url = next;
71
+ (parsed.urls ??= []).push(next);
68
72
  i++;
69
73
  } else if (arg === '--role' && next) {
70
74
  parsed.role = next;
@@ -75,7 +79,12 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
75
79
  } else if (arg === '--no-screenshot') {
76
80
  parsed.noScreenshot = true;
77
81
  } else if (arg === '--viewport' && next) {
82
+ // Kept last-wins for every existing command's single-value semantics.
83
+ // Also accumulated into `viewports` so check/snap/census-style leaves
84
+ // can accept the flag repeated for a multi-viewport sweep, mirroring
85
+ // the `--url` → { url, urls[] } pattern above.
78
86
  parsed.viewport = next;
87
+ (parsed.viewports ??= []).push(next);
79
88
  i++;
80
89
  } else if (arg === '--full-page') {
81
90
  parsed.fullPage = true;
@@ -94,6 +103,93 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
94
103
  } else if (arg === '--limit' && next) {
95
104
  parsed.limit = parseInt(next, 10);
96
105
  i++;
106
+ } else if (arg === '--browser') {
107
+ parsed.browser = true;
108
+ } else if (arg === '--params' && next) {
109
+ parsed.params = next;
110
+ i++;
111
+ } else if (arg === '--wait-event' && next) {
112
+ parsed.waitEvent = next;
113
+ i++;
114
+ } else if (arg === '--timeout' && next) {
115
+ parsed.timeoutMs = parseInt(next, 10);
116
+ i++;
117
+ } else if (arg === '--socket' && next) {
118
+ parsed.socket = next;
119
+ i++;
120
+ } else if (arg === '--freeze-animations') {
121
+ parsed.freezeAnimations = true;
122
+ } else if (arg === '--settle-timeout' && next) {
123
+ parsed.settleTimeout = parseInt(next, 10);
124
+ i++;
125
+ } else if (arg === '--capture-unsettled') {
126
+ parsed.captureUnsettled = true;
127
+ } else if (arg === '--pixels') {
128
+ parsed.pixels = true;
129
+ } else if (arg === '--state' && next) {
130
+ (parsed.state ??= []).push(next);
131
+ i++;
132
+ } else if (arg === '--for' && next) {
133
+ parsed.for = next;
134
+ i++;
135
+ } else if (arg === '--before' && next) {
136
+ parsed.before = next;
137
+ i++;
138
+ } else if (arg === '--after' && next) {
139
+ parsed.after = next;
140
+ i++;
141
+ } else if (arg === '--full') {
142
+ parsed.full = true;
143
+ } else if (arg === '--gate') {
144
+ parsed.gate = true;
145
+ } else if (arg === '--snap' && next) {
146
+ (parsed.snap ??= []).push(next);
147
+ i++;
148
+ } else if (arg === '--set-file' && next) {
149
+ parsed.setFile = next;
150
+ i++;
151
+ } else if (arg === '--axis' && next) {
152
+ parsed.axis = next;
153
+ i++;
154
+ } else if (arg === '--from' && next) {
155
+ // Raw string — axis units vary (width px / dpr float / zoom); parseInt
156
+ // would lossily truncate a non-integer axis value.
157
+ parsed.from = next;
158
+ i++;
159
+ } else if (arg === '--to' && next) {
160
+ parsed.to = next;
161
+ i++;
162
+ } else if (arg === '--viewport-height' && next) {
163
+ parsed.viewportHeight = next;
164
+ i++;
165
+ } else if (arg === '--rec-id' && next) {
166
+ parsed.recId = next;
167
+ i++;
168
+ } else if (arg === '--selector' && next) {
169
+ parsed.selector = next;
170
+ i++;
171
+ } else if (arg === '--size') {
172
+ parsed.size = true;
173
+ } else if (arg === '--text') {
174
+ parsed.text = true;
175
+ } else if (arg === '--form') {
176
+ parsed.form = true;
177
+ } else if (arg === '--start') {
178
+ parsed.start = true;
179
+ } else if (arg === '--stop') {
180
+ parsed.stop = true;
181
+ } else if (arg === '--do' && next) {
182
+ parsed.do = next;
183
+ i++;
184
+ } else if (arg === '--element' && next) {
185
+ parsed.element = next;
186
+ i++;
187
+ } else if (arg === '--prop' && next) {
188
+ parsed.prop = next;
189
+ i++;
190
+ } else if (arg === '--action' && next) {
191
+ parsed.action = next;
192
+ i++;
97
193
  } else if (arg === '--help' || arg === '-h') {
98
194
  parsed.help = true;
99
195
  } else if (arg.startsWith('--')) {
@@ -104,7 +200,23 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
104
200
  }
105
201
  }
106
202
 
107
- // Fill gaps from environment variables (set by pipeline orchestrators)
203
+ // Fill gaps from the active session first. A session is an explicit,
204
+ // scoped choice the caller made (`capture session start`), so its
205
+ // target/har must win over ambient CDP_PORT/CDP_TARGET/CDP_HAR_ID env vars
206
+ // — those are meant for orchestrators only when NOT in a session (see
207
+ // `capture --help` TARGETING section). Letting a stale/inherited env var
208
+ // outrank an active session is how a command silently ends up on the
209
+ // wrong tab instead of the session's own.
210
+ const session = getActiveSession();
211
+ if (session) {
212
+ if (!parsed.har && session.harId) parsed.har = session.harId;
213
+ // An explicit --url picks a different (parallel) tab than the session's
214
+ // own — don't let the session's targetId clobber that choice.
215
+ if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
216
+ }
217
+
218
+ // Fill any still-empty gaps from environment variables (set by pipeline
219
+ // orchestrators that aren't using the session concept).
108
220
  if (!parsed.port && process.env.CDP_PORT) {
109
221
  const envPort = Number.parseInt(process.env.CDP_PORT, 10);
110
222
  if (Number.isNaN(envPort)) {
@@ -120,14 +232,5 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
120
232
  parsed.har = process.env.CDP_HAR_ID;
121
233
  }
122
234
 
123
- // Fill remaining gaps from active session context (explicit flags always win)
124
- const session = getActiveSession();
125
- if (session) {
126
- if (!parsed.har && session.harId) parsed.har = session.harId;
127
- // An explicit --url picks a different (parallel) tab than the session's
128
- // own — don't let the session's targetId clobber that choice.
129
- if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
130
- }
131
-
132
235
  return parsed as ParsedArgs;
133
236
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client side of the bridge protocol \u2014 used by `capture cdp --browser` to
3
+ * relay one request to an already-running bridge server over its socket.
4
+ */
5
+
6
+ import * as net from 'net';
7
+ import { type BridgeRequest, type BridgeResponse } from './protocol.js';
8
+
9
+ let reqCounter = 0;
10
+
11
+ export function sendBridgeRequest(
12
+ socketPath: string,
13
+ req: Omit<BridgeRequest, 'reqId'>,
14
+ ): Promise<BridgeResponse> {
15
+ const wireTimeoutMs = (req.timeoutMs ?? 10000) + 5000;
16
+ return new Promise((resolve, reject) => {
17
+ const reqId = ++reqCounter;
18
+ const socket = net.createConnection(socketPath);
19
+ let buffer = '';
20
+
21
+ const timer = setTimeout(() => {
22
+ socket.destroy();
23
+ reject(new Error(`Bridge request timed out after ${wireTimeoutMs}ms (socket: ${socketPath})`));
24
+ }, wireTimeoutMs);
25
+
26
+ socket.on('connect', () => {
27
+ socket.write(JSON.stringify({ ...req, reqId } satisfies BridgeRequest) + '\n');
28
+ });
29
+
30
+ socket.on('data', (chunk) => {
31
+ buffer += chunk.toString('utf-8');
32
+ const idx = buffer.indexOf('\n');
33
+ if (idx < 0) return;
34
+ clearTimeout(timer);
35
+ const line = buffer.slice(0, idx);
36
+ socket.end();
37
+ try {
38
+ resolve(JSON.parse(line) as BridgeResponse);
39
+ } catch (err) {
40
+ reject(err instanceof Error ? err : new Error(String(err)));
41
+ }
42
+ });
43
+
44
+ socket.on('error', (err) => {
45
+ clearTimeout(timer);
46
+ reject(err);
47
+ });
48
+ });
49
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Wire protocol between `capture cdp --browser` and the held bridge process
3
+ * (`capture __bridge-serve`). Newline-delimited JSON over a Unix domain
4
+ * socket — one request per connection, one response, then the socket closes.
5
+ */
6
+
7
+ export interface BridgeRequest {
8
+ reqId: number;
9
+ /** CDP method, e.g. "Browser.grantPermissions". Omit when only waiting for an event. */
10
+ method?: string;
11
+ params?: Record<string, unknown>;
12
+ /**
13
+ * Target (page or worker) to scope this call to, via a flattened
14
+ * `Target.attachToTarget` session on the SAME held browser websocket.
15
+ * Required for domains that live on a target rather than the browser
16
+ * itself (`ServiceWorker.*`, `Page.*`, ...) — omit for pure browser-level
17
+ * domains (`Browser.*`, `Target.*`).
18
+ */
19
+ targetId?: string;
20
+ /** CDP event name to wait for (consumes the next occurrence, FIFO, buffered if it already fired). */
21
+ waitEvent?: string;
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ export interface BridgeResponseOk {
26
+ reqId: number;
27
+ ok: true;
28
+ result?: unknown;
29
+ event?: unknown;
30
+ }
31
+
32
+ export interface BridgeResponseErr {
33
+ reqId: number;
34
+ ok: false;
35
+ error: string;
36
+ }
37
+
38
+ export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Recorder-mode protocol — the SAME NDJSON-over-unix-socket wire format as
42
+ // above, specialized for `capture motion rec` (see ../recorder-bridge.ts).
43
+ // The generic `BridgeRequest`/`BridgeResponse` are unchanged and still
44
+ // govern `capture cdp --browser` / `session start --hold`; a recorder-mode
45
+ // bridge process (spawned in recorder mode, see ../bridge/spawn.ts) speaks
46
+ // this union instead, one request per connection, one response, same as
47
+ // the plain bridge.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** One synchronized (performance.now, wall-clock) pair, captured when the recorder arms. */
51
+ export interface RecorderClockBaselines {
52
+ performanceNowMs: number;
53
+ wallClockMs: number;
54
+ }
55
+
56
+ /**
57
+ * Arms the recorder on its held tab connection: enables the motion-rec CDP
58
+ * domains, starts `Page.startScreencast` + `Tracing`, injects the
59
+ * Mutation/Resize/Performance observers, and captures the clock baseline.
60
+ * A recorder process only accepts one `rec-start` while `state==="idle"`.
61
+ */
62
+ export interface RecStartRequest {
63
+ reqId: number;
64
+ type: 'rec-start';
65
+ }
66
+
67
+ export interface RecStartResponseOk {
68
+ reqId: number;
69
+ ok: true;
70
+ type: 'rec-start';
71
+ /** Returned for the caller to persist into `markers.json` — the recorder itself does not write that file. */
72
+ markers: RecorderClockBaselines;
73
+ }
74
+
75
+ /**
76
+ * Stops screencast + tracing, flushes the injected observers, and tears
77
+ * down the recorder's subscriptions (not the socket — the caller closes
78
+ * the bridge process once it has read the response). Returns counts for
79
+ * the caller's `meta.json`; the recorder itself does not write that file.
80
+ */
81
+ export interface RecStopRequest {
82
+ reqId: number;
83
+ type: 'rec-stop';
84
+ }
85
+
86
+ export interface RecStopResponseOk {
87
+ reqId: number;
88
+ ok: true;
89
+ type: 'rec-stop';
90
+ frameCount: number;
91
+ eventCount: number;
92
+ durationMs: number;
93
+ }
94
+
95
+ /**
96
+ * A CDP request routed through the recorder's held tab connection — the
97
+ * mechanism intervening session commands (`click`, `type`, `navigate`, ...)
98
+ * use during a composed recording. Setting `mark` brackets the dispatch
99
+ * with two performance.now() reads taken in the page's execution context
100
+ * (`../timing.ts`'s `withDocumentPerformanceNow`) and appends a labeled
101
+ * input-landmark record to `events.jsonl`, tying the action to the frame
102
+ * timeline — this is the "marked CDP request message" the protocol
103
+ * supports. Omit `mark` for a plain passthrough call (still observed
104
+ * natively by the recorder's own event subscriptions, just not logged as a
105
+ * distinct input landmark).
106
+ */
107
+ export interface RecCdpRequest {
108
+ reqId: number;
109
+ type: 'cdp';
110
+ method: string;
111
+ params?: Record<string, unknown>;
112
+ mark?: string;
113
+ waitEvent?: string;
114
+ timeoutMs?: number;
115
+ }
116
+
117
+ export interface RecCdpResponseOk {
118
+ reqId: number;
119
+ ok: true;
120
+ type: 'cdp';
121
+ result?: unknown;
122
+ event?: unknown;
123
+ }
124
+
125
+ export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest;
126
+
127
+ export interface RecorderResponseErr {
128
+ reqId: number;
129
+ ok: false;
130
+ type: RecorderRequest['type'];
131
+ error: string;
132
+ }
133
+
134
+ export type RecStartResponse = RecStartResponseOk | RecorderResponseErr;
135
+ export type RecStopResponse = RecStopResponseOk | RecorderResponseErr;
136
+ export type RecCdpResponse = RecCdpResponseOk | RecorderResponseErr;
137
+ export type RecorderResponse = RecStartResponse | RecStopResponse | RecCdpResponse;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The bridge server: holds ONE browser-level CDP connection open for the
3
+ * lifetime of a held session and exposes it to (many, sequential) `capture
4
+ * cdp --browser` invocations over a Unix socket.
5
+ *
6
+ * Why this exists: `Browser.grantPermissions`, `ServiceWorker.enable`, and
7
+ * other browser-scoped CDP state are per-CLIENT and revert the instant the
8
+ * granting websocket disconnects. `capture` normally opens/closes a fresh
9
+ * websocket per command, so that state was gone by the next command. Running
10
+ * this as a long-lived detached process is what keeps ONE websocket (and
11
+ * therefore that state) alive across many separate `capture` invocations.
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import * as net from 'net';
16
+ import * as path from 'path';
17
+ import { getBrowserClient, findTabById } from '../targets.js';
18
+ import { type CDPClient } from '../client.js';
19
+ import { type BridgeRequest, type BridgeResponse } from './protocol.js';
20
+
21
+ interface EventWaiter {
22
+ resolve: (v: unknown) => void;
23
+ }
24
+
25
+ /**
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.
32
+ */
33
+ export class EventBroker {
34
+ private queues = new Map<string, unknown[]>();
35
+ private waiters = new Map<string, EventWaiter[]>();
36
+ private listening = new Set<string>();
37
+
38
+ constructor(private client: Pick<CDPClient, 'on'>) {}
39
+
40
+ private ensureListening(eventName: string): void {
41
+ if (this.listening.has(eventName)) return;
42
+ 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);
54
+ });
55
+ }
56
+
57
+ wait(eventName: string, timeoutMs: number): Promise<unknown> {
58
+ this.ensureListening(eventName);
59
+ const q = this.queues.get(eventName);
60
+ if (q && q.length > 0) {
61
+ return Promise.resolve(q.shift());
62
+ }
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);
81
+ });
82
+ }
83
+ }
84
+
85
+ export async function runBridgeServer(socketPath: string, port?: number): Promise<void> {
86
+ const resolvedPort = await resolvePort(port);
87
+ const { client } = await getBrowserClient(resolvedPort);
88
+ const events = new EventBroker(client);
89
+
90
+ // Cache flattened Target.attachToTarget sessions so repeated requests
91
+ // against the same target (e.g. ServiceWorker.enable then
92
+ // ServiceWorker.deliverPushMessage) reuse one attach instead of piling up.
93
+ const targetSessions = new Map<string, string>();
94
+ async function attach(targetId: string): Promise<string> {
95
+ const cached = targetSessions.get(targetId);
96
+ if (cached) return cached;
97
+ // Accept the same 8-char-prefix targeting every other capture command
98
+ // promises (see the top-level --help TARGETING section) instead of
99
+ // requiring the full 32-char target id here.
100
+ const tab = await findTabById(resolvedPort, targetId);
101
+ if (!tab) {
102
+ throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture list" to see available tabs.`);
103
+ }
104
+ const result = (await client.send('Target.attachToTarget', { targetId: tab.id, flatten: true })) as {
105
+ sessionId: string;
106
+ };
107
+ targetSessions.set(targetId, result.sessionId);
108
+ return result.sessionId;
109
+ }
110
+
111
+ async function handleLine(line: string, socket: net.Socket): Promise<void> {
112
+ let req: BridgeRequest;
113
+ try {
114
+ req = JSON.parse(line);
115
+ } catch {
116
+ return;
117
+ }
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
+ }
128
+ socket.write(JSON.stringify(resp) + '\n');
129
+ }
130
+
131
+ const server = await listenNdjsonSocket(socketPath, handleLine);
132
+
133
+ const cleanup = (): void => {
134
+ closeNdjsonSocket(server, socketPath);
135
+ try {
136
+ client.close();
137
+ } catch {
138
+ // Already closed.
139
+ }
140
+ };
141
+ installProcessCleanup(cleanup, client);
142
+ // Intentionally does not resolve further work here: the open server and
143
+ // the live websocket keep the event loop (and this detached process) alive
144
+ // until `stopBridge()` sends SIGTERM.
145
+ }
146
+
147
+ /**
148
+ * Ensures `socketPath`'s parent directory exists and removes any stale
149
+ * socket file left at that path (a previous bridge process that died
150
+ * without cleaning up). Shared preparation step before binding a new
151
+ * `net.Server` there.
152
+ */
153
+ export function prepareSocketPath(socketPath: string): void {
154
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
155
+ try {
156
+ fs.unlinkSync(socketPath);
157
+ } catch {
158
+ // No stale socket to remove.
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Binds a Unix-domain `net.Server` at `socketPath` that frames each
164
+ * connection's input as newline-delimited JSON, invoking `handleLine` once
165
+ * per complete line. This is the wire framing every bridge mode (plain
166
+ * browser-level bridge, recorder bridge) shares — "one request per
167
+ * connection, one response, then the socket closes" is a convention the
168
+ * caller's `handleLine` implements by writing exactly one response per
169
+ * line and letting the client end the connection; the server itself is
170
+ * agnostic to that convention and would happily frame a connection that
171
+ * sends multiple lines.
172
+ */
173
+ export async function listenNdjsonSocket(
174
+ socketPath: string,
175
+ handleLine: (line: string, socket: net.Socket) => void | Promise<void>,
176
+ ): Promise<net.Server> {
177
+ prepareSocketPath(socketPath);
178
+
179
+ const server = net.createServer((socket) => {
180
+ let buffer = '';
181
+ socket.on('data', (chunk) => {
182
+ buffer += chunk.toString('utf-8');
183
+ let idx: number;
184
+ while ((idx = buffer.indexOf('\n')) >= 0) {
185
+ const line = buffer.slice(0, idx);
186
+ buffer = buffer.slice(idx + 1);
187
+ if (line.trim()) void handleLine(line, socket);
188
+ }
189
+ });
190
+ });
191
+
192
+ await new Promise<void>((resolve, reject) => {
193
+ server.on('error', reject);
194
+ server.listen(socketPath, () => resolve());
195
+ });
196
+ return server;
197
+ }
198
+
199
+ /** Closes the socket server and best-effort unlinks the socket file. */
200
+ export function closeNdjsonSocket(server: net.Server, socketPath: string): void {
201
+ try {
202
+ server.close();
203
+ } catch {
204
+ // Already closed.
205
+ }
206
+ try {
207
+ fs.unlinkSync(socketPath);
208
+ } catch {
209
+ // Already gone.
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Wires SIGTERM/SIGINT and the held client's disconnect into one `cleanup`
215
+ * call followed by `process.exit(0)` — the shutdown sequence every
216
+ * detached bridge process (plain or recorder) uses.
217
+ */
218
+ export function installProcessCleanup(
219
+ cleanup: () => void,
220
+ client: Pick<CDPClient, 'onDisconnect'>,
221
+ ): void {
222
+ process.on('SIGTERM', () => {
223
+ cleanup();
224
+ process.exit(0);
225
+ });
226
+ process.on('SIGINT', () => {
227
+ cleanup();
228
+ process.exit(0);
229
+ });
230
+ client.onDisconnect(() => {
231
+ // The browser (or its CDP endpoint) went away — nothing left to bridge.
232
+ cleanup();
233
+ process.exit(0);
234
+ });
235
+ }
236
+
237
+ async function resolvePort(port?: number): Promise<number> {
238
+ if (port) return port;
239
+ const { detectCdpPort } = await import('../detect.js');
240
+ return detectCdpPort();
241
+ }