item-34 - Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control
This commit is contained in:
+52
-22
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { after, before, test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { createApp, type AppOptions } from "./app.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { createLogger } from "../logger.ts";
|
||||
@@ -20,11 +21,11 @@ import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||
@@ -286,7 +287,7 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
|
||||
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
||||
});
|
||||
|
||||
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => {
|
||||
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => {
|
||||
const NO_STORE = "private, no-store";
|
||||
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
|
||||
@@ -300,7 +301,7 @@ test("Cache-Control: pages and JSON are private, no-store unless the route sets
|
||||
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
|
||||
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
|
||||
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
|
||||
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), "public, max-age=300"); // static.test.ts covers asset caching
|
||||
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
@@ -418,6 +419,54 @@ test("renders the 500 HTML page when a handler throws", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Every /public/ reference in the page, as written in the HTML.
|
||||
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
|
||||
|
||||
test("every /public/ href the host renders is versioned and answers a year-long immutable Cache-Control", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
mkdirSync(join(dir, "styled", "views"), { recursive: true });
|
||||
mkdirSync(join(dir, "styled", "public"), { recursive: true });
|
||||
writeFileSync(join(dir, "styled", "views", "page.ejs"), `<%- include("partials/shell", { body: "<p>styled</p>", styles: ["/public/styled/app.css"] }) %>`);
|
||||
writeFileSync(join(dir, "styled", "public", "app.css"), ".styled{}");
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const styled: Plugin = { apiVersion: "1.0.0", id: "styled", routes: [{ handler: () => ({ data: {}, view: "page" }), method: "GET", path: "/page", public: true }] };
|
||||
const url = await startApp(t, [styled], dir);
|
||||
|
||||
const pages = { home: "/", notFound: "/no-such-page", plugin: "/styled/page" };
|
||||
const seen = new Set<string>();
|
||||
for (const [name, path] of Object.entries(pages)) {
|
||||
const refs = publicRefs(await (await fetch(url + path)).text());
|
||||
assert.ok(refs.length > 0, name);
|
||||
for (const ref of refs) {
|
||||
assert.match(ref, /\?v=[\w-]+$/, `${name}: ${ref}`);
|
||||
seen.add(ref);
|
||||
}
|
||||
}
|
||||
assert.ok([...seen].some((ref) => ref.startsWith("/public/styled/app.css?v=")), "the plugin's styles href");
|
||||
assert.ok([...seen].some((ref) => ref.startsWith("/public/favicon.svg?v=")), "the favicon");
|
||||
for (const ref of seen) {
|
||||
const res = await fetch(url + ref);
|
||||
assert.equal(res.status, 200, ref);
|
||||
assert.equal(res.headers.get("cache-control"), "public, max-age=31536000, immutable", ref);
|
||||
}
|
||||
});
|
||||
|
||||
test("the standalone error and consent views version their stylesheets", async () => {
|
||||
for (const view of ["403", "404", "500", "503", "error", "oauth-consent"]) {
|
||||
const html = await ejs.renderFile(join(viewsDir, `${view}.ejs`), {
|
||||
...ENGLISH_LOCALS,
|
||||
assetHref: (href: string) => `${href}?v=test`,
|
||||
consent: { account: "a@b.c", challenge: "c", client: "x", scopes: [] },
|
||||
csrfField: "csrf",
|
||||
csrfToken: "t",
|
||||
error: { message: "m" },
|
||||
});
|
||||
const refs = publicRefs(html);
|
||||
assert.ok(refs.length > 0, view);
|
||||
for (const ref of refs) assert.match(ref, /\?v=test$/, `${view}: ${ref}`);
|
||||
}
|
||||
});
|
||||
|
||||
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||
const demoPlugin: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
@@ -1664,25 +1713,6 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
|
||||
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css");
|
||||
});
|
||||
|
||||
test("contentTypeFor maps known and unknown extensions", () => {
|
||||
assert.match(contentTypeFor("a.css"), /text\/css/);
|
||||
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
|
||||
});
|
||||
|
||||
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
|
||||
const ids = new Set(["scheduling"]);
|
||||
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
|
||||
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
|
||||
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
|
||||
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
|
||||
});
|
||||
|
||||
// ---- language (i18n) ----
|
||||
|
||||
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
|
||||
|
||||
+8
-8
@@ -32,7 +32,7 @@ import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { publicAssets } from "./static.ts";
|
||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||
|
||||
const NO_STORE = "private, no-store";
|
||||
@@ -98,6 +98,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||
const assets = publicAssets(publicDir, pluginsDir, pluginIds);
|
||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
||||
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
||||
@@ -131,10 +132,10 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
t: ctx.t,
|
||||
url: ctx.url,
|
||||
});
|
||||
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
||||
// breaking the shell around it.
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
// i18n locals and `assetHref` (static.ts) go last: their names are reserved, so a handler's
|
||||
// colliding key loses instead of breaking the shell around it.
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
@@ -184,7 +185,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
||||
// Error pages can render before this request has a context at all (a throw on the way to one),
|
||||
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
|
||||
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS });
|
||||
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS, assetHref: assets.href });
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
@@ -196,8 +197,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||
await assets.serve(url, req, res, (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
import { contentTypeFor, publicAssets, resolveStaticPath, routePublic } from "./static.ts";
|
||||
|
||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
const SHORT = "public, max-age=300";
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "static-test-"));
|
||||
const publicDir = join(root, "public");
|
||||
const pluginsDir = join(root, "plugins");
|
||||
mkdirSync(join(publicDir, "css"), { recursive: true });
|
||||
mkdirSync(join(pluginsDir, "demo", "public"), { recursive: true });
|
||||
writeFileSync(join(publicDir, "css", "a.css"), "body { color: red }");
|
||||
writeFileSync(join(pluginsDir, "demo", "public", "app.css"), ".demo {}");
|
||||
const assets = publicAssets(publicDir, pluginsDir, new Set(["demo"]));
|
||||
const assetHref = assets.href;
|
||||
|
||||
let base = "";
|
||||
const server = createServer((req, res) => void assets.serve(new URL(req.url ?? "/", "http://localhost"), req, res, (err) => assert.fail(err)));
|
||||
before(() => new Promise<void>((r) => server.listen(0, () => {
|
||||
base = `http://localhost:${(server.address() as AddressInfo).port}`;
|
||||
r();
|
||||
})));
|
||||
after(() => {
|
||||
server.close();
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css");
|
||||
});
|
||||
|
||||
test("contentTypeFor maps known and unknown extensions", () => {
|
||||
assert.match(contentTypeFor("a.css"), /text\/css/);
|
||||
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
|
||||
});
|
||||
|
||||
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
|
||||
const ids = new Set(["scheduling"]);
|
||||
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
|
||||
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
|
||||
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
|
||||
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
|
||||
});
|
||||
|
||||
test("assetHref versions a /public/ file, core or plugin, and leaves every other href alone", () => {
|
||||
assert.match(assetHref("/public/css/a.css"), /^\/public\/css\/a\.css\?v=[\w-]+$/);
|
||||
assert.match(assetHref("/public/demo/app.css"), /^\/public\/demo\/app\.css\?v=[\w-]+$/);
|
||||
assert.notEqual(assetHref("/public/css/a.css"), assetHref("/public/demo/app.css"));
|
||||
for (const href of ["/public/css/missing.css", "/public/css", "/public/css/a.css?x=1", "/public/css/a.css#top", "/public/..%2f..%2fetc/passwd", "/public/%E0%A4%A", "https://cdn.example/a.css", "data:image/svg+xml,<svg/>", "/elsewhere/a.css"]) {
|
||||
assert.equal(assetHref(href), href, href);
|
||||
}
|
||||
});
|
||||
|
||||
test("assetHref changes when the file's content does", () => {
|
||||
const file = join(publicDir, "css", "changing.css");
|
||||
writeFileSync(file, "a {}");
|
||||
const first = assetHref("/public/css/changing.css");
|
||||
assert.equal(assetHref("/public/css/changing.css"), first);
|
||||
writeFileSync(file, "a { color: blue }");
|
||||
assert.notEqual(assetHref("/public/css/changing.css"), first);
|
||||
});
|
||||
|
||||
test("a URL carrying the current version is cached for a year as immutable", async () => {
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
const res = await fetch(base + assetHref("/public/css/a.css"), { method });
|
||||
assert.equal(res.status, 200, method);
|
||||
assert.equal(res.headers.get("cache-control"), IMMUTABLE, method);
|
||||
}
|
||||
assert.equal((await fetch(base + assetHref("/public/demo/app.css"))).headers.get("cache-control"), IMMUTABLE);
|
||||
});
|
||||
|
||||
test("an unversioned or stale-versioned URL is cached briefly, with an ETag and Last-Modified to revalidate", async () => {
|
||||
const lastModified = statSync(join(publicDir, "css", "a.css")).mtime.toUTCString();
|
||||
for (const path of ["/public/css/a.css", "/public/css/a.css?v=stale"]) {
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.status, 200, path);
|
||||
assert.equal(await res.text(), "body { color: red }", path);
|
||||
assert.equal(res.headers.get("cache-control"), SHORT, path);
|
||||
assert.match(res.headers.get("etag") ?? "", /^"[\w-]+"$/, path);
|
||||
assert.equal(res.headers.get("last-modified"), lastModified, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("If-None-Match naming the current ETag answers 304 with no body, for GET and HEAD", async () => {
|
||||
const etag = (await fetch(base + "/public/css/a.css")).headers.get("etag") ?? "";
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
for (const ifNoneMatch of [etag, `W/${etag}`, `"other", ${etag}`, "*"]) {
|
||||
const res = await fetch(base + "/public/css/a.css", { headers: { "if-none-match": ifNoneMatch }, method });
|
||||
assert.equal(res.status, 304, `${method} ${ifNoneMatch}`);
|
||||
assert.equal(await res.text(), "");
|
||||
assert.equal(res.headers.get("etag"), etag);
|
||||
assert.equal(res.headers.get("cache-control"), SHORT);
|
||||
}
|
||||
}
|
||||
const versioned = await fetch(base + assetHref("/public/css/a.css"), { headers: { "if-none-match": etag } });
|
||||
assert.equal(versioned.status, 304);
|
||||
assert.equal(versioned.headers.get("cache-control"), IMMUTABLE);
|
||||
|
||||
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-none-match": '"other"' } })).status, 200);
|
||||
});
|
||||
|
||||
test("If-Modified-Since at or after the file's mtime answers 304; before it, or beside a failed If-None-Match, 200", async () => {
|
||||
const lastModified = (await fetch(base + "/public/css/a.css")).headers.get("last-modified") ?? "";
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
const res = await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified }, method });
|
||||
assert.equal(res.status, 304, method);
|
||||
assert.equal(await res.text(), "");
|
||||
}
|
||||
const earlier = new Date(Date.parse(lastModified) - 1000).toUTCString();
|
||||
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": earlier } })).status, 200);
|
||||
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": "not a date" } })).status, 200);
|
||||
// RFC 9110 §13.2.2: If-None-Match, when present, decides alone.
|
||||
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified, "if-none-match": '"other"' } })).status, 200);
|
||||
});
|
||||
|
||||
test("unsafe or missing paths still answer 400, 403 and 404", async () => {
|
||||
assert.equal((await fetch(base + "/public/%E0%A4%A")).status, 400);
|
||||
assert.equal((await fetch(base + "/public/..%2f..%2fetc%2fpasswd")).status, 403);
|
||||
assert.equal((await fetch(base + "/public/css/missing.css")).status, 404);
|
||||
assert.equal((await fetch(base + "/public/css")).status, 404);
|
||||
});
|
||||
+81
-11
@@ -1,6 +1,7 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, readFileSync, statSync, type Stats } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { extname, isAbsolute, join, relative } from "node:path";
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
@@ -38,8 +39,8 @@ export interface StaticRoute {
|
||||
|
||||
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
||||
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
|
||||
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
|
||||
// and traversal-guards the sub-path as before.
|
||||
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; the sub-path is
|
||||
// decoded and traversal-guarded after.
|
||||
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
||||
const slash = restPath.indexOf("/");
|
||||
const first = slash === -1 ? restPath : restPath.slice(0, slash);
|
||||
@@ -49,28 +50,82 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
|
||||
return { dir: publicDir, subPath: restPath };
|
||||
}
|
||||
|
||||
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
|
||||
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
|
||||
// repeat page view downloads no asset; a release changes the hash, and with it the URL. Any other
|
||||
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
|
||||
// then revalidated against the ETag (that same hash) or Last-Modified.
|
||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
const SHORT = "public, max-age=300";
|
||||
|
||||
// Keyed on mtime and size, so a file edited in place (a dev bind mount) gets a new hash.
|
||||
const hashes = new Map<string, { hash: string; mtimeMs: number; size: number }>();
|
||||
|
||||
function contentHash(filePath: string, info: Stats): string {
|
||||
const known = hashes.get(filePath);
|
||||
if (known?.mtimeMs === info.mtimeMs && known.size === info.size) return known.hash;
|
||||
const hash = createHash("sha256").update(readFileSync(filePath)).digest("base64url").slice(0, 16);
|
||||
hashes.set(filePath, { hash, mtimeMs: info.mtimeMs, size: info.size });
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Synchronous because EJS templates are: a view calls it mid-render. The stat per href is the
|
||||
// price of picking up an edited file; the hash itself is read once per file version.
|
||||
function versioned(href: string, route: StaticRoute): string {
|
||||
try {
|
||||
const filePath = resolveStaticPath(route.dir, decodeURIComponent(route.subPath));
|
||||
if (filePath === null) return href;
|
||||
const info = statSync(filePath);
|
||||
return info.isFile() ? `${href}?v=${contentHash(filePath, info)}` : href;
|
||||
} catch {
|
||||
return href; // malformed %-encoding or a missing file: the request answers 400/404 on its own
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 9110 §13.1.2, weak comparison: `W/"x"` matches `"x"`, and `*` matches any existing file.
|
||||
function etagMatches(ifNoneMatch: string, etag: string): boolean {
|
||||
return ifNoneMatch.split(",").some((tag) => {
|
||||
const t = tag.trim();
|
||||
return t === "*" || t.replace(/^W\//, "") === etag;
|
||||
});
|
||||
}
|
||||
|
||||
// RFC 9110 §13.2.2: If-None-Match, when present, decides alone; HTTP dates have whole seconds.
|
||||
function notModified(req: IncomingMessage, etag: string, info: Stats): boolean {
|
||||
const ifNoneMatch = req.headers["if-none-match"];
|
||||
if (ifNoneMatch !== undefined) return etagMatches(ifNoneMatch, etag);
|
||||
const since = Date.parse(req.headers["if-modified-since"] ?? "");
|
||||
return !Number.isNaN(since) && Math.floor(info.mtimeMs / 1000) * 1000 <= since;
|
||||
}
|
||||
|
||||
function plain(res: ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
||||
}
|
||||
|
||||
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
|
||||
// static.ts stays standalone, while app.ts passes the request logger for structured output.
|
||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
|
||||
interface StaticRequest extends StaticRoute {
|
||||
version: string | null; // the request's `v` query parameter
|
||||
}
|
||||
|
||||
async function serveStatic(route: StaticRequest, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void): Promise<void> {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(requestedPath);
|
||||
decoded = decodeURIComponent(route.subPath);
|
||||
} catch {
|
||||
return plain(res, 400, "Bad Request");
|
||||
}
|
||||
|
||||
const filePath = resolveStaticPath(dir, decoded);
|
||||
const filePath = resolveStaticPath(route.dir, decoded);
|
||||
if (filePath === null) return plain(res, 403, "Forbidden");
|
||||
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||
if (head) return void res.end(); // headers only — skip opening the file
|
||||
const hash = contentHash(filePath, info);
|
||||
const etag = `"${hash}"`;
|
||||
const validators = { "cache-control": route.version === hash ? IMMUTABLE : SHORT, etag, "last-modified": info.mtime.toUTCString() };
|
||||
if (notModified(req, etag, info)) return void res.writeHead(304, validators).end();
|
||||
res.writeHead(200, { ...validators, "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||
if (req.method === "HEAD") return void res.end(); // headers only — skip opening the file
|
||||
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
|
||||
// log and destroy the response to signal a truncated body, not a hung socket.
|
||||
createReadStream(filePath)
|
||||
@@ -83,3 +138,18 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
|
||||
plain(res, 404, "Not Found");
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublicAssets {
|
||||
href: (href: string) => string; // a /public/ href with its version; any other href unchanged
|
||||
// onError handles a mid-stream read failure (headers already sent), e.g. with the request logger.
|
||||
serve: (url: URL, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void) => Promise<void>;
|
||||
}
|
||||
|
||||
// The /public/ namespace both ways: the hrefs views render, and the requests those hrefs make.
|
||||
export function publicAssets(publicDir: string, pluginsDir: string, pluginIds: Set<string>): PublicAssets {
|
||||
const route = (pathname: string): StaticRoute => routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
return {
|
||||
href: (href) => (href.startsWith("/public/") && !/[?#]/.test(href) ? versioned(href, route(href)) : href),
|
||||
serve: (url, req, res, onError) => serveStatic({ ...route(url.pathname), version: url.searchParams.get("v") }, req, res, onError),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user