/** * Serves the built frontend, as its static files and, for every client-side * route, `index.html` with its placeholders filled. main.ts installs it after * the routers. * * @module */ import { Context, Next, send } from "@oak/oak"; import { loadIndexHtml } from "./index-html.ts"; async function serveIndexHtml( context: Context>, root: string, ) { const html = await loadIndexHtml(root); context.response.type = "text/html"; // It names the hashed chunks of the current build, so it is always // revalidated. context.response.headers.set("Cache-Control", "no-cache"); context.response.body = html; } /** * Prefixes the build output whose URL changes whenever its content does, which * are 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; /** * Returns the middleware that serves a static file from the first of * `staticPaths` that has it, and `index.html` of the first for any other path. * Hashed build output is cached for a year, and a missing chunk is a 404. * main.ts passes dist/ then public/. */ export function routeStaticFilesFrom( staticPaths: string[], ): (context: Context>, next: Next) => Promise { // The last middleware: it always answers, so it never calls `next`. return async (context: Context>, _next: Next) => { const pathname = context.request.url.pathname; 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; } // Any other path is a client-side route, opened directly or reloaded. await serveIndexHtml(context, staticPaths[0]); }; }