Files
gacha-event-tracker/serve.ts
T
Lucas WintherandClaude Opus 5 f2cf9ba0cb fix: make the server tests hermetic
They read from public/, which does not exist on a clean checkout — CI runs
bun test before any build, so the four of them failed there while passing
locally against stale build output.

serve.ts now takes PUBLIC_DIR, and the tests serve a temporary tree they
create themselves. They exercise serve.ts rather than the build, which is
what they were always meant to do.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:26:01 +02:00

98 lines
3.3 KiB
TypeScript

/**
* Static file server for the built app.
*
* A placeholder for the Bun server in docs/ARCHITECTURE.md: it serves what is
* in public/ and nothing else. When the real server lands it will add /api/*
* and the ingest scheduler, and this file goes away.
*
* Deliberately minimal — no framework, no dependencies beyond Bun.
*/
import { resolve } from "node:path";
const PORT = Number(process.env.PORT ?? 3000);
/** Overridable so tests can point at a fixture tree instead of the build. */
const ROOT = process.env.PUBLIC_DIR ?? "public";
const ROOT_DIR = resolve(ROOT);
/** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */
const CACHE: Record<string, string> = {
".html": "public, max-age=0, must-revalidate",
".json": "public, max-age=300",
".js": "public, max-age=3600",
".css": "public, max-age=3600",
".svg": "public, max-age=86400",
".webmanifest": "public, max-age=3600",
};
function extname(path: string): string {
const i = path.lastIndexOf(".");
return i === -1 ? "" : path.slice(i);
}
const server = Bun.serve({
port: PORT,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
const feed = Bun.file(resolve(ROOT_DIR, "data/events.v1.json"));
const ok = await feed.exists();
return Response.json(
{ status: ok ? "ok" : "no-feed", generatedAt: new Date().toISOString() },
{ status: ok ? 200 : 503 },
);
}
let decoded: string;
try {
decoded = decodeURIComponent(url.pathname);
} catch {
return new Response("Bad request", { status: 400 });
}
if (decoded.includes("\0")) {
return new Response("Bad request", { status: 400 });
}
const path = decoded === "/" ? "/index.html" : decoded;
// Confine every read to public/ by resolving the path and checking it is
// still inside the root. String-matching ".." is not enough: encodings and
// URL normalisation both change what the string looks like, and only the
// resolved path tells the truth about which file would be opened.
const resolved = resolve(ROOT_DIR, `.${path}`);
if (resolved !== ROOT_DIR && !resolved.startsWith(`${ROOT_DIR}/`)) {
return new Response("Bad request", { status: 400 });
}
const file = Bun.file(resolved);
if (await file.exists()) {
return new Response(file, {
headers: {
"cache-control": CACHE[extname(path)] ?? "public, max-age=600",
// The service worker must never be served stale, or a deploy can be
// pinned by an old worker indefinitely.
...(path === "/sw.js"
? { "cache-control": "no-cache", "service-worker-allowed": "/" }
: {}),
},
});
}
// Single-page app: unknown paths fall back to the shell so client routing
// and deep links work. Anything under /data or /api is a genuine 404.
if (!path.startsWith("/data") && !path.startsWith("/api")) {
const shell = Bun.file(resolve(ROOT_DIR, "index.html"));
if (await shell.exists()) {
return new Response(shell, {
headers: { "cache-control": "public, max-age=0, must-revalidate" },
});
}
}
return new Response("Not found", { status: 404 });
},
});
console.log(`Event Clock serving ${ROOT} on http://localhost:${server.port}`);