WarTracker / server /index.js
skander101's picture
initial commit: vibes only, probably bloated
6512187
Raw
History Blame Contribute Delete
11.4 kB
import express from "express";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 7860;
const WFM_BASE = "https://api.warframe.market/v2";
const WFM_HEADERS = { Platform: "pc", Language: "en" };
// --- Rate limiter: token bucket, 5 tokens/sec burst-friendly ---
class RateLimiter {
constructor(maxPerSecond = 5) {
this.maxPerSecond = maxPerSecond;
this.tokens = maxPerSecond;
this.lastRefill = Date.now();
}
async acquire() {
const now = Date.now();
const elapsed = now - this.lastRefill;
if (elapsed >= 1000) {
this.tokens = Math.min(this.maxPerSecond, this.tokens + Math.floor(elapsed / 1000) * this.maxPerSecond);
this.lastRefill = now;
}
if (this.tokens <= 0) {
const wait = 1000 - elapsed;
await new Promise((r) => setTimeout(r, wait));
this.tokens = this.maxPerSecond;
this.lastRefill = Date.now();
}
this.tokens--;
}
}
const limiter = new RateLimiter(15);
// --- In-memory cache ---
class Cache {
constructor(ttlMs) {
this.ttlMs = ttlMs;
this.store = new Map();
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() - entry.ts > this.ttlMs) {
this.store.delete(key);
return null;
}
return entry.data;
}
set(key, data) {
this.store.set(key, { data, ts: Date.now() });
}
invalidate(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
}
const catalogCache = new Cache(60 * 60 * 1000); // 1 hour
const ordersCache = new Cache(60 * 1000); // 60s
const priceCache = new Cache(60 * 1000); // 60s for batch price lookups
// --- Upstream fetch with rate limiting ---
async function wfmFetch(path) {
await limiter.acquire();
const res = await fetch(`${WFM_BASE}${path}`, { headers: WFM_HEADERS });
if (!res.ok) {
const text = await res.text();
throw new Error(`WFM API ${res.status}: ${text}`);
}
return res.json();
}
// --- API routes ---
app.get("/api/items", async (req, res) => {
try {
let items = catalogCache.get("catalog");
if (!items) {
const body = await wfmFetch("/items");
items = body.data;
catalogCache.set("catalog", items);
}
res.json(items);
} catch (err) {
console.error("Error fetching items:", err.message);
res.status(502).json({ error: "Failed to fetch item catalog from Warframe Market" });
}
});
app.get("/api/refresh-catalog", async (req, res) => {
try {
catalogCache.clear();
const body = await wfmFetch("/items");
catalogCache.set("catalog", body.data);
res.json({ ok: true, count: body.data.length });
} catch (err) {
console.error("Error refreshing catalog:", err.message);
res.status(502).json({ error: "Failed to refresh catalog" });
}
});
app.get("/api/orders/:slug", async (req, res) => {
const { slug } = req.params;
try {
let orders = ordersCache.get(slug);
if (!orders) {
const body = await wfmFetch(`/orders/item/${encodeURIComponent(slug)}`);
orders = body.data;
ordersCache.set(slug, orders);
}
res.json(orders);
} catch (err) {
console.error(`Error fetching orders for ${slug}:`, err.message);
res.status(502).json({ error: `Failed to fetch orders for ${slug}` });
}
});
// --- Batch price lookup ---
app.use(express.json());
app.post("/api/prices", async (req, res) => {
const { slugs } = req.body;
if (!Array.isArray(slugs) || slugs.length === 0) {
return res.status(400).json({ error: "slugs array required" });
}
const result = {};
const toFetch = [];
for (const slug of slugs) {
const cached = priceCache.get(slug);
if (cached !== null) {
result[slug] = cached;
} else {
toFetch.push(slug);
}
}
// Build a slug→tags lookup from catalog cache (ensure it's loaded)
let catalog = catalogCache.get("catalog");
if (!catalog) {
try {
const body = await wfmFetch("/items");
catalog = body.data;
catalogCache.set("catalog", catalog);
} catch {
catalog = [];
}
}
const slugTags = {};
for (const item of catalog) {
slugTags[item.slug] = item.tags || [];
}
// Fetch missing prices in parallel batches
const CONCURRENCY = 15;
for (let i = 0; i < toFetch.length; i += CONCURRENCY) {
const batch = toFetch.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(
batch.map(async (slug) => {
const tags = slugTags[slug] || [];
const isRanked = tags.includes("arcane_enhancement") || tags.includes("mod");
if (isRanked) {
// For arcanes/mods, fetch all orders and filter to max-rank only
const body = await wfmFetch(`/orders/item/${encodeURIComponent(slug)}`);
const orders = body.data;
const online = (orders || []).filter(
(o) => o.user?.status === "ingame" || o.user?.status === "online"
);
const maxRank = online.length > 0
? Math.max(...online.map((o) => o.rank ?? 0))
: 0;
const maxedOnline = online.filter((o) => (o.rank ?? 0) === maxRank);
const sells = maxedOnline.filter((o) => o.type === "sell");
const buys = maxedOnline.filter((o) => o.type === "buy");
const lowestSell = sells.length > 0 ? Math.min(...sells.map((o) => o.platinum)) : null;
const highestBuy = buys.length > 0 ? Math.max(...buys.map((o) => o.platinum)) : null;
return { slug, lowestSell, highestBuy, activeSells: sells.length };
}
// Normal items: use the lightweight /top endpoint
const body = await wfmFetch(`/orders/item/${encodeURIComponent(slug)}/top`);
const data = body.data;
const sells = (data.sell || [])
.filter((o) => o.user?.status === "ingame" || o.user?.status === "online");
const buys = (data.buy || [])
.filter((o) => o.user?.status === "ingame" || o.user?.status === "online");
const lowestSell = sells.length > 0 ? Math.min(...sells.map((o) => o.platinum)) : null;
const highestBuy = buys.length > 0 ? Math.max(...buys.map((o) => o.platinum)) : null;
return { slug, lowestSell, highestBuy, activeSells: sells.length };
})
);
for (const r of results) {
if (r.status === "fulfilled") {
const { slug, ...prices } = r.value;
result[slug] = prices;
priceCache.set(slug, prices);
}
}
}
res.json(result);
});
// --- SSE batch price streaming ---
app.post("/api/prices/stream", async (req, res) => {
const { slugs } = req.body;
if (!Array.isArray(slugs) || slugs.length === 0) {
return res.status(400).json({ error: "slugs array required" });
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const toFetch = [];
const cached = {};
for (const slug of slugs) {
const c = priceCache.get(slug);
if (c !== null) {
cached[slug] = c;
} else {
toFetch.push(slug);
}
}
// Send cached results immediately
if (Object.keys(cached).length > 0) {
res.write(`data: ${JSON.stringify(cached)}\n\n`);
}
// Ensure catalog is loaded
let catalog = catalogCache.get("catalog");
if (!catalog) {
try {
const body = await wfmFetch("/items");
catalog = body.data;
catalogCache.set("catalog", catalog);
} catch {
catalog = [];
}
}
const slugTags = {};
for (const item of catalog) {
slugTags[item.slug] = item.tags || [];
}
// Stream results — fetch in parallel batches with retry for failures
const CONCURRENCY = 10;
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15000);
async function fetchSlug(slug) {
const tags = slugTags[slug] || [];
const isRanked = tags.includes("arcane_enhancement") || tags.includes("mod");
for (let attempt = 0; attempt < 3; attempt++) {
try {
if (isRanked) {
const body = await wfmFetch(`/orders/item/${encodeURIComponent(slug)}`);
const orders = body.data;
const online = (orders || []).filter(
(o) => o.user?.status === "ingame" || o.user?.status === "online"
);
const maxRank = online.length > 0
? Math.max(...online.map((o) => o.rank ?? 0))
: 0;
const maxedOnline = online.filter((o) => (o.rank ?? 0) === maxRank);
const sells = maxedOnline.filter((o) => o.type === "sell");
const buys = maxedOnline.filter((o) => o.type === "buy");
const lowestSell = sells.length > 0 ? Math.min(...sells.map((o) => o.platinum)) : null;
const highestBuy = buys.length > 0 ? Math.max(...buys.map((o) => o.platinum)) : null;
return { slug, lowestSell, highestBuy, activeSells: sells.length };
}
const body = await wfmFetch(`/orders/item/${encodeURIComponent(slug)}/top`);
const data = body.data;
const sells = (data.sell || [])
.filter((o) => o.user?.status === "ingame" || o.user?.status === "online");
const buys = (data.buy || [])
.filter((o) => o.user?.status === "ingame" || o.user?.status === "online");
const lowestSell = sells.length > 0 ? Math.min(...sells.map((o) => o.platinum)) : null;
const highestBuy = buys.length > 0 ? Math.max(...buys.map((o) => o.platinum)) : null;
return { slug, lowestSell, highestBuy, activeSells: sells.length };
} catch {
if (attempt < 2) await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
return null; // failed after 3 attempts
}
const failedSlugs = [];
for (let i = 0; i < toFetch.length; i += CONCURRENCY) {
const batch = toFetch.slice(i, i + CONCURRENCY);
const results = await Promise.all(batch.map((slug) => fetchSlug(slug)));
const batchResult = {};
for (const r of results) {
if (r) {
const { slug, ...prices } = r;
batchResult[slug] = prices;
priceCache.set(slug, prices);
}
}
// Track failed slugs for a final retry pass
for (let j = 0; j < results.length; j++) {
if (!results[j]) failedSlugs.push(batch[j]);
}
if (Object.keys(batchResult).length > 0) {
res.write(`data: ${JSON.stringify(batchResult)}\n\n`);
}
}
// Final retry pass for any items that failed
if (failedSlugs.length > 0) {
const retryResult = {};
for (const slug of failedSlugs) {
const r = await fetchSlug(slug);
if (r) {
const { slug: s, ...prices } = r;
retryResult[s] = prices;
priceCache.set(s, prices);
}
}
if (Object.keys(retryResult).length > 0) {
res.write(`data: ${JSON.stringify(retryResult)}\n\n`);
}
}
clearInterval(heartbeat);
res.write("event: done\ndata: {}\n\n");
res.end();
});
// --- Serve static React build ---
const clientDist = join(__dirname, "..", "client", "dist");
app.use(express.static(clientDist));
// SPA fallback
app.get("*", (req, res) => {
res.sendFile(join(clientDist, "index.html"));
});
app.listen(PORT, "0.0.0.0", () => {
console.log(`WFM Tracker server listening on port ${PORT}`);
});