rechrome 1.32.0 → 1.33.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -377,6 +377,9 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
377
377
  return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
378
378
  }
379
379
 
380
+ // The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
381
+ // daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
382
+ const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
380
383
  const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
381
384
 
382
385
  /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
@@ -384,7 +387,7 @@ async function runTailscale(args: string[]): Promise<string | null> {
384
387
  const binary = tailscaleBinary();
385
388
  if (!binary) return null;
386
389
  try {
387
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
390
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
388
391
  const timeout = setTimeout(() => proc.kill(), 2000);
389
392
  try {
390
393
  const output = await new Response(proc.stdout).text();
@@ -445,6 +448,101 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
445
448
  return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
446
449
  }
447
450
 
451
+ type ServeHandler = { hostPort: string; mount: string; target: URL };
452
+
453
+ /** Every Serve route that proxies to a loopback HTTP port. */
454
+ function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
455
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
456
+ const handlers: ServeHandler[] = [];
457
+ for (const [hostPort, config] of Object.entries(web)) {
458
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
459
+ if (!handler.Proxy) continue;
460
+ let target: URL;
461
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
462
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
463
+ handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
464
+ }
465
+ }
466
+ return handlers;
467
+ }
468
+
469
+ export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
470
+ export type ServeStale = { hostPort: string; mount: string; port: number };
471
+
472
+ /**
473
+ * Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
474
+ * mount that proxies to another port (the listener moved), re-pointed at the listener's port.
475
+ * `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
476
+ * /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
477
+ * Root ("/") mounts are never touched: they could be anything.
478
+ */
479
+ export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
480
+ const ports = new Set(listeners.map(l => l.port));
481
+ const mounts = new Map<string, number>();
482
+ for (const l of listeners) {
483
+ const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
484
+ if (mount) mounts.set(mount, l.port);
485
+ }
486
+ const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
487
+ const repair: ServeRepair[] = [], stale: ServeStale[] = [];
488
+ for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
489
+ const port = Number(target.port), want = mounts.get(mount);
490
+ if (want !== undefined) {
491
+ if (port !== want) {
492
+ const proxy = new URL(target); proxy.port = String(want);
493
+ repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
494
+ }
495
+ } else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
496
+ stale.push({ hostPort, mount, port });
497
+ }
498
+ }
499
+ return { repair, stale };
500
+ }
501
+
502
+ /** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
503
+ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
504
+ const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
505
+ if (!binary || !raw) return;
506
+ let serveStatus: unknown;
507
+ try { serveStatus = JSON.parse(raw); } catch { return; }
508
+ const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
509
+ for (const h of loopbackServeHandlers(serveStatus)) {
510
+ if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
511
+ const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
512
+ const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
513
+ console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
519
+ * Re-points a moved listener's route on this node; only reports stale routes (removing a
520
+ * route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
521
+ */
522
+ export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
523
+ const binary = tailscaleBinary();
524
+ if (!binary) return [];
525
+ const raw = await runTailscale(["serve", "status", "--json"]);
526
+ if (!raw) return [];
527
+ let serveStatus: unknown;
528
+ try { serveStatus = JSON.parse(raw); } catch { return []; }
529
+ const { repair, stale } = planTailscaleServeRepairs(serveStatus, listeners);
530
+ const lines: string[] = [];
531
+ for (const r of repair) {
532
+ const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
533
+ const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
534
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
535
+ const ok = await proc.exited === 0;
536
+ lines.push(ok
537
+ ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
538
+ : `tailscale serve: ${r.hostPort}${r.mount} points at 127.0.0.1:${r.from}, listener is on ${r.to}; re-pointing failed (${(await new Response(proc.stderr).text()).trim()}). Run: tailscale ${args.join(" ")}`);
539
+ }
540
+ for (const s of stale) {
541
+ lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
542
+ }
543
+ return lines;
544
+ }
545
+
448
546
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
449
547
  export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
450
548
  const local = new URL(localUrl);
@@ -1408,7 +1506,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
1408
1506
  const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1409
1507
  const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1410
1508
  console.error(`Running: tailscale ${args.join(" ")}`);
1411
- const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1509
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
1412
1510
  if (await proc.exited !== 0) {
1413
1511
  console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1414
1512
  return null;
@@ -1669,7 +1767,9 @@ async function callServe(
1669
1767
  return JSON.parse(text);
1670
1768
  } catch {
1671
1769
  // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1672
- const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1770
+ let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1771
+ if (res.status === 502 || res.status === 503 || res.status === 504)
1772
+ detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
1673
1773
  if (throwOnFailure) throw new Error(detail);
1674
1774
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1675
1775
  process.exit(1);
@@ -2759,6 +2859,7 @@ async function removeListener(name: string): Promise<void> {
2759
2859
  config.listeners = config.listeners.filter(l => l !== listener);
2760
2860
  await writeListeners(config);
2761
2861
  console.log("Listener removed from configuration; daemon reloads automatically.");
2862
+ await removeTailscaleServeRoutes(listener);
2762
2863
  }
2763
2864
 
2764
2865
  async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
package/rechrome.ts CHANGED
@@ -377,6 +377,9 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
377
377
  return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
378
378
  }
379
379
 
380
+ // The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
381
+ // daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
382
+ const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
380
383
  const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
381
384
 
382
385
  /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
@@ -384,7 +387,7 @@ async function runTailscale(args: string[]): Promise<string | null> {
384
387
  const binary = tailscaleBinary();
385
388
  if (!binary) return null;
386
389
  try {
387
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
390
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
388
391
  const timeout = setTimeout(() => proc.kill(), 2000);
389
392
  try {
390
393
  const output = await new Response(proc.stdout).text();
@@ -445,6 +448,101 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
445
448
  return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
446
449
  }
447
450
 
451
+ type ServeHandler = { hostPort: string; mount: string; target: URL };
452
+
453
+ /** Every Serve route that proxies to a loopback HTTP port. */
454
+ function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
455
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
456
+ const handlers: ServeHandler[] = [];
457
+ for (const [hostPort, config] of Object.entries(web)) {
458
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
459
+ if (!handler.Proxy) continue;
460
+ let target: URL;
461
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
462
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
463
+ handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
464
+ }
465
+ }
466
+ return handlers;
467
+ }
468
+
469
+ export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
470
+ export type ServeStale = { hostPort: string; mount: string; port: number };
471
+
472
+ /**
473
+ * Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
474
+ * mount that proxies to another port (the listener moved), re-pointed at the listener's port.
475
+ * `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
476
+ * /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
477
+ * Root ("/") mounts are never touched: they could be anything.
478
+ */
479
+ export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
480
+ const ports = new Set(listeners.map(l => l.port));
481
+ const mounts = new Map<string, number>();
482
+ for (const l of listeners) {
483
+ const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
484
+ if (mount) mounts.set(mount, l.port);
485
+ }
486
+ const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
487
+ const repair: ServeRepair[] = [], stale: ServeStale[] = [];
488
+ for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
489
+ const port = Number(target.port), want = mounts.get(mount);
490
+ if (want !== undefined) {
491
+ if (port !== want) {
492
+ const proxy = new URL(target); proxy.port = String(want);
493
+ repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
494
+ }
495
+ } else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
496
+ stale.push({ hostPort, mount, port });
497
+ }
498
+ }
499
+ return { repair, stale };
500
+ }
501
+
502
+ /** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
503
+ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
504
+ const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
505
+ if (!binary || !raw) return;
506
+ let serveStatus: unknown;
507
+ try { serveStatus = JSON.parse(raw); } catch { return; }
508
+ const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
509
+ for (const h of loopbackServeHandlers(serveStatus)) {
510
+ if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
511
+ const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
512
+ const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
513
+ console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
519
+ * Re-points a moved listener's route on this node; only reports stale routes (removing a
520
+ * route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
521
+ */
522
+ export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
523
+ const binary = tailscaleBinary();
524
+ if (!binary) return [];
525
+ const raw = await runTailscale(["serve", "status", "--json"]);
526
+ if (!raw) return [];
527
+ let serveStatus: unknown;
528
+ try { serveStatus = JSON.parse(raw); } catch { return []; }
529
+ const { repair, stale } = planTailscaleServeRepairs(serveStatus, listeners);
530
+ const lines: string[] = [];
531
+ for (const r of repair) {
532
+ const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
533
+ const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
534
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
535
+ const ok = await proc.exited === 0;
536
+ lines.push(ok
537
+ ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
538
+ : `tailscale serve: ${r.hostPort}${r.mount} points at 127.0.0.1:${r.from}, listener is on ${r.to}; re-pointing failed (${(await new Response(proc.stderr).text()).trim()}). Run: tailscale ${args.join(" ")}`);
539
+ }
540
+ for (const s of stale) {
541
+ lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
542
+ }
543
+ return lines;
544
+ }
545
+
448
546
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
449
547
  export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
450
548
  const local = new URL(localUrl);
@@ -1408,7 +1506,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
1408
1506
  const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1409
1507
  const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1410
1508
  console.error(`Running: tailscale ${args.join(" ")}`);
1411
- const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1509
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
1412
1510
  if (await proc.exited !== 0) {
1413
1511
  console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1414
1512
  return null;
@@ -1669,7 +1767,9 @@ async function callServe(
1669
1767
  return JSON.parse(text);
1670
1768
  } catch {
1671
1769
  // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1672
- const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1770
+ let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1771
+ if (res.status === 502 || res.status === 503 || res.status === 504)
1772
+ detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
1673
1773
  if (throwOnFailure) throw new Error(detail);
1674
1774
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1675
1775
  process.exit(1);
@@ -2759,6 +2859,7 @@ async function removeListener(name: string): Promise<void> {
2759
2859
  config.listeners = config.listeners.filter(l => l !== listener);
2760
2860
  await writeListeners(config);
2761
2861
  console.log("Listener removed from configuration; daemon reloads automatically.");
2862
+ await removeTailscaleServeRoutes(listener);
2762
2863
  }
2763
2864
 
2764
2865
  async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
package/serve.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  resolvePlaywrightCli,
18
18
  readTokenRegistry,
19
19
  readChromeProfileCache,
20
+ checkTailscaleServe,
20
21
  } from "./rechrome.js";
21
22
 
22
23
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -1041,4 +1042,19 @@ export async function serve() {
1041
1042
  } finally { reloading = false; }
1042
1043
  }, 1000);
1043
1044
  log("Connection credentials remain in local configuration; listener changes reload automatically");
1045
+ // Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
1046
+ // change, and every few minutes (routes can be edited by hand). Each finding is logged once.
1047
+ let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
1048
+ setInterval(async () => {
1049
+ if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
1050
+ checking = true;
1051
+ try {
1052
+ const listeners = JSON.parse(applied || "[]") as Listener[];
1053
+ const lines = await checkTailscaleServe(listeners);
1054
+ const report = lines.join("\n");
1055
+ if (report && report !== lastReport) for (const line of lines) log(line);
1056
+ lastReport = report; checkedFor = applied; lastCheck = Date.now();
1057
+ } catch (error) { log(`tailscale serve health check failed: ${error}`); }
1058
+ finally { checking = false; }
1059
+ }, 2000);
1044
1060
  }
package/serve.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  resolvePlaywrightCli,
18
18
  readTokenRegistry,
19
19
  readChromeProfileCache,
20
+ checkTailscaleServe,
20
21
  } from "./rechrome.ts";
21
22
 
22
23
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -1041,4 +1042,19 @@ export async function serve() {
1041
1042
  } finally { reloading = false; }
1042
1043
  }, 1000);
1043
1044
  log("Connection credentials remain in local configuration; listener changes reload automatically");
1045
+ // Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
1046
+ // change, and every few minutes (routes can be edited by hand). Each finding is logged once.
1047
+ let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
1048
+ setInterval(async () => {
1049
+ if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
1050
+ checking = true;
1051
+ try {
1052
+ const listeners = JSON.parse(applied || "[]") as Listener[];
1053
+ const lines = await checkTailscaleServe(listeners);
1054
+ const report = lines.join("\n");
1055
+ if (report && report !== lastReport) for (const line of lines) log(line);
1056
+ lastReport = report; checkedFor = applied; lastCheck = Date.now();
1057
+ } catch (error) { log(`tailscale serve health check failed: ${error}`); }
1058
+ finally { checking = false; }
1059
+ }, 2000);
1044
1060
  }