@monoes/monobrowse 1.0.25 → 1.0.27

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 (67) hide show
  1. package/dist/src/__tests__/browser-launch.test.d.ts +3 -2
  2. package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -1
  3. package/dist/src/__tests__/browser-launch.test.js +71 -6
  4. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  5. package/dist/src/__tests__/launch-close-settle.test.d.ts +6 -0
  6. package/dist/src/__tests__/launch-close-settle.test.d.ts.map +1 -1
  7. package/dist/src/__tests__/launch-close-settle.test.js +29 -8
  8. package/dist/src/__tests__/launch-close-settle.test.js.map +1 -1
  9. package/dist/src/browser/actions-eval.d.ts +8 -0
  10. package/dist/src/browser/actions-eval.d.ts.map +1 -0
  11. package/dist/src/browser/actions-eval.js +72 -0
  12. package/dist/src/browser/actions-eval.js.map +1 -0
  13. package/dist/src/browser/actions-form.d.ts +10 -0
  14. package/dist/src/browser/actions-form.d.ts.map +1 -0
  15. package/dist/src/browser/actions-form.js +133 -0
  16. package/dist/src/browser/actions-form.js.map +1 -0
  17. package/dist/src/browser/actions-keyboard.d.ts +7 -0
  18. package/dist/src/browser/actions-keyboard.d.ts.map +1 -0
  19. package/dist/src/browser/actions-keyboard.js +131 -0
  20. package/dist/src/browser/actions-keyboard.js.map +1 -0
  21. package/dist/src/browser/actions-mouse.d.ts +11 -0
  22. package/dist/src/browser/actions-mouse.d.ts.map +1 -0
  23. package/dist/src/browser/actions-mouse.js +105 -0
  24. package/dist/src/browser/actions-mouse.js.map +1 -0
  25. package/dist/src/browser/actions.d.ts +12 -28
  26. package/dist/src/browser/actions.d.ts.map +1 -1
  27. package/dist/src/browser/actions.js +12 -434
  28. package/dist/src/browser/actions.js.map +1 -1
  29. package/dist/src/browser/browser-discovery.d.ts +26 -0
  30. package/dist/src/browser/browser-discovery.d.ts.map +1 -0
  31. package/dist/src/browser/browser-discovery.js +113 -0
  32. package/dist/src/browser/browser-discovery.js.map +1 -0
  33. package/dist/src/browser/browser-launch.d.ts +3 -0
  34. package/dist/src/browser/browser-launch.d.ts.map +1 -0
  35. package/dist/src/browser/browser-launch.js +324 -0
  36. package/dist/src/browser/browser-launch.js.map +1 -0
  37. package/dist/src/browser/browser-lifecycle.d.ts +52 -0
  38. package/dist/src/browser/browser-lifecycle.d.ts.map +1 -0
  39. package/dist/src/browser/browser-lifecycle.js +233 -0
  40. package/dist/src/browser/browser-lifecycle.js.map +1 -0
  41. package/dist/src/browser/browser-page.d.ts +13 -0
  42. package/dist/src/browser/browser-page.d.ts.map +1 -0
  43. package/dist/src/browser/browser-page.js +179 -0
  44. package/dist/src/browser/browser-page.js.map +1 -0
  45. package/dist/src/browser/browser-state.d.ts +7 -0
  46. package/dist/src/browser/browser-state.d.ts.map +1 -0
  47. package/dist/src/browser/browser-state.js +19 -0
  48. package/dist/src/browser/browser-state.js.map +1 -0
  49. package/dist/src/browser/browser.d.ts +5 -68
  50. package/dist/src/browser/browser.d.ts.map +1 -1
  51. package/dist/src/browser/browser.js +9 -794
  52. package/dist/src/browser/browser.js.map +1 -1
  53. package/dist/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +1 -1
  55. package/src/__tests__/browser-launch.test.ts +78 -6
  56. package/src/__tests__/launch-close-settle.test.ts +32 -10
  57. package/src/browser/actions-eval.ts +118 -0
  58. package/src/browser/actions-form.ts +218 -0
  59. package/src/browser/actions-keyboard.ts +171 -0
  60. package/src/browser/actions-mouse.ts +212 -0
  61. package/src/browser/actions.ts +37 -712
  62. package/src/browser/browser-discovery.ts +116 -0
  63. package/src/browser/browser-launch.ts +358 -0
  64. package/src/browser/browser-lifecycle.ts +238 -0
  65. package/src/browser/browser-page.ts +213 -0
  66. package/src/browser/browser-state.ts +21 -0
  67. package/src/browser/browser.ts +17 -859
@@ -1,859 +1,17 @@
1
- import { execSync, spawn } from 'node:child_process';
2
- import { existsSync, readFileSync, rmSync } from 'node:fs';
3
- import { connect } from 'node:net';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { CdpClient, fetchNewTarget, fetchTargets } from './cdp.js';
7
- import { enableConsoleCapture, setupConsoleCapture } from './console-log.js';
8
- import { setupDialogAutoHandling } from './dialog.js';
9
- import {
10
- listSessionRecords,
11
- loadSessionRecord,
12
- removeSessionRecord,
13
- type SessionRecord,
14
- } from './ref-cache.js';
15
- import type { BrowserConfig, CdpTarget } from './types.js';
16
- import { CHROME_EXECUTABLES } from './types.js';
17
-
18
- const DEFAULT_PORT = 9222;
19
- const LAUNCH_TIMEOUT = 10_000;
20
- const POLL_INTERVAL = 200;
21
- const BROWSER_CLOSE_TIMEOUT_MS = 3000;
22
- // How long closeBrowser() waits for the process to actually disappear before
23
- // forcing it, and again after forcing. Bounded so a wedged Chrome cannot hang
24
- // a caller indefinitely.
25
- const PROCESS_EXIT_TIMEOUT_MS = 5000;
26
- const PROCESS_EXIT_POLL_MS = 50;
27
- // A cross-process persisted PID older than this is not trusted for the
28
- // SIGKILL fallback in closeBrowser() — see the call site for why.
29
- const PERSISTED_PID_MAX_AGE_MS = 24 * 60 * 60 * 1000;
30
- // A browser WE launched that has had no targets for longer than this is
31
- // reaped at the next launch/attach — see reapIdleLaunchedBrowser().
32
- const IDLE_REAP_AFTER_MS = 30 * 60 * 1000;
33
- const REAP_CONNECT_TIMEOUT_MS = 3000;
34
-
35
- // Tracks the PID of Chrome instances *we* spawned, keyed by CDP port, so
36
- // closeBrowser() has a kill fallback when the graceful `Browser.close` CDP
37
- // command fails or times out (e.g. a hung renderer). Ports we merely attached
38
- // to (already-running browser) are never recorded here — we only ever kill
39
- // processes we launched ourselves.
40
- const launchedPids = new Map<number, number>();
41
- // userDataDir per launched port — informational, persisted alongside the pid
42
- // so a later process could clean up the temp profile dir too if ever needed.
43
- const launchedUserDataDirs = new Map<number, string>();
44
-
45
- /** PID of the Chrome instance *this process* launched on `port`, if any. */
46
- export function getLaunchedPid(port: number): number | undefined {
47
- return launchedPids.get(port);
48
- }
49
-
50
- /** userDataDir of the Chrome instance *this process* launched on `port`, if any. */
51
- export function getLaunchedUserDataDir(port: number): string | undefined {
52
- return launchedUserDataDirs.get(port);
53
- }
54
-
55
- function findChrome(executablePath?: string): string {
56
- if (executablePath) {
57
- if (existsSync(executablePath)) return executablePath;
58
- throw new Error(`Chrome executable not found: ${executablePath}`);
59
- }
60
- for (const candidate of CHROME_EXECUTABLES) {
61
- if (existsSync(candidate)) return candidate;
62
- }
63
- // Try PATH
64
- try {
65
- const result = execSync(
66
- 'which google-chrome chromium-browser chromium microsoft-edge microsoft-edge-stable 2>/dev/null',
67
- { encoding: 'utf8' },
68
- ).trim();
69
- const first = result.split('\n')[0];
70
- if (first) return first;
71
- } catch {
72
- // ignore
73
- }
74
- throw new Error(
75
- 'No supported browser found. Install Google Chrome, Microsoft Edge, or Chromium — or pass executablePath in BrowserConfig.',
76
- );
77
- }
78
-
79
- export async function isPortOpen(port: number): Promise<boolean> {
80
- try {
81
- await fetchTargets(port);
82
- return true;
83
- } catch {
84
- return false;
85
- }
86
- }
87
-
88
- /**
89
- * Confirm the CDP endpoint on `port` actually identifies as Chrome/Chromium
90
- * via `/json/version`'s `Browser` field, rather than assuming any CDP-speaking
91
- * responder is "ours". Reduces (does not eliminate) the risk of silently
92
- * attaching to an unrelated real browser that happens to be listening there.
93
- */
94
- async function isChromeIdentity(port: number): Promise<boolean> {
95
- return (await chromeIdentity(port)) === 'chrome';
96
- }
97
-
98
- /** isChromeIdentity, telling "answered as something else" apart from "did
99
- * not answer in time" ('unknown': a timeout, a refused or reset connection,
100
- * a non-OK status or an unparseable body). */
101
- async function chromeIdentity(port: number): Promise<'chrome' | 'other' | 'unknown'> {
102
- try {
103
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
104
- signal: AbortSignal.timeout(1000),
105
- });
106
- if (!res.ok) return 'unknown';
107
- const info = (await res.json()) as { Browser?: string };
108
- // Edge's Browser field is "Edg/<version>" with no "chrome"/"chromium"
109
- // substring, even though it's Chromium-based and CDP-compatible —
110
- // accept it alongside Chrome/Chromium rather than rejecting a valid target.
111
- return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser)
112
- ? 'chrome'
113
- : 'other';
114
- } catch {
115
- return 'unknown';
116
- }
117
- }
118
-
119
- /** Raw TCP connect probe — distinguishes "nothing listening" from "something listening that isn't CDP". */
120
- function isTcpPortOpen(port: number, timeoutMs = 1000): Promise<boolean> {
121
- return new Promise((resolve) => {
122
- const socket = connect({ host: '127.0.0.1', port, timeout: timeoutMs });
123
- const done = (result: boolean) => {
124
- socket.removeAllListeners();
125
- socket.destroy();
126
- resolve(result);
127
- };
128
- socket.once('connect', () => done(true));
129
- socket.once('timeout', () => done(false));
130
- socket.once('error', () => done(false));
131
- });
132
- }
133
-
134
- /** Ports scanned when the requested one is occupied by a non-Chrome process
135
- * (e.g. another local tool that happens to reuse Chrome's conventional CDP
136
- * default, 9222 — mirrors the auto-increment convention this monorepo's own
137
- * dashboard server uses in bindServer/server.mjs). Only occupied-by-a-
138
- * DIFFERENT-process is worked around; an already-attachable Chrome on the
139
- * requested port is still returned as-is (existing "attach, don't relaunch"
140
- * behavior). */
141
- const LAUNCH_PORT_SCAN_TRIES = 10;
142
-
143
- export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
144
- const rawPort = config.port ?? DEFAULT_PORT;
145
- // Port 0 means "let Chrome bind a free port and tell us which" — see
146
- // launchOnFreePort. Otherwise validate port is in a safe range for
147
- // localhost CDP debugging.
148
- if (rawPort === 0) {
149
- if (!config.userDataDir) {
150
- throw new Error(
151
- 'port 0 requires a dedicated userDataDir (Chrome reports the port it bound inside it).',
152
- );
153
- }
154
- } else if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
155
- throw new Error(`Invalid port: ${rawPort}. Must be 0 or an integer between 1024 and 65535.`);
156
- }
157
-
158
- // Every launch/attach is this tool's only chance to notice that a previous
159
- // session abandoned a browser on a CDP port and never gave it back — there
160
- // is no daemon to do it on a timer. Bounded and non-throwing; a stale
161
- // instance on the port we are about to use is freed before we probe it.
162
- await reapIdleLaunchedBrowser();
163
-
164
- if (rawPort === 0) return launchOnFreePort(config, 0);
165
-
166
- // strictPort: fail fast on the exact requested port, matching the old
167
- // behavior (Vite has the same escape hatch for the same reason) — for
168
- // callers that treat the error as a signal ("this port is taken by
169
- // something else, bail") rather than consuming the returned port.
170
- if (config.strictPort) {
171
- if (await isTcpPortOpen(rawPort)) {
172
- if (await isChromeIdentity(rawPort)) return rawPort;
173
- throw new Error(
174
- `Port ${rawPort} is occupied by a process that does not identify as Chrome/Chromium. ` +
175
- `Refusing to attach — pass a different port or free port ${rawPort}.`,
176
- );
177
- }
178
- return launchOnFreePort(config, rawPort);
179
- }
180
-
181
- const candidates: number[] = [];
182
- for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++)
183
- candidates.push(rawPort + i);
184
-
185
- for (const candidate of candidates) {
186
- // TCP-level check for "is anything at all listening" — isPortOpen()
187
- // does a full CDP /json fetch, which returns false BOTH for a genuinely
188
- // free port and for one occupied by a non-CDP process (that ambiguity is
189
- // exactly what the post-spawn isTcpPortOpen fallback below exists to
190
- // resolve, the hard way, after a 10s launch timeout). Checking the raw
191
- // socket first tells free and occupied apart up front, so the scan can
192
- // skip an occupied candidate instead of trying to spawn Chrome on top of
193
- // it and only discovering the conflict after a timeout.
194
- if (await isTcpPortOpen(candidate)) {
195
- // Attach-if-already-Chrome only applies to the EXACT requested port —
196
- // the original, deliberate, single-port risk ("don't silently take
197
- // over an unrelated real browser that happens to be on this port").
198
- // Scanning past an occupied default must not let that same shortcut
199
- // attach to a DIFFERENT Chrome instance the caller never named;
200
- // forward candidates are launch-only (skip if anything is there,
201
- // Chrome or not).
202
- if (candidate === rawPort && (await isChromeIdentity(candidate))) return candidate;
203
- // Occupied (by anything — not just non-Chrome, per the note above) —
204
- // try the next candidate instead of failing outright, same as a
205
- // normal EADDRINUSE retry would.
206
- continue;
207
- }
208
- try {
209
- return await launchOnFreePort(config, candidate);
210
- } catch (err) {
211
- const isLastCandidate = candidate === candidates[candidates.length - 1];
212
- // isTcpPortOpen() above is a connect() probe, not an atomic claim: two
213
- // concurrent launchBrowser() calls with no explicit --port can both
214
- // observe the same candidate as free and both spawn Chrome on it
215
- // before either binds. One wins; the other's Chrome exits before its
216
- // CDP endpoint opens ("Chrome exited before the CDP endpoint opened
217
- // ... code=21"). Something is listening on the candidate NOW that
218
- // was not a moment ago — a losing race, not a broken Chrome install —
219
- // so try the next candidate exactly like an already-occupied one,
220
- // instead of failing the whole launch outright. A candidate that
221
- // fails with nothing now listening (a real launch failure — bad
222
- // executable, sandbox refusal, etc.) is not a race: retrying the
223
- // next candidate would only fail the same way, so surface it as-is.
224
- if (!isLastCandidate && (await isTcpPortOpen(candidate))) continue;
225
- throw err;
226
- }
227
- }
228
- throw new Error(
229
- `Ports ${candidates[0]}-${candidates[candidates.length - 1]} are all occupied and port ${candidates[0]} ` +
230
- `isn't a Chrome/Chromium instance to attach to. Pass a different --port.`,
231
- );
232
- }
233
-
234
- /** The port Chrome wrote to `<userDataDir>/DevToolsActivePort` once its
235
- * DevTools server was listening, or null if it has not (yet). */
236
- function readDevToolsActivePort(file: string): number | null {
237
- try {
238
- const port = Number.parseInt(readFileSync(file, 'utf8').split('\n')[0], 10);
239
- return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
240
- } catch {
241
- return null;
242
- }
243
- }
244
-
245
- /**
246
- * Spawn Chrome and wait for its CDP endpoint.
247
- *
248
- * With a fixed `port`, "our Chrome is up" is inferred from *some* Chrome
249
- * answering on 127.0.0.1:<port> — which is not necessarily ours. When the port
250
- * is already bound on 127.0.0.1 (a concurrent launch picked the same "free"
251
- * port between its probe and Chrome's bind), Chrome does not fail: it logs
252
- * `bind() failed: Address already in use` and listens on [::1]:<port>
253
- * instead. The poll then accepts the OTHER launcher's browser, both callers
254
- * drive one Chrome, and whichever closes first kills the other's connections
255
- * ("CDP connection closed") while this launch's own Chrome is orphaned.
256
- *
257
- * `port: 0` removes the race instead of narrowing it: Chrome asks the kernel
258
- * for a free port at bind time — atomic, nothing to collide on — and writes
259
- * the port it got to DevToolsActivePort in its own (caller-dedicated) profile
260
- * directory, which is proof the endpoint is the process we spawned.
261
- */
262
- async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
263
- const chromePath = findChrome(config.executablePath);
264
- // Suffixed with our own pid: two concurrent launches that both probe the
265
- // same candidate port as free (the race this function exists to survive —
266
- // see the retry-on-collision caller above) briefly run Chrome with
267
- // *identical* --user-data-dir values if it were derived from the port
268
- // alone, sharing a profile directory (lock files, preferences, the
269
- // DevToolsActivePort file itself) between two unrelated Chrome processes
270
- // for as long as the loser stays alive. Different processes always have
271
- // different pids, so this can never collide even when the port does.
272
- const userDataDir =
273
- config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}-${process.pid}`);
274
- const activePortFile = join(userDataDir, 'DevToolsActivePort');
275
- // A reused profile dir may hold a previous run's file naming a port some
276
- // other process now owns.
277
- if (port === 0) rmSync(activePortFile, { force: true });
278
-
279
- const defaultArgs = [
280
- `--remote-debugging-port=${port}`,
281
- `--user-data-dir=${userDataDir}`,
282
- '--no-first-run',
283
- '--no-default-browser-check',
284
- '--disable-background-networking',
285
- '--disable-client-side-phishing-detection',
286
- '--disable-default-apps',
287
- '--disable-extensions',
288
- '--disable-hang-monitor',
289
- '--disable-popup-blocking',
290
- '--disable-prompt-on-repost',
291
- '--disable-sync',
292
- '--disable-translate',
293
- '--metrics-recording-only',
294
- '--safebrowsing-disable-auto-update',
295
- '--password-store=basic',
296
- '--use-mock-keychain',
297
- ];
298
-
299
- if (config.headless !== false) {
300
- defaultArgs.push('--headless=new');
301
- }
302
-
303
- // Chrome's setuid sandbox cannot initialise on most CI runners and
304
- // containers, so the process dies during startup and the CDP endpoint never
305
- // opens. The launch below then burns its whole timeout before reporting a
306
- // generic failure, which reads as a hang rather than "Chrome could not
307
- // start". Disabling the sandbox is the standard remedy and is scoped to CI
308
- // so a real user's browser keeps it. --disable-dev-shm-usage goes with it:
309
- // a container's default /dev/shm is 64MB, which Chrome exhausts and then
310
- // crashes the same way.
311
- if (process.env.CI) {
312
- defaultArgs.push('--no-sandbox', '--disable-dev-shm-usage');
313
- }
314
-
315
- // Cap caller-supplied args to prevent memory exhaustion via huge argument arrays
316
- const callerArgs = (config.args ?? []).slice(0, 50);
317
- const args = [...defaultArgs, ...callerArgs];
318
- const child = spawn(chromePath, args, {
319
- detached: true,
320
- stdio: 'ignore',
321
- });
322
-
323
- // Without this, a spawn failure (e.g. EACCES/ENOENT — chromePath exists per
324
- // findChrome()'s existsSync check but isn't actually executable, or is
325
- // removed between the check and exec) fires Node's 'error' event on the
326
- // next tick. With no listener, Node throws it as an uncaught exception and
327
- // the whole process goes down — which strands this function's promise
328
- // forever pending rather than rejecting it (issue #314: node:test reports
329
- // that as "cancelledByParent" / "still pending" because nothing here ever
330
- // got the chance to settle it). Recording the failure and having the poll
331
- // loop below notice it turns that crash into a normal rejection. 'exit'
332
- // covers the twin case: Chrome execs fine but the process itself dies
333
- // immediately (missing shared libraries, a container's sandbox refusing
334
- // it, etc.) — that fires 'exit', not 'error', and without this the poll
335
- // loop would just burn its whole timeout probing a port nothing will ever
336
- // open on.
337
- let earlyFailure: Error | null = null;
338
- child.on('error', (err) => {
339
- earlyFailure ??= new Error(`Chrome failed to start on port ${port}: ${err.message}`);
340
- });
341
- child.on('exit', (code, signal) => {
342
- earlyFailure ??= new Error(
343
- `Chrome exited before the CDP endpoint opened on port ${port} ` +
344
- `(code=${code ?? 'null'}, signal=${signal ?? 'null'})`,
345
- );
346
- });
347
-
348
- child.unref();
349
- const track = (boundPort: number) => {
350
- if (!child.pid) return;
351
- launchedPids.set(boundPort, child.pid);
352
- launchedUserDataDirs.set(boundPort, userDataDir);
353
- };
354
- // Tracked only once Chrome is CONFIRMED up on `boundPort`, not right after
355
- // spawn(). For port !== 0 this used to track(port) unconditionally the
356
- // moment the child was spawned, before knowing whether it would actually
357
- // win the port — when a racing launchBrowser() call retries a candidate
358
- // that a concurrent process also spawned Chrome on (see the retry loop in
359
- // launchBrowser), BOTH child processes called track() for the SAME
360
- // candidate, and whichever call's spawn happened to run last clobbered the
361
- // map entry with its own pid — including the LOSING, about-to-exit
362
- // process's pid overwriting the real winner's, corrupting the very map
363
- // closeBrowser()'s kill fallback depends on.
364
-
365
- // With port 0 a refused or timed-out launch is not tracked under any port,
366
- // so no closeBrowser() could ever reach it — stop it rather than leave it
367
- // running.
368
- const killUntracked = () => {
369
- try {
370
- if (child.pid) process.kill(child.pid, 'SIGKILL');
371
- } catch {
372
- /* already gone */
373
- }
374
- };
375
-
376
- const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
377
- const deadline = Date.now() + launchTimeout;
378
- while (Date.now() < deadline) {
379
- if (earlyFailure) throw earlyFailure;
380
- await sleep(POLL_INTERVAL);
381
- if (earlyFailure) throw earlyFailure;
382
- const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
383
- if (boundPort !== null && (await isPortOpen(boundPort))) {
384
- const identity = await chromeIdentity(boundPort);
385
- if (identity === 'chrome') {
386
- track(boundPort);
387
- return boundPort;
388
- }
389
- // A Chrome that has only just opened its endpoint, on a loaded
390
- // machine, can answer /json/list (no timeout) yet miss the identity
391
- // check's 1 s timeout on /json/version. That says nothing about who is
392
- // listening — keep polling rather than refuse our own Chrome.
393
- if (identity === 'unknown') continue;
394
- if (port === 0) killUntracked();
395
- throw new Error(
396
- `Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
397
- `Refusing to attach — pass a different port or free port ${boundPort}.`,
398
- );
399
- }
400
- }
401
-
402
- if (earlyFailure) throw earlyFailure;
403
-
404
- if (port === 0) {
405
- killUntracked();
406
- throw new Error(
407
- `Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
408
- );
409
- }
410
-
411
- // Timed out waiting for our Chrome to come up on the port. Distinguish
412
- // "nothing is listening" (real launch failure) from "something non-CDP is
413
- // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
414
- if (await isTcpPortOpen(port)) {
415
- throw new Error(
416
- `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${launchTimeout}ms). ` +
417
- `Free the port or pass a different one.`,
418
- );
419
- }
420
-
421
- throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
422
- }
423
-
424
- export async function enableSessionDomains(client: CdpClient, sessionId: string): Promise<void> {
425
- await Promise.all([
426
- client.send('Page.enable', {}, sessionId),
427
- client.send('Runtime.enable', {}, sessionId),
428
- client.send('Network.enable', {}, sessionId),
429
- client.send('DOM.enable', {}, sessionId),
430
- client.send('Accessibility.enable', {}, sessionId),
431
- ]);
432
- setupConsoleCapture(client, sessionId);
433
- await enableConsoleCapture(client, sessionId);
434
- setupDialogAutoHandling(client, sessionId);
435
- }
436
-
437
- export async function connectToTarget(
438
- port: number,
439
- targetId?: string,
440
- ): Promise<{ client: CdpClient; target: CdpTarget; sessionId: string }> {
441
- const targets = await fetchTargets(port);
442
- const pageTargets = targets.filter((t) => t.type === 'page');
443
-
444
- let target: CdpTarget;
445
- if (targetId) {
446
- const found = pageTargets.find((t) => t.id === targetId);
447
- if (!found) throw new Error(`Target ${targetId} not found`);
448
- target = found;
449
- } else if (pageTargets.length > 0) {
450
- target = pageTargets[0];
451
- } else {
452
- target = await fetchNewTarget(port, 'about:blank');
453
- }
454
-
455
- const wsUrl = target.webSocketDebuggerUrl ?? `ws://127.0.0.1:${port}/devtools/page/${target.id}`;
456
- const client = new CdpClient();
457
- await client.connect(wsUrl);
458
-
459
- const { sessionId } = await client.send<{ sessionId: string }>('Target.attachToTarget', {
460
- targetId: target.id,
461
- flatten: true,
462
- });
463
-
464
- await enableSessionDomains(client, sessionId);
465
- return { client, target, sessionId };
466
- }
467
-
468
- /**
469
- * Cleanly terminate a Chrome/Chromium instance we launched on `port`.
470
- * Sends the `Browser.close` CDP command (the correct graceful shutdown —
471
- * closes all tabs and exits the process cleanly) over `client`'s connection.
472
- * If that command fails, times out, or the connection is already gone,
473
- * falls back to killing the tracked PID directly so a headed/interactive
474
- * browser window (e.g. one spawned for a login/CAPTCHA flow) never lingers
475
- * as a visible, authenticated, still-debuggable orphan process.
476
- *
477
- * On the graceful path this resolves only once the process is actually gone,
478
- * or after force-killing it when it outlasts PROCESS_EXIT_TIMEOUT_MS. Chrome
479
- * acknowledges `Browser.close` well before it exits, and this used to return
480
- * on the ack with an unref'd 1s timer as the only force-kill — so it resolved
481
- * while Chrome was still running and holding its port and its profile's
482
- * singleton lock, and if the caller's process exited first the kill never
483
- * happened at all. Callers reasonably read a resolved close() as "that
484
- * browser is gone"; on the graceful path it now means that.
485
- */
486
- export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
487
- let gracefullyClosed = false;
488
- let closeTimer: ReturnType<typeof setTimeout> | undefined;
489
- try {
490
- await Promise.race([
491
- client.send('Browser.close', {}),
492
- // Deliberately NOT unref'd, for the same reason waitForProcessExit()'s
493
- // poll below is not. This timer is the only thing that settles the race
494
- // when Chrome's socket dies without ever acknowledging the close, and
495
- // closeBrowser() is actively awaited by callers. An unref'd timer does
496
- // not hold the event loop open, so Node would consider the loop drained
497
- // and exit — status 0, before the caller's verdict was ever printed.
498
- // Cleared in the finally so a close that IS acknowledged does not pin
499
- // the loop for the rest of BROWSER_CLOSE_TIMEOUT_MS.
500
- new Promise<never>((_, reject) => {
501
- closeTimer = setTimeout(
502
- () => reject(new Error('Browser.close timed out')),
503
- BROWSER_CLOSE_TIMEOUT_MS,
504
- );
505
- }),
506
- ]);
507
- gracefullyClosed = true;
508
- } catch {
509
- // fall through to PID-kill fallback below
510
- } finally {
511
- clearTimeout(closeTimer);
512
- }
513
-
514
- let pid = launchedPids.get(port);
515
- if (pid === undefined) {
516
- // Fresh process (each CLI invocation is its own node process — see
517
- // module header) — launchedPids is per-process and empty here even
518
- // though a prior `open` in a DIFFERENT process launched this Chrome.
519
- // Fall back to the PID that process persisted via saveSessionRecord().
520
- try {
521
- const persisted = await loadSessionRecord(port);
522
- if (persisted?.launched && persisted.pid) {
523
- // Bound how far we trust a cross-process PID: if the Chrome we
524
- // launched died outside this flow (OOM-killed, host slept, etc.)
525
- // without its session record ever being cleared, the OS can recycle
526
- // that PID for an unrelated process. There is no cmdline/start-time
527
- // check available portably, so a coarse age bound is the practical
528
- // guard — a PID persisted more than a day ago is far more likely to
529
- // be stale/reused than to still be the same live Chrome.
530
- const age = persisted.savedAt !== undefined ? Date.now() - persisted.savedAt : undefined;
531
- if (age === undefined || age <= PERSISTED_PID_MAX_AGE_MS) {
532
- pid = persisted.pid;
533
- }
534
- }
535
- } catch {
536
- // best-effort — fall through to "nothing to kill" below
537
- }
538
- }
539
- if (pid === undefined) return; // not a process we launched, or too stale to trust — nothing to kill
540
- launchedPids.delete(port);
541
-
542
- // Liveness-check before every SIGKILL (not just the safety-net path below)
543
- // — process.kill(pid, 0) throws ESRCH if the PID is no longer running,
544
- // which also catches the case where the PID has already been recycled by
545
- // an unrelated process that happens to have exited too.
546
- if (!gracefullyClosed) {
547
- try {
548
- process.kill(pid, 0);
549
- process.kill(pid, 'SIGKILL');
550
- } catch {
551
- /* already exited, or never was — nothing to do */
552
- }
553
- return;
554
- }
555
-
556
- // Browser.close was acknowledged — wait for the process to actually go away,
557
- // then force-kill as a safety net in case it hung.
558
- if (await waitForProcessExit(pid)) return;
559
- try {
560
- process.kill(pid, 'SIGKILL');
561
- } catch {
562
- /* raced us to exit — expected */
563
- }
564
- }
565
-
566
- /** Poll until `pid` is gone or PROCESS_EXIT_TIMEOUT_MS elapses. Returns
567
- * whether it exited. `process.kill(pid, 0)` throws once the process is no
568
- * longer there, on Windows as well as POSIX.
569
- *
570
- * This timer is deliberately NOT unref'd. closeBrowser() awaits this poll
571
- * as part of its own return value — a caller that awaits closeBrowser() is
572
- * actively blocked on it, not doing other work in the background. Once
573
- * Chrome's CDP websocket closes (which happens right around here, as part
574
- * of it shutting down), nothing else may be left holding the event loop
575
- * open; an unref'd timer at that point never fires because Node considers
576
- * the loop drained and exits without running it, leaving this promise (and
577
- * closeBrowser()'s) pending forever — reproduced locally by closing a real
578
- * launched browser with nothing else scheduled. Bounded by
579
- * PROCESS_EXIT_TIMEOUT_MS (5s) either way, so keeping it ref'd only ever
580
- * costs a caller a few seconds, never a hang. */
581
- async function waitForProcessExit(pid: number): Promise<boolean> {
582
- const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS;
583
- for (;;) {
584
- try {
585
- process.kill(pid, 0);
586
- } catch {
587
- return true;
588
- }
589
- if (Date.now() >= deadline) return false;
590
- await new Promise((resolve) => {
591
- setTimeout(resolve, PROCESS_EXIT_POLL_MS);
592
- });
593
- }
594
- }
595
-
596
- /**
597
- * Give back the CDP port of a browser we launched that nothing is using any
598
- * more. A launched Chrome is deliberately detached and unref'd so it outlives
599
- * the command that spawned it and later commands can reattach — but nothing
600
- * reaped it afterwards, so an abandoned session kept listening on its port
601
- * (9222 by convention, which other local tools want) indefinitely. A foreign
602
- * CDP server *answers* there rather than refusing the connection, so those
603
- * tools retry the wrong port and report misleading errors instead of moving on.
604
- *
605
- * Nothing long-lived exists to run this on a timer — every CLI command is its
606
- * own short-lived process — so it runs at the next launch/attach instead
607
- * (see launchBrowser). A browser is reaped only when ALL of these hold:
608
- * - the persisted session says WE launched it — `connect` records
609
- * launched:false for a browser that belongs to the user, never touched;
610
- * - that session is older than IDLE_REAP_AFTER_MS;
611
- * - the CDP endpoint still answers and reports ZERO targets. An open tab,
612
- * or a client attached to one (Chrome drops webSocketDebuggerUrl from an
613
- * attached target but still lists it), means the session is live and
614
- * reattach-across-commands must keep working.
615
- *
616
- * Shutdown goes through closeBrowser() on the browser-level websocket — the
617
- * only connection left once every target is gone — so a recycled PID can
618
- * never be signaled by mistake: graceful `Browser.close` reaches whatever is
619
- * actually listening on that port, at any age, and the persisted PID is only
620
- * ever used for closeBrowser's own age-bounded force-kill.
621
- *
622
- * Never throws: a failed reap must not stop a launch. Returns the reaped
623
- * port, or null when there was nothing to reap. Sessions are per-port
624
- * (#318), so every recorded one is considered, not just the newest.
625
- */
626
- export async function reapIdleLaunchedBrowser(): Promise<number | null> {
627
- let reaped: number | null = null;
628
- for (const session of await listSessionRecords().catch(() => [])) {
629
- const port = await reapSession(session);
630
- reaped ??= port;
631
- }
632
- return reaped;
633
- }
634
-
635
- async function reapSession(session: SessionRecord): Promise<number | null> {
636
- try {
637
- if (!session.launched || session.savedAt === undefined) return null;
638
- if (Date.now() - session.savedAt < IDLE_REAP_AFTER_MS) return null;
639
-
640
- const wsUrl = await fetchBrowserWebSocketUrl(session.port);
641
- if (!wsUrl) return null;
642
- if ((await fetchTargets(session.port)).length > 0) return null;
643
-
644
- const client = new CdpClient();
645
- let reapTimer: ReturnType<typeof setTimeout> | undefined;
646
- try {
647
- // CdpClient.connect() has no timeout of its own — a socket that never
648
- // opens (and never errors) would hang the launch this reap is running
649
- // inside of, which is strictly worse than leaving the port squatted.
650
- // Not unref'd (see closeBrowser): a socket that never opens and never
651
- // errors leaves this timer as the only thing that can settle the race,
652
- // and the reap is awaited inside launch/attach. Cleared in the finally
653
- // below so a connect that succeeds does not pin the event loop.
654
- await Promise.race([
655
- client.connect(wsUrl),
656
- new Promise<never>((_, reject) => {
657
- reapTimer = setTimeout(
658
- () => reject(new Error('Reap: CDP connect timed out')),
659
- REAP_CONNECT_TIMEOUT_MS,
660
- );
661
- }),
662
- ]);
663
- await closeBrowser(client, session.port);
664
- } finally {
665
- clearTimeout(reapTimer);
666
- try {
667
- client.close();
668
- } catch {
669
- /* already gone */
670
- }
671
- }
672
- launchedPids.delete(session.port);
673
- launchedUserDataDirs.delete(session.port);
674
- await removeSessionRecord(session.port);
675
- return session.port;
676
- } catch {
677
- return null;
678
- }
679
- }
680
-
681
- /** Chrome's browser-level debugger endpoint, from /json/version — the
682
- * connection `Browser.close` belongs on. Null if the port isn't answering as
683
- * a Chrome/Chromium CDP endpoint (same identity check as isChromeIdentity). */
684
- async function fetchBrowserWebSocketUrl(port: number): Promise<string | null> {
685
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
686
- signal: AbortSignal.timeout(1000),
687
- });
688
- if (!res.ok) return null;
689
- const info = (await res.json()) as { Browser?: string; webSocketDebuggerUrl?: string };
690
- if (typeof info.Browser !== 'string' || !/chrom(e|ium)|edg/i.test(info.Browser)) return null;
691
- return typeof info.webSocketDebuggerUrl === 'string' ? info.webSocketDebuggerUrl : null;
692
- }
693
-
694
- export async function openUrl(client: CdpClient, sessionId: string, url: string): Promise<void> {
695
- // Cap to 2 MB to prevent OOM in CDP message serializer (e.g. data: URI attacks)
696
- if (url.length > 2_097_152) throw new Error('URL exceeds 2 MB limit');
697
- // Page.navigate reports a protocol-level failure (e.g. a refused connection)
698
- // via errorText in its own response, before Chrome ever settles on the
699
- // chrome-error://chromewebdata/ page — without this check a refused
700
- // connection previously reported success against that error page.
701
- const nav = await client.send<{ errorText?: string }>('Page.navigate', { url }, sessionId);
702
- if (nav.errorText) throw new Error(`Navigation to ${url} failed: ${nav.errorText}`);
703
- await waitForNetworkIdle(client, sessionId, 500, 30_000);
704
- }
705
-
706
- export async function waitForLoad(
707
- client: CdpClient,
708
- sessionId: string,
709
- condition: 'load' | 'networkidle' | 'domcontentloaded' = 'load',
710
- timeout = 30_000,
711
- ): Promise<void> {
712
- if (condition === 'load' || condition === 'domcontentloaded') {
713
- // Guard against race where the page loads before the listener is registered
714
- const readyExpr =
715
- condition === 'load'
716
- ? 'document.readyState === "complete"'
717
- : 'document.readyState !== "loading"';
718
- const readyCheck = await client
719
- .send<{ result: { value?: boolean } }>(
720
- 'Runtime.evaluate',
721
- {
722
- expression: readyExpr,
723
- returnByValue: true,
724
- },
725
- sessionId,
726
- )
727
- .catch(() => ({ result: { value: false } }));
728
- if (readyCheck.result?.value) return;
729
-
730
- const event = condition === 'load' ? 'Page.loadEventFired' : 'Page.domContentEventFired';
731
- const [eventPromise, cancelOnce] = client.onceWithOff(event, sessionId);
732
- let timedOut = false;
733
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
734
- const timeoutPromise = new Promise<void>((resolve) => {
735
- timeoutHandle = setTimeout(() => {
736
- timedOut = true;
737
- resolve();
738
- }, timeout);
739
- });
740
- try {
741
- await Promise.race([eventPromise, timeoutPromise]);
742
- if (timedOut) throw new Error(`Timeout waiting for ${condition}`);
743
- } finally {
744
- cancelOnce();
745
- clearTimeout(timeoutHandle);
746
- }
747
- return;
748
- }
749
-
750
- // networkidle: no network requests for 500ms
751
- await waitForNetworkIdle(client, sessionId, 500, timeout);
752
- }
753
-
754
- async function waitForNetworkIdle(
755
- client: CdpClient,
756
- sessionId: string,
757
- idleMs: number,
758
- timeout: number,
759
- ): Promise<void> {
760
- return new Promise((resolve, reject) => {
761
- let pending = 0;
762
- const inflight = new Set<string>();
763
- let idleTimer: ReturnType<typeof setTimeout> | null = null;
764
- const killTimer = setTimeout(() => {
765
- cleanup();
766
- reject(new Error('Timeout waiting for networkidle'));
767
- }, timeout);
768
-
769
- const cleanup = () => {
770
- if (idleTimer) {
771
- clearTimeout(idleTimer);
772
- idleTimer = null;
773
- }
774
- clearTimeout(killTimer);
775
- offReq();
776
- offResp();
777
- offFail();
778
- offCache();
779
- offResp2();
780
- };
781
-
782
- const settle = () => {
783
- cleanup();
784
- resolve();
785
- };
786
-
787
- const check = () => {
788
- if (pending === 0) {
789
- if (idleTimer) clearTimeout(idleTimer);
790
- idleTimer = setTimeout(settle, idleMs);
791
- } else {
792
- if (idleTimer) {
793
- clearTimeout(idleTimer);
794
- idleTimer = null;
795
- }
796
- }
797
- };
798
-
799
- const offReq = client.on('Network.requestWillBeSent', (params, sid) => {
800
- if (sid !== sessionId) return;
801
- const id = params.requestId as string;
802
- if (!inflight.has(id)) {
803
- inflight.add(id);
804
- pending++;
805
- check();
806
- }
807
- });
808
-
809
- const decrement = (params: Record<string, unknown>, sid?: string) => {
810
- if (sid !== sessionId) return;
811
- const id = params.requestId as string;
812
- if (inflight.delete(id)) {
813
- pending = Math.max(0, pending - 1);
814
- check();
815
- }
816
- };
817
-
818
- const offResp = client.on('Network.loadingFinished', decrement);
819
- const offFail = client.on('Network.loadingFailed', decrement);
820
- const offCache = client.on('Network.requestServedFromCache', decrement);
821
- // Guard against requests that never fire loadingFinished/loadingFailed (e.g. data: URLs)
822
- // Skip 3xx redirect responses — the request continues under the same requestId
823
- const offResp2 = client.on('Network.responseReceived', (params, sid) => {
824
- const p = params as { requestId: string; response: { status: number } };
825
- if (p.response.status >= 300 && p.response.status < 400) return;
826
- decrement(params, sid);
827
- });
828
-
829
- check();
830
- });
831
- }
832
-
833
- export async function getCurrentUrl(client: CdpClient, sessionId: string): Promise<string> {
834
- const result = await client.send<{ result: { value: string } }>(
835
- 'Runtime.evaluate',
836
- {
837
- expression: 'location.href',
838
- returnByValue: true,
839
- },
840
- sessionId,
841
- );
842
- return result.result?.value ?? '';
843
- }
844
-
845
- export async function getCurrentTitle(client: CdpClient, sessionId: string): Promise<string> {
846
- const result = await client.send<{ result: { value: string } }>(
847
- 'Runtime.evaluate',
848
- {
849
- expression: 'document.title',
850
- returnByValue: true,
851
- },
852
- sessionId,
853
- );
854
- return result.result?.value ?? '';
855
- }
856
-
857
- function sleep(ms: number): Promise<void> {
858
- return new Promise((r) => setTimeout(r, ms));
859
- }
1
+ // File-size sweep: split into sibling modules (browser-state, browser-discovery,
2
+ // browser-launch, browser-lifecycle, browser-page). Pure move — this file now
3
+ // re-exports everything it used to define, so every existing import path
4
+ // keeps working.
5
+
6
+ export { isPortOpen } from './browser-discovery.js';
7
+ export { launchBrowser } from './browser-launch.js';
8
+ export { closeBrowser, reapIdleLaunchedBrowser } from './browser-lifecycle.js';
9
+ export {
10
+ connectToTarget,
11
+ enableSessionDomains,
12
+ getCurrentTitle,
13
+ getCurrentUrl,
14
+ openUrl,
15
+ waitForLoad,
16
+ } from './browser-page.js';
17
+ export { getLaunchedPid, getLaunchedUserDataDir } from './browser-state.js';