import express from "express"; import fetch, { Headers } from "node-fetch"; import crypto from "node:crypto"; import { setInterval as nodeSetInterval } from "node:timers"; import config from "./config.js"; import { get, set, del, initCache } from "./cache.js"; // pro-Key In-Flight Lock gegen Thundering Herd const inflight = new Map(); const app = express(); app.disable("x-powered-by"); // Optional: schlankes CORS if (config.allowCors) { app.use((req, res, next) => { res.setHeader("Access-Control-Allow-Origin", config.corsOrigin); res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "*"); if (req.method === "OPTIONS") return res.sendStatus(204); next(); }); } // Optional: minimalistisches Rate-Limit (global) if (config.rateLimitRps > 0) { let tokens = config.rateLimitRps; nodeSetInterval(() => { tokens = config.rateLimitRps; }, 1000).unref(); app.use((req, res, next) => { if (req.method === "GET" || req.method === "HEAD") { if (tokens > 0) { tokens--; return next(); } res.setHeader("Retry-After", "1"); return res.status(429).send("Too Many Requests"); } next(); }); } // Health app.get("/_healthz", (_req, res) => { res.json({ ok: true, ts: new Date().toISOString() }); }); // Purge per URL (optional; mit Secret schützen) app.post("/_purge", express.json(), async (req, res) => { const { secret, url } = req.query; if (secret !== process.env.PURGE_SECRET || !url) return res.sendStatus(403); const key = cacheKeyFromUrl(String(url)); await del(key); res.json({ purged: true, key }); }); function cacheKeyFromUrl(fullUrl) { const vary = ""; // Purge-Variante: bewusst ohne Header-Vary return crypto.createHash("sha1").update(`GET|${fullUrl}|${vary}`).digest("hex"); } function cacheKey(req) { const varyHeaders = ["accept"]; const h = varyHeaders.map((hn) => `${hn}:${req.header(hn) ?? ""}`).join("|"); const url = new URL(req.originalUrl, "http://x"); // Base egal, wir brauchen nur Pfad/Query return crypto .createHash("sha1") .update(`${req.method}|${url.pathname}|${url.searchParams.toString()}|${h}`) .digest("hex"); } function pickTtl(pathname) { for (const r of config.routeTtls) { if (r.re.test(pathname)) return r.seconds; } return config.ttlSeconds; } function isFresh(entry, ttl, now) { return now - entry.ts < ttl * 1000; } function isStaleOk(entry, ttl, stale, now) { return now - entry.ts < (ttl + stale) * 1000; } app.all("*", async (req, res) => { if (!["GET", "HEAD", "OPTIONS"].includes(req.method)) { return res.status(405).send("Method Not Allowed"); } if (req.method === "OPTIONS") return res.sendStatus(204); const upstreamUrl = new URL(req.originalUrl, config.upstreamBase).toString(); const key = cacheKey(req); const now = Date.now(); const ttl = pickTtl(new URL(upstreamUrl).pathname); const entry = await get(key); if (entry && isFresh(entry, ttl, now)) { res.set("X-Cache-Status", "HIT"); Object.entries(entry.headers).forEach(([k, v]) => res.set(k, v)); return res.status(entry.status).send(entry.body); } if (entry && isStaleOk(entry, ttl, config.staleSeconds, now)) { res.set("X-Cache-Status", "STALE"); Object.entries(entry.headers).forEach(([k, v]) => res.set(k, v)); res.status(entry.status).send(entry.body); void backgroundRefresh(upstreamUrl, req, key, entry).catch(() => { }); return; } try { const fresh = await getOrFetch(upstreamUrl, req, key, entry ?? undefined); res.set("X-Cache-Status", entry ? "MISS-EXPIRED" : "MISS"); Object.entries(fresh.headers).forEach(([k, v]) => res.set(k, v)); res.status(fresh.status).send(fresh.body); } catch { if (entry) { res.set("X-Cache-Status", "STALE-ERROR"); Object.entries(entry.headers).forEach(([k, v]) => res.set(k, v)); return res.status(entry.status).send(entry.body); } res.status(502).send("Bad Gateway"); } }); async function backgroundRefresh(upstreamUrl, req, key, prev) { try { await getOrFetch(upstreamUrl, req, key, prev); } catch { } } async function getOrFetch(upstreamUrl, req, key, prev) { if (inflight.has(key)) return inflight.get(key); const p = (async () => { const headers = new Headers(); ["accept", "user-agent"].forEach((hn) => { const v = req.header(hn); if (v) headers.set(hn, v); }); if (prev?.etag) headers.set("if-none-match", prev.etag); if (prev?.lastModified) headers.set("if-modified-since", prev.lastModified); // Epirent Auth-Header (nur upstream) headers.set("X-EPI-NO-SESSION", config.epiNoSession); headers.set("X-EPI-ACC-TOK", config.epiAccessToken); const resp = await fetch(upstreamUrl, { method: "GET", headers }); if (resp.status === 304 && prev) { const refreshed = { ...prev, ts: Date.now() }; await set(key, refreshed); return refreshed; } const body = Buffer.from(await resp.arrayBuffer()); const outHeaders = {}; let etag; let lastMod; for (const [k, v] of resp.headers.entries()) { const kl = k.toLowerCase(); if (["content-type", "etag", "last-modified", "cache-control", "date"].includes(kl)) { outHeaders[kl] = v; } if (kl === "etag") etag = v; if (kl === "last-modified") lastMod = v; } const stored = { status: resp.status, headers: outHeaders, body, ts: Date.now(), etag, lastModified: lastMod }; await set(key, stored); return stored; })(); inflight.set(key, p); try { return await p; } finally { inflight.delete(key); } } initCache().then(() => { app.listen(config.port, () => { console.log(`Epirent Read-Only Cache läuft auf :${config.port}`); }); });