Files
epirent-cache-proxy/dist/server.js

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}`);
});
});