@ricsam/r5d-browser 0.0.50 → 0.0.53

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.
@@ -0,0 +1,680 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var chrome_launcher_exports = {};
30
+ __export(chrome_launcher_exports, {
31
+ acquireChromeRunLock: () => acquireChromeRunLock,
32
+ assertChromeProfileAvailable: () => assertChromeProfileAvailable,
33
+ buildChromeLaunchArgs: () => buildChromeLaunchArgs,
34
+ defaultChromeLockPath: () => defaultChromeLockPath,
35
+ defaultChromeProfilePath: () => defaultChromeProfilePath,
36
+ launchStableChrome: () => launchStableChrome,
37
+ resolveChromeExecutable: () => resolveChromeExecutable,
38
+ validateChromeProfilePath: () => validateChromeProfilePath,
39
+ validateDebuggerWebSocketUrl: () => validateDebuggerWebSocketUrl,
40
+ validateEndpointOwnership: () => validateEndpointOwnership
41
+ });
42
+ module.exports = __toCommonJS(chrome_launcher_exports);
43
+ var import_node_child_process = require("node:child_process");
44
+ var import_node_fs = __toESM(require("node:fs"), 1);
45
+ var import_node_http = __toESM(require("node:http"), 1);
46
+ var import_node_net = __toESM(require("node:net"), 1);
47
+ var import_node_os = __toESM(require("node:os"), 1);
48
+ var import_node_path = __toESM(require("node:path"), 1);
49
+ var import_promises = require("node:timers/promises");
50
+ var import_playwright_core = require("playwright-core");
51
+ var import_ws = __toESM(require("ws"), 1);
52
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15e3;
53
+ const MAX_LAUNCH_ATTEMPTS = 3;
54
+ const GRACEFUL_CLOSE_TIMEOUT_MS = 5e3;
55
+ const TERMINATE_TIMEOUT_MS = 3e3;
56
+ const CDP_REQUEST_TIMEOUT_MS = 2e3;
57
+ const MAX_DIAGNOSTIC_BYTES = 32 * 1024;
58
+ const SYSTEM_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
59
+ class DebuggerPortCollisionError extends Error {
60
+ }
61
+ class ChromeExitedDuringStartupError extends Error {
62
+ }
63
+ function defaultChromeProfilePath(homeDirectory = import_node_os.default.homedir()) {
64
+ return import_node_path.default.join(homeDirectory, ".r5d", "browser", "chrome-profile");
65
+ }
66
+ function defaultChromeLockPath(homeDirectory = import_node_os.default.homedir()) {
67
+ return import_node_path.default.join(homeDirectory, ".r5d", "browser", "run.lock");
68
+ }
69
+ function describeChromeRequirement() {
70
+ return "Install stable Google Chrome from https://www.google.com/chrome/ or pass --chrome-path (or R5D_BROWSER_CHROME_PATH).";
71
+ }
72
+ function validateExecutable(candidate, source) {
73
+ const resolved = import_node_path.default.resolve(candidate);
74
+ try {
75
+ const stats = import_node_fs.default.statSync(resolved);
76
+ if (!stats.isFile()) throw new Error("not a regular file");
77
+ import_node_fs.default.accessSync(resolved, import_node_fs.default.constants.X_OK);
78
+ } catch (error) {
79
+ const detail = error instanceof Error ? error.message : String(error);
80
+ throw new Error(
81
+ `Google Chrome from ${source} is not an executable regular file: ${resolved} (${detail}). ${describeChromeRequirement()}`
82
+ );
83
+ }
84
+ return resolved;
85
+ }
86
+ function resolveChromeExecutable(options = {}) {
87
+ const env = options.env ?? process.env;
88
+ const homeDirectory = options.homeDirectory ?? import_node_os.default.homedir();
89
+ if (options.chromePath !== void 0) return validateExecutable(options.chromePath, "--chrome-path");
90
+ if (env.R5D_BROWSER_CHROME_PATH !== void 0) {
91
+ return validateExecutable(env.R5D_BROWSER_CHROME_PATH, "R5D_BROWSER_CHROME_PATH");
92
+ }
93
+ const candidates = [
94
+ options.systemChromePath ?? SYSTEM_CHROME_PATH,
95
+ import_node_path.default.join(homeDirectory, "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome")
96
+ ];
97
+ for (const candidate of candidates) {
98
+ try {
99
+ return validateExecutable(candidate, "the standard installation location");
100
+ } catch {
101
+ }
102
+ }
103
+ throw new Error(`Stable Google Chrome could not be found. ${describeChromeRequirement()}`);
104
+ }
105
+ function canonicalizePotentialPath(candidate) {
106
+ const resolved = import_node_path.default.resolve(candidate);
107
+ let existing = resolved;
108
+ const missingSegments = [];
109
+ while (!import_node_fs.default.existsSync(existing)) {
110
+ const parent = import_node_path.default.dirname(existing);
111
+ if (parent === existing) return resolved;
112
+ missingSegments.unshift(import_node_path.default.basename(existing));
113
+ existing = parent;
114
+ }
115
+ try {
116
+ return import_node_path.default.join(import_node_fs.default.realpathSync.native(existing), ...missingSegments);
117
+ } catch {
118
+ return resolved;
119
+ }
120
+ }
121
+ function validateChromeProfilePath(profilePath, homeDirectory = import_node_os.default.homedir()) {
122
+ const canonicalProfile = canonicalizePotentialPath(profilePath);
123
+ const dailyDriverRoot = canonicalizePotentialPath(import_node_path.default.join(homeDirectory, "Library", "Application Support", "Google", "Chrome"));
124
+ if (canonicalProfile === dailyDriverRoot || canonicalProfile.startsWith(`${dailyDriverRoot}${import_node_path.default.sep}`)) {
125
+ throw new Error(
126
+ `Refusing to control Chrome's daily-driver profile at ${import_node_path.default.resolve(profilePath)}. Use the dedicated r5d-browser profile or another separate directory.`
127
+ );
128
+ }
129
+ return canonicalProfile;
130
+ }
131
+ function isProcessAlive(pid) {
132
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
133
+ try {
134
+ process.kill(pid, 0);
135
+ return true;
136
+ } catch (error) {
137
+ return error.code === "EPERM";
138
+ }
139
+ }
140
+ function readLockRecord(lockPath) {
141
+ const stats = import_node_fs.default.lstatSync(lockPath);
142
+ if (!stats.isFile() || stats.isSymbolicLink()) throw new Error(`Browser run lock is not a regular file: ${lockPath}`);
143
+ const raw = import_node_fs.default.readFileSync(lockPath, "utf8");
144
+ const parsed = JSON.parse(raw);
145
+ if (!Number.isSafeInteger(parsed.pid) || Number(parsed.pid) <= 0 || typeof parsed.profilePath !== "string" || typeof parsed.token !== "string" || !parsed.token || typeof parsed.createdAt !== "string") {
146
+ throw new Error(`Browser run lock has invalid contents: ${lockPath}`);
147
+ }
148
+ return { raw, record: parsed, stats };
149
+ }
150
+ function acquireChromeRunLock(lockPath, profilePath, ownerPid = process.pid) {
151
+ const resolvedLockPath = import_node_path.default.resolve(lockPath);
152
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(resolvedLockPath), { recursive: true, mode: 448 });
153
+ for (let attempt = 0; attempt < 10; attempt += 1) {
154
+ const token = crypto.randomUUID();
155
+ const record = {
156
+ pid: ownerPid,
157
+ profilePath: import_node_path.default.resolve(profilePath),
158
+ token,
159
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
160
+ };
161
+ let descriptor;
162
+ try {
163
+ descriptor = import_node_fs.default.openSync(resolvedLockPath, import_node_fs.default.constants.O_CREAT | import_node_fs.default.constants.O_EXCL | import_node_fs.default.constants.O_WRONLY, 384);
164
+ import_node_fs.default.writeFileSync(descriptor, `${JSON.stringify(record)}
165
+ `, "utf8");
166
+ import_node_fs.default.fsyncSync(descriptor);
167
+ import_node_fs.default.closeSync(descriptor);
168
+ descriptor = void 0;
169
+ let released = false;
170
+ return {
171
+ lockPath: resolvedLockPath,
172
+ token,
173
+ release: () => {
174
+ if (released) return;
175
+ released = true;
176
+ try {
177
+ const current = readLockRecord(resolvedLockPath);
178
+ if (current.record.token === token) import_node_fs.default.unlinkSync(resolvedLockPath);
179
+ } catch (error) {
180
+ if (error.code !== "ENOENT") {
181
+ process.stderr.write(`[r5d-browser] could not release run lock: ${error instanceof Error ? error.message : String(error)}
182
+ `);
183
+ }
184
+ }
185
+ }
186
+ };
187
+ } catch (error) {
188
+ if (descriptor !== void 0) {
189
+ try {
190
+ import_node_fs.default.closeSync(descriptor);
191
+ } catch {
192
+ }
193
+ }
194
+ const code = error.code;
195
+ if (code !== "EEXIST") {
196
+ try {
197
+ const current = readLockRecord(resolvedLockPath);
198
+ if (current.record.token === token) import_node_fs.default.unlinkSync(resolvedLockPath);
199
+ } catch {
200
+ }
201
+ throw error;
202
+ }
203
+ let existing;
204
+ try {
205
+ existing = readLockRecord(resolvedLockPath);
206
+ } catch (readError) {
207
+ throw new Error(
208
+ `Cannot safely inspect the existing browser run lock at ${resolvedLockPath}: ${readError instanceof Error ? readError.message : String(readError)}`
209
+ );
210
+ }
211
+ if (isProcessAlive(existing.record.pid)) {
212
+ throw new Error(
213
+ `r5d-browser is already running (PID ${existing.record.pid}, profile ${existing.record.profilePath}). Only one browser may run at a time.`
214
+ );
215
+ }
216
+ try {
217
+ const currentStats = import_node_fs.default.lstatSync(resolvedLockPath);
218
+ const currentRaw = import_node_fs.default.readFileSync(resolvedLockPath, "utf8");
219
+ if (currentStats.dev !== existing.stats.dev || currentStats.ino !== existing.stats.ino || currentRaw !== existing.raw) continue;
220
+ import_node_fs.default.unlinkSync(resolvedLockPath);
221
+ } catch (unlinkError) {
222
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
223
+ }
224
+ }
225
+ }
226
+ throw new Error(`Could not acquire browser run lock after repeated concurrent changes: ${resolvedLockPath}`);
227
+ }
228
+ function singletonLockOwner(profilePath) {
229
+ const singletonLockPath = import_node_path.default.join(profilePath, "SingletonLock");
230
+ let target;
231
+ try {
232
+ target = import_node_fs.default.readlinkSync(singletonLockPath);
233
+ } catch (error) {
234
+ if (error.code === "ENOENT") return void 0;
235
+ return { description: singletonLockPath };
236
+ }
237
+ const match = /-(\d+)$/.exec(target);
238
+ const pid = match ? Number(match[1]) : void 0;
239
+ return { description: target, pid: Number.isSafeInteger(pid) ? pid : void 0 };
240
+ }
241
+ function assertChromeProfileAvailable(profilePath) {
242
+ const owner = singletonLockOwner(profilePath);
243
+ if (!owner) return;
244
+ if (owner.pid !== void 0 && !isProcessAlive(owner.pid)) return;
245
+ const ownerText = owner.pid === void 0 ? owner.description : `PID ${owner.pid}`;
246
+ throw new Error(
247
+ `The dedicated Google Chrome profile is already open (${ownerText}). Quit that Chrome window before starting r5d-browser; it will not attach to or terminate an existing browser.`
248
+ );
249
+ }
250
+ function buildChromeLaunchArgs(profilePath, debuggingPort) {
251
+ if (!Number.isInteger(debuggingPort) || debuggingPort < 1 || debuggingPort > 65535) {
252
+ throw new Error(`Invalid Chrome debugging port: ${debuggingPort}`);
253
+ }
254
+ return [
255
+ `--user-data-dir=${import_node_path.default.resolve(profilePath)}`,
256
+ "--remote-debugging-address=127.0.0.1",
257
+ `--remote-debugging-port=${debuggingPort}`,
258
+ "--no-first-run",
259
+ "--no-default-browser-check",
260
+ "--new-window",
261
+ "about:blank"
262
+ ];
263
+ }
264
+ function validateDebuggerWebSocketUrl(webSocketDebuggerUrl, debuggingPort) {
265
+ let parsed;
266
+ try {
267
+ parsed = new URL(webSocketDebuggerUrl);
268
+ } catch {
269
+ throw new DebuggerPortCollisionError("Chrome returned an invalid DevTools WebSocket URL.");
270
+ }
271
+ if (parsed.protocol !== "ws:" || parsed.hostname !== "127.0.0.1" || Number(parsed.port) !== debuggingPort || !/^\/devtools\/browser\/[^/]+$/.test(parsed.pathname) || parsed.username || parsed.password || parsed.search || parsed.hash) {
272
+ throw new DebuggerPortCollisionError(`Refusing unexpected DevTools WebSocket endpoint: ${webSocketDebuggerUrl}`);
273
+ }
274
+ return parsed.toString();
275
+ }
276
+ async function allocateLoopbackPort() {
277
+ const server = import_node_net.default.createServer();
278
+ await new Promise((resolve, reject) => {
279
+ server.once("error", reject);
280
+ server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => resolve());
281
+ });
282
+ const address = server.address();
283
+ if (!address || typeof address === "string") {
284
+ server.close();
285
+ throw new Error("Could not allocate a loopback debugging port.");
286
+ }
287
+ const port = address.port;
288
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
289
+ return port;
290
+ }
291
+ async function readDebuggerVersion(debuggingPort) {
292
+ return await new Promise((resolve, reject) => {
293
+ const request = import_node_http.default.get({ host: "127.0.0.1", port: debuggingPort, path: "/json/version", timeout: 500 }, (response) => {
294
+ const chunks = [];
295
+ let length = 0;
296
+ response.on("data", (chunk) => {
297
+ length += chunk.byteLength;
298
+ if (length > 1024 * 1024) {
299
+ request.destroy(new DebuggerPortCollisionError("DevTools version response exceeded 1 MiB."));
300
+ return;
301
+ }
302
+ chunks.push(chunk);
303
+ });
304
+ response.on("end", () => {
305
+ if (response.statusCode !== 200) {
306
+ reject(new DebuggerPortCollisionError(`Unexpected response on Chrome debugging port: HTTP ${response.statusCode ?? "unknown"}.`));
307
+ return;
308
+ }
309
+ try {
310
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
311
+ } catch {
312
+ reject(new DebuggerPortCollisionError("Unexpected non-JSON response on Chrome debugging port."));
313
+ }
314
+ });
315
+ });
316
+ request.once("timeout", () => request.destroy());
317
+ request.once("error", (error) => {
318
+ if (error.code === "ECONNREFUSED" || error.code === "ECONNRESET" || error.code === "ETIMEDOUT" || error.message === "socket hang up") {
319
+ resolve(void 0);
320
+ } else {
321
+ reject(error);
322
+ }
323
+ });
324
+ });
325
+ }
326
+ async function waitForDebuggerEndpoint(params) {
327
+ let exitResult;
328
+ params.exited.then((result) => {
329
+ exitResult = result;
330
+ });
331
+ const deadline = Date.now() + params.startupTimeoutMs;
332
+ while (Date.now() < deadline) {
333
+ if (exitResult) {
334
+ const detail = params.stderr().trim();
335
+ throw new ChromeExitedDuringStartupError(
336
+ `Google Chrome exited before its control endpoint became ready (${exitResult.signal ? `signal ${exitResult.signal}` : `code ${exitResult.code ?? "unknown"}`})${detail ? `: ${detail}` : "."}`
337
+ );
338
+ }
339
+ const version = await readDebuggerVersion(params.debuggingPort);
340
+ if (version) {
341
+ if (typeof version.webSocketDebuggerUrl !== "string") {
342
+ throw new DebuggerPortCollisionError("Chrome debugging endpoint did not return a WebSocket URL.");
343
+ }
344
+ return {
345
+ webSocketDebuggerUrl: validateDebuggerWebSocketUrl(version.webSocketDebuggerUrl, params.debuggingPort),
346
+ version
347
+ };
348
+ }
349
+ await (0, import_promises.setTimeout)(100);
350
+ }
351
+ throw new DebuggerPortCollisionError(`Google Chrome control endpoint did not become ready within ${params.startupTimeoutMs} ms.`);
352
+ }
353
+ class RawCdpConnection {
354
+ constructor(socket) {
355
+ this.socket = socket;
356
+ socket.on("message", (data) => {
357
+ let message;
358
+ try {
359
+ message = JSON.parse(data.toString());
360
+ } catch {
361
+ return;
362
+ }
363
+ if (typeof message.id !== "number") {
364
+ this.onmessage?.(message);
365
+ return;
366
+ }
367
+ const pending = this.pending.get(message.id);
368
+ if (!pending) {
369
+ this.onmessage?.(message);
370
+ return;
371
+ }
372
+ this.pending.delete(message.id);
373
+ clearTimeout(pending.timer);
374
+ if (message.error)
375
+ pending.reject(new Error(typeof message.error.message === "string" ? message.error.message : "CDP request failed."));
376
+ else pending.resolve(message.result);
377
+ });
378
+ const rejectPending = (error) => {
379
+ for (const pending of this.pending.values()) {
380
+ clearTimeout(pending.timer);
381
+ pending.reject(error);
382
+ }
383
+ this.pending.clear();
384
+ };
385
+ socket.on("error", (error) => rejectPending(error));
386
+ socket.once("close", (_code, reason) => {
387
+ rejectPending(new Error("Chrome DevTools connection closed."));
388
+ this.onclose?.(reason.toString());
389
+ });
390
+ }
391
+ socket;
392
+ nextId = 1;
393
+ pending = /* @__PURE__ */ new Map();
394
+ onmessage;
395
+ onclose;
396
+ static async connect(endpoint) {
397
+ const socket = new import_ws.default(endpoint);
398
+ socket.on("error", () => void 0);
399
+ await new Promise((resolve, reject) => {
400
+ const timer = setTimeout(() => {
401
+ socket.terminate();
402
+ reject(new Error("Timed out connecting to Chrome DevTools."));
403
+ }, CDP_REQUEST_TIMEOUT_MS);
404
+ socket.once("open", () => {
405
+ clearTimeout(timer);
406
+ resolve();
407
+ });
408
+ socket.once("error", (error) => {
409
+ clearTimeout(timer);
410
+ reject(error);
411
+ });
412
+ });
413
+ return new RawCdpConnection(socket);
414
+ }
415
+ send(methodOrMessage, params = {}) {
416
+ if (typeof methodOrMessage !== "string") {
417
+ this.socket.send(JSON.stringify(methodOrMessage));
418
+ return;
419
+ }
420
+ const method = methodOrMessage;
421
+ const id = this.nextId++;
422
+ return new Promise((resolve, reject) => {
423
+ const timer = setTimeout(() => {
424
+ this.pending.delete(id);
425
+ reject(new Error(`Timed out waiting for ${method}.`));
426
+ }, CDP_REQUEST_TIMEOUT_MS);
427
+ this.pending.set(id, { resolve, reject, timer });
428
+ this.socket.send(JSON.stringify({ id, method, params }), (error) => {
429
+ if (!error) return;
430
+ const pending = this.pending.get(id);
431
+ if (!pending) return;
432
+ this.pending.delete(id);
433
+ clearTimeout(pending.timer);
434
+ pending.reject(error);
435
+ });
436
+ });
437
+ }
438
+ async sendBrowserClose() {
439
+ await Promise.race([
440
+ this.send("Browser.close").then(() => void 0),
441
+ new Promise((resolve) => this.socket.once("close", () => resolve()))
442
+ ]);
443
+ }
444
+ close() {
445
+ if (this.socket.readyState !== import_ws.default.CLOSED) this.socket.terminate();
446
+ }
447
+ }
448
+ function validateEndpointOwnership(params) {
449
+ const browserProcess = params.processInfo?.find((processInfo) => processInfo.type === "browser");
450
+ if (!browserProcess || Number(browserProcess.id) !== params.chromePid) {
451
+ throw new DebuggerPortCollisionError("The DevTools endpoint belongs to a different browser process.");
452
+ }
453
+ if (typeof params.product !== "string" || !params.product) throw new Error("Chrome did not report its version.");
454
+ if (typeof params.commandLine !== "string" || !params.commandLine) {
455
+ throw new DebuggerPortCollisionError("The DevTools endpoint did not report its browser command line.");
456
+ }
457
+ const commandLine = params.commandLine;
458
+ if (!commandLine.includes(params.chromePath) || params.launchArgs.some((argument) => !commandLine.includes(argument))) {
459
+ throw new DebuggerPortCollisionError("The DevTools process command line does not match the Chrome process launched by r5d-browser.");
460
+ }
461
+ for (const forbidden of ["--enable-automation", "--remote-debugging-pipe", "--remote-debugging-port=0", "AutomationControlled"]) {
462
+ if (commandLine.includes(forbidden)) throw new Error(`Chrome unexpectedly started with forbidden automation flag ${forbidden}.`);
463
+ }
464
+ return params.product;
465
+ }
466
+ async function verifyEndpointOwnership(params) {
467
+ const cdp = await RawCdpConnection.connect(params.webSocketDebuggerUrl);
468
+ try {
469
+ const processResult = await cdp.send("SystemInfo.getProcessInfo");
470
+ const version = await cdp.send("Browser.getVersion");
471
+ const systemInfo = await cdp.send("SystemInfo.getInfo");
472
+ const chromeProduct = validateEndpointOwnership({
473
+ chromePid: params.chromePid,
474
+ chromePath: params.chromePath,
475
+ launchArgs: params.launchArgs,
476
+ processInfo: processResult.processInfo,
477
+ product: version.product,
478
+ commandLine: systemInfo.commandLine
479
+ });
480
+ return { chromeProduct, transport: cdp };
481
+ } catch (error) {
482
+ cdp.close();
483
+ throw error;
484
+ }
485
+ }
486
+ function observeChild(child) {
487
+ let diagnostics = "";
488
+ child.stderr?.on("data", (chunk) => {
489
+ diagnostics += chunk.toString();
490
+ if (Buffer.byteLength(diagnostics) > MAX_DIAGNOSTIC_BYTES) diagnostics = diagnostics.slice(-MAX_DIAGNOSTIC_BYTES);
491
+ });
492
+ const exited = new Promise((resolve) => {
493
+ let settled = false;
494
+ const finish = (result) => {
495
+ if (settled) return;
496
+ settled = true;
497
+ resolve(result);
498
+ };
499
+ child.once("exit", (code, signal) => finish({ code, signal }));
500
+ child.once("error", (error) => {
501
+ diagnostics += `${diagnostics ? "\n" : ""}${error.message}`;
502
+ finish({ code: null, signal: null });
503
+ });
504
+ });
505
+ return { exited, stderr: () => diagnostics };
506
+ }
507
+ async function waitForExit(exited, timeoutMs) {
508
+ let timer;
509
+ try {
510
+ return await Promise.race([
511
+ exited.then(() => true),
512
+ new Promise((resolve) => {
513
+ timer = setTimeout(() => resolve(false), timeoutMs);
514
+ })
515
+ ]);
516
+ } finally {
517
+ if (timer) clearTimeout(timer);
518
+ }
519
+ }
520
+ async function requestOwnedBrowserClose(webSocketDebuggerUrl, chromePid) {
521
+ const cdp = await RawCdpConnection.connect(webSocketDebuggerUrl);
522
+ try {
523
+ const processResult = await cdp.send("SystemInfo.getProcessInfo");
524
+ const browserProcess = processResult.processInfo?.find((processInfo) => processInfo.type === "browser");
525
+ if (!browserProcess || Number(browserProcess.id) !== chromePid) return false;
526
+ await cdp.sendBrowserClose();
527
+ return true;
528
+ } finally {
529
+ cdp.close();
530
+ }
531
+ }
532
+ async function terminateOwnedProcess(params) {
533
+ if (params.child.exitCode !== null || params.child.signalCode !== null) return;
534
+ if (params.browser?.isConnected()) {
535
+ void params.browser.newBrowserCDPSession().then(async (cdp) => {
536
+ await cdp.send("Browser.close");
537
+ }).catch(() => void 0);
538
+ if (await waitForExit(params.exited, GRACEFUL_CLOSE_TIMEOUT_MS)) return;
539
+ } else if (params.ownedWebSocketDebuggerUrl) {
540
+ const closeRequested = await requestOwnedBrowserClose(params.ownedWebSocketDebuggerUrl, params.chromePid).catch(() => false);
541
+ if (closeRequested && await waitForExit(params.exited, GRACEFUL_CLOSE_TIMEOUT_MS)) return;
542
+ }
543
+ if (params.child.exitCode === null && params.child.signalCode === null) params.child.kill("SIGTERM");
544
+ if (await waitForExit(params.exited, TERMINATE_TIMEOUT_MS)) return;
545
+ if (params.child.exitCode === null && params.child.signalCode === null) params.child.kill("SIGKILL");
546
+ await waitForExit(params.exited, TERMINATE_TIMEOUT_MS);
547
+ }
548
+ function occupiedProfileError(profilePath) {
549
+ const owner = singletonLockOwner(profilePath);
550
+ if (!owner) return void 0;
551
+ if (owner.pid !== void 0 && !isProcessAlive(owner.pid)) return void 0;
552
+ const ownerText = owner.pid === void 0 ? owner.description : `PID ${owner.pid}`;
553
+ return new Error(
554
+ `The dedicated Google Chrome profile is already open (${ownerText}). Quit that Chrome window before starting r5d-browser; it will not attach to or terminate an existing browser.`
555
+ );
556
+ }
557
+ async function launchStableChrome(options) {
558
+ if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
559
+ const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
560
+ if (!Number.isFinite(startupTimeoutMs) || startupTimeoutMs <= 0) throw new Error("startupTimeoutMs must be positive.");
561
+ const chromePath = resolveChromeExecutable({ chromePath: options.chromePath });
562
+ const profilePath = validateChromeProfilePath(options.profilePath);
563
+ const playwrightDownloadsPath = import_node_path.default.resolve(options.playwrightDownloadsPath);
564
+ const lock = acquireChromeRunLock(options.lockPath ?? defaultChromeLockPath(), profilePath);
565
+ try {
566
+ import_node_fs.default.mkdirSync(profilePath, { recursive: true, mode: 448 });
567
+ import_node_fs.default.mkdirSync(playwrightDownloadsPath, { recursive: true, mode: 448 });
568
+ import_node_fs.default.chmodSync(profilePath, 448);
569
+ import_node_fs.default.chmodSync(playwrightDownloadsPath, 448);
570
+ assertChromeProfileAvailable(profilePath);
571
+ let lastError;
572
+ for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) {
573
+ const debuggingPort = await allocateLoopbackPort();
574
+ const launchArgs = buildChromeLaunchArgs(profilePath, debuggingPort);
575
+ const child = (0, import_node_child_process.spawn)(chromePath, launchArgs, { stdio: ["ignore", "ignore", "pipe"] });
576
+ const observed = observeChild(child);
577
+ const chromePid = child.pid;
578
+ if (chromePid === void 0) {
579
+ await observed.exited;
580
+ throw new Error(`Could not start Google Chrome${observed.stderr().trim() ? `: ${observed.stderr().trim()}` : "."}`);
581
+ }
582
+ let ownedWebSocketDebuggerUrl;
583
+ try {
584
+ const endpoint = await waitForDebuggerEndpoint({
585
+ debuggingPort,
586
+ exited: observed.exited,
587
+ startupTimeoutMs,
588
+ stderr: observed.stderr
589
+ });
590
+ const ownedEndpoint = await verifyEndpointOwnership({
591
+ webSocketDebuggerUrl: endpoint.webSocketDebuggerUrl,
592
+ chromePid,
593
+ chromePath,
594
+ launchArgs
595
+ });
596
+ ownedWebSocketDebuggerUrl = endpoint.webSocketDebuggerUrl;
597
+ const browser = await import_playwright_core.chromium.connectOverCDP(ownedEndpoint.transport, {
598
+ artifactsDir: playwrightDownloadsPath,
599
+ isLocal: true,
600
+ noDefaults: false,
601
+ timeout: startupTimeoutMs
602
+ });
603
+ const context = browser.contexts()[0];
604
+ if (!context) throw new Error("Google Chrome did not expose its default browser context.");
605
+ const chromeVersion = browser.version();
606
+ if (!/^\d+(?:\.\d+)+$/.test(chromeVersion)) throw new Error(`Google Chrome reported an invalid version: ${chromeVersion}`);
607
+ const hadExistingPages = context.pages().length > 0;
608
+ const verificationPage = await context.newPage();
609
+ try {
610
+ await verificationPage.goto("about:blank");
611
+ if (await verificationPage.evaluate(() => navigator.webdriver) !== false) {
612
+ throw new Error("Google Chrome unexpectedly exposed navigator.webdriver; refusing an automation-marked session.");
613
+ }
614
+ } finally {
615
+ if (hadExistingPages) await verificationPage.close().catch(() => void 0);
616
+ }
617
+ const disconnected = browser.isConnected() ? new Promise((resolve) => browser.once("disconnected", () => resolve())) : Promise.resolve();
618
+ let closePromise;
619
+ const close = () => {
620
+ closePromise ??= (async () => {
621
+ try {
622
+ await terminateOwnedProcess({
623
+ child,
624
+ exited: observed.exited,
625
+ chromePid,
626
+ ownedWebSocketDebuggerUrl,
627
+ browser
628
+ });
629
+ await browser.close().catch(() => void 0);
630
+ } finally {
631
+ lock.release();
632
+ }
633
+ })();
634
+ return closePromise;
635
+ };
636
+ return {
637
+ browser,
638
+ context,
639
+ chromeVersion,
640
+ chromePid,
641
+ debuggingPort,
642
+ profilePath,
643
+ exited: observed.exited,
644
+ disconnected,
645
+ close
646
+ };
647
+ } catch (error) {
648
+ lastError = error;
649
+ await terminateOwnedProcess({
650
+ child,
651
+ exited: observed.exited,
652
+ chromePid,
653
+ ownedWebSocketDebuggerUrl
654
+ });
655
+ const occupied = occupiedProfileError(profilePath);
656
+ if (occupied) throw occupied;
657
+ if (!(error instanceof DebuggerPortCollisionError)) throw error;
658
+ }
659
+ }
660
+ throw new Error(
661
+ `Could not launch Google Chrome after ${MAX_LAUNCH_ATTEMPTS} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`
662
+ );
663
+ } catch (error) {
664
+ lock.release();
665
+ throw error;
666
+ }
667
+ }
668
+ // Annotate the CommonJS export names for ESM import in node:
669
+ 0 && (module.exports = {
670
+ acquireChromeRunLock,
671
+ assertChromeProfileAvailable,
672
+ buildChromeLaunchArgs,
673
+ defaultChromeLockPath,
674
+ defaultChromeProfilePath,
675
+ launchStableChrome,
676
+ resolveChromeExecutable,
677
+ validateChromeProfilePath,
678
+ validateDebuggerWebSocketUrl,
679
+ validateEndpointOwnership
680
+ });