/cdn: a publish reaches browsers in minutes. Moving URLs revalidate after 5 min with an ETag and 304s; hashed names are immutable
This commit is contained in:
parent
3408088647
commit
b7cd91d3e3
3 changed files with 109 additions and 2 deletions
|
|
@ -11,6 +11,7 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, normalize } from 'node:path'
|
||||
import { serveTreeFile } from './cdn-cache.mjs'
|
||||
|
||||
const env = process.env
|
||||
const ADMIN = (env.OIDC_ADMIN_GROUPS || '').split(',').map((g) => g.trim()).filter(Boolean)
|
||||
|
|
@ -101,7 +102,8 @@ if (Object.keys(SUBDOMAINS).length) {
|
|||
if (!f.startsWith(CDN + '/dx-maps/')) return text('not found', 404)
|
||||
const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f
|
||||
if (!existsSync(file)) return text('not found', 404)
|
||||
return new Response(Bun.file(file), { headers })
|
||||
// cache by what the file is (cdn-cache.mjs): moving URLs revalidate in 5 min, with an ETag + 304
|
||||
return serveTreeFile(req, file, file.slice(CDN.length), { 'access-control-allow-origin': '*' })
|
||||
},
|
||||
})
|
||||
console.log(`[admin] subdomain apps: ${Object.entries(SUBDOMAINS).map(([h, t]) => `${h} → ${t}`).join(', ')}`)
|
||||
|
|
@ -121,7 +123,9 @@ Bun.serve({
|
|||
if (!f.startsWith(CDN)) return text('bad path', 400)
|
||||
const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f
|
||||
if (!existsSync(file)) return text('not found', 404)
|
||||
return new Response(Bun.file(file), { headers: { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400, stale-while-revalidate=604800' } })
|
||||
// Was one day + a week's stale-while-revalidate for everything, with no ETag, so a publish took days to
|
||||
// reach a browser. Now by what the file is (cdn-cache.mjs), with an ETag and 304s.
|
||||
return serveTreeFile(req, file, file.slice(CDN.length), { 'access-control-allow-origin': '*' })
|
||||
}
|
||||
if (p.startsWith('/api-prod/')) {
|
||||
if (!p.startsWith('/api-prod/api/')) return text('only /api/ is public', 403)
|
||||
|
|
|
|||
45
deployment/cloud/cdn-cache.mjs
Normal file
45
deployment/cloud/cdn-cache.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// cdn-cache.mjs — how the container serves a file from the published tree (/cdn/… and the subdomain apps).
|
||||
// "Publish once, updates everywhere" needs browsers to notice a publish within minutes, so:
|
||||
// MOVING — URLs whose bytes change on a publish under the same name (manifest.json, runtime-config.js,
|
||||
// every HTML page, the bundles' entry scripts, COMMIT): max-age=300, then revalidate.
|
||||
// HASHED — a content hash in the file name (name-3f9a1c2b.js, name.3f9a1c2b.css): the bytes never change
|
||||
// under that name, so a year + immutable. (The tree has none today; the rule is ready for them.)
|
||||
// DATA — everything else (tables, boundaries, previews, fonts, vendor): max-age=3600, then revalidate.
|
||||
// Every response carries an ETag (size + mtime; each publish writes a new build folder, so it changes on
|
||||
// publish), and a matching If-None-Match gets a 304 with no body.
|
||||
import { statSync } from 'node:fs'
|
||||
|
||||
const HASHED = /[.-][0-9a-f]{8,}\.[a-z0-9]+$/i
|
||||
const MOVING = /(^|\/)(manifest\.json|runtime-config\.js|COMMIT|[^/]+\.html?)$/i
|
||||
|
||||
export function cacheClass(relPath) {
|
||||
const p = relPath.replace(/^\/+/, '')
|
||||
if (HASHED.test(p)) return 'hashed'
|
||||
if (MOVING.test(p)) return 'moving'
|
||||
// a bundle's entry script sits beside its folder name: <bundle>/<bundle>.js (and its .css)
|
||||
const m = p.match(/(?:^|\/)([^/]+)\/\1\.(js|css)$/)
|
||||
if (m) return 'moving'
|
||||
return 'data'
|
||||
}
|
||||
|
||||
export const CACHE_CONTROL = {
|
||||
moving: 'public, max-age=300',
|
||||
hashed: 'public, max-age=31536000, immutable',
|
||||
data: 'public, max-age=3600',
|
||||
}
|
||||
|
||||
export function etagFor(file) {
|
||||
const s = statSync(file)
|
||||
return `"${s.size.toString(16)}-${Math.floor(s.mtimeMs).toString(16)}"`
|
||||
}
|
||||
|
||||
// Serves `file` (already resolved and checked to exist) for `req`; relPath is the path inside the tree.
|
||||
export function serveTreeFile(req, file, relPath, baseHeaders = {}) {
|
||||
const etag = etagFor(file)
|
||||
const headers = { ...baseHeaders, 'cache-control': CACHE_CONTROL[cacheClass(relPath)], etag }
|
||||
const inm = req.headers.get('if-none-match')
|
||||
if (inm && inm.split(',').map((t) => t.trim().replace(/^W\//, '')).includes(etag)) {
|
||||
return new Response(null, { status: 304, headers })
|
||||
}
|
||||
return new Response(Bun.file(file), { headers })
|
||||
}
|
||||
58
internal/check-cdn-cache.mjs
Normal file
58
internal/check-cdn-cache.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// check-cdn-cache.mjs — the container's /cdn cache rules (deployment/cloud/cdn-cache.mjs), measured over real HTTP.
|
||||
// Serves a throwaway tree with the SAME function admin.mjs uses and checks, per kind of URL:
|
||||
// moving (manifest.json, an index page, a bundle's entry script) → max-age=300 + ETag, and a 304 on If-None-Match;
|
||||
// hashed (a content hash in the name) → a year + immutable; data (a table, a font) → max-age=3600 + ETag;
|
||||
// a changed file (a new publish) → a NEW ETag, so a stale If-None-Match gets 200 with the new bytes.
|
||||
// Run: bun internal/check-cdn-cache.mjs (exit 1 on any failure)
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { serveTreeFile, cacheClass } from '../deployment/cloud/cdn-cache.mjs'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'cdn-cache-'))
|
||||
const put = (rel, body) => { const f = join(root, rel); mkdirSync(join(f, '..'), { recursive: true }); writeFileSync(f, body); return f }
|
||||
put('dx-maps/manifest.json', '{"bundles":[]}')
|
||||
put('dx-maps/doxa-simple-map/doxa-simple-map.js', 'console.log(1)')
|
||||
put('dx-maps/doxa-simple-map/simple/index.html', '<!doctype html>')
|
||||
put('dx-maps/assets/tables/countries.json', '[]')
|
||||
put('dx-maps/assets/fonts/oswald-400.woff2', 'font')
|
||||
put('dx-maps/assets/chunk-3f9a1c2b.js', 'x')
|
||||
|
||||
const server = Bun.serve({ port: 0, fetch(req) {
|
||||
const rel = decodeURIComponent(new URL(req.url).pathname)
|
||||
return serveTreeFile(req, join(root, rel), rel, { 'access-control-allow-origin': '*' })
|
||||
} })
|
||||
const base = `http://127.0.0.1:${server.port}`
|
||||
const fails = []
|
||||
const expect = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails.push(msg) }
|
||||
|
||||
const CASES = [
|
||||
['/dx-maps/manifest.json', 'moving', 'public, max-age=300'],
|
||||
['/dx-maps/doxa-simple-map/doxa-simple-map.js', 'moving', 'public, max-age=300'],
|
||||
['/dx-maps/doxa-simple-map/simple/index.html', 'moving', 'public, max-age=300'],
|
||||
['/dx-maps/assets/tables/countries.json', 'data', 'public, max-age=3600'],
|
||||
['/dx-maps/assets/fonts/oswald-400.woff2', 'data', 'public, max-age=3600'],
|
||||
['/dx-maps/assets/chunk-3f9a1c2b.js', 'hashed', 'public, max-age=31536000, immutable'],
|
||||
]
|
||||
for (const [path, cls, cc] of CASES) {
|
||||
expect(cacheClass(path) === cls, `${path} is ${cls}`)
|
||||
const r = await fetch(base + path)
|
||||
const etag = r.headers.get('etag')
|
||||
expect(r.status === 200 && r.headers.get('cache-control') === cc, `${path} → 200, cache-control "${cc}" (got ${r.status}, "${r.headers.get('cache-control')}")`)
|
||||
expect(!!etag && r.headers.get('access-control-allow-origin') === '*', `${path} carries an ETag and CORS`)
|
||||
const r2 = await fetch(base + path, { headers: { 'if-none-match': etag } })
|
||||
expect(r2.status === 304 && (await r2.text()) === '', `${path} with If-None-Match → 304, empty body (got ${r2.status})`)
|
||||
}
|
||||
|
||||
// a publish rewrites the file: the ETag changes, so a browser holding the old one gets the new bytes
|
||||
const m = '/dx-maps/manifest.json'
|
||||
const old = (await fetch(base + m)).headers.get('etag')
|
||||
const f = put('dx-maps/manifest.json', '{"bundles":[{"bundle":"new"}]}')
|
||||
utimesSync(f, new Date(), new Date(Date.now() + 5000))
|
||||
const after = await fetch(base + m, { headers: { 'if-none-match': old } })
|
||||
expect(after.status === 200 && after.headers.get('etag') !== old && (await after.text()).includes('new'), 'after a publish, the old ETag gets 200 + the new bytes')
|
||||
|
||||
server.stop(true)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
if (fails.length) { console.log(`\n${fails.length} FAILED`); process.exit(1) }
|
||||
console.log('\nall cdn cache rules hold')
|
||||
Loading…
Reference in a new issue