|
@@ -0,0 +1,187 @@
|
|
|
|
|
+import express from 'express';
|
|
|
|
|
+
|
|
|
|
|
+import { getCurrenciesInfo, getEventsByIds } from '../libs/globalData.js';
|
|
|
|
|
+import { pinnacleGet, pinnaclePost } from '../libs/pinnacleClient.js';
|
|
|
|
|
+
|
|
|
|
|
+const router = express.Router();
|
|
|
|
|
+const SECOND = 1000;
|
|
|
|
|
+const MINUTE = 60 * SECOND;
|
|
|
|
|
+
|
|
|
|
|
+const getCacheTtls = new Map([
|
|
|
|
|
+ ['/v3/sports', MINUTE],
|
|
|
|
|
+ ['/v2/inrunning', 2 * SECOND],
|
|
|
|
|
+ ['/v1/bets/betting-status', SECOND],
|
|
|
|
|
+ ['/v1/cancellationreasons', 10 * MINUTE],
|
|
|
|
|
+]);
|
|
|
|
|
+
|
|
|
|
|
+const getRateLimitTtls = new Map([
|
|
|
|
|
+ ['/v3/leagues', MINUTE],
|
|
|
|
|
+ ['/v3/bets', 2 * SECOND],
|
|
|
|
|
+]);
|
|
|
|
|
+
|
|
|
|
|
+const responseCache = new Map();
|
|
|
|
|
+const inFlightRequests = new Map();
|
|
|
|
|
+const lastRequests = new Map();
|
|
|
|
|
+
|
|
|
|
|
+const allowedEndpoints = new Map([
|
|
|
|
|
+ ['GET /v2/line', 'Get Straight Line - v2'],
|
|
|
|
|
+ ['POST /v3/line/parlay', 'Get Parlay Line - v3'],
|
|
|
|
|
+ ['POST /v1/line/teaser', 'Get Teaser Line - v1'],
|
|
|
|
|
+ ['GET /v2/line/special', 'Get Special Line - v2'],
|
|
|
|
|
+ ['GET /v3/sports', 'Get Sports - v3'],
|
|
|
|
|
+ ['GET /v3/leagues', 'Get Leagues - v3'],
|
|
|
|
|
+ ['GET /v1/periods', 'Get Periods - v1'],
|
|
|
|
|
+ ['GET /v2/inrunning', 'Get In-Running - v2'],
|
|
|
|
|
+ ['GET /v1/cancellationreasons', 'Get Cancellation Reasons - v1'],
|
|
|
|
|
+ ['GET /v2/currencies', 'Get Currencies - v2'],
|
|
|
|
|
+ ['GET /v1/client/balance', 'Get Client Balance - v1'],
|
|
|
|
|
+ ['POST /v4/bets/place', 'Place straight bet - v4'],
|
|
|
|
|
+ ['POST /v4/bets/parlay', 'Place parlay bet - v4'],
|
|
|
|
|
+ ['POST /v4/bets/teaser', 'Place teaser bet - v4'],
|
|
|
|
|
+ ['POST /v4/bets/special', 'Place specials bet - v4'],
|
|
|
|
|
+ ['GET /v3/bets', 'Get Bets - v3'],
|
|
|
|
|
+ ['GET /v3/bets/settled', 'Get Bets Settled - v3'],
|
|
|
|
|
+ ['GET /v1/regrades/wager-history', 'Get Regrades Wager History - v1'],
|
|
|
|
|
+ ['GET /v1/bets/betting-status', 'Get Betting Status'],
|
|
|
|
|
+]);
|
|
|
|
|
+
|
|
|
|
|
+const normalizePath = (path) => {
|
|
|
|
|
+ if (!path || path === '/') {
|
|
|
|
|
+ return '/';
|
|
|
|
|
+ }
|
|
|
|
|
+ return path.endsWith('/') ? path.slice(0, -1) : path;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const stableStringify = (value) => {
|
|
|
|
|
+ if (Array.isArray(value)) {
|
|
|
|
|
+ return `[${value.map(stableStringify).join(',')}]`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (value && typeof value === 'object') {
|
|
|
|
|
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return JSON.stringify(value);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const getCacheKey = (method, targetPath, query) => `${method} ${targetPath} ${stableStringify(query)}`;
|
|
|
|
|
+
|
|
|
|
|
+const checkRateLimit = (targetPath) => {
|
|
|
|
|
+ const ttl = getRateLimitTtls.get(targetPath);
|
|
|
|
|
+ if (!ttl) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const lastRequestAt = lastRequests.get(targetPath) ?? 0;
|
|
|
|
|
+ const retryAfterMs = lastRequestAt + ttl - Date.now();
|
|
|
|
|
+ if (retryAfterMs > 0) {
|
|
|
|
|
+ return retryAfterMs;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lastRequests.set(targetPath, Date.now());
|
|
|
|
|
+ return null;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const getCachedPinnacleData = async (targetPath, query) => {
|
|
|
|
|
+ if (targetPath === '/v2/currencies') {
|
|
|
|
|
+ const { data, updatedAt } = await getCurrenciesInfo();
|
|
|
|
|
+ return { cacheStatus: 'GLOBAL_DATA', data, updatedAt };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const ttl = getCacheTtls.get(targetPath);
|
|
|
|
|
+
|
|
|
|
|
+ if (!ttl) {
|
|
|
|
|
+ return { cacheStatus: 'BYPASS', data: await pinnacleGet(targetPath, query) };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const cacheKey = getCacheKey('GET', targetPath, query);
|
|
|
|
|
+ const cached = responseCache.get(cacheKey);
|
|
|
|
|
+ if (cached && Date.now() - cached.createdAt < ttl) {
|
|
|
|
|
+ return { cacheStatus: 'HIT', data: cached.data };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (inFlightRequests.has(cacheKey)) {
|
|
|
|
|
+ return { cacheStatus: 'INFLIGHT', data: await inFlightRequests.get(cacheKey) };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const request = pinnacleGet(targetPath, query)
|
|
|
|
|
+ .then(data => {
|
|
|
|
|
+ responseCache.set(cacheKey, { createdAt: Date.now(), data });
|
|
|
|
|
+ return data;
|
|
|
|
|
+ })
|
|
|
|
|
+ .finally(() => {
|
|
|
|
|
+ inFlightRequests.delete(cacheKey);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ inFlightRequests.set(cacheKey, request);
|
|
|
|
|
+ return { cacheStatus: cached ? 'STALE_REFRESH' : 'MISS', data: await request };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const getEventIdsFromRequest = (req) => {
|
|
|
|
|
+ const source = req.method === 'GET' ? req.query : req.body;
|
|
|
|
|
+ return source?.ids ?? source?.id ?? source?.eventIds ?? source?.eventId;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+router.use(async (req, res) => {
|
|
|
|
|
+ const method = req.method.toUpperCase();
|
|
|
|
|
+ const targetPath = normalizePath(req.path);
|
|
|
|
|
+
|
|
|
|
|
+ if (targetPath === '/events' && (method === 'GET' || method === 'POST')) {
|
|
|
|
|
+ const ids = getEventIdsFromRequest(req);
|
|
|
|
|
+ return res.status(200).json(getEventsByIds(ids));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const endpointKey = `${method} ${targetPath}`;
|
|
|
|
|
+
|
|
|
|
|
+ if (!allowedEndpoints.has(endpointKey)) {
|
|
|
|
|
+ return res.status(404).json({
|
|
|
|
|
+ statusCode: 404,
|
|
|
|
|
+ code: -1,
|
|
|
|
|
+ message: 'Unsupported Pinnacle API endpoint',
|
|
|
|
|
+ data: { method, path: targetPath },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (method === 'GET') {
|
|
|
|
|
+ const retryAfterMs = checkRateLimit(targetPath);
|
|
|
|
|
+ if (retryAfterMs) {
|
|
|
|
|
+ res.set('Retry-After', String(Math.ceil(retryAfterMs / SECOND)));
|
|
|
|
|
+ return res.status(429).json({
|
|
|
|
|
+ statusCode: 429,
|
|
|
|
|
+ code: -1,
|
|
|
|
|
+ message: 'Pinnacle API endpoint is rate limited, please retry later',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ path: targetPath,
|
|
|
|
|
+ retryAfterMs,
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (method === 'GET') {
|
|
|
|
|
+ const data = await getCachedPinnacleData(targetPath, req.query);
|
|
|
|
|
+ res.set('X-Pinnacle-Proxy-Cache', data.cacheStatus);
|
|
|
|
|
+ if (data.updatedAt) {
|
|
|
|
|
+ res.set('X-Pinnacle-Proxy-Updated-At', String(data.updatedAt));
|
|
|
|
|
+ }
|
|
|
|
|
+ return res.status(200).json(data.data);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await pinnaclePost(targetPath, req.body);
|
|
|
|
|
+ return res.status(200).json(data);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (err) {
|
|
|
|
|
+ const status = err.response?.status ?? err.status ?? err.cause ?? 500;
|
|
|
|
|
+ const data = err.response?.data ?? err.data;
|
|
|
|
|
+ const message = data?.message ?? err.message ?? 'Pinnacle API request failed';
|
|
|
|
|
+
|
|
|
|
|
+ return res.status(status).json(data ?? {
|
|
|
|
|
+ statusCode: status,
|
|
|
|
|
+ code: -1,
|
|
|
|
|
+ message,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export default router;
|