@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
package/cli/door.mjs ADDED
@@ -0,0 +1,263 @@
1
+ // The front door's protocol (version 1). The door itself lives in the global
2
+ // `rer` package, which bundles this file; each checkout's pinned copy reads the
3
+ // same state, so the two meet only here.
4
+ //
5
+ // RER_DOOR_DIR (default ~/.rer/door): a 0700 directory owned by this account,
6
+ // never a link, holding 0600 files:
7
+ // state.json door policy, recorded environment and the registered sites
8
+ // (and the names removed ones keep reserved)
9
+ // (written under the job queue's bakery lock)
10
+ // token the door's secret; it proves it holds it before rer prints a key
11
+ // process.json the running door: protocol, PID and start, port, addresses,
12
+ // version and version directory
13
+ // A checkout keeps its door identity in its own .rer/local/door-site.json
14
+ // ({id}, 0600); a registered site records the same id, so a moved or renamed
15
+ // checkout keeps its name (and with it its origin and sign-in).
16
+ // A checkout in door mode serves on 127.0.0.1:<proxyPort> and records
17
+ // `door: {v, name, port, secret}` on its server-registry entry (also 0600). Its
18
+ // proxy answers only its own Host carrying the per-start secret header, which
19
+ // only the door (and rer's own probes) add.
20
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
23
+ import { userInfo } from 'node:os';
24
+ import { basename, join, resolve } from 'node:path';
25
+ import { setTimeout as delay } from 'node:timers/promises';
26
+ import { identity } from '../modules/job-queue/identity.mjs';
27
+ import { processInfo } from '../modules/job-queue/processes.mjs';
28
+ import { transaction } from '../modules/job-queue/storage.mjs';
29
+ import { refuse } from './errors.mjs';
30
+ import { shared } from './shared.mjs';
31
+
32
+ export const doorProtocol = 1;
33
+ export const defaultDoorPort = 8700;
34
+ export const doorSecretHeader = 'x-rer-door-secret';
35
+ export const doorChallengeHeader = 'x-rer-door-challenge';
36
+ export const doorIdentityPath = '/__rer/door/identity';
37
+ export const doorDefaults = Object.freeze({ serverLimit: 10, idleSeconds: 43200 });
38
+
39
+ export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(userInfo().homedir, '.rer/door'));
40
+ export const doorOrigin = (name, port) => `http://${name}.localhost:${port}`;
41
+ export const doorHost = (name, port) => `${name}.localhost:${port}`;
42
+
43
+ // A checkout's name: the DNS-safe slug of its folder name.
44
+ export function siteSlug(path) {
45
+ return basename(path).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').slice(0, 50).replace(/-+$/, '') || 'site';
46
+ }
47
+
48
+ // { name, port } when `origin` is exactly a saved front-door origin.
49
+ export function doorSite(origin) {
50
+ let url; try { url = new URL(origin); } catch { return undefined; }
51
+ const match = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.localhost$/.exec(url.hostname);
52
+ if (!match || url.protocol !== 'http:' || !url.port || url.origin !== origin) return undefined;
53
+ return { name: match[1], port: Number(url.port) };
54
+ }
55
+
56
+ async function ownedDirectory(directory, create) {
57
+ if (create) await mkdir(directory, { recursive: true, mode: 0o700 });
58
+ let info;
59
+ try { info = await lstat(directory); } catch (error) { if (error.code === 'ENOENT' && !create) return false; throw error; }
60
+ if (info.isSymbolicLink() || !info.isDirectory() || info.uid !== process.getuid()) throw refuse(`The front door directory ${directory} is foreign or linked. Preserve it for inspection and choose an owned RER_DOOR_DIR.`);
61
+ if (create) await chmod(directory, 0o700);
62
+ return true;
63
+ }
64
+
65
+ // A 0600 file in the door directory, or undefined when it does not exist.
66
+ export async function readPrivate(path) {
67
+ let info;
68
+ try { info = await lstat(path); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
69
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.uid !== process.getuid()) throw refuse(`${path} is foreign or linked. Preserve it for inspection.`);
70
+ if ((info.mode & 0o077) !== 0) await chmod(path, 0o600);
71
+ return readFile(path, 'utf8');
72
+ }
73
+
74
+ export async function writePrivate(path, text) {
75
+ const existing = await readPrivate(path);
76
+ if (existing !== undefined) await chmod(path, 0o600);
77
+ await writeFile(path, text, { mode: 0o600 });
78
+ await chmod(path, 0o600);
79
+ }
80
+
81
+ export async function prepareDoorDirectory(env = process.env) {
82
+ const directory = doorDirectory(env);
83
+ await ownedDirectory(directory, true);
84
+ for (const name of ['locks', 'logs']) await ownedDirectory(join(directory, name), true);
85
+ return directory;
86
+ }
87
+
88
+ // The recorded door, read-only: { directory, door, sites } or undefined.
89
+ // `sites` lists registered checkouts only, never a removed one's reservation.
90
+ export async function readDoorState(env = process.env) {
91
+ const directory = doorDirectory(env);
92
+ if (!await ownedDirectory(directory, false)) return undefined;
93
+ const text = await readPrivate(join(directory, 'state.json'));
94
+ if (text === undefined) return undefined;
95
+ const state = JSON.parse(text);
96
+ return state.door ? { directory, door: state.door, sites: (state.sites ?? []).filter(site => !site.removed) } : undefined;
97
+ }
98
+
99
+ // Change the door state under the lock. `action(state)` edits state.door and
100
+ // state.sites in place. (The lock's own file also keeps empty queue fields.)
101
+ export async function changeDoorState(env, action) {
102
+ const directory = await prepareDoorDirectory(env);
103
+ await readPrivate(join(directory, 'state.json'));
104
+ const lifetime = await identity();
105
+ try {
106
+ return await transaction(directory, lifetime.info, async state => { state.sites ??= []; return action(state); });
107
+ } finally { await lifetime.close(); }
108
+ }
109
+
110
+ export async function ensureToken(directory) {
111
+ const path = join(directory, 'token');
112
+ if (await readPrivate(path) === undefined) await writeFile(path, randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
113
+ return (await readPrivate(path)).trim();
114
+ }
115
+
116
+ // The proof names the loopback address it was asked on, so a process holding
117
+ // one address cannot relay a challenge to the door on the other.
118
+ export const doorProof = (token, challenge, address) => createHmac('sha256', token).update(`rer-door:${address}:${challenge}`).digest('hex');
119
+
120
+ // The running door's record, when its process is alive: { pid, start, port, ... }.
121
+ export async function runningDoor(env = process.env) {
122
+ const directory = doorDirectory(env);
123
+ if (!await ownedDirectory(directory, false)) return undefined;
124
+ const text = await readPrivate(join(directory, 'process.json'));
125
+ if (!text) return undefined;
126
+ const record = JSON.parse(text), current = await processInfo(record.pid);
127
+ return current && !current.zombie && current.start === record.start ? record : undefined;
128
+ }
129
+
130
+ // Names are unique and stable per checkout. `rer door remove` keeps a removed
131
+ // checkout's name reserved for it (`removed: true`), because its saved origin
132
+ // still names it: the door stops routing it, no other checkout can claim it,
133
+ // and that checkout's next rer dev or rer door add takes it back. A
134
+ // reservation ends only when its checkout folder no longer exists.
135
+ function releaseVanished(state) {
136
+ state.sites = state.sites.filter(site => !site.removed || existsSync(site.checkout));
137
+ }
138
+
139
+ const identityFile = '.rer/local/door-site.json';
140
+ const newIdentity = () => randomBytes(16).toString('hex');
141
+
142
+ // The door identity a checkout folder holds, or undefined.
143
+ export function checkoutIdentity(root) {
144
+ try { const id = JSON.parse(readFileSync(join(root, identityFile), 'utf8')).id; return /^[0-9a-f]{32}$/.test(id) ? id : undefined; } catch { return undefined; }
145
+ }
146
+
147
+ export async function saveCheckoutIdentity(root, id) {
148
+ if (checkoutIdentity(root) === id) return;
149
+ const { privateDirectory, writePrivateFile } = await shared('private-files.mjs');
150
+ await privateDirectory(root, ['.rer', 'local']);
151
+ await writePrivateFile(join(root, identityFile), JSON.stringify({ id }) + '\n');
152
+ }
153
+
154
+ // In a door state being changed: the checkout's entry, claiming a name when it
155
+ // has none (its slug, or `preferred`, with a numeric suffix when another
156
+ // checkout holds that name). A removed entry is registered again.
157
+ // `id` is the identity the checkout folder holds (undefined when it holds
158
+ // none). A checkout is found by its folder, or else by its identity: a
159
+ // registered checkout whose folder no longer holds that identity has moved,
160
+ // and its entry follows it to the new folder, name and all. A copy (whose
161
+ // identity the original folder still holds) is a checkout of its own and gets
162
+ // a new identity. An entry registered before checkouts kept an identity
163
+ // follows a checkout whose saved origin names it (`preferred`) once its own
164
+ // folder is gone. The caller saves `entry.id` in the checkout folder.
165
+ // `holder` names the checkout that holds `preferred` when it is not this one.
166
+ export function claimEntry(state, checkout, preferred, id) {
167
+ releaseVanished(state);
168
+ // By folder, unless that folder now holds another checkout's identity.
169
+ let entry = state.sites.find(site => site.checkout === checkout && !(site.id && id && site.id !== id)), copy = false;
170
+ if (!entry && id) {
171
+ const prior = state.sites.find(site => site.id === id);
172
+ if (prior && checkoutIdentity(prior.checkout) !== id) entry = Object.assign(prior, { checkout, movedAt: Date.now() });
173
+ else copy = Boolean(prior);
174
+ }
175
+ if (!entry && preferred) {
176
+ const named = state.sites.find(site => site.name === preferred && !site.id && !existsSync(site.checkout));
177
+ if (named) entry = Object.assign(named, { checkout, movedAt: Date.now() });
178
+ }
179
+ if (entry) delete entry.removed;
180
+ else {
181
+ const taken = new Set(state.sites.map(site => site.name)), base = preferred ?? siteSlug(checkout);
182
+ let name = base;
183
+ for (let suffix = 2; taken.has(name); suffix++) name = `${base}-${suffix}`;
184
+ entry = { name, checkout, addedAt: Date.now() };
185
+ state.sites.push(entry);
186
+ }
187
+ entry.id ??= copy || !id ? newIdentity() : id;
188
+ const holder = preferred && entry.name !== preferred ? state.sites.find(site => site.name === preferred) : undefined;
189
+ return { entry, holder };
190
+ }
191
+
192
+ // A checkout's registered name, claiming one when it has none. `mode` (door or
193
+ // direct) and a direct `origin` are kept on the entry for listings while the
194
+ // checkout is stopped. With `exact` (a saved door origin, which names its
195
+ // host), the claim must give `preferred` itself: when another checkout holds
196
+ // it, or this checkout is registered under another name, nothing changes and
197
+ // rer refuses, so a name and its cookies never reach a second checkout.
198
+ export async function claimSiteName(env, checkout, { preferred, mode, origin, exact = false } = {}) {
199
+ const describe = entry => { if (mode) entry.mode = mode; if (origin) entry.origin = origin; else if (mode === 'door') delete entry.origin; };
200
+ const held = checkoutIdentity(checkout);
201
+ const claimed = await changeDoorState(env, state => {
202
+ const { entry, holder } = claimEntry(state, checkout, preferred, held);
203
+ if (exact && entry.name !== preferred) throw refuse(holder
204
+ ? `This checkout's saved origin names ${preferred}.localhost, which the front door has registered for another checkout (${holder.checkout}). Nothing was started and no sign-in link was printed. Give this checkout its own name: rer dev --direct, then rer dev --door.`
205
+ : `This checkout's saved origin names ${preferred}.localhost, but the front door has it registered as ${entry.name}. Nothing was started and no sign-in link was printed. Move it again: rer dev --direct, then rer dev --door.`);
206
+ describe(entry);
207
+ return { name: entry.name, id: entry.id };
208
+ });
209
+ await saveCheckoutIdentity(checkout, claimed.id);
210
+ return claimed.name;
211
+ }
212
+
213
+ const portOf = name => Number(/:(\d+)$/.exec(name)?.[1]);
214
+
215
+ async function challenge(host, port, token) {
216
+ const { loopbackRequest } = await shared('app-fetch.mjs');
217
+ const nonce = randomBytes(24).toString('hex');
218
+ const reply = await loopbackRequest({ host, port, path: doorIdentityPath, headers: { host: `localhost:${port}`, [doorChallengeHeader]: nonce }, timeoutMs: 5000 });
219
+ let answer; try { answer = JSON.parse(reply.body.toString('utf8')); } catch {}
220
+ const expected = Buffer.from(doorProof(token, nonce, host)), given = Buffer.from(String(answer?.proof ?? ''));
221
+ return reply.status === 200 && given.length === expected.length && timingSafeEqual(given, expected) ? answer : undefined;
222
+ }
223
+
224
+ // Before rer prints a dev key for a door origin on port `port`: every loopback
225
+ // listener on it must belong to the door PID recorded in its state, and the
226
+ // door must prove it holds the 0600 token on each address it serves. While
227
+ // `rer door restart` (or enable) replaces the door (`restarting` in its state,
228
+ // for at most restartWindowMs), a failed check is repeated until the new door
229
+ // passes it, so a start under way finishes against the new door.
230
+ export const restartWindowMs = 90_000;
231
+ export async function verifyDoor(env, port) {
232
+ for (;;) {
233
+ try { return await verifyOnce(env, port); }
234
+ catch (error) {
235
+ const since = (await readDoorState(env).catch(() => undefined))?.door?.restarting;
236
+ if (!(Date.now() - since < restartWindowMs)) throw error;
237
+ await delay(250);
238
+ }
239
+ }
240
+ }
241
+
242
+ async function verifyOnce(env, port) {
243
+ const directory = doorDirectory(env), record = await runningDoor(env);
244
+ const { listeners, processName } = await shared('process-diagnostics.mjs');
245
+ const holders = (await listeners()).filter(row => row.names.some(name => portOf(name) === port));
246
+ const foreign = holders.find(row => row.pid !== record?.pid);
247
+ const withheld = 'No sign-in link was printed.';
248
+ if (foreign) throw refuse(`Port ${port} is held by PID ${foreign.pid} (${await processName(foreign.pid)}), which is not this account's front door${record ? ` (PID ${record.pid})` : ''}. ${withheld} Stop PID ${foreign.pid}, then run rer door restart.`);
249
+ if (!record || record.port !== port) throw refuse(`The front door is not running on port ${port}. ${withheld} Start it with rer door start, then run this again.`);
250
+ const token = (await readPrivate(join(directory, 'token')))?.trim();
251
+ if (!token) throw refuse(`The front door's token is missing from ${directory}. ${withheld} Run rer door restart.`);
252
+ for (const host of ['127.0.0.1', '::1']) {
253
+ let answer;
254
+ try { answer = await challenge(host, port, token); }
255
+ catch (error) {
256
+ // No IPv6 loopback, or nothing on [::1]:P while the door serves only 127.0.0.1.
257
+ if (host === '::1' && ['ECONNREFUSED', 'EADDRNOTAVAIL', 'EAFNOSUPPORT', 'ENETUNREACH'].includes(error.code) && !record.addresses?.includes('::1')) continue;
258
+ throw refuse(`The front door (PID ${record.pid}) did not answer on ${host === '::1' ? '[::1]' : host}:${port}: ${error.code ?? error.message}. ${withheld} Run rer door restart.`);
259
+ }
260
+ if (!answer || answer.pid !== record.pid) throw refuse(`Something other than this account's front door (PID ${record.pid}) answers on ${host === '::1' ? '[::1]' : host}:${port}. ${withheld} Find and stop it, then run rer door restart.`);
261
+ }
262
+ return record;
263
+ }
@@ -32,8 +32,11 @@ async function miniflareFor(root) {
32
32
  return import(pathToFileURL(createRequire(wrangler).resolve('miniflare')));
33
33
  }
34
34
 
35
- // Deploy binds its declared secrets; stand-ins follow the setup manifest's kind or default.
36
- const standIn = field => field?.kind === 'email' ? 'owner@example.com' : typeof field?.default === 'string' && field.default ? field.default : randomBytes(24).toString('hex');
35
+ // Deploy binds its declared secrets. A secret item's verifyStandIn is the
36
+ // hosted-valid value this check uses (MAIL_PROVIDER: "resend" where the setup
37
+ // default "development" is refused once deployed); otherwise stand-ins follow
38
+ // the setup manifest's kind or default.
39
+ const standIn = (item, field) => typeof item.verifyStandIn === 'string' && item.verifyStandIn ? item.verifyStandIn : field?.kind === 'email' ? 'owner@example.com' : typeof field?.default === 'string' && field.default ? field.default : randomBytes(24).toString('hex');
37
40
  const links = html => [...html.matchAll(/(?:src|href)=["'](\/[^"'#?\s]+)/g)].map(match => match[1].slice(1)).filter(path => !path.startsWith('/') && /\.[A-Za-z0-9]+$/.test(path));
38
41
 
39
42
  /** The build served as rer deploy hosts it, with the vars, origin and (stand-in) secrets of its
@@ -51,7 +54,7 @@ export async function serveHosted(root, { declaration: declarationPath = 'deploy
51
54
  const entry = join(build, config.main);
52
55
  const modules = [entry, ...(await files(build)).map(path => join(build, path)).filter(path => path !== entry && moduleTypes[extname(path)])].map(path => ({ type: moduleTypes[extname(path)], path }));
53
56
  const identity = { sourceSha: createHash('sha1').update(randomBytes(16)).digest('hex'), fingerprint: createHash('sha256').update(randomBytes(16)).digest('hex'), versionId: crypto.randomUUID() };
54
- const secrets = Object.fromEntries((declaration.secrets ?? []).map(item => [item.binding, standIn(manifest.fields?.find(field => field.name === item.binding))]));
57
+ const secrets = Object.fromEntries((declaration.secrets ?? []).map(item => [item.binding, standIn(item, manifest.fields?.find(field => field.name === item.binding))]));
55
58
  const mf = new Miniflare(convertV4MiniflareOptions({ host: '127.0.0.1', port: 0, cf: false, workers: [{ name: 'ingress', modules: true, script: ingress, serviceBindings: { APP: 'app' } }, {
56
59
  name: 'app', modules, modulesRoot: build, compatibilityDate: config.compatibility_date, compatibilityFlags: config.compatibility_flags,
57
60
  bindings: { ...config.vars, ...declaration.vars, ...secrets, ...(declaration.originVar ? { [declaration.originVar]: ORIGIN } : {}),
@@ -83,15 +86,22 @@ export async function serveHosted(root, { declaration: declarationPath = 'deploy
83
86
  export async function checkHostedOutput(root, options) {
84
87
  const hosted = await serveHosted(root, options), { client, identity } = hosted, failures = [];
85
88
  try {
86
- const request = async path => { const response = await hosted.fetch(path); return { status: response.status, type: response.headers.get('content-type') ?? '', body: await response.text() }; };
89
+ const request = async path => { const response = await hosted.fetch(path); return { status: response.status, type: response.headers.get('content-type') ?? '', etag: response.headers.get('etag'), body: await response.text() }; };
87
90
  const built = await files(client).catch(() => []);
88
91
  const documents = await Promise.all(built.filter(path => extname(path) === '.html').map(path => readFile(join(client, path), 'utf8')));
89
92
  const home = await request('');
90
93
  if (home.status === 200 && home.type.includes('text/html')) documents.push(home.body);
91
- const paths = [...new Set([...publicAssetPaths(built).map(path => path.split('/').map(encodeURIComponent).join('/')), ...documents.flatMap(links)])];
94
+ // Every built file answers 200 by its own path, built `.html` documents included.
95
+ const html = built.filter(path => extname(path) === '.html' && !path.split('/').some(part => part.startsWith('.')));
96
+ const paths = [...new Set([...[...publicAssetPaths(built), ...html].map(path => path.split('/').map(encodeURIComponent).join('/')), ...documents.flatMap(links)])];
92
97
  for (let i = 0; i < paths.length; i += 8) await Promise.all(paths.slice(i, i + 8).map(async path => {
93
98
  const answer = await request(path);
94
99
  if (answer.status !== 200) failures.push(`${answer.status} /${path}`);
100
+ // A browser revisits a built .html with If-None-Match: it must answer 304, not the app's 404.
101
+ else if (answer.etag && path.endsWith('.html')) {
102
+ const again = await hosted.fetch(path, { headers: { 'if-none-match': answer.etag } }); await again.body?.cancel();
103
+ if (again.status !== 304) failures.push(`${again.status} /${path} (If-None-Match)`);
104
+ }
95
105
  }));
96
106
  failures.sort();
97
107
  const health = await request('health');
@@ -0,0 +1,91 @@
1
+ // Chromium from the seed's own Playwright, the two ways of serving pages to it
2
+ // (the production build as rer deploy hosts it, or a running origin), and one
3
+ // load: scripts held until the probe releases them, every external request aborted.
4
+ import { createRequire } from 'node:module';
5
+ import { join } from 'node:path';
6
+ import { RerError } from '../errors.mjs';
7
+ import { serveHosted } from '../hosted-output.mjs';
8
+ import { probe } from './probe.mjs';
9
+ import { cloudflareRewrite } from './cases.mjs';
10
+
11
+ const INSTALL = 'pnpm exec playwright install chromium';
12
+
13
+ export async function launchChromium(root) {
14
+ const require = createRequire(join(root, 'package.json'));
15
+ let playwright, name;
16
+ for (const candidate of ['playwright-core', '@playwright/test', 'playwright']) { try { playwright = require(candidate); name = candidate; break; } catch {} }
17
+ if (!playwright?.chromium) throw new RerError('hydration_browser_missing', `The hydration check runs Chromium through the seed's own Playwright, and none of playwright-core, @playwright/test or playwright resolves from ${join(root, 'package.json')}. Add @playwright/test to devDependencies, install, then run ${INSTALL}.`, 1);
18
+ // Pages fulfilled by the check would otherwise count as public, and Chromium would
19
+ // refuse a dev server's loopback HMR socket from them.
20
+ try { return await playwright.chromium.launch({ args: ['--disable-features=LocalNetworkAccessChecks'] }); }
21
+ catch (error) { throw new RerError('hydration_browser_missing', `The hydration check could not start Chromium from the seed's ${name}: ${String(error.message).split('\n').find(line => line.trim()) ?? error}. Install the browser it pins with ${INSTALL}.`, 1); }
22
+ }
23
+
24
+ const strip = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection']);
25
+ const plainHeaders = headers => { const out = {}; headers.forEach((value, key) => { if (!strip.has(key)) out[key] = value; }); return out; };
26
+
27
+ /** The production build served the way rer deploy hosts it, at its https origin. */
28
+ export async function hostedServer(root, options) {
29
+ const hosted = await serveHosted(root, options);
30
+ const request = async (path, init) => { const response = await hosted.fetch(path.replace(/^\//, ''), init); return { status: response.status, headers: response.headers, body: Buffer.from(await response.arrayBuffer()) }; };
31
+ return { origin: hosted.origin, mode: 'hosted', close: () => hosted.close(), request,
32
+ answer: async route => { const r = route.request(), url = new URL(r.url()); const answer = await request(url.pathname + url.search, { method: r.method(), headers: r.headers(), body: r.postDataBuffer() ?? undefined }); return { status: answer.status, headers: plainHeaders(answer.headers), body: answer.body }; } };
33
+ }
34
+
35
+ /** A running rer dev or rer preview. */
36
+ export function originServer(origin) {
37
+ origin = new URL(origin).origin;
38
+ const request = async (path, init) => { const response = await fetch(origin + path, { redirect: 'manual', ...init }); return { status: response.status, headers: response.headers, body: Buffer.from(await response.arrayBuffer()) }; };
39
+ return { origin, mode: 'origin', close: async () => {}, request,
40
+ answer: async route => { const response = await route.fetch({ maxRedirects: 0 }); const headers = { ...response.headers() }; for (const key of strip) delete headers[key]; return { status: response.status(), headers, body: await response.body() }; } };
41
+ }
42
+
43
+ /** One page load: returns the probe's record, console and page errors, the
44
+ * same-origin script requests (scripts), those answered before the probe released
45
+ * the hold (unheld: none unless the hold failed) and the aborted cross-origin script
46
+ * requests sent in CORS mode, as module fetches are (crossScripts). */
47
+ export async function runLoad(browser, server, path, item, { timeout = 30000 } = {}) {
48
+ const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: item.reduced ? 'reduce' : 'no-preference', serviceWorkers: 'block' });
49
+ const errors = [], warnings = [], aborted = new Set(), scripts = new Set(), unheld = new Set(), crossScripts = new Set();
50
+ let release, isReleased = false; const released = new Promise(resolve => { release = () => { isReleased = true; resolve(); }; });
51
+ try {
52
+ const page = await context.newPage();
53
+ await page.exposeBinding('__rerRelease', () => { release(); });
54
+ await page.addInitScript({ content: `(${probe})(${JSON.stringify({ kind: item.kind, section: item.section, release: item.release })})` });
55
+ page.on('pageerror', error => errors.push(`page error: ${String(error.message).split('\n')[0]}`));
56
+ page.on('console', message => {
57
+ const text = message.text(), type = message.type();
58
+ if (type === 'error' && !(/^Failed to load resource/.test(text) && aborted.has(message.location().url))) errors.push(`console error: ${text.split('\n')[0].slice(0, 300)}`);
59
+ if (type === 'warning' && /hydrat/i.test(text)) warnings.push({ text: text.split('\n')[0].slice(0, 300), componentStack: /\n\s+at \S/.test(text) });
60
+ });
61
+ await page.route('**/*', async route => {
62
+ const request = route.request(), url = new URL(request.url());
63
+ if (url.origin !== server.origin) {
64
+ aborted.add(request.url());
65
+ if (request.resourceType() === 'script' && request.headers().origin) crossScripts.add(request.url());
66
+ return route.abort();
67
+ }
68
+ if (request.resourceType() === 'script') { scripts.add(url.pathname); await released; }
69
+ if (request.resourceType() === 'script' && !isReleased) unheld.add(url.pathname);
70
+ try {
71
+ const rewrite = item.kind === 'cloudflare' && request.isNavigationRequest() && request.frame() === page.mainFrame();
72
+ if (!rewrite && server.mode === 'origin') return await route.continue();
73
+ const answer = await server.answer(route);
74
+ if (rewrite && answer.status === 200) answer.body = Buffer.from(cloudflareRewrite(answer.body.toString('utf8'), { decode: item.decode }).html);
75
+ await route.fulfill(answer);
76
+ } catch (error) { if (!page.isClosed()) await route.abort().catch(() => {}); if (!/closed|disposed/i.test(String(error))) errors.push(`check could not serve ${url.pathname}: ${error.message}`); }
77
+ });
78
+ const started = Date.now();
79
+ await page.goto(server.origin + path, { waitUntil: 'commit', timeout });
80
+ const settled = await page.waitForFunction(() => window.__rerProbe?.settledAt !== null && window.__rerProbe?.settledAt !== undefined, null, { timeout, polling: 50 }).then(() => true, () => false);
81
+ const record = await page.evaluate(() => window.__rerProbe ?? null).catch(() => null);
82
+ return { record: settled ? record : { ...(record ?? {}), settled: 'timeout' }, errors, warnings, aborted: [...aborted], scripts: [...scripts], unheld: [...unheld], crossScripts: [...crossScripts], ms: Date.now() - started };
83
+ } finally { release(); await context.close(); }
84
+ }
85
+
86
+ /** Runs tasks with at most `width` in flight, keeping their order. */
87
+ export async function pool(tasks, width) {
88
+ const results = []; let next = 0;
89
+ await Promise.all(Array.from({ length: Math.min(width, tasks.length) }, async () => { while (next < tasks.length) { const index = next++; results[index] = await tasks[index](); } }));
90
+ return results;
91
+ }
@@ -0,0 +1,106 @@
1
+ // Which pages the hydration check loads, which cases it runs on each, and the
2
+ // Cloudflare email-obfuscation rewrite it applies to served HTML.
3
+
4
+ export const MAX_PAGES = 6;
5
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
6
+ const RAW = new Set(['script', 'style', 'textarea', 'noscript', 'template', 'xmp', 'title']);
7
+
8
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]), HOPS = 5;
9
+ const html = answer => answer.status === 200 && /text\/html/i.test(answer.type);
10
+ const pagePath = (href, base) => { try { const url = new URL(href, base); return url.origin === new URL(base).origin ? url.pathname : null; } catch { return null; } };
11
+
12
+ /** Requests `start` and follows same-origin redirects, at most HOPS. Returns the
13
+ * final { path, answer }, the redirects followed and, when the final answer is not
14
+ * a page, why (stopped). */
15
+ async function follow(request, start, origin) {
16
+ const followed = [];
17
+ let path = start, answer = await request(path);
18
+ for (;;) {
19
+ if (!(REDIRECTS.has(answer.status) && answer.location)) return { path, answer, followed, stopped: html(answer) ? null : `${path} answered ${answer.status}${answer.type ? ' ' + answer.type : ''}` };
20
+ let next = null; try { next = new URL(answer.location, origin + path); } catch {}
21
+ const note = `${path} answered ${answer.status} to ${answer.location}`;
22
+ if (!next || next.origin !== origin) return { path, answer, followed, stopped: `${note}, another origin the check does not follow` };
23
+ if (followed.length >= HOPS) return { path, answer, followed, stopped: `${note}, past ${HOPS} redirects` };
24
+ followed.push(note); path = next.pathname + next.search; answer = await request(path);
25
+ }
26
+ }
27
+
28
+ /** The public pages to check: `publicPages` when the seed declares it, otherwise
29
+ * the page `/` answers (following same-origin redirects, at most HOPS) plus
30
+ * same-origin links on it that answer 200 HTML without a session (following only a
31
+ * trailing-slash redirect), at most MAX_PAGES. `request(path)` answers
32
+ * { status, type, location, body } from `origin`. Returns the pages, the redirects
33
+ * followed from `/` and what was not checked (refused). */
34
+ export async function selectPages(request, declared, origin) {
35
+ if (Array.isArray(declared) && declared.length) return { source: 'publicPages', pages: await Promise.all([...new Set(declared)].map(async path => { const answer = await request(path); return { path, body: answer.body, cacheControl: answer.cacheControl }; })), redirects: [], refused: [] };
36
+ const home = await follow(request, '/', origin);
37
+ if (home.stopped) return { source: 'discovered', pages: [], redirects: home.followed, refused: [home.stopped] };
38
+ const pages = [{ path: home.path, body: home.answer.body, cacheControl: home.answer.cacheControl }], refused = [];
39
+ const links = [...new Set([...home.answer.body.matchAll(/<a\b[^>]*?\bhref=(["'])([^"']*)\1/gi)].map(match => match[2].startsWith('//') ? null : pagePath(match[2], origin + home.path)).filter(path => path && path !== '/' && path !== new URL(home.path, origin).pathname && !/\.[A-Za-z0-9]+$/.test(path)))];
40
+ for (const path of links) {
41
+ if (pages.length >= MAX_PAGES) break;
42
+ let answer = await request(path), final = path;
43
+ if (REDIRECTS.has(answer.status) && answer.location && pagePath(answer.location, origin + path) === path + '/') { final = path + '/'; answer = await request(final); }
44
+ if (html(answer)) { if (!pages.some(page => page.path === final)) pages.push({ path: final, body: answer.body, cacheControl: answer.cacheControl }); }
45
+ else refused.push(`${path} answered ${answer.status}${answer.location ? ' to ' + answer.location : ''}`);
46
+ }
47
+ return { source: 'discovered', pages, redirects: home.followed, refused };
48
+ }
49
+
50
+ // Cloudflare's Email Address Obfuscation: a text address becomes an anchor to
51
+ // /cdn-cgi/l/email-protection (a span when it is already inside a link), and a
52
+ // mailto: href becomes /cdn-cgi/l/email-protection#<hex>. <head> and raw-text
53
+ // elements are left alone.
54
+ const encode = address => { const key = 0x5a; return key.toString(16) + [...address].map(c => (c.charCodeAt(0) ^ key).toString(16).padStart(2, '0')).join(''); };
55
+ const decoder = `<script>(function(){function d(h){var k=parseInt(h.slice(0,2),16),s='';for(var i=2;i<h.length;i+=2)s+=String.fromCharCode(parseInt(h.slice(i,i+2),16)^k);return s}[].forEach.call(document.querySelectorAll('a[href*="/cdn-cgi/l/email-protection#"]'),function(a){a.setAttribute('href','mailto:'+d(a.getAttribute('href').split('#')[1]))});[].forEach.call(document.querySelectorAll('.__cf_email__'),function(e){e.parentNode.replaceChild(document.createTextNode(d(e.getAttribute('data-cfemail'))),e)});document.currentScript.remove()})()</script>`;
56
+
57
+ /** Returns { html, count }. With `decode`, appends Cloudflare's decoder so it runs
58
+ * before any held application script (the decoder is deferred; it runs before
59
+ * DOMContentLoaded). */
60
+ export function cloudflareRewrite(source, { decode = false } = {}) {
61
+ const tag = /<!--[\s\S]*?-->|<(\/?)([A-Za-z][\w:-]*)\b[^>]*>/g;
62
+ let out = '', at = 0, count = 0, link = false, head = false;
63
+ for (;;) {
64
+ tag.lastIndex = at;
65
+ const match = tag.exec(source), end = match ? match.index : source.length;
66
+ const text = source.slice(at, end);
67
+ out += head ? text : text.replace(EMAIL, address => { count++; return link ? `<span class="__cf_email__" data-cfemail="${encode(address)}">[email&#160;protected]</span>` : `<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="${encode(address)}">[email&#160;protected]</a>`; });
68
+ if (!match) break;
69
+ let token = match[0];
70
+ const closing = match[1] === '/', name = match[2]?.toLowerCase();
71
+ if (name === 'head') head = !closing;
72
+ if (name === 'a') {
73
+ link = !closing;
74
+ if (!closing) token = token.replace(/\bhref=(["'])mailto:([^"'?]+)([^"']*)\1/i, (all, quote, address) => { count++; return `href=${quote}/cdn-cgi/l/email-protection#${encode(decodeURIComponent(address))}${quote}`; });
75
+ }
76
+ out += token; at = match.index + match[0].length;
77
+ if (name && !closing && RAW.has(name)) { const close = source.toLowerCase().indexOf('</' + name, at), stop = close < 0 ? source.length : close; out += source.slice(at, stop); at = stop; }
78
+ }
79
+ if (decode) { const close = out.lastIndexOf('</body>'); out = close < 0 ? out + decoder : out.slice(0, close) + decoder + out.slice(close); }
80
+ return { html: out, count };
81
+ }
82
+
83
+ /** The loads for one page, from its plain load's survey. */
84
+ export function planCases(page) {
85
+ const cases = [];
86
+ page.survey.sections.forEach((section, index) => {
87
+ if (!section.split && !section.lone) return;
88
+ cases.push({ kind: 'section', section: index, release: 'after' });
89
+ if (page.intros.some(key => key === section.key || key.startsWith(section.key + '>'))) cases.push({ kind: 'section', section: index, release: 'mid' });
90
+ });
91
+ if (page.survey.loose) cases.push({ kind: 'loose', release: 'after' });
92
+ if (page.addresses) for (const decode of [true, false]) {
93
+ cases.push({ kind: 'cloudflare', decode, release: 'after' });
94
+ if (page.intros.length) cases.push({ kind: 'cloudflare', decode, release: 'mid' });
95
+ }
96
+ cases.push({ kind: 'plain', reduced: true, release: 'after' });
97
+ return cases;
98
+ }
99
+
100
+ export function describeCase(item, survey) {
101
+ const when = item.release === 'mid' ? 'released mid-intro' : 'released after the intros';
102
+ if (item.kind === 'section') { const section = survey.sections[item.section]; return `edit in ${section.label} (${section.key}), ${when}`; }
103
+ if (item.kind === 'loose') return `edit in text outside every section, ${when}`;
104
+ if (item.kind === 'cloudflare') return `Cloudflare email rewrite ${item.decode ? 'decoded before hydration' : 'with its anchors present at hydration'}, ${when}`;
105
+ return item.reduced ? 'plain load with prefers-reduced-motion: reduce' : 'plain load';
106
+ }
@@ -0,0 +1,105 @@
1
+ // The Base hydration check (SEED-COMMAND-CONTRACT.md, "Hydration units"). rer
2
+ // verify runs it on the production build served as rer deploy hosts it; run it on
3
+ // its own against a running rer dev or rer preview with --origin:
4
+ // node scripts/base-cli/cli/hydration/check.mjs [ROOT] [--origin URL] [--pages /,/docs] [--details]
5
+ // For each public page it edits text in each page section before React hydrates
6
+ // (scripts held in the network layer) and fails when anything outside that section
7
+ // is replaced, an intro replays, a frame loses a section, recovery is noisy or
8
+ // uncounted, or a hydrated page's document lacks Cache-Control: no-transform.
9
+ import { resolve } from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { RerError } from '../errors.mjs';
12
+ import { hostedServer, launchChromium, originServer, pool, runLoad } from './browser.mjs';
13
+ import { cloudflareRewrite, describeCase, planCases, selectPages } from './cases.mjs';
14
+ import { applicationScripts } from './scripts.mjs';
15
+ import { judgePage, judgeUnhydrated, measure, plainHtml } from './verdict.mjs';
16
+
17
+ const CONTEXTS = 3;
18
+
19
+ /** Checks the pages and returns the result; `report(lines)` receives its human
20
+ * summary (pages, skips, redirects followed, what was not checked) before it
21
+ * returns or fails on a page. With no page to load it fails naming why. */
22
+ export async function checkHydration(root, { origin, pages: declared, declaration, report } = {}) {
23
+ const started = Date.now();
24
+ const server = origin ? originServer(origin) : await hostedServer(root, { declaration });
25
+ let browser;
26
+ try {
27
+ const request = async path => { const answer = await server.request(path); return { status: answer.status, type: answer.headers.get('content-type') ?? '', location: answer.headers.get('location'), cacheControl: answer.headers.get('cache-control'), body: answer.body.toString('utf8') }; };
28
+ const failures = [];
29
+ const selection = await selectPages(request, declared, server.origin);
30
+ // A check that loads no page proves nothing, so it fails rather than passing.
31
+ if (!selection.pages.length) {
32
+ const notChecked = [...selection.redirects, ...selection.refused];
33
+ throw new RerError('hydration_no_pages', `The hydration check loaded no page, so it checked nothing. Not checked: ${notChecked.join('; ')}. Make / answer a page as HTML (directly or through a redirect on the same origin), or name the seed's public pages with the commandAdapter option publicPages (SEED-COMMAND-CONTRACT.md, Hydration units).`, 1, { hydration: { pages: [], redirects: selection.redirects, notChecked: selection.refused, loads: 0 } });
34
+ }
35
+ browser = await launchChromium(root);
36
+ const plain = { kind: 'plain', release: 'after' };
37
+ const baselines = await pool(selection.pages.map(page => () => runLoad(browser, server, page.path, plain)), CONTEXTS);
38
+ const pages = selection.pages.map((page, index) => {
39
+ const baseline = baselines[index], record = baseline.record;
40
+ const hydrated = record?.settled === 'hydrated', scripts = applicationScripts(baseline, page.body, server.origin, page.path);
41
+ const intros = [...new Set((record?.oneShot ?? []).map(id => id.split(' ')[0]))];
42
+ return { path: page.path, cacheControl: page.cacheControl, baseline, survey: record?.survey ?? { sections: [], loose: false, advisories: [] }, intros, addresses: cloudflareRewrite(page.body).count > 0, hydrated, scripts, skipped: plainHtml(baseline, scripts) };
43
+ });
44
+ // Only a document React hydrates needs no-transform: a redirect or a page that
45
+ // never hydrates has no HTML whose rewrite could cause a mismatch.
46
+ for (const page of pages.filter(page => page.hydrated)) if (!/(^|[\s,])no-transform($|[\s,])/i.test(page.cacheControl ?? '')) failures.push(`${page.path} document: Cache-Control is "${page.cacheControl ?? 'absent'}" and lacks no-transform, so a proxy (Cloudflare's email address obfuscation) may rewrite the HTML React hydrates`);
47
+ for (const page of pages) if (!page.hydrated && !page.skipped) failures.push(judgeUnhydrated(page));
48
+ const work = pages.filter(page => page.hydrated).flatMap(page => planCases(page).map(item => ({ page, item })));
49
+ const loads = await pool(work.map(({ page, item }) => () => runLoad(browser, server, page.path, item)), CONTEXTS);
50
+ work.forEach(({ page, item }, index) => { (page.loads ??= []).push({ item, load: loads[index] }); });
51
+ for (const page of pages.filter(page => page.hydrated)) failures.push(...judgePage({ ...page, loads: page.loads ?? [] }));
52
+ const result = {
53
+ served: server.mode === 'hosted' ? 'production build as rer deploy hosts it' : server.origin,
54
+ pages: pages.map(page => page.skipped ? { path: page.path, skipped: 'plain HTML: React does not hydrate this page and it loads no application script' } : {
55
+ path: page.path,
56
+ sections: page.survey.sections.map(section => `${section.label} (${section.key})`),
57
+ intros: page.baseline.record?.oneShot ?? [],
58
+ loads: 1 + (page.loads?.length ?? 0),
59
+ advisories: page.survey.advisories.map(item => `${item.section}: "${item.text}" is text beside sibling elements and holds an email address; make the address the only child of its element`),
60
+ cases: (page.loads ?? []).map(({ item, load }) => summarize(item, load, page)),
61
+ }),
62
+ ...(selection.redirects.length ? { redirects: selection.redirects } : {}),
63
+ ...(selection.refused.length ? { notChecked: selection.refused } : {}),
64
+ loads: pages.length + loads.length,
65
+ ms: Date.now() - started,
66
+ };
67
+ report?.(summary(result));
68
+ if (failures.length) throw new RerError('hydration_units_failed', `Page sections did not hydrate as separate units:\n ${failures.join('\n ')}\nEvery page section and the root chrome sit in a HydrationUnit, intros carry data-intro under the root's intro guard, and the client entry passes createRecoverableErrorHandler (src/base/hydration/README.md; SEED-COMMAND-CONTRACT.md, Hydration units).`, 1, { failures, hydration: result });
69
+ return result;
70
+ } finally { await browser?.close(); await server.close(); }
71
+ }
72
+
73
+ /** The human lines for a result: each page, then redirects and what was not checked. */
74
+ export function summary(result) {
75
+ return [`Hydration check: ${result.pages.length} page(s), ${result.loads} load(s)`,
76
+ ...result.pages.map(page => ` ${page.path}: ${page.skipped ? `skipped, ${page.skipped}` : `${page.loads} load(s), ${page.sections.length} section(s)`}`),
77
+ ...(result.redirects ?? []).map(note => ` redirect followed: ${note}`),
78
+ ...(result.notChecked ?? []).map(note => ` not checked: ${note}`)];
79
+ }
80
+
81
+ function summarize(item, load, page) {
82
+ const record = load.record ?? {}, { replaced, outside, replays, missing } = record.replaced ? measure(item, record, page) : { replaced: [], outside: [], replays: [], missing: [] };
83
+ return { case: describeCase(item, page.survey), ...(record.edit ? { edit: `${record.edit.mode} "${record.edit.text}"` } : {}), ...(record.releaseNote ? { released: record.releaseNote } : {}), ...(record.fiberAtRelease ? { hydratedBeforeRelease: true } : {}), ...(load.unheld?.length ? { unheld: load.unheld } : {}),
84
+ replaced: replaced.length, replacedOutside: outside.length, replays: replays.length, framesMissingSection: missing.length, frames: record.frames, recovered: record.rer?.count ?? 0,
85
+ ...(load.warnings.length ? { warnings: load.warnings.length, warningsWithStack: load.warnings.filter(w => w.componentStack).length, warning: load.warnings[0].text.slice(0, 160) } : {}), ...(load.errors.length ? { errors: load.errors } : {}), ...(load.aborted.length ? { aborted: load.aborted } : {}) };
86
+ }
87
+
88
+ async function declaredPages(root) {
89
+ try { const { resolveProject } = await import('../project.mjs'); const project = await resolveProject(root); return { pages: project.adapter?.publicPages, declaration: project.adapter?.deployDeclaration ?? project.adapter?.deploy?.declaration }; }
90
+ catch { return {}; }
91
+ }
92
+
93
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
94
+ const args = process.argv.slice(2), flag = name => { const at = args.indexOf(name); return at < 0 ? undefined : args.splice(at, 2)[1]; };
95
+ const origin = flag('--origin'), pages = flag('--pages')?.split(',').filter(Boolean), details = args.includes('--details');
96
+ const root = resolve(args.find(arg => !arg.startsWith('--')) ?? '.'), declared = await declaredPages(root);
97
+ try {
98
+ const result = await checkHydration(root, { origin, pages: pages ?? declared.pages, declaration: declared.declaration });
99
+ console.log(JSON.stringify(details ? result : { ...result, pages: result.pages.map(({ cases, ...page }) => page) }, null, 1));
100
+ } catch (error) {
101
+ console.error(error.message);
102
+ if (details && error.hydration) console.error(JSON.stringify(error.hydration, null, 1));
103
+ process.exitCode = 1;
104
+ }
105
+ }