109 lines
3.5 KiB
JavaScript
109 lines
3.5 KiB
JavaScript
/**
|
|
* GET /api/widget — public embeddable widget data feed.
|
|
*
|
|
* - CORS: open to all origins (it's a public widget).
|
|
* - Cache: 15 minutes server-side + Cache-Control headers.
|
|
* - Rate limit: 60 req/min/Origin (60/min/IP if no Origin header).
|
|
*
|
|
* Response: tonight's top 3 grades, sport-filterable.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const axios = require('axios');
|
|
|
|
const router = express.Router();
|
|
|
|
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
|
const CACHE_TTL_MS = 15 * 60_000;
|
|
const RATE_WINDOW_MS = 60_000;
|
|
const RATE_MAX = 60;
|
|
|
|
// Per-key sliding-window counter
|
|
const buckets = new Map();
|
|
function checkRate(key) {
|
|
const now = Date.now();
|
|
const arr = (buckets.get(key) || []).filter((t) => now - t < RATE_WINDOW_MS);
|
|
if (arr.length >= RATE_MAX) return false;
|
|
arr.push(now);
|
|
buckets.set(key, arr);
|
|
return true;
|
|
}
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [k, arr] of buckets.entries()) {
|
|
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
|
if (fresh.length === 0) buckets.delete(k);
|
|
else buckets.set(k, fresh);
|
|
}
|
|
}, RATE_WINDOW_MS).unref?.();
|
|
|
|
// Tiny in-memory cache keyed by sport
|
|
const cache = new Map();
|
|
function cacheGet(key) {
|
|
const hit = cache.get(key);
|
|
if (!hit) return null;
|
|
if (Date.now() - hit.at > CACHE_TTL_MS) { cache.delete(key); return null; }
|
|
return hit.value;
|
|
}
|
|
function cacheSet(key, value) {
|
|
cache.set(key, { at: Date.now(), value });
|
|
}
|
|
|
|
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb']);
|
|
|
|
router.get('/', async (req, res) => {
|
|
// CORS — open to all origins for the widget feed only. We DO NOT echo
|
|
// request headers; we send an explicit allow-list of headers we accept.
|
|
res.set('Access-Control-Allow-Origin', '*');
|
|
res.set('Access-Control-Allow-Methods', 'GET');
|
|
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.set('Vary', 'Origin');
|
|
|
|
const origin = req.get('Origin') || (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString();
|
|
if (!checkRate(origin)) return res.status(429).json({ error: 'rate limit exceeded' });
|
|
|
|
const sport = String(req.query.sport || 'nba').toLowerCase();
|
|
if (!VALID_SPORTS.has(sport)) return res.status(400).json({ error: 'invalid sport' });
|
|
|
|
const cached = cacheGet(sport);
|
|
if (cached) {
|
|
res.set('X-Cache', 'HIT');
|
|
res.set('Cache-Control', 'public, max-age=900');
|
|
return res.json(cached);
|
|
}
|
|
|
|
try {
|
|
const r = await axios.get(`${API_BASE}/api/props/top-graded?limit=3&sport=${encodeURIComponent(sport)}`, { timeout: 8_000 });
|
|
const props = Array.isArray(r.data?.props) ? r.data.props.slice(0, 3) : [];
|
|
const payload = {
|
|
sport,
|
|
generated_at: new Date().toISOString(),
|
|
props: props.map((p) => ({
|
|
player: p.player_name || p.player,
|
|
sport: p.sport,
|
|
stat: p.stat_type || p.stat,
|
|
direction: p.direction,
|
|
line: p.line,
|
|
grade: p.grade,
|
|
})),
|
|
link: 'https://vyndr.app',
|
|
};
|
|
cacheSet(sport, payload);
|
|
res.set('X-Cache', 'MISS');
|
|
res.set('Cache-Control', 'public, max-age=900');
|
|
return res.json(payload);
|
|
} catch (err) {
|
|
return res.status(502).json({ error: 'upstream unavailable', detail: err?.message || 'unknown' });
|
|
}
|
|
});
|
|
|
|
router.options('/', (_req, res) => {
|
|
res.set('Access-Control-Allow-Origin', '*');
|
|
res.set('Access-Control-Allow-Methods', 'GET');
|
|
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.set('Access-Control-Max-Age', '86400');
|
|
res.status(204).end();
|
|
});
|
|
|
|
module.exports = router;
|