184 lines
6.3 KiB
JavaScript
184 lines
6.3 KiB
JavaScript
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}`);
|
|
});
|
|
});
|