diff --git a/server/static.ts b/server/static.ts index f14485e6d..2652cc229 100644 --- a/server/static.ts +++ b/server/static.ts @@ -136,6 +136,22 @@ export async function serveStaticFile( : 'no-cache' const mime = contentType(filePath) + // `no-cache` means "revalidate before reuse", which only saves bytes when + // the response carries a validator. Without one, every editor preview + // reload re-downloaded every image and font in full. + const validators = fileValidators(file.size, file.lastModified) + if (req && isNotModified(req, validators, file.lastModified)) { + return new Response(null, { + status: 304, + headers: { + 'cache-control': cacheControl, + ...validators, + // Same `vary` as the 200 this revalidates (compressed bodies only). + ...(isCompressible(filePath, file.size) ? { 'vary': 'accept-encoding' } : {}), + }, + }) + } + const buffer = await file.arrayBuffer() const bytes = new Uint8Array(buffer) as ResponseBytes const acceptEncoding = req?.headers.get('accept-encoding') ?? null @@ -154,6 +170,7 @@ export async function serveStaticFile( // Tells caches the response varies based on the request encoding, // so a gzip-only client doesn't get served a cached brotli payload. 'vary': 'accept-encoding', + ...validators, }, }) } @@ -162,10 +179,39 @@ export async function serveStaticFile( headers: { 'content-type': mime, 'cache-control': cacheControl, + ...validators, }, }) } +/** + * Weak ETag from size + mtime: any rewrite of the file (a media replace that + * keeps its path, a redeploy) changes it. Weak, because the same validator + * covers the identity, gzip and brotli bodies. + */ +function fileValidators(size: number, mtimeMs: number): { etag: string; 'last-modified': string } { + return { + etag: `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`, + 'last-modified': new Date(mtimeMs).toUTCString(), + } +} + +/** RFC 9110 §13.1: `If-None-Match` wins; `If-Modified-Since` only without it. */ +function isNotModified(req: Request, validators: { etag: string }, mtimeMs: number): boolean { + if (req.method !== 'GET' && req.method !== 'HEAD') return false + const ifNoneMatch = req.headers.get('if-none-match') + if (ifNoneMatch !== null) { + const opaque = (tag: string) => tag.trim().replace(/^W\//, '') + const current = opaque(validators.etag) + return ifNoneMatch.split(',').some((tag) => tag.trim() === '*' || opaque(tag) === current) + } + const ifModifiedSince = req.headers.get('if-modified-since') + if (ifModifiedSince === null) return false + const since = Date.parse(ifModifiedSince) + // HTTP dates have whole-second precision. + return Number.isFinite(since) && Math.floor(mtimeMs / 1000) * 1000 <= since +} + // --------------------------------------------------------------------------- // Admin shell — pre-rendered login skeleton for unauthenticated visitors. // --------------------------------------------------------------------------- diff --git a/src/__tests__/server/staticAdmin.test.ts b/src/__tests__/server/staticAdmin.test.ts index 808460b72..4c81b1782 100644 --- a/src/__tests__/server/staticAdmin.test.ts +++ b/src/__tests__/server/staticAdmin.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { handleServerRequest } from '../../../server/router' @@ -209,4 +209,69 @@ describe('self-hosted admin static serving', () => { rmSync(uploadsDir, { recursive: true, force: true }) } }) + + // `no-cache` uploads must carry a validator, or every editor preview reload + // re-downloads every image and font in full. + it('answers conditional /uploads requests with 304 until the file changes', async () => { + const uploadsDir = mkdtempSync(join(tmpdir(), 'instatic-uploads-')) + const get = (headers: Record = {}) => + handleServerRequest(new Request('http://localhost/uploads/hero.png', { headers }), { + db: fakeDb, + uploadsDir, + }) + try { + writeFileSync(join(uploadsDir, 'hero.png'), 'image-bytes') + utimesSync(join(uploadsDir, 'hero.png'), 1_700_000_000, 1_700_000_000) + + const first = await get() + expect(first.status).toBe(200) + expect(first.headers.get('cache-control')).toBe('no-cache') + const etag = first.headers.get('etag') + const lastModified = first.headers.get('last-modified') + expect(etag).toMatch(/^W\/".+"$/) + expect(lastModified).toBe(new Date(1_700_000_000_000).toUTCString()) + + const revalidated = await get({ 'if-none-match': etag! }) + expect(revalidated.status).toBe(304) + expect(await revalidated.text()).toBe('') + expect(revalidated.headers.get('etag')).toBe(etag) + expect(revalidated.headers.get('x-content-type-options')).toBe('nosniff') + + expect((await get({ 'if-none-match': `"other", ${etag}` })).status).toBe(304) + expect((await get({ 'if-modified-since': lastModified! })).status).toBe(304) + expect((await get({ 'if-modified-since': new Date(1_600_000_000_000).toUTCString() })).status).toBe(200) + // If-None-Match takes precedence over If-Modified-Since. + expect((await get({ 'if-none-match': '"stale"', 'if-modified-since': lastModified! })).status).toBe(200) + + // A replace that keeps the path changes the validator. + writeFileSync(join(uploadsDir, 'hero.png'), 'new-image-bytes') + const changed = await get({ 'if-none-match': etag! }) + expect(changed.status).toBe(200) + expect(changed.headers.get('etag')).not.toBe(etag) + expect(await changed.text()).toBe('new-image-bytes') + } finally { + rmSync(uploadsDir, { recursive: true, force: true }) + } + }) + + it('sends validators on immutable admin assets too', async () => { + const staticDir = createStaticDir() + try { + const first = await handleServerRequest(new Request('http://localhost/assets/app.js'), { + db: fakeDb, + staticDir, + }) + expect(first.headers.get('cache-control')).toBe('public, max-age=31536000, immutable') + const etag = first.headers.get('etag') + expect(etag).toBeTruthy() + + const revalidated = await handleServerRequest( + new Request('http://localhost/assets/app.js', { headers: { 'if-none-match': etag! } }), + { db: fakeDb, staticDir }, + ) + expect(revalidated.status).toBe(304) + } finally { + rmSync(staticDir, { recursive: true, force: true }) + } + }) })