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