rechrome 1.21.0 → 1.22.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 +60 -19
- package/rech.ts +60 -19
- package/serve.js +130 -19
- package/serve.ts +130 -19
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -104,7 +104,7 @@ function openInDefaultApp(target: string): void {
|
|
|
104
104
|
const cmd = process.platform === "darwin" ? ["open", target]
|
|
105
105
|
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
106
106
|
: ["xdg-open", target];
|
|
107
|
-
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
107
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
@@ -137,7 +137,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
|
|
|
137
137
|
try {
|
|
138
138
|
Bun.spawn(
|
|
139
139
|
[chromeBin, `--profile-directory=${profileDir}`, target],
|
|
140
|
-
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
140
|
+
{ stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
|
|
141
141
|
);
|
|
142
142
|
return true;
|
|
143
143
|
} catch {
|
|
@@ -200,7 +200,8 @@ function realpathSafe(p: string): string {
|
|
|
200
200
|
|
|
201
201
|
async function gitOutput(args: string[], cwd: string): Promise<string | null> {
|
|
202
202
|
try {
|
|
203
|
-
|
|
203
|
+
// windowsHide: don't flash a console window on Windows (git.exe is a console app)
|
|
204
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
204
205
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
205
206
|
await proc.exited;
|
|
206
207
|
return out || null;
|
|
@@ -478,12 +479,18 @@ async function listProfiles(): Promise<void> {
|
|
|
478
479
|
}
|
|
479
480
|
}
|
|
480
481
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
482
|
+
// Header clarifies each column; email/nickname sit beside the profile dir so a bare
|
|
483
|
+
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
484
|
+
// from Local State — the gaia real name is deliberately never surfaced.
|
|
485
|
+
const rows = [
|
|
486
|
+
["PROFILE", "EMAIL", "NICKNAME", ""],
|
|
487
|
+
...Object.entries(cache).map(([dir, info]) => [
|
|
488
|
+
dir,
|
|
489
|
+
info.user_name || "",
|
|
490
|
+
info.name || "",
|
|
491
|
+
dir === currentDir ? "← current" : "",
|
|
492
|
+
]),
|
|
493
|
+
];
|
|
487
494
|
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
488
495
|
for (const row of rows) {
|
|
489
496
|
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
@@ -494,9 +501,13 @@ async function callServe(
|
|
|
494
501
|
url: string,
|
|
495
502
|
args: string[],
|
|
496
503
|
overrideEnv?: Record<string, string>,
|
|
504
|
+
precomputedIdentity?: { key: string; label: string; profile?: string },
|
|
497
505
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
498
506
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
499
|
-
|
|
507
|
+
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
508
|
+
// and run() has already done so for its log line. Recomputing here would double those git
|
|
509
|
+
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
510
|
+
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
500
511
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
501
512
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
502
513
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
@@ -545,7 +556,7 @@ async function run(url: string, args: string[]) {
|
|
|
545
556
|
);
|
|
546
557
|
|
|
547
558
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
548
|
-
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
559
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
|
|
549
560
|
|
|
550
561
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
551
562
|
if (existingSession && isOpenWithUrl) {
|
|
@@ -658,6 +669,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
|
|
|
658
669
|
const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
|
|
659
670
|
stdout: "inherit",
|
|
660
671
|
stderr: "inherit",
|
|
672
|
+
windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
|
|
661
673
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
662
674
|
});
|
|
663
675
|
await proc.exited;
|
|
@@ -667,7 +679,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
|
|
|
667
679
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
668
680
|
// Both render the process name verbatim, so callers can substring-match it.
|
|
669
681
|
async function pmList(): Promise<string> {
|
|
670
|
-
const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
|
|
682
|
+
const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
671
683
|
return await new Response(proc.stdout).text();
|
|
672
684
|
}
|
|
673
685
|
|
|
@@ -983,7 +995,7 @@ async function provisionExtensionToken(opts: {
|
|
|
983
995
|
if (!opts.headed) args.push("--headless=new");
|
|
984
996
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
985
997
|
args.push("about:blank");
|
|
986
|
-
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
|
|
998
|
+
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
987
999
|
let cdp: CDPClient | null = null;
|
|
988
1000
|
try {
|
|
989
1001
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
@@ -1198,13 +1210,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1198
1210
|
);
|
|
1199
1211
|
if (opts.profile !== undefined) {
|
|
1200
1212
|
const num = parseInt(opts.profile);
|
|
1201
|
-
if (!isNaN(num) && String(num) === opts.profile.trim())
|
|
1213
|
+
if (!isNaN(num) && String(num) === opts.profile.trim()) {
|
|
1214
|
+
// A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
|
|
1215
|
+
// "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
|
|
1216
|
+
// *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
|
|
1217
|
+
// and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
|
|
1218
|
+
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1219
|
+
const sel = available[num - 1] ?? null;
|
|
1220
|
+
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1221
|
+
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1222
|
+
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1223
|
+
console.error(
|
|
1224
|
+
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1225
|
+
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1226
|
+
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1227
|
+
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1231
|
+
return sel;
|
|
1232
|
+
}
|
|
1202
1233
|
const needle = opts.profile.toLowerCase();
|
|
1203
|
-
|
|
1234
|
+
const match = available.find(([dir, info]) =>
|
|
1204
1235
|
dir.toLowerCase() === needle
|
|
1205
1236
|
|| (info.name ?? "").toLowerCase() === needle
|
|
1206
1237
|
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1207
1238
|
) ?? null;
|
|
1239
|
+
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1240
|
+
return match;
|
|
1208
1241
|
}
|
|
1209
1242
|
if (available.length === 1) {
|
|
1210
1243
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
@@ -1414,14 +1447,17 @@ async function status(): Promise<void> {
|
|
|
1414
1447
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
1415
1448
|
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
1416
1449
|
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
1417
|
-
const
|
|
1450
|
+
const pingBody = ping
|
|
1418
1451
|
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
1419
1452
|
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
1420
1453
|
signal: AbortSignal.timeout(2000),
|
|
1421
|
-
}).then(r => (r.ok ? r.json() : null)).
|
|
1422
|
-
:
|
|
1454
|
+
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
1455
|
+
: null;
|
|
1456
|
+
const bind = pingBody?.bind;
|
|
1423
1457
|
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
1424
1458
|
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
1459
|
+
if (pingBody?.degraded)
|
|
1460
|
+
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
1461
|
const pmOut = await pmList();
|
|
1426
1462
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
1427
1463
|
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
@@ -1448,7 +1484,12 @@ function printHelp(): void {
|
|
|
1448
1484
|
Usage:
|
|
1449
1485
|
rech setup [--profile <num|email>] [--token <tok>]
|
|
1450
1486
|
First-time setup: daemon + Chrome extension + config
|
|
1451
|
-
--profile selects the Chrome profile non-interactively
|
|
1487
|
+
--profile selects the Chrome profile non-interactively.
|
|
1488
|
+
A bare number is the 1-based MENU INDEX (position in the
|
|
1489
|
+
listed order), NOT the Chrome directory "Profile N". For
|
|
1490
|
+
scripts prefer the email (e.g. --profile you@gmail.com) —
|
|
1491
|
+
it is the only unambiguous selector; an exact directory
|
|
1492
|
+
name ("Profile 1") also matches.
|
|
1452
1493
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1453
1494
|
non-TTY/agent runs, skipping the interactive paste
|
|
1454
1495
|
rech provision-profile <name> --experimental [--headed]
|
package/rech.ts
CHANGED
|
@@ -104,7 +104,7 @@ function openInDefaultApp(target: string): void {
|
|
|
104
104
|
const cmd = process.platform === "darwin" ? ["open", target]
|
|
105
105
|
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
106
106
|
: ["xdg-open", target];
|
|
107
|
-
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
107
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
@@ -137,7 +137,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
|
|
|
137
137
|
try {
|
|
138
138
|
Bun.spawn(
|
|
139
139
|
[chromeBin, `--profile-directory=${profileDir}`, target],
|
|
140
|
-
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
140
|
+
{ stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
|
|
141
141
|
);
|
|
142
142
|
return true;
|
|
143
143
|
} catch {
|
|
@@ -200,7 +200,8 @@ function realpathSafe(p: string): string {
|
|
|
200
200
|
|
|
201
201
|
async function gitOutput(args: string[], cwd: string): Promise<string | null> {
|
|
202
202
|
try {
|
|
203
|
-
|
|
203
|
+
// windowsHide: don't flash a console window on Windows (git.exe is a console app)
|
|
204
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
204
205
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
205
206
|
await proc.exited;
|
|
206
207
|
return out || null;
|
|
@@ -478,12 +479,18 @@ async function listProfiles(): Promise<void> {
|
|
|
478
479
|
}
|
|
479
480
|
}
|
|
480
481
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
482
|
+
// Header clarifies each column; email/nickname sit beside the profile dir so a bare
|
|
483
|
+
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
484
|
+
// from Local State — the gaia real name is deliberately never surfaced.
|
|
485
|
+
const rows = [
|
|
486
|
+
["PROFILE", "EMAIL", "NICKNAME", ""],
|
|
487
|
+
...Object.entries(cache).map(([dir, info]) => [
|
|
488
|
+
dir,
|
|
489
|
+
info.user_name || "",
|
|
490
|
+
info.name || "",
|
|
491
|
+
dir === currentDir ? "← current" : "",
|
|
492
|
+
]),
|
|
493
|
+
];
|
|
487
494
|
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
488
495
|
for (const row of rows) {
|
|
489
496
|
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
@@ -494,9 +501,13 @@ async function callServe(
|
|
|
494
501
|
url: string,
|
|
495
502
|
args: string[],
|
|
496
503
|
overrideEnv?: Record<string, string>,
|
|
504
|
+
precomputedIdentity?: { key: string; label: string; profile?: string },
|
|
497
505
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
498
506
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
499
|
-
|
|
507
|
+
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
508
|
+
// and run() has already done so for its log line. Recomputing here would double those git
|
|
509
|
+
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
510
|
+
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
500
511
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
501
512
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
502
513
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
@@ -545,7 +556,7 @@ async function run(url: string, args: string[]) {
|
|
|
545
556
|
);
|
|
546
557
|
|
|
547
558
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
548
|
-
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
559
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
|
|
549
560
|
|
|
550
561
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
551
562
|
if (existingSession && isOpenWithUrl) {
|
|
@@ -658,6 +669,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
|
|
|
658
669
|
const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
|
|
659
670
|
stdout: "inherit",
|
|
660
671
|
stderr: "inherit",
|
|
672
|
+
windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
|
|
661
673
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
662
674
|
});
|
|
663
675
|
await proc.exited;
|
|
@@ -667,7 +679,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
|
|
|
667
679
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
668
680
|
// Both render the process name verbatim, so callers can substring-match it.
|
|
669
681
|
async function pmList(): Promise<string> {
|
|
670
|
-
const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
|
|
682
|
+
const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
671
683
|
return await new Response(proc.stdout).text();
|
|
672
684
|
}
|
|
673
685
|
|
|
@@ -983,7 +995,7 @@ async function provisionExtensionToken(opts: {
|
|
|
983
995
|
if (!opts.headed) args.push("--headless=new");
|
|
984
996
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
985
997
|
args.push("about:blank");
|
|
986
|
-
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
|
|
998
|
+
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
987
999
|
let cdp: CDPClient | null = null;
|
|
988
1000
|
try {
|
|
989
1001
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
@@ -1198,13 +1210,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1198
1210
|
);
|
|
1199
1211
|
if (opts.profile !== undefined) {
|
|
1200
1212
|
const num = parseInt(opts.profile);
|
|
1201
|
-
if (!isNaN(num) && String(num) === opts.profile.trim())
|
|
1213
|
+
if (!isNaN(num) && String(num) === opts.profile.trim()) {
|
|
1214
|
+
// A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
|
|
1215
|
+
// "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
|
|
1216
|
+
// *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
|
|
1217
|
+
// and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
|
|
1218
|
+
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1219
|
+
const sel = available[num - 1] ?? null;
|
|
1220
|
+
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1221
|
+
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1222
|
+
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1223
|
+
console.error(
|
|
1224
|
+
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1225
|
+
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1226
|
+
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1227
|
+
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1231
|
+
return sel;
|
|
1232
|
+
}
|
|
1202
1233
|
const needle = opts.profile.toLowerCase();
|
|
1203
|
-
|
|
1234
|
+
const match = available.find(([dir, info]) =>
|
|
1204
1235
|
dir.toLowerCase() === needle
|
|
1205
1236
|
|| (info.name ?? "").toLowerCase() === needle
|
|
1206
1237
|
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1207
1238
|
) ?? null;
|
|
1239
|
+
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1240
|
+
return match;
|
|
1208
1241
|
}
|
|
1209
1242
|
if (available.length === 1) {
|
|
1210
1243
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
@@ -1414,14 +1447,17 @@ async function status(): Promise<void> {
|
|
|
1414
1447
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
1415
1448
|
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
1416
1449
|
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
1417
|
-
const
|
|
1450
|
+
const pingBody = ping
|
|
1418
1451
|
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
1419
1452
|
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
1420
1453
|
signal: AbortSignal.timeout(2000),
|
|
1421
|
-
}).then(r => (r.ok ? r.json() : null)).
|
|
1422
|
-
:
|
|
1454
|
+
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
1455
|
+
: null;
|
|
1456
|
+
const bind = pingBody?.bind;
|
|
1423
1457
|
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
1424
1458
|
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
1459
|
+
if (pingBody?.degraded)
|
|
1460
|
+
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
1461
|
const pmOut = await pmList();
|
|
1426
1462
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
1427
1463
|
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
@@ -1448,7 +1484,12 @@ function printHelp(): void {
|
|
|
1448
1484
|
Usage:
|
|
1449
1485
|
rech setup [--profile <num|email>] [--token <tok>]
|
|
1450
1486
|
First-time setup: daemon + Chrome extension + config
|
|
1451
|
-
--profile selects the Chrome profile non-interactively
|
|
1487
|
+
--profile selects the Chrome profile non-interactively.
|
|
1488
|
+
A bare number is the 1-based MENU INDEX (position in the
|
|
1489
|
+
listed order), NOT the Chrome directory "Profile N". For
|
|
1490
|
+
scripts prefer the email (e.g. --profile you@gmail.com) —
|
|
1491
|
+
it is the only unambiguous selector; an exact directory
|
|
1492
|
+
name ("Profile 1") also matches.
|
|
1452
1493
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1453
1494
|
non-TTY/agent runs, skipping the interactive paste
|
|
1454
1495
|
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)`);
|