@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.
- package/README.md +111 -1
- package/cli/README.md +28 -3
- package/cli/contract.mjs +4 -4
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +15 -5
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +56 -9
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +2 -0
- package/cli/managed-server.mjs +52 -18
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +14 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-check.mjs +96 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-render.mjs +77 -0
- package/cli/site.mjs +109 -0
- package/global/CHANGELOG.md +63 -0
- package/global/README.md +111 -1
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +37 -3
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/owner.mjs +5 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +6 -3
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +5 -3
- package/modules/release/produce.mjs +5 -2
- package/modules/release/source.mjs +15 -4
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +75 -1
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/public_hygiene.py +35 -4
- package/scripts/publish-check/publication-config.json +14 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Package mode: a first-party checkout that pins the @runeditrun/rer package as
|
|
2
|
+
// an exact devDependency instead of carrying a vendored copy (scripts/rer.mjs,
|
|
3
|
+
// scripts/base-cli and scripts/base-onboarding). Its commands run `rer VERB`
|
|
4
|
+
// from node_modules/.bin, and its adapter imports the package's exports.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const packageName = '@runeditrun/rer';
|
|
9
|
+
export const exactVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
10
|
+
export const vendoredPaths = ['scripts/rer.mjs', 'scripts/base-cli', 'scripts/base-onboarding'];
|
|
11
|
+
|
|
12
|
+
// The specifier a package.json declares for the package, if any.
|
|
13
|
+
export const declaredPackage = pkg => pkg?.devDependencies?.[packageName] ?? pkg?.dependencies?.[packageName];
|
|
14
|
+
|
|
15
|
+
// What a verb's OPERATIONS argv and package script are in each mode
|
|
16
|
+
// (a null verb is the bare `rer` alias).
|
|
17
|
+
export const commandArgv = (verb, packaged) => packaged ? ['rer', ...(verb ? [verb] : [])] : ['node', 'scripts/rer.mjs', ...(verb ? [verb] : [])];
|
|
18
|
+
export const commandLine = (verb, packaged) => commandArgv(verb, packaged).join(' ');
|
|
19
|
+
|
|
20
|
+
// The importer entry pnpm-lock.yaml holds for the package: { group,
|
|
21
|
+
// specifier, version, locked } (locked: the packages section resolves that
|
|
22
|
+
// version with a sha512 integrity), or undefined. pnpm writes lockfile
|
|
23
|
+
// version 9 in a fixed layout, which this reads line by line.
|
|
24
|
+
export async function lockedPackage(root) {
|
|
25
|
+
let text;
|
|
26
|
+
try { text = await readFile(resolve(root, 'pnpm-lock.yaml'), 'utf8'); } catch { return undefined; }
|
|
27
|
+
const lines = text.split('\n'), indent = line => line.length - line.trimStart().length;
|
|
28
|
+
// The lines under a key at a given indentation, up to the next line at or above it.
|
|
29
|
+
const block = (from, to, key, depth) => {
|
|
30
|
+
const start = lines.findIndex((line, index) => index >= from && index < to && indent(line) === depth && line.trim() === key);
|
|
31
|
+
if (start < 0) return undefined;
|
|
32
|
+
let end = start + 1;
|
|
33
|
+
while (end < to && (!lines[end].trim() || indent(lines[end]) > depth)) end++;
|
|
34
|
+
return [start + 1, end];
|
|
35
|
+
};
|
|
36
|
+
const importers = block(0, lines.length, 'importers:', 0), own = importers && block(...importers, '.:', 2);
|
|
37
|
+
if (!own) return undefined;
|
|
38
|
+
for (const group of ['devDependencies', 'dependencies']) {
|
|
39
|
+
const entries = block(...own, `${group}:`, 4), entry = entries && block(...entries, `'${packageName}':`, 6);
|
|
40
|
+
if (!entry) continue;
|
|
41
|
+
const fields = Object.fromEntries(lines.slice(...entry).map(line => /^\s+(specifier|version):\s*'?([^'\s]*)'?\s*$/.exec(line)).filter(Boolean).map(match => [match[1], match[2]]));
|
|
42
|
+
const packages = block(0, lines.length, 'packages:', 0), resolved = packages && block(...packages, `'${packageName}@${fields.version}':`, 2);
|
|
43
|
+
return { group, specifier: fields.specifier, version: fields.version, locked: Boolean(resolved && lines.slice(...resolved).some(line => /^\s+resolution: \{integrity: sha512-/.test(line))) };
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
package/cli/parse.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { commonFlags, serverDefaults, verbs } from './contract.mjs';
|
|
3
3
|
import { invalid, refuse } from './errors.mjs';
|
|
4
|
-
|
|
4
|
+
import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
|
|
5
|
+
const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
|
|
5
6
|
export const mailbox = value => typeof value === 'string' && value.length <= 254 && /^[a-zA-Z0-9.!+_%\-]+@[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+$/.test(value);
|
|
6
|
-
|
|
7
|
+
// A loopback host: the shared predicate (including `*.localhost` door names),
|
|
8
|
+
// plus `::1` as --host and a bracket-stripped hostname spell it.
|
|
9
|
+
export const loopback = host => host === '::1' || isLoopbackHost(host);
|
|
10
|
+
// The addresses rer binds directly. A `*.localhost` name is reached through the
|
|
11
|
+
// front door; binding one would give a `::1`-only listener on macOS.
|
|
12
|
+
export const listenHost = host => ['127.0.0.1', 'localhost', '::1'].includes(host);
|
|
7
13
|
export const origin = (host, port) => `http://${host === '::1' ? '[::1]' : host}:${port}`;
|
|
8
14
|
|
|
9
15
|
export function parse(argv, cwd = process.cwd(), { independent = false, deferTail = false } = {}) {
|
|
@@ -39,7 +45,10 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
39
45
|
}
|
|
40
46
|
if (options.help) return options;
|
|
41
47
|
if (deferTail) { options.cwd=resolve(cwd,options.cwd);return options; }
|
|
42
|
-
if(options.stop && ['--port','--host','--idle-timeout','--no-queue','--as','--login','--models','--models-service'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
|
|
48
|
+
if(options.stop && ['--port','--host','--idle-timeout','--no-queue','--as','--login','--models','--models-service','--door','--direct'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
|
|
49
|
+
// --port and --host always mean direct mode; --door and --direct pick a mode.
|
|
50
|
+
if(options.door&&options.direct)throw invalid('Choose one of --door and --direct.');
|
|
51
|
+
if(options.door&&['--port','--host'].some(flag=>options.supplied.has(flag)))throw invalid('--door cannot be combined with --port or --host, which always serve directly.');
|
|
43
52
|
if(options.models!==undefined && !['fake','live'].includes(options.models))throw invalid('--models takes fake or live.');
|
|
44
53
|
if(options.modelsService!==undefined && options.models!=='live')throw invalid('--models-service requires --models live.');
|
|
45
54
|
if(options.as!==undefined && (options.login || !/^[a-z][a-z0-9-]{0,39}$/.test(options.as)))throw invalid(options.login?'--as and --login cannot be combined.':'--as takes a persona name such as owner, signed-out or admin.');
|
|
@@ -53,9 +62,10 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
53
62
|
options.port = Number(options.port);
|
|
54
63
|
}
|
|
55
64
|
for(const key of ['httpPort','httpsPort'])if(options[key]!==undefined){if(!/^[0-9]+$/.test(options[key])||Number(options[key])<1||Number(options[key])>65535)throw invalid('Ingress port must be an integer from 1 to 65535.');options[key]=Number(options[key]);}
|
|
65
|
+
if(options.site&&['platform','image','bindAddress','httpPort','httpsPort','envFile','host'].some(key=>options[key]!==undefined))throw invalid('--site takes no platform, container or host flags: the run site is static files on an assets-only Worker.');
|
|
56
66
|
if(['image','bindAddress','httpPort','httpsPort'].some(key=>options[key]!==undefined)&&options.platform!=='container')throw invalid('Container image and ingress flags require --platform container.');
|
|
57
67
|
if(options.platform !== undefined && !['workers','container'].includes(options.platform))throw invalid('Platform must be workers or container.');
|
|
58
|
-
if (options.host !== undefined && !(verb === 'deploy' && options.platform === 'container') && !
|
|
68
|
+
if (options.host !== undefined && !(verb === 'deploy' && options.platform === 'container') && !listenHost(options.host)) throw refuse('Host must be exactly 127.0.0.1, localhost or ::1.');
|
|
59
69
|
if (options.ownerEmail !== undefined) { if (!mailbox(options.ownerEmail)) throw invalid('Provide a valid plain owner mailbox.'); options.ownerEmail = options.ownerEmail.toLowerCase(); }
|
|
60
70
|
if (options.ownerName !== undefined && (options.ownerName.length > 200 || /[\p{Cc}\p{Cf}]/u.test(options.ownerName))) throw invalid('Provide a valid owner name.');
|
|
61
71
|
if (options.target !== undefined && !['local', 'remote'].includes(options.target)) throw invalid('Target must be local or remote.');
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { Transform } from 'node:stream';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { missing } from './errors.mjs';
|
|
10
|
+
import { shellQuote } from './node-runtime-cache.mjs';
|
|
11
|
+
|
|
12
|
+
// The project's pinned pnpm, provided the way rer provides its pinned Node:
|
|
13
|
+
// fetched once from the npm registry, its tarball checked against the
|
|
14
|
+
// registry's sha512 integrity before anything is extracted or run, and kept in
|
|
15
|
+
// rer's own cache (~/.rer/pnpm/pnpm-VERSION). Only rer's own commands for the
|
|
16
|
+
// project put it on their PATH; nothing global changes, and it is used only
|
|
17
|
+
// when the person's PATH has no pnpm of the pinned version.
|
|
18
|
+
export const pnpmCache = () => join(homedir(), '.rer', 'pnpm');
|
|
19
|
+
const directoryOf = version => join(pnpmCache(), `pnpm-${version}`);
|
|
20
|
+
const shim = version => join(directoryOf(version), 'shim');
|
|
21
|
+
const receipt = version => join(directoryOf(version), '.rer-verified.json');
|
|
22
|
+
const entry = version => join(directoryOf(version), 'bin', 'pnpm.cjs');
|
|
23
|
+
|
|
24
|
+
async function digest(path, algorithm, encoding) {
|
|
25
|
+
const hash = createHash(algorithm);
|
|
26
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
27
|
+
return hash.digest(encoding);
|
|
28
|
+
}
|
|
29
|
+
async function owned(path, directory) {
|
|
30
|
+
const stat = await lstat(path);
|
|
31
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022)) throw Error('Unsafe pnpm cache placement.');
|
|
32
|
+
}
|
|
33
|
+
async function prepareCache() {
|
|
34
|
+
for (const path of [join(homedir(), '.rer'), pnpmCache()]) {
|
|
35
|
+
await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
36
|
+
try { await owned(path, true); } catch { throw missing(`The pnpm cache directory must be an owned, non-linked directory without group/other write access: ${path}. Correct its placement and permissions before retrying.`); }
|
|
37
|
+
}
|
|
38
|
+
return pnpmCache();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The verified cached pnpm's PATH directory, or undefined when none is cached. */
|
|
42
|
+
export async function cachedPnpm(version) {
|
|
43
|
+
try { await lstat(directoryOf(version)); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
44
|
+
try {
|
|
45
|
+
for (const path of [join(homedir(), '.rer'), pnpmCache(), directoryOf(version), shim(version)]) await owned(path, true);
|
|
46
|
+
for (const path of [receipt(version), entry(version), join(shim(version), 'pnpm')]) await owned(path, false);
|
|
47
|
+
const record = JSON.parse(await readFile(receipt(version), 'utf8'));
|
|
48
|
+
if (record.version !== version || !/^sha512-/.test(record.integrity) || record.entrySha256 !== await digest(entry(version), 'sha256', 'hex')) throw Error('pnpm cache integrity mismatch.');
|
|
49
|
+
return shim(version);
|
|
50
|
+
} catch {
|
|
51
|
+
throw missing(`The cached pnpm is unsafe or incomplete. Preserve it for inspection with: mv ${shellQuote(directoryOf(version))} ${shellQuote(directoryOf(version) + '.quarantine-' + Date.now())}. Then rerun the original rer command to fetch a verified release.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function download(url, destination, limit, signal) {
|
|
56
|
+
const response = await fetch(url, { signal, redirect: 'error' });
|
|
57
|
+
if (!response.ok || !response.body) throw Error('The pnpm release could not be downloaded.');
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
const bounded = new Transform({ transform(chunk, encoding, callback) { bytes += chunk.length; callback(bytes > limit ? Error('pnpm release download exceeds its size limit.') : null, chunk); } });
|
|
60
|
+
await pipeline(response.body, bounded, createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal });
|
|
61
|
+
}
|
|
62
|
+
function extract(archive, directory, signal) {
|
|
63
|
+
const child = spawn('tar', ['-xzf', archive, '-C', directory], { stdio: 'ignore', signal });
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
let failure;
|
|
66
|
+
child.once('error', error => { failure = error; });
|
|
67
|
+
child.once('close', code => code === 0 && !failure ? resolve() : reject(failure ?? Error('Verified pnpm release extraction failed.')));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fetch, verify and cache pnpm VERSION; returns its PATH directory. `registry` is the npm registry origin. */
|
|
72
|
+
export async function downloadPnpm(version, cancellation, { registry = 'https://registry.npmjs.org' } = {}) {
|
|
73
|
+
const cache = await prepareCache(), temporary = await mkdtemp(join(cache, '.download-'));
|
|
74
|
+
const signal = AbortSignal.any([cancellation, AbortSignal.timeout(60000)].filter(Boolean));
|
|
75
|
+
try {
|
|
76
|
+
const metadata = join(temporary, 'release.json'), archive = join(temporary, 'pnpm.tgz');
|
|
77
|
+
await download(`${registry}/pnpm/${version}`, metadata, 1024 * 1024, signal);
|
|
78
|
+
const release = JSON.parse(await readFile(metadata, 'utf8'));
|
|
79
|
+
const integrity = release?.dist?.integrity;
|
|
80
|
+
if (release?.version !== version || typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) throw Error('The pnpm release integrity is missing.');
|
|
81
|
+
await download(`${registry}/pnpm/-/pnpm-${version}.tgz`, archive, 64 * 1024 * 1024, signal);
|
|
82
|
+
if (`sha512-${await digest(archive, 'sha512', 'base64')}` !== integrity) throw Error('pnpm release integrity verification failed; nothing was extracted or run.');
|
|
83
|
+
// This is the first point at which any downloaded content can be run.
|
|
84
|
+
await extract(archive, temporary, signal);
|
|
85
|
+
const extracted = join(temporary, 'package');
|
|
86
|
+
if (JSON.parse(await readFile(join(extracted, 'package.json'), 'utf8')).version !== version) throw Error('Verified pnpm release did not name the project pin.');
|
|
87
|
+
await mkdir(join(extracted, 'shim'), { mode: 0o700 });
|
|
88
|
+
// `node` is rer's own: the command puts its Node first on PATH beside this.
|
|
89
|
+
await writeFile(join(extracted, 'shim', 'pnpm'), `#!/bin/sh\nexec node ${shellQuote(entry(version))} "$@"\n`, { mode: 0o700 });
|
|
90
|
+
await writeFile(join(extracted, '.rer-verified.json'), JSON.stringify({ version, integrity, entrySha256: await digest(join(extracted, 'bin', 'pnpm.cjs'), 'sha256', 'hex') }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
91
|
+
await chmod(extracted, 0o700);
|
|
92
|
+
try { await rename(extracted, directoryOf(version)); }
|
|
93
|
+
catch (error) { if (!['EEXIST', 'ENOTEMPTY'].includes(error.code) || !(await cachedPnpm(version))) throw error; }
|
|
94
|
+
return shim(version);
|
|
95
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
96
|
+
}
|
|
@@ -2,7 +2,7 @@ import { portabilityPlan, ensurePortabilitySchema } from '../modules/container/p
|
|
|
2
2
|
import { exportArchive, importArchive, fileRecords } from '../modules/portability/files.mjs';
|
|
3
3
|
import { readLegacyArchive } from '../modules/portability/format.mjs';
|
|
4
4
|
import { recoverLegacyArchive } from '../modules/portability/legacy-files.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { legacyTargetState, requireLegacyEmpty } from '../modules/portability/legacy-safety.mjs';
|
|
6
6
|
import { migrationReplay } from '../modules/portability/migration-history.mjs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
@@ -33,7 +33,7 @@ async function run(direction, { root, path, journal, binding, buckets, policy, t
|
|
|
33
33
|
const migrations=async()=>migrationReplay(await appliedMigrations(root,proxy.env[binding],binding,unstable_splitSqlQuery),policy);
|
|
34
34
|
const options={ db: proxy.env[binding], buckets: Object.fromEntries(Object.entries(buckets).map(([name, binding]) => [name, proxy.env[binding]])), policy, migrations };
|
|
35
35
|
if(direction==='legacy-preflight') {
|
|
36
|
-
await requireLegacyEmpty(options,await
|
|
36
|
+
await requireLegacyEmpty(options,await legacyTargetState(options.db,options.migrations));
|
|
37
37
|
return {checkedEmpty:true};
|
|
38
38
|
}
|
|
39
39
|
await ensurePortabilitySchema(proxy.env[binding], await portabilityPlan(unstable_splitSqlQuery));
|
|
@@ -7,10 +7,12 @@ import { trafficProxy } from './server-proxy.mjs';
|
|
|
7
7
|
// All public serving, including setup's bounded runtime, uses this listener.
|
|
8
8
|
// The adapter receives a separate loopback port and keeps the public origin
|
|
9
9
|
// for application URLs, cookies and sign-in links.
|
|
10
|
-
|
|
10
|
+
// In door mode (`door`) the listener is 127.0.0.1 on a port the OS assigns,
|
|
11
|
+
// and only the machine's front door reaches it.
|
|
12
|
+
export async function runtimeFrontDoor({ host, port, activity, door, devKey, origin }) {
|
|
11
13
|
const runtimePort = await runtimeLoopbackPort(port);
|
|
12
14
|
const observer = activity ?? { begin: () => () => {}, touch: () => {} };
|
|
13
|
-
const proxy = await trafficProxy({ host, port, target: runtimePort, activity: observer });
|
|
15
|
+
const proxy = await trafficProxy({ host, port, target: runtimePort, activity: observer, door, devKey, origin });
|
|
14
16
|
return { ...proxy, runtimePort };
|
|
15
17
|
}
|
|
16
18
|
|
package/cli/server-proxy.mjs
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import net from 'node:net';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { posix } from 'node:path';
|
|
5
5
|
import { refuse } from './errors.mjs';
|
|
6
|
+
import { loopback } from './parse.mjs';
|
|
6
7
|
import { fontResponseHeaders } from './font-cache.mjs';
|
|
7
8
|
|
|
8
9
|
// The public listener observes transport lifetime without buffering or replacing
|
|
9
10
|
// application responses. Preserve Host, Origin, cookies, status and streaming.
|
|
10
11
|
// Each request opens its own loopback connection: a pooled keep-alive socket
|
|
11
12
|
// that the runtime has already dropped would otherwise fail a fresh request.
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// In door mode (`door`: {host, header, secret}) it answers only requests whose
|
|
14
|
+
// Host is its own `<name>.localhost:<port>` (or a subdomain) and that carry the
|
|
15
|
+
// per-start secret the front door adds; cookies are not scoped by port, so
|
|
16
|
+
// anything else could bring another site's cookies here.
|
|
17
|
+
// Vite's /@fs/<absolute path> answers only a request holding the checkout's dev
|
|
18
|
+
// key (devKey: the key gate's header or cookie); anything else gets 404, so
|
|
19
|
+
// Vite's refusal page, which lists the serving allow list, never answers it: a
|
|
20
|
+
// 403 carrying that page for any other path (a checkout-root file) is turned
|
|
21
|
+
// into the same 404.
|
|
22
|
+
// A Host that is neither loopback, a `*.localhost` name nor the configured
|
|
23
|
+
// origin's (origin) gets a plain 421 here: the application would refuse it
|
|
24
|
+
// anyway, and an auth check that throws on it must not answer with a crash page.
|
|
25
|
+
export async function trafficProxy({host,port,target,activity,door,devKey,origin}) {
|
|
26
|
+
const probe=randomUUID(),sockets=new Set(),keyed=keyHolder(devKey),configured=origin?new URL(origin).host:null;
|
|
14
27
|
const server=http.createServer((req,res)=>{
|
|
15
|
-
if(
|
|
28
|
+
if(door&&!doorAdmits(req,door)){req.resume();res.writeHead(403,{'content-type':'text/plain; charset=utf-8'});res.end('This checkout answers only through its front door.');return;}
|
|
29
|
+
if(!hostAdmits(req,configured)){req.resume();res.writeHead(421,{'content-type':'text/plain; charset=utf-8'});res.end('This local server answers only its own host.');return;}
|
|
30
|
+
if(!publicPath(req.url)||fsPath(req.url)&&!keyed(req)){req.resume();notFound(res);return;}
|
|
16
31
|
const internal=req.headers['x-rer-probe']===probe;delete req.headers['x-rer-probe'];
|
|
17
32
|
const finish=internal?()=>{}:activity.begin();
|
|
18
33
|
let requestEnded=req.complete,responseEnded=false;
|
|
@@ -20,6 +35,20 @@ export async function trafficProxy({host,port,target,activity}) {
|
|
|
20
35
|
req.once('end',()=>{requestEnded=true;completed();});req.once('aborted',finish);
|
|
21
36
|
res.once('close',()=>{responseEnded=true;if(req.destroyed)finish();else completed();});res.once('finish',()=>{responseEnded=true;completed();});
|
|
22
37
|
const upstream=http.request({host:'127.0.0.1',port:target,path:req.url,method:req.method,headers:req.headers,agent:false},reply=>{
|
|
38
|
+
// Vite answers a path outside its serving allow list with a 403 page that lists the
|
|
39
|
+
// allow list (checkout and home paths); a request without the dev key gets 404 instead.
|
|
40
|
+
// Any other 403 is the application's own and passes through.
|
|
41
|
+
if(reply.statusCode===403&&!keyed(req)){
|
|
42
|
+
const parts=[];
|
|
43
|
+
reply.on('data',chunk=>parts.push(chunk));
|
|
44
|
+
reply.once('end',()=>{
|
|
45
|
+
const body=Buffer.concat(parts);
|
|
46
|
+
if(body.includes(restrictedPage)){notFound(res);return;}
|
|
47
|
+
res.writeHead(reply.statusCode,fontResponseHeaders(req.url,reply.statusCode,reply.rawHeaders,req.headers));res.end(body);
|
|
48
|
+
});
|
|
49
|
+
reply.once('error',()=>res.destroy());
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
23
52
|
res.writeHead(reply.statusCode,fontResponseHeaders(req.url,reply.statusCode,reply.rawHeaders,req.headers));reply.pipe(res);
|
|
24
53
|
});
|
|
25
54
|
req.once('aborted',()=>upstream.destroy());res.once('close',()=>upstream.destroy());
|
|
@@ -27,7 +56,9 @@ export async function trafficProxy({host,port,target,activity}) {
|
|
|
27
56
|
});
|
|
28
57
|
server.on('connection',socket=>{sockets.add(socket);socket.on('close',()=>sockets.delete(socket));});
|
|
29
58
|
server.on('upgrade',(req,socket,head)=>{
|
|
30
|
-
if(
|
|
59
|
+
if(door&&!doorAdmits(req,door)){socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
|
|
60
|
+
if(!hostAdmits(req,configured)){socket.end('HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
|
|
61
|
+
if(!publicPath(req.url)||fsPath(req.url)&&!keyed(req)){socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
|
|
31
62
|
const hmr=String(req.headers['sec-websocket-protocol']??'').split(',').map(s=>s.trim()).includes('vite-hmr');
|
|
32
63
|
const upstream=net.connect(target,'127.0.0.1',()=>{
|
|
33
64
|
upstream.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${req.rawHeaders.reduce((lines,value,i)=>i%2?lines:lines+value+': '+req.rawHeaders[i+1]+'\r\n','')}\r\n`);
|
|
@@ -55,6 +86,33 @@ export async function trafficProxy({host,port,target,activity}) {
|
|
|
55
86
|
}
|
|
56
87
|
return {probe,port:server.address().port,async close(){for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));}};
|
|
57
88
|
}
|
|
89
|
+
function doorAdmits(req,door){
|
|
90
|
+
const host=String(req.headers.host??'').toLowerCase(),given=Buffer.from(String(req.headers[door.header]??'')),secret=Buffer.from(door.secret);
|
|
91
|
+
// The secret never reaches the application.
|
|
92
|
+
delete req.headers[door.header];
|
|
93
|
+
for(let i=req.rawHeaders.length-2;i>=0;i-=2)if(req.rawHeaders[i].toLowerCase()===door.header)req.rawHeaders.splice(i,2);
|
|
94
|
+
return (host===door.host||host.endsWith('.'+door.host))&&given.length===secret.length&&timingSafeEqual(given,secret);
|
|
95
|
+
}
|
|
96
|
+
function hostAdmits(req,configured){
|
|
97
|
+
const host=req.headers.host;
|
|
98
|
+
if(host===undefined)return true;
|
|
99
|
+
let url;try{url=new URL(`http://${host}`);}catch{return false;}
|
|
100
|
+
if(url.username||url.password||url.pathname!=='/'||url.search||url.hash)return false;
|
|
101
|
+
return loopback(url.hostname)||url.host===configured;
|
|
102
|
+
}
|
|
103
|
+
// The key gate's own credentials (modules/operator-auth/dev-gate.mjs): the
|
|
104
|
+
// x-rer-dev-key header, or the browser's rer_dev_gate_<port> cookie.
|
|
105
|
+
function keyHolder(key){
|
|
106
|
+
if(!key)return ()=>false;
|
|
107
|
+
const same=(a,b)=>{a=Buffer.from(a);b=Buffer.from(b);return a.length===b.length&&timingSafeEqual(a,b);};
|
|
108
|
+
const cookie=createHmac('sha256',key).update('rer-dev-gate\0cookie').digest('hex');
|
|
109
|
+
return req=>{
|
|
110
|
+
const header=req.headers['x-rer-dev-key'];
|
|
111
|
+
if(header!==undefined)return same(String(header),key);
|
|
112
|
+
return String(req.headers.cookie??'').split(';').map(part=>part.trim()).some(part=>/^rer_dev_gate_\d+=/.test(part)&&same(part.slice(part.indexOf('=')+1),cookie));
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const restrictedPage=Buffer.from('<h1>403 Restricted</h1>');
|
|
58
116
|
function notFound(res){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found');}
|
|
59
117
|
// The runtime's built-in routes (/cdn-cgi/*: Local Explorer, trigger handlers,
|
|
60
118
|
// R2 and platform proxies), the checkout's private files and export archives
|
|
@@ -73,6 +131,12 @@ export function publicPath(url){
|
|
|
73
131
|
const served=posix.normalize('/'+segments.join('/')).split('/').filter(Boolean),exports=segment=>/^exports$/i.test(segment);
|
|
74
132
|
return !exports(served[0])&&!(served[0]?.startsWith('@')&&served.some(exports));
|
|
75
133
|
}
|
|
134
|
+
// Vite's /@fs/ prefix in any spelling publicPath decodes.
|
|
135
|
+
export function fsPath(url){
|
|
136
|
+
let path=String(url).split(/[?#]/)[0];
|
|
137
|
+
for(let round=0;round<4;round++){let decoded;try{decoded=decodeURIComponent(path);}catch{return true;}if(decoded===path)break;path=decoded;}
|
|
138
|
+
return /^@fs$/i.test(posix.normalize('/'+path.replace(/\\/g,'/').split('/').map(segment=>segment.trim()).join('/')).split('/').filter(Boolean)[0]??'');
|
|
139
|
+
}
|
|
76
140
|
// Count application data frames, not protocol ping/pong or an open control
|
|
77
141
|
// socket. Parse lengths incrementally without retaining application payloads.
|
|
78
142
|
export function frameObserver(touch) {
|
package/cli/server-registry.mjs
CHANGED
|
@@ -55,12 +55,13 @@ function memoryMessage(counted,available,floor,mode) {
|
|
|
55
55
|
const list=counted.length?`Running now:\n${counted.map(listing).join('\n')}\nA dev server holds about 0.5-1.3 GiB and a preview 0.4-0.8 GiB; stopping one with its command above frees that.`:'No dev or preview server is running, so something else is using the memory.';
|
|
56
56
|
return `${head}\n${list}\nTo start anyway, lower the floor, for example RER_SERVERS_MIN_MEMORY_BYTES=${Math.floor(floor/2)} rer ${mode} (0 turns it off).`;
|
|
57
57
|
}
|
|
58
|
-
function limitMessage(counted,cap,
|
|
59
|
-
const holders=
|
|
58
|
+
function limitMessage(counted,cap,explicit,mode) {
|
|
59
|
+
const holders=explicit?[]:counted.filter(item=>item.explicit&&item.limit===cap);
|
|
60
60
|
const head=`Not started: ${counted.length} dev or preview ${counted.length===1?'server is':'servers are'} running on this computer and the limit is ${cap}.`;
|
|
61
61
|
const list=`Running now:\n${counted.map(listing).join('\n')}`;
|
|
62
62
|
const raise=holders.length
|
|
63
63
|
?`The limit is ${cap} because ${holders.length===1?'a server was':'servers were'} started with RER_SERVER_LIMIT=${cap} (${holders.map(item=>item.checkout).join(', ')}). Stop ${holders.length===1?'it':'them'} and start again, or start everything with a higher RER_SERVER_LIMIT=N.`
|
|
64
|
+
:explicit?`This start set RER_SERVER_LIMIT=${cap}; to allow more, start it with a higher RER_SERVER_LIMIT=N.`
|
|
64
65
|
:`To allow more, start this one with a higher limit, for example RER_SERVER_LIMIT=${cap*2} rer ${mode}. Servers started without RER_SERVER_LIMIT allow ${defaultServerLimit}.`;
|
|
65
66
|
return `${head}\n${list}\nTo make room, stop one with its command above. ${raise}`;
|
|
66
67
|
}
|
|
@@ -71,9 +72,11 @@ function limitMessage(counted,cap,limit,mode) {
|
|
|
71
72
|
// before queue admission/local operation. Stop waits for process+state cleanup,
|
|
72
73
|
// which never needs the machine transaction. Generation removal happens last.
|
|
73
74
|
// The limit is 10 unless the person sets RER_SERVER_LIMIT. Every record keeps the
|
|
74
|
-
// limit it started with and whether the person set it.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
75
|
+
// limit it started with and whether the person set it. A start that sets
|
|
76
|
+
// RER_SERVER_LIMIT is capped by that value alone, higher or lower than any
|
|
77
|
+
// running server's. A start that sets nothing is capped by the lowest limit a
|
|
78
|
+
// running server's person set explicitly, so a deliberate "RER_SERVER_LIMIT=3"
|
|
79
|
+
// keeps holding starts that do not say otherwise. A recorded default (including
|
|
77
80
|
// the old default of 2 in records written before this rule) never lowers the cap
|
|
78
81
|
// below the current default.
|
|
79
82
|
export const defaultServerLimit=serverDefaults.serverLimit;
|
|
@@ -132,8 +135,8 @@ export async function serverRegistry(root,{env=process.env,memory=availableMemor
|
|
|
132
135
|
const same=state.running.find(item=>item.checkout===checkout);
|
|
133
136
|
if(same)return {existing:same};
|
|
134
137
|
// Journeys are listed but uncounted: they neither use nor lower the cap.
|
|
135
|
-
const counted=state.running.filter(item=>!item.journey),cap=Math.min(limit,...counted.filter(item=>item.explicit).map(item=>item.limit));
|
|
136
|
-
if(!journey&&counted.length>=cap)throw refuse(limitMessage(counted,cap,
|
|
138
|
+
const counted=state.running.filter(item=>!item.journey),cap=explicit?limit:Math.min(limit,...counted.filter(item=>item.explicit).map(item=>item.limit));
|
|
139
|
+
if(!journey&&counted.length>=cap)throw refuse(limitMessage(counted,cap,explicit,mode));
|
|
137
140
|
// Unreadable memory is not a reason to refuse; only a reading below the floor is.
|
|
138
141
|
const available=journey||floor===0?NaN:await memory();
|
|
139
142
|
if(available<floor)throw refuse(memoryMessage(counted,available,floor,mode));
|
package/cli/settings.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { missing } from './errors.mjs';
|
|
2
|
+
import { shared } from './shared.mjs';
|
|
3
|
+
|
|
1
4
|
// Reuse the seed's existing declarations; no parallel auth setting aliases.
|
|
2
5
|
export function localSettingKeys(project) {
|
|
3
6
|
const manifest = project.setupManifest ?? project.setupSeed?.manifest;
|
|
@@ -13,3 +16,14 @@ export function requiredLocalSettings(project, values) {
|
|
|
13
16
|
const manifest=project.setupManifest ?? project.setupSeed?.manifest;
|
|
14
17
|
return [...new Set([keys.email,keys.origin,...(manifest?.fields??[]).filter(field=>field.kind==='secret'&&field.required&&!field.when&&field.input!=='entered'&&field.name!==manifest?.steps.find(step=>step.type==='owner-claim')?.tokenField).map(field=>field.name)])].filter(name=>!values[name]);
|
|
15
18
|
}
|
|
19
|
+
|
|
20
|
+
// The CLI's own proof to the running application, keyed by the session secret
|
|
21
|
+
// in the seed's private settings: its declared file and format, under the name
|
|
22
|
+
// its adapter declares as sessionSecretKey (default SESSION_SECRET).
|
|
23
|
+
export async function localCliSessionProof(project) {
|
|
24
|
+
const hooks = project.adapter?.hooks ?? {}, key = hooks.sessionSecretKey ?? 'SESSION_SECRET';
|
|
25
|
+
const { readSettings } = await shared('adapter-settings.mjs'), { localCliProof } = await shared('adapter-auth.mjs');
|
|
26
|
+
const secret = (await readSettings(project.root, hooks.secrets ?? {})).values[key];
|
|
27
|
+
if (typeof secret !== 'string' || !secret) throw missing(`${key} is missing from the private settings.`);
|
|
28
|
+
return localCliProof(secret);
|
|
29
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, posix } from 'node:path';
|
|
3
|
+
import { refuse } from './errors.mjs';
|
|
4
|
+
import { DEV_AI_MARKER, DEV_LAYER_MARKER } from './dev-identity.mjs';
|
|
5
|
+
|
|
6
|
+
// What a run site may hold: static pages and the files they use, served as
|
|
7
|
+
// Cloudflare static assets with default HTML handling. Never server code,
|
|
8
|
+
// Wrangler configuration, migrations, settings, local data or owner data.
|
|
9
|
+
const FORBIDDEN = /^(?:wrangler\.(?:json|jsonc|toml)|_worker\.js|_routes\.json|_headers|_redirects|.*\.(?:sql|rer|db|sqlite|sqlite3|env|map))$/i;
|
|
10
|
+
// Routes that exist only on an installed instance.
|
|
11
|
+
export const INSTANCE_ROUTES = ['/signin', '/sign-in', '/login', '/setup', '/app', '/auth', '/api', '/__dev', '/health', '/.well-known'];
|
|
12
|
+
const MAX_FILE = 25 * 1024 * 1024, MAX_FILES = 20000;
|
|
13
|
+
|
|
14
|
+
/** Every file, as a site path ("run.html") with its bytes. */
|
|
15
|
+
async function files(folder) {
|
|
16
|
+
const found = new Map();
|
|
17
|
+
async function walk(directory, prefix) {
|
|
18
|
+
for (const name of (await readdir(directory)).sort()) {
|
|
19
|
+
const path = join(directory, name), site = prefix + name, status = await lstat(path);
|
|
20
|
+
if (name.startsWith('.')) throw refuse(`The run site holds a hidden file: ${site}. It contains only public pages and their files.`);
|
|
21
|
+
if (status.isSymbolicLink() || (!status.isFile() && !status.isDirectory())) throw refuse(`The run site holds a link or special file: ${site}.`);
|
|
22
|
+
if (status.isDirectory()) { await walk(path, site + '/'); continue; }
|
|
23
|
+
if (FORBIDDEN.test(name)) throw refuse(`The run site holds ${site}, which is not a static page or asset. Server code, configuration, migrations and data never go in it.`);
|
|
24
|
+
if (status.size > MAX_FILE) throw refuse(`The run site file ${site} is larger than 25 MiB.`);
|
|
25
|
+
found.set(site, await readFile(path));
|
|
26
|
+
if (found.size > MAX_FILES) throw refuse('The run site holds more than 20,000 files.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await walk(folder, '');
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const safeDecode = path => { try { return decodeURI(path); } catch { return path; } };
|
|
34
|
+
|
|
35
|
+
/** How Cloudflare's static assets (auto-trailing-slash) answer a path, or null. */
|
|
36
|
+
export function servedFile(names, pathname) {
|
|
37
|
+
const path = pathname.replace(/^\/+/, '');
|
|
38
|
+
if (!path) return names.has('index.html') ? 'index.html' : null;
|
|
39
|
+
if (names.has(path) && !path.endsWith('.html')) return path;
|
|
40
|
+
const bare = path.replace(/\/$/, '');
|
|
41
|
+
if (names.has(`${bare}.html`)) return `${bare}.html`;
|
|
42
|
+
if (names.has(`${bare}/index.html`)) return `${bare}/index.html`;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// href/src attributes, srcset candidates and CSS url() references.
|
|
47
|
+
function references(text, html) {
|
|
48
|
+
const found = [];
|
|
49
|
+
if (html) {
|
|
50
|
+
for (const match of text.matchAll(/<([a-z][a-z0-9-]*)\b([^>]*)>/gi)) {
|
|
51
|
+
const [, tag, attributes] = match, rel = /\brel\s*=\s*["']?([^"'>]*)/i.exec(attributes)?.[1].toLowerCase() ?? '';
|
|
52
|
+
for (const attribute of attributes.matchAll(/\b(href|src|srcset|action|poster|data)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
53
|
+
const name = attribute[1].toLowerCase(), value = (attribute[2] ?? attribute[3]).replace(/&/g, '&');
|
|
54
|
+
const values = name === 'srcset' ? value.split(',').map(part => part.trim().split(/\s+/)[0]).filter(Boolean) : [value];
|
|
55
|
+
// Only a plain link or a page anchor navigates; everything else is fetched by the page.
|
|
56
|
+
const navigates = name === 'href' && (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'area' || (tag.toLowerCase() === 'link' && /\b(?:canonical|alternate|author|license|help|me)\b/.test(rel)));
|
|
57
|
+
for (const url of values) found.push({ url, navigates });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const match of text.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi)) found.push({ url: match[1] ?? match[2] ?? match[3], navigates: false });
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Refuse a run site folder that is not exactly static pages and the files they
|
|
67
|
+
* use: an index and a Run page, every same-origin link resolving, no link to an
|
|
68
|
+
* instance-only route, nothing fetched from another origin, no dev layer, and
|
|
69
|
+
* none of the given private values (owner identity, secrets, the dev key).
|
|
70
|
+
*/
|
|
71
|
+
export async function checkSite(folder, { privateValues = [] } = {}) {
|
|
72
|
+
const all = await files(folder), names = new Set(all.keys());
|
|
73
|
+
if (!names.has('index.html')) throw refuse('The run site has no index.html (its homepage).');
|
|
74
|
+
if (!servedFile(names, '/run')) throw refuse('The run site has no /run page.');
|
|
75
|
+
const markers = [DEV_LAYER_MARKER, DEV_AI_MARKER].map(value => Buffer.from(value));
|
|
76
|
+
const secrets = privateValues.filter(value => typeof value === 'string' && value.length >= 6).map(value => Buffer.from(value));
|
|
77
|
+
const problems = [];
|
|
78
|
+
for (const [site, bytes] of all) {
|
|
79
|
+
if (markers.some(marker => bytes.includes(marker))) throw refuse(`The run site file ${site} contains the local-only dev layer.`);
|
|
80
|
+
if (secrets.some(secret => bytes.includes(secret))) throw refuse(`The run site file ${site} contains this installation's private data (owner identity or a secret). The run site renders only edition identity.`);
|
|
81
|
+
const html = site.endsWith('.html'), css = site.endsWith('.css');
|
|
82
|
+
if (!html && !css) continue;
|
|
83
|
+
for (const { url, navigates } of references(bytes.toString('utf8'), html)) {
|
|
84
|
+
if (!url || /^(?:#|mailto:|tel:|data:|javascript:void)/i.test(url)) continue;
|
|
85
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
|
|
86
|
+
if (!navigates) problems.push(`${site} fetches ${url} from another origin`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const pathname = posix.resolve('/' + posix.dirname(site), url.replace(/[?#].*$/, ''));
|
|
90
|
+
if (INSTANCE_ROUTES.some(route => pathname === route || pathname.startsWith(route + '/'))) problems.push(`${site} links to ${pathname}, which exists only on an installed instance`);
|
|
91
|
+
else if (!servedFile(names, safeDecode(pathname))) problems.push(`${site} links to ${pathname}, which the site does not have`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (problems.length) throw refuse(`The run site has broken or out-of-site references: ${problems.slice(0, 8).join('; ')}${problems.length > 8 ? `; and ${problems.length - 8} more` : ''}.`);
|
|
95
|
+
return { files: names.size, pages: [...names].filter(name => name.endsWith('.html')).length };
|
|
96
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readdir, readFile, lstat } from 'node:fs/promises';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { servedFile } from './site-check.mjs';
|
|
5
|
+
|
|
6
|
+
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
|
|
7
|
+
|
|
8
|
+
async function names(folder) {
|
|
9
|
+
const found = new Set();
|
|
10
|
+
async function walk(directory, prefix) {
|
|
11
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
12
|
+
if (entry.isDirectory()) await walk(join(directory, entry.name), prefix + entry.name + '/');
|
|
13
|
+
else if (entry.isFile()) found.add(prefix + entry.name);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
await walk(folder, '');
|
|
17
|
+
return found;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serve a built run site on loopback the way Cloudflare's static assets serve
|
|
22
|
+
* it (auto-trailing-slash HTML handling, 404.html for a missing path): the
|
|
23
|
+
* preview `rer dev` prints as RER_SITE. GET and HEAD only; nothing else is read.
|
|
24
|
+
*/
|
|
25
|
+
export async function sitePreview(folder, { host = '127.0.0.1', ports }) {
|
|
26
|
+
const server = createServer(async (request, response) => {
|
|
27
|
+
try {
|
|
28
|
+
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { allow: 'GET, HEAD' }); return response.end(); }
|
|
29
|
+
const url = new URL(request.url, 'http://site.invalid'), files = await names(folder);
|
|
30
|
+
let pathname; try { pathname = decodeURIComponent(url.pathname); } catch { pathname = '/'; }
|
|
31
|
+
// A request for page.html is answered at its canonical path, as the host does.
|
|
32
|
+
if (pathname.endsWith('.html') && files.has(pathname.slice(1))) {
|
|
33
|
+
const canonical = pathname.endsWith('/index.html') || pathname === '/index.html' ? pathname.slice(0, -'index.html'.length) : pathname.slice(0, -'.html'.length);
|
|
34
|
+
response.writeHead(307, { location: canonical + url.search }); return response.end();
|
|
35
|
+
}
|
|
36
|
+
const file = pathname.includes('\0') || pathname.split('/').some(part => part === '..' || part.startsWith('.')) ? null : servedFile(files, pathname);
|
|
37
|
+
const target = file ?? (files.has('404.html') ? '404.html' : null);
|
|
38
|
+
if (!target || !(await lstat(join(folder, target))).isFile()) { response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); return response.end('Not found'); }
|
|
39
|
+
const body = await readFile(join(folder, target));
|
|
40
|
+
response.writeHead(file ? 200 : 404, { 'content-type': types[extname(target).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'no-store', 'content-length': body.length });
|
|
41
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
42
|
+
} catch { if (!response.headersSent) response.writeHead(500); response.end(); }
|
|
43
|
+
});
|
|
44
|
+
for (const port of ports) {
|
|
45
|
+
const bound = await new Promise(resolve => {
|
|
46
|
+
const failed = () => resolve(false);
|
|
47
|
+
server.once('error', failed);
|
|
48
|
+
server.listen(port, host, () => { server.off('error', failed); resolve(true); });
|
|
49
|
+
});
|
|
50
|
+
if (bound) return { url: `http://${host === '::1' ? '[::1]' : host}:${port}`, port, close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()); }) };
|
|
51
|
+
}
|
|
52
|
+
throw new Error('No free port for the run site preview.');
|
|
53
|
+
}
|