@runeditrun/rer 0.1.1 → 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 (106) hide show
  1. package/README.md +111 -1
  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 +10 -7
  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 +111 -1
  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 +21 -7
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/install-pinned.mjs +22 -0
  48. package/global/launch/prepare.mjs +38 -5
  49. package/global/launch/render.mjs +6 -1
  50. package/global/launch/steps.mjs +12 -7
  51. package/global/launch/worker.mjs +3 -3
  52. package/global/list.mjs +6 -3
  53. package/global/main.mjs +37 -3
  54. package/global/version.mjs +8 -8
  55. package/modules/dependencies/protocol.mjs +2 -1
  56. package/modules/deploy-kit/README.md +34 -1
  57. package/modules/deploy-kit/artifact.mjs +10 -0
  58. package/modules/deploy-kit/owner.mjs +5 -4
  59. package/modules/deploy-kit/prepare.mjs +3 -2
  60. package/modules/deploy-kit/provider.mjs +6 -3
  61. package/modules/deploy-kit/site.mjs +135 -0
  62. package/modules/erasure/CONTRACT.md +6 -3
  63. package/modules/erasure/README.md +8 -1
  64. package/modules/erasure/audit.mjs +2 -2
  65. package/modules/erasure/cascade.mjs +52 -11
  66. package/modules/erasure/order.mjs +29 -22
  67. package/modules/erasure/plan.mjs +4 -2
  68. package/modules/erasure/queue.mjs +41 -0
  69. package/modules/erasure/scope.mjs +3 -3
  70. package/modules/erasure/verification.mjs +4 -2
  71. package/modules/mail/README.md +1 -1
  72. package/modules/mail/index.mjs +1 -1
  73. package/modules/mail/src/core/errors.mjs +9 -0
  74. package/modules/mail/src/development/index.d.mts +6 -0
  75. package/modules/mail/src/development/index.mjs +6 -3
  76. package/modules/mail/src/development/viewer-html.mjs +3 -3
  77. package/modules/mail/src/development/viewer.mjs +8 -1
  78. package/modules/operator-auth/dev/index.mjs +15 -5
  79. package/modules/operator-auth/http.mjs +2 -3
  80. package/modules/operator-auth/loopback.mjs +17 -0
  81. package/modules/owner-claim/verify.mjs +2 -1
  82. package/modules/portability/README.md +8 -0
  83. package/modules/portability/hash.mjs +6 -2
  84. package/modules/portability/import.mjs +1 -1
  85. package/modules/portability/legacy-files.mjs +4 -4
  86. package/modules/portability/legacy-safety.mjs +51 -10
  87. package/modules/portability/migration-history.mjs +15 -0
  88. package/modules/release/README.md +5 -3
  89. package/modules/release/produce.mjs +5 -2
  90. package/modules/release/source.mjs +15 -4
  91. package/onboarding/adapter-auth.mjs +4 -3
  92. package/onboarding/adapter-settings.mjs +21 -5
  93. package/onboarding/adapter.mjs +2 -1
  94. package/onboarding/app-fetch.mjs +79 -0
  95. package/onboarding/archive-output.d.mts +4 -2
  96. package/onboarding/archive-output.mjs +22 -10
  97. package/onboarding/dependency-approval.mjs +4 -3
  98. package/onboarding/local/environment.mjs +7 -2
  99. package/onboarding/operator-install.mjs +8 -2
  100. package/onboarding/operator-prerequisites.mjs +5 -2
  101. package/onboarding/queued-entry.mjs +9 -5
  102. package/operations/operations.mjs +11 -2
  103. package/package.json +75 -1
  104. package/scripts/publish-check/github_refs.py +28 -2
  105. package/scripts/publish-check/public_hygiene.py +35 -4
  106. package/scripts/publish-check/publication-config.json +14 -1
@@ -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
+ }
@@ -0,0 +1,164 @@
1
+ // The in-page half of the hydration check. `probe` is serialized into an init
2
+ // script, so it must stay self-contained (no imports, no closures over module
3
+ // scope). It runs before the document parses, while the check holds every
4
+ // application script in the network layer:
5
+ // - when parsing ends (readyState "interactive"), it surveys the page sections,
6
+ // makes the case's edit, snapshots every element in <body> and starts sampling;
7
+ // - it releases the held scripts (window.__rerRelease) once every one-shot
8
+ // animation has finished ("after") or at the midpoint of the edited section's
9
+ // intro window ("mid"), recording whether React had already hydrated;
10
+ // - every frame it samples one-shot animations, the opacity of animated elements
11
+ // and which page sections are present;
12
+ // - once React owns the page and the DOM and animations are quiet, it records
13
+ // which snapshot elements were replaced and sets settledAt.
14
+ // Elements are matched across a rebuild by a structural key (tag:index path from
15
+ // <body>) that ignores script, style, link, template and noscript elements and the
16
+ // nodes the probe inserted.
17
+ export function probe(config) {
18
+ const S = window.__rerProbe = { config, survey: null, edit: null, editBeforeHydration: null, sectionKeys: [], addressSections: [], addressLoose: false,
19
+ replaced: [], starts: {}, opacity: {}, missing: [], frames: 0, oneShot: [], releasedAt: null, releaseNote: null, fiberAtRelease: null, hydratedAt: null, settledAt: null, settled: null, rer: null, error: null };
20
+ const SECTION = new Set(['HEADER', 'NAV', 'SECTION', 'ARTICLE', 'ASIDE', 'FOOTER']), SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'TEMPLATE', 'NOSCRIPT']);
21
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/, ADDRESS = '.__cf_email__,a[href*="/cdn-cgi/l/email-protection"]';
22
+ const inserted = new WeakSet(), tracked = new WeakMap(), watch = new Set();
23
+ const now = () => Math.round(performance.now() * 10) / 10;
24
+ const own = (el, prefix) => { for (const key of Object.keys(el)) if (key.startsWith(prefix)) return el[key]; return undefined; };
25
+ const fiber = el => own(el, '__reactFiber$') !== undefined;
26
+ const counted = el => !SKIP.has(el.tagName) && !inserted.has(el);
27
+ const kids = el => [...el.children].filter(counted);
28
+ const keyOf = el => {
29
+ const parts = [];
30
+ for (let e = el; e !== document.body; e = e.parentElement) { if (!e || !e.parentElement || !counted(e)) return null; parts.unshift(e.tagName.toLowerCase() + ':' + kids(e.parentElement).indexOf(e)); }
31
+ return parts.join('>');
32
+ };
33
+ const resolveKey = key => {
34
+ let el = document.body;
35
+ for (const part of key.split('>')) { const at = part.lastIndexOf(':'); el = kids(el)[+part.slice(at + 1)]; if (!el || el.tagName.toLowerCase() !== part.slice(0, at)) return null; }
36
+ return el;
37
+ };
38
+ const walk = (el, key, out) => { let i = 0; for (const child of el.children) { if (!counted(child)) continue; const k = (key ? key + '>' : '') + child.tagName.toLowerCase() + ':' + i++; out.push([child, k]); walk(child, k, out); } return out; };
39
+ const outermost = () => { const out = []; const visit = el => { for (const child of el.children) { if (!counted(child)) continue; if (SECTION.has(child.tagName)) out.push(child); else visit(child); } }; visit(document.body); return out; };
40
+ const label = el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.classList.length ? '.' + el.classList[0] : '') + (el.getAttribute('aria-label') ? `[aria-label="${el.getAttribute('aria-label')}"]` : '');
41
+ const texts = root => {
42
+ const out = [], walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
43
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) { const p = n.parentElement; if (p && !p.closest('script,style,template,noscript,textarea,svg') && n.nodeValue.trim()) out.push(n); }
44
+ return out;
45
+ };
46
+ // A text node with sibling nodes is a separate text fiber: splitting it always mismatches.
47
+ const mixed = n => n.nodeValue.trim().length >= 4 && n.parentNode.childNodes.length >= 2;
48
+ const lone = n => n.nodeValue.trim().length >= 2 && n.parentNode.childNodes.length === 1;
49
+ const split = n => { const rest = n.splitText(Math.max(1, Math.floor(n.nodeValue.length / 2))), span = document.createElement('span'); inserted.add(span); span.textContent = rest.nodeValue; rest.replaceWith(span); };
50
+ const anyFiber = () => fiber(document.body) || [...document.body.querySelectorAll('*')].some(fiber);
51
+ // An element React has not hydrated yet: no fiber, under a parent React owns that does not set its HTML.
52
+ const pending = () => [...document.body.querySelectorAll('*')].filter(el => counted(el) && !el.closest('svg') && !fiber(el) && el.parentElement && fiber(el.parentElement) && !own(el.parentElement, '__reactProps$')?.dangerouslySetInnerHTML).length;
53
+ const oneShot = () => document.getAnimations().filter(a => {
54
+ if (typeof CSSTransition !== 'undefined' && a instanceof CSSTransition) return false;
55
+ const timing = a.effect && a.effect.getComputedTiming();
56
+ return timing && Number.isFinite(timing.endTime) && timing.activeDuration >= 50;
57
+ });
58
+ const outside = n => !outermost().some(section => section.contains(n));
59
+ let snapshot = [], scope = null, editAt = null, lastMutation = 0;
60
+
61
+ function begin() {
62
+ const sections = outermost();
63
+ S.sectionKeys = sections.map(keyOf);
64
+ S.survey = {
65
+ sections: sections.map(section => { const all = texts(section); return { key: keyOf(section), label: label(section), split: all.some(mixed), lone: all.some(lone) }; }),
66
+ loose: texts(document.body).some(n => outside(n) && mixed(n)),
67
+ advisories: texts(document.body).filter(n => mixed(n) && EMAIL.test(n.nodeValue)).map(n => { const section = sections.find(s => s.contains(n)); return { section: section ? label(section) : 'outside every section', text: n.nodeValue.trim().slice(0, 80) }; }),
68
+ };
69
+ S.addressSections = sections.filter(s => s.querySelector(ADDRESS) || EMAIL.test(s.textContent)).map(keyOf);
70
+ S.addressLoose = texts(document.body).some(n => outside(n) && EMAIL.test(n.nodeValue)) || [...document.querySelectorAll(ADDRESS)].some(outside);
71
+ S.editBeforeHydration = !anyFiber();
72
+ if (config.kind === 'section' || config.kind === 'loose') {
73
+ const root = config.kind === 'section' ? sections[config.section] : document.body;
74
+ if (config.kind === 'section') scope = root;
75
+ const candidates = root ? texts(root).filter(n => config.kind === 'section' || outside(n)) : [];
76
+ const target = candidates.find(mixed) ?? (config.kind === 'section' ? candidates.find(lone) : undefined);
77
+ if (target) {
78
+ const mode = mixed(target) ? 'split' : 'text';
79
+ S.edit = { mode, parent: keyOf(target.parentElement), text: target.nodeValue.trim().slice(0, 60) };
80
+ if (mode === 'split') split(target); else target.nodeValue = 'Übersetzt: ' + target.nodeValue;
81
+ }
82
+ }
83
+ snapshot = walk(document.body, '', []);
84
+ editAt = now();
85
+ new MutationObserver(() => { lastMutation = now(); }).observe(document.body, { childList: true, subtree: true, characterData: true });
86
+ requestAnimationFrame(frame);
87
+ }
88
+
89
+ function release(note) {
90
+ // React owning any element already means a script the check could not hold (an
91
+ // inline module script) hydrated the page, so the release timing was not exercised.
92
+ S.releasedAt = now(); S.releaseNote = note ?? null; S.fiberAtRelease = anyFiber();
93
+ window.__rerRelease();
94
+ }
95
+
96
+ function tryRelease() {
97
+ if (S.releasedAt !== null || S.frames < 3) return;
98
+ const animations = oneShot();
99
+ if (config.release === 'mid') {
100
+ const mine = animations.filter(a => (scope ?? document.body).contains(a.effect.target));
101
+ if (!mine.length) return release('no intro to release into');
102
+ if (mine.some(a => a.startTime === null)) return;
103
+ const start = Math.min(...mine.map(a => a.startTime + a.effect.getTiming().delay)), end = Math.max(...mine.map(a => a.startTime + a.effect.getComputedTiming().endTime));
104
+ const at = document.timeline.currentTime;
105
+ if (at >= (start + end) / 2) release(`${Math.round(100 * (at - start) / (end - start))}% through the intro window (${Math.round(start)}-${Math.round(end)} ms)`);
106
+ return;
107
+ }
108
+ if (animations.some(a => a.playState !== 'finished') && now() - editAt < 15000) return;
109
+ release();
110
+ }
111
+
112
+ function sample() {
113
+ S.frames++;
114
+ for (const a of oneShot()) {
115
+ const el = a.effect.target;
116
+ if (!el || !el.isConnected) continue;
117
+ let record = tracked.get(el);
118
+ // A new element object at a key gets a new id, so a rebuilt element is a second entry.
119
+ if (!record) { const key = keyOf(el); if (key === null) continue; record = { key, id: S.oneShot.length + 1 }; tracked.set(el, record); watch.add(key); }
120
+ const name = a.animationName || a.id || 'animation', list = (S.starts[record.key + ' ' + name] ??= []);
121
+ if (!S.oneShot.includes(record.key + ' ' + name)) S.oneShot.push(record.key + ' ' + name);
122
+ if (a.startTime !== null && !list.some(item => item.id === record.id)) list.push({ id: record.id, start: Math.round((a.startTime + a.effect.getTiming().delay) * 10) / 10, seen: now() });
123
+ }
124
+ for (const key of watch) {
125
+ const el = resolveKey(key);
126
+ if (!el) continue;
127
+ const opacity = +getComputedStyle(el).opacity, record = (S.opacity[key] ??= { first: opacity, max: opacity, dip: null });
128
+ if (opacity > record.max) record.max = opacity;
129
+ else if (record.max > record.first && record.max - opacity > 0.15 && (!record.dip || record.max - opacity > record.dip.from - record.dip.to)) record.dip = { from: record.max, to: opacity, at: now() };
130
+ }
131
+ const present = new Set(outermost().map(keyOf)), lost = S.sectionKeys.filter(key => !present.has(key));
132
+ if (lost.length) S.missing.push({ at: now(), lost });
133
+ }
134
+
135
+ function frame() {
136
+ if (S.settledAt !== null) return;
137
+ try {
138
+ sample();
139
+ tryRelease();
140
+ const t = now();
141
+ if (S.releasedAt !== null) {
142
+ if (S.hydratedAt === null && fiber(document.body) && pending() === 0) S.hydratedAt = t;
143
+ const quiet = t - lastMutation > 300 && !oneShot().some(a => a.playState === 'running');
144
+ if (S.hydratedAt !== null && t - S.hydratedAt > 300 && quiet) return settle('hydrated');
145
+ if (S.hydratedAt === null && t - S.releasedAt > 8000 && !anyFiber()) return settle('not hydrated');
146
+ if (t - S.releasedAt > 20000) return settle('timeout');
147
+ }
148
+ } catch (error) { S.error = String(error && error.stack || error); return settle('error'); }
149
+ requestAnimationFrame(frame);
150
+ }
151
+
152
+ function settle(state) {
153
+ S.settled = state; S.settledAt = now();
154
+ S.replaced = snapshot.filter(([el]) => !el.isConnected).map(([, key]) => key);
155
+ S.rer = window.__rerHydration ? JSON.parse(JSON.stringify(window.__rerHydration)) : null;
156
+ }
157
+
158
+ let begun = false;
159
+ document.addEventListener('readystatechange', () => {
160
+ if (begun || document.readyState === 'loading') return;
161
+ begun = true;
162
+ try { begin(); } catch (error) { S.error = String(error && error.stack || error); settle('error'); }
163
+ });
164
+ }
@@ -0,0 +1,39 @@
1
+ // Which scripts a page loads count as the application's, so a page that loads them
2
+ // and never hydrates fails rather than being skipped as plain HTML. They are:
3
+ // script requests to the page's own origin; module scripts and modulepreload links
4
+ // in the served document, from any origin (a Vite base on a CDN host); and
5
+ // cross-origin script requests sent in CORS mode, as every module fetch is, that
6
+ // the document does not name as a classic <script src>. A third-party classic
7
+ // script on a plain-HTML page is none of these.
8
+
9
+ const attribute = (attrs, name) => { const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, 'i').exec(attrs); return match ? (match[1] ?? match[2] ?? match[3]) : null; };
10
+ const resolveUrl = (href, base) => { try { return new URL(href, base).href; } catch { return null; } };
11
+
12
+ /** The module scripts (inline ones as "inline module script"), modulepreload links
13
+ * and classic script sources of a served document, as absolute URLs. */
14
+ export function documentScripts(html, pageUrl) {
15
+ const tag = /<!--[\s\S]*?-->|<(script|link)\b([^>]*)>/gi, modules = [], classic = [];
16
+ for (let match; (match = tag.exec(html));) {
17
+ const name = match[1]?.toLowerCase(), attrs = match[2] ?? '';
18
+ if (name === 'link') {
19
+ const href = attribute(attrs, 'href');
20
+ if (href && (attribute(attrs, 'rel') ?? '').toLowerCase().split(/\s+/).includes('modulepreload')) modules.push(resolveUrl(href, pageUrl));
21
+ } else if (name === 'script') {
22
+ const src = attribute(attrs, 'src'), module = (attribute(attrs, 'type') ?? '').trim().toLowerCase() === 'module';
23
+ if (module) modules.push(src ? resolveUrl(src, pageUrl) : 'inline module script');
24
+ else if (src) classic.push(resolveUrl(src, pageUrl));
25
+ const close = html.toLowerCase().indexOf('</script', tag.lastIndex);
26
+ tag.lastIndex = close < 0 ? html.length : close;
27
+ }
28
+ }
29
+ return { modules: modules.filter(Boolean), classic: classic.filter(Boolean) };
30
+ }
31
+
32
+ /** The application scripts of one load of `path`, given the document it was served:
33
+ * same-origin paths as paths, other origins as full URLs. */
34
+ export function applicationScripts(load, html, origin, path) {
35
+ const page = new URL(path, origin), show = url => { const parsed = new URL(url, page); return parsed.origin === page.origin ? parsed.pathname + parsed.search : parsed.href; };
36
+ const { modules, classic } = documentScripts(html ?? '', page.href), named = new Set(classic);
37
+ const found = [...load.scripts, ...modules.map(url => url === 'inline module script' ? url : show(url)), ...load.crossScripts.filter(url => !named.has(url)).map(show)];
38
+ return [...new Set(found)];
39
+ }