diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml
new file mode 100644
index 00000000..82ab4f88
--- /dev/null
+++ b/.github/workflows/demo.yml
@@ -0,0 +1,313 @@
+name: Demo
+
+on:
+ workflow_call:
+ inputs:
+ digest:
+ type: string
+ required: true
+ version:
+ type: string
+ required: true
+ secrets:
+ CLOUDFLARE_API_TOKEN:
+ required: false
+ CLOUDFLARE_ACCOUNT_ID:
+ required: false
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Published version, for example 1.18.0
+ type: string
+ required: true
+
+permissions:
+ contents: read
+
+# The release's demo job holds the same group. Runs that overlap would promote out
+# of order. A called workflow gets a group of its own, so it never waits on its caller.
+concurrency:
+ group: ${{ github.event_name == 'workflow_dispatch' && 'stackyard-demo-deploy' || format('demo-{0}', github.run_id) }}
+ cancel-in-progress: false
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ WRANGLER_VERSION: 4.151.3
+
+jobs:
+ # Dev dependencies run here. Never give this job a token.
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ permissions:
+ contents: read
+ steps:
+ # The version reaches Wrangler's command line below.
+ - name: Check the version
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ echo "::error::$VERSION is not a stable version such as 1.18.0"
+ exit 1
+ fi
+
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: '24'
+
+ - name: Install dev tooling
+ run: npm ci
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: playwright-${{ runner.os }}-
+
+ - name: Install the browser
+ run: npx playwright install --with-deps chromium
+
+ - name: Start the image in demo mode
+ env:
+ DIGEST: ${{ inputs.digest }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ -n "$DIGEST" ]; then
+ IMAGE="ghcr.io/sandobserver/stackyard@${DIGEST}"
+ else
+ IMAGE="ghcr.io/sandobserver/stackyard:${VERSION}"
+ fi
+ docker pull "$IMAGE"
+ echo "Demo from $(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE")"
+ docker run -d --name stackyard-demo -e DEMO_MODE=true -p 8740:80 "$IMAGE"
+ for i in $(seq 1 60); do
+ if curl -fsS http://127.0.0.1:8740/health >/dev/null 2>&1; then echo "up after ${i}s"; exit 0; fi
+ sleep 1
+ done
+ echo "::error::the demo container never became healthy"
+ docker logs stackyard-demo
+ exit 1
+
+ - name: Copy the web root
+ run: docker cp stackyard-demo:/usr/share/nginx/html "$RUNNER_TEMP/webroot"
+
+ - name: Tour the container
+ run: node scripts/demo/tour.js discover http://127.0.0.1:8740 "$RUNNER_TEMP/tour.json"
+
+ - name: Record the demo
+ run: node scripts/demo/record.js http://127.0.0.1:8740 "$RUNNER_TEMP/tour.json" "$RUNNER_TEMP/webroot" "$RUNNER_TEMP/recording.json" 10
+
+ - name: Build the static demo
+ run: |
+ set -euo pipefail
+ node scripts/demo/build.js "$RUNNER_TEMP/webroot" "$RUNNER_TEMP/recording.json" demo-site
+ mkdir demo-recording
+ cp "$RUNNER_TEMP/tour.json" "$RUNNER_TEMP/recording.json" demo-recording/
+
+ - name: Container logs on failure
+ if: failure()
+ run: docker logs stackyard-demo
+
+ - name: Upload the static demo
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: demo-site
+ path: demo-site/
+ retention-days: 7
+
+ - name: Upload the recording
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: demo-recording
+ path: demo-recording/
+ retention-days: 7
+
+ upload:
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions: {}
+ outputs:
+ url: ${{ steps.version.outputs.url }}
+ id: ${{ steps.version.outputs.id }}
+ steps:
+ - name: Check for Cloudflare credentials
+ id: creds
+ env:
+ TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ run: |
+ if [ -z "$TOKEN" ]; then
+ echo 'CLOUDFLARE_API_TOKEN is not set. Nothing uploaded.'
+ echo 'set=false' >> "$GITHUB_OUTPUT"
+ else
+ echo 'set=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ # The Wrangler config comes from the repository, never from the build job.
+ - name: Checkout the Wrangler config
+ if: steps.creds.outputs.set == 'true'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ path: repo
+ sparse-checkout: scripts/demo/wrangler.jsonc
+ sparse-checkout-cone-mode: false
+
+ - name: Download the static demo
+ if: steps.creds.outputs.set == 'true'
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: demo-site
+ path: upload/site
+
+ # The build job runs dev dependencies. Wrangler runs in this folder with the
+ # token, so it must hold web files only: no package, config or link.
+ - name: Check the site holds web files only
+ if: steps.creds.outputs.set == 'true'
+ run: |
+ set -euo pipefail
+ bad=$(find upload/site \( -type l -o -name '.*' -o -name node_modules -o -name 'package*.json' \
+ -o -name 'wrangler.*' -o -name '_worker.js' -o \( -type f \
+ ! -regex '.*\.\(html\|js\|css\|json\|png\|jpg\|jpeg\|webp\|svg\|ico\|woff2\)$' \
+ ! -name _headers ! -name _redirects \) \) -print)
+ if [ -n "$bad" ]; then
+ echo "::error::the site holds files that are not web files:"
+ printf '%s\n' "$bad"
+ exit 1
+ fi
+ cp repo/scripts/demo/wrangler.jsonc upload/
+ rm -rf repo
+
+ # Wrangler cannot upload a version of a Worker that does not exist yet. The
+ # first run creates it with a placeholder page, so nothing untested goes live.
+ - name: Check the Worker exists
+ id: worker
+ if: steps.creds.outputs.set == 'true'
+ env:
+ TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ set -euo pipefail
+ # The header comes from stdin, so the token never appears in a process list.
+ code=$(printf 'Authorization: Bearer %s' "$TOKEN" | curl --silent --output /dev/null \
+ --write-out '%{http_code}' --max-time 30 --header @- \
+ "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/workers/scripts/stackyard-demo/settings")
+ case "$code" in
+ 200) echo 'exists=true' >> "$GITHUB_OUTPUT" ;;
+ 404) echo 'exists=false' >> "$GITHUB_OUTPUT" ;;
+ *) echo "::error::Cloudflare answered HTTP $code"; exit 1 ;;
+ esac
+
+ - name: Prepare the placeholder
+ if: steps.creds.outputs.set == 'true' && steps.worker.outputs.exists == 'false'
+ run: |
+ set -euo pipefail
+ mkdir -p placeholder/site
+ printf '
Stackyard demoThe demo is being prepared.
\n' > placeholder/site/index.html
+ printf '/ /index.html 200\n' > placeholder/site/_redirects
+ cp upload/wrangler.jsonc placeholder/
+
+ - name: Create the Worker
+ if: steps.creds.outputs.set == 'true' && steps.worker.outputs.exists == 'false'
+ uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ wranglerVersion: ${{ env.WRANGLER_VERSION }}
+ workingDirectory: placeholder
+ command: deploy
+
+ - name: Upload a version
+ id: wrangler
+ if: steps.creds.outputs.set == 'true'
+ uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ wranglerVersion: ${{ env.WRANGLER_VERSION }}
+ workingDirectory: upload
+ command: versions upload --tag v${{ inputs.version }} --message "Stackyard ${{ inputs.version }}"
+
+ - name: Read the version
+ id: version
+ if: steps.creds.outputs.set == 'true'
+ env:
+ OUT: ${{ steps.wrangler.outputs.command-output }}
+ run: |
+ set -euo pipefail
+ id=$(printf '%s\n' "$OUT" | sed -n 's/.*Worker Version ID: *\([0-9a-f-]\{36\}\).*/\1/p' | head -1)
+ url=$(printf '%s\n' "$OUT" | sed -n 's/.*Version Preview URL: *\(https:[^ ]*\).*/\1/p' | head -1)
+ if [ -z "$id" ] || [ -z "$url" ]; then
+ echo '::error::the upload printed no version ID or preview address'
+ exit 1
+ fi
+ echo "id=$id" >> "$GITHUB_OUTPUT"
+ echo "url=$url" >> "$GITHUB_OUTPUT"
+ echo "Version $id previews at $url"
+
+ # Dev dependencies run here. Never give this job a token.
+ verify:
+ needs: upload
+ if: needs.upload.outputs.url != ''
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: '24'
+
+ - name: Install dev tooling
+ run: npm ci
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: playwright-${{ runner.os }}-
+
+ - name: Install the browser
+ run: npx playwright install --with-deps chromium
+
+ - name: Download the recording
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: demo-recording
+ path: demo-recording
+
+ - name: Tour the preview
+ env:
+ URL: ${{ needs.upload.outputs.url }}
+ run: node scripts/demo/tour.js verify "$URL" demo-recording/tour.json demo-recording/recording.json
+
+ # Keep verify in needs. Without it, an untested version goes live.
+ promote:
+ needs: [upload, verify]
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions: {}
+ steps:
+ - name: Deploy the version
+ uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ wranglerVersion: ${{ env.WRANGLER_VERSION }}
+ command: versions deploy ${{ needs.upload.outputs.id }}@100 --name stackyard-demo --yes --message "Stackyard ${{ inputs.version }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7c0b7707..04741154 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -400,3 +400,19 @@ jobs:
exit 1
fi
echo 'Documentation site rebuild requested.'
+
+ demo:
+ needs: [build-and-push, release-page]
+ if: needs.release-page.outputs.prerelease == 'false'
+ concurrency:
+ group: stackyard-demo-deploy
+ cancel-in-progress: false
+ permissions:
+ contents: read
+ uses: ./.github/workflows/demo.yml
+ with:
+ digest: ${{ needs.build-and-push.outputs.digest }}
+ version: ${{ needs.build-and-push.outputs.version }}
+ secrets:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/scripts/demo/build.js b/scripts/demo/build.js
new file mode 100644
index 00000000..e81fdc8e
--- /dev/null
+++ b/scripts/demo/build.js
@@ -0,0 +1,219 @@
+#!/usr/bin/env node
+
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+
+const CHUNK_FRAMES = 60;
+const MAX_HEADER_RULES = 100;
+const MISSING = [503, JSON.stringify({ error: 'Not available in the live demo.', kind: 'blocked' })];
+const OUTBOUND_ROUTES = ['POST /api/widget-options/:id'];
+
+const { listFiles, routePattern } = require('./source');
+
+const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
+
+/** @param {Array>} frames */
+function classify(frames) {
+ const fixed = {};
+ const varying = [];
+ for (const key of Object.keys(frames[0] || {})) {
+ const first = JSON.stringify(frames[0][key]);
+ if (frames.every(f => JSON.stringify(f[key]) === first)) fixed[key] = frames[0][key];
+ else varying.push(key);
+ }
+ return { fixed, varying };
+}
+
+/** @param {Array>} frames @param {string} key */
+function splitSeries(frames, key, size = CHUNK_FRAMES) {
+ const out = [];
+ for (let c = 0; c < frames.length; c += size) {
+ const json = JSON.stringify(frames.slice(c, c + size).map(f => f[key]));
+ out.push({ name: `${hash(key)}.${hash(json)}.json`, json });
+ }
+ return out;
+}
+
+/** The replay takes the first match. Keep the most specific routes first.
+ @param {{ fixed: Record, writes: Array<{ method: string, route: string, answer: [number, string] }> }} r */
+function fallbackRoutes({ fixed, writes }) {
+ const routes = [];
+ const byPath = new Map();
+ for (const [key, answer] of Object.entries(fixed)) {
+ const [method, full] = key.split(' ');
+ const [pathname, query] = full.split('?');
+ if (method !== 'GET' || query === undefined) continue;
+ if (!byPath.has(pathname)) byPath.set(pathname, []);
+ byPath.get(pathname).push(answer);
+ }
+ /* Only failures fall back. A result for one query is wrong for another. */
+ for (const [pathname, answers] of byPath) {
+ if (answers.every(a => a[0] >= 400)) routes.push([{ method: 'GET', path: routePattern(pathname) }, answers[0]]);
+ }
+ const ping = writes.find(w => w.method === 'POST' && w.route === '/api/ping');
+ const blocked = ping && JSON.parse(ping.answer[1]);
+ for (const w of writes) {
+ if (OUTBOUND_ROUTES.includes(`${w.method} ${w.route}`)) {
+ if (blocked?.code !== 'blocked.demo') throw new Error(`no recorded demo-mode refusal to answer ${w.route} with`);
+ const body = JSON.stringify({ error: blocked.error, kind: blocked.kind, code: blocked.code });
+ routes.push([{ method: w.method, path: routePattern(w.route) }, [502, body]]);
+ } else {
+ routes.push([{ method: w.method, path: routePattern(w.route) }, w.answer]);
+ }
+ }
+ return routes;
+}
+
+const RULE_HEADERS = [
+ 'content-security-policy',
+ 'x-frame-options',
+ 'cache-control',
+ 'x-content-type-options',
+ 'referrer-policy',
+ 'permissions-policy',
+];
+const TITLE = {
+ 'content-security-policy': 'Content-Security-Policy',
+ 'x-frame-options': 'X-Frame-Options',
+ 'cache-control': 'Cache-Control',
+ 'x-content-type-options': 'X-Content-Type-Options',
+ 'referrer-policy': 'Referrer-Policy',
+ 'permissions-policy': 'Permissions-Policy',
+};
+
+const pick = h => Object.fromEntries(RULE_HEADERS.filter(n => h[n] !== undefined).map(n => [n, h[n]]));
+const same = (a, b) => JSON.stringify(pick(a)) === JSON.stringify(pick(b));
+
+/* Detach each changed header first. Cloudflare joins a header that two matching
+ rules set, and a joined Content-Security-Policy blocks the page. */
+function diffLines(base, h) {
+ const lines = [];
+ for (const n of RULE_HEADERS) {
+ if (base[n] === h[n]) continue;
+ if (base[n] !== undefined) lines.push(`! ${TITLE[n]}`);
+ if (h[n] !== undefined) lines.push(`${TITLE[n]}: ${h[n]}`);
+ }
+ return lines;
+}
+
+/** @param {Record>} files headers per file path
+ @param {Record>} routes headers per route that is not a file */
+function headerRules(files, routes = {}) {
+ /* The main page's headers are the base, so a path no rule names gets the
+ strictest policy, not the widgets' inline-script one. */
+ if (!files['/index.html']) throw new Error('the web root has no /index.html');
+ const base = pick(files['/index.html']);
+ const rules = [['/*', RULE_HEADERS.filter(n => base[n] !== undefined).map(n => `${TITLE[n]}: ${base[n]}`)]];
+ const covered = [];
+
+ const walk = dir => {
+ const inside = Object.keys(files).filter(p => p.startsWith(dir));
+ const first = files[inside[0]];
+ if (dir !== '/' && inside.every(p => same(files[p], first))) {
+ if (!same(first, base)) {
+ rules.push([`${dir}*`, diffLines(base, first)]);
+ covered.push([dir, first]);
+ }
+ return;
+ }
+ const subdirs = new Set();
+ for (const p of inside) {
+ const rest = p.slice(dir.length);
+ if (rest.includes('/')) subdirs.add(`${dir}${rest.split('/')[0]}/`);
+ else if (!same(files[p], base)) rules.push([p, diffLines(base, files[p])]);
+ }
+ for (const d of [...subdirs].sort()) walk(d);
+ };
+ walk('/');
+
+ for (const [route, h] of Object.entries(routes)) {
+ const group = covered.find(([dir]) => route.startsWith(dir));
+ if (group) {
+ if (!same(group[1], h)) throw new Error(`${route} needs other headers than ${group[0]}*`);
+ continue;
+ }
+ if (!same(h, base)) rules.push([route, diffLines(base, h)]);
+ }
+ if (rules.length > MAX_HEADER_RULES)
+ throw new Error(`${rules.length} header rules; Cloudflare allows ${MAX_HEADER_RULES}`);
+ return rules.map(([p, lines]) => `${p}\n${lines.map(l => ` ${l}`).join('\n')}\n`).join('\n');
+}
+
+/** @param {string[]} routes */
+function redirectRules(routes) {
+ const lines = [...routes].sort().map(r => `${r} ${r.endsWith('/') ? r : `${r}/`}index.html 200`);
+ return `${lines.join('\n')}\n`;
+}
+
+/** @param {string} html @param {string} src */
+function injectScript(html, src) {
+ const out = html.replace(/]*)?>/i, m => `${m}`);
+ if (out === html) throw new Error('page has no ');
+ return out;
+}
+
+function build(webroot, recording, out) {
+ fs.rmSync(out, { recursive: true, force: true });
+ fs.cpSync(webroot, out, { recursive: true });
+ const files = listFiles(out);
+
+ const { fixed: still, varying } = classify(recording.frames);
+ const fixed = { ...recording.once, ...still };
+ const bad = Object.entries(fixed).filter(([, [status]]) => status === 429);
+ if (bad.length) throw new Error(`rate limited while recording: ${bad.map(([k]) => k).join(', ')}`);
+
+ const dir = path.join(out, 'demo-replay');
+ fs.mkdirSync(dir);
+ const series = {};
+ for (const key of varying) {
+ series[key.slice('GET '.length)] = splitSeries(recording.frames, key).map(({ name, json }) => {
+ fs.writeFileSync(path.join(dir, name), json);
+ return name;
+ });
+ }
+ const index = JSON.stringify({
+ start: recording.start,
+ step: recording.step,
+ count: recording.frames.length,
+ chunk: CHUNK_FRAMES,
+ fixed,
+ series,
+ routes: fallbackRoutes({ fixed, writes: recording.writes }),
+ missing: MISSING,
+ });
+ const indexName = `index.${hash(index)}.json`;
+ fs.writeFileSync(path.join(dir, indexName), index);
+ const script = fs
+ .readFileSync(path.join(__dirname, 'replay.js'), 'utf8')
+ .replace('__REPLAY_INDEX__', `/demo-replay/${indexName}`);
+ const scriptName = `replay.${hash(script)}.js`;
+ fs.writeFileSync(path.join(dir, scriptName), script);
+
+ for (const f of files.filter(p => p.endsWith('.html'))) {
+ const file = path.join(out, f);
+ fs.writeFileSync(file, injectScript(fs.readFileSync(file, 'utf8'), `/demo-replay/${scriptName}`));
+ }
+
+ const replayHeaders = { ...recording.headers.files['/index.html'] };
+ replayHeaders['cache-control'] = 'public, max-age=31536000, immutable';
+ const replayFiles = Object.fromEntries(listFiles(dir, '/demo-replay').map(p => [p, replayHeaders]));
+ fs.writeFileSync(
+ path.join(out, '_headers'),
+ headerRules({ ...recording.headers.files, ...replayFiles }, recording.headers.routes),
+ );
+ fs.writeFileSync(path.join(out, '_redirects'), redirectRules(Object.keys(recording.headers.routes)));
+ return { pages: files.filter(p => p.endsWith('.html')).length, fixed: Object.keys(fixed).length, varying };
+}
+
+module.exports = { classify, splitSeries, fallbackRoutes, headerRules, redirectRules, injectScript, build };
+
+if (require.main === module) {
+ const [webroot, recordingFile, out] = process.argv.slice(2);
+ if (!out) {
+ console.error('usage: node scripts/demo/build.js ');
+ process.exit(2);
+ }
+ const result = build(webroot, JSON.parse(fs.readFileSync(recordingFile, 'utf8')), out);
+ console.log(`${result.pages} pages, ${result.fixed} fixed answers, replaying ${result.varying.join(', ')}`);
+}
diff --git a/scripts/demo/record.js b/scripts/demo/record.js
new file mode 100644
index 00000000..65f8e17e
--- /dev/null
+++ b/scripts/demo/record.js
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { listFiles, writeRoutes } = require('./source');
+
+const STEP_MS = 5000;
+/* A valid address, so routes that call out reach the demo-mode refusal, not input validation. */
+const PROBE_BODY = JSON.stringify({ url: 'https://example.com', name: 'demo' });
+
+/** @param {string} origin @param {string} target */
+async function headersOf(origin, target) {
+ const r = await fetch(origin + target, { method: 'HEAD', redirect: 'manual' });
+ return r.status === 200 ? Object.fromEntries(r.headers) : null;
+}
+
+/** @param {string} origin @param {string} method @param {string} target @param {string|null} body */
+async function exchange(origin, method, target, body = null) {
+ const r = await fetch(origin + target, {
+ method,
+ headers: method === 'GET' ? {} : { Origin: origin, 'Content-Type': 'application/json' },
+ body: method === 'GET' ? undefined : (body ?? '{}'),
+ });
+ return [r.status, await r.text()];
+}
+
+async function record({ origin, tour, webroot, minutes, apiSource }) {
+ /* Do not remove. The write probes below change the config of a container that
+ is not in demo mode. */
+ const [, local] = await exchange(origin, 'GET', '/api/icons/local');
+ if (JSON.parse(local).demo !== true) throw new Error(`${origin} is not in demo mode`);
+ const files = {};
+ for (const f of listFiles(webroot)) {
+ files[f] = await headersOf(origin, f);
+ if (!files[f]) throw new Error(`${f} is in the web root but the image does not serve it`);
+ }
+ const routes = {};
+ for (const f of Object.keys(files).filter(p => p.endsWith('/index.html'))) {
+ const dir = f.slice(0, -'index.html'.length);
+ for (const r of dir === '/' ? ['/'] : [dir, dir.slice(0, -1)]) {
+ const h = await headersOf(origin, r);
+ if (h) routes[r] = h;
+ }
+ }
+
+ const gets = [...new Set(tour.filter(e => e.method === 'GET').map(e => e.path))].sort();
+ const once = {};
+ const writes = [];
+ const known = writeRoutes(apiSource);
+ for (const e of tour.filter(x => x.method !== 'GET')) {
+ const route = known.find(w => w.method === e.method && new RegExp(w.pattern).test(e.path.split('?')[0]));
+ if (!route) throw new Error(`${e.method} ${e.path} matches no route in the API source`);
+ once[`${e.method} ${e.path}`] = [e.status, e.body];
+ if (!writes.some(w => w.route === route.route && w.method === e.method)) {
+ writes.push({ method: e.method, route: route.route, answer: [e.status, e.body] });
+ }
+ }
+ const widgetId = gets.map(g => /^\/api\/widget-config\/([^/?]+)/.exec(g)?.[1]).find(Boolean) || 'demo';
+ for (const w of known) {
+ if (writes.some(x => x.route === w.route && x.method === w.method)) continue;
+ writes.push({
+ method: w.method,
+ route: w.route,
+ answer: await exchange(origin, w.method, w.route.replace(/:[a-z]+/gi, widgetId), PROBE_BODY),
+ });
+ }
+
+ const count = Math.round((minutes * 60_000) / STEP_MS);
+ const start = Math.ceil(Date.now() / STEP_MS) * STEP_MS;
+ const frames = [];
+ for (let i = 0; i < count; i++) {
+ await new Promise(r => setTimeout(r, Math.max(0, start + i * STEP_MS - Date.now())));
+ const frame = {};
+ await Promise.all(
+ gets.map(async g => {
+ frame[`GET ${g}`] = await exchange(origin, 'GET', g);
+ }),
+ );
+ frames.push(frame);
+ }
+ return { start, step: STEP_MS, frames, once, writes, headers: { files, routes } };
+}
+
+module.exports = { record };
+
+if (require.main === module) {
+ const [origin, tourFile, webroot, out, minutes = '10'] = process.argv.slice(2);
+ if (!out) {
+ console.error('usage: node scripts/demo/record.js [minutes]');
+ process.exit(2);
+ }
+ record({
+ origin,
+ tour: JSON.parse(fs.readFileSync(tourFile, 'utf8')).exchanges,
+ webroot,
+ minutes: Number(minutes),
+ apiSource: path.join(__dirname, '..', '..', 'api', 'src'),
+ }).then(rec => {
+ fs.writeFileSync(out, JSON.stringify(rec));
+ console.log(
+ `${rec.frames.length} frames of ${Object.keys(rec.frames[0]).length} addresses, ${rec.writes.length} write routes`,
+ );
+ });
+}
diff --git a/scripts/demo/replay.js b/scripts/demo/replay.js
new file mode 100644
index 00000000..5951d00c
--- /dev/null
+++ b/scripts/demo/replay.js
@@ -0,0 +1,61 @@
+(() => {
+ const INDEX = '__REPLAY_INDEX__';
+ const realFetch = window.fetch.bind(window);
+ const loaded = new Map();
+ /* A 404 means a newer release replaced this page's recording. Only a reload
+ fetches the new one. One reload a minute, so a missing file cannot loop. */
+ const RELOADED_AT = 'demo-replay-reloaded-at';
+ const reloadOnce = () => {
+ try {
+ if (Date.now() - (Number(sessionStorage.getItem(RELOADED_AT)) || 0) < 60_000) return;
+ sessionStorage.setItem(RELOADED_AT, String(Date.now()));
+ } catch {
+ return;
+ }
+ location.reload();
+ };
+ /* A failed load is dropped, so the next request retries it. Kept, it fails every
+ later request until the page reloads. */
+ const load = path => {
+ if (!loaded.has(path)) {
+ const p = realFetch(path).then(r => {
+ if (r.status === 404) reloadOnce();
+ if (!r.ok) throw new Error(`${path} answered ${r.status}`);
+ return r.json();
+ });
+ p.catch(() => loaded.delete(path));
+ loaded.set(path, p);
+ }
+ return loaded.get(path);
+ };
+ const ISO_DATE = /"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z)"/g;
+
+ async function answer(d, method, url) {
+ const path = url.pathname + url.search;
+ const fixed = d.fixed[`${method} ${path}`];
+ if (fixed) return fixed;
+ const series = method === 'GET' && d.series[path];
+ if (series) {
+ const span = d.step * d.count;
+ const i = Math.floor(((((Date.now() - d.start) % span) + span) % span) / d.step);
+ const [status, body] = (await load(`/demo-replay/${series[Math.floor(i / d.chunk)]}`))[i % d.chunk];
+ /* Keep this shift. Without it, widgets show every reading as stale. */
+ const shift = Date.now() - (d.start + i * d.step);
+ return [status, body.replace(ISO_DATE, (_, iso) => `"${new Date(Date.parse(iso) + shift).toISOString()}"`)];
+ }
+ for (const [pattern, recorded] of d.routes) {
+ if (pattern.method === method && new RegExp(pattern.path).test(url.pathname)) return recorded;
+ }
+ console.warn('[demo-replay] not recorded:', method, path);
+ return d.missing;
+ }
+
+ window.fetch = async (input, init) => {
+ const req = input instanceof Request ? input : null;
+ const url = new URL(req ? req.url : String(input), location.href);
+ if (url.origin !== location.origin || !url.pathname.startsWith('/api/')) return realFetch(input, init);
+ const method = (init?.method || req?.method || 'GET').toUpperCase();
+ const [status, body] = await answer(await load(INDEX), method, url);
+ return new Response(body, { status, headers: { 'content-type': 'application/json' } });
+ };
+})();
diff --git a/scripts/demo/source.js b/scripts/demo/source.js
new file mode 100644
index 00000000..54ff8f67
--- /dev/null
+++ b/scripts/demo/source.js
@@ -0,0 +1,30 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+/** @param {string} dir @returns {string[]} paths below dir, each starting with / */
+function listFiles(dir, prefix = '') {
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
+ const rel = `${prefix}/${e.name}`;
+ return e.isDirectory() ? listFiles(path.join(dir, e.name), rel) : [rel];
+ });
+}
+
+/** @param {string} route @returns {string} */
+function routePattern(route) {
+ const escaped = route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:[a-z]+/gi, '[^/]+');
+ return `^${escaped}$`;
+}
+
+/** @param {string} apiSrc @returns {Array<{ method: string, route: string, pattern: string }>} */
+function writeRoutes(apiSrc) {
+ const out = [];
+ for (const f of listFiles(apiSrc).filter(p => p.endsWith('.js'))) {
+ const src = fs.readFileSync(path.join(apiSrc, f), 'utf8');
+ for (const m of src.matchAll(/\bon\(\s*'(POST|PUT|PATCH|DELETE)',\s*'(\/api\/[^']+)'/g)) {
+ out.push({ method: m[1], route: m[2], pattern: routePattern(m[2]) });
+ }
+ }
+ return out.sort((a, b) => a.route.localeCompare(b.route));
+}
+
+module.exports = { listFiles, routePattern, writeRoutes };
diff --git a/scripts/demo/tour.js b/scripts/demo/tour.js
new file mode 100644
index 00000000..fefdbd1a
--- /dev/null
+++ b/scripts/demo/tour.js
@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+
+const fs = require('node:fs');
+const { chromium } = require('@playwright/test');
+
+const VIEWS = [
+ { name: 'desktop', viewport: { width: 1440, height: 900 } },
+ { name: 'desktop-light', viewport: { width: 1440, height: 900 }, colorScheme: 'light' },
+ { name: 'tablet', viewport: { width: 820, height: 1180 }, isMobile: true, hasTouch: true },
+ { name: 'phone', viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true },
+ { name: 'narrow', viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true },
+ { name: 'rtl', viewport: { width: 1440, height: 900 }, locale: 'fa-IR' },
+];
+const CHECKED_HEADERS = [
+ 'content-security-policy',
+ 'x-frame-options',
+ 'cache-control',
+ 'x-content-type-options',
+ 'referrer-policy',
+ 'permissions-policy',
+];
+
+async function run(origin) {
+ const browser = await chromium.launch();
+ const exchanges = [];
+ const problems = [];
+ const errors = new Set();
+ const outcomes = new Set();
+ let changed = false;
+
+ const open = async view => {
+ const ctx = await browser.newContext(view);
+ ctx.on('response', async r => {
+ const u = new URL(r.url());
+ if (u.origin !== origin) return;
+ if (u.pathname.startsWith('/api/')) {
+ const body = await r.text().catch(() => '');
+ exchanges.push({ method: r.request().method(), path: u.pathname + u.search, status: r.status(), body });
+ } else if (r.status() >= 400) {
+ problems.push(`${view.name}: ${r.status()} ${u.pathname}`);
+ }
+ });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(5000);
+ page.setDefaultNavigationTimeout(30_000);
+ page.on('pageerror', e => errors.add(`page error: ${e.message}`));
+ page.on('console', m => {
+ if (m.text().startsWith('[demo-replay]')) problems.push(`${view.name}: ${m.text()}`);
+ else if (m.type() === 'error') errors.add(`console: ${m.text()}`);
+ });
+ return { ctx, page };
+ };
+
+ for (const view of VIEWS) {
+ const { ctx, page } = await open(view);
+ await page.goto(`${origin}/`, { waitUntil: 'networkidle' });
+ const before = await widgetText(page);
+ await page.waitForTimeout(25_000);
+ if ((await widgetText(page)) !== before) changed = true;
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ );
+ if (overflow > 0) problems.push(`${view.name}: the dashboard scrolls sideways by ${overflow}px`);
+ await page.keyboard.press('ArrowRight');
+ await page.waitForTimeout(1500);
+ await page.keyboard.press('ArrowLeft');
+ await page.mouse.click(5, 5);
+ await page.keyboard.type('a');
+ await page.waitForTimeout(800);
+ await page.keyboard.press('Escape');
+ await ctx.close();
+ }
+
+ for (const view of [VIEWS[0], VIEWS[3]]) {
+ const { ctx, page } = await open(view);
+ await page.goto(`${origin}/admin`, { waitUntil: 'networkidle' });
+ for (const sec of ['general', 'appearance', 'about', 'dashboard']) {
+ await page
+ .locator(`[data-sec="${sec}"]:visible`)
+ .first()
+ .click()
+ .catch(() => {});
+ await page.waitForTimeout(500);
+ }
+ const edits = page.locator('#sec-dashboard button:text-is("Edit")');
+ const n = await edits.count();
+ for (let i = 0; i < n; i++) {
+ await edits
+ .nth(i)
+ .click()
+ .catch(() => {});
+ await page.waitForTimeout(700);
+ await page
+ .locator('button:has-text("Save"):visible')
+ .first()
+ .click()
+ .catch(() => {});
+ outcomes.add(`${view.name}: save: ${await toastText(page)}`);
+ await page
+ .locator('button:has-text("Back"):visible')
+ .first()
+ .click()
+ .catch(() => {});
+ await page.waitForTimeout(300);
+ }
+ await page
+ .locator('#btn-add')
+ .click()
+ .catch(() => {});
+ await page
+ .locator('#ip-in')
+ .fill('jellyfin')
+ .catch(() => {});
+ await page.waitForTimeout(1500);
+ await page
+ .locator('#hc-en')
+ .evaluate(e => /** @type {HTMLElement} */ (e).click())
+ .catch(() => {});
+ await page
+ .locator('#hc-type-ping')
+ .evaluate(e => /** @type {HTMLElement} */ (e).click())
+ .catch(() => {});
+ await page.evaluate(() => {
+ const input = /** @type {HTMLInputElement|null} */ (document.getElementById('hc-ping'));
+ if (input) input.value = 'https://example.com';
+ });
+ await page
+ .locator('#hc-ping-test')
+ .evaluate(e => /** @type {HTMLElement} */ (e).click())
+ .catch(() => {});
+ await page.waitForTimeout(2500);
+ outcomes.add(
+ `${view.name}: test: ${await page
+ .locator('#hc-ping-status')
+ .innerText()
+ .catch(() => '')}`,
+ );
+ await page.goto(`${origin}/admin`, { waitUntil: 'networkidle' });
+ await page
+ .locator('[data-sec="general"]:visible')
+ .first()
+ .click()
+ .catch(() => {});
+ const download = page.waitForEvent('download', { timeout: 5000 }).then(
+ d => `download ${d.suggestedFilename()}`,
+ () => 'no download',
+ );
+ await page
+ .locator('#btn-exp')
+ .click({ force: true })
+ .catch(() => {});
+ outcomes.add(`${view.name}: export: ${await download}`);
+ await ctx.close();
+ }
+
+ await browser.close();
+ return { exchanges, problems, errors: [...errors].sort(), outcomes: [...outcomes].sort(), changed };
+}
+
+/** @param {import('@playwright/test').Page} page */
+async function toastText(page) {
+ await page
+ .waitForFunction(() => document.getElementById('toast')?.textContent?.trim(), null, { timeout: 3000 })
+ .catch(() => {});
+ const text = await page.evaluate(() => document.getElementById('toast')?.textContent?.trim() || '');
+ await page.evaluate(() => {
+ const toast = document.getElementById('toast');
+ if (toast) toast.textContent = '';
+ });
+ return text;
+}
+
+/** @param {import('@playwright/test').Page} page */
+async function widgetText(page) {
+ const texts = [];
+ for (const frame of page.frames().filter(x => x !== page.mainFrame())) {
+ texts.push(await frame.evaluate(() => document.body?.innerText || '').catch(() => ''));
+ }
+ return texts.join('\n');
+}
+
+async function checkHeaders(origin, recording) {
+ const problems = [];
+ const targets = { ...recording.headers.files, ...recording.headers.routes };
+ for (const [target, want] of Object.entries(targets)) {
+ const r = await fetch(origin + target, { method: 'HEAD', redirect: 'manual' });
+ if (r.status !== 200) {
+ problems.push(`${target}: ${r.status}`);
+ continue;
+ }
+ for (const n of CHECKED_HEADERS) {
+ const got = r.headers.get(n) ?? undefined;
+ if (got !== want[n]) problems.push(`${target}: ${n} is ${got}, the image sends ${want[n]}`);
+ }
+ }
+ return problems;
+}
+
+async function main() {
+ const [mode, origin, tourFile, recordingFile] = process.argv.slice(2);
+ if (mode === 'discover' && tourFile) {
+ const result = await run(origin);
+ if (!result.changed) throw new Error('no widget changed in 25 seconds on the container');
+ if (result.outcomes.some(o => o.endsWith(': ') || o.endsWith('no download'))) {
+ throw new Error(`the container showed no result for an action:\n${result.outcomes.join('\n')}`);
+ }
+ fs.writeFileSync(
+ tourFile,
+ JSON.stringify({ exchanges: result.exchanges, errors: result.errors, outcomes: result.outcomes }),
+ );
+ console.log(`${result.exchanges.length} API exchanges, ${result.errors.length} console errors on the container`);
+ console.log(result.outcomes.join('\n'));
+ return;
+ }
+ if (mode === 'verify' && recordingFile) {
+ const container = JSON.parse(fs.readFileSync(tourFile, 'utf8'));
+ const baseline = new Set(container.errors);
+ const result = await run(origin);
+ const problems = [...result.problems];
+ for (const e of result.errors) if (!baseline.has(e)) problems.push(e);
+ const seen = new Set(result.outcomes);
+ for (const o of container.outcomes)
+ if (!seen.has(o)) problems.push(`the container showed "${o}", the static demo did not`);
+ for (const o of result.outcomes)
+ if (!container.outcomes.includes(o)) problems.push(`the static demo showed "${o}"`);
+ if (!result.changed) problems.push('no widget changed in 25 seconds');
+ problems.push(...(await checkHeaders(origin, JSON.parse(fs.readFileSync(recordingFile, 'utf8')))));
+ if (problems.length) {
+ console.error(problems.join('\n'));
+ process.exit(1);
+ }
+ console.log('The static demo matches the container.');
+ return;
+ }
+ console.error('usage: tour.js discover | verify ');
+ process.exit(2);
+}
+
+main().catch(e => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/demo/wrangler.jsonc b/scripts/demo/wrangler.jsonc
new file mode 100644
index 00000000..688219c6
--- /dev/null
+++ b/scripts/demo/wrangler.jsonc
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://unpkg.com/wrangler@4.151.3/config-schema.json",
+ "name": "stackyard-demo",
+ "compatibility_date": "2026-10-01",
+ "workers_dev": true,
+ "preview_urls": true,
+ "assets": {
+ "directory": "./site",
+ "html_handling": "none",
+ "not_found_handling": "none"
+ }
+}
diff --git a/test/repo/demo-build.test.js b/test/repo/demo-build.test.js
new file mode 100644
index 00000000..9d72a638
--- /dev/null
+++ b/test/repo/demo-build.test.js
@@ -0,0 +1,161 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+
+const {
+ classify,
+ splitSeries,
+ fallbackRoutes,
+ headerRules,
+ redirectRules,
+ injectScript,
+} = require('../../scripts/demo/build');
+const { writeRoutes, routePattern } = require('../../scripts/demo/source');
+
+const ok = body => [200, JSON.stringify(body)];
+
+test('an answer that never changed is fixed, one that changed is replayed', () => {
+ const frames = [
+ { 'GET /api/config': ok({ a: 1 }), 'GET /api/widget-data/w': ok({ cpu: 10 }) },
+ { 'GET /api/config': ok({ a: 1 }), 'GET /api/widget-data/w': ok({ cpu: 12 }) },
+ ];
+ const { fixed, varying } = classify(frames);
+ assert.deepEqual(Object.keys(fixed), ['GET /api/config']);
+ assert.deepEqual(varying, ['GET /api/widget-data/w']);
+});
+
+test('a series is cut into chunks named by their content', () => {
+ const frames = Array.from({ length: 5 }, (_, i) => ({ k: ok({ i }) }));
+ const chunks = splitSeries(frames, 'k', 2);
+ assert.deepEqual(
+ chunks.map(c => JSON.parse(c.json).length),
+ [2, 2, 1],
+ );
+ const changed = splitSeries([{ k: ok({ i: 9 }) }, ...frames.slice(1)], 'k', 2);
+ assert.notEqual(changed[0].name, chunks[0].name, 'new content must get a new name, or browsers keep the old chunk');
+ assert.equal(changed[1].name, chunks[1].name);
+});
+
+const ping = {
+ method: 'POST',
+ route: '/api/ping',
+ answer: ok({
+ ok: false,
+ error: 'Outbound requests are disabled in demo mode',
+ kind: 'blocked',
+ code: 'blocked.demo',
+ }),
+};
+
+test('a query the recording never saw falls back only when every recorded query failed', () => {
+ const routes = fallbackRoutes({
+ fixed: {
+ 'GET /api/icons/search?q=plex': [502, '{"error":"x"}'],
+ 'GET /api/icons/variants?ref=a': ok({ variants: ['a'] }),
+ 'GET /api/icons/variants?ref=b': [404, '{}'],
+ },
+ writes: [ping],
+ });
+ const gets = routes.filter(([r]) => r.method === 'GET').map(([r]) => r.path);
+ assert.deepEqual(gets, [routePattern('/api/icons/search')]);
+});
+
+test('an options lookup answers with the outbound refusal the image gives', () => {
+ const routes = fallbackRoutes({
+ fixed: {},
+ writes: [ping, { method: 'POST', route: '/api/widget-options/:id', answer: [400, '{}'] }],
+ });
+ const [, [status, body]] = routes.find(([r]) => r.path === routePattern('/api/widget-options/:id'));
+ assert.equal(status, 502);
+ assert.equal(JSON.parse(body).error, 'Outbound requests are disabled in demo mode');
+ assert.throws(() =>
+ fallbackRoutes({ fixed: {}, writes: [{ method: 'POST', route: '/api/widget-options/:id', answer: [400, '{}'] }] }),
+ );
+ const invalidPing = { ...ping, answer: [400, JSON.stringify({ ok: false, error: 'url required', kind: 'invalid' })] };
+ assert.throws(
+ () =>
+ fallbackRoutes({
+ fixed: {},
+ writes: [invalidPing, { method: 'POST', route: '/api/widget-options/:id', answer: [400, '{}'] }],
+ }),
+ /demo-mode refusal/,
+ 'an input error recorded for ping must not stand in for the demo refusal',
+ );
+});
+
+const BASE = {
+ 'content-security-policy': 'default',
+ 'x-frame-options': 'SAMEORIGIN',
+ 'x-content-type-options': 'nosniff',
+};
+const WIDGET = { 'content-security-policy': 'widget', 'x-content-type-options': 'nosniff' };
+
+test('header rules start from the main page and group folders that differ', () => {
+ const text = headerRules({
+ '/index.html': BASE,
+ '/js/a.js': BASE,
+ '/js/b.js': BASE,
+ '/widgets/w/index.html': WIDGET,
+ '/widgets/w/data.js': WIDGET,
+ });
+ assert.match(text, /^\/\*\n {2}Content-Security-Policy: default\n/);
+ const widgets = text.split('\n\n').find(r => r.startsWith('/widgets/*'));
+ assert.ok(widgets, text);
+ assert.match(widgets, /! Content-Security-Policy\n {2}Content-Security-Policy: widget/);
+ assert.match(widgets, /! X-Frame-Options\n/);
+ assert.doesNotMatch(widgets, /X-Frame-Options: /);
+ assert.doesNotMatch(text, /\/js\//, 'a folder with the base headers needs no rule');
+});
+
+test('a folder with mixed headers gets one rule per differing file', () => {
+ const text = headerRules({
+ '/index.html': BASE,
+ '/mix/x.js': BASE,
+ '/mix/y.js': { ...BASE, 'cache-control': 'no-store' },
+ });
+ assert.match(text, /^\/mix\/y\.js\n {2}Cache-Control: no-store$/m);
+ assert.doesNotMatch(text, /^\/mix\/\*/m);
+});
+
+test('a route that needs other headers than its folder rule stops the build', () => {
+ const files = { '/index.html': BASE, '/admin/index.html': WIDGET };
+ assert.doesNotThrow(() => headerRules(files, { '/admin/': WIDGET, '/admin': WIDGET }));
+ assert.throws(() => headerRules(files, { '/admin/': BASE }), /needs other headers/);
+});
+
+test('the base is the main page even when most files are widgets', () => {
+ const files = { '/index.html': BASE, '/w/a.js': WIDGET, '/w/b.js': WIDGET, '/w/c.js': WIDGET };
+ assert.match(headerRules(files), /^\/\*\n {2}Content-Security-Policy: default\n/);
+ assert.throws(() => headerRules({ '/w/a.js': WIDGET }), /no \/index.html/);
+});
+
+test('more rules than Cloudflare reads stops the build', () => {
+ const files = { '/index.html': BASE };
+ for (let i = 0; i < 120; i++) files[`/f${i}.js`] = { ...BASE, 'cache-control': `max-age=${i}` };
+ assert.throws(() => headerRules(files), /Cloudflare allows 100/);
+});
+
+test('each route the image answered serves its folder page', () => {
+ assert.equal(
+ redirectRules(['/admin/', '/', '/admin']),
+ '/ /index.html 200\n/admin /admin/index.html 200\n/admin/ /admin/index.html 200\n',
+ );
+});
+
+test('the replay script loads before anything else in the page', () => {
+ const html =
+ '';
+ const out = injectScript(html, '/demo-replay/replay.1.js');
+ assert.ok(out.indexOf('replay.1.js') < out.indexOf('/js/a.js'));
+ assert.ok(out.startsWith(''));
+ assert.throws(() => injectScript('no head
', '/x.js'));
+});
+
+test('every write route of the API is found, with its parameters', () => {
+ const routes = writeRoutes(path.join(__dirname, '..', '..', 'api', 'src')).map(r => `${r.method} ${r.route}`);
+ assert.ok(routes.includes('POST /api/config'));
+ assert.ok(routes.includes('POST /api/widget-options/:id'));
+ assert.ok(routes.every(r => /^(POST|PUT|PATCH|DELETE) \/api\//.test(r)));
+ assert.match('/api/widget-options/w-stats', new RegExp(routePattern('/api/widget-options/:id')));
+ assert.doesNotMatch('/api/widget-options/a/b', new RegExp(routePattern('/api/widget-options/:id')));
+});
diff --git a/test/repo/demo-replay.test.js b/test/repo/demo-replay.test.js
new file mode 100644
index 00000000..b7e30571
--- /dev/null
+++ b/test/repo/demo-replay.test.js
@@ -0,0 +1,158 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const SOURCE = fs.readFileSync(path.join(__dirname, '..', '..', 'scripts', 'demo', 'replay.js'), 'utf8');
+const ORIGIN = 'https://demo.example';
+const STEP = 5000;
+const START = 1_700_000_000_000;
+
+const index = {
+ start: START,
+ step: STEP,
+ count: 4,
+ chunk: 2,
+ fixed: {
+ 'GET /api/config': [200, '{"items":[]}'],
+ 'POST /api/config': [403, '{"code":"blocked.read-only"}'],
+ },
+ series: { '/api/widget-data/w?endpoint=system': ['c0.json', 'c1.json'] },
+ routes: [[{ method: 'GET', path: '^/api/icons/search$' }, [502, '{"error":"search"}']]],
+ missing: [503, '{"error":"missing"}'],
+};
+const chunks = {
+ 'c0.json': [
+ [200, '{"cpu":0,"ts":"2023-11-14T22:13:20.000Z"}'],
+ [200, '{"cpu":1}'],
+ ],
+ 'c1.json': [
+ [200, '{"cpu":2}'],
+ [200, '{"cpu":3}'],
+ ],
+};
+
+function page(now, intercept = () => null, storage = new Map()) {
+ const warnings = [];
+ const passed = [];
+ const reloads = [];
+ const window = {
+ fetch: async input => {
+ const url = String(input);
+ const stubbed = intercept(url);
+ if (stubbed) return stubbed;
+ if (url === '/demo-replay/index.test.json') return new Response(JSON.stringify(index));
+ const name = url.replace('/demo-replay/', '');
+ if (chunks[name]) return new Response(JSON.stringify(chunks[name]));
+ passed.push(url);
+ return new Response('static');
+ },
+ };
+ const ctx = {
+ window,
+ location: { href: `${ORIGIN}/`, origin: ORIGIN, reload: () => reloads.push(now) },
+ sessionStorage: {
+ getItem: k => (storage.has(k) ? storage.get(k) : null),
+ setItem: (k, v) => storage.set(k, String(v)),
+ },
+ Number,
+ String,
+ console: { warn: (...a) => warnings.push(a.join(' ')) },
+ Date: class extends Date {
+ static now() {
+ return now;
+ }
+ },
+ URL,
+ Request,
+ Response,
+ Map,
+ JSON,
+ Math,
+ RegExp,
+ };
+ vm.runInNewContext(SOURCE.replace('__REPLAY_INDEX__', '/demo-replay/index.test.json'), ctx);
+ const get = async (url, init) => {
+ const r = await window.fetch(url, init);
+ return { status: r.status, body: await r.text() };
+ };
+ return { get, warnings, passed, reloads };
+}
+
+test('a fixed answer replays as recorded', async () => {
+ const { get } = page(START);
+ assert.deepEqual(await get('/api/config'), { status: 200, body: '{"items":[]}' });
+});
+
+test('a write gets its own recorded answer, by method', async () => {
+ const { get } = page(START);
+ assert.equal((await get('/api/config', { method: 'POST', body: '{}' })).status, 403);
+ assert.equal((await get(new Request(`${ORIGIN}/api/config`, { method: 'POST', body: '{}' }))).status, 403);
+});
+
+test('a series follows the clock and loops', async () => {
+ const at = async ms => JSON.parse((await page(START + ms).get('/api/widget-data/w?endpoint=system')).body).cpu;
+ assert.equal(await at(0), 0);
+ assert.equal(await at(STEP), 1);
+ assert.equal(await at(3 * STEP + 4999), 3);
+ assert.equal(await at(4 * STEP), 0, 'the recording must loop');
+ assert.equal(await at(-STEP), 3, 'a clock behind the recording start must still land on a frame');
+});
+
+test('a recorded timestamp keeps the age it had when recorded', async () => {
+ const later = START + 4 * STEP * 10 + 1234;
+ const body = JSON.parse((await page(later).get('/api/widget-data/w?endpoint=system')).body);
+ assert.equal(Date.parse(body.ts), later);
+});
+
+test('an unrecorded query uses its route fallback', async () => {
+ assert.deepEqual(await page(START).get('/api/icons/search?q=anything'), { status: 502, body: '{"error":"search"}' });
+});
+
+test('an unrecorded request is reported, not hidden', async () => {
+ const p = page(START);
+ assert.equal((await p.get('/api/nothing')).status, 503);
+ assert.match(p.warnings[0], /^\[demo-replay\] not recorded: GET \/api\/nothing$/);
+});
+
+test('a failed load is retried on the next request', async () => {
+ let fail = 2;
+ const p = page(START, url => {
+ if (fail > 0) {
+ fail--;
+ return new Response('', { status: url.endsWith('index.test.json') ? 503 : 404 });
+ }
+ return null;
+ });
+ await assert.rejects(p.get('/api/config'));
+ await assert.rejects(p.get('/api/widget-data/w?endpoint=system'));
+ assert.equal(JSON.parse((await p.get('/api/widget-data/w?endpoint=system')).body).cpu, 0);
+});
+
+test('a chunk the new release no longer serves reloads the page, once a minute at most', async () => {
+ const gone = url => (url.endsWith('.json') && !url.includes('index') ? new Response('', { status: 404 }) : null);
+ const storage = new Map();
+ const first = page(START, gone, storage);
+ await assert.rejects(first.get('/api/widget-data/w?endpoint=system'));
+ await assert.rejects(first.get('/api/widget-data/w?endpoint=system'));
+ assert.equal(first.reloads.length, 1, 'repeated 404s must not reload again');
+ const soon = page(START + 30_000, gone, storage);
+ await assert.rejects(soon.get('/api/widget-data/w?endpoint=system'));
+ assert.equal(soon.reloads.length, 0, 'a reload within a minute would loop');
+ const later = page(START + 61_000, gone, storage);
+ await assert.rejects(later.get('/api/widget-data/w?endpoint=system'));
+ assert.equal(later.reloads.length, 1);
+ const offline = page(START, url =>
+ url.endsWith('.json') && !url.includes('index') ? new Response('', { status: 503 }) : null,
+ );
+ await assert.rejects(offline.get('/api/widget-data/w?endpoint=system'));
+ assert.equal(offline.reloads.length, 0, 'only a missing file means a new release');
+});
+
+test('files and other sites go to the network', async () => {
+ const p = page(START);
+ await p.get('/js/a.js');
+ await p.get('https://cdn.example/api/x');
+ assert.deepEqual(p.passed, ['/js/a.js', 'https://cdn.example/api/x']);
+});