commit 87faf968051cbf825a36d7ba407a5efebfaa888c
parent 1ddf9f95a156ac050c53f24e2b4ed82d4896f642
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 12:24:05 -0400
export: `start` serves out/ with scripts/serve-out.mjs (moment dirs served, not listed)
The report-site e2e server moves from e2e-report/serve.mjs to
scripts/serve-out.mjs and gains `/x` -> `x.html` and more content types.
`start` runs it on EXPORT_DEV_PORT (3000); the e2e:report config points at
the new path. `serve` stays a dependency (the container's site service and
the two-origin suite use it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 123 insertions(+), 108 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -110,7 +110,7 @@ Every local server's default port, from `common/lib/ports.mjs`. The primary chec
| `EDITOR_PORT` | `3001` | Editor real dev/start (`pnpm dev:editor`). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
| `PORT` | `3011` | Editor test server + Playwright editor baseURL. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
| `EXPORT_PORT` | `3010` | Export server launched by the editor e2e. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
-| `EXPORT_DEV_PORT` | `3000` | Export real dev (`pnpm dev:export`). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
+| `EXPORT_DEV_PORT` | `3000` | Export real dev (`pnpm dev:export`) and its built `out/` (`pnpm start:export`). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
| `EXPORT_E2E_PORT` | `3020` | Export's own Playwright suite. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
| `OLLAMA_STUB_PORT` | `11435` | Digest-lane stub server in the editor e2e suite. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
| `HOMEPAGE_DEV_PORT` | `3030` | Homepage real dev (`pnpm dev:homepage`). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs |
diff --git a/common/lib/ports.mjs b/common/lib/ports.mjs
@@ -35,7 +35,7 @@ export const PORTS = Object.freeze({
EDITOR_PORT: { base: 3001, what: "editor real dev/start (`pnpm dev:editor`)" },
PORT: { base: 3011, what: "editor test server + Playwright editor baseURL" },
EXPORT_PORT: { base: 3010, what: "export server launched by the editor e2e" },
- EXPORT_DEV_PORT: { base: 3000, what: "export real dev (`pnpm dev:export`)" },
+ EXPORT_DEV_PORT: { base: 3000, what: "export real dev (`pnpm dev:export`) and its built `out/` (`pnpm start:export`)" },
EXPORT_E2E_PORT: { base: 3020, what: "export's own Playwright suite" },
OLLAMA_STUB_PORT: { base: 11435, what: "digest-lane stub server in the editor e2e suite" },
HOMEPAGE_DEV_PORT: { base: 3030, what: "homepage real dev (`pnpm dev:homepage`)" },
diff --git a/export/e2e-report/serve.mjs b/export/e2e-report/serve.mjs
@@ -1,103 +0,0 @@
-// A static server for the staged cited build (playwright.report.config.ts),
-// serving a directory the way Cloudflare Pages and the container's Caddy do:
-// `/dir/` is `dir/index.html`, `/dir` redirects to `/dir/`.
-//
-// Not `serve` (export's `start`): serve-handler stats a path whose last segment
-// looks like it has an extension as a file, and lists the directory instead of
-// serving its index.html — and a span moment's directory is named
-// `<start>-<end>` in seconds with two decimals (`3126.00-3151.00`), so every
-// video and audio moment page came back as a directory listing.
-//
-// Byte ranges are honoured (a media element asks for them).
-//
-// node e2e-report/serve.mjs <dir> <port>
-
-import fs from "node:fs";
-import http from "node:http";
-import path from "node:path";
-
-const [rootArg, portArg] = process.argv.slice(2);
-if (!rootArg || !portArg) {
- console.error("usage: serve.mjs <dir> <port>");
- process.exit(2);
-}
-const ROOT = path.resolve(rootArg);
-const PORT = Number(portArg);
-
-const TYPES = {
- ".html": "text/html; charset=utf-8",
- ".txt": "text/plain; charset=utf-8",
- ".json": "application/json; charset=utf-8",
- ".js": "text/javascript; charset=utf-8",
- ".css": "text/css; charset=utf-8",
- ".csv": "text/csv; charset=utf-8",
- ".xml": "application/xml; charset=utf-8",
- ".webmanifest": "application/manifest+json",
- ".svg": "image/svg+xml",
- ".png": "image/png",
- ".ico": "image/x-icon",
- ".woff2": "font/woff2",
- ".mp4": "video/mp4",
- ".m4a": "audio/mp4",
-};
-
-function statOrNull(p) {
- try {
- return fs.statSync(p);
- } catch {
- return null;
- }
-}
-
-function sendFile(req, res, file, status = 200) {
- const size = fs.statSync(file).size;
- const headers = {
- "Content-Type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream",
- "Accept-Ranges": "bytes",
- };
- const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "") : null;
- if (range && (range[1] || range[2])) {
- let start = range[1] ? Number(range[1]) : size - Number(range[2]);
- let end = range[1] && range[2] ? Number(range[2]) : size - 1;
- start = Math.max(0, start);
- end = Math.min(end, size - 1);
- if (start > end) {
- res.writeHead(416, { "Content-Range": `bytes */${size}` }).end();
- return;
- }
- res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": end - start + 1 });
- if (req.method === "HEAD") return void res.end();
- fs.createReadStream(file, { start, end }).pipe(res);
- return;
- }
- res.writeHead(status, { ...headers, "Content-Length": size });
- if (req.method === "HEAD") return void res.end();
- fs.createReadStream(file).pipe(res);
-}
-
-http
- .createServer((req, res) => {
- const url = new URL(req.url ?? "/", "http://localhost");
- let pathname;
- try {
- pathname = decodeURIComponent(url.pathname);
- } catch {
- return void res.writeHead(400).end();
- }
- const file = path.join(ROOT, pathname);
- if (file !== ROOT && !file.startsWith(ROOT + path.sep)) return void res.writeHead(403).end();
- const st = statOrNull(file);
- if (st?.isDirectory()) {
- if (!pathname.endsWith("/")) {
- return void res.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end();
- }
- const index = path.join(file, "index.html");
- if (statOrNull(index)?.isFile()) return sendFile(req, res, index);
- } else if (st?.isFile()) {
- return sendFile(req, res, file);
- }
- const notFound = path.join(ROOT, "404.html");
- if (statOrNull(notFound)?.isFile()) return sendFile(req, res, notFound, 404);
- res.writeHead(404).end();
- })
- .listen(PORT, () => console.log(`[report-site] serving ${ROOT} on http://localhost:${PORT}`));
diff --git a/export/package.json b/export/package.json
@@ -14,7 +14,7 @@
"compose:hub": "tsx ../common/bin/archilyzer.ts compose hub",
"build": "tsx ../common/bin/archilyzer.ts build site",
"build:hub": "tsx ../common/bin/archilyzer.ts build hub",
- "start": "serve out",
+ "start": "node scripts/serve-out.mjs out ${EXPORT_DEV_PORT:-3000}",
"lint": "eslint",
"test": "tsx --test \"app/**/*.test.ts\"",
"e2e": "node ../scripts/queue-lock.mjs --ports EXPORT_E2E_PORT:3020 -- playwright test",
diff --git a/export/playwright.report.config.ts b/export/playwright.report.config.ts
@@ -9,7 +9,7 @@ import { REPORT_SITE_OUT, stageReportSite } from "./e2e-report/stage";
// The CITED report-site suite: a real static export of a cited fixture site
// (one fact-check citing every kind, its moments, their media), staged and
// built OUTSIDE export/ — e2e-report/stage.ts says why — and served as Pages
-// serves a deployed site (e2e-report/serve.mjs; not `serve`, which lists a
+// serves a deployed site (scripts/serve-out.mjs; not `serve`, which lists a
// span moment's directory instead of serving its page). What the specs see is
// what a cited build ships, and audit.spec.ts holds the same out/ to the cited
// allowlist audit.
@@ -34,7 +34,7 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
webServer: {
- command: `node e2e-report/serve.mjs ${REPORT_SITE_OUT} ${PORT}`,
+ command: `node scripts/serve-out.mjs ${REPORT_SITE_OUT} ${PORT}`,
url: `${baseURL}/site.json`,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
diff --git a/export/scripts/serve-out.mjs b/export/scripts/serve-out.mjs
@@ -0,0 +1,118 @@
+// A static server for a built site (export's `start`, and the report-site e2e
+// in playwright.report.config.ts), serving a directory the way Cloudflare
+// Pages does: `/dir/` is `dir/index.html`, `/dir` redirects to `/dir/`, `/x`
+// is `x.html` when there is one.
+//
+// Not `serve`: serve-handler stats a path whose last segment looks like it has
+// an extension as a file, and lists the directory instead of serving its
+// index.html — and a span moment's directory is named `<start>-<end>` in
+// seconds with two decimals (`3126.00-3151.00`), so every video and audio
+// moment page came back as a directory listing.
+//
+// Byte ranges are honoured (a media element asks for them).
+//
+// node scripts/serve-out.mjs <dir> <port>
+
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+
+const [rootArg, portArg] = process.argv.slice(2);
+if (!rootArg || !portArg) {
+ console.error("usage: serve-out.mjs <dir> <port>");
+ process.exit(2);
+}
+const ROOT = path.resolve(rootArg);
+const PORT = Number(portArg);
+
+const TYPES = {
+ ".html": "text/html; charset=utf-8",
+ ".txt": "text/plain; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".csv": "text/csv; charset=utf-8",
+ ".xml": "application/xml; charset=utf-8",
+ ".webmanifest": "application/manifest+json",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".ico": "image/x-icon",
+ ".woff2": "font/woff2",
+ ".woff": "font/woff",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".webp": "image/webp",
+ ".gif": "image/gif",
+ ".mp4": "video/mp4",
+ ".webm": "video/webm",
+ ".m4a": "audio/mp4",
+ ".mp3": "audio/mpeg",
+ ".vtt": "text/vtt; charset=utf-8",
+ ".srt": "text/plain; charset=utf-8",
+ ".md": "text/markdown; charset=utf-8",
+ ".zip": "application/zip",
+ ".wasm": "application/wasm",
+};
+
+function statOrNull(p) {
+ try {
+ return fs.statSync(p);
+ } catch {
+ return null;
+ }
+}
+
+function sendFile(req, res, file, status = 200) {
+ const size = fs.statSync(file).size;
+ const headers = {
+ "Content-Type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream",
+ "Accept-Ranges": "bytes",
+ };
+ const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "") : null;
+ if (range && (range[1] || range[2])) {
+ let start = range[1] ? Number(range[1]) : size - Number(range[2]);
+ let end = range[1] && range[2] ? Number(range[2]) : size - 1;
+ start = Math.max(0, start);
+ end = Math.min(end, size - 1);
+ if (start > end) {
+ res.writeHead(416, { "Content-Range": `bytes */${size}` }).end();
+ return;
+ }
+ res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": end - start + 1 });
+ if (req.method === "HEAD") return void res.end();
+ fs.createReadStream(file, { start, end }).pipe(res);
+ return;
+ }
+ res.writeHead(status, { ...headers, "Content-Length": size });
+ if (req.method === "HEAD") return void res.end();
+ fs.createReadStream(file).pipe(res);
+}
+
+http
+ .createServer((req, res) => {
+ const url = new URL(req.url ?? "/", "http://localhost");
+ let pathname;
+ try {
+ pathname = decodeURIComponent(url.pathname);
+ } catch {
+ return void res.writeHead(400).end();
+ }
+ const file = path.join(ROOT, pathname);
+ if (file !== ROOT && !file.startsWith(ROOT + path.sep)) return void res.writeHead(403).end();
+ const st = statOrNull(file);
+ if (st?.isDirectory()) {
+ if (!pathname.endsWith("/")) {
+ return void res.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end();
+ }
+ const index = path.join(file, "index.html");
+ if (statOrNull(index)?.isFile()) return sendFile(req, res, index);
+ } else if (st?.isFile()) {
+ return sendFile(req, res, file);
+ } else if (!pathname.endsWith("/") && statOrNull(`${file}.html`)?.isFile()) {
+ return sendFile(req, res, `${file}.html`);
+ }
+ const notFound = path.join(ROOT, "404.html");
+ if (statOrNull(notFound)?.isFile()) return sendFile(req, res, notFound, 404);
+ res.writeHead(404).end();
+ })
+ .listen(PORT, () => console.log(`[serve-out] serving ${ROOT} on http://localhost:${PORT}`));