diff --git a/graphql-yoga.mjs b/graphql-yoga.mjs index d0a49ef8..4742f17f 100644 --- a/graphql-yoga.mjs +++ b/graphql-yoga.mjs @@ -7,12 +7,13 @@ import graphqlUtil from './utils/graphql-util.mjs'; import graphQLOptions from './utils/graphql-options.mjs'; import useRequestTimer from './plugins/plugin-request-timer.mjs'; -import useHttpServer from './plugins/plugin-http-server.mjs'; +import useGraphQLOrigin from './plugins/plugin-graphql-origin.mjs'; import useCacheMachine from './plugins/plugin-use-cache-machine.mjs'; import useTwitch from './plugins/plugin-twitch.mjs'; import useNightbot from './plugins/plugin-nightbot.mjs'; import usePlayground from './plugins/plugin-playground.mjs'; import useOptionMethod from './plugins/plugin-option-method.mjs'; +import useLiteApi from './plugins/plugin-lite-api.mjs'; let dataAPI, yoga; @@ -45,9 +46,10 @@ export default async function getYoga(env) { useOptionMethod(), useTwitch(env), usePlayground(), - useNightbot(env), - useHttpServer(env), useCacheMachine(env), + useGraphQLOrigin(env), + useNightbot(env), + useLiteApi(env), ], cors: { origin: graphQLOptions.cors.allowOrigin, diff --git a/http/package-lock.json b/http/package-lock.json index 8e2540c6..ea547a9a 100644 --- a/http/package-lock.json +++ b/http/package-lock.json @@ -333,9 +333,9 @@ } }, "node_modules/dset": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz", - "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", "engines": { "node": ">=4" } diff --git a/index.mjs b/index.mjs index 5e2ce4e2..ca1a2065 100644 --- a/index.mjs +++ b/index.mjs @@ -26,8 +26,9 @@ import graphQLOptions from './utils/graphql-options.mjs'; import cacheMachine from './utils/cache-machine.mjs'; import fetchWithTimeout from './utils/fetch-with-timeout.mjs'; -import { getNightbotResponse } from './plugins/plugin-nightbot.mjs'; +import { getNightbotResponse, useNightbotOnUrl } from './plugins/plugin-nightbot.mjs'; import { getTwitchResponse } from './plugins/plugin-twitch.mjs'; +import { getLiteApiResponse, useLiteApiOnUrl } from './plugins/plugin-lite-api.mjs'; let dataAPI; @@ -108,12 +109,15 @@ async function graphqlHandler(request, env, ctx) { //console.log(`Skipping cache in ${ENVIRONMENT} environment`); } - // if an HTTP GraphQL server is configured, pass the request to that + // if an origin server is configured, pass the request if (env.USE_ORIGIN === 'true') { try { - const serverUrl = `https://api.tarkov.dev${graphQLOptions.baseEndpoint}`; - const queryResult = await fetchWithTimeout(serverUrl, { - method: request.method, + const originUrl = new URL(request.url); + if (env.ORIGIN_OVERRIDE) { + originUrl.host = env.ORIGIN_OVERRIDE; + } + const queryResult = await fetchWithTimeout(originUrl, { + method: 'POST', body: JSON.stringify({ query, variables, @@ -127,10 +131,10 @@ async function graphqlHandler(request, env, ctx) { if (queryResult.status !== 200) { throw new Error(`${queryResult.status} ${await queryResult.text()}`); } - console.log('Request served from graphql server'); + console.log('Request served from origin server'); return new Response(await queryResult.text(), responseOptions); } catch (error) { - console.error(`Error getting response from GraphQL server: ${error}`); + console.error(`Error getting response from origin server: ${error}`); } } @@ -206,47 +210,42 @@ export default { const requestStart = new Date(); const url = new URL(request.url); - let response; - try { if (url.pathname === '/twitch') { - response = await getTwitchResponse(env); + const response = await getTwitchResponse(env); if (graphQLOptions.cors) { setCors(response, graphQLOptions.cors); } + return response; } if (url.pathname === graphQLOptions.playgroundEndpoint) { //response = playground(request, graphQLOptions); - response = graphiql(graphQLOptions); - } - - if (graphQLOptions.forwardUnmatchedRequestsToOrigin) { - return fetch(request); + return graphiql(graphQLOptions); } - if (url.pathname === '/webhook/nightbot' || - url.pathname === '/webhook/stream-elements' || - url.pathname === '/webhook/moobot' - ) { - response = await getNightbotResponse(request, url, env, ctx); + if (useNightbotOnUrl(url)) { + return await getNightbotResponse(request, url, env, ctx); + } + + if (useLiteApiOnUrl(url)) { + return await getLiteApiResponse(request, url, env, ctx); } if (url.pathname === graphQLOptions.baseEndpoint) { - response = await graphqlHandler(request, env, ctx); + const response = await graphqlHandler(request, env, ctx); if (graphQLOptions.cors) { setCors(response, graphQLOptions.cors); } + return response; } - if (!response) { - response = new Response('Not found', { status: 404 }); - } - console.log(`Response time: ${new Date() - requestStart} ms`); - return response; + return new Response('Not found', { status: 404 }); } catch (err) { console.log(err); return new Response(graphQLOptions.debug ? err : 'Something went wrong', { status: 500 }); + } finally { + console.log(`Response time: ${new Date() - requestStart} ms`); } }, }; diff --git a/package-lock.json b/package-lock.json index 64c1daab..aba4baf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1211,9 +1211,9 @@ } }, "node_modules/dset": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz", - "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", "engines": { "node": ">=4" } @@ -2031,9 +2031,9 @@ "dev": true }, "node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/pathe": { @@ -3642,9 +3642,9 @@ } }, "dset": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz", - "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==" + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==" }, "ecc-jsbn": { "version": "0.1.2", @@ -4265,9 +4265,9 @@ "dev": true }, "path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "pathe": { diff --git a/plugins/plugin-http-server.mjs b/plugins/plugin-graphql-origin.mjs similarity index 55% rename from plugins/plugin-http-server.mjs rename to plugins/plugin-graphql-origin.mjs index a2c59ae6..e1b7a2f7 100644 --- a/plugins/plugin-http-server.mjs +++ b/plugins/plugin-graphql-origin.mjs @@ -1,29 +1,33 @@ -import graphQLOptions from '../utils/graphql-options.mjs'; +// Pass the request to an origin server if USE_ORIGIN is set to 'true' +import fetchWithTimeout from '../utils/fetch-with-timeout.mjs'; -export default function useHttpServer(env) { +export default function useGraphQLOrigin(env) { return { async onParams({params, request, setParams, setResult, fetchAPI}) { - // if an HTTP GraphQL server is configured, pass the request to that if (env.USE_ORIGIN !== 'true') { return; } try { - const serverUrl = `https://api.tarkov.dev${graphQLOptions.baseEndpoint}`; - const queryResult = await fetch(serverUrl, { + const originUrl = new URL(request.url); + if (env.ORIGIN_OVERRIDE) { + originUrl.host = env.ORIGIN_OVERRIDE; + } + const queryResult = await fetchWithTimeout(originUrl, { method: request.method, body: JSON.stringify(params), headers: { 'Content-Type': 'application/json', }, + timeout: 20000 }); if (queryResult.status !== 200) { throw new Error(`${queryResult.status} ${queryResult.statusText}: ${await queryResult.text()}`); } - console.log('Request served from graphql server'); + console.log('Request served from origin server'); setResult(await queryResult.json()); request.cached = true; } catch (error) { - console.error(`Error getting response from GraphQL server: ${error}`); + console.error(`Error getting response from origin server: ${error}`); } }, } diff --git a/plugins/plugin-lite-api.mjs b/plugins/plugin-lite-api.mjs new file mode 100644 index 00000000..610a9a75 --- /dev/null +++ b/plugins/plugin-lite-api.mjs @@ -0,0 +1,215 @@ +import DataSource from '../datasources/index.mjs'; +import graphqlUtil from '../utils/graphql-util.mjs'; +import cacheMachine from '../utils/cache-machine.mjs'; +import fetchWithTimeout from '../utils/fetch-with-timeout.mjs'; + +export const liteApiPathRegex = /\/api\/v1(?\/\w+)?\/(?item[\w\/]*)/; + +export function useLiteApiOnUrl(url) { + return !!url.pathname.match(liteApiPathRegex); +}; + +const currencyMap = { + RUB: '₽', + USD: '$', + EUR: '€', +}; + +export async function getLiteApiResponse(request, url, env, serverContext) { + let q, lang, uid, tags, sort, sort_direction; + if (request.method.toUpperCase() === 'GET') { + q = url.searchParams.get('q'); + lang = url.searchParams.get('lang') ?? 'en'; + uid = url.searchParams.get('uid'); + tags = url.searchParams.get('tags')?.split(','); + sort = url.searchParams.get('sort'); + sort_direction = url.searchParams.get('sort_direction'); + } else if (request.method.toUpperCase() === 'POST') { + const body = await request.json(); + q = body.q; + lang = body.lang ?? 'en'; + uid = body.uid; + tags = body.tags?.split(','); + sort = body.sort; + sort_direction = body.sort_direction; + } else { + return new Response(null, { + status: 405, + headers: { 'cache-control': 'public, max-age=2592000' }, + }); + } + + const pathInfo = url.pathname.match(liteApiPathRegex); + + const gameMode = pathInfo.groups.gameMode || 'regular'; + + const endpoint = pathInfo.groups.endpoint; + + let key; + if (env.SKIP_CACHE !== 'true' && env.SKIP_CACHE_CHECK !== 'true' && !request.headers.has('cache-check-complete')) { + const requestStart = new Date(); + key = await cacheMachine.createKey(env, url.pathname, { q, lang, gameMode, uid, tags, sort, sort_direction }); + const cachedResponse = await cacheMachine.get(env, {key}); + if (cachedResponse) { + // Construct a new response with the cached data + const newResponse = new Response(cachedResponse); + // Add a custom 'X-CACHE: HIT' header so we know the request hit the cache + newResponse.headers.append('X-CACHE', 'HIT'); + console.log(`Request served from cache: ${new Date() - requestStart} ms`); + // Return the new cached response + request.cached = true; + return newResponse; + } else { + console.log('no cached response'); + } + } else { + //console.log(`Skipping cache in ${ENVIRONMENT} environment`); + } + + if (env.USE_ORIGIN === 'true') { + try { + const originUrl = new URL(request.url); + if (env.ORIGIN_OVERRIDE) { + originUrl.host = env.ORIGIN_OVERRIDE; + } + const response = await fetchWithTimeout(originUrl, { + method: 'POST', + body: JSON.stringify({ + q, + lang, + uid, + tags: tags?.join(','), + sort, + sort_direction, + }), + headers: { + 'cache-check-complete': 'true', + }, + timeout: 20000 + }); + if (response.status !== 200) { + throw new Error(`${response.status} ${await response.text()}`); + } + console.log('Request served from origin server'); + return response; + } catch (error) { + console.error(`Error getting response from origin server: ${error}`); + } + } + + const data = new DataSource(env); + const context = graphqlUtil.getDefaultContext(data); + + const info = graphqlUtil.getGenericInfo(lang, gameMode); + + function toLiteApiItem(item) { + const bestTraderSell = item.traderPrices.reduce((best, current) => { + if (!best || current.priceRUB > best.priceRUB) { + return current; + } + return best; + }, undefined); + return { + uid: item.id, + name: data.worker.item.getLocale(item.name, context, info), + tags: item.types, + shortName: data.worker.item.getLocale(item.shortName, context, info), + price: item.lastLowPrice, + basePrice: item.basePrice, + avg24hPrice: item.avg24hPrice, + //avg7daysPrice: null, + traderName: bestTraderSell ? bestTraderSell.name : null, + traderPrice: bestTraderSell ? bestTraderSell.price : null, + traderPriceCur: bestTraderSell ? currencyMap[bestTraderSell.currency] : null, + updated: item.updated, + slots: item.width * item.height, + diff24h: item.changeLast48h, + //diff7days: null, + icon: item.iconLink, + link: item.link, + wikiLink: item.wikiLink, + img: item.gridImageLink, + imgBig: item.inspectImageLink, + img512: item.image512pxLink, + image8x: item.image8xLink, + bsgId: item.id, + isFunctional: true, // !item.types.includes('gun'), + reference: 'https://tarkov.dev', + }; + } + + let items, ttl; + const responseOptions = { + headers: { + 'Content-Type': 'application/json', + }, + }; + try { + if (endpoint.startsWith('items')) { + items = await data.worker.item.getAllItems(context, info); + if (endpoint.endsWith('/download')) { + responseOptions.headers['Content-Disposition'] = 'attachment; filename="items.json"'; + } + if (tags) { + items = await data.worker.item.getItemsByTypes(context, info, tags, items); + } + } + if (!items && endpoint.startsWith('item')) { + if (!q && !uid) { + throw new Error('The item request requires either a q or uid parameter'); + } + if (q) { + items = await data.worker.item.getItemsByName(context, info, q); + } else if (uid) { + items = [await data.worker.item.getItem(context, info, uid)]; + } + } + items = items.map(toLiteApiItem); + ttl = data.getRequestTtl(context.requestId); + } catch (error) { + return new Response(error.message, {status: 400}); + } finally { + data.clearRequestData(context.requestId); + } + if (sort && items?.length) { + items.sort((a, b) => { + let aValue = sort_direction === 'desc' ? b[sort] : a[sort]; + let bValue = sort_direction === 'desc' ? a[sort] : b[sort]; + if (sort === 'updated') { + aValue = new Date(aValue); + bValue = new Date(bValue); + } + if (typeof aValue === 'string') { + return aValue.localeCompare(bValue, lang); + } + return aValue - bValue; + }); + } + const responseBody = JSON.stringify(items ?? [], null, 4); + + // Update the cache with the results of the query + if (env.SKIP_CACHE !== 'true' && ttl > 0) { + const putCachePromise = cacheMachine.put(env, responseBody, { key, query: url.pathname, variables: { q, lang, gameMode, uid, tags, sort, sort_direction }, ttl: String(ttl)}); + // using waitUntil doens't hold up returning a response but keeps the worker alive as long as needed + if (request.ctx?.waitUntil) { + request.ctx.waitUntil(putCachePromise); + } else if (serverContext.waitUntil) { + serverContext.waitUntil(putCachePromise); + } + } + + return new Response(responseBody, responseOptions); +} + +export default function useLiteApi(env) { + return { + async onRequest({ request, url, endResponse, serverContext, fetchAPI }) { + if (!useLiteApiOnUrl(url)) { + return; + } + const response = await getLiteApiResponse(request, url, env, serverContext); + + endResponse(response); + }, + } +} diff --git a/plugins/plugin-nightbot.mjs b/plugins/plugin-nightbot.mjs index 72f67b33..908e0aeb 100644 --- a/plugins/plugin-nightbot.mjs +++ b/plugins/plugin-nightbot.mjs @@ -1,17 +1,22 @@ -import cacheMachine from '../utils/cache-machine.mjs'; import DataSource from '../datasources/index.mjs'; +import cacheMachine from '../utils/cache-machine.mjs'; import graphqlUtil from '../utils/graphql-util.mjs'; +import fetchWithTimeout from '../utils/fetch-with-timeout.mjs'; function capitalize(s) { return s && s[0].toUpperCase() + s.slice(1); } -const usePaths = [ +export const nightbotPaths = [ '/webhook/nightbot', '/webhook/stream-elements', '/webhook/moobot', ]; +export function useNightbotOnUrl(url) { + return nightbotPaths.includes(url.pathname); +}; + export async function getNightbotResponse(request, url, env, serverContext) { if (request.method.toUpperCase() !== 'GET') { return new Response(null, { @@ -32,7 +37,7 @@ export async function getNightbotResponse(request, url, env, serverContext) { const query = url.searchParams.get('q'); let key; - if (env.SKIP_CACHE !== 'true' && !request.headers.has('cache-check-complete')) { + if (env.SKIP_CACHE !== 'true' && env.SKIP_CACHE_CHECK !== 'true' && !request.headers.has('cache-check-complete')) { const requestStart = new Date(); key = await cacheMachine.createKey(env, 'nightbot', { q: query, l: lang, m: gameMode }); const cachedResponse = await cacheMachine.get(env, {key}); @@ -51,6 +56,30 @@ export async function getNightbotResponse(request, url, env, serverContext) { } else { //console.log(`Skipping cache in ${ENVIRONMENT} environment`); } + + if (env.USE_ORIGIN === 'true') { + try { + const originUrl = new URL(request.url); + if (env.ORIGIN_OVERRIDE) { + originUrl.host = env.ORIGIN_OVERRIDE; + } + const response = await fetchWithTimeout(originUrl, { + method: 'GET', + headers: { + 'cache-check-complete': 'true', + }, + timeout: 20000 + }); + if (response.status !== 200) { + throw new Error(`${response.status} ${await response.text()}`); + } + console.log('Request served from origin server'); + return response; + } catch (error) { + console.error(`Error getting response from origin server: ${error}`); + } + } + const data = new DataSource(env); const context = graphqlUtil.getDefaultContext(data); @@ -88,7 +117,7 @@ export async function getNightbotResponse(request, url, env, serverContext) { export default function useNightbot(env) { return { async onRequest({ request, url, endResponse, serverContext, fetchAPI }) { - if (!usePaths.includes(url.pathname)) { + if (!useNightbotOnUrl(url)) { return; } const response = await getNightbotResponse(request, url, env, serverContext);