veil-browser 0.3.0 → 0.4.1

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/dist/browser.js CHANGED
@@ -1,54 +1,361 @@
1
+ import { constants as fsConstants, promises as fs } from 'fs';
1
2
  import { chromium } from 'playwright';
2
- import { promises as fs } from 'fs';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
5
- import { loadSession } from './session.js';
6
- const VEIL_DIR = join(homedir(), '.veil');
3
+ import { resolve } from 'path';
4
+ import { loadSession, saveSession } from './session.js';
7
5
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
6
+ const DEFAULT_TIMEOUT_MS = 30000;
7
+ const KNOWN_BROWSERS = {
8
+ playwright: {},
9
+ chrome: {
10
+ executablePaths: [
11
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
12
+ '/usr/bin/google-chrome',
13
+ '/usr/bin/chromium',
14
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
15
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
16
+ ],
17
+ channel: 'chrome',
18
+ },
19
+ dia: {
20
+ executablePaths: [
21
+ '/Applications/Dia.app/Contents/MacOS/Dia',
22
+ ],
23
+ },
24
+ };
8
25
  let _browser = null;
9
26
  let _context = null;
10
27
  let _page = null;
11
- export async function ensureBrowser(opts = {}) {
12
- if (_browser?.isConnected() && _page && !_page.isClosed()) {
13
- return { browser: _browser, context: _context, page: _page };
28
+ let _closeTarget = 'browser';
29
+ let _exitHandlerRegistered = false;
30
+ /**
31
+ * Resolve a CLI option or environment variable override.
32
+ */
33
+ function resolveOption(value, envKey) {
34
+ if (value !== undefined)
35
+ return String(value);
36
+ return process.env[envKey];
37
+ }
38
+ /**
39
+ * Normalize a path-like option to an absolute path.
40
+ */
41
+ function normalizePath(value) {
42
+ if (!value)
43
+ return undefined;
44
+ return resolve(value);
45
+ }
46
+ /**
47
+ * Validate and normalize the requested browser launch mode.
48
+ */
49
+ async function resolveBrowserOptions(opts) {
50
+ const browserName = (resolveOption(opts.browser, 'VEIL_BROWSER') ?? 'playwright').toLowerCase();
51
+ const browserPath = normalizePath(resolveOption(opts.browserPath, 'VEIL_BROWSER_PATH'));
52
+ const cdpUrl = resolveOption(opts.cdpUrl, 'VEIL_CDP_URL');
53
+ const userDataDir = normalizePath(resolveOption(opts.userDataDir, 'VEIL_USER_DATA_DIR'));
54
+ const timeoutMs = parseTimeout(resolveOption(opts.timeoutMs, 'VEIL_BROWSER_TIMEOUT_MS'));
55
+ if (!isKnownBrowser(browserName) && !browserPath) {
56
+ throw new Error(`Unsupported browser "${browserName}". Use one of: ${Object.keys(KNOWN_BROWSERS).join(', ')} or pass --browser-path.`);
14
57
  }
15
- const browser = await chromium.launch({
16
- headless: !opts.headed,
17
- args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1280,800'],
18
- });
19
- const context = await browser.newContext({
58
+ if (cdpUrl && userDataDir) {
59
+ throw new Error('Use either --cdp-url or --user-data-dir, not both.');
60
+ }
61
+ if (cdpUrl)
62
+ validateCdpUrl(cdpUrl);
63
+ if (browserPath)
64
+ await ensureExecutable(browserPath);
65
+ if (userDataDir)
66
+ await ensureDirectory(userDataDir);
67
+ return {
68
+ browser: (isKnownBrowser(browserName) ? browserName : 'playwright'),
69
+ browserPath,
70
+ cdpUrl,
71
+ userDataDir,
72
+ headed: !!opts.headed,
73
+ platform: opts.platform,
74
+ timeoutMs,
75
+ };
76
+ }
77
+ /**
78
+ * Check whether the requested browser name is one of veil's built-in targets.
79
+ */
80
+ function isKnownBrowser(value) {
81
+ return value === 'playwright' || value === 'chrome' || value === 'dia';
82
+ }
83
+ /**
84
+ * Parse a timeout input into a positive millisecond value.
85
+ */
86
+ function parseTimeout(value) {
87
+ if (!value)
88
+ return DEFAULT_TIMEOUT_MS;
89
+ const parsed = Number.parseInt(value, 10);
90
+ if (!Number.isFinite(parsed) || parsed <= 0) {
91
+ throw new Error(`Invalid timeout "${value}". Expected a positive integer in milliseconds.`);
92
+ }
93
+ return parsed;
94
+ }
95
+ /**
96
+ * Validate that the requested CDP endpoint is well-formed.
97
+ */
98
+ function validateCdpUrl(url) {
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(url);
102
+ }
103
+ catch {
104
+ throw new Error(`Invalid CDP URL "${url}". Expected http(s)://host:port or ws(s)://...`);
105
+ }
106
+ const protocols = new Set(['http:', 'https:', 'ws:', 'wss:']);
107
+ if (!protocols.has(parsed.protocol)) {
108
+ throw new Error(`Unsupported CDP protocol "${parsed.protocol}". Use http, https, ws, or wss.`);
109
+ }
110
+ }
111
+ /**
112
+ * Verify that the requested browser executable exists and is runnable.
113
+ */
114
+ async function ensureExecutable(pathValue) {
115
+ try {
116
+ await fs.access(pathValue, fsConstants.X_OK);
117
+ }
118
+ catch {
119
+ throw new Error(`Browser executable is not accessible: ${pathValue}`);
120
+ }
121
+ }
122
+ /**
123
+ * Verify that the requested browser profile directory exists and is writable.
124
+ */
125
+ async function ensureDirectory(pathValue) {
126
+ await fs.mkdir(pathValue, { recursive: true });
127
+ try {
128
+ await fs.access(pathValue, fsConstants.R_OK | fsConstants.W_OK);
129
+ }
130
+ catch {
131
+ throw new Error(`Browser profile directory is not readable and writable: ${pathValue}`);
132
+ }
133
+ }
134
+ /**
135
+ * Locate a known browser executable on the current machine.
136
+ */
137
+ async function resolveExecutablePath(browser) {
138
+ const candidates = KNOWN_BROWSERS[browser].executablePaths ?? [];
139
+ for (const candidate of candidates) {
140
+ try {
141
+ await fs.access(candidate, fsConstants.X_OK);
142
+ return candidate;
143
+ }
144
+ catch {
145
+ // Try the next configured candidate.
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Build Playwright launch options for an ephemeral browser instance.
152
+ */
153
+ async function buildLaunchOptions(config) {
154
+ const options = {
155
+ headless: !config.headed,
156
+ timeout: config.timeoutMs,
157
+ args: [
158
+ '--disable-blink-features=AutomationControlled',
159
+ '--no-sandbox',
160
+ '--disable-setuid-sandbox',
161
+ '--window-size=1280,800',
162
+ '--disable-dev-shm-usage',
163
+ '--disable-gpu',
164
+ '--no-first-run',
165
+ '--no-default-browser-check',
166
+ '--disable-default-apps',
167
+ ],
168
+ };
169
+ if (config.browserPath) {
170
+ options.executablePath = config.browserPath;
171
+ return options;
172
+ }
173
+ const executablePath = await resolveExecutablePath(config.browser);
174
+ if (executablePath) {
175
+ options.executablePath = executablePath;
176
+ return options;
177
+ }
178
+ const channel = KNOWN_BROWSERS[config.browser].channel;
179
+ if (channel) {
180
+ options.channel = channel;
181
+ return options;
182
+ }
183
+ if (config.browser !== 'playwright') {
184
+ throw new Error(`Could not locate a ${config.browser} executable. Install it or pass --browser-path.`);
185
+ }
186
+ return options;
187
+ }
188
+ /**
189
+ * Build new-context options, restoring saved storage state when available.
190
+ */
191
+ function buildContextOptions(storageState) {
192
+ const options = {
20
193
  viewport: { width: 1280, height: 800 },
21
194
  userAgent: UA,
22
195
  locale: 'en-US',
23
196
  timezoneId: 'America/New_York',
24
197
  extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
25
- });
198
+ ignoreHTTPSErrors: true,
199
+ };
200
+ if (storageState) {
201
+ options.storageState = storageState;
202
+ }
203
+ return options;
204
+ }
205
+ /**
206
+ * Apply veil's anti-detection browser shims to a browser context.
207
+ */
208
+ async function applyStealthContext(context) {
26
209
  await context.addInitScript(() => {
27
210
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
211
+ Object.defineProperty(navigator, 'chromeapp', { get: () => undefined });
28
212
  window.chrome = { runtime: {} };
29
213
  Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
30
214
  Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
215
+ const permissionShim = {
216
+ query: (_permissionDesc) => Promise.resolve({ state: Notification.permission }),
217
+ };
218
+ Object.defineProperty(window.navigator, 'permissions', {
219
+ configurable: true,
220
+ value: permissionShim,
221
+ });
222
+ const originalToString = Function.prototype.toString;
223
+ Function.prototype.toString = function toString() {
224
+ if (this === permissionShim.query) {
225
+ return 'function query() { [native code] }';
226
+ }
227
+ return originalToString.call(this);
228
+ };
31
229
  });
32
- const session = await loadSession(opts.platform ?? 'default').catch(() => null);
33
- if (session?.cookies?.length)
34
- await context.addCookies(session.cookies).catch(() => { });
230
+ }
231
+ /**
232
+ * Open a fresh browser instance for one-shot veil operations.
233
+ */
234
+ async function launchEphemeralBrowser(config, storageState) {
235
+ const browser = await chromium.launch(await buildLaunchOptions(config));
236
+ const context = await browser.newContext(buildContextOptions(storageState));
237
+ await applyStealthContext(context);
35
238
  const page = await context.newPage();
36
239
  _browser = browser;
37
240
  _context = context;
38
241
  _page = page;
39
- await fs.mkdir(VEIL_DIR, { recursive: true });
40
- process.once('exit', () => { browser.close().catch(() => { }); });
242
+ _closeTarget = 'browser';
243
+ return { browser, context, page };
244
+ }
245
+ /**
246
+ * Open a persistent browser profile backed by a user-data directory.
247
+ */
248
+ async function launchPersistentBrowser(config) {
249
+ if (!config.userDataDir) {
250
+ throw new Error('Persistent browser launch requires a user data directory.');
251
+ }
252
+ const persistentOptions = {
253
+ ...(await buildLaunchOptions(config)),
254
+ ...buildContextOptions(null),
255
+ };
256
+ const context = await chromium.launchPersistentContext(config.userDataDir, persistentOptions);
257
+ await applyStealthContext(context);
258
+ const browser = context.browser();
259
+ if (!browser) {
260
+ await context.close();
261
+ throw new Error('Persistent browser launch succeeded but no browser handle was returned.');
262
+ }
263
+ const page = context.pages()[0] ?? await context.newPage();
264
+ _browser = browser;
265
+ _context = context;
266
+ _page = page;
267
+ _closeTarget = 'context';
268
+ return { browser, context, page };
269
+ }
270
+ /**
271
+ * Attach to a manually launched Chromium browser via CDP.
272
+ */
273
+ async function connectToExistingBrowser(config) {
274
+ if (!config.cdpUrl) {
275
+ throw new Error('CDP browser attach requires a CDP URL.');
276
+ }
277
+ const browser = await chromium.connectOverCDP(config.cdpUrl, { timeout: config.timeoutMs });
278
+ const context = browser.contexts()[0];
279
+ if (!context) {
280
+ await browser.close().catch(() => { });
281
+ throw new Error(`No browser context is available at ${config.cdpUrl}. Open a normal tab in the target browser and try again.`);
282
+ }
283
+ await applyStealthContext(context);
284
+ const page = context.pages()[0] ?? await context.newPage();
285
+ _browser = browser;
286
+ _context = context;
287
+ _page = page;
288
+ _closeTarget = 'none';
41
289
  return { browser, context, page };
42
290
  }
291
+ /**
292
+ * Register a single process-exit hook that respects veil's close policy.
293
+ */
294
+ function registerExitHandler() {
295
+ if (_exitHandlerRegistered)
296
+ return;
297
+ _exitHandlerRegistered = true;
298
+ process.once('exit', () => {
299
+ void closeBrowser();
300
+ });
301
+ }
302
+ /**
303
+ * Ensure a browser session is available, creating or attaching as needed.
304
+ */
305
+ export async function ensureBrowser(opts = {}) {
306
+ if (_browser?.isConnected() && _context && _page && !_page.isClosed()) {
307
+ return { browser: _browser, context: _context, page: _page };
308
+ }
309
+ const config = await resolveBrowserOptions(opts);
310
+ const storageState = config.platform ? await loadSession(config.platform).catch(() => null) : null;
311
+ registerExitHandler();
312
+ if (config.cdpUrl) {
313
+ return connectToExistingBrowser(config);
314
+ }
315
+ if (config.userDataDir) {
316
+ return launchPersistentBrowser(config);
317
+ }
318
+ return launchEphemeralBrowser(config, storageState);
319
+ }
320
+ /**
321
+ * Return the current live page if the browser is still open.
322
+ */
43
323
  export async function getPage() {
44
- return (_page && !_page.isClosed()) ? _page : null;
324
+ return _page && !_page.isClosed() ? _page : null;
45
325
  }
46
- export async function closeBrowser(_platform) {
47
- await _browser?.close().catch(() => { });
48
- _browser = null;
49
- _context = null;
50
- _page = null;
326
+ /**
327
+ * Persist the current storage state when requested and close veil-owned resources.
328
+ */
329
+ export async function closeBrowser(platform) {
330
+ if (_context && platform) {
331
+ try {
332
+ await saveSession(platform, await _context.storageState());
333
+ }
334
+ catch {
335
+ // Do not let session save errors block cleanup.
336
+ }
337
+ }
338
+ try {
339
+ if (_closeTarget === 'context' && _context) {
340
+ await _context.close();
341
+ }
342
+ else if (_closeTarget === 'browser' && _browser) {
343
+ await _browser.close();
344
+ }
345
+ }
346
+ catch {
347
+ // Ignore cleanup failures; the process may already be tearing down.
348
+ }
349
+ finally {
350
+ _browser = null;
351
+ _context = null;
352
+ _page = null;
353
+ _closeTarget = 'browser';
354
+ }
51
355
  }
356
+ /**
357
+ * Sleep for a short human-like randomized delay.
358
+ */
52
359
  export function humanDelay(min = 400, max = 900) {
53
- return new Promise(r => setTimeout(r, Math.floor(Math.random() * (max - min) + min)));
360
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, Math.floor(Math.random() * (max - min) + min)));
54
361
  }