@runeditrun/rer 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -2
- 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 +19 -11
- 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 +115 -2
- 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 +25 -23
- package/global/launch/door.mjs +36 -0
- package/global/launch/facts.mjs +3 -2
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +41 -6
- package/global/launch/render.mjs +6 -1
- package/global/launch/state.mjs +10 -1
- package/global/launch/steps.mjs +24 -10
- package/global/launch/stop.mjs +89 -0
- package/global/launch/worker.mjs +30 -11
- 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
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));
|
|
@@ -167,11 +170,16 @@ export async function serverRegistry(root,{env=process.env,memory=availableMemor
|
|
|
167
170
|
throw refuse('The existing server is still starting. Wait for its ready output.');
|
|
168
171
|
}
|
|
169
172
|
return {checkout,existing,stop,reserve,serve,update,updateActivity,waitReady,custodyToken:()=>owned?.token,
|
|
173
|
+
// The sockets close even when the record cannot be removed (its registry
|
|
174
|
+
// deleted under it, say): an open listener would keep this process alive.
|
|
170
175
|
close(){return closing??=(async()=>{
|
|
171
|
-
if(owned)await change(state=>{state.running=state.running.filter(item=>item.generation!==owned.generation);});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
try{if(owned)await change(state=>{state.running=state.running.filter(item=>item.generation!==owned.generation);});}
|
|
177
|
+
finally{
|
|
178
|
+
try{
|
|
179
|
+
if(control)await new Promise(resolve=>control.close(resolve));
|
|
180
|
+
if(owned)await unlink(owned.control).catch(error=>{if(error.code!=='ENOENT')throw error;});
|
|
181
|
+
}finally{await lifetime.close();}
|
|
182
|
+
}
|
|
175
183
|
})();}
|
|
176
184
|
};
|
|
177
185
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Child of `rer build --site` and `rer dev`: builds the seed's declared site
|
|
3
|
+
// entry with the checkout's own Vite for Node (an SSR build), loads it and
|
|
4
|
+
// calls its sitePages({ styles }), then writes the static run site: "/" to
|
|
5
|
+
// index.html, "/run" to run.html, "/docs/a" to docs/a.html, "/404" to
|
|
6
|
+
// 404.html, the declared public files, and the stylesheet and files the
|
|
7
|
+
// entry's CSS imports compile to. The optional site config is a Vite config
|
|
8
|
+
// naming the plugins the site pages need (a docs virtual module, a CSS
|
|
9
|
+
// toolchain); the app's own Vite config is never loaded, because its Worker
|
|
10
|
+
// plugins do not run in Node. It reads no installation data.
|
|
11
|
+
// Usage: node site-render.mjs ROOT ENTRY OUTPUT [CONFIG]
|
|
12
|
+
import { cp, lstat, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const [root, entry, output, config] = process.argv.slice(2);
|
|
18
|
+
const fail = message => { process.stderr.write(`${message}\n`); process.exit(2); };
|
|
19
|
+
if (!root || !entry || !output) fail('Usage: site-render.mjs ROOT ENTRY OUTPUT [CONFIG]');
|
|
20
|
+
const inside = (base, path) => { const target = resolve(base, path); return target === base || target.startsWith(base + sep) ? target : null; };
|
|
21
|
+
// Compiled CSS and the files it references land under this folder of the site.
|
|
22
|
+
const ASSETS = 'site-assets';
|
|
23
|
+
|
|
24
|
+
let vite;
|
|
25
|
+
try { vite = await import(pathToFileURL(createRequire(join(root, 'package.json')).resolve('vite')).href); }
|
|
26
|
+
catch { fail('The run site renders with the checkout\'s own Vite. Run rer install first.'); }
|
|
27
|
+
process.env.NODE_ENV ??= 'production';
|
|
28
|
+
|
|
29
|
+
// The Node bundle sits beside the output, inside the checkout, so its external
|
|
30
|
+
// imports resolve from the checkout's node_modules. It never enters the site.
|
|
31
|
+
const server = `${output}.render`;
|
|
32
|
+
let module, styles = [], files = [];
|
|
33
|
+
try {
|
|
34
|
+
const built = await vite.build({
|
|
35
|
+
root, configFile: config ? resolve(root, config) : false, envFile: false, logLevel: 'error', mode: 'production',
|
|
36
|
+
build: {
|
|
37
|
+
ssr: resolve(root, entry), outDir: server, emptyOutDir: true, copyPublicDir: false, ssrEmitAssets: true,
|
|
38
|
+
cssCodeSplit: false, assetsDir: ASSETS, sourcemap: false, minify: false, reportCompressedSize: false, watch: null,
|
|
39
|
+
},
|
|
40
|
+
}).catch(error => fail(`The run site entry ${entry} did not build: ${error.message.split('\n')[0]}${config ? '' : ' If its pages need Vite plugins (a docs virtual module, a CSS toolchain), name a site Vite config: site: { config } in the command adapter.'}`));
|
|
41
|
+
const outputs = (Array.isArray(built) ? built : [built]).flatMap(result => result.output ?? []);
|
|
42
|
+
const chunk = outputs.find(item => item.type === 'chunk' && item.isEntry);
|
|
43
|
+
if (!chunk) fail(`The run site entry ${entry} produced no module.`);
|
|
44
|
+
files = outputs.filter(item => item.type === 'asset' && item.fileName.startsWith(`${ASSETS}/`)).map(item => item.fileName);
|
|
45
|
+
styles = files.filter(name => name.endsWith('.css')).map(name => `/${name}`);
|
|
46
|
+
await writeFile(join(server, 'package.json'), '{"type":"module"}\n');
|
|
47
|
+
module = await import(pathToFileURL(join(server, chunk.fileName)).href);
|
|
48
|
+
if (typeof module.sitePages !== 'function') fail(`${entry} must export sitePages(), returning { pages: [{ path, html }], assets: [public paths] }.`);
|
|
49
|
+
const build = await module.sitePages({ styles });
|
|
50
|
+
if (!Array.isArray(build?.pages) || !Array.isArray(build.assets)) fail('sitePages() returns { pages, assets }.');
|
|
51
|
+
|
|
52
|
+
const written = new Set();
|
|
53
|
+
for (const page of build.pages) {
|
|
54
|
+
if (typeof page?.path !== 'string' || !/^\/(?:[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*)?$/i.test(page.path) || page.path.endsWith('.html') || typeof page.html !== 'string') fail(`Invalid site page ${JSON.stringify(page?.path)}: a path such as /run with its HTML.`);
|
|
55
|
+
const file = page.path === '/' ? 'index.html' : `${page.path.slice(1)}.html`;
|
|
56
|
+
if (written.has(file) || file.startsWith(`${ASSETS}/`)) fail(`Duplicate site page ${page.path}.`);
|
|
57
|
+
written.add(file);
|
|
58
|
+
await mkdir(dirname(join(output, file)), { recursive: true });
|
|
59
|
+
await writeFile(join(output, file), page.html);
|
|
60
|
+
}
|
|
61
|
+
const publicDirectory = resolve(root, 'public');
|
|
62
|
+
for (const asset of build.assets) {
|
|
63
|
+
const source = typeof asset === 'string' && !asset.split('/').some(part => !part || part.startsWith('.')) && inside(publicDirectory, asset);
|
|
64
|
+
if (!source || source === publicDirectory) fail(`Invalid site asset ${JSON.stringify(asset)}: a file or folder under public/.`);
|
|
65
|
+
if (asset === ASSETS || asset.startsWith(`${ASSETS}/`)) fail(`public/${ASSETS} is reserved for the run site's compiled styles; rename it.`);
|
|
66
|
+
await lstat(source).catch(() => fail(`Missing site asset public/${asset}.`));
|
|
67
|
+
await cp(source, join(output, asset), { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true });
|
|
68
|
+
}
|
|
69
|
+
// Only the emitted files: the Node chunks share the folder and never ship.
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
await mkdir(dirname(join(output, file)), { recursive: true });
|
|
72
|
+
await cp(join(server, file), join(output, file), { errorOnExist: true, force: false });
|
|
73
|
+
}
|
|
74
|
+
process.stdout.write(`${JSON.stringify({ pages: build.pages.length, assets: build.assets.length, styles: styles.length })}\n`);
|
|
75
|
+
} finally { await rm(server, { recursive: true, force: true }); }
|
|
76
|
+
// Vite and the loaded pages can keep handles open; every file is written.
|
|
77
|
+
process.exit(0);
|
package/cli/site.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile, rename, rm, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { contained } from './project.mjs';
|
|
5
|
+
import { invalid, missing, refuse, RerError } from './errors.mjs';
|
|
6
|
+
import { checkSite } from './site-check.mjs';
|
|
7
|
+
import { readDevKey } from './dev-identity.mjs';
|
|
8
|
+
import { shared } from './shared.mjs';
|
|
9
|
+
|
|
10
|
+
// The run site: a seed's static marketing pages, /run and docs, built from
|
|
11
|
+
// edition identity with no installation data. A seed opts in with
|
|
12
|
+
// commandAdapter's `site: { entry, output, declaration, config? }`: the module
|
|
13
|
+
// whose sitePages({ styles }) returns the pages, the ignored folder the build
|
|
14
|
+
// writes, the deploy declaration naming the run site's domain, and optionally
|
|
15
|
+
// the Vite config holding the plugins the site pages need.
|
|
16
|
+
const relativePath = value => typeof value === 'string' && /^[A-Za-z0-9._/-]+$/.test(value) && !value.split('/').some(part => !part || part === '.' || part === '..');
|
|
17
|
+
|
|
18
|
+
export function declaredSite(project) {
|
|
19
|
+
const site = project.adapter?.site;
|
|
20
|
+
if (!site) throw refuse('This checkout declares no run site. A seed opts in with site: { entry, output, declaration } in its command adapter; see SEED-COMMAND-CONTRACT.md.');
|
|
21
|
+
if (!relativePath(site.entry) || !relativePath(site.output) || !relativePath(site.declaration) || (site.config !== undefined && !relativePath(site.config)) || ['src', 'public', 'node_modules', 'migrations'].some(top => site.output === top || site.output.startsWith(top + '/'))) throw invalid('The declared run site needs a relative entry, an output folder outside src, public and node_modules, a deploy declaration and, when named, a relative site Vite config.');
|
|
22
|
+
return site;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Owner identity, secrets and the dev key: none may appear in the run site.
|
|
26
|
+
async function privateValues(project) {
|
|
27
|
+
const values = [], names = project.adapter.input?.names ?? {};
|
|
28
|
+
const { localVariables } = await shared('local/environment.mjs');
|
|
29
|
+
const variables = await readFile(join(project.root, project.adapter.hooks?.secrets?.file ?? '.dev.vars'), 'utf8').then(localVariables, () => ({}));
|
|
30
|
+
for (const [key, value] of Object.entries(variables)) if ([names.email, names.name].includes(key) || /SECRET|TOKEN|PASSWORD|_KEY$/.test(key)) values.push(value);
|
|
31
|
+
const answers = await readFile(join(project.root, '.seed/installer/setup-answers.json'), 'utf8').then(JSON.parse, () => ({}));
|
|
32
|
+
for (const key of [names.email, names.name]) if (typeof answers?.[key] === 'string') values.push(answers[key]);
|
|
33
|
+
const key = await readDevKey(project.root).catch(() => null);
|
|
34
|
+
if (key) values.push(key);
|
|
35
|
+
return values;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render the run site into its declared folder, check it and replace the previous build. */
|
|
39
|
+
export async function buildSite(project, store, signal) {
|
|
40
|
+
const site = declaredSite(project);
|
|
41
|
+
await contained(project.root, site.entry);
|
|
42
|
+
if (site.config) await contained(project.root, site.config);
|
|
43
|
+
const folder = await contained(project.root, site.output, { optional: true });
|
|
44
|
+
await mkdir(dirname(folder), { recursive: true });
|
|
45
|
+
const staging = `${folder}.building-${process.pid}`;
|
|
46
|
+
await rm(staging, { recursive: true, force: true });
|
|
47
|
+
try {
|
|
48
|
+
await mkdir(staging);
|
|
49
|
+
await store.run('Building the run site', process.execPath, [fileURLToPath(new URL('./site-render.mjs', import.meta.url)), project.root, site.entry, staging, ...(site.config ? [site.config] : [])], { cwd: project.root, signal, heavy: false });
|
|
50
|
+
const checked = await checkSite(staging, { privateValues: await privateValues(project) });
|
|
51
|
+
await rm(folder, { recursive: true, force: true });
|
|
52
|
+
await rename(staging, folder);
|
|
53
|
+
return { folder, ...checked };
|
|
54
|
+
} finally { await Promise.all([staging, `${staging}.render`].map(path => rm(path, { recursive: true, force: true }))); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function siteDeployment(project, options, folder) {
|
|
58
|
+
const site = declaredSite(project), environment = options.environment ?? 'production';
|
|
59
|
+
const read = async path => { try { return JSON.parse(await readFile(await contained(project.root, path), 'utf8')); } catch (error) { if (error instanceof RerError) throw error; throw invalid(`Malformed ${path}.`); } };
|
|
60
|
+
const app = await read(project.adapter.deploy?.declaration ?? 'deploy.json'), declaration = await read(site.declaration);
|
|
61
|
+
const { sitePlan } = await import('../modules/deploy-kit/site.mjs');
|
|
62
|
+
try { return sitePlan({ project: app.project, environment, compatibilityDate: app.compatibilityDate, declaration, assets: folder }); }
|
|
63
|
+
catch (error) { throw invalid(`The run site deploy declaration is invalid (${error.message}). ${site.declaration} holds {"version": 1, "domain": "<apex hostname>"}.`); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `rer build --site` and `rer deploy --site [--dry-run]`. */
|
|
67
|
+
export async function siteCommand(project, options, store, signal, out) {
|
|
68
|
+
const built = await buildSite(project, store, signal);
|
|
69
|
+
if (options.verb === 'build') {
|
|
70
|
+
// Output redaction masks an inherited PWD (a credential-shaped name), so the
|
|
71
|
+
// checkout-relative folder is named too.
|
|
72
|
+
const path = relative(project.root, built.folder);
|
|
73
|
+
if (!options.json) out.human(`Run site written to ${path} (${built.pages} pages, ${built.files} files): ${built.folder}`);
|
|
74
|
+
return { site: { folder: built.folder, path, pages: built.pages, files: built.files } };
|
|
75
|
+
}
|
|
76
|
+
const plan = await siteDeployment(project, options, built.folder);
|
|
77
|
+
const human = [`Run site plan: site Worker ${plan.worker}, Base-generated, its only binding the assets (the app's Worker ${plan.appWorker} is not touched)`, ` assets: ${plan.assets} (${built.files} files; HTML ${plan.assetsConfig.html_handling}, not found ${plan.assetsConfig.not_found_handling}; the Worker runs first)`,
|
|
78
|
+
` custom domains: ${plan.hostnames.join(', ')}; workers.dev off`, ...plan.redirects.map(rule => ` redirect in the Worker: ${rule.from} -> ${rule.to} (${rule.status}, keeps ${rule.keeps.join(' and ')})`)];
|
|
79
|
+
const summary = { worker: plan.worker, appWorker: plan.appWorker, module: plan.module, bindings: plan.bindings, assets: plan.assets, files: built.files, domain: plan.domain, hostnames: plan.hostnames, workersDev: false, runWorkerFirst: true, assetsConfig: plan.assetsConfig, redirects: plan.redirects.map(({ from, to, status }) => ({ from, to, status })) };
|
|
80
|
+
if (options.dryRun) {
|
|
81
|
+
if (options.json) out.event('site', summary); else for (const line of human) out.human(line);
|
|
82
|
+
store.report('deploy --site: dry run complete; no deployment occurred and no provider request was made.');
|
|
83
|
+
return { dryRun: true, deployed: false, site: summary };
|
|
84
|
+
}
|
|
85
|
+
const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
|
|
86
|
+
if (names.some(name => !process.env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required, with the run site token permissions in the deploy-kit README.');
|
|
87
|
+
if (!options.json) for (const line of human) out.human(line);
|
|
88
|
+
const { cloudflare } = await import('../modules/deploy-kit/provider.mjs');
|
|
89
|
+
const { collectFiles } = await import('../modules/deploy-kit/artifact.mjs');
|
|
90
|
+
const { siteDeploy } = await import('../modules/deploy-kit/site.mjs');
|
|
91
|
+
const environment = options.environment ?? 'production';
|
|
92
|
+
try {
|
|
93
|
+
const api = cloudflare({ accountId: process.env.CLOUDFLARE_ACCOUNT_ID, token: process.env.CLOUDFLARE_API_TOKEN });
|
|
94
|
+
// The receipt is for the person; ownership is read from the provider, so a fresh checkout deploys too.
|
|
95
|
+
const result = await siteDeploy({ api, plan, files: await collectFiles(built.folder), receiptPath: join(project.root, '.rer', 'deploy', `site-${environment}.json`) });
|
|
96
|
+
return { dryRun: false, deployed: true, origin: result.origin, site: summary };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof RerError) throw error;
|
|
99
|
+
throw new RerError('deploy_refused', `Run site deploy stopped: ${/^[a-z0-9-]{1,80}$/.test(error.message) ? error.message : 'site-deploy-failed'}${error.resource ? ` (${error.resource})` : ''}.`, 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** For rer dev: build the run site and serve it on the first free port above the app's. */
|
|
104
|
+
export async function devSite(project, store, signal, { host, port }) {
|
|
105
|
+
const built = await buildSite(project, store, signal);
|
|
106
|
+
const { sitePreview } = await import('./site-preview.mjs');
|
|
107
|
+
return { ...await sitePreview(built.folder, { host, ports: Array.from({ length: 50 }, (_, index) => port + 1 + index).filter(value => value <= 65535) }), folder: relative(project.root, built.folder) };
|
|
108
|
+
}
|
|
109
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
What changes for a user since 0.1.1.
|
|
6
|
+
|
|
7
|
+
### Package mode
|
|
8
|
+
|
|
9
|
+
- A first-party seed pins `@runeditrun/rer` as an exact devDependency instead of
|
|
10
|
+
carrying `scripts/rer.mjs`, `scripts/base-cli` and `scripts/base-onboarding`.
|
|
11
|
+
`node scripts/export-cli.mjs --to-package SEED_ROOT [--version X]` converts a
|
|
12
|
+
vendored seed (it refuses local edits and lists them).
|
|
13
|
+
- `rer check` and `rer describe` in a checkout whose `node_modules` lacks its
|
|
14
|
+
pin install it with the frozen lockfile first, then run the pinned verb, so a
|
|
15
|
+
fresh clone checks with the pinned bytes. `rer check --profile first-party`
|
|
16
|
+
accepts package mode (SC-13) and still accepts vendored mode.
|
|
17
|
+
- Named exports for every Base file a seed's adapter, Vite config, build or
|
|
18
|
+
package script imports; nothing needs `internal/`.
|
|
19
|
+
|
|
20
|
+
### Lobby, homepage variants, setup state
|
|
21
|
+
|
|
22
|
+
- The Lobby (the pre-claim page) and `GET /setup/state`; a seed supplies its own
|
|
23
|
+
first-run read, and the fonts stylesheet its edition names.
|
|
24
|
+
- Homepage variants and a hydration check that follows a redirecting `/`, counts
|
|
25
|
+
cross-origin client scripts and fails an edit case React hydrated too early to test.
|
|
26
|
+
|
|
27
|
+
### Run sites
|
|
28
|
+
|
|
29
|
+
- `rer build --site` builds the seed's run site (with its own Vite config and
|
|
30
|
+
CSS) and `rer deploy --site` deploys it. Ownership is proven from the provider
|
|
31
|
+
alone (the latest deployment serves the newest version, which carries the
|
|
32
|
+
kit's annotation), so a fresh CI checkout redeploys; a foreign Worker or
|
|
33
|
+
domain is refused.
|
|
34
|
+
- The site Worker is Base-generated: `www.<domain>` answers a 301 to the apex
|
|
35
|
+
(path and query kept) and everything else comes from assets. No zone ruleset
|
|
36
|
+
is read or written.
|
|
37
|
+
- The publication scan accepts the run-NAME repository names the owner
|
|
38
|
+
proposed (for example run-lamp under the just-every account) as intended
|
|
39
|
+
public destinations; the earlier names stay valid.
|
|
40
|
+
|
|
41
|
+
### Local development hardening
|
|
42
|
+
|
|
43
|
+
- A request with a foreign `Host` gets a plain 421; keyless `/@fs` and any
|
|
44
|
+
path Vite would answer with its 403 "Restricted" page (which lists checkout
|
|
45
|
+
and home paths) get 404. The dev key opens them.
|
|
46
|
+
- `rer` fetches the pinned pnpm from the npm registry, checked against its
|
|
47
|
+
integrity hash, when PATH lacks it.
|
|
48
|
+
- An explicit `RER_SERVER_LIMIT` caps its own start, higher or lower.
|
|
49
|
+
- `rer door`: keys never go through the door, a moved checkout keeps its name,
|
|
50
|
+
a restart hands starts over, a missing folder is a plain page.
|
|
51
|
+
|
|
52
|
+
### Hosted output
|
|
53
|
+
|
|
54
|
+
- Built `.html` files are served by the hosted Worker (`staticAssetResponse`),
|
|
55
|
+
including revalidation (`If-None-Match` answers 304). The hosted check
|
|
56
|
+
requests every built file and sends one conditional request per `.html`.
|
|
57
|
+
- A deploy secret may carry `verifyStandIn` for the hosted check.
|
|
58
|
+
- Packed deploy assets leave out `.assetsignore` and what it names.
|
|
59
|
+
|
|
60
|
+
### Fixes
|
|
61
|
+
|
|
62
|
+
- Setup reads the seed's declared settings format and session-secret name.
|
|
63
|
+
- Large stores export, and a fresh install recovers a legacy backup.
|