import { Context, Next, send } from "@oak/oak"; import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { emojiToCodepoint } from "./site-icons.ts"; const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); // index.html with its placeholders filled, by root. Kept until the file // changes (a rebuild under a running server): a stat per navigation instead of // a read and four replaceAll. const indexHtml = new Map(); async function loadIndexHtml(root: string): Promise { const path = `${root}/index.html`; const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0; const cached = indexHtml.get(root); if (cached && cached.mtime === mtime) return cached.html; const html = (await Deno.readTextFile(path)) .replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); indexHtml.set(root, { mtime, html }); return html; } async function serveIndexHtml( context: Context>, root: string, ) { const html = await loadIndexHtml(root); context.response.type = "text/html"; // Always revalidated: it names the hashed chunks of the current build. context.response.headers.set("Cache-Control", "no-cache"); context.response.body = html; } // Build output whose URL changes whenever its content does: Vite's hashed // chunks, and the emoji data under its package version (see vite.config.ts). const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"]; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; export function routeStaticFilesFrom(staticPaths: string[]) { return async (context: Context>, next: Next) => { const pathname = context.request.url.pathname; // Serve index.html with runtime placeholder replacement if (pathname === "/" || pathname === "/index.html") { await serveIndexHtml(context, staticPaths[0]); return; } const immutable = IMMUTABLE_PREFIXES.some((prefix) => pathname.startsWith(prefix) ); // A request without Accept-Encoding technically accepts any coding (RFC // 9110), but in practice it's curl or a bot that can't decode brotli. const compress = context.request.headers.has("accept-encoding"); for (const path of staticPaths) { try { // send() picks a prebuilt .br or .gz sibling when the browser accepts it. await send(context, pathname, { root: path, brotli: compress, gzip: compress, ...(immutable && { maxage: ONE_YEAR_MS, immutable: true }), }); context.response.headers.append("Vary", "Accept-Encoding"); return; } catch { continue; } } // A chunk from an older build (an open tab after a deploy) must fail as a // 404, not load index.html as a script; the app reloads on that failure. if (immutable) { context.response.status = 404; return; } // SPA fallback: serve index.html so client-side routes work on direct navigation await serveIndexHtml(context, staticPaths[0]); await next(); }; }