@crouton-kit/capture 1.3.18 → 1.3.19
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 +4109 -3229
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +14 -2
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -1
- package/src/cdp/commands/measure/snap.ts +37 -2
- package/src/cdp/commands/motion/mask.ts +8 -2
- package/src/cdp/commands/page/click.ts +14 -0
- package/src/cdp/commands/page/elements.ts +2 -2
- package/src/cdp/commands/page/shot.ts +26 -5
- package/src/cdp/commands/tab/index.ts +19 -8
- package/src/cdp/commands/tab/launch.ts +73 -0
- package/src/cdp/commands/tab/list.ts +14 -2
- package/src/cdp/commands/tab/quit.ts +78 -0
- package/src/cdp/har-recorder.ts +14 -20
- package/src/cdp/measure/collectors/ax.ts +3 -4
- package/src/cdp/measure/collectors/forms.ts +4 -6
- package/src/cdp/measure/collectors/media.ts +1 -2
- package/src/cdp/measure/collectors/states.ts +1 -6
- package/src/cdp/measure/collectors/style-provenance.ts +4 -16
- package/src/cdp/measure/collectors/styles.ts +21 -10
- package/src/cdp/measure/collectors/text.ts +3 -5
- package/src/cdp/measure/snapshot.ts +195 -29
- package/src/cdp/measure/types.ts +32 -2
- package/src/cdp/motion/jank.ts +8 -1
- package/src/cdp/screenshot.ts +83 -8
- package/src/cdp/targets.ts +5 -0
- package/src/cdp/types.ts +4 -0
- package/src/har-manager.ts +16 -15
- package/src/interact.ts +20 -4
- package/src/output/render.ts +2 -0
- package/src/registry.ts +1 -1
- package/src/session/commands.ts +223 -43
- package/src/session/har-redact.ts +125 -0
package/package.json
CHANGED
package/src/capture.ts
CHANGED
|
@@ -56,7 +56,10 @@ I/O contract: flags and positionals on input; one rendered prose block on
|
|
|
56
56
|
stdout. --json mirrors the same result as JSON, but the rendered block is
|
|
57
57
|
the contract. stderr carries in-flight diagnostics only.
|
|
58
58
|
|
|
59
|
-
A CDP-enabled browser must be running; \`capture tab list\` is the probe
|
|
59
|
+
A CDP-enabled browser must be running; \`capture tab list\` is the probe, and
|
|
60
|
+
\`capture tab launch\` starts one capture owns and reaps when nothing is (never
|
|
61
|
+
hand-roll a detached browser — nothing reaps that). Capture never signals a
|
|
62
|
+
browser it did not start, so attaching to your own with --port is unchanged.
|
|
60
63
|
CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators — precedence:
|
|
61
64
|
explicit flag > active session > env.`;
|
|
62
65
|
}
|
package/src/cdp/args.ts
CHANGED
|
@@ -60,7 +60,7 @@ function validateKnownLeaf(command: string, positional: readonly string[]): void
|
|
|
60
60
|
const branches: Record<string, readonly string[]> = {
|
|
61
61
|
session: ['start', 'stop', 'list', 'view', 'har', 'log'],
|
|
62
62
|
page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements'],
|
|
63
|
-
tab: ['list', 'open', 'close', 'reset', 'network'],
|
|
63
|
+
tab: ['launch', 'quit', 'list', 'open', 'close', 'reset', 'network'],
|
|
64
64
|
measure: ['snap', 'check', 'diff', 'census', 'explain', 'sweep', 'map'],
|
|
65
65
|
motion: ['rec', 'mask', 'timeline', 'jank', 'response'],
|
|
66
66
|
lib: ['list', 'search', 'show', 'read'],
|
|
@@ -130,7 +130,11 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
if (parsed.command === 'tab') {
|
|
133
|
-
|
|
133
|
+
// `launch` takes no target at all (its browser does not exist yet) and
|
|
134
|
+
// `quit` takes an optional port; every other tab leaf names exactly one.
|
|
135
|
+
if (leaf === 'launch' || leaf === 'list') requireCount(values, 0, 0, `tab ${leaf}`);
|
|
136
|
+
else if (leaf === 'quit') requireCount(values, 0, 1, 'tab quit');
|
|
137
|
+
else requireCount(values, 1, 1, `tab ${leaf}`);
|
|
134
138
|
if (leaf === 'network' && values[0] !== 'offline' && values[0] !== 'online') {
|
|
135
139
|
throw invalidInput('tab network requires offline or online.');
|
|
136
140
|
}
|
|
@@ -223,6 +227,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
|
|
|
223
227
|
else if (arg === '--settle') { parsed.settle = integer(valueFor(arg, next), '--settle', 0, 2_147_483_647); i++; }
|
|
224
228
|
else if (arg === '--file') { parsed.file = valueFor(arg, next); i++; }
|
|
225
229
|
else if (arg === '--new') parsed.new = true;
|
|
230
|
+
else if (arg === '--headed') parsed.headed = true;
|
|
226
231
|
else if (arg === '--target') { parsed.target = valueFor(arg, next); parsed.targetSource = 'flag'; i++; }
|
|
227
232
|
else if (arg === '--url') { const value = valueFor(arg, next); parsed.url = value; (parsed.urls ??= []).push(value); i++; }
|
|
228
233
|
else if (arg === '--into') { parsed.into = valueFor(arg, next); i++; }
|
|
@@ -246,6 +251,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
|
|
|
246
251
|
else if (arg === '--freeze-animations') parsed.freezeAnimations = true;
|
|
247
252
|
else if (arg === '--settle-timeout') { parsed.settleTimeout = integer(valueFor(arg, next), '--settle-timeout', 1, 2_147_483_647); i++; }
|
|
248
253
|
else if (arg === '--capture-unsettled') parsed.captureUnsettled = true;
|
|
254
|
+
else if (arg === '--skip-collector') { (parsed.skipCollector ??= []).push(valueFor(arg, next)); i++; }
|
|
249
255
|
else if (arg === '--pixels') parsed.pixels = true;
|
|
250
256
|
else if (arg === '--state') { (parsed.state ??= []).push(valueFor(arg, next)); i++; }
|
|
251
257
|
else if (arg === '--for') { parsed.for = valueFor(arg, next); i++; }
|
|
@@ -294,6 +300,12 @@ function needsEndpointResolution(parsed: ParsedArgs): boolean {
|
|
|
294
300
|
&& (parsed.positional.length === 0 || (parsed.command === 'measure' && parsed.positional.length === 1 && parsed.positional[0] === 'map'));
|
|
295
301
|
if (branchOnly) return false;
|
|
296
302
|
if (parsed.command === 'session' && parsed.positional[0] !== 'start') return false;
|
|
303
|
+
// `tab launch` CREATES an endpoint and `tab quit` addresses capture's own
|
|
304
|
+
// registry, so neither may inherit one: an ambient CDP_PORT (or a session's
|
|
305
|
+
// port) must never silently become the port a new browser is told to bind,
|
|
306
|
+
// nor the browser a quit reaches. Only an explicit --port flag, parsed in
|
|
307
|
+
// `parseCliSyntax`, applies to these two.
|
|
308
|
+
if (parsed.command === 'tab' && (parsed.positional[0] === 'launch' || parsed.positional[0] === 'quit')) return false;
|
|
297
309
|
return true;
|
|
298
310
|
}
|
|
299
311
|
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browsers capture itself starts, and the only place capture is allowed to
|
|
3
|
+
* signal one.
|
|
4
|
+
*
|
|
5
|
+
* capture's whole surface attaches to a CDP endpoint it does not own, so before
|
|
6
|
+
* this module the only way to get an endpoint was to hand-roll a detached
|
|
7
|
+
* Chrome (`chrome --remote-debugging-port=9334 --user-data-dir=/tmp/whatever &`)
|
|
8
|
+
* with an invented port and an invented scratch profile. Nothing reaped those:
|
|
9
|
+
* the spawning shell exits, the browser is detached, and it survives — one
|
|
10
|
+
* ~1GB resident orphan per agent turn, forever.
|
|
11
|
+
*
|
|
12
|
+
* `capture tab launch` replaces that with a browser capture owns end to end: a
|
|
13
|
+
* profile under `CAPTURE_ROOT`, a kernel-chosen free port, and a registry
|
|
14
|
+
* record naming the pid it started. Ownership is the whole point of the
|
|
15
|
+
* module, so it is enforced structurally, not by convention:
|
|
16
|
+
*
|
|
17
|
+
* - Capture signals a process ONLY when a registry record names it AND the
|
|
18
|
+
* live PID-birth identity still matches the recorded one (`sameBirth`, the
|
|
19
|
+
* same gate the private-lock owner check uses). A recycled pid can never
|
|
20
|
+
* make capture kill a stranger's process.
|
|
21
|
+
* - An endpoint reached with `--port` — the user's own CDP-enabled browser,
|
|
22
|
+
* or anything a previous agent started by hand — has no record here, so it
|
|
23
|
+
* is never swept, never signalled, and never cleaned up. Attaching to a
|
|
24
|
+
* browser capture did not start behaves exactly as it always has.
|
|
25
|
+
*
|
|
26
|
+
* Reaping has no daemon behind it. Three paths cover the lifecycle:
|
|
27
|
+
* 1. `tab quit` — explicit, immediate.
|
|
28
|
+
* 2. a launch that never reports readiness reaps the child it just spawned
|
|
29
|
+
* before it throws (the discipline `bridge/spawn.ts` already models).
|
|
30
|
+
* 3. `sweepOwnedBrowsers()`, run at the top of `tab launch`, `tab list`, and
|
|
31
|
+
* `tab quit` — drops records whose process is gone, and kills owned
|
|
32
|
+
* browsers that are unusable (endpoint unreachable) or idle past
|
|
33
|
+
* `IDLE_REAP_MS`. `tab list` is the probe agents already run constantly,
|
|
34
|
+
* so an abandoned browser is reaped by the next agent through the door
|
|
35
|
+
* rather than living until reboot.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { spawn } from 'node:child_process';
|
|
39
|
+
import * as crypto from 'node:crypto';
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as os from 'node:os';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
import { captureError } from '../errors.js';
|
|
44
|
+
import {
|
|
45
|
+
CAPTURE_ROOT,
|
|
46
|
+
assertUnderCaptureRoot,
|
|
47
|
+
ensurePrivateDir,
|
|
48
|
+
exactKeys,
|
|
49
|
+
parseBirth,
|
|
50
|
+
processPidBirthProvider,
|
|
51
|
+
readPrivateFile,
|
|
52
|
+
sameBirth,
|
|
53
|
+
unlinkPrivateFile,
|
|
54
|
+
writeJsonPrivate,
|
|
55
|
+
type PidBirth,
|
|
56
|
+
} from '../session/artifacts.js';
|
|
57
|
+
|
|
58
|
+
/** How long an owned browser may sit unused before a sweep reaps it. Documented
|
|
59
|
+
* in `capture tab launch -h`; deliberately a constant, not a flag — a knob here
|
|
60
|
+
* would just be one more thing for a caller to get wrong, and an explicit
|
|
61
|
+
* `capture tab quit` is always available for a shorter life. */
|
|
62
|
+
export const IDLE_REAP_MS = 30 * 60 * 1000;
|
|
63
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
64
|
+
const READY_POLL_MS = 50;
|
|
65
|
+
const ENDPOINT_PROBE_MS = 2_000;
|
|
66
|
+
const TERM_GRACE_MS = 3_000;
|
|
67
|
+
const KILL_GRACE_MS = 2_000;
|
|
68
|
+
const RECORD_TOKEN = /^[0-9a-f]{16}$/;
|
|
69
|
+
|
|
70
|
+
export type BrowserSource = 'CAPTURE_BROWSER' | 'puppeteer-cache' | 'system';
|
|
71
|
+
|
|
72
|
+
/** One launched browser, exactly as persisted at `CAPTURE_ROOT/browsers/<token>.json`.
|
|
73
|
+
* `port` is null only in the window between spawn and the readiness report — a
|
|
74
|
+
* record in that state is still a full ownership claim, so a capture process
|
|
75
|
+
* killed mid-launch still leaves the browser reapable by the next sweep. */
|
|
76
|
+
export interface OwnedBrowser {
|
|
77
|
+
version: 1;
|
|
78
|
+
token: string;
|
|
79
|
+
port: number | null;
|
|
80
|
+
pid: number;
|
|
81
|
+
birth: PidBirth;
|
|
82
|
+
profileDir: string;
|
|
83
|
+
executablePath: string;
|
|
84
|
+
source: BrowserSource;
|
|
85
|
+
headless: boolean;
|
|
86
|
+
startedAt: string;
|
|
87
|
+
lastUsedAt: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ReapedBrowser {
|
|
91
|
+
port: number | null;
|
|
92
|
+
pid: number;
|
|
93
|
+
/** `exited` — the process was already gone; `unreachable` — alive but not
|
|
94
|
+
* answering CDP, so useless to capture; `idle` — unused past IDLE_REAP_MS;
|
|
95
|
+
* `requested` — an explicit `tab quit`. */
|
|
96
|
+
reason: 'exited' | 'unreachable' | 'idle' | 'requested';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The registry's pathname only. Deliberately NOT `ensurePrivateDir` — reading
|
|
100
|
+
* the registry (which every sweep does, on every `tab list`) must not create
|
|
101
|
+
* anything: a capture root that has never launched a browser stays empty. The
|
|
102
|
+
* private artifact transaction behind `writeJsonPrivate` establishes this
|
|
103
|
+
* directory when the first record is actually written. */
|
|
104
|
+
function browsersDir(): string {
|
|
105
|
+
return path.join(CAPTURE_ROOT, 'browsers');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function recordPath(token: string): string {
|
|
109
|
+
return path.join(browsersDir(), `${token}.json`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function profilePath(token: string): string {
|
|
113
|
+
return path.join(CAPTURE_ROOT, 'browsers', 'profiles', token);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SOURCES: readonly BrowserSource[] = ['CAPTURE_BROWSER', 'puppeteer-cache', 'system'];
|
|
117
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
118
|
+
|
|
119
|
+
/** Strictly parses one persisted registry record; anything weaker is undefined
|
|
120
|
+
* (and therefore not an ownership claim, so nothing it names can be signalled). */
|
|
121
|
+
export function parseOwnedBrowser(value: unknown): OwnedBrowser | undefined {
|
|
122
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
123
|
+
const record = value as Record<string, unknown>;
|
|
124
|
+
const birth = parseBirth(record.birth);
|
|
125
|
+
const valid = birth !== undefined
|
|
126
|
+
&& exactKeys(record, ['version', 'token', 'port', 'pid', 'birth', 'profileDir', 'executablePath', 'source', 'headless', 'startedAt', 'lastUsedAt'])
|
|
127
|
+
&& record.version === 1
|
|
128
|
+
&& typeof record.token === 'string' && RECORD_TOKEN.test(record.token)
|
|
129
|
+
&& (record.port === null || (Number.isSafeInteger(record.port) && (record.port as number) >= 1 && (record.port as number) <= 65535))
|
|
130
|
+
&& Number.isSafeInteger(record.pid) && (record.pid as number) > 0
|
|
131
|
+
&& typeof record.profileDir === 'string' && record.profileDir === profilePath(record.token as string)
|
|
132
|
+
&& typeof record.executablePath === 'string' && path.isAbsolute(record.executablePath)
|
|
133
|
+
&& typeof record.source === 'string' && SOURCES.includes(record.source as BrowserSource)
|
|
134
|
+
&& typeof record.headless === 'boolean'
|
|
135
|
+
&& typeof record.startedAt === 'string' && ISO.test(record.startedAt)
|
|
136
|
+
&& typeof record.lastUsedAt === 'string' && ISO.test(record.lastUsedAt);
|
|
137
|
+
// `valid` already implies a parsed birth; the explicit disjunct is what
|
|
138
|
+
// narrows `birth` for the return type — do not "simplify" it away.
|
|
139
|
+
if (!valid || birth === undefined) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
version: 1,
|
|
142
|
+
token: record.token as string,
|
|
143
|
+
port: record.port as number | null,
|
|
144
|
+
pid: record.pid as number,
|
|
145
|
+
birth,
|
|
146
|
+
profileDir: record.profileDir as string,
|
|
147
|
+
executablePath: record.executablePath as string,
|
|
148
|
+
source: record.source as BrowserSource,
|
|
149
|
+
headless: record.headless as boolean,
|
|
150
|
+
startedAt: record.startedAt as string,
|
|
151
|
+
lastUsedAt: record.lastUsedAt as string,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Every well-formed registry record. A malformed or unreadable file names no
|
|
156
|
+
* process capture may signal, so it is dropped from the registry rather than
|
|
157
|
+
* interpreted. */
|
|
158
|
+
export function readOwnedBrowsers(): OwnedBrowser[] {
|
|
159
|
+
let entries: string[];
|
|
160
|
+
try {
|
|
161
|
+
entries = fs.readdirSync(browsersDir());
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const records: OwnedBrowser[] = [];
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.endsWith('.json') || !RECORD_TOKEN.test(entry.slice(0, -'.json'.length))) continue;
|
|
168
|
+
const file = path.join(browsersDir(), entry);
|
|
169
|
+
let parsed: OwnedBrowser | undefined;
|
|
170
|
+
try {
|
|
171
|
+
parsed = parseOwnedBrowser(JSON.parse(readPrivateFile(file).toString('utf-8')));
|
|
172
|
+
} catch {
|
|
173
|
+
parsed = undefined;
|
|
174
|
+
}
|
|
175
|
+
if (parsed) records.push(parsed);
|
|
176
|
+
else forgetRecordFile(file);
|
|
177
|
+
}
|
|
178
|
+
return records.sort((a, b) => (a.port ?? 0) - (b.port ?? 0));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function forgetRecordFile(file: string): void {
|
|
182
|
+
try {
|
|
183
|
+
unlinkPrivateFile(file);
|
|
184
|
+
} catch {
|
|
185
|
+
// Already gone, or not ours to remove — either way it names nothing we act on.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** True when the recorded process is still the process capture started. The
|
|
190
|
+
* ONLY gate that authorizes a signal; an `unknown` read (a provider that could
|
|
191
|
+
* not answer) is deliberately NOT a kill authorization. */
|
|
192
|
+
function stillOurs(record: OwnedBrowser): boolean {
|
|
193
|
+
const observed = processPidBirthProvider.read(record.pid);
|
|
194
|
+
return observed.status === 'found' && sameBirth(observed.identity, record.birth);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Removes an owned profile tree. Deliberately `fs.rmSync`, NOT the repo's
|
|
199
|
+
* pinned `removeArtifactTree`: Chrome writes `SingletonLock`/`SingletonSocket`
|
|
200
|
+
* (symlinks and unix sockets) into its user-data-dir, and the pinned remover
|
|
201
|
+
* refuses every non-regular, non-directory entry by design. The path is still
|
|
202
|
+
* proven to be inside `CAPTURE_ROOT` first, so this can only ever delete a
|
|
203
|
+
* profile capture created.
|
|
204
|
+
*/
|
|
205
|
+
function removeProfile(profileDir: string): void {
|
|
206
|
+
fs.rmSync(assertUnderCaptureRoot(profileDir), { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sleep(ms: number): Promise<void> {
|
|
210
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** SIGTERM, then SIGKILL if it is still alive, then forget it. Every signal is
|
|
214
|
+
* re-gated on `stillOurs` so a pid that dies and is recycled mid-reap cannot
|
|
215
|
+
* receive the escalation. */
|
|
216
|
+
async function reap(record: OwnedBrowser): Promise<void> {
|
|
217
|
+
for (const [signal, graceMs] of [['SIGTERM', TERM_GRACE_MS], ['SIGKILL', KILL_GRACE_MS]] as const) {
|
|
218
|
+
if (!stillOurs(record)) break;
|
|
219
|
+
try {
|
|
220
|
+
process.kill(record.pid, signal);
|
|
221
|
+
} catch {
|
|
222
|
+
break; // Gone between the birth check and the signal.
|
|
223
|
+
}
|
|
224
|
+
const deadline = Date.now() + graceMs;
|
|
225
|
+
while (Date.now() < deadline && stillOurs(record)) await sleep(READY_POLL_MS);
|
|
226
|
+
}
|
|
227
|
+
forgetRecordFile(recordPath(record.token));
|
|
228
|
+
removeProfile(record.profileDir);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function endpointAnswers(port: number): Promise<boolean> {
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
234
|
+
signal: AbortSignal.timeout(ENDPOINT_PROBE_MS),
|
|
235
|
+
});
|
|
236
|
+
return response.ok;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reconciles the registry with reality and reaps what no longer earns its
|
|
244
|
+
* memory. Run at the top of every `tab` leaf that touches owned browsers — this
|
|
245
|
+
* is what makes an abandoned browser die on its own instead of outliving the
|
|
246
|
+
* agent that started it.
|
|
247
|
+
*/
|
|
248
|
+
export async function sweepOwnedBrowsers(): Promise<ReapedBrowser[]> {
|
|
249
|
+
const reaped: ReapedBrowser[] = [];
|
|
250
|
+
for (const record of readOwnedBrowsers()) {
|
|
251
|
+
if (!stillOurs(record)) {
|
|
252
|
+
// Crashed, or SIGKILLed by someone else. Nothing to signal — just drop
|
|
253
|
+
// the claim and the profile it left behind.
|
|
254
|
+
forgetRecordFile(recordPath(record.token));
|
|
255
|
+
removeProfile(record.profileDir);
|
|
256
|
+
reaped.push({ port: record.port, pid: record.pid, reason: 'exited' });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (record.port === null || !(await endpointAnswers(record.port))) {
|
|
260
|
+
await reap(record);
|
|
261
|
+
reaped.push({ port: record.port, pid: record.pid, reason: 'unreachable' });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (Date.now() - Date.parse(record.lastUsedAt) > IDLE_REAP_MS) {
|
|
265
|
+
await reap(record);
|
|
266
|
+
reaped.push({ port: record.port, pid: record.pid, reason: 'idle' });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return reaped;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Refreshes an owned browser's idle clock. Called from the one browser-level
|
|
274
|
+
* CDP connect (`getBrowserClient`), so any capture command that actually uses a
|
|
275
|
+
* browser keeps it alive; a port with no record — the user's own browser — is a
|
|
276
|
+
* cheap no-op with no writes at all.
|
|
277
|
+
*/
|
|
278
|
+
export function touchOwnedBrowser(port: number): void {
|
|
279
|
+
const record = readOwnedBrowsers().find((candidate) => candidate.port === port);
|
|
280
|
+
if (!record) return;
|
|
281
|
+
writeJsonPrivate(recordPath(record.token), { ...record, lastUsedAt: new Date().toISOString() });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Owned browsers by port, for `tab list`'s capture-owned marker. */
|
|
285
|
+
export function ownedBrowsersByPort(): Map<number, OwnedBrowser> {
|
|
286
|
+
const byPort = new Map<number, OwnedBrowser>();
|
|
287
|
+
for (const record of readOwnedBrowsers()) if (record.port !== null) byPort.set(record.port, record);
|
|
288
|
+
return byPort;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Stops owned browsers: one port, or every one capture started. Only records
|
|
292
|
+
* are consulted, so a port capture never launched matches nothing and no signal
|
|
293
|
+
* is sent — including when that port is a live browser of the user's. */
|
|
294
|
+
export async function quitOwnedBrowsers(selector: { port?: number; all?: boolean }): Promise<ReapedBrowser[]> {
|
|
295
|
+
const records = readOwnedBrowsers().filter((record) => selector.all || record.port === selector.port);
|
|
296
|
+
const stopped: ReapedBrowser[] = [];
|
|
297
|
+
for (const record of records) {
|
|
298
|
+
await reap(record);
|
|
299
|
+
stopped.push({ port: record.port, pid: record.pid, reason: 'requested' });
|
|
300
|
+
}
|
|
301
|
+
return stopped;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Executable resolution
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
interface Candidate {
|
|
309
|
+
path: string;
|
|
310
|
+
source: BrowserSource;
|
|
311
|
+
/** A headless-shell build has no window and cannot serve `--headed`. */
|
|
312
|
+
headlessOnly: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const PUPPETEER_RELATIVE: Record<string, { chrome: string; shell: string } | undefined> = {
|
|
316
|
+
'darwin-arm64': {
|
|
317
|
+
chrome: 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
318
|
+
shell: 'chrome-headless-shell-mac-arm64/chrome-headless-shell',
|
|
319
|
+
},
|
|
320
|
+
'darwin-x64': {
|
|
321
|
+
chrome: 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
322
|
+
shell: 'chrome-headless-shell-mac-x64/chrome-headless-shell',
|
|
323
|
+
},
|
|
324
|
+
'linux-x64': { chrome: 'chrome-linux64/chrome', shell: 'chrome-headless-shell-linux64/chrome-headless-shell' },
|
|
325
|
+
'linux-arm64': { chrome: 'chrome-linux64/chrome', shell: 'chrome-headless-shell-linux64/chrome-headless-shell' },
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const SYSTEM_PATHS: Record<string, readonly string[]> = {
|
|
329
|
+
darwin: [
|
|
330
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
331
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
332
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
333
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
334
|
+
],
|
|
335
|
+
linux: [
|
|
336
|
+
'/usr/bin/google-chrome',
|
|
337
|
+
'/usr/bin/google-chrome-stable',
|
|
338
|
+
'/usr/bin/chromium',
|
|
339
|
+
'/usr/bin/chromium-browser',
|
|
340
|
+
'/usr/bin/microsoft-edge',
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function executable(file: string): boolean {
|
|
345
|
+
try {
|
|
346
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
347
|
+
return fs.statSync(file).isFile();
|
|
348
|
+
} catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Newest-first version directories under one `~/.cache/puppeteer/<product>` tree. */
|
|
354
|
+
function puppeteerVersionDirs(product: string): string[] {
|
|
355
|
+
const root = path.join(os.homedir(), '.cache', 'puppeteer', product);
|
|
356
|
+
try {
|
|
357
|
+
return fs.readdirSync(root).sort().reverse().map((entry) => path.join(root, entry));
|
|
358
|
+
} catch {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Every browser capture would launch, best first. The puppeteer cache comes
|
|
365
|
+
* before the user's installed Chrome deliberately: Chrome for Testing is a
|
|
366
|
+
* pinned, updateless build meant to be driven, and it is exactly what the
|
|
367
|
+
* hand-rolled launches this command replaces were already reaching for.
|
|
368
|
+
*/
|
|
369
|
+
function browserCandidates(): Candidate[] {
|
|
370
|
+
const configured = process.env.CAPTURE_BROWSER;
|
|
371
|
+
const candidates: Candidate[] = configured
|
|
372
|
+
? [{ path: path.resolve(configured), source: 'CAPTURE_BROWSER', headlessOnly: false }]
|
|
373
|
+
: [];
|
|
374
|
+
const relative = PUPPETEER_RELATIVE[`${process.platform}-${process.arch}`];
|
|
375
|
+
if (relative) {
|
|
376
|
+
for (const dir of puppeteerVersionDirs('chrome-headless-shell')) {
|
|
377
|
+
candidates.push({ path: path.join(dir, relative.shell), source: 'puppeteer-cache', headlessOnly: true });
|
|
378
|
+
}
|
|
379
|
+
for (const dir of puppeteerVersionDirs('chrome')) {
|
|
380
|
+
candidates.push({ path: path.join(dir, relative.chrome), source: 'puppeteer-cache', headlessOnly: false });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const file of SYSTEM_PATHS[process.platform] ?? []) {
|
|
384
|
+
candidates.push({ path: file, source: 'system', headlessOnly: false });
|
|
385
|
+
}
|
|
386
|
+
return candidates;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The browser `tab launch` would start, with the provenance of that choice.
|
|
390
|
+
* Reported on every launch so the caller can see WHICH browser answered — a
|
|
391
|
+
* measurement, not a recommendation. */
|
|
392
|
+
export function resolveBrowserExecutable(headless: boolean): { path: string; source: BrowserSource } {
|
|
393
|
+
const candidates = browserCandidates().filter((candidate) => headless || !candidate.headlessOnly);
|
|
394
|
+
const found = candidates.find((candidate) => executable(candidate.path));
|
|
395
|
+
if (found) return { path: found.path, source: found.source };
|
|
396
|
+
throw captureError(
|
|
397
|
+
'world',
|
|
398
|
+
'no_browser_executable',
|
|
399
|
+
`received: no usable ${headless ? 'browser' : 'headed browser'} executable; expected: Chrome/Chromium at one of ` +
|
|
400
|
+
`${candidates.map((candidate) => candidate.path).join(', ') || '(no known location for this platform)'}. ` +
|
|
401
|
+
`Set CAPTURE_BROWSER=<absolute path> to name one explicitly, or install Chrome for Testing ` +
|
|
402
|
+
`(npx @puppeteer/browsers install chrome-headless-shell@stable).`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Launch
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/** Chrome writes its chosen debugging port here once it is listening — the only
|
|
411
|
+
* readiness signal available when `--remote-debugging-port=0` picks a free port
|
|
412
|
+
* for us (stdio is `ignore`, so its stderr banner is not). */
|
|
413
|
+
function devToolsPort(profileDir: string): number | null {
|
|
414
|
+
try {
|
|
415
|
+
const first = fs.readFileSync(path.join(profileDir, 'DevToolsActivePort'), 'utf-8').split('\n')[0]?.trim();
|
|
416
|
+
if (!first || !/^[1-9]\d*$/.test(first)) return null;
|
|
417
|
+
const port = Number(first);
|
|
418
|
+
return port >= 1 && port <= 65535 ? port : null;
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface LaunchOptions {
|
|
425
|
+
/** First page to open; `about:blank` when unset. */
|
|
426
|
+
url?: string;
|
|
427
|
+
/** Pin the debugging port instead of letting the kernel choose a free one. */
|
|
428
|
+
port?: number;
|
|
429
|
+
headless: boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Starts a browser capture owns and registers it before it is even ready, so no
|
|
434
|
+
* window exists in which a live browser has no ownership record. A launch that
|
|
435
|
+
* never reports readiness reaps its own child — the same discipline
|
|
436
|
+
* `startBridge()` applies to the bridge process it spawns.
|
|
437
|
+
*/
|
|
438
|
+
export async function launchOwnedBrowser(options: LaunchOptions): Promise<OwnedBrowser> {
|
|
439
|
+
const executablePath = resolveBrowserExecutable(options.headless);
|
|
440
|
+
const token = crypto.randomBytes(8).toString('hex');
|
|
441
|
+
const profileDir = ensurePrivateDir(profilePath(token));
|
|
442
|
+
const args = [
|
|
443
|
+
`--remote-debugging-port=${options.port ?? 0}`,
|
|
444
|
+
`--user-data-dir=${profileDir}`,
|
|
445
|
+
'--no-first-run',
|
|
446
|
+
'--no-default-browser-check',
|
|
447
|
+
'--disable-gpu',
|
|
448
|
+
...(options.headless ? ['--headless=new'] : []),
|
|
449
|
+
options.url ?? 'about:blank',
|
|
450
|
+
];
|
|
451
|
+
const child = spawn(executablePath.path, args, { detached: true, stdio: 'ignore' });
|
|
452
|
+
child.unref();
|
|
453
|
+
const pid = child.pid;
|
|
454
|
+
if (pid === undefined) {
|
|
455
|
+
removeProfile(profileDir);
|
|
456
|
+
throw captureError('world', 'browser_spawn_failed', `received: ${executablePath.path}; spawning it produced no pid.`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const observed = processPidBirthProvider.read(pid);
|
|
460
|
+
if (observed.status !== 'found') {
|
|
461
|
+
// Without a birth identity there is no gate that could ever authorize a
|
|
462
|
+
// signal to this pid, so it must not become a registry record. Reap it now,
|
|
463
|
+
// while the child handle still proves which process it is.
|
|
464
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
465
|
+
removeProfile(profileDir);
|
|
466
|
+
throw captureError(
|
|
467
|
+
'world',
|
|
468
|
+
'browser_identity_unavailable',
|
|
469
|
+
`received: browser pid ${pid}; expected: a readable process-birth identity to own it by ` +
|
|
470
|
+
`(${observed.status === 'unknown' ? observed.reason : 'the process was already gone'}).`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const now = new Date().toISOString();
|
|
475
|
+
const record: OwnedBrowser = {
|
|
476
|
+
version: 1,
|
|
477
|
+
token,
|
|
478
|
+
port: null,
|
|
479
|
+
pid,
|
|
480
|
+
birth: observed.identity,
|
|
481
|
+
profileDir,
|
|
482
|
+
executablePath: executablePath.path,
|
|
483
|
+
source: executablePath.source,
|
|
484
|
+
headless: options.headless,
|
|
485
|
+
startedAt: now,
|
|
486
|
+
lastUsedAt: now,
|
|
487
|
+
};
|
|
488
|
+
writeJsonPrivate(recordPath(token), record);
|
|
489
|
+
|
|
490
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
491
|
+
for (;;) {
|
|
492
|
+
const port = options.port ?? devToolsPort(profileDir);
|
|
493
|
+
if (port !== null && (await endpointAnswers(port))) {
|
|
494
|
+
const ready: OwnedBrowser = { ...record, port, lastUsedAt: new Date().toISOString() };
|
|
495
|
+
writeJsonPrivate(recordPath(token), ready);
|
|
496
|
+
return ready;
|
|
497
|
+
}
|
|
498
|
+
if (Date.now() > deadline) {
|
|
499
|
+
await reap(record);
|
|
500
|
+
throw captureError(
|
|
501
|
+
'world',
|
|
502
|
+
'browser_launch_timeout',
|
|
503
|
+
`received: ${executablePath.path} (pid ${pid}); it did not answer CDP on ` +
|
|
504
|
+
`${options.port === undefined ? 'a kernel-chosen port' : `port ${options.port}`} within ${READY_TIMEOUT_MS}ms. ` +
|
|
505
|
+
`The browser was stopped and its profile removed.`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
await sleep(READY_POLL_MS);
|
|
509
|
+
}
|
|
510
|
+
}
|
package/src/cdp/client.ts
CHANGED
|
@@ -63,6 +63,7 @@ export class CDPClient {
|
|
|
63
63
|
sessionId?: string,
|
|
64
64
|
): Promise<unknown> {
|
|
65
65
|
await this.ready;
|
|
66
|
+
if (this.ws.readyState !== WebSocket.OPEN) throw new Error('CDP client is closed');
|
|
66
67
|
const id = ++this.messageId;
|
|
67
68
|
|
|
68
69
|
return new Promise((resolve, reject) => {
|
|
@@ -103,7 +104,15 @@ export class CDPClient {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
close(): void {
|
|
106
|
-
//
|
|
107
|
+
// Reject pending requests before clearing their timers. Leaving their
|
|
108
|
+
// promises unresolved strands collectors that were interrupted by a
|
|
109
|
+
// bounded command timeout, so the CLI never reaches its structured
|
|
110
|
+
// partial-artifact result or exits after the socket closes.
|
|
111
|
+
const error = new Error('CDP client closed');
|
|
112
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
113
|
+
pending.reject(error);
|
|
114
|
+
this.pendingRequests.delete(id);
|
|
115
|
+
}
|
|
107
116
|
for (const timer of this.pendingTimeouts.values()) {
|
|
108
117
|
clearTimeout(timer);
|
|
109
118
|
}
|