Initial Commit with Working server and metrics
This commit is contained in:
36
dist/cache.js
vendored
Normal file
36
dist/cache.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import config from "./config.js";
|
||||
let redis = null;
|
||||
let useRedis = false;
|
||||
export async function initCache() {
|
||||
if (config.redisUrl) {
|
||||
const { createClient } = await import("redis");
|
||||
redis = createClient({ url: config.redisUrl });
|
||||
await redis.connect();
|
||||
useRedis = true;
|
||||
}
|
||||
}
|
||||
const lru = new LRUCache({ max: 1000 });
|
||||
export async function get(key) {
|
||||
if (useRedis) {
|
||||
const raw = await redis.get(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
return lru.get(key) ?? null;
|
||||
}
|
||||
export async function set(key, val) {
|
||||
if (useRedis) {
|
||||
await redis.set(key, JSON.stringify(val));
|
||||
}
|
||||
else {
|
||||
lru.set(key, val);
|
||||
}
|
||||
}
|
||||
export async function del(key) {
|
||||
if (useRedis) {
|
||||
await redis.del(key);
|
||||
}
|
||||
else {
|
||||
lru.delete(key);
|
||||
}
|
||||
}
|
||||
30
dist/config.js
vendored
Normal file
30
dist/config.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
// Zentrale Konfiguration, per .env überschreibbar
|
||||
export default {
|
||||
port: parseInt(process.env.PORT || "8080", 10),
|
||||
// Upstream (Epirent)
|
||||
upstreamBase: process.env.UPSTREAM_BASE || "https://epirent.example.com", // <-- anpassen
|
||||
epiNoSession: process.env.EPI_NO_SESSION || "true",
|
||||
epiAccessToken: process.env.EPI_ACCESS_TOKEN || "xxxDerAccessTokenxxx",
|
||||
// Cache
|
||||
ttlSeconds: parseInt(process.env.TTL_SECONDS || "600", 10), // frisch
|
||||
staleSeconds: parseInt(process.env.STALE_SECONDS || "600", 10), // zusätzlich stale
|
||||
// Optional: Pfad-spezifische TTL-Regeln (Regex-Strings)
|
||||
routeTtls: (process.env.ROUTE_TTLS || "")
|
||||
.split("|")
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(rule => {
|
||||
// Format: /regex/i=SECONDS (i optional)
|
||||
const [pat, sec] = rule.split("=");
|
||||
const flags = /\/[gimsuy]*$/.test(pat) ? pat.split("/").pop() : "";
|
||||
const body = pat.replace(/^\/|\/[gimsuy]*$/g, "");
|
||||
return { re: new RegExp(body, flags || "i"), seconds: parseInt(sec, 10) };
|
||||
}),
|
||||
// Redis (leer => In-Memory LRU)
|
||||
redisUrl: process.env.REDIS_URL || "",
|
||||
// Sicherheit / CORS
|
||||
allowCors: (process.env.CORS || "false").toLowerCase() === "true",
|
||||
corsOrigin: process.env.CORS_ORIGIN || "*",
|
||||
// Rate limit (sehr simpel, optional)
|
||||
rateLimitRps: parseInt(process.env.RATE_LIMIT_RPS || "0", 10), // 0 = aus
|
||||
};
|
||||
183
dist/server.js
vendored
Normal file
183
dist/server.js
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user