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