@runeditrun/rer 0.1.0 → 0.2.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 (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. package/scripts/publish-check/publication-config.json +14 -1
@@ -0,0 +1,49 @@
1
+ // The door's own small pages: the index, starting, start button, failure and
2
+ // unknown-name pages. Plain HTML; the starting page polls the door's state
3
+ // path (a fetch, which never starts a site) and reloads, keeping the fragment.
4
+ const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
5
+
6
+ const style = `:root{color-scheme:light dark;--bg:#f7f7f5;--fg:#1d1d1b;--muted:#666;--line:#ddd;--accent:#2457c5}
7
+ @media (prefers-color-scheme:dark){:root{--bg:#161615;--fg:#ecece8;--muted:#a3a39d;--line:#333;--accent:#8fb0ff}}
8
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,sans-serif}
9
+ main{max-width:760px;margin:0 auto;padding:40px 16px}h1{font-size:20px;margin:0 0 4px}p{margin:8px 0}.muted{color:var(--muted)}
10
+ table{width:100%;border-collapse:collapse;margin:20px 0}th,td{text-align:left;padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top}
11
+ a{color:var(--accent)}code{font:13px ui-monospace,monospace}button{font:inherit;padding:6px 14px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--fg);cursor:pointer}
12
+ pre{white-space:pre-wrap;background:rgba(127,127,127,.12);padding:12px;border-radius:6px;font:13px ui-monospace,monospace}`;
13
+
14
+ function page(title, body, { script = '' } = {}) {
15
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="origin"><title>${escape(title)}</title><style>${style}</style></head><body><main>${body}</main>${script ? `<script>${script}</script>` : ''}</body></html>`;
16
+ }
17
+
18
+ export const rebootNote = 'After a reboot or logout the front door needs <code>rer door start</code> (or any <code>rer dev</code> in a door-mode checkout).';
19
+
20
+ export function indexPage({ port, version, sites }) {
21
+ const rows = sites.map(site => {
22
+ const link = site.mode === 'door' || site.state !== 'running' ? site.doorUrl : site.url;
23
+ const direct = site.mode === 'direct' ? ` <span class="muted">direct${site.url && site.url !== site.doorUrl ? `: <a href="${escape(site.url)}">${escape(site.url)}</a>` : ''}</span>` : '';
24
+ return `<tr><td><a href="${escape(link)}/">${escape(site.name)}</a>${direct}</td><td>${escape(site.state)}</td><td class="muted"><code>${escape(site.path)}</code></td></tr>`;
25
+ }).join('');
26
+ return page('rer front door', `<h1>rer front door</h1><p class="muted">Port ${port} · rer ${escape(version)}. A site starts when you open it and stops when idle.</p>
27
+ ${sites.length ? `<table><thead><tr><th>Site</th><th>State</th><th>Checkout</th></tr></thead><tbody>${rows}</tbody></table>` : '<p>No checkouts are registered. Add one with <code>rer door add PATH</code>.</p>'}
28
+ <p class="muted">Add a checkout with <code>rer door add PATH</code>; see sign-in links with <code>rer door status --links</code>. ${rebootNote}</p>`);
29
+ }
30
+
31
+ const poll = `const fragment=location.hash;async function check(){try{const r=await fetch('/__rer/door/state',{cache:'no-store'});const s=await r.json();
32
+ if(s.state==='running'){if(s.redirect){location.replace(s.redirect+location.pathname+location.search+fragment);}else{location.reload();}return;}
33
+ if(s.state==='failed'){document.getElementById('status').textContent='The site could not start:';const p=document.getElementById('reason');p.textContent=s.reason;p.hidden=false;return;}}catch{}setTimeout(check,1000);}check();`;
34
+
35
+ export function startingPage(name) {
36
+ return page(`Starting ${name}`, `<h1>Starting ${escape(name)}…</h1><p id="status" class="muted">This page reloads when the site is ready. A first start can take a minute.</p><pre id="reason" hidden></pre>`, { script: poll });
37
+ }
38
+
39
+ export function confirmPage(name, to) {
40
+ return page(`Start ${name}?`, `<h1>${escape(name)} is not running</h1><p>Another site sent you here. Start it?</p><form method="post" action="/__rer/door/start?to=${encodeURIComponent(to)}"><button type="submit">Start ${escape(name)}</button></form>`);
41
+ }
42
+
43
+ export function failurePage(name, reason, to) {
44
+ return page(`${name} could not start`, `<h1>${escape(name)} could not start</h1><pre>${escape(reason)}</pre><form method="post" action="/__rer/door/start?to=${encodeURIComponent(to)}"><button type="submit">Try again</button></form>`);
45
+ }
46
+
47
+ export function unknownPage(name, port) {
48
+ return page('No such site', `<h1>No checkout is registered as ${escape(name)}</h1><p>Register a checkout with <code>rer door add PATH</code>, then open <code>http://NAME.localhost:${port}</code>. <a href="http://localhost:${port}/">All sites</a>.</p>`);
49
+ }
@@ -0,0 +1,49 @@
1
+ // The door's policy is stored, never inherited: `rer door enable` records the
2
+ // limit, the idle time and the RER_* variables in effect at that moment, and
3
+ // every site the door starts gets exactly those on top of an allowlisted base
4
+ // environment. Whoever later starts the door (a fleet agent, a test) cannot
5
+ // change them by running it with another environment.
6
+ import { resolve } from 'node:path';
7
+ import { doorDefaults, doorDirectory } from '../../cli/door.mjs';
8
+ import { serversDirectory } from '../registry.mjs';
9
+
10
+ const base = new Set(['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TMPDIR', 'TERM']);
11
+ // Set by the policy itself, so never recorded from a shell.
12
+ const policyVariables = new Set(['RER_SERVER_LIMIT', 'RER_SERVER_IDLE_SECONDS']);
13
+
14
+ export function baseEnvironment(env = process.env) {
15
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => value !== undefined && (base.has(name) || name.startsWith('LC_'))));
16
+ }
17
+
18
+ export function recordedEnvironment(env = process.env) {
19
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => value !== undefined && name.startsWith('RER_') && !policyVariables.has(name)).sort(([a], [b]) => a.localeCompare(b)));
20
+ }
21
+
22
+ function count(value, name, minimum = 1) {
23
+ if (!/^[0-9]+$/.test(value ?? '') || !Number.isSafeInteger(Number(value)) || Number(value) < minimum) throw Object.assign(Error(`${name} takes a whole number of at least ${minimum}.`), { exitCode: 2 });
24
+ return Number(value);
25
+ }
26
+
27
+ // What `rer door enable` records: flags, else the person defaults.
28
+ export function enabledPolicy({ port, serverLimit, idleTimeout }, env = process.env) {
29
+ const chosen = port === undefined ? undefined : count(port, '--port');
30
+ if (chosen !== undefined && chosen > 65535) throw Object.assign(Error('--port takes a port from 1 to 65535.'), { exitCode: 2 });
31
+ return {
32
+ port: chosen,
33
+ policy: { serverLimit: serverLimit === undefined ? doorDefaults.serverLimit : count(serverLimit, '--server-limit'), idleSeconds: idleTimeout === undefined ? doorDefaults.idleSeconds : count(idleTimeout, '--idle-timeout') },
34
+ directory: doorDirectory(env),
35
+ serversDir: resolve(serversDirectory(env)),
36
+ environment: recordedEnvironment(env),
37
+ };
38
+ }
39
+
40
+ // The environment of a site the door starts.
41
+ export function siteEnvironment(door, env = process.env) {
42
+ return { ...baseEnvironment(env), ...door.environment, RER_DOOR_DIR: door.directory, RER_SERVERS_DIR: door.serversDir, RER_SERVER_LIMIT: String(door.policy.serverLimit), RER_SERVER_IDLE_SECONDS: String(door.policy.idleSeconds) };
43
+ }
44
+
45
+ // The environment of the door process itself.
46
+ export const doorProcessEnvironment = (door, env = process.env) => ({ ...baseEnvironment(env), RER_DOOR_DIR: door.directory });
47
+
48
+ // `rer dev` may bring an enabled door back only for the registry it records.
49
+ export const ownsRegistry = (door, env = process.env) => doorDirectory(env) === door.directory && resolve(serversDirectory(env)) === door.serversDir;
@@ -0,0 +1,45 @@
1
+ // How the door reads a request: which Host it names, and whether it may start
2
+ // a stopped site. Measured facts behind the start rule (Chromium 140-154,
3
+ // WebKit 26, Safari 27): only a top-level document navigation carries
4
+ // Sec-Fetch-Mode navigate with Sec-Fetch-Dest document; iframes say iframe,
5
+ // subresources and fetch say no-cors/cors, Chromium sends no Sec-Fetch-* on a
6
+ // WebSocket upgrade, and an open tab's Vite ping is a fetch.
7
+ import { isLocalhostName } from '../../modules/operator-auth/loopback.mjs';
8
+
9
+ export const indexHostnames = new Set(['localhost', '127.0.0.1', '[::1]']);
10
+
11
+ // { kind: 'index' | 'site' | 'redirect' | 'foreign', name?, host?, canonical? }
12
+ // Matching is case-insensitive: any letter case of a door Host is answered as
13
+ // the lowercase Host is, and `host` is that canonical lowercase spelling, which
14
+ // the door sends upstream (a site's dev server allows only lowercase
15
+ // *.localhost). A trailing-dot Host redirects to the canonical Host.
16
+ export function readHost(header, port) {
17
+ const match = /^(\[[0-9a-f:.]+\]|[^:[\]]+)(?::(\d{1,5}))?$/.exec(String(header ?? '').toLowerCase());
18
+ if (!match || Number(match[2] ?? 80) !== port) return { kind: 'foreign' };
19
+ const hostname = match[1].replace(/\.+$/, ''), host = `${hostname}:${port}`;
20
+ const index = indexHostnames.has(hostname);
21
+ if (!index && !isLocalhostName(hostname)) return { kind: 'foreign' };
22
+ if (hostname !== match[1]) return { kind: 'redirect', canonical: host };
23
+ if (index) return { kind: 'index', host };
24
+ // Subdomains route to their site: x.cradle.localhost is cradle.
25
+ return { kind: 'site', name: hostname.slice(0, -'.localhost'.length).split('.').pop(), host };
26
+ }
27
+
28
+ const refererOrigin = value => { try { return new URL(value).origin; } catch { return undefined; } };
29
+
30
+ // 'start': a top-level navigation the person made (typed, bookmarked, same
31
+ // site, from the door's own index, or a client without fetch metadata asking
32
+ // for HTML). 'confirm': another site's navigation, which gets a Start button.
33
+ // 'never': everything else (subresources, fetch/XHR, prefetch, OPTIONS, other
34
+ // methods and every WebSocket upgrade).
35
+ export function startKind(request, indexOrigins) {
36
+ const headers = request.headers;
37
+ if (headers.upgrade || !['GET', 'HEAD'].includes(request.method)) return 'never';
38
+ if (headers['sec-purpose'] || headers.purpose || headers['x-purpose'] || headers['x-moz'] === 'prefetch') return 'never';
39
+ const mode = headers['sec-fetch-mode'], dest = headers['sec-fetch-dest'], site = headers['sec-fetch-site'];
40
+ if (mode === undefined && dest === undefined && site === undefined) return /\btext\/html\b/i.test(headers.accept ?? '') ? 'start' : 'never';
41
+ if (mode !== 'navigate' || dest !== 'document') return 'never';
42
+ if (['none', 'same-origin', 'same-site'].includes(site)) return 'start';
43
+ if (indexOrigins.has(refererOrigin(headers.referer))) return 'start';
44
+ return 'confirm';
45
+ }
@@ -0,0 +1,255 @@
1
+ // The front door process: node server.mjs, with RER_DOOR_DIR set. One process
2
+ // listens on 127.0.0.1:PORT (required) and [::1]:PORT (when IPv6 loopback
3
+ // exists), never a wildcard address, and routes by Host:
4
+ // localhost, 127.0.0.1, [::1] the index of sites (and the identity proof)
5
+ // NAME.localhost and subdomains that checkout, Host preserved (lowercased;
6
+ // matching ignores letter case)
7
+ // a trailing-dot Host 308 to the canonical Host
8
+ // an unknown *.localhost 404 saying how to add a checkout
9
+ // anything else 400, never proxied (the DNS-rebinding guard)
10
+ // It never reads cookies (beyond making Domain=localhost Set-Cookie host-only,
11
+ // on HTTP and WebSocket handshake replies alike) and never holds a dev key.
12
+ import http from 'node:http';
13
+ import net from 'node:net';
14
+ import { readFile, rm } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { setTimeout as delay } from 'node:timers/promises';
17
+ import { processInfo } from '../../modules/job-queue/processes.mjs';
18
+ import { changeDoorState, doorChallengeHeader, doorIdentityPath, doorProof, doorProtocol, doorSecretHeader, readDoorState, readPrivate, writePrivate } from '../../cli/door.mjs';
19
+ import { listeners, processName } from '../../onboarding/process-diagnostics.mjs';
20
+ import { ownManifest, ownRoot } from '../package-info.mjs';
21
+ import { confirmPage, failurePage, indexPage, startingPage, unknownPage } from './pages.mjs';
22
+ import { hostOnlyCookie, hostOnlyHead, splitHead } from './cookies.mjs';
23
+ import { readHost, startKind } from './routes.mjs';
24
+ import { doorSites } from './sites.mjs';
25
+
26
+ const say = line => process.stdout.write(`${new Date().toISOString()} ${line}\n`);
27
+ function fail(message, code = 4) { process.stderr.write(`rer door: ${message}\n`); process.exit(code); }
28
+
29
+ const state = await readDoorState(process.env);
30
+ if (!state?.door?.enabled) fail('The front door is not enabled. Run rer door enable.', 3);
31
+ const { door } = state, port = door.port, version = ownManifest().version;
32
+ const token = (await readPrivate(join(state.directory, 'token')))?.trim();
33
+ if (!token) fail(`The front door's token is missing from ${state.directory}. Run rer door enable.`, 3);
34
+ const sites = doorSites({ env: process.env, door, log: say });
35
+ const indexOrigins = new Set(['localhost', '127.0.0.1', '[::1]'].map(host => `http://${host}:${port}`));
36
+ const portOf = name => Number(/:(\d+)$/.exec(name)?.[1]);
37
+
38
+ async function holder() {
39
+ const row = (await listeners()).find(item => item.pid !== process.pid && item.names.some(name => portOf(name) === port));
40
+ return row && `PID ${row.pid} (${await processName(row.pid)})`;
41
+ }
42
+ const busy = who => `Port ${port} is held by ${who}. The front door needs 127.0.0.1:${port} and [::1]:${port} to itself. Stop that process, or enable the door on another port with rer door enable --port N.`;
43
+
44
+ // --- responses -------------------------------------------------------------
45
+ const send = (res, status, type, body, headers = {}) => { res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers }); res.end(body); };
46
+ const html = (res, status, body, headers) => send(res, status, 'text/html; charset=utf-8', body, headers);
47
+ const text = (res, status, body, headers) => send(res, status, 'text/plain; charset=utf-8', body + '\n', headers);
48
+ const json = (res, status, value) => send(res, status, 'application/json', JSON.stringify(value));
49
+
50
+ const publicView = view => ({ name: view.name, path: view.path, mode: view.mode, state: view.state, url: view.url, ...(view.proxyPort ? { proxyPort: view.proxyPort } : {}), ...(view.reason ? { reason: view.reason } : {}) });
51
+
52
+ async function index(req, res) {
53
+ const path = new URL(req.url, 'http://door').pathname;
54
+ if (path === doorIdentityPath) {
55
+ const challenge = String(req.headers[doorChallengeHeader] ?? '');
56
+ if (!/^[0-9a-f]{16,128}$/.test(challenge)) return json(res, 400, { error: 'challenge required' });
57
+ const address = req.socket.localAddress?.replace(/^::ffff:/, '');
58
+ return json(res, 200, { protocol: doorProtocol, pid: process.pid, proof: doorProof(token, challenge, address) });
59
+ }
60
+ if (path === '/__rer/door/status') return json(res, 200, { protocol: doorProtocol, pid: process.pid, port, version, sites: (await sites.views()).map(publicView) });
61
+ if (path !== '/' || !['GET', 'HEAD'].includes(req.method)) return text(res, 404, 'Not found.');
62
+ return html(res, 200, indexPage({ port, version, sites: await sites.views() }), { 'referrer-policy': 'origin' });
63
+ }
64
+
65
+ // `stale()` answers instead when the server has just stopped (the registry
66
+ // view is cached for a second): a refused connection before any byte was sent.
67
+ function proxy(req, res, view, host, stale) {
68
+ const upstream = http.request({ host: '127.0.0.1', port: view.proxyPort, method: req.method, path: req.url, headers: { ...req.headers, host, [doorSecretHeader]: view.secret }, agent: false }, reply => {
69
+ const raw = [];
70
+ for (let i = 0; i < reply.rawHeaders.length; i += 2) raw.push(reply.rawHeaders[i], reply.rawHeaders[i].toLowerCase() === 'set-cookie' ? hostOnlyCookie(reply.rawHeaders[i + 1]) : reply.rawHeaders[i + 1]);
71
+ res.writeHead(reply.statusCode, reply.statusMessage, raw);
72
+ reply.pipe(res);
73
+ });
74
+ req.once('aborted', () => upstream.destroy());
75
+ res.once('close', () => upstream.destroy());
76
+ upstream.on('error', error => {
77
+ sites.invalidate();
78
+ if (res.headersSent) return res.destroy();
79
+ if (error.code === 'ECONNREFUSED' && stale && ['GET', 'HEAD'].includes(req.method)) return stale();
80
+ text(res, 502, `${view.name} stopped answering. Reload to start it again.`);
81
+ });
82
+ if (req.readableEnded) upstream.end(); else req.pipe(upstream);
83
+ }
84
+
85
+ async function site(req, res, target, retried = false) {
86
+ const url = new URL(req.url, 'http://door'), registered = (await sites.list()).find(item => item.name === target.name);
87
+ if (!registered) { req.resume(); return html(res, 404, unknownPage(target.name, port)); }
88
+ // The door's own paths on a site host: its state (polled by the starting
89
+ // page) and the same-origin Start button.
90
+ if (url.pathname === '/__rer/door/state') {
91
+ const view = await sites.view(registered, { force: true });
92
+ return json(res, 200, { state: view.state, ...(view.reason ? { reason: view.reason } : {}), ...(view.state === 'running' && view.mode === 'direct' ? { redirect: view.url } : {}) });
93
+ }
94
+ if (url.pathname === '/__rer/door/start') {
95
+ req.resume();
96
+ const sameOrigin = req.headers.origin === `http://${target.host}` && ['same-origin', undefined].includes(req.headers['sec-fetch-site']);
97
+ if (req.method !== 'POST' || !sameOrigin) return text(res, 403, 'Start a site from its own page.');
98
+ sites.ensureStarted(registered);
99
+ const to = url.searchParams.get('to');
100
+ return send(res, 303, 'text/plain; charset=utf-8', '', { location: to?.startsWith('/') && !to.startsWith('//') ? to : '/' });
101
+ }
102
+ const view = await sites.view(registered, { force: retried });
103
+ const kind = startKind(req, indexOrigins), here = url.pathname + url.search;
104
+ if (view.state === 'running') {
105
+ if (view.mode === 'door') return proxy(req, res, view, target.host, retried ? undefined : () => site(req, res, target, true).catch(() => { if (!res.headersSent) text(res, 502, 'The front door could not answer this request.'); }));
106
+ // A direct-mode checkout (an old pin): send navigations to its own URL.
107
+ req.resume();
108
+ return kind === 'never' ? text(res, 404, `${view.name} is served directly at ${view.url}.`) : send(res, 302, 'text/plain; charset=utf-8', '', { location: view.url + here });
109
+ }
110
+ req.resume();
111
+ if (kind === 'never') return text(res, 503, `${view.name} is not running. Open http://${target.host}/ in a browser to start it.`);
112
+ if (view.state === 'failed') return html(res, 200, failurePage(view.name, view.reason, here));
113
+ if (kind === 'confirm' && view.state !== 'starting') return html(res, 200, confirmPage(view.name, here));
114
+ sites.ensureStarted(registered);
115
+ return html(res, 503, startingPage(view.name), { 'retry-after': '2' });
116
+ }
117
+
118
+ const active = new Set(), upgraded = new Set();
119
+ async function handle(req, res) {
120
+ active.add(res); res.once('close', () => active.delete(res));
121
+ try {
122
+ const target = readHost(req.headers.host, port);
123
+ if (target.kind === 'redirect') { req.resume(); return send(res, 308, 'text/plain; charset=utf-8', '', { location: `http://${target.canonical}${req.url}` }); }
124
+ if (target.kind === 'index') return await index(req, res);
125
+ if (target.kind === 'site') return await site(req, res, target);
126
+ req.resume();
127
+ return text(res, 400, `This front door serves only localhost names on port ${port}.`);
128
+ } catch (error) {
129
+ say(`request failed: ${error.message}`);
130
+ if (!res.headersSent) text(res, 500, 'The front door could not answer this request.'); else res.destroy();
131
+ }
132
+ }
133
+
134
+ // WebSockets (Vite HMR among them) reach running door-mode sites only; an
135
+ // upgrade never starts a site, and one whose Origin is another site's is refused.
136
+ // An upgrade cannot follow a redirect, so a trailing-dot Host is read as its
137
+ // canonical Host; upstream always sees the canonical (lowercase) Host.
138
+ async function upgrade(req, socket, head) {
139
+ const refuse = status => socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
140
+ socket.on('error', () => socket.destroy());
141
+ const read = readHost(req.headers.host, port);
142
+ const target = read.kind === 'redirect' ? readHost(read.canonical, port) : read;
143
+ if (target.kind !== 'site') return refuse('400 Bad Request');
144
+ const registered = (await sites.list()).find(item => item.name === target.name);
145
+ if (!registered) return refuse('404 Not Found');
146
+ const origin = req.headers.origin;
147
+ if (origin !== undefined && origin !== `http://${target.host}`) return refuse('403 Forbidden');
148
+ const view = await sites.view(registered);
149
+ if (view.state !== 'running' || view.mode !== 'door') return refuse('503 Service Unavailable');
150
+ const upstream = net.connect(view.proxyPort, '127.0.0.1', () => {
151
+ let lines = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
152
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
153
+ const name = req.rawHeaders[i].toLowerCase();
154
+ if (name !== doorSecretHeader) lines += `${req.rawHeaders[i]}: ${name === 'host' ? target.host : req.rawHeaders[i + 1]}\r\n`;
155
+ }
156
+ upstream.write(`${lines}${doorSecretHeader}: ${view.secret}\r\n\r\n`);
157
+ if (head.length) upstream.write(head);
158
+ socket.pipe(upstream);
159
+ // The reply's head (101 or any other status) goes through the cookie rule;
160
+ // the bytes after it pass unchanged.
161
+ let reply = Buffer.alloc(0);
162
+ const readHead = chunk => {
163
+ reply = Buffer.concat([reply, chunk]);
164
+ const split = splitHead(reply);
165
+ if (!split) { if (reply.length > 65536) close(); return; }
166
+ upstream.off('data', readHead);
167
+ socket.write(hostOnlyHead(split.head));
168
+ if (split.rest.length) socket.write(split.rest);
169
+ upstream.pipe(socket);
170
+ };
171
+ upstream.on('data', readHead);
172
+ });
173
+ upgraded.add(socket);
174
+ const close = () => { upgraded.delete(socket); socket.destroy(); upstream.destroy(); };
175
+ upstream.on('error', close); upstream.on('close', close); socket.on('close', close);
176
+ }
177
+
178
+ // --- listening ---------------------------------------------------------------
179
+ const servers = [];
180
+ function listen(host) {
181
+ const server = http.createServer(handle);
182
+ server.on('upgrade', upgrade);
183
+ server.on('clientError', (error, socket) => socket.destroy());
184
+ return new Promise((resolve, reject) => {
185
+ server.once('error', reject);
186
+ server.listen({ host, port, exclusive: true, ...(host === '::1' ? { ipv6Only: true } : {}) }, () => { servers.push(server); resolve(server); });
187
+ });
188
+ }
189
+
190
+ const before = await holder();
191
+ if (before) fail(busy(before));
192
+ try { await listen('127.0.0.1'); }
193
+ catch (error) { fail(error.code === 'EADDRINUSE' ? busy(await holder() ?? 'another process') : `Cannot listen on 127.0.0.1:${port}: ${error.code ?? error.message}.`); }
194
+ const addresses = ['127.0.0.1'];
195
+ try { await listen('::1'); addresses.push('::1'); }
196
+ catch (error) {
197
+ // IPv6 loopback may not exist (a container, ipv6.disable=1); 127.0.0.1 alone serves then.
198
+ if (!['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(error.code)) fail(error.code === 'EADDRINUSE' ? busy(await holder() ?? 'another process on [::1]') : `Cannot listen on [::1]:${port}: ${error.code ?? error.message}.`);
199
+ }
200
+ // Address families do not exclude each other: a wildcard listener elsewhere
201
+ // would still answer browsers that try [::1] first.
202
+ const after = await holder();
203
+ if (after) fail(busy(after));
204
+
205
+ const { start } = await processInfo(process.pid);
206
+ const record = { protocol: doorProtocol, pid: process.pid, start, port, addresses, version, versionDir: ownRoot, startedAt: new Date().toISOString() };
207
+ await writePrivate(join(state.directory, 'process.json'), JSON.stringify(record) + '\n');
208
+ say(`front door listening on ${addresses.map(address => address === '::1' ? `[::1]:${port}` : `${address}:${port}`).join(' and ')} (PID ${process.pid}, rer ${version})`);
209
+
210
+ // On SIGTERM: stop accepting and close WebSockets (open tabs reconnect through
211
+ // HMR). During a restart (`restarting` in the state) the starts under way are
212
+ // handed to the next door, and the process record is removed at once so that
213
+ // door can take the port while this one finishes its requests (up to 10 s).
214
+ // On a plain stop, starts under way get up to 25 s (inside rer door stop's 30)
215
+ // to record their servers as door-started.
216
+ let stopping = false;
217
+ async function release() {
218
+ try { const current = JSON.parse(await readFile(join(state.directory, 'process.json'), 'utf8')); if (current.pid === process.pid) await rm(join(state.directory, 'process.json'), { force: true }); } catch {}
219
+ }
220
+ async function handOver() {
221
+ try {
222
+ return await changeDoorState(process.env, current => {
223
+ if (!current.door?.restarting) return false;
224
+ const names = sites.pending();
225
+ if (names.length) current.door.handoff = [...new Set([...(current.door.handoff ?? []), ...names])];
226
+ return true;
227
+ });
228
+ } catch (error) { say(`could not hand over the starts under way: ${error.message}`); return false; }
229
+ }
230
+ async function stop(signal) {
231
+ if (stopping) return; stopping = true;
232
+ say(`stopping (${signal})`);
233
+ for (const server of servers) server.close();
234
+ for (const socket of upgraded) socket.destroy();
235
+ const restarting = await handOver();
236
+ if (restarting) { say('handing over to the next door'); await release(); }
237
+ for (const started = Date.now(); (active.size && Date.now() - started < 10000) || (!restarting && sites.busy() && Date.now() - started < 25000);) await delay(100);
238
+ for (const server of servers) server.closeAllConnections();
239
+ await release();
240
+ process.exit(0);
241
+ }
242
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => void stop(signal));
243
+ process.on('SIGHUP', () => {});
244
+
245
+ // Starts the door this one replaced handed over (rer door restart or enable):
246
+ // each continues here, attaching to the launch already under way, so its tab
247
+ // on the starting page reloads into the site.
248
+ try {
249
+ const handed = await changeDoorState(process.env, current => { const names = current.door?.handoff ?? []; if (current.door) delete current.door.handoff; return names; });
250
+ const registered = await sites.list(true);
251
+ for (const name of handed) {
252
+ const site = registered.find(item => item.name === name);
253
+ if (site) { say(`continuing the start of ${name} from the previous door`); sites.ensureStarted(site); }
254
+ }
255
+ } catch (error) { say(`could not read the starts handed over: ${error.message}`); }
@@ -0,0 +1,192 @@
1
+ // The door's registered sites and the servers behind them. Routes come from
2
+ // the shared server registry the door recorded (a door-mode server lists its
3
+ // proxy port and per-start secret there), so a door restart loses nothing.
4
+ // The door starts a stopped site by running that checkout's own `rer dev`
5
+ // through this package's launcher, with the site environment from its stored
6
+ // policy, and reads only its exit status (and, on failure, the error events of
7
+ // its private log); it never reads or holds a dev key.
8
+ import { spawn } from 'node:child_process';
9
+ import { open, readFile, stat } from 'node:fs/promises';
10
+ import { createConnection } from 'node:net';
11
+ import { join } from 'node:path';
12
+ import { changeDoorState, doorOrigin, doorProtocol, doorSite, readDoorState } from '../../cli/door.mjs';
13
+ import { ownRoot } from '../package-info.mjs';
14
+ import { liveServers } from '../registry.mjs';
15
+ import { siteEnvironment } from './policy.mjs';
16
+
17
+ const rerEntry = join(ownRoot, 'global/rer.mjs');
18
+ const startTimeoutMs = 15 * 60_000, failureShownMs = 30_000;
19
+
20
+ // A registered server's own status: whether it has finished starting.
21
+ function serverReady(record) {
22
+ return new Promise(resolve => {
23
+ const socket = createConnection(record.control);
24
+ let text = '';
25
+ socket.setTimeout(2000, () => socket.destroy());
26
+ socket.on('connect', () => socket.end(JSON.stringify({ token: record.token, action: 'status' })));
27
+ socket.on('data', chunk => { text += chunk; if (text.length > 16384) socket.destroy(); });
28
+ socket.on('end', () => { try { const value = JSON.parse(text); resolve(value.token === record.token && value.ready === true); } catch { resolve(false); } });
29
+ socket.on('error', () => resolve(false));
30
+ socket.on('close', () => resolve(false));
31
+ });
32
+ }
33
+
34
+ // { code, error }: listened for as soon as the child is spawned, because a
35
+ // spawn that fails (its folder gone) reports it on the next tick.
36
+ function exitOf(child) { return new Promise(resolve => { child.once('error', error => resolve({ code: 1, error })); child.once('exit', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1) })); }); }
37
+
38
+ // The `message` of the launcher's JSON error events, nothing else.
39
+ async function failureReason(path, from) {
40
+ const lines = (await readFile(path, 'utf8')).slice(from).split('\n');
41
+ const reasons = [];
42
+ for (const line of lines) {
43
+ if (!line.startsWith('{')) continue;
44
+ try { const event = JSON.parse(line); if (event.event === 'error' && typeof event.message === 'string') reasons.push(event.message); } catch {}
45
+ }
46
+ return reasons.at(-1) ?? 'The launch stopped without a reason. Run rer dev in the checkout to see it.';
47
+ }
48
+
49
+ export function doorSites({ env, door, log = () => {} }) {
50
+ const doorEnv = { ...env, RER_DOOR_DIR: door.directory }, serverEnv = { ...env, RER_SERVERS_DIR: door.serversDir };
51
+ const starts = new Map(), failures = new Map();
52
+ let records = { at: 0, list: [] }, registered = { at: 0, list: [] }, started;
53
+
54
+ async function list(force = false) {
55
+ if (force || Date.now() - registered.at > 1000) registered = { at: Date.now(), list: (await readDoorState(doorEnv))?.sites ?? [] };
56
+ return registered.list;
57
+ }
58
+ async function running(force = false) {
59
+ if (force || Date.now() - records.at > 1000) {
60
+ const live = await liveServers(serverEnv);
61
+ records = { at: Date.now(), list: await Promise.all(live.map(async record => ({ ...record, ready: await serverReady(record) }))) };
62
+ }
63
+ return records.list;
64
+ }
65
+ // Generations of the servers this door started (kept in the door state, so
66
+ // they survive a door restart); only those may be stopped for capacity. A
67
+ // draining door waits for its starts under way to record theirs (busy()).
68
+ async function doorStarted() {
69
+ started ??= new Set((await readDoorState(doorEnv))?.door?.started ?? []);
70
+ return started;
71
+ }
72
+ // Liveness is read inside the state change, so a concurrent start's
73
+ // generation, recorded a moment earlier, is never dropped as dead.
74
+ async function remember(generation) {
75
+ await changeDoorState(doorEnv, async state => {
76
+ const live = new Set((await liveServers(serverEnv)).map(record => record.generation));
77
+ state.door.started = [...new Set([...(state.door.started ?? []), generation])].filter(item => live.has(item));
78
+ started = new Set(state.door.started);
79
+ });
80
+ }
81
+
82
+ async function view(site, { force = false } = {}) {
83
+ const record = (await running(force)).find(item => item.checkout === site.checkout);
84
+ const doorUrl = doorOrigin(site.name, door.port);
85
+ const result = { name: site.name, path: site.checkout, mode: record ? (record.door || doorSite(record.url) ? 'door' : 'direct') : site.mode === 'door' ? 'door' : 'direct', doorUrl };
86
+ if (record?.door && record.door.v !== doorProtocol) return { ...result, state: 'failed', url: doorUrl, reason: `This checkout's server speaks door protocol ${record.door.v}; restart the door with the installed rer (rer door restart).` };
87
+ // A door-mode server is routable once it has recorded its proxy and is ready.
88
+ if (record?.ready && (record.door ? record.port : record.url && !doorSite(record.url))) {
89
+ return { ...result, state: 'running', url: record.url, ...(record.door ? { proxyPort: record.port, secret: record.door.secret } : {}), record };
90
+ }
91
+ if (starts.has(site.name) || record) return { ...result, state: 'starting', url: result.mode === 'door' ? doorUrl : site.origin ?? doorUrl };
92
+ const failure = failures.get(site.name);
93
+ if (failure && Date.now() - failure.at < failureShownMs) return { ...result, state: 'failed', reason: failure.reason, url: doorUrl };
94
+ return { ...result, state: 'stopped', url: result.mode === 'door' ? doorUrl : site.origin ?? doorUrl };
95
+ }
96
+
97
+ async function runLauncher(args, site, logPath) {
98
+ const handle = await open(logPath, 'a', 0o600), from = (await handle.stat()).size;
99
+ let child, exited;
100
+ try { child = spawn(process.execPath, [rerEntry, ...args], { cwd: site.checkout, env: siteEnvironment(door, env), detached: true, stdio: ['ignore', handle.fd, handle.fd] }); exited = exitOf(child); }
101
+ finally { await handle.close(); }
102
+ const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} }, startTimeoutMs);
103
+ const { code, error } = await exited;
104
+ clearTimeout(timer);
105
+ return { code, from, error };
106
+ }
107
+ const launchFailure = async (site, logPath, { from, error }) => error ? `rer could not run in ${site.checkout}: ${error.code ?? error.message}.` : failureReason(logPath, from);
108
+
109
+ // A checkout whose folder was moved, renamed or deleted cannot start: say so
110
+ // before admission, so nothing is stopped to make room for it.
111
+ async function present(site) {
112
+ const folder = await stat(site.checkout).catch(() => undefined);
113
+ if (!folder?.isDirectory()) throw Error(`The checkout folder ${site.checkout} no longer exists, so ${site.name} was not started. If it moved, run rer dev in its new folder (or rer door add NEW_PATH): it keeps the name ${site.name}, its address and its sign-in. Otherwise unregister it with rer door remove ${site.name}.`);
114
+ }
115
+
116
+ // Admission is serial. Each start in turn counts the servers running, the
117
+ // starts already admitted and the servers already being stopped for one, then
118
+ // takes a free slot, or stops this door's own least recently active server
119
+ // that no other start is stopping (never one someone started with rer dev),
120
+ // or is refused naming what holds the cap. So concurrent visits (a browser
121
+ // restoring many tabs) each get their own slot or a clear refusal, and never
122
+ // stop the same server twice and then fail at the limit.
123
+ const admitted = new Set(), evicting = new Set();
124
+ let admission = Promise.resolve();
125
+ function admit(site) {
126
+ const turn = admission.then(() => reserve(site));
127
+ admission = turn.catch(() => {});
128
+ return turn;
129
+ }
130
+ async function reserve(site) {
131
+ const live = (await running(true)).filter(record => !record.journey && record.checkout !== site.checkout);
132
+ const cap = Math.min(door.policy.serverLimit, ...live.filter(record => record.explicit).map(record => record.limit));
133
+ const staying = live.filter(record => !evicting.has(record.generation)), liveCheckouts = new Set(live.map(record => record.checkout));
134
+ const starting = [...admitted].filter(checkout => checkout !== site.checkout && !liveCheckouts.has(checkout));
135
+ if (staying.length + starting.length < cap) { admitted.add(site.checkout); return undefined; }
136
+ const mine = await doorStarted();
137
+ const victim = staying.filter(record => mine.has(record.generation) && !admitted.has(record.checkout)).sort((a, b) => (a.lastActivity ?? 0) - (b.lastActivity ?? 0))[0];
138
+ if (!victim) throw Error([
139
+ `The server limit is ${cap}, and every place is held by a server someone started with rer dev or by a start already under way, so none was stopped for ${site.name}:`,
140
+ ...staying.map(record => ` ${record.mode} for ${record.checkout} (PID ${record.owner?.pid})${admitted.has(record.checkout) ? ', starting for another visit' : ', started with rer dev'}`),
141
+ ...starting.map(checkout => ` starting ${checkout} for another visit`),
142
+ 'Stop one with rer dev --stop in its checkout, or raise the limit with rer door enable --server-limit N.',
143
+ ].join('\n'));
144
+ evicting.add(victim.generation); admitted.add(site.checkout);
145
+ return victim;
146
+ }
147
+ async function evict(victim, site) {
148
+ try {
149
+ const owner = (await list()).find(item => item.checkout === victim.checkout) ?? { checkout: victim.checkout, name: 'evicted' };
150
+ log(`stopping ${victim.checkout} (least recently active door-started server) to make room for ${site.name}`);
151
+ const logPath = join(door.directory, 'logs', `${owner.name}.log`), run = await runLauncher([victim.mode, '--stop', '--cwd', victim.checkout], owner, logPath);
152
+ if (run.code !== 0) throw Error(`The server limit is reached, and stopping ${victim.checkout} to make room failed: ${await launchFailure(owner, logPath, run)}`);
153
+ await running(true);
154
+ } finally { evicting.delete(victim.generation); }
155
+ }
156
+
157
+ async function start(site) {
158
+ const logPath = join(door.directory, 'logs', `${site.name}.log`);
159
+ let reserved = false;
160
+ try {
161
+ await present(site);
162
+ const victim = await admit(site);
163
+ reserved = true;
164
+ if (victim) await evict(victim, site);
165
+ log(`starting ${site.name} (${site.checkout})`);
166
+ const run = await runLauncher(['dev', '--cwd', site.checkout, '--detach', '--json', '--door-start'], site, logPath);
167
+ if (run.code !== 0) throw Error(await launchFailure(site, logPath, run));
168
+ const record = (await running(true)).find(item => item.checkout === site.checkout);
169
+ if (!record) throw Error('The site reported ready but is not in the server registry.');
170
+ await remember(record.generation);
171
+ failures.delete(site.name);
172
+ log(`${site.name} is running`);
173
+ } catch (error) {
174
+ failures.set(site.name, { at: Date.now(), reason: error.message });
175
+ log(`${site.name} did not start: ${error.message.split('\n')[0]}`);
176
+ } finally { if (reserved) admitted.delete(site.checkout); starts.delete(site.name); records.at = 0; }
177
+ }
178
+
179
+ return {
180
+ list, view, running,
181
+ async views() { return Promise.all((await list()).map(site => view(site))); },
182
+ // Begin a start unless one is already under way.
183
+ ensureStarted(site) {
184
+ if (!starts.has(site.name)) { failures.delete(site.name); starts.set(site.name, start(site)); }
185
+ return starts.get(site.name);
186
+ },
187
+ invalidate() { records.at = 0; },
188
+ busy() { return starts.size > 0; },
189
+ // The names of the sites starting now (handed to the next door on a restart).
190
+ pending() { return [...starts.keys()]; },
191
+ };
192
+ }