@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/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/browser.d.ts +16 -0
- package/dist/browser.js +43 -0
- package/dist/cdp.d.ts +39 -0
- package/dist/cdp.js +128 -0
- package/dist/human.d.ts +33 -0
- package/dist/human.js +73 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/launcher.d.ts +67 -0
- package/dist/launcher.js +272 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +156 -0
- package/dist/page.d.ts +194 -0
- package/dist/page.js +527 -0
- package/dist/stealth.d.ts +28 -0
- package/dist/stealth.js +74 -0
- package/package.json +54 -0
- package/src/browser.ts +43 -0
- package/src/cdp.ts +133 -0
- package/src/human.ts +82 -0
- package/src/index.ts +6 -0
- package/src/launcher.ts +325 -0
- package/src/mcp.ts +155 -0
- package/src/page.ts +571 -0
- package/src/stealth.ts +103 -0
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";
|
package/src/launcher.ts
ADDED
|
@@ -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
|
+
}
|