feat: minification serveur à la volée (terser + fallback Bun) et cache immutable

- import terser/Bun.Transpiler (fallback si terser indisponible)
- minifySrc(): CSS via regex, JS via terser.minify (compress+mangle) sinon source
- buildFileCache asynchrone, etag stable sha1(content), cache de minification hashé
- Cache-Control: html no-store, assets ?v= immutable 1 an, sinon must-revalidate
This commit is contained in:
riricdev 2026-09-06 14:48:11 +02:00
commit bf9b3ccf69
3 changed files with 79 additions and 11 deletions

View file

@ -15,11 +15,32 @@ import { docsPage } from "./docs"
import { zmapWsHandler, zmapWsMessage, zmapWsClose, zmapWsKeepAlive } from "./zmap-ws"
import { join, normalize } from "node:path"
import { statSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync } from "node:fs"
import { randomBytes } from "node:crypto"
import { randomBytes, createHash } from "node:crypto"
import { spawn, execSync } from "node:child_process"
import os from "node:os"
type CachedFile = { data: Buffer; type: string; etag: string }
let terser: typeof import("terser") | null = null
try { terser = await import("terser") } catch {}
let trans: import("bun").Transpiler | null = null
try { trans = new Bun.Transpiler({ loader: "js", minify: { whitespace: true, syntax: true, semicolons: true } } as never) } catch {}
const MINIFY_EXT = new Set([".js", ".mjs", ".css"])
const minifyCache = new Map<string, { hash: string; data: Buffer }>()
async function minifySrc(src: string, ext: string): Promise<string> {
try {
if (ext === ".css") {
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").replace(/\s*([{};:,])\s*/g, "$1").replace(/;}/g, "}").trim()
}
if (terser) return (await terser.minify(src, { compress: true, mangle: true, format: { comments: false } })).code || src
if (trans) return trans.transformSync(src)
} catch { /* en cas d'échec on garde la source lisible */ }
return src
}
let fileCache = new Map<string, CachedFile>()
type ArticleLang = { title: string; ogTitle: string; description: string; body: string }
@ -1059,27 +1080,40 @@ const types: Record<string, string> = {
".pdf": "application/pdf",
}
function buildFileCache() {
async function buildFileCache() {
const next = new Map<string, CachedFile>()
function walk(dir: string) {
async function walk(dir: string) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) { walk(full); continue }
if (entry.isDirectory()) { await walk(full); continue }
try {
const data = readFileSync(full) as Buffer
const ext = full.slice(full.lastIndexOf(".")).toLowerCase()
const rel = full.slice(root.length)
const hash = createHash("sha1").update(data).digest("hex")
let out = data
if (MINIFY_EXT.has(ext)) {
const cached = minifyCache.get(rel)
if (cached && cached.hash === hash) {
out = cached.data
} else {
const minified = await minifySrc(data.toString("utf8"), ext)
out = Buffer.from(minified)
minifyCache.set(rel, { hash, data: out })
}
}
next.set(rel, {
data,
data: out,
type: types[ext] ?? "application/octet-stream",
etag: `"${data.length}-${Date.now()}"`,
etag: `"${hash}"`,
})
} catch {}
}
}
walk(root)
await walk(root)
fileCache = next
console.log(`[cache] ${fileCache.size} fichiers en RAM (${(process.memoryUsage().rss / 1048576).toFixed(0)} Mo RSS)`)
const saved = process.memoryUsage().rss / 1048576
console.log(`[cache] ${fileCache.size} fichiers en RAM (${saved.toFixed(0)} Mo RSS)`)
}
function startWatcher() {
@ -1108,7 +1142,7 @@ function startWatcher() {
}
console.log(`Zektyc sert ${root} sur http://${host}:${port}`)
buildFileCache()
await buildFileCache()
startWatcher()
writeSitemaps(readArticles())
@ -1201,10 +1235,15 @@ server = serve({
const ifNoneMatch = req.headers.get("if-none-match")
if (ifNoneMatch && ifNoneMatch === cached.etag) return new Response(null, { status: 304 })
const isHtml = cached.type.startsWith("text/html")
const cacheControl = isHtml
? "no-store"
: url.searchParams.has("v")
? "public, max-age=31536000, immutable"
: "public, max-age=300, must-revalidate"
return new Response(cached.data, {
headers: {
"Content-Type": cached.type,
"Cache-Control": isHtml ? "no-store" : "public, max-age=300",
"Cache-Control": cacheControl,
"ETag": cached.etag,
},
})
@ -1213,10 +1252,15 @@ server = serve({
const f = file(filePath)
if (await f.exists()) {
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase()
const cacheControl = ext === ".html"
? "no-cache"
: url.searchParams.has("v")
? "public, max-age=31536000, immutable"
: "public, max-age=300, must-revalidate"
return new Response(f, {
headers: {
"Content-Type": types[ext] ?? "application/octet-stream",
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=300",
"Cache-Control": cacheControl,
},
})
}