@achamm/veilbrowser 0.3.0

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/src/browser.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Browser: the top-level handle. Launches Chrome, opens the CDP socket, and
3
+ * hands out Page objects attached via flat sessions.
4
+ */
5
+ import { launchChrome, type LaunchOptions, type LaunchResult } from "./launcher.js";
6
+ import { CDP } from "./cdp.js";
7
+ import { Page } from "./page.js";
8
+
9
+ export class Browser {
10
+ private constructor(
11
+ private cdp: CDP,
12
+ private launch: LaunchResult,
13
+ private blockPrivate: boolean,
14
+ ) {}
15
+
16
+ static async launch(opts: LaunchOptions = {}): Promise<Browser> {
17
+ const launch = await launchChrome(opts);
18
+ const cdp = await CDP.connect(launch.webSocketDebuggerUrl);
19
+ // Default ON: a stealth browser shouldn't let visited sites port-scan your
20
+ // localhost/LAN. Opt out with { blockPrivateNetwork: false }.
21
+ return new Browser(cdp, launch, opts.blockPrivateNetwork ?? true);
22
+ }
23
+
24
+ /** Open a fresh tab and return an initialised Page. */
25
+ async newPage(): Promise<Page> {
26
+ const { targetId } = await this.cdp.send("Target.createTarget", { url: "about:blank" });
27
+ const { sessionId } = await this.cdp.send("Target.attachToTarget", {
28
+ targetId,
29
+ flatten: true,
30
+ });
31
+ const page = new Page(this.cdp, sessionId, targetId);
32
+ await page.init({ maskWebgl: this.launch.maskWebgl, blockPrivateNetwork: this.blockPrivate });
33
+ return page;
34
+ }
35
+
36
+ async close() {
37
+ try {
38
+ await this.cdp.send("Browser.close");
39
+ } catch {}
40
+ this.cdp.close();
41
+ this.launch.kill();
42
+ }
43
+ }
package/src/cdp.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Raw Chrome DevTools Protocol client.
3
+ *
4
+ * No puppeteer, no playwright, no chrome-remote-interface. Just a WebSocket and
5
+ * the protocol. We use "flat" session mode (Target.attachToTarget {flatten:true})
6
+ * so every command/event is tagged with a sessionId and we can talk to the
7
+ * browser and any number of page targets over a single socket.
8
+ *
9
+ * Crucially: we never call `Runtime.enable`. That command is one of the loudest
10
+ * automation tells on the web (it creates a detectable execution-context binding
11
+ * and fires events sites listen for). `Runtime.evaluate` works fine without it.
12
+ */
13
+
14
+ type Pending = { resolve: (v: any) => void; reject: (e: Error) => void; method: string };
15
+ type EventHandler = (params: any) => void;
16
+
17
+ export class CDP {
18
+ private ws!: WebSocket;
19
+ private nextId = 1;
20
+ private pending = new Map<number, Pending>();
21
+ // key: `${sessionId ?? ""}:${method}` -> set of handlers
22
+ private handlers = new Map<string, Set<EventHandler>>();
23
+ private closed = false;
24
+
25
+ private constructor(private url: string) {}
26
+
27
+ static async connect(webSocketDebuggerUrl: string): Promise<CDP> {
28
+ const cdp = new CDP(webSocketDebuggerUrl);
29
+ await cdp.open();
30
+ return cdp;
31
+ }
32
+
33
+ private open(): Promise<void> {
34
+ return new Promise((resolve, reject) => {
35
+ this.ws = new WebSocket(this.url);
36
+ this.ws.addEventListener("open", () => resolve());
37
+ this.ws.addEventListener("error", (e: any) =>
38
+ reject(new Error(`CDP socket error: ${e?.message ?? "unknown"}`)),
39
+ );
40
+ this.ws.addEventListener("close", () => {
41
+ this.closed = true;
42
+ for (const { reject } of this.pending.values())
43
+ reject(new Error("CDP connection closed"));
44
+ this.pending.clear();
45
+ });
46
+ this.ws.addEventListener("message", (ev: any) => this.onMessage(String(ev.data)));
47
+ });
48
+ }
49
+
50
+ private onMessage(data: string) {
51
+ let msg: any;
52
+ try {
53
+ msg = JSON.parse(data);
54
+ } catch {
55
+ return;
56
+ }
57
+ if (typeof msg.id === "number") {
58
+ const p = this.pending.get(msg.id);
59
+ if (!p) return;
60
+ this.pending.delete(msg.id);
61
+ if (msg.error) p.reject(new Error(`${p.method}: ${msg.error.message} (${msg.error.code})`));
62
+ else p.resolve(msg.result);
63
+ return;
64
+ }
65
+ // Event. Dispatch to handlers keyed with and without sessionId.
66
+ if (msg.method) {
67
+ const sid = msg.sessionId ?? "";
68
+ for (const key of [`${sid}:${msg.method}`, `*:${msg.method}`]) {
69
+ const set = this.handlers.get(key);
70
+ if (set) for (const h of [...set]) h(msg.params ?? {});
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Send a CDP command. Optionally scoped to a session (page target). */
76
+ send<T = any>(method: string, params: Record<string, any> = {}, sessionId?: string): Promise<T> {
77
+ if (this.closed) return Promise.reject(new Error("CDP connection closed"));
78
+ const id = this.nextId++;
79
+ const payload: any = { id, method, params };
80
+ if (sessionId) payload.sessionId = sessionId;
81
+ return new Promise<T>((resolve, reject) => {
82
+ this.pending.set(id, { resolve, reject, method });
83
+ this.ws.send(JSON.stringify(payload));
84
+ });
85
+ }
86
+
87
+ /** Subscribe to an event. Pass sessionId, or "*" to match any session. */
88
+ on(method: string, handler: EventHandler, sessionId = "*"): () => void {
89
+ const key = `${sessionId}:${method}`;
90
+ let set = this.handlers.get(key);
91
+ if (!set) this.handlers.set(key, (set = new Set()));
92
+ set.add(handler);
93
+ return () => set!.delete(handler);
94
+ }
95
+
96
+ /** Resolve once an event fires (with optional predicate / timeout). */
97
+ once(method: string, opts: { sessionId?: string; predicate?: (p: any) => boolean; timeout?: number } = {}): Promise<any> {
98
+ return new Promise((resolve, reject) => {
99
+ const off = this.on(
100
+ method,
101
+ (p) => {
102
+ if (opts.predicate && !opts.predicate(p)) return;
103
+ clearTimeout(timer);
104
+ off();
105
+ resolve(p);
106
+ },
107
+ opts.sessionId ?? "*",
108
+ );
109
+ const timer = setTimeout(() => {
110
+ off();
111
+ reject(new Error(`Timed out waiting for ${method}`));
112
+ }, opts.timeout ?? 30000);
113
+ });
114
+ }
115
+
116
+ /** Remove all handlers for a specific sessionId. Called on page close to prevent accumulation. */
117
+ clearHandlers(sessionId: string) {
118
+ // Remove all handlers keyed with this sessionId
119
+ const prefix = `${sessionId}:`;
120
+ for (const key of this.handlers.keys()) {
121
+ if (key.startsWith(prefix)) {
122
+ this.handlers.delete(key);
123
+ }
124
+ }
125
+ }
126
+
127
+ close() {
128
+ this.closed = true;
129
+ try {
130
+ this.ws.close();
131
+ } catch {}
132
+ }
133
+ }
package/src/human.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Human-like input timing.
3
+ *
4
+ * Robotic automation moves the mouse in straight teleports and types at a
5
+ * perfectly fixed cadence. Behavioural-fingerprinting scripts (Akamai, PerimeterX,
6
+ * Datadome) watch for exactly that. We move along a curved path with eased,
7
+ * jittered timing and vary keystroke intervals around human norms.
8
+ *
9
+ * Determinism note: we draw randomness from a seedable PRNG so runs can be made
10
+ * reproducible for tests, while still being non-uniform on the wire.
11
+ */
12
+ export class Rng {
13
+ private s: number;
14
+ constructor(seed = (Date.now() ^ (process.pid << 16)) >>> 0) {
15
+ this.s = seed >>> 0 || 1;
16
+ }
17
+ next(): number {
18
+ // xorshift32
19
+ let x = this.s;
20
+ x ^= x << 13;
21
+ x ^= x >>> 17;
22
+ x ^= x << 5;
23
+ this.s = x >>> 0;
24
+ return this.s / 0xffffffff;
25
+ }
26
+ range(min: number, max: number): number {
27
+ return min + this.next() * (max - min);
28
+ }
29
+ int(min: number, max: number): number {
30
+ return Math.floor(this.range(min, max + 1));
31
+ }
32
+ }
33
+
34
+ export interface Point {
35
+ x: number;
36
+ y: number;
37
+ }
38
+
39
+ /**
40
+ * Sample a curved path from `from` to `to` using a quadratic Bézier whose control
41
+ * point is offset perpendicular to the travel direction — the gentle arc a real
42
+ * hand makes. Returns ~steps points with eased spacing (slow-fast-slow).
43
+ */
44
+ export function mousePath(from: Point, to: Point, rng: Rng): Point[] {
45
+ const dx = to.x - from.x;
46
+ const dy = to.y - from.y;
47
+ const dist = Math.hypot(dx, dy);
48
+ const steps = Math.max(8, Math.min(40, Math.round(dist / 12)));
49
+
50
+ // Perpendicular control-point offset, scaled to distance, signed randomly.
51
+ const mag = Math.min(dist * 0.18, 60) * (rng.next() < 0.5 ? -1 : 1) * rng.range(0.4, 1);
52
+ const mx = (from.x + to.x) / 2 + (-dy / (dist || 1)) * mag;
53
+ const my = (from.y + to.y) / 2 + (dx / (dist || 1)) * mag;
54
+
55
+ const pts: Point[] = [];
56
+ for (let i = 1; i <= steps; i++) {
57
+ let t = i / steps;
58
+ // ease-in-out so the cursor accelerates then settles
59
+ t = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
60
+ const u = 1 - t;
61
+ const x = u * u * from.x + 2 * u * t * mx + t * t * to.x;
62
+ const y = u * u * from.y + 2 * u * t * my + t * t * to.y;
63
+ // sub-pixel jitter
64
+ pts.push({ x: x + rng.range(-0.6, 0.6), y: y + rng.range(-0.6, 0.6) });
65
+ }
66
+ pts[pts.length - 1] = { x: to.x, y: to.y }; // land exactly on target
67
+ return pts;
68
+ }
69
+
70
+ /** Per-step delay (ms) for mouse moves — short, slightly jittered. */
71
+ export function moveDelay(rng: Rng): number {
72
+ return rng.range(4, 12);
73
+ }
74
+
75
+ /** Per-character delay (ms) for typing — human burst-and-pause cadence. */
76
+ export function keyDelay(rng: Rng, char: string): number {
77
+ if (char === " ") return rng.range(60, 140);
78
+ if (/[.,!?]/.test(char)) return rng.range(120, 260); // micro-pause at punctuation
79
+ return rng.range(45, 130);
80
+ }
81
+
82
+ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { Browser } from "./browser.js";
2
+ export { Page, isPrivateHost, type Snapshot, type Element, type FedCmAccount, type FedCmDialog } from "./page.js";
3
+ export { launchChrome, findChrome, type LaunchOptions } from "./launcher.js";
4
+ export { CDP } from "./cdp.js";
5
+ export { STEALTH_SOURCE } from "./stealth.js";
6
+ export { Rng, mousePath } from "./human.js";
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Launches a REAL, unmodified Chrome. To a website, this process IS Chrome —
3
+ * identical TLS, identical JS engine, identical canvas/WebGL/font fingerprint —
4
+ * because it literally is the same binary a human runs.
5
+ *
6
+ * The whole stealth game at launch time is: don't add the switches that
7
+ * Puppeteer/Playwright add. Those tools flip on `--enable-automation` and a
8
+ * batch of `--disable-*` flags that change behaviour in fingerprintable ways.
9
+ * We launch with the flags a normal Chrome uses, minus the noise.
10
+ */
11
+ import { spawn, type ChildProcess } from "node:child_process";
12
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ const CANDIDATES = [
17
+ process.env.VEIL_CHROME,
18
+ "/opt/google/chrome/chrome",
19
+ "/usr/bin/google-chrome",
20
+ "/usr/bin/google-chrome-stable",
21
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
22
+ "/usr/bin/chromium",
23
+ "/usr/bin/chromium-browser",
24
+ "/snap/bin/chromium",
25
+ ].filter(Boolean) as string[];
26
+
27
+ export function findChrome(): string {
28
+ for (const c of CANDIDATES) if (existsSync(c)) return c;
29
+ throw new Error("No Chrome/Chromium found. Set VEIL_CHROME=/path/to/chrome");
30
+ }
31
+
32
+ export interface LaunchOptions {
33
+ headless?: boolean; // default false (headful is far less detectable)
34
+ userDataDir?: string; // persist cookies/history -> looks like a used profile
35
+ chromePath?: string;
36
+ windowSize?: { width: number; height: number };
37
+ /**
38
+ * Virtual-display (Xvfb) resolution — the `screen.*` a page sees. Defaults to a
39
+ * realistic desktop 1920x1080 so the Chrome window sits INSIDE the screen. A
40
+ * virtual display sized to the window (screen === window, window taller than
41
+ * screen) is a classic headless tell; a real monitor is bigger than the window.
42
+ * Only applies to veil's own auto-Xvfb, not an external DISPLAY.
43
+ */
44
+ screenSize?: { width: number; height: number };
45
+ proxy?: string; // e.g. "http://user:pass@host:port"
46
+ /**
47
+ * Block visited sites from reaching loopback / private-network hosts (default
48
+ * true). Detectors port-scan 127.0.0.1 from JS to fingerprint the machine's
49
+ * other software; it also leaks your LAN to every page. The agent's own
50
+ * top-level navigation to a private host still works. Set false to allow a
51
+ * page to reach localhost (e.g. driving your own local app via subresources).
52
+ */
53
+ blockPrivateNetwork?: boolean;
54
+ /**
55
+ * WebGL backend:
56
+ * - "hardware": use the real GPU via ANGLE/EGL → genuine, consistent vendor.
57
+ * Works headless AND headful (no Xvfb needed). Best stealth — nothing spoofed.
58
+ * - "software": SwiftShader + a spoofed Intel vendor. For GPU-less hosts only.
59
+ * - "off": no GL flags.
60
+ * - "auto" (default): "hardware" if a DRI render node is accessible, else "software".
61
+ */
62
+ gpu?: "hardware" | "software" | "off" | "auto";
63
+ /**
64
+ * Run headful on a virtual X display via Xvfb — "headful on a server". Headful
65
+ * Chrome scores far better against deep fingerprinters than headless (no
66
+ * headless render quirks, real screen size). Default "auto": on when headful is
67
+ * requested (headless:false) and there's no real DISPLAY. Requires Xvfb on PATH.
68
+ */
69
+ xvfb?: boolean;
70
+ extraArgs?: string[];
71
+ }
72
+
73
+ const RENDER_NODE = "/dev/dri/renderD128";
74
+
75
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
76
+
77
+ // --- Orphan-Chrome reaper --------------------------------------------------
78
+ // Chrome runs as a TREE (main + renderers + gpu + zygote). SIGKILL to just the
79
+ // main pid leaves the rest to reparent to init and run forever; and if the
80
+ // owner dies without calling kill() (Ctrl-C, an uncaught throw), nothing reaps
81
+ // them at all. Fix: every Chrome is spawned `detached` so it leads its own
82
+ // process group, we kill the whole GROUP, and a process-level reaper sweeps any
83
+ // survivors on exit/signal. (Puppeteer/Playwright do the same.)
84
+ interface LiveBrowser {
85
+ child: ChildProcess;
86
+ xvfb: ChildProcess | null;
87
+ userDataDir: string;
88
+ ephemeral: boolean;
89
+ }
90
+ const LIVE = new Set<LiveBrowser>();
91
+ let reaperInstalled = false;
92
+
93
+ function killGroup(proc: ChildProcess | null): void {
94
+ const pid = proc?.pid;
95
+ if (!pid) return;
96
+ try {
97
+ process.kill(-pid, "SIGKILL"); // negative pid = the whole process group
98
+ } catch {
99
+ try {
100
+ proc!.kill("SIGKILL"); // group already gone → fall back to the single pid
101
+ } catch {}
102
+ }
103
+ }
104
+
105
+ function reapAll(): void {
106
+ for (const b of LIVE) {
107
+ killGroup(b.child);
108
+ killGroup(b.xvfb);
109
+ }
110
+ }
111
+
112
+ const SIGNAL_EXIT: Record<string, number> = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
113
+
114
+ function installReaper(): void {
115
+ if (reaperInstalled) return;
116
+ reaperInstalled = true;
117
+ // 'exit' fires on a normal return AND during an uncaught-exception exit — sync
118
+ // context, exactly right for a group-kill.
119
+ process.on("exit", reapAll);
120
+ // Signals don't run 'exit' handlers on their own, so catch them, reap, and then
121
+ // leave with the conventional 128+signum code.
122
+ for (const sig of Object.keys(SIGNAL_EXIT)) {
123
+ process.on(sig as NodeJS.Signals, () => {
124
+ reapAll();
125
+ process.exit(SIGNAL_EXIT[sig]);
126
+ });
127
+ }
128
+ }
129
+
130
+ /** Start an Xvfb virtual display; resolves once it's ready, or null if unavailable. */
131
+ async function startXvfb(width: number, height: number): Promise<{ display: string; proc: ChildProcess } | null> {
132
+ let n = 99;
133
+ for (; n < 160; n++) if (!existsSync(`/tmp/.X${n}-lock`)) break;
134
+ const display = `:${n}`;
135
+ let proc: ChildProcess;
136
+ try {
137
+ proc = spawn("Xvfb", [display, "-screen", "0", `${width}x${height}x24`, "-nolisten", "tcp"], {
138
+ stdio: "ignore",
139
+ });
140
+ } catch {
141
+ return null;
142
+ }
143
+ let exited = false;
144
+ proc.on("exit", () => (exited = true));
145
+ const start = Date.now();
146
+ while (!existsSync(`/tmp/.X${n}-lock`) && !exited && Date.now() - start < 5000) await wait(50);
147
+ if (exited) return null;
148
+ return { display, proc };
149
+ }
150
+
151
+ /** Resolve "auto" → real GPU if we can reach the render node, else SwiftShader. */
152
+ function resolveGpu(mode: LaunchOptions["gpu"]): "hardware" | "software" | "off" {
153
+ if (mode && mode !== "auto") return mode;
154
+ try {
155
+ // accessSync would import fs; existsSync is already imported and the ACL
156
+ // grants read here. Presence of the node is a good-enough hardware signal.
157
+ return existsSync(RENDER_NODE) ? "hardware" : "software";
158
+ } catch {
159
+ return "software";
160
+ }
161
+ }
162
+
163
+ export interface LaunchResult {
164
+ webSocketDebuggerUrl: string;
165
+ process: ChildProcess;
166
+ userDataDir: string;
167
+ /** True only for SwiftShader (software) — the page layer should then mask the vendor. */
168
+ maskWebgl: boolean;
169
+ kill: () => void;
170
+ }
171
+
172
+ export async function launchChrome(opts: LaunchOptions = {}): Promise<LaunchResult> {
173
+ const chromePath = opts.chromePath ?? findChrome();
174
+ const ephemeral = !opts.userDataDir;
175
+ const userDataDir = opts.userDataDir ?? join(tmpdir(), `veil-${process.pid}-${Date.now()}`);
176
+ mkdirSync(userDataDir, { recursive: true });
177
+ // Reused profiles leave a stale DevToolsActivePort from the previous Chrome;
178
+ // waitForPort would read the OLD port and connect to a dead endpoint. Clear it
179
+ // so we wait for THIS launch's fresh port.
180
+ rmSync(join(userDataDir, "DevToolsActivePort"), { force: true });
181
+ // SingletonLock records who owns this profile — Chrome writes it as a symlink
182
+ // "<host>-<pid>". Only clear a STALE lock (its pid is dead). If a LIVE Chrome
183
+ // still owns the profile, REFUSE: two Chromes on one userDataDir silently
184
+ // corrupt it (this was the real cause of an "Initializing…" hang). Blindly
185
+ // removing the lock, as before, let that corruption happen.
186
+ const lockPath = join(userDataDir, "SingletonLock");
187
+ let ownerPid = -1; // -1 = no lock present
188
+ try { ownerPid = parseInt(readlinkSync(lockPath).split("-").pop() || "", 10); } catch {}
189
+ if (ownerPid > 0) {
190
+ let alive = false;
191
+ try { process.kill(ownerPid, 0); alive = true; } catch (e: any) { alive = e?.code === "EPERM"; }
192
+ if (alive) {
193
+ throw new Error(
194
+ `Profile "${userDataDir}" is already in use by Chrome (pid ${ownerPid}). ` +
195
+ `Close it, or launch with a different userDataDir.`,
196
+ );
197
+ }
198
+ }
199
+ rmSync(lockPath, { force: true }); // absent or stale → safe to clear
200
+ rmSync(join(userDataDir, "SingletonCookie"), { force: true });
201
+ rmSync(join(userDataDir, "SingletonSocket"), { force: true });
202
+ // The screen (virtual display) is a realistic desktop; the window sits INSIDE it
203
+ // and is never larger than it, positioned like a real user's window so that
204
+ // screen.* > window.outer.*, availHeight leaves room for a taskbar, and
205
+ // screenX/screenY are non-zero — none of the "display sized to the window" tells.
206
+ const screen = opts.screenSize ?? { width: 1920, height: 1080 };
207
+ const win = opts.windowSize ?? { width: 1280, height: 800 };
208
+ const width = Math.min(win.width, screen.width);
209
+ const height = Math.min(win.height, screen.height);
210
+ const posX = Math.max(0, (screen.width - width) >> 1);
211
+ const posY = Math.max(0, (screen.height - height) >> 1);
212
+
213
+ // Port 0 => Chrome picks a free port and writes it to DevToolsActivePort.
214
+ const args = [
215
+ `--remote-debugging-port=0`,
216
+ `--user-data-dir=${userDataDir}`,
217
+ `--window-size=${width},${height}`,
218
+ `--window-position=${posX},${posY}`,
219
+ // Stealth: navigator.webdriver is gated behind this blink feature. Disabling
220
+ // the "AutomationControlled" feature makes navigator.webdriver === false,
221
+ // matching a normal browser. (Playwright historically left it true.)
222
+ `--disable-blink-features=AutomationControlled`,
223
+ // Quiet, non-suspicious startup — these match a fresh real profile, they are
224
+ // NOT the automation-only switches that change fingerprintable behaviour.
225
+ `--no-first-run`,
226
+ `--no-default-browser-check`,
227
+ `--disable-features=Translate,OptimizationHints`,
228
+ `--password-store=basic`,
229
+ `--homepage=about:blank`,
230
+ `about:blank`,
231
+ ];
232
+ if (opts.headless) {
233
+ // headless=new is the modern engine; still more detectable than headful,
234
+ // so it's opt-in. Default product mode is headful on a real display/Xvfb.
235
+ args.unshift("--headless=new");
236
+ }
237
+ // WebGL backend. Hardware (real GPU via ANGLE/EGL) is preferred — it gives an
238
+ // authentic, self-consistent fingerprint and works headless without Xvfb. We
239
+ // only fall back to SwiftShader (and then mask its vendor) when there's no GPU.
240
+ const gpu = resolveGpu(opts.gpu);
241
+ let maskWebgl = false;
242
+ if (gpu === "hardware") {
243
+ args.push("--use-gl=angle", "--use-angle=gl-egl");
244
+ } else if (gpu === "software") {
245
+ args.push("--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader");
246
+ maskWebgl = true; // SwiftShader's vendor is a server tell — hide it.
247
+ }
248
+ if (opts.proxy) args.push(`--proxy-server=${opts.proxy}`);
249
+ if (opts.extraArgs) args.push(...opts.extraArgs);
250
+
251
+ // Headful on a server: bring up our own Xvfb display if there isn't a real one.
252
+ const childEnv = { ...process.env };
253
+ let xvfbProc: ChildProcess | null = null;
254
+ const wantXvfb = opts.xvfb ?? (!opts.headless && !process.env.DISPLAY);
255
+ if (wantXvfb) {
256
+ const xvfb = await startXvfb(screen.width, screen.height);
257
+ if (xvfb) {
258
+ xvfbProc = xvfb.proc;
259
+ childEnv.DISPLAY = xvfb.display;
260
+ } else if (!process.env.DISPLAY) {
261
+ // No virtual display available — degrade gracefully to headless rather than
262
+ // failing the whole launch. Still uses the real GPU; just a higher headless
263
+ // heuristic score. Better a working browser than none.
264
+ args.unshift("--headless=new");
265
+ }
266
+ }
267
+
268
+ // detached => Chrome leads its own process group, so killGroup() can take down
269
+ // the whole renderer/gpu/zygote tree in one shot instead of orphaning it.
270
+ const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"], env: childEnv, detached: true });
271
+
272
+ const live: LiveBrowser = { child, xvfb: xvfbProc, userDataDir, ephemeral };
273
+ LIVE.add(live);
274
+ installReaper();
275
+ // If Chrome dies on its own (crash, external kill), drop it from the reaper set,
276
+ // tear down its Xvfb, and clean an ephemeral profile — no leak, no stale dir.
277
+ child.on("exit", () => {
278
+ LIVE.delete(live);
279
+ killGroup(xvfbProc);
280
+ if (ephemeral) { try { rmSync(userDataDir, { recursive: true, force: true }); } catch {} }
281
+ });
282
+
283
+ const portFile = join(userDataDir, "DevToolsActivePort");
284
+ const wsPath = await waitForPort(portFile, child);
285
+ const port = wsPath.port;
286
+
287
+ // Fetch the browser-level WebSocket endpoint from Chrome's HTTP side.
288
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
289
+ const info = (await res.json()) as { webSocketDebuggerUrl: string };
290
+
291
+ const kill = () => {
292
+ LIVE.delete(live);
293
+ killGroup(child); // whole process group, not just the main pid
294
+ killGroup(xvfbProc);
295
+ if (ephemeral) {
296
+ try {
297
+ rmSync(userDataDir, { recursive: true, force: true });
298
+ } catch {}
299
+ }
300
+ };
301
+
302
+ return { webSocketDebuggerUrl: info.webSocketDebuggerUrl, process: child, userDataDir, maskWebgl, kill };
303
+ }
304
+
305
+ function waitForPort(portFile: string, child: ChildProcess): Promise<{ port: number }> {
306
+ return new Promise((resolve, reject) => {
307
+ let stderr = "";
308
+ child.stderr?.on("data", (d) => (stderr += d.toString()));
309
+ child.on("exit", (code) =>
310
+ reject(new Error(`Chrome exited early (code ${code}).\n${stderr.slice(-600)}`)),
311
+ );
312
+ const start = Date.now();
313
+ const tick = () => {
314
+ if (existsSync(portFile)) {
315
+ try {
316
+ const port = parseInt(readFileSync(portFile, "utf8").split("\n")[0]!.trim(), 10);
317
+ if (port > 0) return resolve({ port });
318
+ } catch {}
319
+ }
320
+ if (Date.now() - start > 15000) return reject(new Error("Chrome never opened a debug port"));
321
+ setTimeout(tick, 50);
322
+ };
323
+ tick();
324
+ });
325
+ }