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 e0811492fa
3 changed files with 79 additions and 11 deletions

View file

@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"marked": "^18.0.9", "marked": "^18.0.9",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"terser": "^5.51.2",
"wordpress-honeypot": "^1.1.2", "wordpress-honeypot": "^1.1.2",
}, },
"devDependencies": { "devDependencies": {
@ -74,12 +75,28 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="], "marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="],
@ -88,6 +105,12 @@
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"terser": ["terser@5.51.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

View file

@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"marked": "^18.0.9", "marked": "^18.0.9",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"terser": "^5.51.2",
"wordpress-honeypot": "^1.1.2" "wordpress-honeypot": "^1.1.2"
} }
} }

View file

@ -15,11 +15,32 @@ import { docsPage } from "./docs"
import { zmapWsHandler, zmapWsMessage, zmapWsClose, zmapWsKeepAlive } from "./zmap-ws" import { zmapWsHandler, zmapWsMessage, zmapWsClose, zmapWsKeepAlive } from "./zmap-ws"
import { join, normalize } from "node:path" import { join, normalize } from "node:path"
import { statSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync } from "node:fs" 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 { spawn, execSync } from "node:child_process"
import os from "node:os" import os from "node:os"
type CachedFile = { data: Buffer; type: string; etag: string } 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>() let fileCache = new Map<string, CachedFile>()
type ArticleLang = { title: string; ogTitle: string; description: string; body: string } type ArticleLang = { title: string; ogTitle: string; description: string; body: string }
@ -1059,27 +1080,40 @@ const types: Record<string, string> = {
".pdf": "application/pdf", ".pdf": "application/pdf",
} }
function buildFileCache() { async function buildFileCache() {
const next = new Map<string, CachedFile>() const next = new Map<string, CachedFile>()
function walk(dir: string) { async function walk(dir: string) {
for (const entry of readdirSync(dir, { withFileTypes: true })) { for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name) const full = join(dir, entry.name)
if (entry.isDirectory()) { walk(full); continue } if (entry.isDirectory()) { await walk(full); continue }
try { try {
const data = readFileSync(full) as Buffer const data = readFileSync(full) as Buffer
const ext = full.slice(full.lastIndexOf(".")).toLowerCase() const ext = full.slice(full.lastIndexOf(".")).toLowerCase()
const rel = full.slice(root.length) 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, { next.set(rel, {
data, data: out,
type: types[ext] ?? "application/octet-stream", type: types[ext] ?? "application/octet-stream",
etag: `"${data.length}-${Date.now()}"`, etag: `"${hash}"`,
}) })
} catch {} } catch {}
} }
} }
walk(root) await walk(root)
fileCache = next 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() { function startWatcher() {
@ -1108,7 +1142,7 @@ function startWatcher() {
} }
console.log(`Zektyc sert ${root} sur http://${host}:${port}`) console.log(`Zektyc sert ${root} sur http://${host}:${port}`)
buildFileCache() await buildFileCache()
startWatcher() startWatcher()
writeSitemaps(readArticles()) writeSitemaps(readArticles())
@ -1201,10 +1235,15 @@ server = serve({
const ifNoneMatch = req.headers.get("if-none-match") const ifNoneMatch = req.headers.get("if-none-match")
if (ifNoneMatch && ifNoneMatch === cached.etag) return new Response(null, { status: 304 }) if (ifNoneMatch && ifNoneMatch === cached.etag) return new Response(null, { status: 304 })
const isHtml = cached.type.startsWith("text/html") 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, { return new Response(cached.data, {
headers: { headers: {
"Content-Type": cached.type, "Content-Type": cached.type,
"Cache-Control": isHtml ? "no-store" : "public, max-age=300", "Cache-Control": cacheControl,
"ETag": cached.etag, "ETag": cached.etag,
}, },
}) })
@ -1213,10 +1252,15 @@ server = serve({
const f = file(filePath) const f = file(filePath)
if (await f.exists()) { if (await f.exists()) {
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase() 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, { return new Response(f, {
headers: { headers: {
"Content-Type": types[ext] ?? "application/octet-stream", "Content-Type": types[ext] ?? "application/octet-stream",
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=300", "Cache-Control": cacheControl,
}, },
}) })
} }