rechrome 1.20.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.
Files changed (5) hide show
  1. package/package.json +1 -1
  2. package/rech.js +185 -61
  3. package/rech.ts +185 -61
  4. package/serve.js +231 -40
  5. package/serve.ts +231 -40
package/serve.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
2
  import { createHash, X509Certificate } from "crypto";
3
- import { mkdirSync, unlinkSync, accessSync, constants as fsConstants } from "fs";
3
+ import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -16,25 +16,121 @@ import {
16
16
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
17
17
  const CERT_RENEW_THRESHOLD_DAYS = 7;
18
18
 
19
- // Short label for a client identity, used as the Chrome tab-group name (the tab
20
- // strip is space-constrained, so cap at 7 chars). gitUrl ".../owner/repo/tree/branch"
21
- // -> "rep:bra" (3+3); "host:/path/to/dir" -> "dir"; bare host/IP -> as-is. Strips a
22
- // trailing "@profile" suffix first.
23
- const MAX_GROUP_LABEL_LEN = 7;
24
- function shortClientLabel(raw: string): string {
19
+ // Short label for a client identity, used as the Chrome tab-group name (the tab strip is
20
+ // space-constrained, so cap at 7 chars). Handles the current label shape and the legacy gitUrl:
21
+ // "host/owner/repo#<basename>@<branch>" -> "bas:bra" (3+3) (current)
22
+ // "host/owner/repo/tree/branch" -> "rep:bra" (3+3) (legacy gitUrl)
23
+ // "host:/path/to/dir" -> "dir" (non-git)
24
+ // bare host/IP -> as-is
25
+ export const MAX_GROUP_LABEL_LEN = 7;
26
+ export function shortClientLabel(raw: string): string {
25
27
  if (!raw) return raw;
26
- const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
27
- const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
28
- let label: string;
29
- if (git)
30
- label = git[2] ? `${git[1].slice(0, 3)}:${git[2].slice(0, 3)}` : git[1];
31
- else {
32
- const hostCwd = baseId.match(/^[^:]+:(.+)$/);
33
- label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || baseId) : baseId;
28
+ const join3 = (a: string, b?: string) => (b ? `${a.slice(0, 3)}:${b.slice(0, 3)}` : a);
29
+ // Current label: "[remote#]<worktree-basename>[@<branch>]"
30
+ if (raw.includes("#") || (raw.includes("@") && !raw.startsWith("http"))) {
31
+ const afterHash = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw;
32
+ const [base, branch] = afterHash.split("@");
33
+ return join3(base, branch).slice(0, MAX_GROUP_LABEL_LEN);
34
34
  }
35
+ // Legacy gitUrl: ".../owner/repo/tree/branch"
36
+ const git = raw.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
37
+ if (git) return join3(git[1], git[2]).slice(0, MAX_GROUP_LABEL_LEN);
38
+ // "host:/path/to/dir" -> basename
39
+ const hostCwd = raw.match(/^[^:]+:(.+)$/);
40
+ const label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || raw) : raw;
35
41
  return label.slice(0, MAX_GROUP_LABEL_LEN);
36
42
  }
37
43
 
44
+ // --isolate sessions (`rech --isolate ...` -> `-s=iso-<rand>`) are throwaway, single-flow
45
+ // buckets. We reap them on an idle TTL so an OAuth/login drive can't leak an orphaned browser
46
+ // context. A TTL (not close-on-exit-per-command) is required because the flows are multi-step
47
+ // (open -> click -> consent): closing after each command would break them. The reaper runs the
48
+ // CLI's `close` for the specific iso session only — it never touches the user's other sessions
49
+ // or quits Chrome.
50
+ const ISO_SESSION_TTL_MS = Number(process.env.RECH_ISOLATE_TTL_MS) || 15 * 60_000;
51
+ const ISO_REAP_INTERVAL_MS = 60_000;
52
+ const isoLastUsed = new Map<string, number>();
53
+
54
+ export function isIsoSession(namespacedSession: string): boolean {
55
+ return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
56
+ }
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
+
87
+ function tmpSocketRoot(): string {
88
+ return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
89
+ }
90
+
91
+ // On startup, adopt any iso-* sessions a previous daemon left behind so they still get reaped
92
+ // (in-memory tracking alone would miss pre-restart orphans). Best-effort: a mis-derived session
93
+ // just yields a harmless no-op `close`. Socket files are named with the session as their prefix.
94
+ function adoptOrphanedIsoSessions(): void {
95
+ try {
96
+ const root = tmpSocketRoot();
97
+ for (const sub of readdirSync(root)) {
98
+ let entries: string[];
99
+ try { entries = readdirSync(`${root}/${sub}`); } catch { continue; }
100
+ for (const f of entries) {
101
+ const m = f.match(/^([0-9a-f]+-iso-[0-9a-f]+)/) || f.match(/^(iso-[0-9a-f]+)/);
102
+ if (m && !isoLastUsed.has(m[1])) {
103
+ isoLastUsed.set(m[1], Date.now());
104
+ log(`adopted orphaned isolated session for reaping: ${m[1]}`);
105
+ }
106
+ }
107
+ }
108
+ } catch {
109
+ // socket dir may not exist yet — nothing to adopt
110
+ }
111
+ }
112
+
113
+ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): void {
114
+ const now = Date.now();
115
+ for (const [sess, last] of isoLastUsed) {
116
+ if (now - last < ISO_SESSION_TTL_MS) continue;
117
+ isoLastUsed.delete(sess);
118
+ try {
119
+ Bun.spawn([bin, ...binArgs, "close", `-s=${sess}`], {
120
+ cwd: workDir,
121
+ stdin: "ignore",
122
+ stdout: "ignore",
123
+ stderr: "ignore",
124
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
125
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
126
+ });
127
+ log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
128
+ } catch (e) {
129
+ log(`reap failed for ${sess}: ${e}`);
130
+ }
131
+ }
132
+ }
133
+
38
134
  async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
39
135
  const certContent = await file(certPath).text().catch(() => null);
40
136
  if (!certContent) return false;
@@ -46,7 +142,7 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
46
142
  if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
47
143
  log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
48
144
  const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
49
- stdout: "pipe", stderr: "pipe",
145
+ stdout: "pipe", stderr: "pipe", windowsHide: true,
50
146
  });
51
147
  const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
52
148
  if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
@@ -123,7 +219,7 @@ async function freeStalePort(port: number): Promise<void> {
123
219
  " $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
124
220
  "}",
125
221
  ].join(" ");
126
- const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
222
+ const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
127
223
  const out = r.stdout?.toString().trim();
128
224
  if (out) log(out);
129
225
  } else {
@@ -142,6 +238,13 @@ export async function serve() {
142
238
  const workDir = join(RECH_DIR, "output");
143
239
  mkdirSync(workDir, { recursive: true });
144
240
 
241
+ // Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
242
+ adoptOrphanedIsoSessions();
243
+ setInterval(() => {
244
+ const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
245
+ reapIdleIsoSessions(bin, binArgs, workDir);
246
+ }, ISO_REAP_INTERVAL_MS);
247
+
145
248
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
146
249
  const canRead = (p?: string) => { try { accessSync(p!, fsConstants.R_OK); return true; } catch { return false; } };
147
250
  const certPath = canRead(process.env.RECH_TLS_CERT) ? process.env.RECH_TLS_CERT : undefined;
@@ -155,9 +258,13 @@ export async function serve() {
155
258
  }, 86_400_000);
156
259
  }
157
260
  const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
158
- const startServer = () => Bun.serve({
261
+ const startServer = (reusePort = false) => Bun.serve({
159
262
  hostname: listenHost,
160
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,
161
268
  tls,
162
269
  error(err) {
163
270
  log(`unhandled error: ${err.message}`);
@@ -181,7 +288,32 @@ export async function serve() {
181
288
  if (reqUrl.pathname === "/ping") {
182
289
  const denied = authCheck(req, key);
183
290
  if (denied) return denied;
184
- return Response.json({ ok: true, bind: listenHost });
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
+ });
185
317
  }
186
318
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
187
319
  const denied = authCheck(req, key);
@@ -201,14 +333,19 @@ export async function serve() {
201
333
  } else {
202
334
  args = body.args;
203
335
  const id = body.identity as
204
- | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
336
+ | { key?: string; label?: string; gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
205
337
  | undefined;
206
- const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
207
- const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
208
- if (raw) {
209
- sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 8);
210
- clientName = raw;
211
- log(`session from identity: ${raw} -> ${sessionId}`);
338
+ // New clients send {key,label} (key = worktree-root-based, decoupled from the pretty
339
+ // label). Fall back to the legacy {gitUrl|hostname:cwd} shape for older clients.
340
+ const legacy = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
341
+ const keyBase = id?.key || legacy;
342
+ const labelBase = id?.label || legacy || keyBase;
343
+ if (keyBase) {
344
+ // Hash the key (+ profile via a NUL separator that never appears in a label).
345
+ const hashInput = id?.profile ? `${keyBase}\u0000${id.profile}` : keyBase;
346
+ sessionId = createHash("sha256").update(hashInput).digest("hex").slice(0, 8);
347
+ clientName = labelBase || keyBase;
348
+ log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
212
349
  } else {
213
350
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
214
351
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
@@ -233,6 +370,10 @@ export async function serve() {
233
370
  return true;
234
371
  });
235
372
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
373
+ // Track --isolate sessions so the idle-TTL reaper can close them later.
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
236
377
 
237
378
  // daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
238
379
  // fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
@@ -259,6 +400,7 @@ export async function serve() {
259
400
  stdin: "ignore",
260
401
  stdout: "pipe",
261
402
  stderr: "pipe",
403
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
262
404
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
263
405
  });
264
406
  const [listStatus, listOut] = await Promise.race([
@@ -341,16 +483,20 @@ export async function serve() {
341
483
  stdin: "ignore",
342
484
  stdout: "pipe",
343
485
  stderr: "pipe",
486
+ windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
344
487
  env: childEnv,
345
488
  });
346
489
 
347
490
  const TIMEOUT = 60_000;
348
- const timeout = new Promise<never>((_, reject) =>
349
- setTimeout(() => {
491
+ let timedOut = false;
492
+ let timer: ReturnType<typeof setTimeout> | undefined;
493
+ const timeout = new Promise<never>((_, reject) => {
494
+ timer = setTimeout(() => {
495
+ timedOut = true;
350
496
  proc.kill();
351
497
  reject(new Error("timeout"));
352
- }, TIMEOUT),
353
- );
498
+ }, TIMEOUT);
499
+ });
354
500
  const [status, stdout, stderr] = await Promise.race([
355
501
  Promise.all([
356
502
  proc.exited,
@@ -361,9 +507,37 @@ export async function serve() {
361
507
  ]).catch(
362
508
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
363
509
  ) as [number, string, string];
510
+ clearTimeout(timer);
364
511
 
365
512
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
366
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
+
367
541
  // Detect files mentioned in output
368
542
  const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
369
543
  const mentionedFiles = [
@@ -402,17 +576,34 @@ export async function serve() {
402
576
  },
403
577
  });
404
578
 
405
- // A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
406
- // prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
407
- let server: ReturnType<typeof startServer>;
408
- try {
409
- server = startServer();
410
- } catch (e: any) {
411
- if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
412
- log(`port ${port} in use clearing stale daemon holders and retrying`);
413
- await freeStalePort(port);
414
- server = startServer();
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
+ }
415
605
  }
606
+ if (!server) throw new Error(`failed to bind port ${port}`);
416
607
 
417
608
  log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
418
609
  log(`Connection URL set (use .env.local to view)`);