pinnacleProxy.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import express from 'express';
  2. import { getCurrenciesInfo, getEventsByIds } from '../libs/globalData.js';
  3. import { pinnacleGet, pinnaclePost } from '../libs/pinnacleClient.js';
  4. const router = express.Router();
  5. const SECOND = 1000;
  6. const MINUTE = 60 * SECOND;
  7. const getCacheTtls = new Map([
  8. ['/v3/sports', MINUTE],
  9. ['/v2/inrunning', 2 * SECOND],
  10. ['/v1/bets/betting-status', SECOND],
  11. ['/v1/cancellationreasons', 10 * MINUTE],
  12. ]);
  13. const getRateLimitTtls = new Map([
  14. ['/v3/leagues', MINUTE],
  15. ['/v3/bets', 2 * SECOND],
  16. ]);
  17. const responseCache = new Map();
  18. const inFlightRequests = new Map();
  19. const lastRequests = new Map();
  20. const allowedEndpoints = new Map([
  21. ['GET /v2/line', 'Get Straight Line - v2'],
  22. ['POST /v3/line/parlay', 'Get Parlay Line - v3'],
  23. ['POST /v1/line/teaser', 'Get Teaser Line - v1'],
  24. ['GET /v2/line/special', 'Get Special Line - v2'],
  25. ['GET /v3/sports', 'Get Sports - v3'],
  26. ['GET /v3/leagues', 'Get Leagues - v3'],
  27. ['GET /v1/periods', 'Get Periods - v1'],
  28. ['GET /v2/inrunning', 'Get In-Running - v2'],
  29. ['GET /v1/cancellationreasons', 'Get Cancellation Reasons - v1'],
  30. ['GET /v2/currencies', 'Get Currencies - v2'],
  31. ['GET /v1/client/balance', 'Get Client Balance - v1'],
  32. ['POST /v4/bets/place', 'Place straight bet - v4'],
  33. ['POST /v4/bets/parlay', 'Place parlay bet - v4'],
  34. ['POST /v4/bets/teaser', 'Place teaser bet - v4'],
  35. ['POST /v4/bets/special', 'Place specials bet - v4'],
  36. ['GET /v3/bets', 'Get Bets - v3'],
  37. ['GET /v3/bets/settled', 'Get Bets Settled - v3'],
  38. ['GET /v1/regrades/wager-history', 'Get Regrades Wager History - v1'],
  39. ['GET /v1/bets/betting-status', 'Get Betting Status'],
  40. ]);
  41. const normalizePath = (path) => {
  42. if (!path || path === '/') {
  43. return '/';
  44. }
  45. return path.endsWith('/') ? path.slice(0, -1) : path;
  46. };
  47. const stableStringify = (value) => {
  48. if (Array.isArray(value)) {
  49. return `[${value.map(stableStringify).join(',')}]`;
  50. }
  51. if (value && typeof value === 'object') {
  52. return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
  53. }
  54. return JSON.stringify(value);
  55. };
  56. const getCacheKey = (method, targetPath, query) => `${method} ${targetPath} ${stableStringify(query)}`;
  57. const checkRateLimit = (targetPath) => {
  58. const ttl = getRateLimitTtls.get(targetPath);
  59. if (!ttl) {
  60. return null;
  61. }
  62. const lastRequestAt = lastRequests.get(targetPath) ?? 0;
  63. const retryAfterMs = lastRequestAt + ttl - Date.now();
  64. if (retryAfterMs > 0) {
  65. return retryAfterMs;
  66. }
  67. lastRequests.set(targetPath, Date.now());
  68. return null;
  69. };
  70. const getCachedPinnacleData = async (targetPath, query) => {
  71. if (targetPath === '/v2/currencies') {
  72. const { data, updatedAt } = await getCurrenciesInfo();
  73. return { cacheStatus: 'GLOBAL_DATA', data, updatedAt };
  74. }
  75. const ttl = getCacheTtls.get(targetPath);
  76. if (!ttl) {
  77. return { cacheStatus: 'BYPASS', data: await pinnacleGet(targetPath, query) };
  78. }
  79. const cacheKey = getCacheKey('GET', targetPath, query);
  80. const cached = responseCache.get(cacheKey);
  81. if (cached && Date.now() - cached.createdAt < ttl) {
  82. return { cacheStatus: 'HIT', data: cached.data };
  83. }
  84. if (inFlightRequests.has(cacheKey)) {
  85. return { cacheStatus: 'INFLIGHT', data: await inFlightRequests.get(cacheKey) };
  86. }
  87. const request = pinnacleGet(targetPath, query)
  88. .then(data => {
  89. responseCache.set(cacheKey, { createdAt: Date.now(), data });
  90. return data;
  91. })
  92. .finally(() => {
  93. inFlightRequests.delete(cacheKey);
  94. });
  95. inFlightRequests.set(cacheKey, request);
  96. return { cacheStatus: cached ? 'STALE_REFRESH' : 'MISS', data: await request };
  97. };
  98. const getEventIdsFromRequest = (req) => {
  99. const source = req.method === 'GET' ? req.query : req.body;
  100. return source?.ids ?? source?.id ?? source?.eventIds ?? source?.eventId;
  101. };
  102. router.use(async (req, res) => {
  103. const method = req.method.toUpperCase();
  104. const targetPath = normalizePath(req.path);
  105. if (targetPath === '/events' && (method === 'GET' || method === 'POST')) {
  106. const ids = getEventIdsFromRequest(req);
  107. return res.status(200).json(getEventsByIds(ids));
  108. }
  109. const endpointKey = `${method} ${targetPath}`;
  110. if (!allowedEndpoints.has(endpointKey)) {
  111. return res.status(404).json({
  112. statusCode: 404,
  113. code: -1,
  114. message: 'Unsupported Pinnacle API endpoint',
  115. data: { method, path: targetPath },
  116. });
  117. }
  118. try {
  119. if (method === 'GET') {
  120. const retryAfterMs = checkRateLimit(targetPath);
  121. if (retryAfterMs) {
  122. res.set('Retry-After', String(Math.ceil(retryAfterMs / SECOND)));
  123. return res.status(429).json({
  124. statusCode: 429,
  125. code: -1,
  126. message: 'Pinnacle API endpoint is rate limited, please retry later',
  127. data: {
  128. path: targetPath,
  129. retryAfterMs,
  130. },
  131. });
  132. }
  133. }
  134. if (method === 'GET') {
  135. const data = await getCachedPinnacleData(targetPath, req.query);
  136. res.set('X-Pinnacle-Proxy-Cache', data.cacheStatus);
  137. if (data.updatedAt) {
  138. res.set('X-Pinnacle-Proxy-Updated-At', String(data.updatedAt));
  139. }
  140. return res.status(200).json(data.data);
  141. }
  142. const data = await pinnaclePost(targetPath, req.body);
  143. return res.status(200).json(data);
  144. }
  145. catch (err) {
  146. const status = err.response?.status ?? err.status ?? err.cause ?? 500;
  147. const data = err.response?.data ?? err.data;
  148. const message = data?.message ?? err.message ?? 'Pinnacle API request failed';
  149. return res.status(status).json(data ?? {
  150. statusCode: status,
  151. code: -1,
  152. message,
  153. });
  154. }
  155. });
  156. export default router;