Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control #193

Merged
lilleman merged 7 commits from item-34 into main 2026-10-31 10:32:20 +01:00
3 changed files with 12 additions and 11 deletions
Showing only changes of commit c38ad74e3a - Show all commits
+2 -2
View File
@@ -10,8 +10,8 @@ at boot. Entries start at 0.3.0.
Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as
immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and
the standalone error and consent pages. A release changes the hash, so browsers fetch a changed file the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script
a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or
`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses. `Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses.
+1 -1
View File
@@ -132,7 +132,7 @@ export function createApp(options: AppOptions = {}): Server {
t: ctx.t, t: ctx.t,
url: ctx.url, url: ctx.url,
}); });
// i18n locals and `assetHref` (static.ts) go last: their names are reserved, so a handler's // i18n locals and `assetHref` go last: their names are reserved, so a handler's
// colliding key loses instead of breaking the shell around it. // 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 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 pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
+9 -8
View File
@@ -39,8 +39,8 @@ export interface StaticRoute {
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered // 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 // 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; the sub-path is // URL-safe (no %-encoding), so the raw segment compares directly to the id set; the caller decodes
// decoded and traversal-guarded after. // and traversal-guards the sub-path.
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute { export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
const slash = restPath.indexOf("/"); const slash = restPath.indexOf("/");
const first = slash === -1 ? restPath : restPath.slice(0, slash); const first = slash === -1 ? restPath : restPath.slice(0, slash);
@@ -52,13 +52,15 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the // 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 // 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 // repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes, // 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. // then revalidated against the ETag (that same hash) or Last-Modified. A plugin has no way to version
// its own hrefs yet: `assetHref` is a reserved local but not contract, since offering it is a minor.
const IMMUTABLE = "public, max-age=31536000, immutable"; const IMMUTABLE = "public, max-age=31536000, immutable";
const SHORT = "public, max-age=300"; 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. // An entry holds while the file's mtime and size match, 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 }>(); const hashes = new Map<string, { hash: string; mtimeMs: number; size: number }>();
function contentHash(filePath: string, info: Stats): string { function contentHash(filePath: string, info: Stats): string {
@@ -70,7 +72,7 @@ function contentHash(filePath: string, info: Stats): string {
} }
// Synchronous because EJS templates are: a view calls it mid-render. The stat per href is the // 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. // price of picking up an edited file; the file is read and hashed once per version.
function versioned(href: string, route: StaticRoute): string { function versioned(href: string, route: StaticRoute): string {
try { try {
const filePath = resolveStaticPath(route.dir, decodeURIComponent(route.subPath)); const filePath = resolveStaticPath(route.dir, decodeURIComponent(route.subPath));
@@ -141,11 +143,10 @@ async function serveStatic(route: StaticRequest, req: IncomingMessage, res: Serv
export interface PublicAssets { export interface PublicAssets {
href: (href: string) => string; // a /public/ href with its version; any other href unchanged 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. // onError handles a mid-stream read failure (headers already sent).
serve: (url: URL, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void) => Promise<void>; 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 { export function publicAssets(publicDir: string, pluginsDir: string, pluginIds: Set<string>): PublicAssets {
const route = (pathname: string): StaticRoute => routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds); const route = (pathname: string): StaticRoute => routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
return { return {