rechrome 1.28.2 → 1.29.1
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/README.md +8 -7
- package/package.json +1 -1
- package/rechrome.js +199 -32
- package/rechrome.ts +199 -32
- package/serve.js +240 -7
- package/serve.ts +240 -7
package/serve.js
CHANGED
|
@@ -243,6 +243,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
243
243
|
return nameOrEmail;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
247
|
+
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
248
|
+
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
249
|
+
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
250
|
+
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
251
|
+
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
252
|
+
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
253
|
+
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
254
|
+
// Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
|
|
255
|
+
// THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
|
|
256
|
+
// Playwright daemon, which runs from its own node_modules.
|
|
257
|
+
const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
258
|
+
const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
|
|
259
|
+
const RECH_SERVE = String.raw`rech(rome)?(\.js)?"?\s+serve`;
|
|
260
|
+
const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
|
|
261
|
+
// A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
|
|
262
|
+
// responds at an address is a live server there — possibly a healthy rech serve — so it is
|
|
263
|
+
// never killed. Both schemes: the holder's TLS setting may differ from this serve's.
|
|
264
|
+
async function answersAt(address: string, port: number): Promise<boolean> {
|
|
265
|
+
const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
|
|
266
|
+
: address === "::" ? "[::1]"
|
|
267
|
+
: address.includes(":") ? `[${address}]` : address;
|
|
268
|
+
const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
|
|
269
|
+
signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
|
|
270
|
+
} as RequestInit).then(() => true, () => false);
|
|
271
|
+
return (await Promise.all([probe("http"), probe("https")])).includes(true);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type PortHolder = { address: string; pid: number; alive: boolean; command: string };
|
|
275
|
+
|
|
276
|
+
// Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
|
|
277
|
+
// its family (a wildcard bind conflicts with every address of the family). Never this serve's
|
|
278
|
+
// own sockets, which may already hold other addresses on the same port from this startup.
|
|
279
|
+
function conflictingHolders(port: number, host: string): PortHolder[] {
|
|
280
|
+
let holders: PortHolder[] = [];
|
|
281
|
+
if (process.platform === "win32") {
|
|
282
|
+
const ps = [
|
|
283
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
284
|
+
`$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
|
|
285
|
+
" $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
|
|
286
|
+
" [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
|
|
287
|
+
"ConvertTo-Json -Compress -InputObject $r",
|
|
288
|
+
].join(" ");
|
|
289
|
+
const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
|
|
290
|
+
try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
|
|
291
|
+
} else {
|
|
292
|
+
const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
|
|
293
|
+
let pid = 0;
|
|
294
|
+
for (const line of out.split("\n")) {
|
|
295
|
+
if (line.startsWith("p")) pid = Number(line.slice(1));
|
|
296
|
+
else if (line.startsWith("n") && pid) {
|
|
297
|
+
const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
|
|
298
|
+
const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
|
|
299
|
+
holders.push({ address, pid, alive: true, command });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const v6 = (a: string) => a.includes(":");
|
|
304
|
+
const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
|
|
305
|
+
// A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
|
|
306
|
+
return holders.filter(h => h.pid !== process.pid && (
|
|
307
|
+
wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
|
|
308
|
+
: h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Free the conflicting address from stale rech holders before retrying a failed bind.
|
|
312
|
+
// Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
|
|
313
|
+
// cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
|
|
314
|
+
// server; (2) only if the address is STILL held by owners that are all dead — the
|
|
315
|
+
// inherited-handle case, where the socket lives in a child while netstat attributes it to a
|
|
316
|
+
// now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
|
|
317
|
+
// the case (the live holder can't be mapped from the port), so it is a logged last resort.
|
|
318
|
+
async function freeStalePort(port: number, host: string): Promise<void> {
|
|
319
|
+
try {
|
|
320
|
+
const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
|
|
321
|
+
for (const h of conflictingHolders(port, host)) {
|
|
322
|
+
if (!h.alive) continue;
|
|
323
|
+
if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
|
|
324
|
+
if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
|
|
325
|
+
log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
|
|
326
|
+
try { process.kill(h.pid, "SIGKILL"); } catch {}
|
|
327
|
+
}
|
|
328
|
+
await new Promise(r => setTimeout(r, 400));
|
|
329
|
+
const remaining = conflictingHolders(port, host);
|
|
330
|
+
if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
|
|
331
|
+
const q = (text: string) => text.replaceAll("'", "''");
|
|
332
|
+
const ps = [
|
|
333
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
334
|
+
// A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
|
|
335
|
+
// can't be told from an orphan by ancestry. They are only provably orphaned when no
|
|
336
|
+
// other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
|
|
337
|
+
`$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
|
|
338
|
+
`$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
|
|
339
|
+
"if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
|
|
340
|
+
"else {",
|
|
341
|
+
// Exclude this PowerShell itself: its own command line contains the pattern.
|
|
342
|
+
` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
|
|
343
|
+
" Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
|
|
344
|
+
" $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
345
|
+
"}",
|
|
346
|
+
].join(" ");
|
|
347
|
+
const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
|
|
348
|
+
if (out) log(out);
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// best effort — the retry will surface a clear error if the port is still held
|
|
352
|
+
}
|
|
353
|
+
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
354
|
+
}
|
|
355
|
+
|
|
246
356
|
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
247
357
|
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
248
358
|
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
@@ -264,6 +374,88 @@ export function shouldExitOrphanedServe(opts: {
|
|
|
264
374
|
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
265
375
|
}
|
|
266
376
|
|
|
377
|
+
export const LANDING_HEADERS = {
|
|
378
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
379
|
+
"Cache-Control": "no-store",
|
|
380
|
+
"Referrer-Policy": "no-referrer",
|
|
381
|
+
"X-Content-Type-Options": "nosniff",
|
|
382
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The page a shared connection URL shows in a browser: install-and-connect commands to copy,
|
|
387
|
+
* one per shell. It is static; the script reads the full URL (with its #key) from the address
|
|
388
|
+
* bar, shows it with the key masked, and copies it whole.
|
|
389
|
+
*/
|
|
390
|
+
export function landingPage(): string {
|
|
391
|
+
return `<!doctype html>
|
|
392
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
393
|
+
<title>Connect to a shared Chrome · rechrome</title>
|
|
394
|
+
<style>
|
|
395
|
+
:root { --bg:#fff; --fg:#1d1d1f; --muted:#6e6e73; --card:#f5f5f7; --line:#d2d2d7; --accent:#0a66c2; --warn:#b25000; }
|
|
396
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#161617; --fg:#f5f5f7; --muted:#a1a1a6; --card:#232325; --line:#3a3a3c; --accent:#4c9bff; --warn:#ffb86b; } }
|
|
397
|
+
* { box-sizing: border-box; }
|
|
398
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
399
|
+
main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
|
|
400
|
+
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
401
|
+
p { margin: 0 0 16px; color: var(--muted); }
|
|
402
|
+
.row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
|
|
403
|
+
.label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
|
404
|
+
.cmd { display: flex; gap: 10px; align-items: flex-start; }
|
|
405
|
+
code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
|
|
406
|
+
button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
|
|
407
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
408
|
+
.warn { color: var(--warn); font-weight: 600; }
|
|
409
|
+
.small { font-size: .85rem; }
|
|
410
|
+
a { color: var(--accent); }
|
|
411
|
+
</style></head>
|
|
412
|
+
<body><main>
|
|
413
|
+
<h1>Connect to a shared Chrome</h1>
|
|
414
|
+
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
415
|
+
On your computer, inside the project folder that should use it, run:</p>
|
|
416
|
+
<p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
|
|
417
|
+
<div id="cmds"></div>
|
|
418
|
+
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
419
|
+
This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
|
|
420
|
+
</main>
|
|
421
|
+
<script>
|
|
422
|
+
(() => {
|
|
423
|
+
const url = location.href;
|
|
424
|
+
if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
|
|
425
|
+
const posix = s => "'" + s.replace(/'/g, "'\\\\''") + "'";
|
|
426
|
+
const pwsh = s => '"' + s.replace(/[\`"$]/g, c => "\`" + c) + '"';
|
|
427
|
+
const cmd = s => '"' + s.replace(/"/g, "%22") + '"';
|
|
428
|
+
const shells = [
|
|
429
|
+
["macOS / Linux", "bun i -g rechrome && rechrome connect " + posix(url)],
|
|
430
|
+
["Windows PowerShell", "bun i -g rechrome; rechrome connect " + pwsh(url)],
|
|
431
|
+
["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
|
|
432
|
+
];
|
|
433
|
+
const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
|
|
434
|
+
const root = document.getElementById("cmds");
|
|
435
|
+
for (const [label, text] of shells) {
|
|
436
|
+
const row = document.createElement("div"); row.className = "row";
|
|
437
|
+
const name = document.createElement("div"); name.className = "label"; name.textContent = label;
|
|
438
|
+
const line = document.createElement("div"); line.className = "cmd";
|
|
439
|
+
const code = document.createElement("code"); code.textContent = mask(text);
|
|
440
|
+
const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
|
|
441
|
+
button.setAttribute("aria-label", "Copy the " + label + " command");
|
|
442
|
+
button.addEventListener("click", async () => {
|
|
443
|
+
try { await navigator.clipboard.writeText(text); }
|
|
444
|
+
catch {
|
|
445
|
+
// Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
|
|
446
|
+
const area = document.createElement("textarea"); area.value = text; document.body.append(area);
|
|
447
|
+
area.select(); document.execCommand("copy"); area.remove();
|
|
448
|
+
}
|
|
449
|
+
button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
|
450
|
+
});
|
|
451
|
+
line.append(code, button); row.append(name, line); root.append(row);
|
|
452
|
+
}
|
|
453
|
+
})();
|
|
454
|
+
</script>
|
|
455
|
+
</body></html>
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
|
|
267
459
|
export async function serve() {
|
|
268
460
|
// The daemon owns logs/ and output/, so it migrates them before writing anything.
|
|
269
461
|
const migrated = migrateLegacyDataDir();
|
|
@@ -312,9 +504,13 @@ export async function serve() {
|
|
|
312
504
|
const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
|
|
313
505
|
const policies = new Map<string, Listener>();
|
|
314
506
|
const servers = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
315
|
-
const startServer = (initial: Listener) => Bun.serve({
|
|
507
|
+
const startServer = (initial: Listener, reusePort = false) => Bun.serve({
|
|
316
508
|
hostname: initial.host,
|
|
317
509
|
port: initial.port,
|
|
510
|
+
// reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
|
|
511
|
+
// holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
|
|
512
|
+
// instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
|
|
513
|
+
reusePort,
|
|
318
514
|
tls,
|
|
319
515
|
error(err) {
|
|
320
516
|
log(`unhandled error: ${err.message}`);
|
|
@@ -381,6 +577,10 @@ export async function serve() {
|
|
|
381
577
|
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
382
578
|
});
|
|
383
579
|
}
|
|
580
|
+
// A browser opening a shared URL gets copyable connect instructions. The key stays in the
|
|
581
|
+
// #fragment, which browsers never send, so the page is static and the key never reaches here.
|
|
582
|
+
if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
|
|
583
|
+
return new Response(landingPage(), { headers: LANDING_HEADERS });
|
|
384
584
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
385
585
|
const denied = authCheck(req, key);
|
|
386
586
|
if (denied) return denied;
|
|
@@ -687,14 +887,47 @@ export async function serve() {
|
|
|
687
887
|
},
|
|
688
888
|
});
|
|
689
889
|
|
|
890
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
|
|
891
|
+
// prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
|
|
892
|
+
// detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
|
|
893
|
+
// netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
|
|
894
|
+
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
895
|
+
// Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
|
|
896
|
+
// safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
|
|
897
|
+
// reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
|
|
898
|
+
// than a permanent EADDRINUSE crash-loop.
|
|
899
|
+
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
900
|
+
const MAX_BIND_ATTEMPTS = 4;
|
|
901
|
+
const bindAtStartup = async (listener: Listener) => {
|
|
902
|
+
for (let attempt = 1; ; attempt++) {
|
|
903
|
+
try {
|
|
904
|
+
return startServer(listener);
|
|
905
|
+
} catch (e: any) {
|
|
906
|
+
if (!isEaddrInUse(e)) throw e;
|
|
907
|
+
// Something answers at this exact address: a live server (never killed, never port-shared).
|
|
908
|
+
if (await answersAt(listener.host, listener.port)) {
|
|
909
|
+
log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
|
|
910
|
+
throw e;
|
|
911
|
+
}
|
|
912
|
+
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
913
|
+
log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
914
|
+
return startServer(listener, true);
|
|
915
|
+
}
|
|
916
|
+
log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
|
|
917
|
+
await freeStalePort(listener.port, listener.host);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
690
922
|
// Stage all new sockets before changing policy. Bind failure leaves existing
|
|
691
|
-
// listeners and browser sessions intact; never kill a process holding another port
|
|
692
|
-
|
|
923
|
+
// listeners and browser sessions intact; never kill a process holding another port
|
|
924
|
+
// once serving (stale-holder recovery runs only at startup).
|
|
925
|
+
const reconcile = async (listeners: Listener[], startup = false) => {
|
|
693
926
|
const staged = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
694
927
|
try {
|
|
695
928
|
for (const listener of listeners) {
|
|
696
929
|
const address = listenerAddress(listener);
|
|
697
|
-
if (!servers.has(address)) staged.set(address, startServer(listener));
|
|
930
|
+
if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
|
|
698
931
|
}
|
|
699
932
|
} catch (error) {
|
|
700
933
|
for (const server of staged.values()) server.stop(true);
|
|
@@ -709,17 +942,17 @@ export async function serve() {
|
|
|
709
942
|
};
|
|
710
943
|
let applied = "";
|
|
711
944
|
let configured = false;
|
|
712
|
-
const reload = async () => {
|
|
945
|
+
const reload = async (startup = false) => {
|
|
713
946
|
const config = await readListeners();
|
|
714
947
|
if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
|
|
715
948
|
const listeners = config?.listeners ?? [legacy];
|
|
716
949
|
const fingerprint = JSON.stringify(listeners);
|
|
717
950
|
if (applied === fingerprint) return;
|
|
718
|
-
await reconcile(listeners);
|
|
951
|
+
await reconcile(listeners, startup);
|
|
719
952
|
configured ||= !!config;
|
|
720
953
|
applied = fingerprint;
|
|
721
954
|
};
|
|
722
|
-
await reload();
|
|
955
|
+
await reload(true);
|
|
723
956
|
let reloading = false;
|
|
724
957
|
let lastError = "";
|
|
725
958
|
setInterval(async () => {
|
package/serve.ts
CHANGED
|
@@ -243,6 +243,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
243
243
|
return nameOrEmail;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
247
|
+
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
248
|
+
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
249
|
+
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
250
|
+
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
251
|
+
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
252
|
+
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
253
|
+
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
254
|
+
// Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
|
|
255
|
+
// THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
|
|
256
|
+
// Playwright daemon, which runs from its own node_modules.
|
|
257
|
+
const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
258
|
+
const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
|
|
259
|
+
const RECH_SERVE = String.raw`rech(rome)?(\.ts)?"?\s+serve`;
|
|
260
|
+
const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
|
|
261
|
+
// A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
|
|
262
|
+
// responds at an address is a live server there — possibly a healthy rech serve — so it is
|
|
263
|
+
// never killed. Both schemes: the holder's TLS setting may differ from this serve's.
|
|
264
|
+
async function answersAt(address: string, port: number): Promise<boolean> {
|
|
265
|
+
const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
|
|
266
|
+
: address === "::" ? "[::1]"
|
|
267
|
+
: address.includes(":") ? `[${address}]` : address;
|
|
268
|
+
const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
|
|
269
|
+
signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
|
|
270
|
+
} as RequestInit).then(() => true, () => false);
|
|
271
|
+
return (await Promise.all([probe("http"), probe("https")])).includes(true);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type PortHolder = { address: string; pid: number; alive: boolean; command: string };
|
|
275
|
+
|
|
276
|
+
// Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
|
|
277
|
+
// its family (a wildcard bind conflicts with every address of the family). Never this serve's
|
|
278
|
+
// own sockets, which may already hold other addresses on the same port from this startup.
|
|
279
|
+
function conflictingHolders(port: number, host: string): PortHolder[] {
|
|
280
|
+
let holders: PortHolder[] = [];
|
|
281
|
+
if (process.platform === "win32") {
|
|
282
|
+
const ps = [
|
|
283
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
284
|
+
`$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
|
|
285
|
+
" $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
|
|
286
|
+
" [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
|
|
287
|
+
"ConvertTo-Json -Compress -InputObject $r",
|
|
288
|
+
].join(" ");
|
|
289
|
+
const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
|
|
290
|
+
try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
|
|
291
|
+
} else {
|
|
292
|
+
const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
|
|
293
|
+
let pid = 0;
|
|
294
|
+
for (const line of out.split("\n")) {
|
|
295
|
+
if (line.startsWith("p")) pid = Number(line.slice(1));
|
|
296
|
+
else if (line.startsWith("n") && pid) {
|
|
297
|
+
const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
|
|
298
|
+
const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
|
|
299
|
+
holders.push({ address, pid, alive: true, command });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const v6 = (a: string) => a.includes(":");
|
|
304
|
+
const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
|
|
305
|
+
// A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
|
|
306
|
+
return holders.filter(h => h.pid !== process.pid && (
|
|
307
|
+
wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
|
|
308
|
+
: h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Free the conflicting address from stale rech holders before retrying a failed bind.
|
|
312
|
+
// Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
|
|
313
|
+
// cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
|
|
314
|
+
// server; (2) only if the address is STILL held by owners that are all dead — the
|
|
315
|
+
// inherited-handle case, where the socket lives in a child while netstat attributes it to a
|
|
316
|
+
// now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
|
|
317
|
+
// the case (the live holder can't be mapped from the port), so it is a logged last resort.
|
|
318
|
+
async function freeStalePort(port: number, host: string): Promise<void> {
|
|
319
|
+
try {
|
|
320
|
+
const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
|
|
321
|
+
for (const h of conflictingHolders(port, host)) {
|
|
322
|
+
if (!h.alive) continue;
|
|
323
|
+
if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
|
|
324
|
+
if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
|
|
325
|
+
log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
|
|
326
|
+
try { process.kill(h.pid, "SIGKILL"); } catch {}
|
|
327
|
+
}
|
|
328
|
+
await new Promise(r => setTimeout(r, 400));
|
|
329
|
+
const remaining = conflictingHolders(port, host);
|
|
330
|
+
if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
|
|
331
|
+
const q = (text: string) => text.replaceAll("'", "''");
|
|
332
|
+
const ps = [
|
|
333
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
334
|
+
// A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
|
|
335
|
+
// can't be told from an orphan by ancestry. They are only provably orphaned when no
|
|
336
|
+
// other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
|
|
337
|
+
`$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
|
|
338
|
+
`$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
|
|
339
|
+
"if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
|
|
340
|
+
"else {",
|
|
341
|
+
// Exclude this PowerShell itself: its own command line contains the pattern.
|
|
342
|
+
` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
|
|
343
|
+
" Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
|
|
344
|
+
" $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
345
|
+
"}",
|
|
346
|
+
].join(" ");
|
|
347
|
+
const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
|
|
348
|
+
if (out) log(out);
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// best effort — the retry will surface a clear error if the port is still held
|
|
352
|
+
}
|
|
353
|
+
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
354
|
+
}
|
|
355
|
+
|
|
246
356
|
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
247
357
|
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
248
358
|
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
@@ -264,6 +374,88 @@ export function shouldExitOrphanedServe(opts: {
|
|
|
264
374
|
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
265
375
|
}
|
|
266
376
|
|
|
377
|
+
export const LANDING_HEADERS = {
|
|
378
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
379
|
+
"Cache-Control": "no-store",
|
|
380
|
+
"Referrer-Policy": "no-referrer",
|
|
381
|
+
"X-Content-Type-Options": "nosniff",
|
|
382
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The page a shared connection URL shows in a browser: install-and-connect commands to copy,
|
|
387
|
+
* one per shell. It is static; the script reads the full URL (with its #key) from the address
|
|
388
|
+
* bar, shows it with the key masked, and copies it whole.
|
|
389
|
+
*/
|
|
390
|
+
export function landingPage(): string {
|
|
391
|
+
return `<!doctype html>
|
|
392
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
393
|
+
<title>Connect to a shared Chrome · rechrome</title>
|
|
394
|
+
<style>
|
|
395
|
+
:root { --bg:#fff; --fg:#1d1d1f; --muted:#6e6e73; --card:#f5f5f7; --line:#d2d2d7; --accent:#0a66c2; --warn:#b25000; }
|
|
396
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#161617; --fg:#f5f5f7; --muted:#a1a1a6; --card:#232325; --line:#3a3a3c; --accent:#4c9bff; --warn:#ffb86b; } }
|
|
397
|
+
* { box-sizing: border-box; }
|
|
398
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
399
|
+
main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
|
|
400
|
+
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
401
|
+
p { margin: 0 0 16px; color: var(--muted); }
|
|
402
|
+
.row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
|
|
403
|
+
.label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
|
404
|
+
.cmd { display: flex; gap: 10px; align-items: flex-start; }
|
|
405
|
+
code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
|
|
406
|
+
button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
|
|
407
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
408
|
+
.warn { color: var(--warn); font-weight: 600; }
|
|
409
|
+
.small { font-size: .85rem; }
|
|
410
|
+
a { color: var(--accent); }
|
|
411
|
+
</style></head>
|
|
412
|
+
<body><main>
|
|
413
|
+
<h1>Connect to a shared Chrome</h1>
|
|
414
|
+
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
415
|
+
On your computer, inside the project folder that should use it, run:</p>
|
|
416
|
+
<p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
|
|
417
|
+
<div id="cmds"></div>
|
|
418
|
+
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
419
|
+
This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
|
|
420
|
+
</main>
|
|
421
|
+
<script>
|
|
422
|
+
(() => {
|
|
423
|
+
const url = location.href;
|
|
424
|
+
if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
|
|
425
|
+
const posix = s => "'" + s.replace(/'/g, "'\\\\''") + "'";
|
|
426
|
+
const pwsh = s => '"' + s.replace(/[\`"$]/g, c => "\`" + c) + '"';
|
|
427
|
+
const cmd = s => '"' + s.replace(/"/g, "%22") + '"';
|
|
428
|
+
const shells = [
|
|
429
|
+
["macOS / Linux", "bun i -g rechrome && rechrome connect " + posix(url)],
|
|
430
|
+
["Windows PowerShell", "bun i -g rechrome; rechrome connect " + pwsh(url)],
|
|
431
|
+
["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
|
|
432
|
+
];
|
|
433
|
+
const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
|
|
434
|
+
const root = document.getElementById("cmds");
|
|
435
|
+
for (const [label, text] of shells) {
|
|
436
|
+
const row = document.createElement("div"); row.className = "row";
|
|
437
|
+
const name = document.createElement("div"); name.className = "label"; name.textContent = label;
|
|
438
|
+
const line = document.createElement("div"); line.className = "cmd";
|
|
439
|
+
const code = document.createElement("code"); code.textContent = mask(text);
|
|
440
|
+
const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
|
|
441
|
+
button.setAttribute("aria-label", "Copy the " + label + " command");
|
|
442
|
+
button.addEventListener("click", async () => {
|
|
443
|
+
try { await navigator.clipboard.writeText(text); }
|
|
444
|
+
catch {
|
|
445
|
+
// Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
|
|
446
|
+
const area = document.createElement("textarea"); area.value = text; document.body.append(area);
|
|
447
|
+
area.select(); document.execCommand("copy"); area.remove();
|
|
448
|
+
}
|
|
449
|
+
button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
|
450
|
+
});
|
|
451
|
+
line.append(code, button); row.append(name, line); root.append(row);
|
|
452
|
+
}
|
|
453
|
+
})();
|
|
454
|
+
</script>
|
|
455
|
+
</body></html>
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
|
|
267
459
|
export async function serve() {
|
|
268
460
|
// The daemon owns logs/ and output/, so it migrates them before writing anything.
|
|
269
461
|
const migrated = migrateLegacyDataDir();
|
|
@@ -312,9 +504,13 @@ export async function serve() {
|
|
|
312
504
|
const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
|
|
313
505
|
const policies = new Map<string, Listener>();
|
|
314
506
|
const servers = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
315
|
-
const startServer = (initial: Listener) => Bun.serve({
|
|
507
|
+
const startServer = (initial: Listener, reusePort = false) => Bun.serve({
|
|
316
508
|
hostname: initial.host,
|
|
317
509
|
port: initial.port,
|
|
510
|
+
// reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
|
|
511
|
+
// holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
|
|
512
|
+
// instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
|
|
513
|
+
reusePort,
|
|
318
514
|
tls,
|
|
319
515
|
error(err) {
|
|
320
516
|
log(`unhandled error: ${err.message}`);
|
|
@@ -381,6 +577,10 @@ export async function serve() {
|
|
|
381
577
|
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
382
578
|
});
|
|
383
579
|
}
|
|
580
|
+
// A browser opening a shared URL gets copyable connect instructions. The key stays in the
|
|
581
|
+
// #fragment, which browsers never send, so the page is static and the key never reaches here.
|
|
582
|
+
if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
|
|
583
|
+
return new Response(landingPage(), { headers: LANDING_HEADERS });
|
|
384
584
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
385
585
|
const denied = authCheck(req, key);
|
|
386
586
|
if (denied) return denied;
|
|
@@ -687,14 +887,47 @@ export async function serve() {
|
|
|
687
887
|
},
|
|
688
888
|
});
|
|
689
889
|
|
|
890
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
|
|
891
|
+
// prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
|
|
892
|
+
// detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
|
|
893
|
+
// netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
|
|
894
|
+
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
895
|
+
// Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
|
|
896
|
+
// safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
|
|
897
|
+
// reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
|
|
898
|
+
// than a permanent EADDRINUSE crash-loop.
|
|
899
|
+
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
900
|
+
const MAX_BIND_ATTEMPTS = 4;
|
|
901
|
+
const bindAtStartup = async (listener: Listener) => {
|
|
902
|
+
for (let attempt = 1; ; attempt++) {
|
|
903
|
+
try {
|
|
904
|
+
return startServer(listener);
|
|
905
|
+
} catch (e: any) {
|
|
906
|
+
if (!isEaddrInUse(e)) throw e;
|
|
907
|
+
// Something answers at this exact address: a live server (never killed, never port-shared).
|
|
908
|
+
if (await answersAt(listener.host, listener.port)) {
|
|
909
|
+
log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
|
|
910
|
+
throw e;
|
|
911
|
+
}
|
|
912
|
+
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
913
|
+
log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
914
|
+
return startServer(listener, true);
|
|
915
|
+
}
|
|
916
|
+
log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
|
|
917
|
+
await freeStalePort(listener.port, listener.host);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
690
922
|
// Stage all new sockets before changing policy. Bind failure leaves existing
|
|
691
|
-
// listeners and browser sessions intact; never kill a process holding another port
|
|
692
|
-
|
|
923
|
+
// listeners and browser sessions intact; never kill a process holding another port
|
|
924
|
+
// once serving (stale-holder recovery runs only at startup).
|
|
925
|
+
const reconcile = async (listeners: Listener[], startup = false) => {
|
|
693
926
|
const staged = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
694
927
|
try {
|
|
695
928
|
for (const listener of listeners) {
|
|
696
929
|
const address = listenerAddress(listener);
|
|
697
|
-
if (!servers.has(address)) staged.set(address, startServer(listener));
|
|
930
|
+
if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
|
|
698
931
|
}
|
|
699
932
|
} catch (error) {
|
|
700
933
|
for (const server of staged.values()) server.stop(true);
|
|
@@ -709,17 +942,17 @@ export async function serve() {
|
|
|
709
942
|
};
|
|
710
943
|
let applied = "";
|
|
711
944
|
let configured = false;
|
|
712
|
-
const reload = async () => {
|
|
945
|
+
const reload = async (startup = false) => {
|
|
713
946
|
const config = await readListeners();
|
|
714
947
|
if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
|
|
715
948
|
const listeners = config?.listeners ?? [legacy];
|
|
716
949
|
const fingerprint = JSON.stringify(listeners);
|
|
717
950
|
if (applied === fingerprint) return;
|
|
718
|
-
await reconcile(listeners);
|
|
951
|
+
await reconcile(listeners, startup);
|
|
719
952
|
configured ||= !!config;
|
|
720
953
|
applied = fingerprint;
|
|
721
954
|
};
|
|
722
|
-
await reload();
|
|
955
|
+
await reload(true);
|
|
723
956
|
let reloading = false;
|
|
724
957
|
let lastError = "";
|
|
725
958
|
setInterval(async () => {
|