Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions server/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
})
}
Expand All @@ -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.
// ---------------------------------------------------------------------------
Expand Down
67 changes: 66 additions & 1 deletion src/__tests__/server/staticAdmin.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, string> = {}) =>
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 })
}
})
})
Loading