/**
* Static asset serving for vendored browser-side JS.
*
* Serves files from `lib/web/static/` over `GET /static/<path>`.
* Whitelist-based: only files we explicitly know about are servable, so
* there is NO way for a client to read arbitrary files via path traversal.
*
* Content types are inferred from extension.
*
* Caching: assets are served `immutable` with a one-year max-age, and the
* layout stamps `?v=<ASSET_VERSION>` onto every asset URL (see helpers.js).
* The URL therefore changes on every release, which is what makes the long
* max-age safe — a deploy can never leave a browser pinned to stale JS.
*
* This replaced a blanket `no-cache, must-revalidate`. That was fine when
* the daemon was reached over localhost, but 7rnx.net/git proxies to it
* across a ~96ms VPN hop: with no validator and no max-age, all ten assets
* in the layout were re-downloaded in full on every single page view, at
* ~0.45s per round-trip and no possibility of a 304.
*/
import { readFile } from '#node:fs/promises'
import { readFileSync } from '#node:fs'
import { fileURLToPath } from '#node:url'
import { dirname, join, normalize } from '#node:path'
import { acceptsGzip, gzipIfWorthIt } from './compress.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const STATIC_DIR = join(__dirname, 'static')
const ONE_YEAR = 60 * 60 * 24 * 365
const ONE_HOUR = 60 * 60
/**
* Cache-busting token stamped onto asset URLs by the layout.
*
* Read from package.json the same way server.js derives the version it
* registers with the sidecar. If it can't be read we fall back to `dev`
* and serve uncacheable — better a slow page than a browser stuck on a
* stale bundle with no way to bust it.
*/
export const ASSET_VERSION = (() => {
try {
const pkg = JSON.parse(
readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')
)
return pkg.version ? String(pkg.version) : 'dev'
} catch {
return 'dev'
}
})()
const VERSIONED = ASSET_VERSION !== 'dev'
const VERSION_QUERY = '?v=' + encodeURIComponent(ASSET_VERSION)
/**
* Cache policy for one request. Carrying the current `?v=` token is what
* unlocks `immutable` — the URL is then guaranteed to change on the next
* release, so a year-long cache can never go stale.
*
* Assets requested WITHOUT the token get an hour plus ETag revalidation.
* That is not a fallback so much as a requirement: prism-autoloader builds
* its own URLs from `languages_path` and appends the filename itself, so
* the ~200 files under vendor/prism-components/ cannot be stamped. Serving
* those immutable for a year would leave them un-bustable across a prism
* upgrade.
*/
function cacheControl (req) {
if (!VERSIONED) return 'no-cache, must-revalidate'
const stamped = !!req && typeof req.url === 'string' &&
req.url.includes(VERSION_QUERY)
return stamped
? `public, max-age=${ONE_YEAR}, immutable`
: `public, max-age=${ONE_HOUR}`
}
// Whitelist of servable files. Anything not in this list returns 404 even
// if the file exists on disk. This blocks path traversal AND limits the
// API surface to only what the bootstrap script needs.
// Allowed file extensions for static serving. Anything else is blocked.
const ALLOWED_EXTENSIONS = new Set(['.js', '.css', '.json', '.map'])
const CONTENT_TYPES = {
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json'
}
function contentType (path) {
const dot = path.lastIndexOf('.')
if (dot < 0) return 'application/octet-stream'
return CONTENT_TYPES[path.slice(dot)] || 'application/octet-stream'
}
/**
* Serve a static file from the whitelist. Returns true if handled,
* false if the path is unknown / not in the whitelist.
*
* @param {string} relPath - the path AFTER /static/, e.g. "vendor/noble-ed25519.js"
* @param {http.ServerResponse} res
* @param {http.IncomingMessage} [req] - needed to honour If-None-Match
* @returns {Promise<boolean>}
*/
export async function serveStatic (relPath, res, req) {
if (!relPath || typeof relPath !== 'string') return false
// Strip leading slashes
let clean = relPath.replace(/^\/+/, '')
// Normalize and reject any traversal
const normalized = normalize(clean)
if (normalized.startsWith('..') || normalized.includes('/..')) return false
if (normalized !== clean) return false
// Extension allowlist instead of file whitelist (prism-components has 200+ files)
const ext = normalized.lastIndexOf('.') >= 0 ? normalized.slice(normalized.lastIndexOf('.')) : ''
if (!ALLOWED_EXTENSIONS.has(ext)) return false
const filePath = join(STATIC_DIR, normalized)
let body
try {
body = await readFile(filePath)
} catch {
return false
}
// Weak-ish validator: the release version plus the byte length. Content
// hashing would be stronger, but the URL is already version-stamped, so
// this only has to catch "same asset, same release" — and it costs no
// extra read. Gives a cheap 304 on the `dev` path, where we can't send a
// max-age at all.
const etag = `"${ASSET_VERSION}-${body.byteLength}"`
const cc = cacheControl(req)
if (req && req.headers && req.headers['if-none-match'] === etag) {
res.writeHead(304, {
ETag: etag,
'Cache-Control': cc
})
res.end()
return true
}
// Content-Length is intentionally NOT set here: res.end(body) derives it.
// Setting it explicitly AND passing a body makes bare-http1 emit a second
// Content-Length header, which its (stricter-than-node) parser rejects.
const headers = {
'Content-Type': contentType(normalized),
'Cache-Control': cc,
ETag: etag,
Vary: 'Accept-Encoding'
}
// The assets are all JS/CSS/JSON — text that compresses well, and every
// one of them crosses the same slow proxy hop as the page itself.
const gz = acceptsGzip(req) ? gzipIfWorthIt(body) : null
if (gz) {
headers['Content-Encoding'] = 'gzip'
res.writeHead(200, headers)
res.end(gz)
return true
}
res.writeHead(200, headers)
res.end(body)
return true
}