@ricsam/r5d-browser 0.0.51 → 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.
package/dist/cjs/main.cjs CHANGED
@@ -25,35 +25,31 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
  var import_node_fs = __toESM(require("node:fs"), 1);
26
26
  var import_node_os = __toESM(require("node:os"), 1);
27
27
  var import_node_path = __toESM(require("node:path"), 1);
28
- var import_node_child_process = require("node:child_process");
29
- var import_promises = require("node:timers/promises");
30
28
  var import_ws = __toESM(require("ws"), 1);
31
- var import_playwright_install = require("./playwright-install.cjs");
29
+ var import_browser_runtime = require("./browser-runtime.cjs");
30
+ var import_cli_options = require("./cli-options.cjs");
31
+ var import_chrome_launcher = require("./chrome-launcher.cjs");
32
32
  var import_port_forward_manager = require("./port-forward-manager.cjs");
33
33
  const DEFAULT_BASE_URL = "https://r5d.dev";
34
34
  const PACKAGE_NAME = "@ricsam/r5d-browser";
35
35
  const RECONNECT_DELAY_MS = 2e3;
36
36
  const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
37
- const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
37
+ const CHROME_DISCONNECT_EXIT_GRACE_MS = 2e3;
38
38
  function browserRoot() {
39
39
  return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "browser");
40
40
  }
41
- function defaultConfigPath() {
42
- return import_node_path.default.join(import_node_os.default.homedir(), ".config", "r5d", "r5dctl", "config.json");
43
- }
44
41
  function printHelp() {
45
42
  process.stdout.write(`Usage:
46
43
  r5d-browser start [--base-url <url>] [--token <token>] [--api-key <key>]
47
- r5d-browser install
48
44
  r5d-browser --version
49
45
 
50
46
  Options:
51
47
  --base-url <url> r5d.dev base URL (default from r5dctl config or ${DEFAULT_BASE_URL})
52
- --token <token> Interactive r5d token
48
+ --token <token> r5d device credential
53
49
  --api-key <key> r5d API key
54
50
  --config <path> Shared r5dctl config path
55
- --profile <path> Chromium profile path (default ~/.r5d/browser/profile)
56
- --browsers-path <dir> Playwright browser binaries (default ~/.r5d/browser/browsers)
51
+ --profile <path> Google Chrome profile path (default ~/.r5d/browser/chrome-profile)
52
+ --chrome-path <path> Google Chrome executable (default: auto-detect stable Chrome)
57
53
  -v, --version Show r5d-browser version
58
54
  -h, --help Show this help
59
55
  `);
@@ -77,53 +73,6 @@ function findVersion() {
77
73
  }
78
74
  return "unknown";
79
75
  }
80
- function parseArgs(argv) {
81
- const options = { configPath: defaultConfigPath(), help: false, version: false };
82
- const rest = [];
83
- for (let index = 0; index < argv.length; index += 1) {
84
- const arg = argv[index];
85
- if (!arg) continue;
86
- if (arg === "-h" || arg === "--help") {
87
- options.help = true;
88
- continue;
89
- }
90
- if (arg === "-v" || arg === "--version") {
91
- options.version = true;
92
- continue;
93
- }
94
- const readOption = (name) => {
95
- if (arg === name) {
96
- index += 1;
97
- const value = argv[index];
98
- if (!value) throw new Error(`Missing value for ${name}`);
99
- return value;
100
- }
101
- return arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : void 0;
102
- };
103
- const entries = [
104
- ["--base-url", "baseUrl"],
105
- ["--token", "token"],
106
- ["--api-key", "apiKey"],
107
- ["--config", "configPath"],
108
- ["--profile", "profilePath"],
109
- ["--browsers-path", "browsersPath"]
110
- ];
111
- let matched = false;
112
- for (const [name, key] of entries) {
113
- const value = readOption(name);
114
- if (value !== void 0) {
115
- options[key] = value;
116
- matched = true;
117
- break;
118
- }
119
- }
120
- if (!matched) rest.push(arg);
121
- }
122
- if (options.version) return { command: "version", options };
123
- if (options.help || rest.length === 0) return { command: "help", options };
124
- if (rest[0] !== "start" && rest[0] !== "install") throw new Error(`Unknown command: ${rest[0]}`);
125
- return { command: rest[0], options };
126
- }
127
76
  function readConfig(configPath) {
128
77
  if (!import_node_fs.default.existsSync(configPath)) return {};
129
78
  const parsed = JSON.parse(import_node_fs.default.readFileSync(configPath, "utf8"));
@@ -145,18 +94,6 @@ function resolveConnection(options) {
145
94
  token
146
95
  };
147
96
  }
148
- function installChromium(browsersPath) {
149
- import_node_fs.default.mkdirSync(browsersPath, { recursive: true });
150
- process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
151
- const cliPath = (0, import_playwright_install.resolvePlaywrightCliPath)();
152
- process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
153
- `);
154
- const result = (0, import_node_child_process.spawnSync)(process.execPath, [cliPath, "install", "chromium"], {
155
- stdio: "inherit",
156
- env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersPath }
157
- });
158
- if (result.status !== 0) throw new Error(`Chromium installation failed with exit code ${result.status ?? "unknown"}.`);
159
- }
160
97
  function loadInstanceId() {
161
98
  const instancePath = import_node_path.default.join(browserRoot(), "instance-id");
162
99
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(instancePath), { recursive: true });
@@ -170,271 +107,37 @@ function loadInstanceId() {
170
107
  `, { mode: 384 });
171
108
  return id;
172
109
  }
173
- class BrowserRuntime {
174
- constructor(context, downloadsPath) {
175
- this.context = context;
176
- this.downloadsPath = downloadsPath;
177
- import_node_fs.default.mkdirSync(downloadsPath, { recursive: true });
178
- for (const page of context.pages()) this.track(page);
179
- context.on("page", (page) => this.track(page));
180
- }
181
- context;
182
- downloadsPath;
183
- ids = /* @__PURE__ */ new WeakMap();
184
- pages = /* @__PURE__ */ new Map();
185
- cursor = /* @__PURE__ */ new Map();
186
- downloadSaveQueue = Promise.resolve();
187
- track(page) {
188
- const existing = this.ids.get(page);
189
- if (existing) return existing;
190
- const id = crypto.randomUUID();
191
- this.ids.set(page, id);
192
- this.pages.set(id, page);
193
- page.on("download", (download) => {
194
- this.downloadSaveQueue = this.downloadSaveQueue.then(() => this.persistDownload(download)).catch((error) => {
195
- process.stderr.write(`[r5d-browser] failed to save download: ${error instanceof Error ? error.message : String(error)}
196
- `);
197
- });
198
- });
199
- page.once("close", () => {
200
- this.pages.delete(id);
201
- this.cursor.delete(id);
202
- });
203
- return id;
204
- }
205
- safeDownloadFilename(suggestedFilename) {
206
- const basename = import_node_path.default.basename(suggestedFilename).replace(/[\\/\0]/g, "-").trim();
207
- if (!basename || basename === "." || basename === "..") return "download";
208
- const extension = import_node_path.default.extname(basename).slice(0, 30);
209
- let stem = basename.slice(0, basename.length - import_node_path.default.extname(basename).length) || "download";
210
- while (Buffer.byteLength(`${stem}${extension}`) > 200) stem = stem.slice(0, -1);
211
- return `${stem || "download"}${extension}`;
212
- }
213
- availableDownloadPath(suggestedFilename) {
214
- const filename = this.safeDownloadFilename(suggestedFilename);
215
- const extension = import_node_path.default.extname(filename);
216
- const stem = filename.slice(0, filename.length - extension.length) || "download";
217
- for (let suffix = 1; ; suffix += 1) {
218
- const candidate = suffix === 1 ? filename : `${stem}-${suffix}${extension}`;
219
- const candidatePath = import_node_path.default.join(this.downloadsPath, candidate);
220
- if (!import_node_fs.default.existsSync(candidatePath)) return candidatePath;
221
- }
222
- }
223
- async persistDownload(download) {
224
- const failure = await download.failure();
225
- if (failure) throw new Error(failure);
226
- const targetPath = this.availableDownloadPath(download.suggestedFilename());
227
- await download.saveAs(targetPath);
228
- process.stdout.write(`[r5d-browser] downloaded ${import_node_path.default.basename(targetPath)}
229
- `);
230
- }
231
- downloadId(filename) {
232
- return Buffer.from(filename, "utf8").toString("base64url");
233
- }
234
- resolveDownload(downloadId) {
235
- if (typeof downloadId !== "string" || !downloadId) throw new Error("downloadId is required.");
236
- const filename = Buffer.from(downloadId, "base64url").toString("utf8");
237
- if (this.downloadId(filename) !== downloadId || filename !== import_node_path.default.basename(filename) || filename.includes("\0")) {
238
- throw new Error("Invalid downloadId.");
239
- }
240
- const filePath = import_node_path.default.join(this.downloadsPath, filename);
241
- let stats;
242
- try {
243
- stats = import_node_fs.default.lstatSync(filePath);
244
- } catch {
245
- throw new Error(`Browser download ${downloadId} no longer exists.`);
246
- }
247
- if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Browser download is not a regular file.");
248
- return { filename, filePath, stats };
249
- }
250
- async listDownloads() {
251
- await this.downloadSaveQueue;
252
- return import_node_fs.default.readdirSync(this.downloadsPath, { withFileTypes: true }).flatMap((entry) => {
253
- if (!entry.isFile() || entry.name.startsWith(".")) return [];
254
- const { filename, stats } = this.resolveDownload(this.downloadId(entry.name));
255
- return [
256
- {
257
- downloadId: this.downloadId(filename),
258
- filename,
259
- size: stats.size,
260
- modifiedAt: stats.mtime.toISOString()
261
- }
262
- ];
263
- }).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.filename.localeCompare(right.filename));
264
- }
265
- async getDownloadChunk(input) {
266
- await this.downloadSaveQueue;
267
- const { filename, filePath, stats } = this.resolveDownload(input.downloadId);
268
- const offset = input.offset === void 0 ? 0 : Number(input.offset);
269
- const requestedBytes = input.maxBytes === void 0 ? MAX_DOWNLOAD_CHUNK_BYTES : Number(input.maxBytes);
270
- if (!Number.isSafeInteger(offset) || offset < 0 || offset > stats.size) throw new Error("Invalid download offset.");
271
- if (!Number.isSafeInteger(requestedBytes) || requestedBytes < 1) throw new Error("Invalid download chunk size.");
272
- const chunkSize = Math.min(requestedBytes, MAX_DOWNLOAD_CHUNK_BYTES, stats.size - offset);
273
- const bytes = Buffer.alloc(chunkSize);
274
- const handle = import_node_fs.default.openSync(filePath, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
275
- let bytesRead = 0;
276
- try {
277
- const openedStats = import_node_fs.default.fstatSync(handle);
278
- if (!openedStats.isFile() || openedStats.dev !== stats.dev || openedStats.ino !== stats.ino) {
279
- throw new Error("Browser download changed before it could be read.");
280
- }
281
- bytesRead = import_node_fs.default.readSync(handle, bytes, 0, chunkSize, offset);
282
- const completedStats = import_node_fs.default.fstatSync(handle);
283
- if (completedStats.size !== stats.size || completedStats.mtimeMs !== stats.mtimeMs) {
284
- throw new Error("Browser download changed while it was being read.");
285
- }
286
- } finally {
287
- import_node_fs.default.closeSync(handle);
288
- }
289
- const nextOffset = offset + bytesRead;
290
- return {
291
- downloadId: this.downloadId(filename),
292
- filename,
293
- size: stats.size,
294
- modifiedAt: stats.mtime.toISOString(),
295
- offset,
296
- nextOffset,
297
- eof: nextOffset === stats.size,
298
- base64: bytes.subarray(0, bytesRead).toString("base64")
110
+ async function waitForReconnectDelay(signal) {
111
+ if (signal.aborted) return;
112
+ await new Promise((resolve) => {
113
+ const finish = () => {
114
+ clearTimeout(timer);
115
+ signal.removeEventListener("abort", finish);
116
+ resolve();
299
117
  };
300
- }
301
- page(tabId) {
302
- if (typeof tabId !== "string") throw new Error("tabId is required.");
303
- const page = this.pages.get(tabId);
304
- if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
305
- return page;
306
- }
307
- async tabInfo(page) {
308
- const tabId = this.track(page);
309
- let windowId = "unknown";
310
- try {
311
- const cdp = await this.context.newCDPSession(page);
312
- const result = await cdp.send("Browser.getWindowForTarget");
313
- windowId = String(result.windowId);
314
- await cdp.detach();
315
- } catch {
316
- }
317
- return { tabId, windowId, title: await page.title().catch(() => ""), url: page.url() };
318
- }
319
- async listTabs() {
320
- return await Promise.all([...this.pages.values()].filter((page) => !page.isClosed()).map((page) => this.tabInfo(page)));
321
- }
322
- async execute(action, input) {
323
- if (action === "list_tabs") return { tabs: await this.listTabs() };
324
- if (action === "list_downloads") return { downloads: await this.listDownloads() };
325
- if (action === "get_download") return await this.getDownloadChunk(input);
326
- if (action === "open_tab") {
327
- const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
328
- let page2;
329
- if (previous) {
330
- const cdp = await this.context.newCDPSession(previous);
331
- const pagePromise = this.context.waitForEvent("page");
332
- await cdp.send("Target.createTarget", {
333
- url: "about:blank",
334
- newWindow: input.disposition === "window",
335
- background: true
336
- });
337
- page2 = await pagePromise;
338
- await cdp.detach();
339
- if (input.disposition === "window") {
340
- const pageCdp = await this.context.newCDPSession(page2);
341
- const { windowId } = await pageCdp.send("Browser.getWindowForTarget");
342
- await pageCdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "minimized" } });
343
- await pageCdp.detach();
344
- }
345
- } else {
346
- page2 = await this.context.newPage();
347
- }
348
- const url = typeof input.url === "string" ? input.url : "about:blank";
349
- if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
350
- return { tab: await this.tabInfo(page2) };
351
- }
352
- const page = this.page(input.tabId);
353
- const tabId = input.tabId;
354
- switch (action) {
355
- case "close_tab":
356
- await page.close();
357
- return { closed: true, tabId };
358
- case "navigate":
359
- await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
360
- return { tab: await this.tabInfo(page) };
361
- case "screenshot": {
362
- const bytes = await page.screenshot({ type: "png" });
363
- const viewport = page.viewportSize();
364
- const cursor = this.cursor.get(tabId);
365
- return {
366
- base64: bytes.toString("base64"),
367
- width: viewport?.width ?? Number(await page.evaluate(() => window.innerWidth)),
368
- height: viewport?.height ?? Number(await page.evaluate(() => window.innerHeight)),
369
- cursorX: cursor?.x,
370
- cursorY: cursor?.y
371
- };
372
- }
373
- case "move_mouse": {
374
- const x = Number(input.x);
375
- const y = Number(input.y);
376
- if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("move_mouse requires numeric x and y coordinates.");
377
- await page.mouse.move(x, y);
378
- this.cursor.set(tabId, { x, y });
379
- return { tabId, x, y };
380
- }
381
- case "mouse_click": {
382
- const cursor = this.cursor.get(tabId);
383
- if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
384
- const button = input.button === "middle" || input.button === "right" ? input.button : "left";
385
- await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
386
- return { tabId, ...cursor, button };
387
- }
388
- case "keyboard": {
389
- const keys = Array.isArray(input.keys) ? input.keys : [];
390
- const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
391
- for (const modifier of modifiers) await page.keyboard.down(modifier);
392
- try {
393
- for (const key of keys) {
394
- if (typeof key !== "string") throw new Error("keyboard keys must all be strings.");
395
- await page.keyboard.press(key);
396
- }
397
- } finally {
398
- for (const modifier of modifiers.toReversed()) await page.keyboard.up(modifier);
399
- }
400
- return { tabId, keys, modifiers };
401
- }
402
- case "keyboard_type":
403
- if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
404
- await page.keyboard.insertText(input.text);
405
- return { tabId, characters: input.text.length };
406
- case "run_js": {
407
- if (typeof input.code !== "string") throw new Error("run_js requires code.");
408
- const result = await page.evaluate(async (code) => {
409
- const invoke = new Function(`"use strict"; return (async () => {
410
- ${code}
411
- })()`);
412
- return await invoke();
413
- }, input.code);
414
- const serialized = JSON.stringify(result);
415
- if (serialized && Buffer.byteLength(serialized) > 1024 * 1024) {
416
- throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
417
- }
418
- return { tabId, result };
419
- }
420
- default:
421
- throw new Error(`Unsupported browser operation: ${action}`);
422
- }
423
- }
118
+ const timer = setTimeout(finish, RECONNECT_DELAY_MS);
119
+ signal.addEventListener("abort", finish, { once: true });
120
+ });
424
121
  }
425
122
  async function connectLoop(params) {
426
- for (; ; ) {
123
+ while (!params.signal.aborted) {
427
124
  const url = new URL("/browser/ws", params.baseUrl);
428
125
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
429
126
  url.searchParams.set("instance", params.instanceId);
430
127
  url.searchParams.set("version", findVersion());
431
128
  let socket = null;
129
+ let watchdog;
130
+ const closeForAbort = () => socket?.close(1e3, "r5d-browser shutting down");
432
131
  try {
433
132
  socket = new import_ws.default(url, { headers: { Authorization: `Bearer ${params.token}` } });
133
+ socket.on("error", () => void 0);
134
+ params.signal.addEventListener("abort", closeForAbort, { once: true });
434
135
  await new Promise((resolve, reject) => {
435
136
  socket.addEventListener("open", () => resolve(), { once: true });
436
137
  socket.addEventListener("error", () => reject(new Error("WebSocket connection failed.")), { once: true });
138
+ socket.addEventListener("close", () => reject(new Error("WebSocket closed before connecting.")), { once: true });
437
139
  });
140
+ if (params.signal.aborted) break;
438
141
  process.stdout.write(`[r5d-browser] connected to ${params.baseUrl}
439
142
  `);
440
143
  socket.send(
@@ -454,11 +157,12 @@ async function connectLoop(params) {
454
157
  })
455
158
  );
456
159
  let lastServerMessageAt = Date.now();
457
- const watchdog = setInterval(() => {
160
+ watchdog = setInterval(() => {
458
161
  if (Date.now() - lastServerMessageAt >= SERVER_HEARTBEAT_TIMEOUT_MS) socket?.close();
459
162
  }, 5e3);
460
163
  await new Promise((resolve) => {
461
164
  socket.addEventListener("message", (event) => {
165
+ if (params.signal.aborted) return;
462
166
  lastServerMessageAt = Date.now();
463
167
  void (async () => {
464
168
  const message = JSON.parse(String(event.data));
@@ -469,9 +173,7 @@ async function connectLoop(params) {
469
173
  if (message.type === "port_forward_start") {
470
174
  try {
471
175
  await params.portForwards.start(message.forward);
472
- socket?.send(
473
- JSON.stringify({ type: "port_forward_result", requestId: message.requestId, forward: message.forward })
474
- );
176
+ socket?.send(JSON.stringify({ type: "port_forward_result", requestId: message.requestId, forward: message.forward }));
475
177
  } catch (error) {
476
178
  socket?.send(
477
179
  JSON.stringify({
@@ -521,69 +223,174 @@ async function connectLoop(params) {
521
223
  });
522
224
  socket.addEventListener("close", () => resolve(), { once: true });
523
225
  });
524
- clearInterval(watchdog);
525
226
  } catch (error) {
526
- process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
227
+ if (!params.signal.aborted) process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
527
228
  `);
528
229
  } finally {
230
+ if (watchdog) clearInterval(watchdog);
231
+ params.signal.removeEventListener("abort", closeForAbort);
529
232
  socket?.close();
530
233
  }
234
+ if (params.signal.aborted) break;
531
235
  process.stdout.write(`[r5d-browser] disconnected; reconnecting...
532
236
  `);
533
- await (0, import_promises.setTimeout)(RECONNECT_DELAY_MS);
237
+ await waitForReconnectDelay(params.signal);
238
+ }
239
+ }
240
+ function isProcessAlive(pid) {
241
+ try {
242
+ process.kill(pid, 0);
243
+ return true;
244
+ } catch (error) {
245
+ return error.code === "EPERM";
246
+ }
247
+ }
248
+ async function waitUpTo(promise, timeoutMs) {
249
+ let timer;
250
+ try {
251
+ return await Promise.race([
252
+ promise.then(() => true),
253
+ new Promise((resolve) => {
254
+ timer = setTimeout(() => resolve(false), timeoutMs);
255
+ })
256
+ ]);
257
+ } finally {
258
+ if (timer) clearTimeout(timer);
259
+ }
260
+ }
261
+ async function resultWithin(promise, timeoutMs) {
262
+ let timer;
263
+ try {
264
+ return await Promise.race([
265
+ promise,
266
+ new Promise((resolve) => {
267
+ timer = setTimeout(() => resolve(void 0), timeoutMs);
268
+ })
269
+ ]);
270
+ } finally {
271
+ if (timer) clearTimeout(timer);
534
272
  }
535
273
  }
536
274
  async function start(options) {
537
275
  if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
538
276
  const { baseUrl, token } = resolveConnection(options);
539
- const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
540
- const profilePath = import_node_path.default.resolve(options.profilePath ?? import_node_path.default.join(browserRoot(), "profile"));
277
+ const requestedProfilePath = import_node_path.default.resolve(options.profilePath ?? (0, import_chrome_launcher.defaultChromeProfilePath)());
541
278
  const downloadsPath = import_node_path.default.join(browserRoot(), "downloads");
542
279
  const playwrightDownloadsPath = import_node_path.default.join(downloadsPath, ".playwright");
543
- process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
544
- let playwright = await import("playwright");
545
- if (!import_node_fs.default.existsSync(playwright.chromium.executablePath())) {
546
- installChromium(browsersPath);
547
- playwright = await import("playwright");
548
- }
549
- import_node_fs.default.mkdirSync(profilePath, { recursive: true });
550
- import_node_fs.default.mkdirSync(playwrightDownloadsPath, { recursive: true });
551
- const context = await playwright.chromium.launchPersistentContext(profilePath, {
552
- headless: false,
553
- viewport: { width: 1440, height: 900 },
554
- acceptDownloads: true,
555
- downloadsPath: playwrightDownloadsPath
280
+ import_node_fs.default.mkdirSync(playwrightDownloadsPath, { recursive: true, mode: 448 });
281
+ let requestedSignalOutcome;
282
+ let requestStop = () => void 0;
283
+ const requestedStop = new Promise((resolve) => {
284
+ requestStop = resolve;
556
285
  });
557
- if (context.pages().length === 0) await context.newPage();
558
- const runtime = new BrowserRuntime(context, downloadsPath);
559
- const instanceId = loadInstanceId();
560
- const portForwards = new import_port_forward_manager.BrowserPortForwardManager(baseUrl, token, instanceId);
561
- process.stdout.write(`[r5d-browser] profile: ${profilePath}
286
+ const signalHandlers = /* @__PURE__ */ new Map();
287
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
288
+ const handler = () => {
289
+ requestedSignalOutcome ??= { exitCode: 0 };
290
+ requestStop(requestedSignalOutcome);
291
+ };
292
+ signalHandlers.set(signal, handler);
293
+ process.on(signal, handler);
294
+ }
295
+ let launch;
296
+ try {
297
+ launch = await (0, import_chrome_launcher.launchStableChrome)({
298
+ chromePath: options.chromePath,
299
+ profilePath: requestedProfilePath,
300
+ playwrightDownloadsPath,
301
+ lockPath: (0, import_chrome_launcher.defaultChromeLockPath)()
302
+ });
303
+ } catch (error) {
304
+ for (const [signal, handler] of signalHandlers) process.off(signal, handler);
305
+ throw error;
306
+ }
307
+ if (requestedSignalOutcome) {
308
+ try {
309
+ await launch.close();
310
+ } finally {
311
+ for (const [signal, handler] of signalHandlers) process.off(signal, handler);
312
+ }
313
+ return;
314
+ }
315
+ try {
316
+ const profilePath = launch.profilePath;
317
+ const runtime = new import_browser_runtime.BrowserRuntime(launch.context, downloadsPath);
318
+ const instanceId = loadInstanceId();
319
+ const portForwards = new import_port_forward_manager.BrowserPortForwardManager(baseUrl, token, instanceId);
320
+ process.stdout.write(`[r5d-browser] profile: ${profilePath}
562
321
  `);
563
- process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
322
+ process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
564
323
  `);
565
- process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
324
+ process.stdout.write(`[r5d-browser] Google Chrome ${launch.chromeVersion}; navigator.webdriver is disabled.
566
325
  `);
567
- const shutdown = () => void Promise.all([portForwards.close(), context.close()]).finally(() => process.exit(0));
568
- process.on("SIGINT", shutdown);
569
- process.on("SIGTERM", shutdown);
570
- return connectLoop({
571
- runtime,
572
- portForwards,
573
- baseUrl,
574
- token,
575
- instanceId,
576
- profilePath,
577
- chromiumVersion: context.browser()?.version() ?? "unknown"
578
- });
326
+ process.stdout.write(`[r5d-browser] agents use synthetic Chrome input; your system pointer and keyboard stay untouched.
327
+ `);
328
+ const abortController = new AbortController();
329
+ const controlLoop = connectLoop({
330
+ runtime,
331
+ portForwards,
332
+ baseUrl,
333
+ token,
334
+ instanceId,
335
+ profilePath,
336
+ chromiumVersion: launch.chromeVersion,
337
+ signal: abortController.signal
338
+ });
339
+ const controlStopped = controlLoop.then(
340
+ () => ({ exitCode: abortController.signal.aborted ? 0 : 1, message: "The r5d.dev browser control loop stopped unexpectedly." }),
341
+ (error) => ({
342
+ exitCode: 1,
343
+ message: `The r5d.dev browser control loop failed: ${error instanceof Error ? error.message : String(error)}`
344
+ })
345
+ );
346
+ const chromeExited = launch.exited.then(({ code, signal }) => ({
347
+ exitCode: code === 0 ? 0 : 1,
348
+ message: code === 0 ? void 0 : `Google Chrome exited unexpectedly (${signal ? `signal ${signal}` : `code ${code ?? "unknown"}`}).`
349
+ }));
350
+ const chromeDisconnected = launch.disconnected.then(async () => {
351
+ const exitOutcome = await resultWithin(chromeExited, CHROME_DISCONNECT_EXIT_GRACE_MS);
352
+ if (exitOutcome) return exitOutcome;
353
+ if (!isProcessAlive(launch.chromePid)) return await chromeExited;
354
+ return { exitCode: 1, message: "The Google Chrome control connection closed unexpectedly." };
355
+ });
356
+ const outcome = await Promise.race([requestedStop, chromeExited, chromeDisconnected, controlStopped]);
357
+ abortController.abort();
358
+ let cleanupError;
359
+ try {
360
+ await portForwards.close();
361
+ } catch (error) {
362
+ cleanupError = error;
363
+ }
364
+ try {
365
+ if (!await waitUpTo(runtime.drainDownloads(), 5e3)) {
366
+ process.stderr.write("[r5d-browser] timed out waiting for active downloads during shutdown.\n");
367
+ }
368
+ } catch (error) {
369
+ cleanupError ??= error;
370
+ }
371
+ try {
372
+ await launch.close();
373
+ } catch (error) {
374
+ cleanupError ??= error;
375
+ }
376
+ await controlStopped;
377
+ for (const [signal, handler] of signalHandlers) process.off(signal, handler);
378
+ if (outcome.exitCode !== 0) throw new Error(outcome.message ?? "Google Chrome stopped unexpectedly.");
379
+ if (cleanupError) throw cleanupError;
380
+ } catch (error) {
381
+ try {
382
+ await launch.close().catch(() => void 0);
383
+ } finally {
384
+ for (const [signal, handler] of signalHandlers) process.off(signal, handler);
385
+ }
386
+ throw error;
387
+ }
579
388
  }
580
389
  async function main() {
581
- const { command, options } = parseArgs(process.argv.slice(2));
390
+ const { command, options } = (0, import_cli_options.parseBrowserArgs)(process.argv.slice(2));
582
391
  if (command === "help") return printHelp();
583
392
  if (command === "version") return void process.stdout.write(`r5d-browser ${findVersion()}
584
393
  `);
585
- const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
586
- if (command === "install") return installChromium(browsersPath);
587
394
  await start(options);
588
395
  }
589
396
  main().catch((error) => {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "type": "commonjs"
5
5
  }
@@ -205,7 +205,7 @@ class BrowserPortForwardManager {
205
205
  socket.once("drain", () => relay.resume());
206
206
  }
207
207
  });
208
- relay.once("error", cleanup);
208
+ relay.on("error", cleanup);
209
209
  relay.once("close", cleanup);
210
210
  socket.on("data", (chunk) => {
211
211
  if (!ready || relay.readyState !== import_ws.default.OPEN) {