/** * 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;