@runeditrun/rer 0.1.1 → 0.2.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.
Files changed (154) hide show
  1. package/README.md +113 -2
  2. package/cli/README.md +32 -5
  3. package/cli/contract.mjs +5 -5
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/dev-identity.mjs +2 -1
  7. package/cli/door-mode.mjs +134 -0
  8. package/cli/door.mjs +263 -0
  9. package/cli/hosted-output.mjs +17 -7
  10. package/cli/hydration/browser.mjs +91 -0
  11. package/cli/hydration/cases.mjs +106 -0
  12. package/cli/hydration/check.mjs +105 -0
  13. package/cli/hydration/probe.mjs +164 -0
  14. package/cli/hydration/scripts.mjs +39 -0
  15. package/cli/hydration/verdict.mjs +85 -0
  16. package/cli/inspect.mjs +57 -10
  17. package/cli/lifecycle.mjs +36 -12
  18. package/cli/local.mjs +9 -3
  19. package/cli/main.mjs +3 -0
  20. package/cli/managed-server.mjs +62 -21
  21. package/cli/package-calls.mjs +81 -0
  22. package/cli/package-mode.mjs +46 -0
  23. package/cli/parse.mjs +15 -4
  24. package/cli/pnpm-runtime.mjs +96 -0
  25. package/cli/portability-worker.mjs +2 -2
  26. package/cli/release.mjs +1 -0
  27. package/cli/runtime-front-door.mjs +4 -2
  28. package/cli/server-proxy.mjs +69 -5
  29. package/cli/server-registry.mjs +10 -7
  30. package/cli/settings.mjs +14 -0
  31. package/cli/site-bindings.mjs +291 -0
  32. package/cli/site-browser-objects.mjs +36 -0
  33. package/cli/site-check.mjs +111 -0
  34. package/cli/site-client.mjs +50 -0
  35. package/cli/site-data.mjs +54 -0
  36. package/cli/site-entities.json +1 -0
  37. package/cli/site-input-assets.mjs +58 -0
  38. package/cli/site-inputs.mjs +79 -0
  39. package/cli/site-markup.mjs +102 -0
  40. package/cli/site-preview.mjs +53 -0
  41. package/cli/site-private.mjs +36 -0
  42. package/cli/site-references.mjs +93 -0
  43. package/cli/site-render.mjs +79 -0
  44. package/cli/site-syntax.mjs +21 -0
  45. package/cli/site.mjs +130 -0
  46. package/global/CHANGELOG.md +88 -0
  47. package/global/README.md +113 -2
  48. package/global/checkout.mjs +24 -10
  49. package/global/cli/install.mjs +6 -2
  50. package/global/delegate.mjs +9 -7
  51. package/global/door/command.mjs +254 -0
  52. package/global/door/control.mjs +65 -0
  53. package/global/door/cookies.mjs +41 -0
  54. package/global/door/pages.mjs +49 -0
  55. package/global/door/policy.mjs +49 -0
  56. package/global/door/routes.mjs +45 -0
  57. package/global/door/server.mjs +255 -0
  58. package/global/door/sites.mjs +192 -0
  59. package/global/launch/dev.mjs +21 -7
  60. package/global/launch/door.mjs +36 -0
  61. package/global/launch/install-pinned.mjs +22 -0
  62. package/global/launch/prepare.mjs +38 -5
  63. package/global/launch/render.mjs +6 -1
  64. package/global/launch/steps.mjs +12 -7
  65. package/global/launch/worker.mjs +3 -3
  66. package/global/list.mjs +6 -3
  67. package/global/main.mjs +39 -3
  68. package/global/output.mjs +12 -0
  69. package/global/rer.mjs +3 -0
  70. package/global/version.mjs +8 -8
  71. package/modules/dependencies/protocol.mjs +2 -1
  72. package/modules/deploy-kit/README.md +34 -1
  73. package/modules/deploy-kit/artifact.mjs +10 -0
  74. package/modules/deploy-kit/deploy.mjs +5 -2
  75. package/modules/deploy-kit/diagnostics.mjs +55 -0
  76. package/modules/deploy-kit/owner-cli.mjs +10 -4
  77. package/modules/deploy-kit/owner.mjs +8 -4
  78. package/modules/deploy-kit/prepare.mjs +3 -2
  79. package/modules/deploy-kit/provider.mjs +51 -9
  80. package/modules/deploy-kit/site.mjs +135 -0
  81. package/modules/erasure/CONTRACT.md +6 -3
  82. package/modules/erasure/README.md +8 -1
  83. package/modules/erasure/audit.mjs +2 -2
  84. package/modules/erasure/cascade.mjs +52 -11
  85. package/modules/erasure/order.mjs +29 -22
  86. package/modules/erasure/plan.mjs +4 -2
  87. package/modules/erasure/queue.mjs +41 -0
  88. package/modules/erasure/scope.mjs +3 -3
  89. package/modules/erasure/verification.mjs +4 -2
  90. package/modules/mail/README.md +1 -1
  91. package/modules/mail/index.mjs +1 -1
  92. package/modules/mail/src/core/errors.mjs +9 -0
  93. package/modules/mail/src/development/index.d.mts +6 -0
  94. package/modules/mail/src/development/index.mjs +6 -3
  95. package/modules/mail/src/development/viewer-html.mjs +3 -3
  96. package/modules/mail/src/development/viewer.mjs +8 -1
  97. package/modules/operator-auth/accounts/native.mjs +1 -1
  98. package/modules/operator-auth/dev/badge.mjs +4 -1
  99. package/modules/operator-auth/dev/index.mjs +15 -5
  100. package/modules/operator-auth/http.mjs +2 -3
  101. package/modules/operator-auth/index.d.mts +4 -9
  102. package/modules/operator-auth/loopback.mjs +17 -0
  103. package/modules/operator-auth/page.mjs +1 -1
  104. package/modules/operator-auth/service.mjs +1 -0
  105. package/modules/operator-auth/signin/index.d.mts +11 -0
  106. package/modules/operator-auth/signin/index.mjs +10 -7
  107. package/modules/owner-claim/verify.mjs +2 -1
  108. package/modules/portability/README.md +8 -0
  109. package/modules/portability/hash.mjs +6 -2
  110. package/modules/portability/import.mjs +1 -1
  111. package/modules/portability/legacy-files.mjs +4 -4
  112. package/modules/portability/legacy-safety.mjs +51 -10
  113. package/modules/portability/migration-history.mjs +15 -0
  114. package/modules/release/README.md +68 -3
  115. package/modules/release/keygen.mjs +18 -0
  116. package/modules/release/produce.mjs +14 -2
  117. package/modules/release/sign.mjs +44 -0
  118. package/modules/release/signature.mjs +336 -0
  119. package/modules/release/signing-cli.mjs +56 -0
  120. package/modules/release/signing-io.mjs +61 -0
  121. package/modules/release/signing-key.mjs +22 -0
  122. package/modules/release/source.mjs +15 -4
  123. package/modules/release/statement.mjs +80 -0
  124. package/modules/release/vectors/generate.mjs +381 -0
  125. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  126. package/modules/release/verify.mjs +27 -0
  127. package/onboarding/adapter-auth.mjs +4 -3
  128. package/onboarding/adapter-settings.mjs +21 -5
  129. package/onboarding/adapter.mjs +2 -1
  130. package/onboarding/app-fetch.mjs +79 -0
  131. package/onboarding/archive-output.d.mts +4 -2
  132. package/onboarding/archive-output.mjs +22 -10
  133. package/onboarding/dependency-approval.mjs +4 -3
  134. package/onboarding/local/environment.mjs +7 -2
  135. package/onboarding/operator-install.mjs +8 -2
  136. package/onboarding/operator-prerequisites.mjs +5 -2
  137. package/onboarding/production-assets.mjs +23 -0
  138. package/onboarding/production-runtime.mjs +8 -4
  139. package/onboarding/queued-entry.mjs +9 -5
  140. package/operations/operations.mjs +11 -2
  141. package/package.json +76 -1
  142. package/scripts/publish-check/README.md +27 -17
  143. package/scripts/publish-check/content_identities.py +14 -233
  144. package/scripts/publish-check/git_history.py +2 -2
  145. package/scripts/publish-check/github_refs.py +28 -2
  146. package/scripts/publish-check/gitleaks.toml +1 -1
  147. package/scripts/publish-check/history_scan.py +6 -4
  148. package/scripts/publish-check/hygiene_config.py +3 -1
  149. package/scripts/publish-check/public_hygiene.py +63 -8
  150. package/scripts/publish-check/publication-config.json +16 -1
  151. package/scripts/publish-check/source_metadata.py +62 -0
  152. package/scripts/publish-check/source_shapes.py +91 -0
  153. package/scripts/publish-check/tree_scan.py +2 -2
  154. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -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
+ }
@@ -14,6 +14,7 @@ import { dispatcher } from '../checkout.mjs';
14
14
  import { delegate } from '../delegate.mjs';
15
15
  import { ownRoot } from '../package-info.mjs';
16
16
  import { LaunchFailure } from './failure.mjs';
17
+ import { describeCheckout } from './facts.mjs';
17
18
  import { prepare } from './prepare.mjs';
18
19
  import { printer } from './render.mjs';
19
20
  import { stopLaunch } from './stop.mjs';
@@ -24,7 +25,14 @@ const workerPath = fileURLToPath(new URL('./worker.mjs', import.meta.url));
24
25
  export async function launchDev(target) {
25
26
  const args = target.args.slice(1);
26
27
  if (args.includes('--detach') && args.includes('--foreground')) { process.stderr.write('Choose one of --detach and --foreground.\n'); return 2; }
27
- const devArgs = args.filter(arg => arg !== '--detach' && arg !== '--foreground');
28
+ // --door-start: the front door is starting this checkout (internal).
29
+ const doorStart = args.includes('--door-start');
30
+ const devArgs = args.filter(arg => !['--detach', '--foreground', '--door-start'].includes(arg));
31
+ // A pin from before the front door cannot move to it: say so in one line.
32
+ if ((devArgs.includes('--door') || devArgs.includes('--direct')) && !await doorCapable(target.root)) {
33
+ process.stderr.write('This checkout\'s pinned Base has no front-door support, so it stays direct. Refresh its Base to serve it through the door; rer door add still lists it and starts it on a visit.\n');
34
+ return 2;
35
+ }
28
36
  // An invocation the pinned dispatcher refuses goes straight to it, so the
29
37
  // refusal is its own, with nothing prepared first.
30
38
  if (!await accepted(target.root, devArgs)) return delegate(target, ['dev', ...devArgs]);
@@ -34,9 +42,13 @@ export async function launchDev(target) {
34
42
  catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
35
43
  }
36
44
  const here = await realpath(process.cwd()).catch(() => undefined);
37
- const out = printer({ json: devArgs.includes('--json'), login: devArgs.includes('--login'), stopHint: `rer dev --stop${here === checkout ? '' : ` --cwd ${JSON.stringify(checkout)}`}` });
45
+ const out = printer({ json: devArgs.includes('--json'), login: devArgs.includes('--login'), stopHint: `rer dev --stop${here === checkout ? '' : ` --cwd ${JSON.stringify(checkout)}`}`, keyless: doorStart });
38
46
  const detach = args.includes('--detach') || (!args.includes('--foreground') && !process.stdout.isTTY);
39
- return detach ? detached(target, devArgs, directory, out) : foreground(target, devArgs, directory, out);
47
+ return detach ? detached(target, devArgs, directory, out, doorStart) : foreground(target, devArgs, directory, out, doorStart);
48
+ }
49
+
50
+ async function doorCapable(root) {
51
+ try { return Boolean((await describeCheckout(root, dispatcher(root).entry, process.env)).door); } catch { return true; }
40
52
  }
41
53
 
42
54
  async function accepted(root, devArgs) {
@@ -45,7 +57,7 @@ async function accepted(root, devArgs) {
45
57
  try { (await import(pathToFileURL(parser).href)).parse(['dev', ...devArgs], process.cwd()); return true; } catch { return false; }
46
58
  }
47
59
 
48
- async function foreground(target, devArgs, directory, out) {
60
+ async function foreground(target, devArgs, directory, out, doorStart) {
49
61
  const holder = await liveWorker(directory);
50
62
  if (holder) {
51
63
  out.attaching(holder.startedAt);
@@ -56,21 +68,22 @@ async function foreground(target, devArgs, directory, out) {
56
68
  let prepared;
57
69
  // The same progress lines as a detached launch while install or setup runs.
58
70
  const heartbeat = setInterval(() => out.heartbeat(), 1000);
59
- try { prepared = await prepare({ root: target.root, args: devArgs, env: process.env, report: fields => out.phase(fields), directory, log: text => log.write(text + '\n'), children: new Set() }); }
71
+ try { prepared = await prepare({ root: target.root, args: devArgs, env: process.env, report: fields => out.phase(fields), directory, log: text => log.write(text + '\n'), children: new Set(), doorStart }); }
60
72
  catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; out.failure(failure); return failure.exitCode; }
61
73
  finally { clearInterval(heartbeat); log.end(); }
62
74
  Object.assign(process.env, prepared.added);
63
75
  return delegate(target, ['dev', ...devArgs]);
64
76
  }
65
77
 
66
- async function detached(target, devArgs, directory, out) {
78
+ async function detached(target, devArgs, directory, out, doorStart) {
67
79
  let token, worker;
68
80
  const holder = await liveWorker(directory);
69
81
  if (holder) { token = holder.token; out.attaching(holder.startedAt); }
70
82
  else {
71
83
  token = randomUUID();
72
84
  // The worker ends itself for a stop that ran after this command started.
73
- worker = spawn(process.execPath, [workerPath, target.root, token, JSON.stringify(devArgs.filter(arg => arg !== '--json')), String(performance.timeOrigin)], { cwd: target.root, env: process.env, detached: true, stdio: 'ignore' });
85
+ worker = spawn(process.execPath, [workerPath, target.root, token, JSON.stringify(devArgs.filter(arg => arg !== '--json')), String(performance.timeOrigin), ...(doorStart ? ['door-start'] : [])], { cwd: target.root, env: process.env, detached: true, stdio: 'ignore' });
86
+ worker.once('error', () => {});
74
87
  worker.unref();
75
88
  }
76
89
  return follow(directory, token, out, { worker });
@@ -82,6 +95,7 @@ async function follow(directory, token, out, { worker, quiet = false }) {
82
95
  const path = join(directory, `events-${token}.jsonl`);
83
96
  let offset = 0, rest = '', exited = false, missingSince, polls = 0;
84
97
  worker?.once('exit', () => { exited = true; });
98
+ worker?.once('error', () => { exited = true; });
85
99
  for (;;) {
86
100
  let size = 0;
87
101
  try { size = (await stat(path)).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
@@ -0,0 +1,36 @@
1
+ // What the front door means for one `rer dev`. With the door enabled, a fresh
2
+ // checkout whose pin supports it is set up at its door origin; a checkout
3
+ // already there needs the door running, which `rer dev` brings back only for
4
+ // the registry the door recorded (a fleet agent or a test with its own
5
+ // RER_DOOR_DIR or RER_SERVERS_DIR never starts the owner's door). --port,
6
+ // --host and --direct always mean direct mode.
7
+ import { doorProtocol, doorSite, readDoorState, runningDoor } from '../../cli/door.mjs';
8
+ import { startDoor } from '../door/control.mjs';
9
+ import { ownsRegistry } from '../door/policy.mjs';
10
+ import { LaunchFailure } from './failure.mjs';
11
+
12
+ const has = (args, flag) => args.includes(flag);
13
+
14
+ export async function doorPlan(described, args, env) {
15
+ const capable = described?.door?.protocol === doorProtocol;
16
+ const direct = has(args, '--direct') || has(args, '--port') || has(args, '--host');
17
+ const state = await readDoorState(env).catch(() => undefined), door = state?.door;
18
+ const running = door?.enabled ? await runningDoor(env).catch(() => undefined) : undefined;
19
+ const protocol = running?.protocol ?? door?.protocol;
20
+ const known = Boolean(door?.enabled) && protocol === doorProtocol;
21
+ const note = door?.enabled && !known ? `The front door speaks protocol ${protocol}, which this rer does not know, so this checkout stays direct. Run rer door restart.` : undefined;
22
+ const saved = Boolean(described?.local?.origin && doorSite(described.local.origin));
23
+ return { door, known, note, freshAtDoor: known && capable && !direct, atDoor: !direct && (saved || has(args, '--door')) };
24
+ }
25
+
26
+ // Bring an enabled door back before a door-mode checkout starts.
27
+ export async function bringBackDoor(plan, env, report) {
28
+ if (!plan.atDoor || !plan.door?.enabled || !plan.known) return;
29
+ if (await runningDoor(env)) return;
30
+ if (!ownsRegistry(plan.door, env)) {
31
+ report({ phase: 'note', message: 'the front door is not running and this shell uses another RER_DOOR_DIR or RER_SERVERS_DIR than the one it recorded, so it was not started' });
32
+ return;
33
+ }
34
+ report({ phase: 'door', message: 'starting the front door (it was not running)' });
35
+ try { await startDoor(env); } catch (error) { throw new LaunchFailure(error.exitCode ?? 1, error.message); }
36
+ }
@@ -0,0 +1,22 @@
1
+ // A checkout that pins the @runeditrun/rer package runs its own rer once
2
+ // node_modules holds the pinned version; until then none of it can run (its
3
+ // adapter imports the package). So its first dependency install is this
4
+ // launcher's: the frozen pnpm install that `rer install` runs.
5
+ import { spawn } from 'node:child_process';
6
+ import { pinnedPackage } from '../checkout.mjs';
7
+ import { packageName } from '../package-info.mjs';
8
+ import { LaunchFailure } from './failure.mjs';
9
+
10
+ export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
11
+ signal?.throwIfAborted();
12
+ const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...env, CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
13
+ children?.add(child);
14
+ if (signal?.aborted) child.kill('SIGTERM');
15
+ let tail = '';
16
+ for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { tail = (tail + data).slice(-4096); output(String(data)); });
17
+ const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (exit, name) => resolve(exit ?? (name ? 128 : 1))); });
18
+ children?.delete(child);
19
+ if (code !== 0) throw new LaunchFailure(code, `pnpm install --frozen-lockfile failed in ${root} (exit ${code}):\n${tail.trim().split('\n').slice(-8).join('\n')}`);
20
+ const pinned = pinnedPackage(root);
21
+ if (!pinned?.ready) throw new LaunchFailure(3, `${root} pins ${packageName} ${pinned?.declared}, but after pnpm install node_modules holds ${pinned?.installed ?? 'no copy of it'}. Correct the pin or the lockfile (pnpm install), then run rer again.`);
22
+ }