diff --git a/graphql-yoga.mjs b/graphql-yoga.mjs index d0a49ef8..b6f4e069 100644 --- a/graphql-yoga.mjs +++ b/graphql-yoga.mjs @@ -13,6 +13,7 @@ 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; @@ -46,6 +47,7 @@ export default async function getYoga(env) { useTwitch(env), usePlayground(), useNightbot(env), + useLiteApi(env), useHttpServer(env), useCacheMachine(env), ], diff --git a/index.mjs b/index.mjs index 5e2ce4e2..b8ba0681 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, nightbotPaths } from './plugins/plugin-nightbot.mjs'; import { getTwitchResponse } from './plugins/plugin-twitch.mjs'; +import { getLiteApiResponse, liteApiPathRegex } from './plugins/plugin-lite-api.mjs'; let dataAPI; @@ -108,32 +109,6 @@ 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 (env.USE_ORIGIN === 'true') { - try { - const serverUrl = `https://api.tarkov.dev${graphQLOptions.baseEndpoint}`; - const queryResult = await fetchWithTimeout(serverUrl, { - method: request.method, - body: JSON.stringify({ - query, - variables, - }), - headers: { - 'Content-Type': 'application/json', - 'cache-check-complete': 'true', - }, - timeout: 20000 - }); - if (queryResult.status !== 200) { - throw new Error(`${queryResult.status} ${await queryResult.text()}`); - } - console.log('Request served from graphql server'); - return new Response(await queryResult.text(), responseOptions); - } catch (error) { - console.error(`Error getting response from GraphQL server: ${error}`); - } - } - const context = graphqlUtil.getDefaultContext(dataAPI, requestId); let result, ttl; try { @@ -206,47 +181,65 @@ 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); + return graphiql(graphQLOptions); } - if (graphQLOptions.forwardUnmatchedRequestsToOrigin) { - return fetch(request); + if (!nightbotPaths.includes(url.pathname) && !url.pathname.match(liteApiPathRegex) && url.pathname !== graphQLOptions.baseEndpoint) { + return new Response('Not found', { status: 404 }); + } + + // if an origin server is configured, pass the request + if (env.USE_ORIGIN === 'true') { + try { + const response = await fetchWithTimeout(request.clone(), { + 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}`); + } } - if (url.pathname === '/webhook/nightbot' || - url.pathname === '/webhook/stream-elements' || - url.pathname === '/webhook/moobot' - ) { - response = await getNightbotResponse(request, url, env, ctx); + if (nightbotPaths.includes(url.pathname)) { + return await getNightbotResponse(request, url, env, ctx); + } + + if (url.pathname.match(liteApiPathRegex)) { + 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/plugins/plugin-lite-api.mjs b/plugins/plugin-lite-api.mjs new file mode 100644 index 00000000..1db1b9eb --- /dev/null +++ b/plugins/plugin-lite-api.mjs @@ -0,0 +1,178 @@ +import cacheMachine from '../utils/cache-machine.mjs'; +import DataSource from '../datasources/index.mjs'; +import graphqlUtil from '../utils/graphql-util.mjs'; + +export const liteApiPathRegex = /\/api\/v1(?\/\w+)?\/(?item[\w\/]*)/; + +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' && !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`); + } + 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 (!url.pathname.match(liteApiPathRegex)) { + 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..5f2d3da2 100644 --- a/plugins/plugin-nightbot.mjs +++ b/plugins/plugin-nightbot.mjs @@ -6,7 +6,7 @@ function capitalize(s) { return s && s[0].toUpperCase() + s.slice(1); } -const usePaths = [ +export const nightbotPaths = [ '/webhook/nightbot', '/webhook/stream-elements', '/webhook/moobot', @@ -88,7 +88,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 (!nightbotPaths.includes(url.pathname)) { return; } const response = await getNightbotResponse(request, url, env, serverContext);