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:
parent
02125e473d
commit
bf9b3ccf69
3 changed files with 79 additions and 11 deletions
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue