Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
/**
|
|
* 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<Record<string, object>>,
|
|
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<Record<string, object>>, next: Next) => Promise<void> {
|
|
// The last middleware: it always answers, so it never calls `next`.
|
|
return async (context: Context<Record<string, object>>, _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]);
|
|
};
|
|
}
|