rechrome 1.21.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rech.js +140 -39
- package/rech.ts +140 -39
- package/serve.js +130 -19
- package/serve.ts +130 -19
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
|
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
import { spawn as cpSpawn } from "child_process";
|
|
9
|
+
import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
9
10
|
|
|
10
11
|
export const ENV_KEY = "RECHROME_URL";
|
|
11
12
|
export const DEFAULT_PORT = 13775;
|
|
@@ -104,7 +105,7 @@ function openInDefaultApp(target: string): void {
|
|
|
104
105
|
const cmd = process.platform === "darwin" ? ["open", target]
|
|
105
106
|
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
106
107
|
: ["xdg-open", target];
|
|
107
|
-
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
108
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
@@ -137,7 +138,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
|
|
|
137
138
|
try {
|
|
138
139
|
Bun.spawn(
|
|
139
140
|
[chromeBin, `--profile-directory=${profileDir}`, target],
|
|
140
|
-
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
141
|
+
{ stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
|
|
141
142
|
);
|
|
142
143
|
return true;
|
|
143
144
|
} catch {
|
|
@@ -200,7 +201,8 @@ function realpathSafe(p: string): string {
|
|
|
200
201
|
|
|
201
202
|
async function gitOutput(args: string[], cwd: string): Promise<string | null> {
|
|
202
203
|
try {
|
|
203
|
-
|
|
204
|
+
// windowsHide: don't flash a console window on Windows (git.exe is a console app)
|
|
205
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
204
206
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
205
207
|
await proc.exited;
|
|
206
208
|
return out || null;
|
|
@@ -478,12 +480,18 @@ async function listProfiles(): Promise<void> {
|
|
|
478
480
|
}
|
|
479
481
|
}
|
|
480
482
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
483
|
+
// Header clarifies each column; email/nickname sit beside the profile dir so a bare
|
|
484
|
+
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
485
|
+
// from Local State — the gaia real name is deliberately never surfaced.
|
|
486
|
+
const rows = [
|
|
487
|
+
["PROFILE", "EMAIL", "NICKNAME", ""],
|
|
488
|
+
...Object.entries(cache).map(([dir, info]) => [
|
|
489
|
+
dir,
|
|
490
|
+
info.user_name || "",
|
|
491
|
+
info.name || "",
|
|
492
|
+
dir === currentDir ? "← current" : "",
|
|
493
|
+
]),
|
|
494
|
+
];
|
|
487
495
|
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
488
496
|
for (const row of rows) {
|
|
489
497
|
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
@@ -494,9 +502,13 @@ async function callServe(
|
|
|
494
502
|
url: string,
|
|
495
503
|
args: string[],
|
|
496
504
|
overrideEnv?: Record<string, string>,
|
|
505
|
+
precomputedIdentity?: { key: string; label: string; profile?: string },
|
|
497
506
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
498
507
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
499
|
-
|
|
508
|
+
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
509
|
+
// and run() has already done so for its log line. Recomputing here would double those git
|
|
510
|
+
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
511
|
+
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
500
512
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
501
513
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
502
514
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
@@ -545,7 +557,7 @@ async function run(url: string, args: string[]) {
|
|
|
545
557
|
);
|
|
546
558
|
|
|
547
559
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
548
|
-
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
560
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
|
|
549
561
|
|
|
550
562
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
551
563
|
if (existingSession && isOpenWithUrl) {
|
|
@@ -647,27 +659,80 @@ const PM_PROCESS_NAME = "rechrome";
|
|
|
647
659
|
// Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
|
|
648
660
|
// migrates an existing checkout cleanly.
|
|
649
661
|
const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
|
|
650
|
-
// oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
|
|
651
662
|
const IS_WINDOWS = process.platform === "win32";
|
|
652
|
-
const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
|
|
653
663
|
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
664
|
+
// Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
|
|
665
|
+
// isn't on PATH / doesn't answer. Cached — daemonManager() sits on the hot path
|
|
666
|
+
// of several subcommands (status, setup, install). Synchronous by design so the
|
|
667
|
+
// selection has no await threading through call sites.
|
|
668
|
+
let _oxmgrVersion: string | null | undefined;
|
|
669
|
+
function oxmgrVersion(bin: string): string | null {
|
|
670
|
+
if (_oxmgrVersion !== undefined) return _oxmgrVersion;
|
|
671
|
+
try {
|
|
672
|
+
const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
673
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
|
|
674
|
+
_oxmgrVersion = m ? m[1]! : null;
|
|
675
|
+
} catch {
|
|
676
|
+
_oxmgrVersion = null;
|
|
677
|
+
}
|
|
678
|
+
return _oxmgrVersion;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Resolve (and cache) the process manager that daemonizes `rech serve`. The
|
|
682
|
+
// selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
|
|
683
|
+
// RECH_DAEMON_MANAGER override — lives in ./daemon-manager.js (pure + tested);
|
|
684
|
+
// here we just supply the runtime inputs (what's on PATH, the oxmgr version).
|
|
685
|
+
let _daemonMgr: DaemonManager | undefined;
|
|
686
|
+
function daemonManager(): DaemonManager {
|
|
687
|
+
if (_daemonMgr) return _daemonMgr;
|
|
688
|
+
const oxmgrBin = Bun.which("oxmgr");
|
|
689
|
+
const pm2Bin = Bun.which("pm2");
|
|
690
|
+
_daemonMgr = pickDaemonManager({
|
|
691
|
+
oxmgrBin,
|
|
692
|
+
pm2Bin,
|
|
693
|
+
oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
|
|
694
|
+
isWindows: IS_WINDOWS,
|
|
695
|
+
override: process.env.RECH_DAEMON_MANAGER,
|
|
696
|
+
});
|
|
697
|
+
return _daemonMgr;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Spawn the resolved process manager by its absolute path (Bun.which). `env` is
|
|
701
|
+
// merged over process.env for the child: pm2 captures the CLI's environment for
|
|
702
|
+
// the managed process (it has no per-var flag like oxmgr's --env), so install
|
|
703
|
+
// passes daemon env this way.
|
|
704
|
+
async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
|
|
705
|
+
const proc = Bun.spawn([mgr.bin, ...args], {
|
|
659
706
|
stdout: "inherit",
|
|
660
707
|
stderr: "inherit",
|
|
708
|
+
windowsHide: true, // no console-window flash for the manager child on Windows
|
|
661
709
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
662
710
|
});
|
|
663
711
|
await proc.exited;
|
|
664
712
|
return proc.exitCode ?? 1;
|
|
665
713
|
}
|
|
666
714
|
|
|
715
|
+
// oxmgr boot/login autostart. `service install` wires the platform init
|
|
716
|
+
// integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
|
|
717
|
+
// so the daemon — and the managed serve with it — returns at login/boot, the way
|
|
718
|
+
// the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
|
|
719
|
+
// re-running `service install` re-bootstraps the oxmgr daemon, which restarts
|
|
720
|
+
// every managed process (including the live serve). Best-effort — a failure
|
|
721
|
+
// leaves serve crash-managed but not login-persistent.
|
|
722
|
+
async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
|
|
723
|
+
let alreadyInstalled = false;
|
|
724
|
+
try {
|
|
725
|
+
alreadyInstalled =
|
|
726
|
+
Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
|
|
727
|
+
} catch { /* treat a probe failure as not-installed and attempt install */ }
|
|
728
|
+
if (alreadyInstalled) return;
|
|
729
|
+
await runPm(mgr, ["service", "install"]);
|
|
730
|
+
}
|
|
731
|
+
|
|
667
732
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
668
733
|
// Both render the process name verbatim, so callers can substring-match it.
|
|
669
|
-
async function pmList(): Promise<string> {
|
|
670
|
-
const proc = Bun.spawn([
|
|
734
|
+
async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
735
|
+
const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
671
736
|
return await new Response(proc.stdout).text();
|
|
672
737
|
}
|
|
673
738
|
|
|
@@ -729,25 +794,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
729
794
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
730
795
|
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
731
796
|
|
|
797
|
+
const mgr = daemonManager();
|
|
798
|
+
|
|
732
799
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
733
|
-
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
800
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
734
801
|
|
|
735
802
|
let startCode: number;
|
|
736
|
-
if (
|
|
803
|
+
if (mgr.id === "pm2") {
|
|
737
804
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
738
805
|
// autorestarts by default, and runs the bun binary directly with
|
|
739
806
|
// `--interpreter none` (so it isn't fed to node).
|
|
740
|
-
startCode = await runPm([
|
|
807
|
+
startCode = await runPm(mgr, [
|
|
741
808
|
"start", bunBin,
|
|
742
809
|
"--name", PM_PROCESS_NAME,
|
|
743
810
|
"--interpreter", "none",
|
|
744
811
|
"--cwd", home,
|
|
745
812
|
"--", rechScript, "serve",
|
|
746
813
|
], daemonEnv);
|
|
747
|
-
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
814
|
+
await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
748
815
|
} else {
|
|
749
816
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
750
|
-
startCode = await runPm([
|
|
817
|
+
startCode = await runPm(mgr, [
|
|
751
818
|
"start",
|
|
752
819
|
"--name", PM_PROCESS_NAME,
|
|
753
820
|
"--restart", "always",
|
|
@@ -755,18 +822,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
755
822
|
...envArgs,
|
|
756
823
|
`${bunBin} ${rechScript} serve`,
|
|
757
824
|
]);
|
|
758
|
-
|
|
825
|
+
// Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
|
|
826
|
+
// on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
|
|
827
|
+
// path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
|
|
828
|
+
// live daemon.
|
|
829
|
+
await oxmgrEnsureAutostart(mgr);
|
|
759
830
|
}
|
|
760
831
|
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
761
832
|
if (startCode !== 0)
|
|
762
|
-
throw new Error(`${
|
|
833
|
+
throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
|
|
763
834
|
}
|
|
764
835
|
|
|
765
836
|
async function daemonUninstall(): Promise<void> {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
837
|
+
const mgr = daemonManager();
|
|
838
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
839
|
+
if (mgr.id === "pm2") await runPm(mgr, ["save"]);
|
|
840
|
+
else await runPm(mgr, ["service", "uninstall"]);
|
|
841
|
+
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
770
842
|
}
|
|
771
843
|
|
|
772
844
|
// ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
|
|
@@ -983,7 +1055,7 @@ async function provisionExtensionToken(opts: {
|
|
|
983
1055
|
if (!opts.headed) args.push("--headless=new");
|
|
984
1056
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
985
1057
|
args.push("about:blank");
|
|
986
|
-
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
|
|
1058
|
+
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
987
1059
|
let cdp: CDPClient | null = null;
|
|
988
1060
|
try {
|
|
989
1061
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
@@ -1198,13 +1270,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1198
1270
|
);
|
|
1199
1271
|
if (opts.profile !== undefined) {
|
|
1200
1272
|
const num = parseInt(opts.profile);
|
|
1201
|
-
if (!isNaN(num) && String(num) === opts.profile.trim())
|
|
1273
|
+
if (!isNaN(num) && String(num) === opts.profile.trim()) {
|
|
1274
|
+
// A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
|
|
1275
|
+
// "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
|
|
1276
|
+
// *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
|
|
1277
|
+
// and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
|
|
1278
|
+
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1279
|
+
const sel = available[num - 1] ?? null;
|
|
1280
|
+
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1281
|
+
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1282
|
+
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1283
|
+
console.error(
|
|
1284
|
+
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1285
|
+
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1286
|
+
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1287
|
+
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1291
|
+
return sel;
|
|
1292
|
+
}
|
|
1202
1293
|
const needle = opts.profile.toLowerCase();
|
|
1203
|
-
|
|
1294
|
+
const match = available.find(([dir, info]) =>
|
|
1204
1295
|
dir.toLowerCase() === needle
|
|
1205
1296
|
|| (info.name ?? "").toLowerCase() === needle
|
|
1206
1297
|
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1207
1298
|
) ?? null;
|
|
1299
|
+
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1300
|
+
return match;
|
|
1208
1301
|
}
|
|
1209
1302
|
if (available.length === 1) {
|
|
1210
1303
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
@@ -1414,17 +1507,20 @@ async function status(): Promise<void> {
|
|
|
1414
1507
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
1415
1508
|
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
1416
1509
|
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
1417
|
-
const
|
|
1510
|
+
const pingBody = ping
|
|
1418
1511
|
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
1419
1512
|
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
1420
1513
|
signal: AbortSignal.timeout(2000),
|
|
1421
|
-
}).then(r => (r.ok ? r.json() : null)).
|
|
1422
|
-
:
|
|
1514
|
+
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
1515
|
+
: null;
|
|
1516
|
+
const bind = pingBody?.bind;
|
|
1423
1517
|
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
1424
1518
|
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
1519
|
+
if (pingBody?.degraded)
|
|
1520
|
+
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
|
|
1425
1521
|
const pmOut = await pmList();
|
|
1426
1522
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
1427
|
-
console.log(`daemon: ${daemonRegistered ? `${
|
|
1523
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
1428
1524
|
const registry = await readTokenRegistry();
|
|
1429
1525
|
const entries = Object.entries(registry);
|
|
1430
1526
|
if (entries.length) {
|
|
@@ -1448,7 +1544,12 @@ function printHelp(): void {
|
|
|
1448
1544
|
Usage:
|
|
1449
1545
|
rech setup [--profile <num|email>] [--token <tok>]
|
|
1450
1546
|
First-time setup: daemon + Chrome extension + config
|
|
1451
|
-
--profile selects the Chrome profile non-interactively
|
|
1547
|
+
--profile selects the Chrome profile non-interactively.
|
|
1548
|
+
A bare number is the 1-based MENU INDEX (position in the
|
|
1549
|
+
listed order), NOT the Chrome directory "Profile N". For
|
|
1550
|
+
scripts prefer the email (e.g. --profile you@gmail.com) —
|
|
1551
|
+
it is the only unambiguous selector; an exact directory
|
|
1552
|
+
name ("Profile 1") also matches.
|
|
1452
1553
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1453
1554
|
non-TTY/agent runs, skipping the interactive paste
|
|
1454
1555
|
rech provision-profile <name> --experimental [--headed]
|
package/rech.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
|
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
import { spawn as cpSpawn } from "child_process";
|
|
9
|
+
import { pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
|
|
9
10
|
|
|
10
11
|
export const ENV_KEY = "RECHROME_URL";
|
|
11
12
|
export const DEFAULT_PORT = 13775;
|
|
@@ -104,7 +105,7 @@ function openInDefaultApp(target: string): void {
|
|
|
104
105
|
const cmd = process.platform === "darwin" ? ["open", target]
|
|
105
106
|
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
106
107
|
: ["xdg-open", target];
|
|
107
|
-
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
108
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
@@ -137,7 +138,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
|
|
|
137
138
|
try {
|
|
138
139
|
Bun.spawn(
|
|
139
140
|
[chromeBin, `--profile-directory=${profileDir}`, target],
|
|
140
|
-
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
141
|
+
{ stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
|
|
141
142
|
);
|
|
142
143
|
return true;
|
|
143
144
|
} catch {
|
|
@@ -200,7 +201,8 @@ function realpathSafe(p: string): string {
|
|
|
200
201
|
|
|
201
202
|
async function gitOutput(args: string[], cwd: string): Promise<string | null> {
|
|
202
203
|
try {
|
|
203
|
-
|
|
204
|
+
// windowsHide: don't flash a console window on Windows (git.exe is a console app)
|
|
205
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
204
206
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
205
207
|
await proc.exited;
|
|
206
208
|
return out || null;
|
|
@@ -478,12 +480,18 @@ async function listProfiles(): Promise<void> {
|
|
|
478
480
|
}
|
|
479
481
|
}
|
|
480
482
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
483
|
+
// Header clarifies each column; email/nickname sit beside the profile dir so a bare
|
|
484
|
+
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
485
|
+
// from Local State — the gaia real name is deliberately never surfaced.
|
|
486
|
+
const rows = [
|
|
487
|
+
["PROFILE", "EMAIL", "NICKNAME", ""],
|
|
488
|
+
...Object.entries(cache).map(([dir, info]) => [
|
|
489
|
+
dir,
|
|
490
|
+
info.user_name || "",
|
|
491
|
+
info.name || "",
|
|
492
|
+
dir === currentDir ? "← current" : "",
|
|
493
|
+
]),
|
|
494
|
+
];
|
|
487
495
|
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
488
496
|
for (const row of rows) {
|
|
489
497
|
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
@@ -494,9 +502,13 @@ async function callServe(
|
|
|
494
502
|
url: string,
|
|
495
503
|
args: string[],
|
|
496
504
|
overrideEnv?: Record<string, string>,
|
|
505
|
+
precomputedIdentity?: { key: string; label: string; profile?: string },
|
|
497
506
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
498
507
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
499
|
-
|
|
508
|
+
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
509
|
+
// and run() has already done so for its log line. Recomputing here would double those git
|
|
510
|
+
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
511
|
+
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
500
512
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
501
513
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
502
514
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
@@ -545,7 +557,7 @@ async function run(url: string, args: string[]) {
|
|
|
545
557
|
);
|
|
546
558
|
|
|
547
559
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
548
|
-
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
560
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
|
|
549
561
|
|
|
550
562
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
551
563
|
if (existingSession && isOpenWithUrl) {
|
|
@@ -647,27 +659,80 @@ const PM_PROCESS_NAME = "rechrome";
|
|
|
647
659
|
// Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
|
|
648
660
|
// migrates an existing checkout cleanly.
|
|
649
661
|
const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
|
|
650
|
-
// oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
|
|
651
662
|
const IS_WINDOWS = process.platform === "win32";
|
|
652
|
-
const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
|
|
653
663
|
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
664
|
+
// Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
|
|
665
|
+
// isn't on PATH / doesn't answer. Cached — daemonManager() sits on the hot path
|
|
666
|
+
// of several subcommands (status, setup, install). Synchronous by design so the
|
|
667
|
+
// selection has no await threading through call sites.
|
|
668
|
+
let _oxmgrVersion: string | null | undefined;
|
|
669
|
+
function oxmgrVersion(bin: string): string | null {
|
|
670
|
+
if (_oxmgrVersion !== undefined) return _oxmgrVersion;
|
|
671
|
+
try {
|
|
672
|
+
const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
673
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
|
|
674
|
+
_oxmgrVersion = m ? m[1]! : null;
|
|
675
|
+
} catch {
|
|
676
|
+
_oxmgrVersion = null;
|
|
677
|
+
}
|
|
678
|
+
return _oxmgrVersion;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Resolve (and cache) the process manager that daemonizes `rech serve`. The
|
|
682
|
+
// selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
|
|
683
|
+
// RECH_DAEMON_MANAGER override — lives in ./daemon-manager.ts (pure + tested);
|
|
684
|
+
// here we just supply the runtime inputs (what's on PATH, the oxmgr version).
|
|
685
|
+
let _daemonMgr: DaemonManager | undefined;
|
|
686
|
+
function daemonManager(): DaemonManager {
|
|
687
|
+
if (_daemonMgr) return _daemonMgr;
|
|
688
|
+
const oxmgrBin = Bun.which("oxmgr");
|
|
689
|
+
const pm2Bin = Bun.which("pm2");
|
|
690
|
+
_daemonMgr = pickDaemonManager({
|
|
691
|
+
oxmgrBin,
|
|
692
|
+
pm2Bin,
|
|
693
|
+
oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
|
|
694
|
+
isWindows: IS_WINDOWS,
|
|
695
|
+
override: process.env.RECH_DAEMON_MANAGER,
|
|
696
|
+
});
|
|
697
|
+
return _daemonMgr;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Spawn the resolved process manager by its absolute path (Bun.which). `env` is
|
|
701
|
+
// merged over process.env for the child: pm2 captures the CLI's environment for
|
|
702
|
+
// the managed process (it has no per-var flag like oxmgr's --env), so install
|
|
703
|
+
// passes daemon env this way.
|
|
704
|
+
async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
|
|
705
|
+
const proc = Bun.spawn([mgr.bin, ...args], {
|
|
659
706
|
stdout: "inherit",
|
|
660
707
|
stderr: "inherit",
|
|
708
|
+
windowsHide: true, // no console-window flash for the manager child on Windows
|
|
661
709
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
662
710
|
});
|
|
663
711
|
await proc.exited;
|
|
664
712
|
return proc.exitCode ?? 1;
|
|
665
713
|
}
|
|
666
714
|
|
|
715
|
+
// oxmgr boot/login autostart. `service install` wires the platform init
|
|
716
|
+
// integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
|
|
717
|
+
// so the daemon — and the managed serve with it — returns at login/boot, the way
|
|
718
|
+
// the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
|
|
719
|
+
// re-running `service install` re-bootstraps the oxmgr daemon, which restarts
|
|
720
|
+
// every managed process (including the live serve). Best-effort — a failure
|
|
721
|
+
// leaves serve crash-managed but not login-persistent.
|
|
722
|
+
async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
|
|
723
|
+
let alreadyInstalled = false;
|
|
724
|
+
try {
|
|
725
|
+
alreadyInstalled =
|
|
726
|
+
Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
|
|
727
|
+
} catch { /* treat a probe failure as not-installed and attempt install */ }
|
|
728
|
+
if (alreadyInstalled) return;
|
|
729
|
+
await runPm(mgr, ["service", "install"]);
|
|
730
|
+
}
|
|
731
|
+
|
|
667
732
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
668
733
|
// Both render the process name verbatim, so callers can substring-match it.
|
|
669
|
-
async function pmList(): Promise<string> {
|
|
670
|
-
const proc = Bun.spawn([
|
|
734
|
+
async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
735
|
+
const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
671
736
|
return await new Response(proc.stdout).text();
|
|
672
737
|
}
|
|
673
738
|
|
|
@@ -729,25 +794,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
729
794
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
730
795
|
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
731
796
|
|
|
797
|
+
const mgr = daemonManager();
|
|
798
|
+
|
|
732
799
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
733
|
-
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
800
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
734
801
|
|
|
735
802
|
let startCode: number;
|
|
736
|
-
if (
|
|
803
|
+
if (mgr.id === "pm2") {
|
|
737
804
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
738
805
|
// autorestarts by default, and runs the bun binary directly with
|
|
739
806
|
// `--interpreter none` (so it isn't fed to node).
|
|
740
|
-
startCode = await runPm([
|
|
807
|
+
startCode = await runPm(mgr, [
|
|
741
808
|
"start", bunBin,
|
|
742
809
|
"--name", PM_PROCESS_NAME,
|
|
743
810
|
"--interpreter", "none",
|
|
744
811
|
"--cwd", home,
|
|
745
812
|
"--", rechScript, "serve",
|
|
746
813
|
], daemonEnv);
|
|
747
|
-
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
814
|
+
await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
748
815
|
} else {
|
|
749
816
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
750
|
-
startCode = await runPm([
|
|
817
|
+
startCode = await runPm(mgr, [
|
|
751
818
|
"start",
|
|
752
819
|
"--name", PM_PROCESS_NAME,
|
|
753
820
|
"--restart", "always",
|
|
@@ -755,18 +822,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
755
822
|
...envArgs,
|
|
756
823
|
`${bunBin} ${rechScript} serve`,
|
|
757
824
|
]);
|
|
758
|
-
|
|
825
|
+
// Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
|
|
826
|
+
// on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
|
|
827
|
+
// path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
|
|
828
|
+
// live daemon.
|
|
829
|
+
await oxmgrEnsureAutostart(mgr);
|
|
759
830
|
}
|
|
760
831
|
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
761
832
|
if (startCode !== 0)
|
|
762
|
-
throw new Error(`${
|
|
833
|
+
throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
|
|
763
834
|
}
|
|
764
835
|
|
|
765
836
|
async function daemonUninstall(): Promise<void> {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
837
|
+
const mgr = daemonManager();
|
|
838
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
839
|
+
if (mgr.id === "pm2") await runPm(mgr, ["save"]);
|
|
840
|
+
else await runPm(mgr, ["service", "uninstall"]);
|
|
841
|
+
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
770
842
|
}
|
|
771
843
|
|
|
772
844
|
// ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
|
|
@@ -983,7 +1055,7 @@ async function provisionExtensionToken(opts: {
|
|
|
983
1055
|
if (!opts.headed) args.push("--headless=new");
|
|
984
1056
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
985
1057
|
args.push("about:blank");
|
|
986
|
-
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
|
|
1058
|
+
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
987
1059
|
let cdp: CDPClient | null = null;
|
|
988
1060
|
try {
|
|
989
1061
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
@@ -1198,13 +1270,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1198
1270
|
);
|
|
1199
1271
|
if (opts.profile !== undefined) {
|
|
1200
1272
|
const num = parseInt(opts.profile);
|
|
1201
|
-
if (!isNaN(num) && String(num) === opts.profile.trim())
|
|
1273
|
+
if (!isNaN(num) && String(num) === opts.profile.trim()) {
|
|
1274
|
+
// A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
|
|
1275
|
+
// "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
|
|
1276
|
+
// *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
|
|
1277
|
+
// and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
|
|
1278
|
+
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1279
|
+
const sel = available[num - 1] ?? null;
|
|
1280
|
+
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1281
|
+
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1282
|
+
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1283
|
+
console.error(
|
|
1284
|
+
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1285
|
+
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1286
|
+
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1287
|
+
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1291
|
+
return sel;
|
|
1292
|
+
}
|
|
1202
1293
|
const needle = opts.profile.toLowerCase();
|
|
1203
|
-
|
|
1294
|
+
const match = available.find(([dir, info]) =>
|
|
1204
1295
|
dir.toLowerCase() === needle
|
|
1205
1296
|
|| (info.name ?? "").toLowerCase() === needle
|
|
1206
1297
|
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1207
1298
|
) ?? null;
|
|
1299
|
+
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1300
|
+
return match;
|
|
1208
1301
|
}
|
|
1209
1302
|
if (available.length === 1) {
|
|
1210
1303
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
@@ -1414,17 +1507,20 @@ async function status(): Promise<void> {
|
|
|
1414
1507
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
1415
1508
|
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
1416
1509
|
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
1417
|
-
const
|
|
1510
|
+
const pingBody = ping
|
|
1418
1511
|
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
1419
1512
|
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
1420
1513
|
signal: AbortSignal.timeout(2000),
|
|
1421
|
-
}).then(r => (r.ok ? r.json() : null)).
|
|
1422
|
-
:
|
|
1514
|
+
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
1515
|
+
: null;
|
|
1516
|
+
const bind = pingBody?.bind;
|
|
1423
1517
|
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
1424
1518
|
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
1519
|
+
if (pingBody?.degraded)
|
|
1520
|
+
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
|
|
1425
1521
|
const pmOut = await pmList();
|
|
1426
1522
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
1427
|
-
console.log(`daemon: ${daemonRegistered ? `${
|
|
1523
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
1428
1524
|
const registry = await readTokenRegistry();
|
|
1429
1525
|
const entries = Object.entries(registry);
|
|
1430
1526
|
if (entries.length) {
|
|
@@ -1448,7 +1544,12 @@ function printHelp(): void {
|
|
|
1448
1544
|
Usage:
|
|
1449
1545
|
rech setup [--profile <num|email>] [--token <tok>]
|
|
1450
1546
|
First-time setup: daemon + Chrome extension + config
|
|
1451
|
-
--profile selects the Chrome profile non-interactively
|
|
1547
|
+
--profile selects the Chrome profile non-interactively.
|
|
1548
|
+
A bare number is the 1-based MENU INDEX (position in the
|
|
1549
|
+
listed order), NOT the Chrome directory "Profile N". For
|
|
1550
|
+
scripts prefer the email (e.g. --profile you@gmail.com) —
|
|
1551
|
+
it is the only unambiguous selector; an exact directory
|
|
1552
|
+
name ("Profile 1") also matches.
|
|
1452
1553
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1453
1554
|
non-TTY/agent runs, skipping the interactive paste
|
|
1454
1555
|
rech provision-profile <name> --experimental [--headed]
|
package/serve.js
CHANGED
|
@@ -55,6 +55,35 @@ export function isIsoSession(namespacedSession: string): boolean {
|
|
|
55
55
|
return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// --- Relay health / self-heal ---------------------------------------------------------
|
|
59
|
+
// The daemon spawns a short-lived CLI child per command, but all commands share a long-lived
|
|
60
|
+
// per-session cliDaemon and a single extension↔relay path. Under sustained multi-session load
|
|
61
|
+
// the relay can wedge: navigations (`open`) hang to the 60s cap while cheap calls still return,
|
|
62
|
+
// and — critically — the wedge PERSISTS (every later command reuses the same broken cliDaemon),
|
|
63
|
+
// so historically only a manual `oxmgr restart rechrome` cleared it. We heal automatically on
|
|
64
|
+
// two layers, driven by real command timeouts (no synthetic probe → Chrome is never touched):
|
|
65
|
+
// - per-session: after SESSION_CLOSE_TIMEOUTS consecutive timeouts on ONE session, run the
|
|
66
|
+
// CLI `close` for it so the next command respawns a clean cliDaemon.
|
|
67
|
+
// - global: after WATCHDOG_TIMEOUTS consecutive timeouts across ALL sessions with no success
|
|
68
|
+
// in between (= the shared relay is dead), exit(1); oxmgr's `--restart always` respawns us
|
|
69
|
+
// clean and the extension WS reconnects. Each timeout burns 60s of wall time, so this can't
|
|
70
|
+
// spin faster than ~once/minute even under concurrent load.
|
|
71
|
+
const SESSION_CLOSE_TIMEOUTS = Number(process.env.RECH_SESSION_CLOSE_TIMEOUTS) || 2;
|
|
72
|
+
const WATCHDOG_TIMEOUTS = Number(process.env.RECH_WATCHDOG_TIMEOUTS) || 3;
|
|
73
|
+
let consecutiveTimeouts = 0; // global; reset on any non-timeout /run
|
|
74
|
+
const sessionTimeouts = new Map<string, number>(); // per-session consecutive-timeout streak
|
|
75
|
+
// Most-recently-used non-iso sessions, so the deep health probe can target something real
|
|
76
|
+
// instead of spawning a fresh session (which could open a browser window).
|
|
77
|
+
const recentSessions = new Map<string, number>();
|
|
78
|
+
function noteSession(sess: string, now: number): void {
|
|
79
|
+
recentSessions.set(sess, now);
|
|
80
|
+
if (recentSessions.size > 64) {
|
|
81
|
+
let oldestKey: string | undefined, oldestAt = Infinity;
|
|
82
|
+
for (const [k, t] of recentSessions) if (t < oldestAt) { oldestAt = t; oldestKey = k; }
|
|
83
|
+
if (oldestKey) recentSessions.delete(oldestKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
function tmpSocketRoot(): string {
|
|
59
88
|
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
60
89
|
}
|
|
@@ -92,6 +121,7 @@ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): v
|
|
|
92
121
|
stdin: "ignore",
|
|
93
122
|
stdout: "ignore",
|
|
94
123
|
stderr: "ignore",
|
|
124
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
95
125
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
96
126
|
});
|
|
97
127
|
log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
|
|
@@ -112,7 +142,7 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
112
142
|
if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
|
|
113
143
|
log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
|
|
114
144
|
const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
|
|
115
|
-
stdout: "pipe", stderr: "pipe",
|
|
145
|
+
stdout: "pipe", stderr: "pipe", windowsHide: true,
|
|
116
146
|
});
|
|
117
147
|
const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
118
148
|
if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
|
|
@@ -189,7 +219,7 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
189
219
|
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
190
220
|
"}",
|
|
191
221
|
].join(" ");
|
|
192
|
-
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
222
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
|
|
193
223
|
const out = r.stdout?.toString().trim();
|
|
194
224
|
if (out) log(out);
|
|
195
225
|
} else {
|
|
@@ -228,9 +258,13 @@ export async function serve() {
|
|
|
228
258
|
}, 86_400_000);
|
|
229
259
|
}
|
|
230
260
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
231
|
-
const startServer = () => Bun.serve({
|
|
261
|
+
const startServer = (reusePort = false) => Bun.serve({
|
|
232
262
|
hostname: listenHost,
|
|
233
263
|
port,
|
|
264
|
+
// reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
|
|
265
|
+
// holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
|
|
266
|
+
// instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
|
|
267
|
+
reusePort,
|
|
234
268
|
tls,
|
|
235
269
|
error(err) {
|
|
236
270
|
log(`unhandled error: ${err.message}`);
|
|
@@ -254,7 +288,32 @@ export async function serve() {
|
|
|
254
288
|
if (reqUrl.pathname === "/ping") {
|
|
255
289
|
const denied = authCheck(req, key);
|
|
256
290
|
if (denied) return denied;
|
|
257
|
-
|
|
291
|
+
// Shallow ping proves only that the HTTP listener is up (it stayed up through past
|
|
292
|
+
// relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
|
|
293
|
+
// clients / `rech status` can see trouble without an active probe.
|
|
294
|
+
const degraded = consecutiveTimeouts > 0;
|
|
295
|
+
if (!reqUrl.searchParams.get("deep"))
|
|
296
|
+
return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
|
|
297
|
+
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
298
|
+
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
299
|
+
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
300
|
+
if (!target)
|
|
301
|
+
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
|
|
302
|
+
const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
|
|
303
|
+
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
304
|
+
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
305
|
+
windowsHide: true,
|
|
306
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
307
|
+
});
|
|
308
|
+
const probeStatus = await Promise.race([
|
|
309
|
+
probe.exited,
|
|
310
|
+
new Promise<number>((r) => setTimeout(() => { probe.kill(); r(-1); }, 5000)),
|
|
311
|
+
]);
|
|
312
|
+
const healthy = probeStatus !== -1;
|
|
313
|
+
return Response.json({
|
|
314
|
+
ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
|
|
315
|
+
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
316
|
+
});
|
|
258
317
|
}
|
|
259
318
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
260
319
|
const denied = authCheck(req, key);
|
|
@@ -312,7 +371,9 @@ export async function serve() {
|
|
|
312
371
|
});
|
|
313
372
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
314
373
|
// Track --isolate sessions so the idle-TTL reaper can close them later.
|
|
315
|
-
|
|
374
|
+
const nowMs = Date.now();
|
|
375
|
+
if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
|
|
376
|
+
else noteSession(namespacedSession, nowMs); // for the deep health probe to target
|
|
316
377
|
|
|
317
378
|
// daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
|
|
318
379
|
// fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
|
|
@@ -339,6 +400,7 @@ export async function serve() {
|
|
|
339
400
|
stdin: "ignore",
|
|
340
401
|
stdout: "pipe",
|
|
341
402
|
stderr: "pipe",
|
|
403
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
342
404
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
343
405
|
});
|
|
344
406
|
const [listStatus, listOut] = await Promise.race([
|
|
@@ -421,16 +483,20 @@ export async function serve() {
|
|
|
421
483
|
stdin: "ignore",
|
|
422
484
|
stdout: "pipe",
|
|
423
485
|
stderr: "pipe",
|
|
486
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
424
487
|
env: childEnv,
|
|
425
488
|
});
|
|
426
489
|
|
|
427
490
|
const TIMEOUT = 60_000;
|
|
428
|
-
|
|
429
|
-
|
|
491
|
+
let timedOut = false;
|
|
492
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
493
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
494
|
+
timer = setTimeout(() => {
|
|
495
|
+
timedOut = true;
|
|
430
496
|
proc.kill();
|
|
431
497
|
reject(new Error("timeout"));
|
|
432
|
-
}, TIMEOUT)
|
|
433
|
-
);
|
|
498
|
+
}, TIMEOUT);
|
|
499
|
+
});
|
|
434
500
|
const [status, stdout, stderr] = await Promise.race([
|
|
435
501
|
Promise.all([
|
|
436
502
|
proc.exited,
|
|
@@ -441,9 +507,37 @@ export async function serve() {
|
|
|
441
507
|
]).catch(
|
|
442
508
|
() => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
|
|
443
509
|
) as [number, string, string];
|
|
510
|
+
clearTimeout(timer);
|
|
444
511
|
|
|
445
512
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
446
513
|
|
|
514
|
+
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|
|
515
|
+
// non-zero — proves the relay answered, so clear the streaks; a TIMEOUT means it didn't.
|
|
516
|
+
if (timedOut) {
|
|
517
|
+
consecutiveTimeouts++;
|
|
518
|
+
const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
|
|
519
|
+
sessionTimeouts.set(namespacedSession, streak);
|
|
520
|
+
log(`timeout: session=${namespacedSession} sessionStreak=${streak} globalStreak=${consecutiveTimeouts}`);
|
|
521
|
+
if (streak >= SESSION_CLOSE_TIMEOUTS) {
|
|
522
|
+
log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
|
|
523
|
+
try {
|
|
524
|
+
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
525
|
+
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
526
|
+
windowsHide: true,
|
|
527
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
528
|
+
});
|
|
529
|
+
} catch {}
|
|
530
|
+
sessionTimeouts.delete(namespacedSession);
|
|
531
|
+
}
|
|
532
|
+
if (consecutiveTimeouts >= WATCHDOG_TIMEOUTS) {
|
|
533
|
+
log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
|
|
534
|
+
setTimeout(() => process.exit(1), 100); // brief delay to flush this response
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
consecutiveTimeouts = 0;
|
|
538
|
+
sessionTimeouts.delete(namespacedSession);
|
|
539
|
+
}
|
|
540
|
+
|
|
447
541
|
// Detect files mentioned in output
|
|
448
542
|
const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
|
|
449
543
|
const mentionedFiles = [
|
|
@@ -482,17 +576,34 @@ export async function serve() {
|
|
|
482
576
|
},
|
|
483
577
|
});
|
|
484
578
|
|
|
485
|
-
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port
|
|
486
|
-
// prior serve exits
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
579
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
|
|
580
|
+
// prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
|
|
581
|
+
// detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
|
|
582
|
+
// netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
|
|
583
|
+
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
584
|
+
// Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
|
|
585
|
+
// released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
|
|
586
|
+
// last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
|
|
587
|
+
// the port" rather than a permanent EADDRINUSE crash-loop.
|
|
588
|
+
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
589
|
+
const MAX_BIND_ATTEMPTS = 4;
|
|
590
|
+
let server: ReturnType<typeof startServer> | undefined;
|
|
591
|
+
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
|
|
592
|
+
try {
|
|
593
|
+
server = startServer();
|
|
594
|
+
break;
|
|
595
|
+
} catch (e: any) {
|
|
596
|
+
if (!isEaddrInUse(e)) throw e;
|
|
597
|
+
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
598
|
+
log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
599
|
+
server = startServer(true);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
|
|
603
|
+
await freeStalePort(port);
|
|
604
|
+
}
|
|
495
605
|
}
|
|
606
|
+
if (!server) throw new Error(`failed to bind port ${port}`);
|
|
496
607
|
|
|
497
608
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
498
609
|
log(`Connection URL set (use .env.local to view)`);
|
package/serve.ts
CHANGED
|
@@ -55,6 +55,35 @@ export function isIsoSession(namespacedSession: string): boolean {
|
|
|
55
55
|
return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// --- Relay health / self-heal ---------------------------------------------------------
|
|
59
|
+
// The daemon spawns a short-lived CLI child per command, but all commands share a long-lived
|
|
60
|
+
// per-session cliDaemon and a single extension↔relay path. Under sustained multi-session load
|
|
61
|
+
// the relay can wedge: navigations (`open`) hang to the 60s cap while cheap calls still return,
|
|
62
|
+
// and — critically — the wedge PERSISTS (every later command reuses the same broken cliDaemon),
|
|
63
|
+
// so historically only a manual `oxmgr restart rechrome` cleared it. We heal automatically on
|
|
64
|
+
// two layers, driven by real command timeouts (no synthetic probe → Chrome is never touched):
|
|
65
|
+
// - per-session: after SESSION_CLOSE_TIMEOUTS consecutive timeouts on ONE session, run the
|
|
66
|
+
// CLI `close` for it so the next command respawns a clean cliDaemon.
|
|
67
|
+
// - global: after WATCHDOG_TIMEOUTS consecutive timeouts across ALL sessions with no success
|
|
68
|
+
// in between (= the shared relay is dead), exit(1); oxmgr's `--restart always` respawns us
|
|
69
|
+
// clean and the extension WS reconnects. Each timeout burns 60s of wall time, so this can't
|
|
70
|
+
// spin faster than ~once/minute even under concurrent load.
|
|
71
|
+
const SESSION_CLOSE_TIMEOUTS = Number(process.env.RECH_SESSION_CLOSE_TIMEOUTS) || 2;
|
|
72
|
+
const WATCHDOG_TIMEOUTS = Number(process.env.RECH_WATCHDOG_TIMEOUTS) || 3;
|
|
73
|
+
let consecutiveTimeouts = 0; // global; reset on any non-timeout /run
|
|
74
|
+
const sessionTimeouts = new Map<string, number>(); // per-session consecutive-timeout streak
|
|
75
|
+
// Most-recently-used non-iso sessions, so the deep health probe can target something real
|
|
76
|
+
// instead of spawning a fresh session (which could open a browser window).
|
|
77
|
+
const recentSessions = new Map<string, number>();
|
|
78
|
+
function noteSession(sess: string, now: number): void {
|
|
79
|
+
recentSessions.set(sess, now);
|
|
80
|
+
if (recentSessions.size > 64) {
|
|
81
|
+
let oldestKey: string | undefined, oldestAt = Infinity;
|
|
82
|
+
for (const [k, t] of recentSessions) if (t < oldestAt) { oldestAt = t; oldestKey = k; }
|
|
83
|
+
if (oldestKey) recentSessions.delete(oldestKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
function tmpSocketRoot(): string {
|
|
59
88
|
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
60
89
|
}
|
|
@@ -92,6 +121,7 @@ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): v
|
|
|
92
121
|
stdin: "ignore",
|
|
93
122
|
stdout: "ignore",
|
|
94
123
|
stderr: "ignore",
|
|
124
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
95
125
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
96
126
|
});
|
|
97
127
|
log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
|
|
@@ -112,7 +142,7 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
112
142
|
if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
|
|
113
143
|
log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
|
|
114
144
|
const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
|
|
115
|
-
stdout: "pipe", stderr: "pipe",
|
|
145
|
+
stdout: "pipe", stderr: "pipe", windowsHide: true,
|
|
116
146
|
});
|
|
117
147
|
const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
118
148
|
if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
|
|
@@ -189,7 +219,7 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
189
219
|
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
190
220
|
"}",
|
|
191
221
|
].join(" ");
|
|
192
|
-
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
222
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
|
|
193
223
|
const out = r.stdout?.toString().trim();
|
|
194
224
|
if (out) log(out);
|
|
195
225
|
} else {
|
|
@@ -228,9 +258,13 @@ export async function serve() {
|
|
|
228
258
|
}, 86_400_000);
|
|
229
259
|
}
|
|
230
260
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
231
|
-
const startServer = () => Bun.serve({
|
|
261
|
+
const startServer = (reusePort = false) => Bun.serve({
|
|
232
262
|
hostname: listenHost,
|
|
233
263
|
port,
|
|
264
|
+
// reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
|
|
265
|
+
// holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
|
|
266
|
+
// instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
|
|
267
|
+
reusePort,
|
|
234
268
|
tls,
|
|
235
269
|
error(err) {
|
|
236
270
|
log(`unhandled error: ${err.message}`);
|
|
@@ -254,7 +288,32 @@ export async function serve() {
|
|
|
254
288
|
if (reqUrl.pathname === "/ping") {
|
|
255
289
|
const denied = authCheck(req, key);
|
|
256
290
|
if (denied) return denied;
|
|
257
|
-
|
|
291
|
+
// Shallow ping proves only that the HTTP listener is up (it stayed up through past
|
|
292
|
+
// relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
|
|
293
|
+
// clients / `rech status` can see trouble without an active probe.
|
|
294
|
+
const degraded = consecutiveTimeouts > 0;
|
|
295
|
+
if (!reqUrl.searchParams.get("deep"))
|
|
296
|
+
return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
|
|
297
|
+
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
298
|
+
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
299
|
+
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
300
|
+
if (!target)
|
|
301
|
+
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
|
|
302
|
+
const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
|
|
303
|
+
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
304
|
+
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
305
|
+
windowsHide: true,
|
|
306
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
307
|
+
});
|
|
308
|
+
const probeStatus = await Promise.race([
|
|
309
|
+
probe.exited,
|
|
310
|
+
new Promise<number>((r) => setTimeout(() => { probe.kill(); r(-1); }, 5000)),
|
|
311
|
+
]);
|
|
312
|
+
const healthy = probeStatus !== -1;
|
|
313
|
+
return Response.json({
|
|
314
|
+
ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
|
|
315
|
+
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
316
|
+
});
|
|
258
317
|
}
|
|
259
318
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
260
319
|
const denied = authCheck(req, key);
|
|
@@ -312,7 +371,9 @@ export async function serve() {
|
|
|
312
371
|
});
|
|
313
372
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
314
373
|
// Track --isolate sessions so the idle-TTL reaper can close them later.
|
|
315
|
-
|
|
374
|
+
const nowMs = Date.now();
|
|
375
|
+
if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
|
|
376
|
+
else noteSession(namespacedSession, nowMs); // for the deep health probe to target
|
|
316
377
|
|
|
317
378
|
// daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
|
|
318
379
|
// fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
|
|
@@ -339,6 +400,7 @@ export async function serve() {
|
|
|
339
400
|
stdin: "ignore",
|
|
340
401
|
stdout: "pipe",
|
|
341
402
|
stderr: "pipe",
|
|
403
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
342
404
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
343
405
|
});
|
|
344
406
|
const [listStatus, listOut] = await Promise.race([
|
|
@@ -421,16 +483,20 @@ export async function serve() {
|
|
|
421
483
|
stdin: "ignore",
|
|
422
484
|
stdout: "pipe",
|
|
423
485
|
stderr: "pipe",
|
|
486
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
424
487
|
env: childEnv,
|
|
425
488
|
});
|
|
426
489
|
|
|
427
490
|
const TIMEOUT = 60_000;
|
|
428
|
-
|
|
429
|
-
|
|
491
|
+
let timedOut = false;
|
|
492
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
493
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
494
|
+
timer = setTimeout(() => {
|
|
495
|
+
timedOut = true;
|
|
430
496
|
proc.kill();
|
|
431
497
|
reject(new Error("timeout"));
|
|
432
|
-
}, TIMEOUT)
|
|
433
|
-
);
|
|
498
|
+
}, TIMEOUT);
|
|
499
|
+
});
|
|
434
500
|
const [status, stdout, stderr] = await Promise.race([
|
|
435
501
|
Promise.all([
|
|
436
502
|
proc.exited,
|
|
@@ -441,9 +507,37 @@ export async function serve() {
|
|
|
441
507
|
]).catch(
|
|
442
508
|
() => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
|
|
443
509
|
) as [number, string, string];
|
|
510
|
+
clearTimeout(timer);
|
|
444
511
|
|
|
445
512
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
446
513
|
|
|
514
|
+
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|
|
515
|
+
// non-zero — proves the relay answered, so clear the streaks; a TIMEOUT means it didn't.
|
|
516
|
+
if (timedOut) {
|
|
517
|
+
consecutiveTimeouts++;
|
|
518
|
+
const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
|
|
519
|
+
sessionTimeouts.set(namespacedSession, streak);
|
|
520
|
+
log(`timeout: session=${namespacedSession} sessionStreak=${streak} globalStreak=${consecutiveTimeouts}`);
|
|
521
|
+
if (streak >= SESSION_CLOSE_TIMEOUTS) {
|
|
522
|
+
log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
|
|
523
|
+
try {
|
|
524
|
+
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
525
|
+
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
526
|
+
windowsHide: true,
|
|
527
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
528
|
+
});
|
|
529
|
+
} catch {}
|
|
530
|
+
sessionTimeouts.delete(namespacedSession);
|
|
531
|
+
}
|
|
532
|
+
if (consecutiveTimeouts >= WATCHDOG_TIMEOUTS) {
|
|
533
|
+
log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
|
|
534
|
+
setTimeout(() => process.exit(1), 100); // brief delay to flush this response
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
consecutiveTimeouts = 0;
|
|
538
|
+
sessionTimeouts.delete(namespacedSession);
|
|
539
|
+
}
|
|
540
|
+
|
|
447
541
|
// Detect files mentioned in output
|
|
448
542
|
const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
|
|
449
543
|
const mentionedFiles = [
|
|
@@ -482,17 +576,34 @@ export async function serve() {
|
|
|
482
576
|
},
|
|
483
577
|
});
|
|
484
578
|
|
|
485
|
-
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port
|
|
486
|
-
// prior serve exits
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
579
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
|
|
580
|
+
// prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
|
|
581
|
+
// detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
|
|
582
|
+
// netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
|
|
583
|
+
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
584
|
+
// Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
|
|
585
|
+
// released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
|
|
586
|
+
// last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
|
|
587
|
+
// the port" rather than a permanent EADDRINUSE crash-loop.
|
|
588
|
+
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
589
|
+
const MAX_BIND_ATTEMPTS = 4;
|
|
590
|
+
let server: ReturnType<typeof startServer> | undefined;
|
|
591
|
+
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
|
|
592
|
+
try {
|
|
593
|
+
server = startServer();
|
|
594
|
+
break;
|
|
595
|
+
} catch (e: any) {
|
|
596
|
+
if (!isEaddrInUse(e)) throw e;
|
|
597
|
+
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
598
|
+
log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
599
|
+
server = startServer(true);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
|
|
603
|
+
await freeStalePort(port);
|
|
604
|
+
}
|
|
495
605
|
}
|
|
606
|
+
if (!server) throw new Error(`failed to bind port ${port}`);
|
|
496
607
|
|
|
497
608
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
498
609
|
log(`Connection URL set (use .env.local to view)`);
|