@runeditrun/rer 0.2.2 → 0.2.4
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 +9 -2
- package/cli/README.md +21 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +52 -37
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +20 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +46 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +27 -0
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +7 -2
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +5 -2
- package/modules/deploy-kit/owner-secret.mjs +67 -22
- package/modules/deploy-kit/owner.mjs +71 -24
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
package/global/launch/facts.mjs
CHANGED
|
@@ -76,14 +76,20 @@ async function bindable(host, port) {
|
|
|
76
76
|
finally { if (socket.listening) await new Promise(resolve => socket.close(resolve)); }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
// Whether something now listens on a port or keeps it from binding.
|
|
80
|
+
export async function portTaken(port) {
|
|
81
|
+
return (await listeningPorts()).has(port) || !await bindable('127.0.0.1', port) || !await bindable('::1', port);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The first port from 8787 (RER_DEV_PORT_FROM moves the start) that is not
|
|
85
|
+
// `held` (by a registered server, or saved by another checkout that is stopped),
|
|
86
|
+
// nothing listens on, binds on both 127.0.0.1 and ::1 (a browser that tries
|
|
87
|
+
// ::1 first never reaches another server) and that `claim` secures against
|
|
88
|
+
// another launch choosing it at the same time.
|
|
89
|
+
export async function freePort(ports, claim, env = process.env) {
|
|
84
90
|
const from = Number(env.RER_DEV_PORT_FROM ?? 8787);
|
|
85
91
|
if (!Number.isInteger(from) || from < 1 || from > 65535) throw new LaunchFailure(2, 'RER_DEV_PORT_FROM must be a port number.');
|
|
86
|
-
const held = new Set(
|
|
92
|
+
const held = new Set(ports.filter(Boolean)), busy = await listeningPorts();
|
|
87
93
|
for (let port = from; port <= 65535; port++) {
|
|
88
94
|
if (held.has(port) || busy.has(port)) continue;
|
|
89
95
|
if (!await bindable('127.0.0.1', port) || !await bindable('::1', port)) continue;
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
// A checkout that pins the @runeditrun/rer package runs its own rer once
|
|
2
2
|
// node_modules holds the pinned version; until then none of it can run (its
|
|
3
3
|
// adapter imports the package). So its first dependency install is this
|
|
4
|
-
// launcher's: the frozen pnpm install that `rer install` runs.
|
|
4
|
+
// launcher's: the frozen pnpm install that `rer install` runs. Like every
|
|
5
|
+
// dependency install, it runs with an allowlisted environment. Verbs started
|
|
6
|
+
// at once share one install: each takes the checkout's install claim and
|
|
7
|
+
// installs only if the pin is still missing once it holds it.
|
|
5
8
|
import { spawn } from 'node:child_process';
|
|
9
|
+
import { realpath } from 'node:fs/promises';
|
|
10
|
+
import { dependencyEnvironment } from '../../onboarding/dependency-environment.mjs';
|
|
6
11
|
import { pinnedPackage } from '../checkout.mjs';
|
|
7
12
|
import { packageName } from '../package-info.mjs';
|
|
8
13
|
import { ensureProjectPnpm } from '../../cli/project-pnpm.mjs';
|
|
9
14
|
import { RerError } from '../../cli/errors.mjs';
|
|
10
15
|
import { LaunchFailure } from './failure.mjs';
|
|
16
|
+
import { claimPinInstall, settledPinInstall } from './state.mjs';
|
|
17
|
+
|
|
18
|
+
const waiting = (root, output) => pid => output(`Another rer (PID ${pid}) is installing ${root}'s pinned ${packageName}; waiting for it.\n`);
|
|
11
19
|
|
|
12
20
|
export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
|
|
13
21
|
signal?.throwIfAborted();
|
|
22
|
+
const release = await claimPinInstall(await realpath(root), { env, signal, onWait: waiting(root, output) });
|
|
23
|
+
try {
|
|
24
|
+
const pinned = pinnedPackage(root);
|
|
25
|
+
if (pinned?.ready) return;
|
|
26
|
+
if (pinned?.installed) output(`${root} pins ${packageName} ${pinned.declared}, but node_modules holds ${pinned.installed}; installing the pinned version first.\n`);
|
|
27
|
+
await install(root, { env, output, children, signal });
|
|
28
|
+
} finally { await release(); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A verb on a checkout whose pin looks installed still waits for an install
|
|
32
|
+
// another verb is running there: node_modules is not complete until it ends.
|
|
33
|
+
export async function settledPin(root, { env = process.env, output = text => process.stderr.write(text), signal } = {}) {
|
|
34
|
+
await settledPinInstall(await realpath(root), { env, signal, onWait: waiting(root, output) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function install(root, { env, output, children, signal }) {
|
|
14
38
|
try { await ensureProjectPnpm(root, { env, signal, report: text => output(text + '\n') }); }
|
|
15
39
|
catch (error) { if (error instanceof RerError) throw new LaunchFailure(error.exitCode, error.message); throw error; }
|
|
16
|
-
const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...env, CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
40
|
+
const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...dependencyEnvironment(env), CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
41
|
children?.add(child);
|
|
18
42
|
if (signal?.aborted) child.kill('SIGTERM');
|
|
19
43
|
let tail = '';
|
|
@@ -10,8 +10,8 @@ import { liveServers } from '../registry.mjs';
|
|
|
10
10
|
import { checkCapacity, personEnvironment, pinPolicy } from './capacity.mjs';
|
|
11
11
|
import { bringBackDoor, doorPlan } from './door.mjs';
|
|
12
12
|
import { LaunchFailure } from './failure.mjs';
|
|
13
|
-
import { describeCheckout, freePort, localOwner, needsInstall, setupNeeded } from './facts.mjs';
|
|
14
|
-
import { claimPort, installState, setupState } from './state.mjs';
|
|
13
|
+
import { describeCheckout, freePort, localOwner, needsInstall, portTaken, setupNeeded } from './facts.mjs';
|
|
14
|
+
import { claimPort, installState, savedPorts, setupState } from './state.mjs';
|
|
15
15
|
import { runVerb } from './steps.mjs';
|
|
16
16
|
import { localDefaults } from './local-defaults.mjs';
|
|
17
17
|
import { installPinned } from './install-pinned.mjs';
|
|
@@ -74,13 +74,25 @@ export async function prepare({ root, args, env, report, directory, log, childre
|
|
|
74
74
|
return { entry, added };
|
|
75
75
|
} else if (setup === 'fresh') {
|
|
76
76
|
// An interrupted setup resumes on the port it chose while that port is free.
|
|
77
|
+
// A port another process takes between choosing it and setup refusing it is
|
|
78
|
+
// passed over for the next free one; a port the person passed is kept.
|
|
77
79
|
const owner = localOwner(checkout), explicit = flagValue(args, '--port');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
let from = last?.port;
|
|
81
|
+
for (let attempt = 1; ; attempt++) {
|
|
82
|
+
const held = explicit ? [] : [...(await liveServers(childEnv)).map(record => record.port), ...await savedPorts(checkout, childEnv)];
|
|
83
|
+
const { port, release } = explicit ? { port: Number(explicit) } : await freePort(held, port => claimPort(port, childEnv), from ? { ...childEnv, RER_DEV_PORT_FROM: String(from) } : childEnv);
|
|
84
|
+
report({ phase: 'setup', message: `setting up a local owner (${owner}) on port ${port}` });
|
|
85
|
+
try { await step(setupState, ['setup', '--owner-email', owner, '--port', String(port), ...hostArgs], { port, owner }, setupEnv); }
|
|
86
|
+
catch (failure) {
|
|
87
|
+
await release?.();
|
|
88
|
+
if (explicit || attempt === 3 || failure.exitCode !== 4 || !await portTaken(port)) throw failure;
|
|
89
|
+
report({ phase: 'note', message: `port ${port} was taken by another process; choosing another free port` });
|
|
90
|
+
from = port + 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// The claim lasts until the server registers the port: the caller releases it.
|
|
94
|
+
return { entry, added, release };
|
|
95
|
+
}
|
|
84
96
|
}
|
|
85
97
|
await bringBackDoor(plan, env, report);
|
|
86
98
|
return { entry, added };
|
package/global/launch/state.mjs
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// server.log the private log of the server rer started
|
|
9
9
|
// server.json that server's PID and start time, and the process rer spawned
|
|
10
10
|
// stop.json when rer dev --stop last ran: a launch requested before it stops
|
|
11
|
+
// pin-install.json the process installing the checkout's pinned rer package
|
|
11
12
|
import { createHash } from 'node:crypto';
|
|
12
|
-
import { appendFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { appendFile, link, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
13
14
|
import { basename, join } from 'node:path';
|
|
14
15
|
import { processInfo } from '../../modules/job-queue/processes.mjs';
|
|
15
16
|
import { serversDirectory } from '../registry.mjs';
|
|
@@ -55,6 +56,43 @@ export async function claimPort(port, env = process.env) {
|
|
|
55
56
|
return undefined;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// One pinned-package install per checkout at a time: two verbs started at once
|
|
60
|
+
// after a pin change share it. claimPinInstall resolves to a release function
|
|
61
|
+
// once this process holds the claim; both wait (onWait once) while another
|
|
62
|
+
// live process holds it.
|
|
63
|
+
const pinInstallPath = (checkout, env) => join(launchPath(checkout, env), 'pin-install.json');
|
|
64
|
+
const pause = () => new Promise(resolveWait => setTimeout(resolveWait, 200));
|
|
65
|
+
|
|
66
|
+
export async function claimPinInstall(checkout, { env = process.env, signal, onWait } = {}) {
|
|
67
|
+
await launchDirectory(checkout, env);
|
|
68
|
+
const path = pinInstallPath(checkout, env), { start } = await processInfo(process.pid);
|
|
69
|
+
for (let waited = false; ; ) {
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
// Linked complete, so a waiter never reads a claim still being written.
|
|
72
|
+
const written = `${path}.${process.pid}`;
|
|
73
|
+
await writeFile(written, JSON.stringify({ pid: process.pid, start }), { mode: 0o600 });
|
|
74
|
+
try {
|
|
75
|
+
await link(written, path);
|
|
76
|
+
return async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); };
|
|
77
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
78
|
+
finally { await rm(written, { force: true }); }
|
|
79
|
+
const holder = await readJson(path);
|
|
80
|
+
if (!await live(holder)) { await rm(path, { force: true }); continue; }
|
|
81
|
+
if (!waited) { waited = true; onWait?.(holder.pid); }
|
|
82
|
+
await pause();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function settledPinInstall(checkout, { env = process.env, signal, onWait } = {}) {
|
|
87
|
+
for (let waited = false; ; ) {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
const holder = await readJson(pinInstallPath(checkout, env));
|
|
90
|
+
if (!await live(holder)) return;
|
|
91
|
+
if (!waited) { waited = true; onWait?.(holder.pid); }
|
|
92
|
+
await pause();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
58
96
|
// The worker claims the checkout's launch; a second worker started at the
|
|
59
97
|
// same moment finds the first and leaves.
|
|
60
98
|
export async function claimLaunch(directory, token) {
|
|
@@ -92,6 +130,21 @@ const stepRecord = name => ({
|
|
|
92
130
|
});
|
|
93
131
|
export const installState = stepRecord('install'), setupState = stepRecord('setup');
|
|
94
132
|
|
|
133
|
+
// The ports other checkouts were set up on by this launcher. A stopped
|
|
134
|
+
// checkout keeps its saved origin, so a fresh launch must not take its port.
|
|
135
|
+
export async function savedPorts(checkout, env = process.env) {
|
|
136
|
+
const launches = join(serversDirectory(env), 'launches'), own = launchPath(checkout, env);
|
|
137
|
+
let entries = [];
|
|
138
|
+
try { entries = await readdir(launches, { withFileTypes: true }); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
139
|
+
const ports = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isDirectory() || join(launches, entry.name) === own) continue;
|
|
142
|
+
const port = (await setupState.read(join(launches, entry.name)))?.port;
|
|
143
|
+
if (Number.isInteger(port)) ports.push(port);
|
|
144
|
+
}
|
|
145
|
+
return ports;
|
|
146
|
+
}
|
|
147
|
+
|
|
95
148
|
// A stop asks every launch started before it to end. A worker reads this right
|
|
96
149
|
// after it claims the launch; a stop that ran before the claim found no worker
|
|
97
150
|
// to signal, so the worker ends itself here instead of starting anything.
|
package/global/main.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
import { verbs } from '../cli/contract.mjs';
|
|
1
2
|
import { dispatcher, locate, pinnedPackage, unsupportedMessage } from './checkout.mjs';
|
|
2
3
|
import { delegate } from './delegate.mjs';
|
|
3
4
|
import { localDefaults } from './launch/local-defaults.mjs';
|
|
4
5
|
import { handleBrokenPipes } from './output.mjs';
|
|
5
|
-
import { packageName as pinnedName } from './package-info.mjs';
|
|
6
6
|
|
|
7
7
|
const help = `Usage: rer VERB [FLAGS]
|
|
8
8
|
|
|
9
9
|
Inside a project (the nearest OPERATIONS.json at or above this folder, or --cwd DIR),
|
|
10
10
|
every verb runs that checkout's own pinned rer: the @runeditrun/rer version its package.json
|
|
11
|
-
pins (installed first by
|
|
11
|
+
pins (installed first by any verb when node_modules holds another), else its vendored scripts/rer.mjs.
|
|
12
12
|
|
|
13
13
|
rer dev Launch a first-party checkout with no setup steps: installs dependencies,
|
|
14
14
|
sets up a local owner and a free port when needed, starts the app and prints
|
|
@@ -30,12 +30,13 @@ pins (installed first by rer install, rer setup or rer dev), else its vendored s
|
|
|
30
30
|
Run COMMAND once this computer's job queue admits it (heavy package scripts
|
|
31
31
|
of a checkout that pins the rer package use it).
|
|
32
32
|
|
|
33
|
-
Checkout verbs: install, setup, dev, preview, build, verify, reset, deploy, export, import,
|
|
33
|
+
Checkout verbs: install, setup, dev, demo, preview, build, verify, reset, deploy, export, import,
|
|
34
34
|
outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for its flags.
|
|
35
35
|
`;
|
|
36
36
|
|
|
37
|
-
// The verbs that install
|
|
38
|
-
|
|
37
|
+
// The checkout verbs that install the pinned package before they run when
|
|
38
|
+
// node_modules holds another version or none (dev installs it in its launch).
|
|
39
|
+
const installsFirst = new Set([...Object.keys(verbs), 'release'].filter(verb => verb !== 'dev'));
|
|
39
40
|
|
|
40
41
|
export async function main(argv) {
|
|
41
42
|
handleBrokenPipes();
|
|
@@ -86,23 +87,20 @@ export async function main(argv) {
|
|
|
86
87
|
return (await import('../onboarding/queued-entry.mjs')).queuedCommandLine(argv.slice(1));
|
|
87
88
|
}
|
|
88
89
|
// A checkout that pins its own rer package runs all of it, launching included.
|
|
89
|
-
// Until node_modules holds the pinned version
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
90
|
+
// Until node_modules holds the pinned version (a fresh clone, or a pulled pin
|
|
91
|
+
// change), every checkout verb installs it with the frozen lockfile and then
|
|
92
|
+
// runs the pinned verb, so a fresh clone checks and a changed pin deploys with
|
|
93
|
+
// the pinned bytes; the zero-setup launch installs it first.
|
|
94
|
+
// Verbs started at once share one install, and one whose pin already looks
|
|
95
|
+
// installed waits for an install still running.
|
|
93
96
|
const pinned = first && mode !== 'package' ? pinnedPackage(target.root) : undefined;
|
|
94
|
-
if (pinned && !pinned.ready && !argv.includes('--help')) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return delegate(target, target.args);
|
|
100
|
-
}
|
|
101
|
-
if (verb !== 'dev') {
|
|
102
|
-
process.stderr.write(`${target.root} pins ${pinnedName} ${pinned.declared}, ${pinned.installed ? `but node_modules holds ${pinned.installed}` : 'which is not installed yet'}. Run rer install, then rer ${verb} again.\n`);
|
|
103
|
-
return 3;
|
|
104
|
-
}
|
|
97
|
+
if (pinned && !pinned.ready && !argv.includes('--help') && installsFirst.has(verb)) {
|
|
98
|
+
const { installPinned } = await import('./launch/install-pinned.mjs'), { LaunchFailure } = await import('./launch/failure.mjs');
|
|
99
|
+
try { await installPinned(target.root); }
|
|
100
|
+
catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
|
|
101
|
+
return delegate(target, target.args);
|
|
105
102
|
}
|
|
103
|
+
if (mode === 'package' && installsFirst.has(verb)) await (await import('./launch/install-pinned.mjs')).settledPin(target.root);
|
|
106
104
|
if (verb === 'dev' && mode && mode !== 'package' && !argv.includes('--help')) return (await import('./launch/dev.mjs')).launchDev(target);
|
|
107
105
|
// Every other server start or queued job on a vendored pin gets the person
|
|
108
106
|
// defaults too (a pinned package applies its own).
|
|
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { basename, dirname, join } from 'node:path';
|
|
11
|
+
import { dependencyEnvironment } from '../../onboarding/dependency-environment.mjs';
|
|
11
12
|
import { layout } from '../cli/layout.mjs';
|
|
12
13
|
import { channelOf } from '../cli/status.mjs';
|
|
13
14
|
import { ownManifest, ownRoot, packageDigest, packageName } from '../package-info.mjs';
|
|
@@ -47,7 +48,7 @@ async function npmGlobal(version) {
|
|
|
47
48
|
const command = `npm ${args.map(arg => /^[\w@./:=-]+$/.test(arg) ? arg : JSON.stringify(arg)).join(' ')}`;
|
|
48
49
|
process.stdout.write(`Updating the npm global rer: ${command}\n`);
|
|
49
50
|
let code;
|
|
50
|
-
try { code = await run('npm', args); } catch (error) { code = 127; process.stderr.write(`Could not run npm (${error.code ?? error.message}).\n`); }
|
|
51
|
+
try { code = await run('npm', args, dependencyEnvironment()); } catch (error) { code = 127; process.stderr.write(`Could not run npm (${error.code ?? error.message}).\n`); }
|
|
51
52
|
if (code !== 0) {
|
|
52
53
|
process.stderr.write(`npm could not update rer (exit ${code}); rer is unchanged. Fix the problem npm reports (for example, a prefix you cannot write), then run:\n ${command}\n`);
|
|
53
54
|
return code || 1;
|
|
@@ -6,6 +6,7 @@ import { createHash } from 'node:crypto';
|
|
|
6
6
|
import { parseJsonc } from '../../cli/jsonc.mjs';
|
|
7
7
|
import { portabilityPlan } from './portability-schema.mjs';
|
|
8
8
|
import { checkBoundary } from './boundary.mjs';
|
|
9
|
+
import { readMigrationRewrites, withRewrites } from '../deploy-kit/migrations.mjs';
|
|
9
10
|
const hash = source => createHash('sha256').update(source).digest('hex');
|
|
10
11
|
export async function buildContainer(root, options={}) {
|
|
11
12
|
const previous=process.cwd();process.chdir(root);
|
|
@@ -36,12 +37,13 @@ async function buildImage(root, { output = join(root,'.rer/container/image'), en
|
|
|
36
37
|
if (engine === 'workerd') workerConfig = JSON.parse(await readFile(join(root,'dist/server/wrangler.json'),'utf8'));
|
|
37
38
|
else workerConfig = parseJsonc(await readFile(join(root,'wrangler.jsonc'),'utf8'));
|
|
38
39
|
const { unstable_splitSqlQuery:split } = require('wrangler');
|
|
39
|
-
const migrations = {};
|
|
40
|
+
const migrations = {}, rewrites = await readMigrationRewrites(root);
|
|
40
41
|
for (const db of workerConfig.d1_databases ?? []) {
|
|
41
42
|
let directory = resolve(root,db.migrations_dir ?? 'migrations');
|
|
42
43
|
try { await readdir(directory); } catch { directory = resolve(root,'dist/server',db.migrations_dir); }
|
|
43
44
|
migrations[db.binding] = [];
|
|
44
45
|
for (const name of (await readdir(directory)).filter(name => name.endsWith('.sql')).sort()) { const sql = await readFile(join(directory,name),'utf8'); migrations[db.binding].push({ name,sql,sha256:hash(sql),statements:split(sql) }); }
|
|
46
|
+
migrations[db.binding] = withRewrites(migrations[db.binding], rewrites, { partial:true });
|
|
45
47
|
}
|
|
46
48
|
const entry = engine === 'workerd' ? 'dist/server/'+workerConfig.main : 'dist/server/index.mjs';
|
|
47
49
|
await readFile(join(root,entry));
|
|
@@ -62,4 +64,4 @@ async function buildImage(root, { output = join(root,'.rer/container/image'), en
|
|
|
62
64
|
await writeFile(join(output,'Dockerfile'),`FROM node:26.8.1-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends util-linux ca-certificates && apt-get clean\nRUN mkdir -p /private /archive\nWORKDIR /runtime\nCOPY runtime/package.json ./package.json\nRUN npm install --omit=dev --no-audit --no-fund\nCOPY runtime/ /runtime/\nCOPY app/ /app/\nENV NODE_ENV=production PORT=8080 WRANGLER_SEND_METRICS=false\nEXPOSE 8080\nVOLUME /data\nENTRYPOINT ["sh", "/runtime/modules/container/entrypoint.sh"]\n`);
|
|
63
65
|
return { output,manifest };
|
|
64
66
|
}
|
|
65
|
-
if (process.argv[1] === import.meta.filename) { try { const result=await buildContainer(resolve(process.argv[2])); console.log(JSON.stringify({output:result.output,engine:result.manifest.engine})); } catch(cause) { console.error(cause.message); if(cause.failures)console.error(JSON.stringify(cause.failures));process.exitCode=1; } }
|
|
67
|
+
if (process.argv[1] === import.meta.filename) { try { const {output,engine}=JSON.parse(process.argv[3]??'{}'),result=await buildContainer(resolve(process.argv[2]),{...(output?{output:resolve(output)}:{}),...(engine?{engine}:{})}); console.log(JSON.stringify({output:result.output,engine:result.manifest.engine})); } catch(cause) { console.error(cause.message); if(cause.failures)console.error(JSON.stringify(cause.failures));process.exitCode=1; } }
|
|
@@ -18,7 +18,7 @@ export async function startHost({ root = '/app', data = '/data', settings = proc
|
|
|
18
18
|
server = createServer(async (req, res) => {
|
|
19
19
|
try {
|
|
20
20
|
lastRequest=Date.now();
|
|
21
|
-
const request = await incoming(req, { trustedProxy:settings.TRUSTED_PROXY, maxRequestBytes:manifest.maxRequestBytes });
|
|
21
|
+
const request = await incoming(req, { trustedProxy:settings.TRUSTED_PROXY, origin:settings.RER_INGRESS_ORIGIN, maxRequestBytes:manifest.maxRequestBytes });
|
|
22
22
|
let response = await engine.fetch(request);
|
|
23
23
|
if (new URL(request.url).pathname === '/health' && response.ok) {
|
|
24
24
|
const body = await response.json(); const deadJobs = engine.dead();
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
2
|
import { lookup } from 'node:dns/promises';
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
|
-
|
|
4
|
+
// `origin` (RER_INGRESS_ORIGIN) fixes the public origin for a container whose only
|
|
5
|
+
// peer is its own front, such as a hosted demo's Durable Object.
|
|
6
|
+
export async function incoming(req, { trustedProxy, origin, maxRequestBytes = 25 * 1024 * 1024 } = {}) {
|
|
5
7
|
const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(',') : value);
|
|
6
8
|
const peer = req.socket.remoteAddress?.replace('::ffff:', '') ?? '';
|
|
7
9
|
const trusted = trustedProxy && (isIP(trustedProxy) ? peer===trustedProxy : (await lookup(trustedProxy,{all:true})).some(item=>item.address.replace('::ffff:','')===peer));
|
|
8
10
|
let host = req.headers.host, proto = 'http', address = peer;
|
|
9
11
|
if (trusted) { host = req.headers['x-forwarded-host'] ?? host; proto = req.headers['x-forwarded-proto'] ?? proto; address = req.headers['x-forwarded-for']?.split(',').at(-1)?.trim() ?? peer; }
|
|
12
|
+
if (origin) { const fixed = new URL(origin); host = fixed.host; proto = fixed.protocol.slice(0, -1); }
|
|
10
13
|
for (const name of ['cf-connecting-ip','x-forwarded-host','x-forwarded-proto','x-forwarded-for','forwarded','x-rer-original-url']) headers.delete(name);
|
|
11
14
|
if (!['http', 'https'].includes(proto) || typeof host !== 'string' || !/^(?:[a-zA-Z0-9.-]+|\[[0-9a-fA-F:]+\])(?::[0-9]+)?$/.test(host)) throw new Error('Invalid ingress origin');
|
|
12
15
|
headers.set('cf-connecting-ip', address); headers.set('host', host);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { validateMigrations } from '../deploy-kit/migrations.mjs';
|
|
2
|
+
import { recorded } from '../deploy-kit/migration-ledger.mjs';
|
|
2
3
|
export async function migrate(db, migrations, { ledger = '_rer_deploy_migrations' } = {}) {
|
|
3
4
|
if (!['_rer_deploy_migrations', '_rer_portability_migrations'].includes(ledger)) throw new Error('Invalid migration ledger');
|
|
4
5
|
validateMigrations(migrations);
|
|
5
6
|
await db.prepare(`CREATE TABLE IF NOT EXISTS ${ledger}(name TEXT PRIMARY KEY,sha256 TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN ('applying','applied')))`).run();
|
|
6
7
|
const history = (await db.prepare(`SELECT name,sha256,state FROM ${ledger} ORDER BY name`).all()).results;
|
|
7
|
-
if (history.some((row, i) => row
|
|
8
|
+
if (history.some((row, i) => !recorded(row, migrations[i]) || row.state !== 'applied')) throw new Error('changed-or-uncertain-migration-history');
|
|
8
9
|
const legacy = await db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='d1_migrations'").first();
|
|
9
10
|
if (ledger === '_rer_deploy_migrations' && legacy && (await db.prepare('SELECT name FROM d1_migrations ORDER BY id').all()).results.some(row => !history.some(prior => prior.name === row.name))) throw new Error('legacy-migration-history-needs-reviewed-hash-adoption');
|
|
10
11
|
for (const migration of migrations.slice(history.length)) {
|
|
@@ -19,7 +19,9 @@ export async function workerEngine({ root, data, manifest, settings, edge }) {
|
|
|
19
19
|
};
|
|
20
20
|
const mf = new Miniflare(convertV4MiniflareOptions({ host:'127.0.0.1',port:0,cf:false,resourcePersistencePath:data,unsafeLocalExplorer:false,
|
|
21
21
|
workers:[{ name:'ingress',modules:true,script:`export default {fetch(request,env){const observed=request.headers.get('x-rer-original-url');if(!observed)return new Response(null,{status:400});const url=new URL(observed),headers=new Headers(request.headers);headers.delete('x-rer-original-url');headers.set('host',url.host);return env.APP.fetch(new Request(url,{method:request.method,headers,redirect:'manual',...(!['GET','HEAD'].includes(request.method)?{body:request.body}:{})}))}}`,serviceBindings:{ APP:'app' } }, app,
|
|
22
|
-
|
|
22
|
+
// A demo (RER_DEMO=1) sends nothing: its egress worker refuses every request.
|
|
23
|
+
settings.RER_DEMO === '1' ? { name:'egress',modules:true,script:"export default {fetch(request){throw new TypeError('fetch failed: demo egress is denied ('+new URL(request.url).host+')')}}" }
|
|
24
|
+
: { name:'egress',modules:true,script:'export default {fetch(request,env){return env.NETWORK.fetch(request)}}',serviceBindings:{ NETWORK:{ network:{ allow:['public'],tlsOptions:{ trustBrowserCas:true,trustedCertificates:[...rootCertificates] } } } } }],
|
|
23
25
|
}));
|
|
24
26
|
await mf.ready; const databases = {}, objects = {};
|
|
25
27
|
for (const [binding, plan] of Object.entries(manifest.migrations)) { const db = await mf.getD1Database(binding, 'app'); databases[binding] = db; await migrate(db, plan); await ensurePortabilitySchema(db, manifest.portabilityMigrations); }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Hosted demo
|
|
2
|
+
|
|
3
|
+
The front Worker `rer deploy --demo` deploys at `demo.<domain>` for a seed
|
|
4
|
+
that adopted demo mode (`withDemo`, export/DEMO.md). `plan.mjs` turns `deploy-demo.json`
|
|
5
|
+
into the Worker's Wrangler configuration; the CLI (`cli/demo-host.mjs`) writes
|
|
6
|
+
it with these files and the seed's container image folder (workerd engine) to
|
|
7
|
+
`.rer/demo-host/` and runs `wrangler deploy` there.
|
|
8
|
+
|
|
9
|
+
- `worker.mjs`: `GET /` is the start page; `POST /__demo/start` checks the
|
|
10
|
+
exact Origin, the `STARTS` rate limit (3 per IP per 60 s), Turnstile's
|
|
11
|
+
siteverify and capacity, then starts the visitor's demo and answers with the
|
|
12
|
+
owner claim's 303, its session cookie and the `rer_demo` cookie. A request
|
|
13
|
+
with that cookie goes to the visitor's Durable Object; nothing else reaches
|
|
14
|
+
a container.
|
|
15
|
+
- `container.mjs`: `DemoContainer`, one per visitor (`newUniqueId`, checked
|
|
16
|
+
with `idFromString`). It starts the container with `enableInternet: false`,
|
|
17
|
+
`RER_DEMO=1`, the fixed origin (`RER_INGRESS_ORIGIN` and the seed's origin
|
|
18
|
+
variable), fresh `SESSION_SECRET` and `SETUP_TOKEN`,
|
|
19
|
+
`OWNER_EMAIL=owner@demo.invalid` and the owner claim's issuer, installation
|
|
20
|
+
id and public key; waits for `/health`; and posts a five-minute ES256 claim
|
|
21
|
+
(`claim.mjs`) to `/auth/owner-claim`. Its alarm ends the demo at its hour or
|
|
22
|
+
after 15 idle minutes: `destroy()`, `deleteAll()` and the place released. A
|
|
23
|
+
container that stops by itself ends its demo too.
|
|
24
|
+
- `capacity.mjs`: `DemoCapacity` admits at most `max_instances` live demos.
|
|
25
|
+
- `pages.mjs`: the start and ended pages (12ui designs), strict CSP.
|
|
26
|
+
|
|
27
|
+
`node --test modules/demo-host/tests/*.test.mjs` runs the Worker in workerd
|
|
28
|
+
with its real Durable Objects and rate limit; Turnstile and the container
|
|
29
|
+
platform are the only fakes. The end-to-end proof (`wrangler dev` with Docker)
|
|
30
|
+
is in DEMO.md.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
|
|
3
|
+
// One per demo Worker (idFromName('capacity')): the running demos and their
|
|
4
|
+
// ends. A start is admitted while fewer than max_instances demos are live; a
|
|
5
|
+
// demo releases its place when it ends, and a place past its end is free anyway.
|
|
6
|
+
export class DemoCapacity extends DurableObject {
|
|
7
|
+
async acquire(id, ends, max) {
|
|
8
|
+
const now = Date.now(), places = await this.ctx.storage.list();
|
|
9
|
+
const over = [...places].filter(([, until]) => until <= now).map(([key]) => key);
|
|
10
|
+
if (over.length) await this.ctx.storage.delete(over);
|
|
11
|
+
if (places.size - over.length >= max) return false;
|
|
12
|
+
await this.ctx.storage.put(id, ends);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async release(id) {
|
|
17
|
+
await this.ctx.storage.delete(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The signed owner claim (modules/owner-claim) a demo's front Worker makes for
|
|
2
|
+
// its container: one ES256 key pair per container, its public JWK given to the
|
|
3
|
+
// container at start, and one five-minute claim signed with the private key,
|
|
4
|
+
// which is never stored.
|
|
5
|
+
const base64url = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
6
|
+
const segment = value => base64url(new TextEncoder().encode(JSON.stringify(value)));
|
|
7
|
+
|
|
8
|
+
export async function claimKeys() {
|
|
9
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
10
|
+
const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
|
11
|
+
const kid = crypto.randomUUID().replaceAll('-', '');
|
|
12
|
+
return { kid, privateKey: pair.privateKey, publicKeys: { [kid]: { kty, crv, x, y, kid, alg: 'ES256', use: 'sig' } } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The compact JWS /auth/owner-claim verifies: issuer and audience are the demo's own origin. */
|
|
16
|
+
export async function signClaim({ kid, privateKey }, { origin, installationId, email, now }) {
|
|
17
|
+
const iat = Math.floor(now / 1000);
|
|
18
|
+
const signed = `${segment({ alg: 'ES256', typ: 'rer-owner-claim+jwt', kid })}.${segment({ v: 1, iss: origin, aud: origin, installationId, email, jti: crypto.randomUUID(), iat, exp: iat + 300 })}`;
|
|
19
|
+
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(signed));
|
|
20
|
+
return `${signed}.${base64url(signature)}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { claimKeys, signClaim } from './claim.mjs';
|
|
3
|
+
import { endedPage, clearCookie } from './pages.mjs';
|
|
4
|
+
|
|
5
|
+
// One visitor's demo: a Durable Object (newUniqueId) and its container, the
|
|
6
|
+
// seed's image from `rer build --platform container` on the workerd engine.
|
|
7
|
+
// The container starts with no internet and in demo mode, so withDemo and the
|
|
8
|
+
// engine's egress worker refuse every send. The front signs the owner in
|
|
9
|
+
// through the existing signed owner claim. The demo ends at its hour or after
|
|
10
|
+
// 15 idle minutes: the alarm destroys the container and deletes everything.
|
|
11
|
+
const PORT = 8080, IDLE = 15 * 60_000, OWNER = 'owner@demo.invalid';
|
|
12
|
+
// The container's port speaks plain HTTP; the app takes its public origin from RER_INGRESS_ORIGIN.
|
|
13
|
+
const inside = url => `http://container${url.pathname}${url.search}`;
|
|
14
|
+
const random = () => [...crypto.getRandomValues(new Uint8Array(32))].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
15
|
+
|
|
16
|
+
export class DemoContainer extends DurableObject {
|
|
17
|
+
idle = IDLE;
|
|
18
|
+
get container() { return this.ctx.container; }
|
|
19
|
+
port() { return this.container.getTcpPort(PORT); }
|
|
20
|
+
|
|
21
|
+
/** Start the container, wait for /health, claim the owner; returns the claim's 303. */
|
|
22
|
+
async start({ origin, originVar, ends, endsLocal, settings }) {
|
|
23
|
+
const id = this.ctx.id.toString(), keys = await claimKeys(), now = Date.now();
|
|
24
|
+
await this.ctx.storage.put({ ends, seen: now });
|
|
25
|
+
await this.ctx.storage.setAlarm(Math.min(ends, now + this.idle));
|
|
26
|
+
this.container.start({ enableInternet: false, env: { ...settings, RER_DEMO: '1', [originVar]: origin, RER_INGRESS_ORIGIN: origin,
|
|
27
|
+
SESSION_SECRET: random(), SETUP_TOKEN: random(), OWNER_EMAIL: OWNER, RER_OWNER_CLAIM_ISSUER: origin, RER_OWNER_CLAIM_INSTALLATION_ID: id,
|
|
28
|
+
RER_OWNER_CLAIM_PUBLIC_KEYS: JSON.stringify(keys.publicKeys), RER_DEMO_EXPIRES: endsLocal } });
|
|
29
|
+
// A container that stops by itself ends the demo too.
|
|
30
|
+
this.container.monitor().then(() => this.end(), () => this.end());
|
|
31
|
+
await this.healthy();
|
|
32
|
+
const token = await signClaim(keys, { origin, installationId: id, email: OWNER, now: Date.now() });
|
|
33
|
+
const claimed = await this.port().fetch('http://container/auth/owner-claim', { method: 'POST', redirect: 'manual',
|
|
34
|
+
headers: { origin, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token }) });
|
|
35
|
+
await claimed.body?.cancel();
|
|
36
|
+
if (claimed.status !== 303) throw new Error(`owner claim answered ${claimed.status}`);
|
|
37
|
+
return { location: claimed.headers.get('location'), cookies: claimed.headers.getSetCookie() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async healthy() {
|
|
41
|
+
for (const deadline = Date.now() + 120_000; ;) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.port().fetch('http://container/health');
|
|
44
|
+
await response.body?.cancel();
|
|
45
|
+
if (response.ok) return;
|
|
46
|
+
} catch { /* The container is still starting. */ }
|
|
47
|
+
if (Date.now() > deadline) throw new Error('demo container never became healthy');
|
|
48
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every request carrying this demo's cookie: proxied while the demo lives, else the ended page. */
|
|
53
|
+
async fetch(request) {
|
|
54
|
+
const state = await this.ctx.storage.get(['ends', 'seen']), now = Date.now();
|
|
55
|
+
if (!(state.get('ends') > now)) return endedPage(this.env, { status: 410, headers: { 'set-cookie': clearCookie(this.env) } });
|
|
56
|
+
if (now - state.get('seen') > 60_000) await this.ctx.storage.put('seen', now);
|
|
57
|
+
return this.port().fetch(new Request(inside(new URL(request.url)), request));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async alarm() {
|
|
61
|
+
const state = await this.ctx.storage.get(['ends', 'seen']), now = Date.now();
|
|
62
|
+
if (!state.get('ends')) return;
|
|
63
|
+
if (now >= state.get('ends') || now - state.get('seen') >= this.idle) return this.end();
|
|
64
|
+
await this.ctx.storage.setAlarm(Math.min(state.get('ends'), state.get('seen') + this.idle));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Destroy the container and delete this demo's state and place; safe to repeat. */
|
|
68
|
+
async end() {
|
|
69
|
+
if (this.container.running) await this.container.destroy().catch(() => { /* Already stopping. */ });
|
|
70
|
+
await this.ctx.storage.deleteAlarm();
|
|
71
|
+
await this.ctx.storage.deleteAll();
|
|
72
|
+
await this.env.CAPACITY.get(this.env.CAPACITY.idFromName('capacity')).release(this.ctx.id.toString());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The demo host's two pages, from their 12ui designs: the start page (the
|
|
2
|
+
// Turnstile managed widget submits the form by itself) and the ended page.
|
|
3
|
+
// Both are one card in the family's quiet sign-in style.
|
|
4
|
+
const escape = value => String(value).replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
|
|
5
|
+
|
|
6
|
+
const STYLE = `*{box-sizing:border-box}body{margin:0;background:#f2f5f7;color:#192333;font-family:system-ui,-apple-system,'Segoe UI',Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
|
7
|
+
main{width:666px;margin:clamp(32px,18vh,184px) auto 48px}.card{padding:40px 42px 44px;background:#fff;border:1px solid #d6dfe9;border-radius:18px;box-shadow:0 12px 45px rgba(42,65,86,.05)}
|
|
8
|
+
.mark{width:35px;height:6px;border-radius:4px;background:#20574d;margin:0 0 20px}.brand{font-size:18px;line-height:22px;font-weight:600;color:#527875;margin:0 0 14px}
|
|
9
|
+
h1{font-size:42px;line-height:50px;letter-spacing:-1.3px;margin:0 0 14px;font-weight:700}.intro{font-size:20px;line-height:28px;color:#41516a;margin:0}
|
|
10
|
+
ul{padding:0;margin:23px 0 35px;list-style:none;color:#31415a;font-size:18px;line-height:33px}li{position:relative;padding-left:33px}li:before{content:'';position:absolute;left:7px;top:14px;width:7px;height:7px;background:#2b7864;border-radius:50%}
|
|
11
|
+
.notice{margin:0 0 16px;padding:12px 16px;border-radius:8px;background:#fdf6ec;color:#6b3d0f;font-size:16px;line-height:22px}
|
|
12
|
+
.cf-turnstile{min-height:65px}.button{display:block;width:100%;margin-top:28px;min-height:58px;border:1px solid #86a49f;border-radius:11px;background:linear-gradient(110deg,#225b51,#1e5148 60%,#20584d);color:#fff;font:inherit;font-size:20px;font-weight:700;line-height:26px;padding:15px;text-align:center;text-decoration:none;cursor:pointer}
|
|
13
|
+
.button:disabled{opacity:.7;cursor:progress}.button:focus-visible,a:focus-visible{outline:3px solid #388c77;outline-offset:4px}
|
|
14
|
+
.note{font-size:15px;line-height:21px;color:#50617e;text-align:center;margin:21px 0 0}.secondary{text-align:center;margin:22px 0 0;font-size:18px;line-height:24px;font-weight:600}.secondary a{color:#1b554e;text-underline-offset:3px}
|
|
15
|
+
.back{text-align:center;margin:32px 0 0;font-size:15px;line-height:22px}.back a{color:#1b554e;text-underline-offset:3px}
|
|
16
|
+
.ended main{width:min(560px,calc(100% - 32px));margin-top:clamp(32px,26vh,262px)}.ended .card{padding:28px 36px 42px}.ended .brand{font-size:16px;margin-bottom:26px}.ended h1{font-size:36px;line-height:44px;margin-bottom:22px}.ended .intro{margin-bottom:36px}.ended .button{margin-top:0;font-size:18px}
|
|
17
|
+
@media(min-width:600px) and (max-width:1023px){main{width:calc(100% - 64px);max-width:666px;margin:100px auto 48px}.card{padding:36px}h1{font-size:38px;line-height:46px}.intro{font-size:18px;line-height:27px}}
|
|
18
|
+
@media(max-width:599px){main,.ended main{width:calc(100% - 32px);margin:32px auto}.ended .card{padding:26px 22px}.ended h1{font-size:29px;line-height:36px}.ended .intro{font-size:17px;line-height:25px;margin-bottom:28px}.card{padding:26px 22px;border-radius:16px}.brand{font-size:16px}h1{font-size:34px;line-height:41px;letter-spacing:-.8px}.intro{font-size:17px;line-height:25px}ul{font-size:16px;line-height:25px;margin:22px 0 26px}li{padding-left:22px;margin-bottom:9px}li:before{left:2px;top:9px}.button{font-size:18px;margin-top:22px}.note{font-size:13px;line-height:20px}.back{margin-top:24px;font-size:14px}}`;
|
|
19
|
+
|
|
20
|
+
// Starts the demo once Turnstile has passed: the widget put its token in the form.
|
|
21
|
+
// After a refused start the page waits for the visitor's own click instead.
|
|
22
|
+
const SCRIPT = `function rerDemoBusy(){document.querySelector('#start button').disabled=true;document.getElementById('status').textContent='Starting your demo…'}
|
|
23
|
+
function rerDemoStart(){rerDemoBusy();document.getElementById('start').submit()}
|
|
24
|
+
function rerDemoReady(){document.querySelector('#start button').disabled=false}
|
|
25
|
+
addEventListener('DOMContentLoaded',()=>document.getElementById('start').addEventListener('submit',rerDemoBusy))`;
|
|
26
|
+
|
|
27
|
+
function html(env, { page, title, body, status = 200, headers = {}, turnstile = false }) {
|
|
28
|
+
const nonce = crypto.randomUUID().replaceAll('-', ''), site = new URL(env.DEMO_SITE);
|
|
29
|
+
const policy = `default-src 'none'; style-src 'nonce-${nonce}'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'${turnstile ? `; script-src 'nonce-${nonce}' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src https://challenges.cloudflare.com` : ''}`;
|
|
30
|
+
return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escape(title)}</title><style nonce="${nonce}">${STYLE}</style>${turnstile ? `<script nonce="${nonce}">${SCRIPT}</script><script nonce="${nonce}" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : ''}</head>
|
|
31
|
+
<body class="${page}"><main><section class="card" aria-labelledby="title"><div class="mark" aria-hidden="true"></div><p class="brand">${escape(env.DEMO_NAME)}</p>${body}</section><nav class="back" aria-label="Return"><a href="${escape(site.origin)}/">Back to ${escape(site.host)}</a></nav></main></body></html>`,
|
|
32
|
+
{ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'strict-origin', 'x-content-type-options': 'nosniff', ...headers } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET /: the visitor proves they are human and their demo starts. `notice` explains a refused start. */
|
|
36
|
+
export function startPage(env, { status = 200, notice } = {}) {
|
|
37
|
+
const name = escape(env.DEMO_NAME);
|
|
38
|
+
return html(env, { page: 'start', title: `Try ${env.DEMO_NAME}`, status, turnstile: true, body: `<h1 id="title">Try ${name}</h1>
|
|
39
|
+
<p class="intro">Your own copy of ${name}, signed in as its owner, with sample data.</p>
|
|
40
|
+
<ul><li>Nothing is sent: mail stays in an outbox.</li><li>It ends after an hour, or 15 idle minutes.</li><li>Then it is deleted. Nobody else can see it.</li></ul>
|
|
41
|
+
${notice ? `<p class="notice" role="alert">${escape(notice)}</p>` : ''}<form id="start" method="post" action="/__demo/start"><div class="cf-turnstile" data-sitekey="${escape(env.TURNSTILE_SITE_KEY)}" data-size="flexible" data-callback="${notice ? 'rerDemoReady' : 'rerDemoStart'}"></div>
|
|
42
|
+
<button class="button" type="submit" disabled>Start the demo</button><p class="note" id="status" role="status">Starts by itself once the check passes. Takes about ten seconds.</p></form>` });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A demo whose hour or idle time is up: its container and data are gone. */
|
|
46
|
+
export function endedPage(env, { status = 410, headers = {} } = {}) {
|
|
47
|
+
const name = escape(env.DEMO_NAME);
|
|
48
|
+
return html(env, { page: 'ended', title: 'This demo has ended', status, headers, body: `<h1 id="title">This demo has ended</h1>
|
|
49
|
+
<p class="intro">Demos last an hour, or until 15 minutes pass with nothing happening. Its copy of ${name} and everything in it have been deleted.</p>
|
|
50
|
+
<a class="button" href="/">Start a new demo</a><p class="secondary"><a href="${escape(new URL(env.DEMO_SITE).origin)}/run">Run ${name} yourself</a></p>` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cookie = (env, value, seconds) => `rer_demo=${value}; Domain=${new URL(env.DEMO_ORIGIN).hostname}; Path=/; Max-Age=${seconds}; HttpOnly; Secure; SameSite=Lax`;
|
|
54
|
+
/** The visitor's demo: HttpOnly, Secure, SameSite=Lax, for demo.<domain> and its subdomains, one hour. */
|
|
55
|
+
export const demoCookie = (env, id) => cookie(env, id, 3600);
|
|
56
|
+
export const clearCookie = env => cookie(env, '', 0);
|