Files
vyndr/src/routes/widget.js
T

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;