Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control #193
+2
-2
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user