├── .node-version ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── workflows │ ├── combine-prs.yml │ ├── new-pr.yml │ ├── test.yml │ ├── unlock-on-merge.yml │ ├── deploy.yml │ └── branch-deploy.yml ├── dependabot.yml └── new-pr-comment.md ├── .prettierrc ├── .gitignore ├── resolvers ├── statusResolver.mjs ├── ammoResolver.mjs ├── barterResolver.mjs ├── index.mjs ├── craftResolver.mjs ├── hideoutResolver.mjs ├── traderResolver.mjs ├── mapResolver.mjs └── itemResolver.mjs ├── SECURITY.md ├── webpack.dev.js ├── plugins ├── plugin-playground.mjs ├── plugin-request-timer.mjs ├── plugin-option-method.mjs ├── plugin-twitch.mjs ├── plugin-graphql-origin.mjs ├── plugin-nightbot.mjs ├── plugin-use-cache-machine.mjs └── plugin-lite-api.mjs ├── webpack.config.js ├── datasources ├── status.mjs ├── archived-prices.mjs ├── schema.mjs ├── crafts.mjs ├── historical-prices.mjs ├── trader-inventory.mjs ├── barters.mjs ├── hideout.mjs ├── handbook.mjs ├── traders.mjs ├── index.mjs ├── maps.mjs ├── tasks.mjs └── items.mjs ├── schema-dynamic.mjs ├── script ├── test └── release ├── utils ├── setCors.mjs ├── build-attributes.js ├── graphql-options.mjs ├── worker-kv-split.mjs ├── graphql-util.mjs ├── cache-machine.mjs └── worker-kv.mjs ├── http ├── package.json ├── test-kv.mjs ├── cloudflare-kv.mjs ├── env-binding.mjs └── index.mjs ├── wrangler.toml ├── package.json ├── docs ├── maintainer-notes.md └── graphql-examples.md ├── test-playground.mjs ├── populate-local-kv.mjs ├── loader.js ├── graphql-yoga.mjs ├── schema.mjs ├── README.md ├── tail.js ├── handlers └── graphiql.mjs └── index.mjs /.node-version: -------------------------------------------------------------------------------- 1 | 20.11.0 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default reviewers for all files in the repo 2 | * @the-hideout/reviewers 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: tarkov-dev 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | worker/ 3 | .cargo-ok 4 | dist/ 5 | .env 6 | .vscode/ 7 | tmp/ 8 | kv/ 9 | .*.vars 10 | -------------------------------------------------------------------------------- /resolvers/statusResolver.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | Query: { 3 | status(obj, args, context, info) { 4 | return context.data.worker.status.getStatus(context); 5 | } 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 🔒 2 | 3 | ## Supported Versions 4 | 5 | The `main` branch of this repo is considered active and supported for all security concerns 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you discover a security vulnerability, please reach out in our [community Discord](https://discord.gg/XPAsKGHSzH) to report it. 10 | -------------------------------------------------------------------------------- /resolvers/ammoResolver.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | Query: { 3 | ammo(obj, args, context, info) { 4 | return context.util.paginate(context.data.worker.item.getAmmoList(context, info), args); 5 | } 6 | }, 7 | Ammo: { 8 | item(data, args, context, info) { 9 | return context.data.worker.item.getItem(context, info, data.id); 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | mode: 'none', // "production" | "development" | "none" 3 | resolve: { 4 | extensions: ['*', '.mjs', '.js', '.json'] 5 | }, 6 | target: 'webworker', 7 | entry: './index.js', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.mjs$/, 12 | include: /node_modules/, 13 | type: 'javascript/auto' 14 | } 15 | ] 16 | } 17 | }; 18 | 19 | module.exports = config; 20 | -------------------------------------------------------------------------------- /plugins/plugin-playground.mjs: -------------------------------------------------------------------------------- 1 | import graphiql from '../handlers/graphiql.mjs'; 2 | import graphQLOptions from '../utils/graphql-options.mjs'; 3 | 4 | const usePaths = [ 5 | '/', 6 | ]; 7 | 8 | export default function usePlayground() { 9 | return { 10 | async onRequest({ url, endResponse }) { 11 | if (!usePaths.includes(url.pathname)) { 12 | return; 13 | } 14 | 15 | endResponse(graphiql(graphQLOptions)); 16 | }, 17 | } 18 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | mode: 'production', // "production" | "development" | "none" 3 | resolve: { 4 | extensions: ['*', '.mjs', '.js', '.json'] 5 | }, 6 | target: 'webworker', 7 | entry: './index.js', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.mjs$/, 12 | include: /node_modules/, 13 | type: 'javascript/auto' 14 | } 15 | ] 16 | }, 17 | }; 18 | 19 | module.exports = config; 20 | -------------------------------------------------------------------------------- /.github/workflows/combine-prs.yml: -------------------------------------------------------------------------------- 1 | name: Combine PRs 2 | 3 | on: 4 | schedule: 5 | - cron: "0 1 * * 3" # Wednesday at 01:00 6 | workflow_dispatch: 7 | 8 | jobs: 9 | combine-prs: 10 | uses: the-hideout/reusable-workflows/.github/workflows/combine-prs.yml@main 11 | secrets: 12 | COMBINE_PRS_APP_ID: ${{ secrets.COMBINE_PRS_APP_ID }} 13 | COMBINE_PRS_PRIVATE_KEY: ${{ secrets.COMBINE_PRS_PRIVATE_KEY }} 14 | fallback: ${{ secrets.GITHUB_TOKEN }} # fall back to the default token if the app token is not available 15 | -------------------------------------------------------------------------------- /datasources/status.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class StatusAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('status_data', dataSource); 8 | } 9 | 10 | async getStatus(context) { 11 | const { cache } = await this.getCache(context); 12 | if (!cache) { 13 | return Promise.reject(new GraphQLError('Status cache is empty')); 14 | } 15 | return cache.ServerStatus; 16 | } 17 | } 18 | 19 | export default StatusAPI; 20 | -------------------------------------------------------------------------------- /plugins/plugin-request-timer.mjs: -------------------------------------------------------------------------------- 1 | export default function useRequestTimer() { 2 | return { 3 | onRequest({ request, url, endResponse, serverContext, fetchAPI }) { 4 | request.startTime = new Date(); 5 | if (serverContext.waitUntil) { 6 | request.ctx = { waitUntil: serverContext.waitUntil }; 7 | } 8 | }, 9 | onResponse({request, response, serverContext, setResponse, fetchAPI}) { 10 | console.log(`Response sent in ${new Date() - request.startTime}ms`); 11 | }, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | --- 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: "/" 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" 12 | schedule: 13 | interval: monthly 14 | - package-ecosystem: npm 15 | directory: "/" 16 | groups: 17 | npm-dependencies: 18 | patterns: 19 | - "*" 20 | schedule: 21 | interval: monthly 22 | -------------------------------------------------------------------------------- /resolvers/barterResolver.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | Query: { 3 | barters(obj, args, context, info) { 4 | return context.util.paginate(context.data.worker.barter.getList(context, info), args); 5 | } 6 | }, 7 | Barter: { 8 | taskUnlock(data, args, context, info) { 9 | if (!data || !data.taskUnlock) return null; 10 | return context.data.worker.task.get(context, info, data.taskUnlock); 11 | }, 12 | trader(data, args, context, info) { 13 | return context.data.worker.trader.get(context, info, data.trader_id); 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.github/workflows/new-pr.yml: -------------------------------------------------------------------------------- 1 | name: New Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | pull-requests: write 10 | contents: read 11 | 12 | jobs: 13 | comment: 14 | if: github.event_name == 'pull_request' && github.event.action == 'opened' 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | # Comment on new PR requests with deployment instructions 19 | - uses: actions/checkout@v6 20 | 21 | - name: comment 22 | uses: GrantBirki/comment@v2.1.1 23 | continue-on-error: true 24 | with: 25 | file: .github/new-pr-comment.md 26 | -------------------------------------------------------------------------------- /schema-dynamic.mjs: -------------------------------------------------------------------------------- 1 | export default async (data, context) => { 2 | const itemTypes = await data.worker.schema.getItemTypes(context); 3 | const categories = await data.worker.schema.getCategories(context); 4 | const handbookCategories = await data.worker.schema.getHandbookCategories(context); 5 | const languageCodes = await data.worker.schema.getLanguageCodes(context); 6 | return ` 7 | enum ItemType { 8 | ${itemTypes} 9 | } 10 | enum ItemCategoryName { 11 | ${categories} 12 | } 13 | enum HandbookCategoryName { 14 | ${handbookCategories} 15 | } 16 | enum LanguageCode { 17 | ${languageCodes} 18 | } 19 | `; 20 | }; 21 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # CI helper script for running tests 4 | 5 | START=1 6 | END=60 7 | 8 | # Start the GraphQL server in the background 9 | CLOUDFLARE_API_TOKEN=$CLOUDFLARE_API_TOKEN npm run ci & 10 | 11 | for (( c=$START; c<=$END; c++ )) 12 | do 13 | echo "⏳ Checking for GraphQL server to come online - Attempt: #$c" 14 | curl -s http://localhost:8787/ > /dev/null 15 | if [ $? -eq 0 ]; then 16 | echo "✔️ GraphQL server is up" 17 | newman run script/ci/Tarkov.dev.postman_collection.json 18 | exit $? 19 | fi 20 | sleep 1 21 | done 22 | 23 | echo "❌ GraphQL server did not respond in $END retries" 24 | exit 1 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: [ main ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: checkout 18 | uses: actions/checkout@v6 19 | 20 | - name: setup node 21 | uses: actions/setup-node@v6 22 | with: 23 | node-version-file: .node-version 24 | cache: npm 25 | 26 | - run: npm ci 27 | 28 | - name: test 29 | env: 30 | CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 31 | run: script/test 32 | -------------------------------------------------------------------------------- /plugins/plugin-option-method.mjs: -------------------------------------------------------------------------------- 1 | export default function useOptionMethod() { 2 | return { 3 | async onRequest({ request, endResponse, serverContext }) { 4 | if (request.method.toUpperCase() !== 'OPTIONS') { 5 | return; 6 | } 7 | const optionsResponse = new Response(null, { 8 | headers: { 9 | 'cache-control': 'public, max-age=2592000', 10 | 'Access-Control-Max-Age': '86400', 11 | }, 12 | }); 13 | //setCors(optionsResponse, graphQLOptions.cors); 14 | endResponse(optionsResponse); 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/unlock-on-merge.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/github/branch-deploy/blob/d3c24bd92505e623615b75ffdfac5ed5259adbdb/docs/unlock-on-merge.md 2 | 3 | name: Unlock On Merge 4 | 5 | on: 6 | pull_request: 7 | types: [closed] 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | unlock-on-merge: 14 | runs-on: ubuntu-latest 15 | if: github.event.pull_request.merged == true 16 | 17 | steps: 18 | - name: unlock on merge 19 | uses: github/branch-deploy@v11 20 | id: unlock-on-merge 21 | with: 22 | unlock_on_merge_mode: "true" # <-- indicates that this is the "Unlock on Merge Mode" workflow 23 | environment_targets: production,development 24 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Usage: 4 | # script/release 5 | 6 | # COLORS 7 | OFF='\033[0m' 8 | RED='\033[0;31m' 9 | GREEN='\033[0;32m' 10 | BLUE='\033[0;34m' 11 | 12 | latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1)) 13 | echo -e "The latest release tag is:${BLUE}${latest_tag}${OFF}" 14 | read -p 'New Release Tag (vX.X.X format): ' new_tag 15 | 16 | tag_regex='^v\d\.\d\.\d$' 17 | echo "$new_tag" | grep -P -q $tag_regex 18 | 19 | if [[ $? -ne 0 ]]; then 20 | echo "Tag: $new_tag is valid" 21 | fi 22 | 23 | git tag -a $new_tag -m "$new_tag Release" 24 | 25 | echo -e "${GREEN}OK${OFF} - Tagged: $new_tag" 26 | 27 | git push --tags 28 | 29 | echo -e "${GREEN}OK${OFF} - Tags pushed to remote! This will trigger a GitHub action release" 30 | echo -e "${GREEN}DONE${OFF}" -------------------------------------------------------------------------------- /utils/setCors.mjs: -------------------------------------------------------------------------------- 1 | import graphQLOptions from "./graphql-options.mjs"; 2 | 3 | const setCorsHeaders = (response, config) => { 4 | const corsConfig = config instanceof Object ? config : graphQLOptions.cors; 5 | 6 | response.headers.set( 7 | 'Access-Control-Allow-Credentials', 8 | corsConfig?.allowCredentials ?? 'true', 9 | ) 10 | response.headers.set( 11 | 'Access-Control-Allow-Headers', 12 | corsConfig?.allowHeaders ?? 'Content-type', 13 | ) 14 | response.headers.set( 15 | 'Access-Control-Allow-Methods', 16 | corsConfig?.allowMethods ?? 'OPTIONS, GET, POST', 17 | ) 18 | response.headers.set('Access-Control-Allow-Origin', corsConfig?.allowOrigin ?? '*') 19 | response.headers.set('X-Content-Type-Options', 'nosniff') 20 | } 21 | 22 | export default setCorsHeaders; 23 | -------------------------------------------------------------------------------- /datasources/archived-prices.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKVSplit from '../utils/worker-kv-split.mjs'; 4 | 5 | class ArchivedPricesAPI extends WorkerKVSplit { 6 | constructor(dataSource) { 7 | super('archived_price_data', dataSource); 8 | this.addGameMode('pve'); 9 | } 10 | 11 | async getByItemId(context, info, itemId) { 12 | const { cache } = await this.getCache(context, info, itemId); 13 | if (!cache) { 14 | return Promise.reject(new GraphQLError('Archived prices data is empty')); 15 | } 16 | 17 | let prices = cache.ArchivedPrices[itemId]; 18 | if (!prices) { 19 | return []; 20 | } 21 | return prices; 22 | } 23 | } 24 | 25 | export default ArchivedPricesAPI; 26 | -------------------------------------------------------------------------------- /utils/build-attributes.js: -------------------------------------------------------------------------------- 1 | module.exports = (object, list = [], include = false) => { 2 | const attributes = { 3 | int: [], 4 | float: [], 5 | string: [], 6 | boolean: [] 7 | } 8 | for (const att in object) { 9 | if ((list.includes(att) && !include) || (!list.includes(att) && include)) continue; 10 | const val = object[att]; 11 | let type = typeof val; 12 | if (type === 'number') { 13 | if (Number.isInteger(val)) { 14 | type = 'int'; 15 | } else { 16 | type = 'float'; 17 | } 18 | } 19 | if (!attributes[type]) continue; 20 | attributes[type].push({ 21 | name: att, 22 | value: val 23 | }); 24 | } 25 | return attributes; 26 | }; 27 | -------------------------------------------------------------------------------- /resolvers/index.mjs: -------------------------------------------------------------------------------- 1 | import { mergeResolvers } from '@graphql-tools/merge'; 2 | 3 | import ammoResolver from './ammoResolver.mjs'; 4 | import barterResolver from './barterResolver.mjs'; 5 | import craftResolver from './craftResolver.mjs'; 6 | import hideoutResolver from './hideoutResolver.mjs'; 7 | import itemResolver from './itemResolver.mjs'; 8 | import mapResolver from './mapResolver.mjs'; 9 | import statusResolver from './statusResolver.mjs'; 10 | import taskResolver from './taskResolver.mjs'; 11 | import traderResolver from './traderResolver.mjs'; 12 | 13 | 14 | 15 | const mergedResolvers = mergeResolvers([ 16 | ammoResolver, 17 | barterResolver, 18 | craftResolver, 19 | hideoutResolver, 20 | itemResolver, 21 | mapResolver, 22 | statusResolver, 23 | taskResolver, 24 | traderResolver, 25 | ]); 26 | 27 | export default mergedResolvers; 28 | -------------------------------------------------------------------------------- /http/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tarkov-api", 3 | "version": "0.0.0", 4 | "description": "Tarkov Data API", 5 | "main": "index.mjs", 6 | "engines": { 7 | "node": ">=20.11.0" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\"", 11 | "dev": "nodemon index.mjs", 12 | "start": "node index.mjs" 13 | }, 14 | "author": "Oskar Risberg + The-Hideout team", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "nodemon": "^3.1.3" 18 | }, 19 | "dependencies": { 20 | "@graphql-tools/merge": "9.1.2", 21 | "@graphql-tools/schema": "10.0.26", 22 | "graphql-yoga": "^5.16.0", 23 | "dotenv": "^16.4.5", 24 | "uuid": "^13.0.0" 25 | }, 26 | "nodemonConfig": { 27 | "watch": ["../", "*.mjs", ".env"], 28 | "ignore": [], 29 | "delay": 2500 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /utils/graphql-options.mjs: -------------------------------------------------------------------------------- 1 | const graphQLOptions = { 2 | // Set the path for the GraphQL server 3 | baseEndpoint: '/graphql', 4 | 5 | // Set the path for the GraphQL playground 6 | // This option can be removed to disable the playground route 7 | playgroundEndpoint: '/', 8 | 9 | // When a request's path isn't matched, forward it to the origin 10 | forwardUnmatchedRequestsToOrigin: false, 11 | 12 | // Enable debug mode to return script errors directly in browser 13 | debug: true, 14 | 15 | // Enable CORS headers on GraphQL requests 16 | // Set to `true` for defaults (see `utils/setCors`), 17 | // or pass an object to configure each header 18 | // cors: true, 19 | cors: { 20 | allowCredentials: 'true', 21 | allowHeaders: 'Content-type', 22 | allowOrigin: '*', 23 | allowMethods: 'OPTIONS, GET, POST', 24 | }, 25 | }; 26 | 27 | export default graphQLOptions; 28 | -------------------------------------------------------------------------------- /resolvers/craftResolver.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | Query: { 3 | crafts(obj, args, context, info) { 4 | return context.util.paginate(context.data.worker.craft.getList(context, info), args); 5 | } 6 | }, 7 | Craft: { 8 | requiredQuestItems(data, args, context, info) { 9 | return Promise.all(data.requiredQuestItems.map(qi => { 10 | if (qi === null) { 11 | return qi; 12 | } 13 | return context.data.worker.task.getQuestItem(context, info, qi.item); 14 | })); 15 | }, 16 | station(data, args, context, info) { 17 | return context.data.worker.hideout.getStation(context, info, data.station); 18 | }, 19 | taskUnlock(data, args, context, info) { 20 | if (!data || !data.taskUnlock) return null; 21 | return context.data.worker.task.get(context, info, data.taskUnlock); 22 | }, 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /.github/new-pr-comment.md: -------------------------------------------------------------------------------- 1 | ### 👋 Thanks for opening a pull request! 2 | 3 | If you are new, please check out the trimmed down summary of our deployment process below: 4 | 5 | 1. 👀 Observe the CI jobs and tests to ensure they are passing 6 | 1. ✔️ Obtain an approval/review on this pull request 7 | 1. 🚀 Deploy your pull request to the **development** environment with `.deploy to development` 8 | 1. 🚀 Deploy your pull request to the **production** environment with `.deploy` 9 | 10 | > If anything goes wrong, rollback with `.deploy main` 11 | 12 | 1. 🎉 Merge! 13 | 14 | > Need help? Type `.help` as a comment or visit the [usage guide](https://github.com/github/branch-deploy/blob/main/docs/usage.md) for more details 15 | 16 | Please note, if you have a more complex change, it is advised to claim a deployment lock with `.lock --reason ` to prevent other deployments from happening while you are working on your change. 17 | 18 | Once your PR has been merged, you can remove the lock with `.unlock `. 19 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2024-11-11" 2 | name = "api" 3 | main = "index.mjs" 4 | account_id = "424ad63426a1ae47d559873f929eb9fc" 5 | workers_dev = true 6 | kv_namespaces = [ 7 | { binding = "DATA_CACHE", id = "2e6feba88a9e4097b6d2209191ed4ae5", preview_id = "17fd725f04984e408d4a70b37c817171" }, 8 | ] 9 | vars = { ENVIRONMENT = "production", USE_ORIGIN = "true" } 10 | routes = [ 11 | { pattern = "api.tarkov.dev/*", zone_name = "tarkov.dev" }, 12 | { pattern = "streamer.tarkov.dev/*", zone_name = "tarkov.dev" } 13 | ] 14 | 15 | [env.development] 16 | kv_namespaces = [ 17 | { binding = "DATA_CACHE", id = "17fd725f04984e408d4a70b37c817171", preview_id = "17fd725f04984e408d4a70b37c817171" }, 18 | ] 19 | vars = { ENVIRONMENT = "development", USE_ORIGIN = "false", SKIP_CACHE = "false" } 20 | routes = [ 21 | { pattern = "dev-api.tarkov.dev/*", zone_name = "tarkov.dev" }, 22 | { pattern = "dev-streamer.tarkov.dev/*", zone_name = "tarkov.dev" } 23 | ] 24 | 25 | # [secrets] 26 | # CACHE_BASIC_AUTH 27 | # TWITCH_CLIENT_ID 28 | # TWITCH_TOKEN 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tarkov-api", 3 | "version": "0.0.0", 4 | "description": "Tarkov Data API", 5 | "main": "index.mjs", 6 | "engines": { 7 | "node": ">=20.11.0" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\"", 11 | "format": "prettier --write '**/*.{js,css,json,md}'", 12 | "dev": "wrangler dev --env development --remote", 13 | "ci": "wrangler dev --env development --remote", 14 | "local": "wrangler dev --env development", 15 | "tail": "node tail.js", 16 | "tail-dev": "node tail.js development" 17 | }, 18 | "author": "Oskar Risberg + The-Hideout team", 19 | "license": "ISC", 20 | "devDependencies": { 21 | "newman": "^6.2.1", 22 | "prettier": "^3.7.3", 23 | "wrangler": "^4.51.0" 24 | }, 25 | "dependencies": { 26 | "@graphql-tools/merge": "9.1.6", 27 | "@graphql-tools/schema": "10.0.30", 28 | "graphql-yoga": "^5.17.0", 29 | "uuid": "^13.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /plugins/plugin-twitch.mjs: -------------------------------------------------------------------------------- 1 | const usePaths = [ 2 | '/twitch', 3 | ]; 4 | 5 | export async function getTwitchResponse(env) { 6 | try { 7 | const response = await fetch('https://api.twitch.tv/helix/streams?game_id=491931&first=100', { 8 | headers: { 9 | 'Authorization': `Bearer ${env.TWITCH_TOKEN}`, 10 | 'Client-ID': env.TWITCH_CLIENT_ID, 11 | }, 12 | }); 13 | 14 | if (response.status !== 200) { 15 | response.body.cancel(); 16 | throw new Error(`Invalid response code ${response.status}`); 17 | } 18 | 19 | const twitchJson = JSON.stringify(await response.json(), null, 2); 20 | 21 | return new Response(twitchJson, { 22 | headers: { 23 | 'content-type': 'application/json;charset=UTF-8', 24 | }, 25 | }); 26 | } catch (error) { 27 | return new Response('Error retrieving Twitch data', { 28 | status: 500, 29 | }); 30 | } 31 | } 32 | 33 | export default function useTwitch(env) { 34 | return { 35 | async onRequest({ url, endResponse }) { 36 | if (!usePaths.includes(url.pathname)) { 37 | return; 38 | } 39 | endResponse(await getTwitchResponse(env)); 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docs/maintainer-notes.md: -------------------------------------------------------------------------------- 1 | # Maintainer Notes 2 | 3 | This is a living document for the maintainers of this repository. 4 | 5 | ## Deployment Process 6 | 7 | This is a simple run down of how review / deploy a pull request. 8 | 9 | 1. A pull request is opened 10 | 2. We review the code 11 | 3. We comment `.deploy to development` and ensure the creator of the pull request is happy with the changes 12 | 4. We comment `.deploy` to deploy to production. We "let it bake" and ensure everything is working as expected for a few minutes to a few days depending on the complexity of the changes. 13 | 5. We approve the pull request and merge it into `main` 14 | 15 | > It should be noted that the approval step can come before the deployment steps if that suits the situation better. 16 | 17 | ## CI Failures 18 | 19 | A known issue (I am not sure of the cause) for CI failures is when dependabot opens a pull request. For some very strange reason, the necessary secrets are not injected into the Actions workflow when the pull request comes from dependabot. This causes the wrangler environment in CI to fail because it lacks the proper credentials to authenticate with Cloudflare. 20 | 21 | The fix: Simply push a commit to the branch in question. It can be an empty commit if you like. This will trigger the CI workflow again and the secrets will be injected properly. 22 | 23 | IDK why this happens, but it does. ¯\\\_(ツ)\_/¯ 24 | -------------------------------------------------------------------------------- /datasources/schema.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class SchemaAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('schema_data', dataSource); 8 | } 9 | 10 | async getCategories(context) { 11 | const { cache } = await this.getCache(context); 12 | if (!cache) { 13 | return Promise.reject(new GraphQLError('Schema cache is empty')); 14 | } 15 | return cache.ItemCategory; 16 | } 17 | 18 | async getHandbookCategories(context) { 19 | const { cache } = await this.getCache(context); 20 | if (!cache) { 21 | return Promise.reject(new GraphQLError('Schema cache is empty')); 22 | } 23 | return cache.HandbookCategory; 24 | } 25 | 26 | async getItemTypes(context) { 27 | const { cache } = await this.getCache(context); 28 | if (!cache) { 29 | return Promise.reject(new GraphQLError('Schema cache is empty')); 30 | } 31 | return cache.ItemType; 32 | } 33 | 34 | async getLanguageCodes(context) { 35 | const { cache } = await this.getCache(context); 36 | if (!cache) { 37 | return Promise.reject(new GraphQLError('Schema cache is empty')); 38 | } 39 | return cache.LanguageCode; 40 | } 41 | } 42 | 43 | export default SchemaAPI; 44 | -------------------------------------------------------------------------------- /http/test-kv.mjs: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import DataAPI from '../datasources/index.mjs'; 4 | import cloudflareKv from './cloudflare-kv.mjs'; 5 | 6 | const data = new DataAPI(); 7 | 8 | const getKv = async (kvName) => { 9 | const response = await cloudflareKv.get(kvName); 10 | if (response.status !== 200) { 11 | console.error('error', kvName, `${response.status} ${response.statusText}`); 12 | return; 13 | } 14 | console.log(response.status, kvName, (await response.text()).length); 15 | }; 16 | 17 | for (const workerName in data.worker) { 18 | const worker = data.worker[workerName]; 19 | for (const gameMode of worker.gameModes) { 20 | let kvName = worker.kvName; 21 | let suffix = ''; 22 | if (gameMode !== 'regular') { 23 | suffix = `_${gameMode}`; 24 | } 25 | try { 26 | if (worker.kvs) { 27 | for (const hexKey in worker.kvs) { 28 | const splitWorker = worker.kvs[hexKey]; 29 | const fullKvName = `${splitWorker.kvName}${suffix}`; 30 | getKv(fullKvName); 31 | } 32 | } else { 33 | const fullKvName = `${kvName}${suffix}`; 34 | getKv(fullKvName); 35 | } 36 | } catch (error) { 37 | console.error(kvName, gameMode, error); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test-playground.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | Local GraphQL Playground for static schema (SDL) development/testing. 5 | - Runs a standalone server on http://localhost:4000/graphql 6 | - Uses only the static SDL (schema-static.mjs) and stub scalars 7 | - Does not use dynamic data or production API logic 8 | - Useful for viewing schema docs and testing queries against the static schema 9 | - Not used in production or deployment 10 | */ 11 | 12 | import http from 'http' 13 | import { createYoga } from 'graphql-yoga' 14 | import { makeExecutableSchema } from '@graphql-tools/schema' 15 | import staticSDL from './schema-static.mjs' 16 | 17 | // Stubs for custom scalars referenced in staticSDL 18 | const stubSDL = ` 19 | scalar LanguageCode 20 | scalar ItemType 21 | scalar ItemCategoryName 22 | scalar HandbookCategoryName 23 | ` 24 | 25 | // Build schema from static SDL and stubs 26 | const schema = makeExecutableSchema({ 27 | typeDefs: [staticSDL, stubSDL], 28 | resolvers: {}, 29 | }) 30 | 31 | // Enable GraphiQL at /graphql for local exploration 32 | const yoga = createYoga({ 33 | schema, 34 | graphiql: true, 35 | }) 36 | 37 | // Start HTTP server on port 4000 38 | const server = http.createServer(yoga) 39 | const PORT = 4000 40 | server.listen(PORT, () => { 41 | console.log( 42 | `🚀 GraphQL Playground running at http://localhost:${PORT}/graphql` 43 | ) 44 | }) 45 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | deployment-check: 13 | runs-on: ubuntu-latest 14 | outputs: # set outputs for use in downstream jobs 15 | continue: ${{ steps.deployment-check.outputs.continue }} 16 | sha: ${{ steps.deployment-check.outputs.sha }} 17 | 18 | steps: 19 | # https://github.com/github/branch-deploy/blob/d3c24bd92505e623615b75ffdfac5ed5259adbdb/docs/merge-commit-strategy.md 20 | - name: deployment check 21 | uses: github/branch-deploy@v11 22 | id: deployment-check 23 | with: 24 | merge_deploy_mode: "true" 25 | environment: production 26 | 27 | deploy: 28 | if: ${{ needs.deployment-check.outputs.continue == 'true' }} 29 | needs: deployment-check 30 | environment: production 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - name: checkout 35 | uses: actions/checkout@v6 36 | with: 37 | ref: ${{ needs.deployment-check.outputs.sha }} 38 | 39 | - name: setup node 40 | uses: actions/setup-node@v6 41 | with: 42 | node-version-file: .node-version 43 | cache: npm 44 | 45 | - name: install dependencies 46 | run: npm ci 47 | 48 | - name: Publish - Production 49 | uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # pin@3.14.1 50 | with: 51 | wranglerVersion: '2.17.0' 52 | apiToken: ${{ secrets.CF_API_TOKEN }} 53 | -------------------------------------------------------------------------------- /utils/worker-kv-split.mjs: -------------------------------------------------------------------------------- 1 | import WorkerKV from './worker-kv.mjs'; 2 | 3 | class WorkerKVSplit { 4 | constructor(kvName, dataSource, idLength = 1) { 5 | this.dataExpires = {}; 6 | this.kvs = {}; 7 | this.idLength = idLength; 8 | const hexKeys = []; 9 | const maxDecimalValue = parseInt('f'.padEnd(idLength, 'f'), 16); 10 | for (let i = 0; i <= maxDecimalValue; i++) { 11 | const hexValue = i.toString(16).padStart(idLength, '0'); 12 | hexKeys.push(hexValue); 13 | } 14 | for (const hexKey of hexKeys) { 15 | this.kvs[hexKey] = new WorkerKV(`${kvName}_${hexKey}`, dataSource); 16 | } 17 | this.gameModes = ['regular']; 18 | } 19 | 20 | getIdSuffix(id) { 21 | return id.substring(id.length-this.idLength, id.length); 22 | } 23 | 24 | addGameMode(gameMode) { 25 | this.gameModes.push(gameMode); 26 | for (const key in this.kvs) { 27 | this.kvs[key].gameModes.push(gameMode); 28 | } 29 | } 30 | 31 | async getCache(context, info, id) { 32 | const kvId = this.getIdSuffix(id); 33 | if (!this.kvs[kvId]) { 34 | return Promise.reject(`${id} is not a valid id`); 35 | } 36 | return this.kvs[kvId].getCache(context, info).then((result) => { 37 | if (result.cache?.expiration) { 38 | this.dataExpires[result.gameMode] = new Date(result.cache.expiration).valueOf(); 39 | } 40 | return result; 41 | }); 42 | } 43 | } 44 | 45 | export default WorkerKVSplit; 46 | -------------------------------------------------------------------------------- /plugins/plugin-graphql-origin.mjs: -------------------------------------------------------------------------------- 1 | // Pass the request to an origin server if USE_ORIGIN is set to 'true' 2 | 3 | export default function useGraphQLOrigin(env) { 4 | return { 5 | async onParams({params, request, setParams, setResult, fetchAPI}) { 6 | if (env.USE_ORIGIN !== 'true') { 7 | return; 8 | } 9 | try { 10 | const originUrl = new URL(request.url); 11 | if (env.ORIGIN_OVERRIDE) { 12 | originUrl.host = env.ORIGIN_OVERRIDE; 13 | } 14 | const queryResult = await fetch(originUrl, { 15 | method: request.method, 16 | body: JSON.stringify(params), 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | signal: AbortSignal.timeout(20000), 21 | }); 22 | if (queryResult.status !== 200) { 23 | throw new Error(`${queryResult.status} ${queryResult.statusText}: ${await queryResult.text()}`); 24 | } 25 | console.log('Request served from origin server'); 26 | request.cached = true; 27 | if (queryResult.headers.has('X-Cache-Ttl')) { 28 | request.resultTtl = queryResult.headers.get('X-Cache-Ttl'); 29 | } 30 | setResult(await queryResult.json()); 31 | } catch (error) { 32 | console.error(`Error getting response from origin server: ${error}`); 33 | } 34 | }, 35 | } 36 | } -------------------------------------------------------------------------------- /datasources/crafts.mjs: -------------------------------------------------------------------------------- 1 | // datasource for crafts 2 | import WorkerKV from '../utils/worker-kv.mjs'; 3 | 4 | class CraftsAPI extends WorkerKV { 5 | constructor(dataSource) { 6 | super('craft_data', dataSource); 7 | this.gameModes.push('pve'); 8 | } 9 | 10 | async getList(context, info) { 11 | const { cache } = await this.getCache(context, info); 12 | return cache.Craft; 13 | } 14 | 15 | async get(context, info, id) { 16 | const { cache } = await this.getCache(context, info); 17 | return cache.Craft.filter(c => c.id === id); 18 | } 19 | 20 | async getCraftsForItem(context, info, id) { 21 | const { cache } = await this.getCache(context, info); 22 | return cache.Craft.filter(craft => { 23 | return craft.rewardItems.some(rew => rew.item === id); 24 | }); 25 | } 26 | 27 | async getCraftsUsingItem(context, info, id) { 28 | const { cache } = await this.getCache(context, info); 29 | return cache.Craft.filter(craft => { 30 | return craft.requiredItems.some(req => req.item === id); 31 | }); 32 | } 33 | 34 | async getCraftsForStation(context, info, id) { 35 | const { cache } = await this.getCache(context, info); 36 | return cache.Craft.filter(craft => { 37 | return craft.station_id === id; 38 | }); 39 | } 40 | 41 | async getCraftsForStationLevel(context, info, id, level) { 42 | const { cache } = await this.getCache(context, info); 43 | return cache.Craft.filter(craft => { 44 | return craft.station_id === id && craft.level === level; 45 | }); 46 | } 47 | } 48 | 49 | export default CraftsAPI; 50 | -------------------------------------------------------------------------------- /datasources/historical-prices.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKVSplit from '../utils/worker-kv-split.mjs'; 4 | 5 | class historicalPricesAPI extends WorkerKVSplit { 6 | constructor(dataSource) { 7 | super('historical_price_data', dataSource); 8 | this.addGameMode('pve'); 9 | this.defaultDays = 7; 10 | this.maxDays = 30; 11 | this.itemLimitDays = 2; 12 | } 13 | 14 | async getByItemId(context, info, itemId, days = this.defaultDays, halfResults = false) { 15 | const { cache } = await this.getCache(context, info, itemId); 16 | if (!cache) { 17 | return Promise.reject(new GraphQLError('Historical prices cache is empty')); 18 | } 19 | 20 | if (days > this.maxDays || days < 1) { 21 | const warningMessage = `Historical prices days argument of ${days} must be 1-${this.maxDays}; defaulting to ${this.defaultDays}.`; 22 | days = this.defaultDays; 23 | if (!context.warnings.some(warning => warning.message === warningMessage)) { 24 | context.warnings.push({message: warningMessage}); 25 | } 26 | } 27 | 28 | let prices = cache.historicalPricePoint[itemId]; 29 | if (!prices) { 30 | return []; 31 | } 32 | if (days === this.maxDays) { 33 | return prices; 34 | } 35 | const cutoffTimestamp = new Date().setDate(new Date().getDate() - days); 36 | let dayFiltered = prices.filter(hp => hp.timestamp >= cutoffTimestamp); 37 | if (halfResults) { 38 | dayFiltered = dayFiltered.filter((hp, index) => index % 2 === 0); 39 | } 40 | return dayFiltered; 41 | } 42 | } 43 | 44 | export default historicalPricesAPI; 45 | -------------------------------------------------------------------------------- /docs/graphql-examples.md: -------------------------------------------------------------------------------- 1 | # GraphQL Examples 📚 2 | 3 | A document full of helpful examples for how you can use the GraphQL API. 4 | 5 | > Note: For even more examples (and different programming languages) check out our API page: [tarkov.dev/api](https://tarkov.dev/api) 6 | 7 | ## Examples 8 | 9 | ### Game Status 10 | 11 | Get information about server and game status for Escape from Tarkov 12 | 13 | ```graphql 14 | { 15 | status { 16 | currentStatuses { 17 | name 18 | message 19 | status 20 | } 21 | messages { 22 | time 23 | type 24 | content 25 | solveTime 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | ### Item Data 32 | 33 | Retrieve information about a given item in the game. 34 | 35 | ```graphql 36 | { 37 | itemsByName(name: "colt m4a1") { 38 | name 39 | types 40 | avg24hPrice 41 | basePrice 42 | width 43 | height 44 | changeLast48hPercent 45 | iconLink 46 | link 47 | sellFor { 48 | price 49 | source 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | ### Tasks 56 | 57 | Retrieve quest or task info. 58 | 59 | [Inline fragments](https://www.apollographql.com/docs/react/data/fragments/#using-fragments-with-unions-and-interfaces) are used as `objectives` return an `interface TaskObjective`. 60 | 61 | ```graphql 62 | query { 63 | tasks { 64 | id 65 | name 66 | objectives { 67 | id 68 | type 69 | description 70 | maps { 71 | normalizedName 72 | } 73 | ... on TaskObjectiveItem { 74 | item { 75 | name 76 | shortName 77 | } 78 | items { 79 | name 80 | shortName 81 | } 82 | count 83 | foundInRaid 84 | } 85 | ... on TaskObjectiveShoot{ 86 | targetNames 87 | count 88 | } 89 | } 90 | } 91 | } 92 | ``` -------------------------------------------------------------------------------- /datasources/trader-inventory.mjs: -------------------------------------------------------------------------------- 1 | import WorkerKV from '../utils/worker-kv.mjs'; 2 | 3 | class TraderInventoryAPI extends WorkerKV { 4 | constructor(dataSource) { 5 | super('trader_price_data', dataSource); 6 | this.traderCache = {}; 7 | this.gameModes.push('pve'); 8 | } 9 | 10 | async getTraderCache(context, info) { 11 | const { cache, gameMode } = await this.getCache(context, info); 12 | if (this.traderCache[gameMode]) { 13 | return {cache: this.traderCache[gameMode], gameMode}; 14 | } 15 | 16 | try { 17 | const traderCache = {}; 18 | for (const itemOffers of Object.values(cache.TraderCashOffer)) { 19 | for (const offer of itemOffers) { 20 | if (!traderCache[offer.vendor.trader_id]) traderCache[offer.vendor.trader_id] = []; 21 | traderCache[offer.vendor.trader_id].push(offer); 22 | } 23 | } 24 | this.traderCache[gameMode] = traderCache; 25 | } catch (error) { 26 | return Promise.reject(error); 27 | } 28 | return {cache: this.traderCache[gameMode], gameMode}; 29 | } 30 | 31 | async getByItemId(context, info, itemId) { 32 | const { cache } = await this.getCache(context, info); 33 | return cache.TraderCashOffer[itemId] ?? []; 34 | } 35 | 36 | async getPricesForTrader(context, info, traderId) { 37 | const { cache } = await this.getTraderCache(context, info); 38 | return cache[traderId] ?? []; 39 | } 40 | 41 | async getPricesForTraderLevel(context, info, traderId, level) { 42 | const traderPrices = await this.getPricesForTrader(context, info, traderId); 43 | return traderPrices.filter(offer => { 44 | return offer.vendor.traderLevel === level; 45 | }); 46 | } 47 | } 48 | 49 | export default TraderInventoryAPI; 50 | -------------------------------------------------------------------------------- /http/cloudflare-kv.mjs: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | 3 | const completeEmitter = new EventEmitter(); 4 | 5 | const accountId = '424ad63426a1ae47d559873f929eb9fc'; 6 | 7 | const productionNamespaceId = '2e6feba88a9e4097b6d2209191ed4ae5'; 8 | const devNameSpaceID = '17fd725f04984e408d4a70b37c817171'; 9 | 10 | const requestLimit = 6; 11 | 12 | let pending = []; 13 | const queue = []; 14 | 15 | const checkQueue = async () => { 16 | if (pending.length >= requestLimit) { 17 | return; 18 | } 19 | if (queue.length < 1) { 20 | return; 21 | } 22 | const kvName = queue.shift(); 23 | pending.push(kvName); 24 | 25 | const namespaceId = process.env.ENVIRONMENT === 'production' ? productionNamespaceId : devNameSpaceID; 26 | const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${kvName}`; 27 | let response; 28 | try { 29 | response = await fetch(url, { 30 | method: 'GET', 31 | headers: { 32 | 'Content-Type': 'application/json', 33 | Authorization: `Bearer ${process.env.CLOUDFLARE_TOKEN}`, 34 | }, 35 | signal: AbortSignal.timeout(9000), 36 | }); 37 | completeEmitter.emit(kvName, response); 38 | } catch (error) { 39 | //response = new Response(null, {status: 500, statusText: error.message}); 40 | queue.unshift(kvName); 41 | } finally { 42 | pending = pending.filter(kv => kv !== kvName); 43 | } 44 | checkQueue(); 45 | }; 46 | 47 | const cloudflareKv = { 48 | get: async (kvName) => { 49 | return new Promise((resolve) => { 50 | completeEmitter.once(kvName, resolve); 51 | if (!pending.includes(kvName) && !queue.includes(kvName)) { 52 | queue.push(kvName); 53 | } 54 | checkQueue(); 55 | }); 56 | }, 57 | }; 58 | 59 | export default cloudflareKv; 60 | -------------------------------------------------------------------------------- /populate-local-kv.mjs: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import fs from 'node:fs'; 3 | 4 | import DataAPI from './datasources/index.mjs'; 5 | 6 | const data = new DataAPI(); 7 | 8 | 9 | function getKv(kvName) { 10 | return new Promise((resolve, reject) => { 11 | exec(`npx wrangler kv key get "${kvName}" --binding=DATA_CACHE --env=development`, {maxBuffer: 1024 * 35000}, (error, stdout, stderr) => { 12 | if (error) { 13 | return reject(error); 14 | } 15 | fs.writeFileSync(`./${kvName}`, stdout); 16 | resolve(stdout); 17 | }); 18 | }); 19 | } 20 | 21 | function saveLocalKv(kvName) { 22 | console.log(`${ kvName} loading into local storage`); 23 | return new Promise(async (resolve, reject) => { 24 | await getKv(kvName).catch(reject); 25 | exec(`npx wrangler kv key put "${kvName}" --path="${kvName}" --binding=DATA_CACHE --env=development --local --preview false`, (error, stdout, stderr) => { 26 | if (error) { 27 | return reject(error); 28 | } 29 | fs.rmSync(`./${kvName}`); 30 | resolve(stdout); 31 | }); 32 | }); 33 | } 34 | 35 | for (const workerName in data.worker) { 36 | const worker = data.worker[workerName]; 37 | for (const gameMode of worker.gameModes) { 38 | let kvName = worker.kvName; 39 | let suffix = ''; 40 | if (gameMode !== 'regular') { 41 | suffix = `_${gameMode}`; 42 | } 43 | try { 44 | if (worker.kvs) { 45 | for (const hexKey in worker.kvs) { 46 | const splitWorker = worker.kvs[hexKey]; 47 | const fullKvName = `${splitWorker.kvName}${suffix}`; 48 | await saveLocalKv(fullKvName); 49 | } 50 | } else { 51 | const fullKvName = `${kvName}${suffix}`; 52 | await saveLocalKv(fullKvName); 53 | } 54 | } catch (error) { 55 | console.error(kvName, gameMode, error); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /loader.js: -------------------------------------------------------------------------------- 1 | // This is used in DEV environments where we don't actually connect to the KV stores... 2 | // ...so we stub the implementations and download everything locally instead 3 | 4 | const _get = async (url) => { 5 | const response = await fetch(url, {}) 6 | return response.json() 7 | }; 8 | 9 | if (typeof DATA_CACHE === 'undefined') { 10 | global.DATA_CACHE = { 11 | get: async (what) => { 12 | console.log(`trying to get ${what}`) 13 | 14 | if (what === 'TRADER_ITEMS') { 15 | console.log('getting trader items'); 16 | 17 | return _get( 18 | 'https://manager.tarkov.dev/data/trader-items.json' 19 | ); 20 | } 21 | 22 | if (what === 'ITEM_CACHE') { 23 | console.log('getting item cache'); 24 | 25 | return _get( 26 | 'https://manager.tarkov.dev/data/item-data.json' 27 | ); 28 | } 29 | 30 | if (what === 'BARTER_DATA') { 31 | console.log('getting barter data'); 32 | 33 | return _get( 34 | 'https://manager.tarkov.dev/data/barter-data.json' 35 | ); 36 | } 37 | 38 | if (what === 'CRAFT_DATA') { 39 | console.log('getting craft data'); 40 | 41 | return _get( 42 | 'https://manager.tarkov.dev/data/craft-data.json' 43 | ); 44 | } 45 | 46 | if (what === 'HIDEOUT_DATA') { 47 | console.log('getting hideout data'); 48 | 49 | return _get( 50 | 'https://manager.tarkov.dev/data/hideout-data.json' 51 | ); 52 | } 53 | 54 | if (what === 'QUEST_DATA') { 55 | console.log('getting quest data'); 56 | 57 | return _get( 58 | 'https://manager.tarkov.dev/data/quest-data.json' 59 | ); 60 | } 61 | 62 | return null 63 | }, 64 | put: async (...a) => { 65 | console.log('trying to PUT item data', ...a) 66 | return false 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /datasources/barters.mjs: -------------------------------------------------------------------------------- 1 | import WorkerKV from '../utils/worker-kv.mjs'; 2 | 3 | const isAnyDogtag = id => { 4 | return id === '59f32bb586f774757e1e8442' || id === '59f32c3b86f77472a31742f0' || id === '5b9b9020e7ef6f5716480215'; 5 | }; 6 | 7 | const isBothDogtags = id => { 8 | return id === '5b9b9020e7ef6f5716480215'; 9 | }; 10 | 11 | class BartersAPI extends WorkerKV { 12 | constructor(dataSource) { 13 | super('barter_data', dataSource); 14 | this.gameModes.push('pve'); 15 | } 16 | 17 | async getList(context, info) { 18 | const { cache } = await this.getCache(context, info); 19 | return cache.Barter; 20 | } 21 | 22 | async getBartersForItem(context, info, id) { 23 | const { cache } = await this.getCache(context, info); 24 | return cache.Barter.filter(barter => { 25 | for (const item of barter.rewardItems) { 26 | if (item.item === id) return true; 27 | if (item.baseId === id) return true; 28 | } 29 | return false; 30 | }); 31 | } 32 | 33 | async getBartersUsingItem(context, info, id) { 34 | const { cache } = await this.getCache(context, info); 35 | return cache.Barter.filter(barter => { 36 | for (const item of barter.requiredItems) { 37 | if (item.item === id) return true; 38 | if (isBothDogtags(id) && isAnyDogtag(item.item)) { 39 | return true; 40 | } 41 | if (isBothDogtags(item.item) && isAnyDogtag(id)) { 42 | return true; 43 | } 44 | } 45 | return false; 46 | }); 47 | } 48 | 49 | async getBartersForTrader(context, info, id) { 50 | const { cache } = await this.getCache(context, info); 51 | return cache.Barter.filter(barter => { 52 | if (barter.trader_id === id) return true; 53 | return false; 54 | }); 55 | } 56 | 57 | async getBartersForTraderLevel(context, info, id, level) { 58 | const { cache } = await this.getCache(context, info); 59 | return cache.Barter.filter(barter => { 60 | if (barter.trader_id === id && barter.level === level) return true; 61 | return false; 62 | }); 63 | } 64 | } 65 | 66 | export default BartersAPI; 67 | -------------------------------------------------------------------------------- /graphql-yoga.mjs: -------------------------------------------------------------------------------- 1 | import { createYoga, useExecutionCancellation } from 'graphql-yoga' 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | import DataSource from './datasources/index.mjs'; 5 | import schema from './schema.mjs'; 6 | import graphqlUtil from './utils/graphql-util.mjs'; 7 | import graphQLOptions from './utils/graphql-options.mjs'; 8 | 9 | import useRequestTimer from './plugins/plugin-request-timer.mjs'; 10 | import useGraphQLOrigin from './plugins/plugin-graphql-origin.mjs'; 11 | import useCacheMachine from './plugins/plugin-use-cache-machine.mjs'; 12 | import useTwitch from './plugins/plugin-twitch.mjs'; 13 | import useNightbot from './plugins/plugin-nightbot.mjs'; 14 | import usePlayground from './plugins/plugin-playground.mjs'; 15 | import useOptionMethod from './plugins/plugin-option-method.mjs'; 16 | import useLiteApi from './plugins/plugin-lite-api.mjs'; 17 | 18 | let dataAPI, yoga; 19 | 20 | export default async function getYoga(env) { 21 | if (!dataAPI) { 22 | dataAPI = new DataSource(env); 23 | } 24 | if (yoga) { 25 | dataAPI.env = env; 26 | return yoga; 27 | } 28 | yoga = createYoga({ 29 | schema: (context) => { 30 | // this context only has the env vars present on creation 31 | context.request.requestId = uuidv4(); 32 | if (env.ctx) { 33 | context.request.ctx = env.ctx; 34 | } 35 | if (context.ctx) { 36 | context.request.ctx = context.ctx; 37 | } 38 | return schema(dataAPI, graphqlUtil.getDefaultContext(dataAPI, context.request.requestId)); 39 | }, 40 | context: async ({request, params}) => { 41 | return graphqlUtil.getDefaultContext(dataAPI, request.requestId); 42 | }, 43 | plugins: [ 44 | //useExecutionCancellation(), 45 | useRequestTimer(), 46 | useOptionMethod(), 47 | useTwitch(env), 48 | usePlayground(), 49 | useCacheMachine(env), 50 | useGraphQLOrigin(env), 51 | useNightbot(env), 52 | useLiteApi(env), 53 | ], 54 | cors: { 55 | origin: graphQLOptions.cors.allowOrigin, 56 | credentials: true, 57 | allowedHeaders: ['Content-Type'], 58 | methods: graphQLOptions.cors.allowMethods.split(', '), 59 | }, 60 | }); 61 | return yoga; 62 | } 63 | -------------------------------------------------------------------------------- /datasources/hideout.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class HideoutAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('hideout_data', dataSource); 8 | this.gameModes.push('pve'); 9 | } 10 | 11 | async getList(context, info) { 12 | const { cache } = await this.getCache(context, info); 13 | return cache.HideoutStation; 14 | } 15 | 16 | async getModuleById(context, info, id) { 17 | const { cache } = await this.getCache(context, info); 18 | for (const hideoutStation of cache.HideoutStation) { 19 | for (const stage of hideoutStation.levels) { 20 | if (stage.id === id) { 21 | return stage; 22 | } 23 | } 24 | } 25 | return Promise.reject(new GraphQLError(`No hideout station level found with id ${id}`)); 26 | } 27 | 28 | async getModuleByLevel(context, info, stationId, level) { 29 | const { cache } = await this.getCache(context, info); 30 | for (const hideoutStation of cache.HideoutStation) { 31 | if (hideoutStation.id !== stationId) continue; 32 | for (const stage of hideoutStation.levels) { 33 | if (stage.level === level) { 34 | return stage; 35 | } 36 | } 37 | } 38 | return Promise.reject(new GraphQLError(`No hideout station level found with id ${stationId} and level ${level}`)); 39 | } 40 | 41 | async getStation(context, info, id) { 42 | const { cache } = await this.getCache(context, info); 43 | for (const station of cache.HideoutStation) { 44 | if (station.id === id) return station; 45 | } 46 | return Promise.reject(new GraphQLError(`No hideout station found with id ${id}`)); 47 | } 48 | 49 | async getLegacyList(context, info) { 50 | const { cache } = await this.getCache(context, info); 51 | return cache.HideoutModule; 52 | } 53 | 54 | async getLegacyModule(context, info, name, level) { 55 | const { cache } = await this.getCache(context, info); 56 | for (const module of cache.HideoutModule) { 57 | if (module.name === name && module.quantity === level) { 58 | return module; 59 | } 60 | } 61 | return Promise.reject(new GraphQLError(`No hideout module with id ${id} found`)); 62 | } 63 | } 64 | 65 | export default HideoutAPI; 66 | -------------------------------------------------------------------------------- /schema.mjs: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from '@graphql-tools/schema'; 2 | import { mergeTypeDefs } from '@graphql-tools/merge'; 3 | 4 | import resolvers from './resolvers/index.mjs'; 5 | import schemaStatic from './schema-static.mjs'; 6 | import schemaDynamic from './schema-dynamic.mjs'; 7 | 8 | let schema, loadingSchema; 9 | let lastSchemaRefresh = 0; 10 | 11 | const schemaRefreshInterval = 1000 * 60 * 10; 12 | 13 | export default async function getSchema(data, context) { 14 | if (schema && new Date() - lastSchemaRefresh < schemaRefreshInterval) { 15 | return schema; 16 | } 17 | if (loadingSchema) { 18 | return new Promise((resolve) => { 19 | let loadingTimedOut = false; 20 | const loadingTimeout = setTimeout(() => { 21 | loadingTimedOut = true; 22 | }, 3100); 23 | const loadingInterval = setInterval(() => { 24 | if (!loadingSchema) { 25 | clearTimeout(loadingTimeout); 26 | clearInterval(loadingInterval); 27 | return resolve(schema); 28 | } 29 | if (loadingTimedOut) { 30 | console.log(`Schema loading timed out; forcing load`); 31 | clearInterval(loadingInterval); 32 | loadingSchema = false; 33 | return resolve(getSchema(data, context)); 34 | } 35 | }, 100); 36 | }); 37 | } 38 | loadingSchema = true; 39 | return schemaDynamic(data, context).catch(error => { 40 | console.error('Error loading dynamic type definitions', error); 41 | return Promise.reject(error); 42 | }).then(dynamicDefs => { 43 | let mergedDefs; 44 | try { 45 | mergedDefs = mergeTypeDefs([schemaStatic, dynamicDefs]); 46 | } catch (error) { 47 | console.error('Error merging type defs', error); 48 | return Promise.reject(error); 49 | } 50 | try { 51 | schema = makeExecutableSchema({ typeDefs: mergedDefs, resolvers: resolvers }); 52 | //console.log('schema loaded'); 53 | lastSchemaRefresh = new Date(); 54 | return schema; 55 | } catch (error) { 56 | console.error('Error making schema executable'); 57 | if (!error.message) { 58 | console.error('Check type names in resolvers'); 59 | } else { 60 | console.error(error.message); 61 | } 62 | return Promise.reject(error); 63 | } 64 | }).finally(() => { 65 | loadingSchema = false; 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /resolvers/hideoutResolver.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | Query: { 3 | hideoutModules(obj, args, context, info) { 4 | context.warnings.push(`The hideoutModules query is deprecated and provided only for backwards compatibility purposes. Please use the hideoutStations query, which includes the latest hideout information.`); 5 | return context.data.worker.hideout.getLegacyList(context, info); 6 | }, 7 | hideoutStations(obj, args, context, info) { 8 | return context.util.paginate(context.data.worker.hideout.getList(context, info), args); 9 | }, 10 | }, 11 | HideoutStation: { 12 | crafts(data, args, context, info) { 13 | context.util.testDepthLimit(info, 1); 14 | return context.data.worker.craft.getCraftsForStation(context, info, data.id); 15 | }, 16 | name(data, args, context, info) { 17 | return context.data.worker.hideout.getLocale(data.name, context, info); 18 | } 19 | }, 20 | HideoutStationBonus: { 21 | name(data, args, context, info) { 22 | return context.data.worker.hideout.getLocale(data.name, context, info); 23 | }, 24 | skillName(data, args, context, info) { 25 | return context.data.worker.hideout.getLocale(data.skillName, context, info); 26 | }, 27 | slotItems(data, args, context, info) { 28 | if (!data.slotItems || data.slotItems.length === 0) { 29 | return []; 30 | } 31 | return context.data.worker.item.getItemsByIDs(context, info, data.slotItems); 32 | } 33 | }, 34 | HideoutStationLevel: { 35 | crafts(data, args, context, info) { 36 | context.util.testDepthLimit(info, 2); 37 | return context.data.worker.craft.getCraftsForStationLevel(context, info, data.id.substring(0, data.id.indexOf('-')), data.level); 38 | }, 39 | description(data, args, context, info) { 40 | return context.data.worker.hideout.getLocale(data.description, context, info); 41 | } 42 | }, 43 | RequirementHideoutStationLevel: { 44 | station(data, args, context, info) { 45 | return context.data.worker.hideout.getStation(context, info, data.station); 46 | } 47 | }, 48 | RequirementSkill: { 49 | name(data, args, context, info) { 50 | return context.data.worker.hideout.getLocale(data.name, context, info); 51 | }, 52 | skill(data, args, context, info) { 53 | return context.data.worker.handbook.getSkill(context, info, data.name); 54 | }, 55 | }, 56 | HideoutModule: { 57 | moduleRequirements(data, args, context, info) { 58 | return data.moduleRequirements.map(req => { 59 | return context.data.worker.hideout.getLegacyModule(context, info, req.name, req.quantity); 60 | }); 61 | } 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /http/env-binding.mjs: -------------------------------------------------------------------------------- 1 | // this module provides a way for the http server to access the cloudflare KVs 2 | // using the same method as in the worker context 3 | import cluster from 'node:cluster'; 4 | import { EventEmitter } from 'node:events'; 5 | 6 | import { v4 as uuidv4} from 'uuid'; 7 | 8 | import cacheMachine from '../utils/cache-machine.mjs'; 9 | import cloudflareKv from './cloudflare-kv.mjs'; 10 | 11 | const emitter = new EventEmitter(); 12 | 13 | if (!cluster.isPrimary) { 14 | process.on('message', (message) => { 15 | if (!message.id) { 16 | return; 17 | } 18 | emitter.emit(message.id, message); 19 | }); 20 | } 21 | 22 | async function messageParentProcess(message) { 23 | return new Promise(async (resolve, reject) => { 24 | const messageId = uuidv4(); 25 | const responseTimeout = setTimeout(() => { 26 | emitter.off(messageId, messageResponseHandler); 27 | reject(new Error('Response from primary process timed out')); 28 | }, message.timeout ?? 10000); 29 | const messageResponseHandler = (response) => { 30 | clearTimeout(responseTimeout); 31 | if (response.error) { 32 | return reject(new Error(response.error)); 33 | } 34 | resolve(response.data); 35 | } 36 | emitter.once(messageId, messageResponseHandler); 37 | process.send({ ...message, id: messageId }); 38 | }); 39 | } 40 | 41 | async function getDataPrimary(kvName, format) { 42 | const response = await cloudflareKv.get(kvName); 43 | if (response.status === 404) { 44 | return null; 45 | } 46 | if (response.status === 400) { 47 | return Promise.reject(new Error('Invalid CLOUDFLARE_TOKEN')); 48 | } 49 | if (response.status !== 200) { 50 | return Promise.reject(new Error(`${response.statusText} ${response.status}`)); 51 | } 52 | if (format === 'json') { 53 | return response.json(); 54 | } 55 | return response.text(); 56 | } 57 | 58 | async function getDataWorker(kvName, format) { 59 | return messageParentProcess({action: 'getKv', kvName, timeout: 25000}); 60 | } 61 | 62 | const DATA_CACHE = { 63 | get: (kvName, format) => { 64 | if (cluster.isPrimary) { 65 | return getDataPrimary(kvName, format); 66 | } 67 | return getDataWorker(kvName, format); 68 | }, 69 | getWithMetadata: async (kvName, format) => { 70 | return { 71 | value: await DATA_CACHE.get(kvName, format), 72 | }; 73 | }, 74 | }; 75 | 76 | const putCacheWorker = async (env, body, options) => { 77 | const key = await cacheMachine.createKey(env, options.query, options.variables, options.specialCache); 78 | messageParentProcess({action: 'cacheResponse', key, body, ttl: options.ttl}).catch(error => { 79 | console.error(`Error updating cache`, error); 80 | }); 81 | return; 82 | }; 83 | 84 | const RESPONSE_CACHE = { 85 | get: cacheMachine.get, 86 | put: (env, body, options) => { 87 | if (cluster.isPrimary) { 88 | return cacheMachine.put(env, body, options); 89 | } 90 | return putCacheWorker(env, body, options); 91 | }, 92 | }; 93 | 94 | export default function getEnv() { 95 | return { 96 | ...process.env, 97 | DATA_CACHE, 98 | RESPONSE_CACHE, 99 | //ctx: {waitUntil: () => {}}, 100 | } 101 | }; -------------------------------------------------------------------------------- /datasources/handbook.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class HandbookAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('handbook_data', dataSource); 8 | this.gameModes.push('pve'); 9 | } 10 | 11 | async getCategory(context, info, id) { 12 | const { cache } = await this.getCache(context, info); 13 | return cache.ItemCategory[id] || cache.HandbookCategory[id]; 14 | } 15 | 16 | async getTopCategory(context, info, id) { 17 | const cat = await this.getCategory(context, info, id); 18 | if (cat && cat.parent_id) return this.getTopCategory(context, info, cat.parent_id); 19 | return cat; 20 | } 21 | 22 | async getCategories(context, info) { 23 | const { cache } = await this.getCache(context, info); 24 | if (!cache) { 25 | return Promise.reject(new GraphQLError('Item cache is empty')); 26 | } 27 | const categories = []; 28 | for (const id in cache.ItemCategory) { 29 | categories.push(cache.ItemCategory[id]); 30 | } 31 | return categories; 32 | } 33 | 34 | async getCategoriesEnum(context, info) { 35 | const cats = await this.getCategories(context, info); 36 | const map = {}; 37 | for (const id in cats) { 38 | map[cats[id].enumName] = cats[id]; 39 | } 40 | return map; 41 | } 42 | 43 | async getHandbookCategory(context, info, id) { 44 | const { cache } = await this.getCache(context, info); 45 | return cache.HandbookCategory[id]; 46 | } 47 | 48 | async getHandbookCategories(context, info) { 49 | const { cache } = await this.getCache(context, info); 50 | if (!cache) { 51 | return Promise.reject(new GraphQLError('Item cache is empty')); 52 | } 53 | return Object.values(cache.HandbookCategory); 54 | } 55 | 56 | async getArmorMaterials(context, info) { 57 | const { cache } = await this.getCache(context, info); 58 | return Object.values(cache.ArmorMaterial).sort(); 59 | } 60 | 61 | async getArmorMaterial(context, info, matKey) { 62 | const { cache } = await this.getCache(context, info); 63 | return cache.ArmorMaterial[matKey]; 64 | } 65 | 66 | async getMasterings(context, info) { 67 | const { cache } = await this.getCache(context, info); 68 | return cache.Mastering; 69 | } 70 | 71 | async getMastering(context, info, mastId) { 72 | const { cache } = await this.getCache(context, info); 73 | return cache.Mastering.find(m => m.id === mastId); 74 | } 75 | 76 | async getSkills(context, info) { 77 | const { cache } = await this.getCache(context, info); 78 | return cache.Skill; 79 | } 80 | 81 | async getSkill(context, info, skillId) { 82 | const { cache } = await this.getCache(context, info); 83 | return cache.Skill.find(s => s.id === skillId); 84 | } 85 | 86 | async getPlayerLevels(context, info) { 87 | const { cache } = await this.getCache(context, info); 88 | return cache.PlayerLevel; 89 | } 90 | 91 | async getAllItemProperties(context, info) { 92 | const { cache } = await this.getCache(context, info); 93 | return cache.ItemProperties; 94 | } 95 | 96 | async getItemProperties(context, info, itemId) { 97 | const { cache } = await this.getCache(context, info); 98 | return cache.ItemProperties[itemId]; 99 | } 100 | } 101 | 102 | export default HandbookAPI; 103 | -------------------------------------------------------------------------------- /datasources/traders.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | const currencyMap = { 6 | RUB: '5449016a4bdc2d6f028b456f', 7 | USD: '5696686a4bdc2da3298b456a', 8 | EUR: '569668774bdc2da2298b4568' 9 | }; 10 | 11 | const dataIdMap = { 12 | 0: '54cb50c76803fa8b248b4571', 13 | 1: '54cb57776803fa99248b456e', 14 | 2: '58330581ace78e27b8b10cee', 15 | 3: '5935c25fb3acc3127c3d8cd9', 16 | 4: '5a7c2eca46aef81a7ca2145d', 17 | 5: '5ac3b934156ae10c4430e83c', 18 | 6: '5c0647fdd443bc2504c2d371', 19 | 7: '579dc571d53a0658a154fbec', 20 | }; 21 | 22 | const traderNameIdMap = { 23 | 'prapor': '54cb50c76803fa8b248b4571', 24 | 'Prapor': '54cb50c76803fa8b248b4571', 25 | 'therapist': '54cb57776803fa99248b456e', 26 | 'Therapist': '54cb57776803fa99248b456e', 27 | 'fence': '579dc571d53a0658a154fbec', 28 | 'Fence': '579dc571d53a0658a154fbec', 29 | 'skier': '58330581ace78e27b8b10cee', 30 | 'Skier': '58330581ace78e27b8b10cee', 31 | 'peacekeeper': '5935c25fb3acc3127c3d8cd9', 32 | 'Peacekeeper': '5935c25fb3acc3127c3d8cd9', 33 | 'mechanic': '5a7c2eca46aef81a7ca2145d', 34 | 'Mechanic': '5a7c2eca46aef81a7ca2145d', 35 | 'ragman': '5ac3b934156ae10c4430e83c', 36 | 'Ragman': '5ac3b934156ae10c4430e83c', 37 | 'jaeger': '5c0647fdd443bc2504c2d371', 38 | 'Jaeger': '5c0647fdd443bc2504c2d371', 39 | }; 40 | 41 | class TradersAPI extends WorkerKV { 42 | constructor(dataSource) { 43 | super('trader_data', dataSource); 44 | this.gameModes.push('pve'); 45 | } 46 | 47 | async getList(context, info) { 48 | const { cache } = await this.getCache(context, info); 49 | return cache.Trader; 50 | } 51 | 52 | async get(context, info, id) { 53 | const { cache } = await this.getCache(context, info); 54 | for (const trader of cache.Trader) { 55 | if (trader.id === id) { 56 | return trader; 57 | } 58 | } 59 | 60 | return Promise.reject(new GraphQLError(`No trader found with id ${id}`)); 61 | } 62 | 63 | async getByName(context, info, name) { 64 | const { cache } = await this.getCache(context, info); 65 | for (const trader of cache.Trader) { 66 | if (this.getLocale(trader.name, context, info).toLowerCase() === name.toLowerCase()) { 67 | return trader; 68 | } 69 | } 70 | 71 | return Promise.reject(new GraphQLError(`No trader found with name ${name}`)); 72 | } 73 | 74 | async getByLevel(context, info, traderId, level) { 75 | const { cache } = await this.getCache(context, info); 76 | for (const trader of cache.Trader) { 77 | if (trader.id !== traderId) continue; 78 | for (const rawLevel of trader.levels) { 79 | if (rawLevel.level === level) { 80 | return rawLevel; 81 | } 82 | } 83 | } 84 | return Promise.reject(new GraphQLError(`No trader found with id ${traderId} and level ${level}`)); 85 | } 86 | 87 | getByDataId(context, info, dataId) { 88 | return this.get(context, info, dataIdMap[dataId]); 89 | } 90 | 91 | async getTraderResets(context, info) { 92 | const { cache } = await this.getCache(context, info); 93 | return cache.Trader.map(trader => { 94 | return { 95 | name: this.getLocale(trader.name, context, info).toLowerCase(), 96 | resetTimestamp: trader.resetTime, 97 | } 98 | }); 99 | } 100 | 101 | getCurrencyMap() { 102 | return currencyMap; 103 | } 104 | 105 | getDataIdMap() { 106 | return dataIdMap; 107 | } 108 | 109 | getNameIdMap() { 110 | return traderNameIdMap; 111 | } 112 | } 113 | 114 | export default TradersAPI; 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tarkov API (Escape from Tarkov) 💻 2 | 3 | [![deploy](https://github.com/the-hideout/tarkov-data-api/actions/workflows/deploy.yml/badge.svg)](https://github.com/the-hideout/tarkov-data-api/actions/workflows/deploy.yml) [![test](https://github.com/the-hideout/tarkov-api/actions/workflows/test.yml/badge.svg)](https://github.com/the-hideout/tarkov-api/actions/workflows/test.yml) [![Discord](https://img.shields.io/discord/956236955815907388?color=7388DA&label=Discord)](https://discord.gg/XPAsKGHSzH) 4 | 5 | This is the main API for [tarkov.dev](https://tarkov.dev), and was forked from kokarn's Tarkov Tools API. 6 | 7 | It's a simple [GraphQL](https://graphql.org/) API running on [Cloudflare workers](https://workers.cloudflare.com/). 8 | 9 | This API powers all of tarkov.dev and other notable projects as well: 10 | 11 | - [stash](https://github.com/the-hideout/stash) 12 | - [ratscanner](https://github.com/RatScanner/RatScanner) 13 | - [errbot](https://github.com/GrantBirki/errbot) 14 | - [thehideout](https://play.google.com/store/apps/details?id=com.austinhodak.thehideout&hl=en_US&gl=US) 15 | 16 | ## What is this? 💡 17 | 18 | A community made GraphQL API for Escape from Tarkov 19 | 20 | - 🆓 Free 21 | - 🔨 Easy to use 22 | - 📖 Open source 23 | - 🧑‍🤝‍🧑 Community driven 24 | - ⚡ Ultra fast 25 | - ⏰ Data is constantly updated in real-time 26 | 27 | ## What can I do with this API? ⭐ 28 | 29 | - View the prices of items 30 | - Get detailed ammo, armor, and weapon information 31 | - Fetch flea market data 32 | - View item weight, slots, etc 33 | - Calculate barter and hideout profit 34 | - Determine ergo, armor class, durability, etc for an item 35 | - Fetch detailed quest information and unlocks 36 | - View info about crafts and their requirements 37 | - Find information about in-game bosses 38 | - Detailed info on medicines, stims, and in-game healing 39 | - So much more (it would take up this entire page to list everything 😸) 40 | 41 | > This [API](https://api.tarkov.dev/) does almost everything you would ever want for EFT! 42 | 43 | ## API Playground 🎾 44 | 45 | There is a GraphQL playground for you to use and test out 46 | 47 | **Link:** [api.tarkov.dev/](https://api.tarkov.dev/) 48 | 49 | Example Query: 50 | 51 | ```graphql 52 | query { 53 | items { 54 | id 55 | name 56 | shortName 57 | wikiLink 58 | iconLink 59 | updated 60 | } 61 | } 62 | ``` 63 | 64 | More examples can be found in our [graphql example docs](./docs/graphql-examples.md) 📚 65 | 66 | > Even more examples can be found on our [api](https://tarkov.dev/api/) page on tarkov.dev (includes many programming languages too) 67 | 68 | ## Development 🔨 69 | 70 | Prerequisites: 71 | 72 | - Install [Wrangler](https://github.com/cloudflare/wrangler) 73 | - Run `wrangler login` - (needed for k/v store and secrets) 74 | 75 | You may want to create a .dev.vars file in the main project folder with the following values: 76 | 77 | - CACHE_BASIC_AUTH (used for caching) 78 | 79 | Start the API server: 80 | 81 | - Start the dev environment by running `npm run dev` 82 | - Then open up the playground on [localhost:8787/___graphql](http://127.0.0.1:8787/___graphql) 83 | 84 | ## Deployment 🚀 85 | 86 | If you wish to deploy locally and have permissions to do so, run the following command: 87 | 88 | ```bash 89 | wrangler publish 90 | ``` 91 | 92 | > We don't do this often and generally use GitHub actions to do all of our deployments for us 93 | 94 | ## HTTP Server 95 | 96 | There's also an http webserver in the /http folder. It can be run with `npm run dev` or `npm start`. To run locally, you need to set the following vars (for local testing, you can use an .env file in the /http folder): 97 | 98 | - CLOUDFLARE_TOKEN (token must have permissions to read the KVs the API uses) 99 | - CACHE_BASIC_AUTH (used for caching) 100 | - ENVIRONMENT (either `production` or `dev`; determines which KVs are read) 101 | - PORT (defaults to 8088) 102 | - WORKERS (defaults to # of cpus - 1; determines how many worker threads are created to respond to requests) 103 | -------------------------------------------------------------------------------- /tail.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | 3 | const skipCanceled = true; 4 | 5 | const logColors = { 6 | error: 31, 7 | info: 34, 8 | warn: 33, 9 | }; 10 | 11 | const ac = new AbortController(); 12 | let envArg = ''; 13 | let logOnlyError = false; 14 | let shuttingDown = false; 15 | 16 | const outputLog = (json) => { 17 | try { 18 | if (logOnlyError && json.outcome === 'ok') { 19 | return; 20 | } 21 | if (skipCanceled && json.outcome === 'canceled') { 22 | return; 23 | } 24 | for (const logMessage of json.logs) { 25 | const level = logMessage.level;//json.outcome === 'ok' ? logMessage.level : 'error'; 26 | let message = logMessage.message.join('\n'); 27 | if (logColors[level]) { 28 | message = `\x1b[${logColors[level]}m${message}\x1b[0m`; 29 | } 30 | console[level](message); 31 | } 32 | console.log(`Wall time: ${json.wallTime}`); 33 | console.log(`CPU time: ${json.cpuTime}`); 34 | if (json.outcome !== 'ok') { 35 | const errorDesc = json.exceptions.map(ex => ex.message).join('; ') || json.outcome; 36 | console.error(`\x1b[${logColors.error}mFatal Error: ${errorDesc}\x1b[0m`); 37 | //console.error(`\x1b[${logColors.error}mUrl: ${json.event.request.url}\x1b[0m`); 38 | if (json.event.request?.headers?.origin) { 39 | console.error(`\x1b[${logColors.error}mOrigin: ${json.event.request.headers.origin}\x1b[0m`); 40 | } 41 | //console.log(rawLog); 42 | } 43 | } catch (error) { 44 | console.error(`\x1b[${logColors.error}mError processing wrangler output\x1b[0m`, error); 45 | } 46 | }; 47 | 48 | const startTail = () => { 49 | const wrangler = spawn('cmd', ['/c', `wrangler tail${envArg}`], { 50 | signal: ac.signal, 51 | }); 52 | wrangler.stdout.on('data', (data) => { 53 | try { 54 | const jsons = JSON.parse(`[${String(data).replace(/}\s*{/g, '},{')}]`); 55 | for (const json of jsons) { 56 | outputLog(json); 57 | } 58 | } catch (error) { 59 | if (error.message.includes('Unexpected token')) { 60 | console.error(`\x1b[${logColors.error}mJSON parsing error. Raw log:\x1b[0m`, String(data)); 61 | return; 62 | } 63 | if (error.message.includes('Unexpected non-whitespace')) { 64 | console.error(`\x1b[${logColors.error}mJSON parsing error. Raw log:\x1b[0m`, String(data)); 65 | return; 66 | } 67 | console.error('Error processing wrangler output', error.message); 68 | console.error(data); 69 | } 70 | }); 71 | wrangler.on('error', (error) => { 72 | if (error.code === 'ABORT_ERR') { 73 | return; 74 | } 75 | console.log('wrangler process error', error); 76 | }); 77 | wrangler.on('close', () => { 78 | //console.log(`wranger closed with code ${code}`); 79 | if (!shuttingDown) { 80 | console.log('Wrangler closed unexpectedly; restarting'); 81 | startTail(); 82 | } 83 | }); 84 | } 85 | 86 | (async () => { 87 | const shutdown = () => { 88 | shuttingDown = true; 89 | ac.abort(); 90 | }; 91 | //gracefully shutdown on Ctrl+C 92 | process.on( 'SIGINT', shutdown); 93 | //gracefully shutdown on Ctrl+Break 94 | process.on( 'SIGBREAK', shutdown); 95 | //try to gracefully shutdown on terminal closed 96 | process.on( 'SIGHUP', shutdown); 97 | 98 | 99 | let env = 'production'; 100 | if (process.argv.includes('development')) { 101 | env = 'development'; 102 | envArg = ` --env ${env}`; 103 | } 104 | if (process.argv.includes('error')) { 105 | logOnlyError = true; 106 | } 107 | startTail(); 108 | console.log(`Listening to worker logs for ${env} environment${logOnlyError ? ' (errors only)' : ''}`); 109 | })(); -------------------------------------------------------------------------------- /.github/workflows/branch-deploy.yml: -------------------------------------------------------------------------------- 1 | name: branch-deploy 2 | 3 | on: 4 | issue_comment: 5 | types: [ created ] 6 | 7 | # Permissions needed for reacting and adding comments for IssueOps commands 8 | permissions: 9 | pull-requests: write 10 | deployments: write 11 | contents: write 12 | checks: read 13 | statuses: read 14 | 15 | jobs: 16 | deploy: 17 | environment: secrets 18 | if: ${{ github.event.issue.pull_request }} # only run on pull request comments 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: github/branch-deploy@v11 23 | id: branch-deploy 24 | with: 25 | admins: the-hideout/core-contributors 26 | admins_pat: ${{ secrets.BRANCH_DEPLOY_ADMINS_PAT }} 27 | environment_targets: production,development 28 | environment_urls: production|https://api.tarkov.dev/graphql,development|https://dev-api.tarkov.dev/graphql 29 | sticky_locks: "true" 30 | 31 | - name: checkout 32 | if: ${{ steps.branch-deploy.outputs.continue == 'true' }} 33 | uses: actions/checkout@v6 34 | with: 35 | ref: ${{ steps.branch-deploy.outputs.sha }} 36 | 37 | - name: setup node 38 | if: ${{ steps.branch-deploy.outputs.continue == 'true' }} 39 | uses: actions/setup-node@v6 40 | with: 41 | node-version-file: .node-version 42 | cache: npm 43 | 44 | - name: Install dependencies 45 | if: ${{ steps.branch-deploy.outputs.continue == 'true' }} 46 | run: npm ci 47 | 48 | - name: Publish - Development 49 | if: ${{ steps.branch-deploy.outputs.environment == 'development' && 50 | steps.branch-deploy.outputs.noop != 'true' && 51 | steps.branch-deploy.outputs.continue == 'true' }} 52 | uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # pin@3.14.1 53 | with: 54 | wranglerVersion: '2.17.0' 55 | apiToken: ${{ secrets.CF_API_TOKEN }} 56 | environment: "development" 57 | 58 | # Post comment on PR with development deploy info 59 | - uses: GrantBirki/comment@v2.1.1 60 | if: ${{ steps.branch-deploy.outputs.continue == 'true' && 61 | steps.branch-deploy.outputs.noop != 'true' && 62 | steps.branch-deploy.outputs.environment == 'development' }} 63 | with: 64 | issue-number: ${{ github.event.issue.number }} 65 | body: | 66 | ### API Deployment - Development 🪐 67 | 68 | The API has been **deployed** to the **development** environment 🚀 69 | 70 | - Endpoint: `dev-api.tarkov.dev/graphql` 71 | - Playground: [dev-api.tarkov.dev](https://dev-api.tarkov.dev) 72 | 73 | > Pusher: @${{ github.actor }}, Action: `${{ github.event_name }}`, Workflow: `${{ github.workflow }}`; 74 | 75 | - name: Publish - Production 76 | if: ${{ steps.branch-deploy.outputs.continue == 'true' && 77 | steps.branch-deploy.outputs.noop != 'true' && 78 | steps.branch-deploy.outputs.environment == 'production' }} 79 | uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # pin@3.14.1 80 | with: 81 | wranglerVersion: '2.17.0' 82 | apiToken: ${{ secrets.CF_API_TOKEN }} 83 | 84 | # Post comment on PR with production deploy info 85 | - uses: GrantBirki/comment@v2.1.1 86 | if: ${{ steps.branch-deploy.outputs.continue == 'true' && 87 | steps.branch-deploy.outputs.noop != 'true' && 88 | steps.branch-deploy.outputs.environment == 'production' }} 89 | with: 90 | issue-number: ${{ github.event.issue.number }} 91 | body: | 92 | ### API Deployment - Production 🌔 93 | 94 | The API has been **deployed** to the **production** environment 🚀 95 | 96 | - Endpoint: `api.tarkov.dev/graphql` 97 | - Playground: [api.tarkov.dev](https://api.tarkov.dev) 98 | 99 | > Pusher: @${{ github.actor }}, Action: `${{ github.event_name }}`, Workflow: `${{ github.workflow }}`; 100 | -------------------------------------------------------------------------------- /resolvers/traderResolver.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | Query: { 3 | traders(obj, args, context, info) { 4 | return context.util.paginate(context.data.worker.trader.getList(context, info), args); 5 | }, 6 | traderResetTimes: (obj, args, context, info) => { 7 | return context.data.worker.trader.getTraderResets(context, info, info); 8 | }, 9 | }, 10 | Trader: { 11 | name(data, args, context, info) { 12 | return context.data.worker.trader.getLocale(data.name, context, info); 13 | }, 14 | description(data, args, context, info) { 15 | return context.data.worker.trader.getLocale(data.description, context, info); 16 | }, 17 | currency(trader, args, context, info) { 18 | return context.data.worker.item.getItem(context, info, context.data.worker.trader.getCurrencyMap()[trader.currency]); 19 | }, 20 | barters(data, args, context, info) { 21 | context.util.testDepthLimit(info, 1); 22 | return context.data.worker.barter.getBartersForTrader(context, info, data.id); 23 | }, 24 | cashOffers(data, args, context, info) { 25 | context.util.testDepthLimit(info, 1); 26 | return context.data.worker.traderInventory.getPricesForTrader(context, info, data.id); 27 | } 28 | }, 29 | TraderLevel: { 30 | barters(data, args, context, info) { 31 | context.util.testDepthLimit(info, 2); 32 | return context.data.barter.getBartersForTraderLevel(context, info, data.id.substring(0, data.id.indexOf('-')), data.level); 33 | }, 34 | cashOffers(data, args, context, info) { 35 | context.util.testDepthLimit(info, 2); 36 | return context.data.worker.traderInventory.getPricesForTraderLevel(context, info, data.id.substring(0, data.id.indexOf('-')), data.level); 37 | } 38 | }, 39 | TraderCashOffer: { 40 | id(data, args, context, info) { 41 | return data.offer_id; 42 | }, 43 | item(data, args, context, info) { 44 | return context.data.worker.item.getItem(context, info, data.id); 45 | }, 46 | minTraderLevel(data) { 47 | return data.vendor.traderLevel; 48 | }, 49 | currencyItem(data, args, context, info) { 50 | return context.data.worker.item.getItem(context, info, data.currencyItem); 51 | }, 52 | taskUnlock(data, args, context, info) { 53 | if (!data.vendor.taskUnlock) { 54 | return null; 55 | } 56 | return context.data.worker.task.get(context, info, data.vendor.taskUnlock); 57 | }, 58 | buyLimit(data) { 59 | return data.vendor.buyLimit; 60 | } 61 | }, 62 | TraderOffer: { 63 | async name(data, args, context, info) { 64 | const trader = await context.data.worker.trader.get(context, info, data.trader_id); 65 | return context.data.worker.trader.getLocale(trader.name, context, info); 66 | }, 67 | async normalizedName(data, args, context, info) { 68 | const trader = await context.data.worker.trader.get(context, info, data.trader_id); 69 | return trader.normalizedName; 70 | }, 71 | trader(data, args, context, info) { 72 | return context.data.worker.trader.get(context, info, data.trader_id); 73 | }, 74 | taskUnlock(data, args, context, info) { 75 | if (!data.taskUnlock) { 76 | return null; 77 | } 78 | return context.data.worker.task.get(context, info, data.taskUnlock); 79 | }, 80 | }, 81 | TraderPrice: { 82 | trader(data, args, context, info) { 83 | return context.data.worker.trader.get(context, info, data.trader); 84 | } 85 | }, 86 | RequirementTrader: { 87 | trader(data, args, context, info) { 88 | return context.data.worker.trader.get(context, info, data.trader_id); 89 | } 90 | }, 91 | TraderStanding: { 92 | trader(data, args, context, info) { 93 | return context.data.worker.trader.get(context, info, data.trader_id); 94 | } 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /datasources/index.mjs: -------------------------------------------------------------------------------- 1 | import BartersAPI from './barters.mjs'; 2 | import CraftsAPI from './crafts.mjs'; 3 | import HandbookAPI from './handbook.mjs'; 4 | import HideoutAPI from './hideout.mjs'; 5 | import HistoricalPricesAPI from './historical-prices.mjs'; 6 | import ArchivedPricesAPI from './archived-prices.mjs'; 7 | import ItemsAPI from './items.mjs'; 8 | import MapAPI from './maps.mjs'; 9 | import SchemaAPI from './schema.mjs'; 10 | import StatusAPI from './status.mjs'; 11 | import TasksAPI from './tasks.mjs'; 12 | import TraderInventoryAPI from './trader-inventory.mjs'; 13 | import TradersAPI from './traders.mjs'; 14 | 15 | class DataSource { 16 | constructor(env) { 17 | this.env = env; 18 | 19 | this.initialized = false; 20 | this.loading = false; 21 | this.requests = {}; 22 | this.kvLoaded = []; 23 | 24 | this.worker = { 25 | barter: new BartersAPI(this), 26 | craft: new CraftsAPI(this), 27 | handbook: new HandbookAPI(this), 28 | hideout: new HideoutAPI(this), 29 | historicalPrice: new HistoricalPricesAPI(this), 30 | archivedPrice: new ArchivedPricesAPI(this), 31 | item: new ItemsAPI(this), 32 | map: new MapAPI(this), 33 | schema: new SchemaAPI(this), 34 | status: new StatusAPI(this), 35 | task: new TasksAPI(this), 36 | trader: new TradersAPI(this), 37 | traderInventory: new TraderInventoryAPI(this), 38 | }; 39 | } 40 | 41 | kvLoadedForRequest(kvName, requestId) { 42 | if (!requestId) { 43 | return false; 44 | } 45 | if (!this.requests[requestId]) { 46 | this.requests[requestId] = {}; 47 | } 48 | if (!this.requests[requestId].kvLoaded) { 49 | this.requests[requestId].kvLoaded = []; 50 | } 51 | return this.requests[requestId].kvLoaded.includes(kvName); 52 | } 53 | 54 | setKvUsedForRequest(kvName, requestId) { 55 | if (!this.requests[requestId]) { 56 | this.requests[requestId] = {}; 57 | } 58 | if (!this.requests[requestId].kvUsed) { 59 | this.requests[requestId].kvUsed = []; 60 | } 61 | if (!this.requests[requestId].kvUsed.includes(kvName)) { 62 | this.requests[requestId].kvUsed.push(kvName); 63 | } 64 | } 65 | 66 | setKvLoadedForRequest(kvName, requestId) { 67 | if (!this.kvLoaded.includes(kvName)) { 68 | this.kvLoaded.push(kvName); 69 | } 70 | if (!this.requests[requestId]) { 71 | this.requests[requestId] = {}; 72 | } 73 | if (!this.requests[requestId].kvLoaded) { 74 | this.requests[requestId].kvLoaded = []; 75 | } 76 | if (!this.requests[requestId].kvLoaded.includes(kvName)) { 77 | this.requests[requestId].kvLoaded.push(kvName); 78 | } 79 | this.setKvUsedForRequest(kvName, requestId); 80 | } 81 | 82 | getRequestTtl(requestId) { 83 | if (!this.requests[requestId] || !this.requests[requestId].kvUsed) { 84 | return 0; 85 | } 86 | let lowestExpire = Number.MAX_SAFE_INTEGER; 87 | let schemaExpire = Number.MAX_SAFE_INTEGER; 88 | for (const worker of Object.values(this.worker)) { 89 | if (!this.requests[requestId].kvUsed.includes(worker.kvName)) { 90 | continue; 91 | } 92 | if (worker.kvName === 'schema_data') { 93 | schemaExpire = worker.dataExpires; 94 | continue; 95 | } 96 | if (typeof worker.dataExpires !== 'boolean' && worker.dataExpires < lowestExpire) { 97 | lowestExpire = worker.dataExpires; 98 | } 99 | } 100 | if (!lowestExpire) { 101 | lowestExpire = schemaExpire; 102 | } 103 | if (lowestExpire === Number.MAX_SAFE_INTEGER) { 104 | lowestExpire = 0; 105 | } 106 | let ttl = Math.round((lowestExpire - new Date().valueOf()) / 1000); 107 | if (ttl <= 0) { 108 | ttl = 0; 109 | } 110 | ttl = Math.max(ttl, 60 * 5); 111 | return ttl; 112 | } 113 | 114 | clearRequestData(requestId) { 115 | delete this.requests[requestId]; 116 | } 117 | } 118 | 119 | export default DataSource; 120 | -------------------------------------------------------------------------------- /datasources/maps.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class MapAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('map_data', dataSource); 8 | this.gameModes.push('pve'); 9 | } 10 | 11 | async getList(context, info) { 12 | const { cache } = await this.getCache(context, info); 13 | return cache.Map; 14 | } 15 | 16 | async get(context, info, id) { 17 | const { cache } = await this.getCache(context, info); 18 | for (const map of cache.Map) { 19 | if (map.id === id || map.tarkovDataId === id) return map; 20 | } 21 | return Promise.reject(new GraphQLError(`No map found with id ${id}`)); 22 | } 23 | 24 | async getMapsByNames(context, info, names, maps = false) { 25 | const { cache } = await this.getCache(context, info); 26 | if (!maps) { 27 | maps = cache.Map; 28 | } 29 | const searchStrings = names.map(name => { 30 | if (name === '') throw new GraphQLError('Searched map name cannot be blank'); 31 | return name.toLowerCase(); 32 | }); 33 | 34 | return maps.filter((map) => { 35 | for (const search of searchStrings) { 36 | if (this.getLocale(map.name, context, info).toString().toLowerCase().includes(search)) { 37 | return true; 38 | } 39 | } 40 | return false; 41 | }); 42 | } 43 | 44 | async getMapsByEnemies(context, info, enemies, maps = false) { 45 | const { cache } = await this.getCache(context, info); 46 | if (!maps) { 47 | maps = cache.Map; 48 | } 49 | const searchStrings = enemies.map(name => { 50 | if (name === '') throw new GraphQLError('Searched enemy name cannot be blank'); 51 | return name.toLowerCase(); 52 | }); 53 | 54 | return maps.filter((map) => { 55 | if (!map.locale || !map.locale[lang]) return false; 56 | for (const search of searchStrings) { 57 | if (this.getLocale(map.enemies, context, info).some(enemy => enemy.toString().toLowerCase().includes(search))) { 58 | return true; 59 | } 60 | } 61 | return false; 62 | }); 63 | } 64 | 65 | async getAllBosses(context, info) { 66 | const { cache } = await this.getCache(context, info); 67 | return Object.values(cache.MobInfo); 68 | } 69 | 70 | async getMobInfo(context, info, mobId) { 71 | const { cache } = await this.getCache(context, info); 72 | return cache.MobInfo[mobId]; 73 | } 74 | 75 | async getBossesByNames(context, info, names, bosses = false) { 76 | const { cache } = await this.getCache(context, info); 77 | if (!bosses) { 78 | bosses = Object.values(cache.MobInfo); 79 | } 80 | const searchStrings = names.map(name => { 81 | if (name === '') throw new GraphQLError('Searched boss name cannot be blank'); 82 | return name.toLowerCase(); 83 | }); 84 | 85 | return bosses.filter((boss) => { 86 | for (const search of searchStrings) { 87 | if (this.getLocale(boss.name, context, info).toString().toLowerCase().includes(search)) { 88 | return true; 89 | } 90 | } 91 | return false; 92 | }); 93 | } 94 | 95 | async getLootContainer(context, info, id) { 96 | const { cache } = await this.getCache(context, info); 97 | return cache.LootContainer[id]; 98 | } 99 | 100 | async getAllLootContainers(context, info) { 101 | const { cache } = await this.getCache(context, info); 102 | return Object.values(cache.LootContainer); 103 | } 104 | 105 | async getExtract(context, info, id) { 106 | const { cache } = await this.getCache(context, info); 107 | return cache.Map.reduce((found, current) => { 108 | if (found) { 109 | return found; 110 | } 111 | found = current.extracts.find(e => e.id === id); 112 | return found; 113 | }, false); 114 | } 115 | 116 | async getSwitch(context, info, id) { 117 | const { cache } = await this.getCache(context, info); 118 | return cache.Map.reduce((found, current) => { 119 | if (found) { 120 | return found; 121 | } 122 | found = current.switches.find(e => e.id === id); 123 | return found; 124 | }, false); 125 | } 126 | 127 | async getStationaryWeapon(context, info, id) { 128 | const { cache } = await this.getCache(context, info); 129 | return cache.StationaryWeapon[id]; 130 | } 131 | 132 | async getAllStationaryWeapons(context, info) { 133 | const { cache } = await this.getCache(context, info); 134 | return Object.values(cache.StationaryWeapon); 135 | } 136 | 137 | async getGoonReports(context, info) { 138 | const { cache } = await this.getCache(context, info); 139 | return cache.GoonReport; 140 | } 141 | } 142 | 143 | export default MapAPI; 144 | -------------------------------------------------------------------------------- /handlers/graphiql.mjs: -------------------------------------------------------------------------------- 1 | 2 | 3 | const defaultQuery = `# Welcome to the Tarkov.dev API Playground 4 | # 5 | # Type queries into this side of the screen, click the Execute query 6 | # button at the top center, the the results will appear on the right side 7 | # of the screen. 8 | # 9 | # You can explore the available queries and data types by clicking the 10 | # book icon in the upper left to open the documentation. 11 | # 12 | # Here's an example query to get you started: 13 | # 14 | { 15 | items(lang: en) { 16 | id 17 | name 18 | } 19 | } 20 | # 21 | # Keyboard shortcuts: 22 | # 23 | # Prettify query: Shift-Ctrl-P (or press the prettify button) 24 | # 25 | # Merge fragments: Shift-Ctrl-M (or press the merge button) 26 | # 27 | # Run Query: Ctrl-Enter (or press the play button) 28 | # 29 | # Auto Complete: Ctrl-Space (or just start typing) 30 | # 31 | `; 32 | 33 | const html = baseEndpoint => ` 34 | 41 | 42 | 43 | 44 | 45 | 46 | Tarkov.dev API Playground 47 | 48 | 65 | 66 | 70 | 71 | 89 | 133 | 134 | 135 |
136 |
Loading…
137 |
138 | 139 | 140 | ` 141 | 142 | const headers = { 'Content-Type': 'text/html' } 143 | const handler = ({ baseEndpoint }) => new Response(html(baseEndpoint), { headers }); 144 | 145 | export default handler; 146 | -------------------------------------------------------------------------------- /plugins/plugin-nightbot.mjs: -------------------------------------------------------------------------------- 1 | import DataSource from '../datasources/index.mjs'; 2 | import cacheMachine from '../utils/cache-machine.mjs'; 3 | import graphqlUtil from '../utils/graphql-util.mjs'; 4 | 5 | function capitalize(s) { 6 | return s && s[0].toUpperCase() + s.slice(1); 7 | } 8 | 9 | export const nightbotPaths = [ 10 | '/webhook/nightbot', 11 | '/webhook/stream-elements', 12 | '/webhook/moobot', 13 | ]; 14 | 15 | export function useNightbotOnUrl(url) { 16 | return nightbotPaths.includes(url.pathname); 17 | }; 18 | 19 | export async function getNightbotResponse(request, url, env, serverContext) { 20 | if (request.method.toUpperCase() !== 'GET') { 21 | return new Response(null, { 22 | status: 405, 23 | headers: { 'cache-control': 'public, max-age=2592000' }, 24 | }); 25 | } 26 | 27 | if (!url.searchParams.get('q')) { 28 | return new Response('Missing q param', { 29 | status: 405, 30 | headers: { 'cache-control': 'public, max-age=2592000' }, 31 | }); 32 | } 33 | 34 | const lang = url.searchParams.get('l') || 'en'; 35 | const gameMode = url.searchParams.get('m') || 'regular'; 36 | const query = url.searchParams.get('q'); 37 | 38 | let key; 39 | if (env.SKIP_CACHE !== 'true' && env.SKIP_CACHE_CHECK !== 'true' && !request.headers.has('cache-check-complete')) { 40 | const requestStart = new Date(); 41 | key = await cacheMachine.createKey(env, 'nightbot', { q: query, l: lang, m: gameMode }); 42 | const cachedResponse = await cacheMachine.get(env, {key}); 43 | if (cachedResponse) { 44 | console.log(`Request served from cache: ${new Date() - requestStart} ms`); 45 | request.cached = true; 46 | return new Response(await cachedResponse.json(), { 47 | headers: { 48 | 'X-CACHE': 'HIT', 49 | 'Cache-Control': `public, max-age=${cachedResponse.headers.get('X-Cache-Ttl')}`, 50 | } 51 | }); 52 | } else { 53 | console.log('no cached response'); 54 | } 55 | } else { 56 | //console.log(`Skipping cache in ${ENVIRONMENT} environment`); 57 | } 58 | 59 | if (env.USE_ORIGIN === 'true') { 60 | try { 61 | const originUrl = new URL(request.url); 62 | if (env.ORIGIN_OVERRIDE) { 63 | originUrl.host = env.ORIGIN_OVERRIDE; 64 | } 65 | const response = await fetch(originUrl, { 66 | method: 'GET', 67 | headers: { 68 | 'cache-check-complete': 'true', 69 | }, 70 | signal: AbortSignal.timeout(20000), 71 | }); 72 | if (response.status !== 200) { 73 | throw new Error(`${response.status} ${await response.text()}`); 74 | } 75 | console.log('Request served from origin server'); 76 | return response; 77 | } catch (error) { 78 | console.error(`Error getting response from origin server: ${error}`); 79 | } 80 | } 81 | 82 | const data = new DataSource(env); 83 | const context = graphqlUtil.getDefaultContext(data); 84 | 85 | const info = graphqlUtil.getGenericInfo(lang, gameMode); 86 | let items, ttl; 87 | let responseBody = 'Found no item matching that name'; 88 | try { 89 | items = await data.worker.item.getItemsByName(context, info, query); 90 | ttl = data.getRequestTtl(context.requestId); 91 | 92 | if (items.length > 0) { 93 | const bestPrice = items[0].sellFor.sort((a, b) => b.price - a.price); 94 | const itemName = data.worker.item.getLocale(items[0].name, context, info); 95 | responseBody = `${itemName} ${new Intl.NumberFormat().format(bestPrice[0].price)} ₽ ${capitalize(bestPrice[0].source)} https://tarkov.dev/item/${items[0].normalizedName}`; 96 | } 97 | } catch (error) { 98 | throw (error); 99 | } finally { 100 | data.clearRequestData(context.requestId); 101 | } 102 | 103 | const headers = {}; 104 | if (ttl > 0) { 105 | headers['Cache-Control'] = `public, max-age=${ttl}`; 106 | } 107 | 108 | // Update the cache with the results of the query 109 | if (env.SKIP_CACHE !== 'true' && ttl > 0) { 110 | const putCachePromise = cacheMachine.put(env, responseBody, { key, query: 'nightbot', variables: { q: query, l: lang, m: gameMode }, ttl: String(ttl)}); 111 | // using waitUntil doens't hold up returning a response but keeps the worker alive as long as needed 112 | if (request.ctx?.waitUntil) { 113 | request.ctx.waitUntil(putCachePromise); 114 | } else if (serverContext.waitUntil) { 115 | serverContext.waitUntil(putCachePromise); 116 | } 117 | } 118 | return new Response(responseBody, { 119 | headers, 120 | }); 121 | } 122 | 123 | export default function useNightbot(env) { 124 | return { 125 | async onRequest({ request, url, endResponse, serverContext, fetchAPI }) { 126 | if (!useNightbotOnUrl(url)) { 127 | return; 128 | } 129 | const response = await getNightbotResponse(request, url, env, serverContext); 130 | 131 | endResponse(response); 132 | }, 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /utils/graphql-util.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | const graphqlUtil = { 6 | getDepth: (info) => { 7 | let depth = 0; 8 | let currentLevel = info.path; 9 | while (currentLevel.prev) { 10 | if (!Number.isInteger(currentLevel.key)) depth++; 11 | currentLevel = currentLevel.prev; 12 | } 13 | return depth; 14 | }, 15 | testDepthLimit: (info, depthLimit) => { 16 | const depth = graphqlUtil.getDepth(info); 17 | if (depth > depthLimit) throw new GraphQLError(`Query depth ${depth} exceeds maximum (${depthLimit}) for ${info.parentType}.${info.fieldName}.`); 18 | }, 19 | getDefaultContext: (dataSource, requestId) => { 20 | return { 21 | requestId: requestId ?? uuidv4(), 22 | requestStart: new Date(), 23 | data: dataSource, 24 | util: graphqlUtil, 25 | arguments: {}, 26 | warnings: [], 27 | errors: [], 28 | }; 29 | }, 30 | getRoot: (info) => { 31 | let myRoot = info.path.key; 32 | for (let currentNode = info.path.prev; currentNode; currentNode = currentNode.prev) { 33 | myRoot = currentNode.key; 34 | } 35 | return myRoot; 36 | }, 37 | getArgument: (info, argumentName, defaultValue, context) => { 38 | let argValue = defaultValue; 39 | if (!info) { 40 | return argValue; 41 | } 42 | let argumentFound = false; 43 | const myRoot = graphqlUtil.getRoot(info); 44 | for (const selection of info.operation.selectionSet.selections) { 45 | let selectionRoot = selection.name.value; 46 | if (selection.alias) { 47 | selectionRoot = selection.alias.value; 48 | } 49 | if (selectionRoot !== myRoot) { 50 | continue; 51 | } 52 | if (context && !context.arguments[myRoot]) { 53 | context.arguments[myRoot] = {}; 54 | } 55 | if (context && context.arguments[myRoot][argumentName]) { 56 | return context.arguments[myRoot][argumentName]; 57 | } 58 | for (const arg of selection.arguments) { 59 | if (arg.name.value === argumentName) { 60 | if (arg.value.kind === 'Variable') { 61 | argValue = info.variableValues[arg.value.name.value]; 62 | } else { 63 | argValue = arg.value.value; 64 | } 65 | argumentFound = true; 66 | break; 67 | } 68 | } 69 | if (argumentFound) break; 70 | } 71 | if (context) { 72 | context.arguments[myRoot][argumentName] = argValue; 73 | } 74 | return argValue; 75 | }, 76 | getLang: (info, context) => { 77 | return graphqlUtil.getArgument(info, 'lang', 'en', context); 78 | }, 79 | getGameMode: (info, context) => { 80 | return graphqlUtil.getArgument(info, 'gameMode', 'regular', context); 81 | }, 82 | paginate: async (data, args) => { 83 | data = await data; 84 | if (!Array.isArray(data)) return data; 85 | let limit = args.limit; 86 | let offset = args.offset; 87 | if (!limit && !offset) return data; 88 | if (typeof limit === 'undefined') return data.slice(offset); 89 | if (typeof offset === 'undefined') offset = 0; 90 | let end = Math.abs(limit) + offset; 91 | if (offset < 0) end = data.length - Math.abs(offset) + limit; 92 | return data.slice(offset, end); 93 | }, 94 | getGenericInfo: (lang = 'en', gameMode = 'regular') => { 95 | return { 96 | path: { 97 | key: 'query', 98 | }, 99 | operation: { 100 | selectionSet: { 101 | selections: [ 102 | { 103 | name: { 104 | value: 'query' 105 | }, 106 | arguments: [ 107 | { 108 | name: { 109 | value: 'lang', 110 | }, 111 | value: { 112 | value: lang, 113 | } 114 | } 115 | ] 116 | }, 117 | { 118 | name: { 119 | value: 'query' 120 | }, 121 | arguments: [ 122 | { 123 | name: { 124 | value: 'gameMode', 125 | }, 126 | value: { 127 | value: gameMode, 128 | } 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | } 135 | }; 136 | }, 137 | }; 138 | 139 | export default graphqlUtil; 140 | -------------------------------------------------------------------------------- /http/index.mjs: -------------------------------------------------------------------------------- 1 | import { createServer } from 'node:http'; 2 | import cluster from 'node:cluster'; 3 | import { availableParallelism } from 'node:os'; 4 | 5 | import 'dotenv/config'; 6 | 7 | import getYoga from '../graphql-yoga.mjs'; 8 | import getEnv from './env-binding.mjs'; 9 | import cacheMachine from '../utils/cache-machine.mjs'; 10 | 11 | const port = process.env.PORT ?? 8788; 12 | const workerCount = parseInt(process.env.WORKERS ?? String(Math.max(availableParallelism() - 1, 1))); 13 | 14 | /*process.on('uncaughtException', (error) => { 15 | console.error('Uncaught Exception', error.stack); 16 | });*/ 17 | 18 | if (cluster.isPrimary && workerCount > 0) { 19 | const kvStore = {}; 20 | const kvLoading = {}; 21 | const kvRefreshTimeout = {}; 22 | const cachePending = {}; 23 | const msOneMinute = 1000 * 60; 24 | const msFiveMinutes = msOneMinute * 5; 25 | const msHalfHour = msOneMinute * 30; 26 | const env = getEnv(); 27 | 28 | const getKv = async (kvName, rejectOnError = true) => { 29 | let refreshTime = msHalfHour; 30 | try { 31 | console.log(`getting ${kvName} data`); 32 | clearTimeout(kvRefreshTimeout[kvName]); 33 | const oldExpiration = kvStore[kvName]?.expiration ?? 0; 34 | kvLoading[kvName] = env.DATA_CACHE.get(kvName, 'json'); 35 | const data = await kvLoading[kvName]; 36 | kvStore[kvName] = data; 37 | delete kvLoading[kvName]; 38 | if (data?.expiration && new Date(data.expiration) > new Date()) { 39 | refreshTime = new Date(data.expiration) - new Date(); 40 | if (refreshTime < msOneMinute) { 41 | refreshTime = msOneMinute; 42 | } 43 | } 44 | if (data?.expiration === oldExpiration) { 45 | refreshTime = msOneMinute; 46 | } 47 | return data; 48 | } catch (error) { 49 | delete kvLoading[kvName]; 50 | console.error('Error getting KV from cloudflare', error); 51 | if (error.message !== 'Invalid CLOUDFLARE_TOKEN') { 52 | refreshTime = msOneMinute; 53 | if (typeof kvStore[kvName] === 'undefined') { 54 | refreshTime = 1000; 55 | } 56 | } 57 | if (rejectOnError) { 58 | return Promise.reject(error); 59 | } 60 | } finally { 61 | kvRefreshTimeout[kvName] = setTimeout(() => { 62 | getKv(kvName, false); 63 | }, refreshTime); 64 | } 65 | }; 66 | 67 | cluster.on('message', async (worker, message) => { 68 | //console.log(`message from worker ${id}:`, message); 69 | let response = false; 70 | if (message.action === 'getKv') { 71 | response = { 72 | action: 'kvData', 73 | kvName: message.kvName, 74 | id: message.id, 75 | }; 76 | try { 77 | if (typeof kvStore[message.kvName] !== 'undefined') { 78 | response.data = JSON.stringify(kvStore[message.kvName]); 79 | } else if (kvLoading[message.kvName]) { 80 | response.data = JSON.stringify(await kvLoading[message.kvName]); 81 | } else { 82 | response.data = JSON.stringify(await getKv(message.kvName)); 83 | } 84 | } catch (error) { 85 | response.error = error.message; 86 | } 87 | } 88 | if (message.action === 'cacheResponse') { 89 | response = { 90 | id: message.id, 91 | data: false, 92 | }; 93 | try { 94 | if (cachePending[message.key]) { 95 | response.data = await cachePending[message.key]; 96 | } else { 97 | let cachePutCooldown = message.ttl ? message.ttl * 1000 : msFiveMinutes; 98 | cachePending[message.key] = cacheMachine.put(process.env, message.body, {key: message.key, ttl: message.ttl}).catch(error => { 99 | cachePutCooldown = 10000; 100 | return Promise.reject(error); 101 | }).finally(() => { 102 | setTimeout(() => { 103 | delete cachePending[message.key]; 104 | }, cachePutCooldown); 105 | }); 106 | response.data = await cachePending[message.key]; 107 | } 108 | } catch (error) { 109 | response.error = error.message; 110 | } 111 | 112 | } 113 | if (response) { 114 | if (worker.isConnected() && !worker.isDead()) { 115 | try { 116 | worker.send(response); 117 | } catch (error) { 118 | console.error(`Error sending worker ${message.action} message response`, error); 119 | } 120 | } 121 | } 122 | }); 123 | 124 | cluster.on('exit', function (worker, code, signal) { 125 | if (!signal) { 126 | console.log('worker ' + worker.process.pid + ' died'); 127 | cluster.fork(); 128 | } 129 | }); 130 | 131 | console.log(`Starting ${workerCount} workers`); 132 | for (let i = 0; i < workerCount; i++) { 133 | cluster.fork(); 134 | } 135 | } else { 136 | // Workers can share any TCP connection 137 | const yoga = await getYoga(getEnv()); 138 | 139 | const server = createServer(yoga); 140 | 141 | // Start the server and you're done! 142 | server.listen(port, () => { 143 | console.info(`Server is running on http://localhost:${port}`); 144 | }); 145 | } 146 | -------------------------------------------------------------------------------- /datasources/tasks.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class TasksAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('quest_data', dataSource); 8 | this.gameModes.push('pve'); 9 | } 10 | 11 | async getList(context, info) { 12 | const { cache } = await this.getCache(context, info); 13 | return cache.Task; 14 | } 15 | 16 | async get(context, info, id) { 17 | const { cache } = await this.getCache(context, info); 18 | const task = cache.Task.find(t => t.id === id || t.tarkovDataId === id); 19 | if (!task) { 20 | return Promise.reject(new GraphQLError(`No task found with id ${id}`)); 21 | } 22 | return task; 23 | } 24 | 25 | async getTasksRequiringItem(context, info, itemId) { 26 | const { cache } = await this.getCache(context, info); 27 | const tasks = cache.Task.filter(rawTask => { 28 | for (const obj of rawTask.objectives) { 29 | if (obj.item === itemId) { 30 | return true; 31 | } 32 | if (obj.markerItem === itemId) { 33 | return true; 34 | } 35 | if (obj.containsOne) { 36 | for (const item of obj.containsOne) { 37 | if (item.id === itemId) { 38 | return true; 39 | } 40 | } 41 | } 42 | if (obj.containsAll) { 43 | for (const item of obj.containsAll) { 44 | if (item.id === itemId) { 45 | return true; 46 | } 47 | } 48 | } 49 | if (obj.wearing) { 50 | for (const outfit of obj.wearing) { 51 | for (const item of outfit) { 52 | if (item.id === itemId) { 53 | return true; 54 | } 55 | } 56 | } 57 | } 58 | if (obj.usingWeapon) { 59 | for (const item of obj.usingWeapon) { 60 | if (item.id === itemId) { 61 | return true; 62 | } 63 | } 64 | } 65 | if (obj.usingWeaponMods) { 66 | for (const group of obj.usingWeaponMods) { 67 | for (const item of group) { 68 | if (item.id === itemId) { 69 | return true; 70 | } 71 | } 72 | } 73 | } 74 | } 75 | return false; 76 | }); 77 | return tasks; 78 | } 79 | 80 | async getTasksProvidingItem(context, info, itemId) { 81 | const { cache } = await this.getCache(context, info); 82 | const tasks = cache.Task.filter(rawTask => { 83 | for (const reward of rawTask.startRewards.items) { 84 | if (reward.item === itemId) { 85 | return true 86 | } 87 | for (const inner of reward.contains) { 88 | if (inner.item === itemId) { 89 | return true; 90 | } 91 | } 92 | } 93 | for (const reward of rawTask.finishRewards.items) { 94 | if (reward.item === itemId) { 95 | return true; 96 | } 97 | for (const inner of reward.contains) { 98 | if (inner.item === itemId) { 99 | return true; 100 | } 101 | } 102 | } 103 | return false; 104 | }); 105 | return tasks; 106 | } 107 | 108 | async getQuests(context, info) { 109 | const { cache } = await this.getCache(context, info); 110 | return cache.Quest; 111 | } 112 | 113 | async getQuest(context, info, id) { 114 | const { cache } = await this.getCache(context, info); 115 | for (const quest of cache.Quest) { 116 | if (quest.id === id) { 117 | return quest; 118 | } 119 | } 120 | return Promise.reject(new GraphQLError(`No quest with id ${id} found`)); 121 | } 122 | 123 | async getQuestItems(context, info) { 124 | const { cache } = await this.getCache(context, info); 125 | return Object.values(cache.QuestItem); 126 | } 127 | 128 | async getQuestItem(context, info, id) { 129 | const { cache } = await this.getCache(context, info); 130 | return cache.QuestItem[id]; 131 | } 132 | 133 | async getAchievements(context, info) { 134 | const { cache } = await this.getCache(context, info); 135 | return cache.Achievement; 136 | } 137 | 138 | async getAchievement(context, info, id) { 139 | const achievements = await this.getAchievements(context, info); 140 | for (const achievement of achievements) { 141 | if (achievement.id === id) { 142 | return achievement; 143 | } 144 | } 145 | return Promise.reject(new GraphQLError(`No achievement with id ${id} found`)); 146 | } 147 | 148 | async getPrestiges(context, info) { 149 | const { cache } = await this.getCache(context, info); 150 | return cache.Prestige; 151 | } 152 | 153 | async getPrestige(context, info, id) { 154 | const prestiges = await this.getPrestiges(context, info); 155 | for (const prestige of prestiges) { 156 | if (prestige.id === id) { 157 | return prestige; 158 | } 159 | } 160 | return Promise.reject(new GraphQLError(`No prestiges with id ${id} found`)); 161 | } 162 | } 163 | 164 | export default TasksAPI; 165 | -------------------------------------------------------------------------------- /utils/cache-machine.mjs: -------------------------------------------------------------------------------- 1 | // cache url 2 | const cacheUrl = 'https://cache.tarkov.dev' 3 | 4 | let cacheFailCount = 0; 5 | let cachePaused = false; 6 | 7 | function cacheIsPaused() { 8 | if (!cachePaused) { 9 | return false; 10 | } 11 | const coolDownExpired = new Date() - cachePaused > 60000; 12 | if (coolDownExpired) { 13 | cachePaused = false; 14 | return false; 15 | } 16 | return true; 17 | } 18 | 19 | function cacheRequestFail() { 20 | cacheFailCount++; 21 | if (cacheFailCount <= 4 || cacheIsPaused()) { 22 | return; 23 | } 24 | cachePaused = new Date(); 25 | } 26 | 27 | // Helper function to create a hash from a string 28 | // :param string: string to hash 29 | // :return: SHA-256 hash of string 30 | async function hash(string) { 31 | const utf8 = new TextEncoder().encode(string); 32 | const hashBuffer = await crypto.subtle.digest('SHA-256', utf8); 33 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 34 | const hashHex = hashArray 35 | .map((bytes) => bytes.toString(16).padStart(2, '0')) 36 | .join(''); 37 | 38 | return hashHex; 39 | } 40 | 41 | const cacheMachine = { 42 | createKey: (env, query, variables = {}, specialCache = '') => { 43 | if (typeof variables !== 'string') { 44 | variables = JSON.stringify(variables); 45 | } 46 | if (typeof query !== 'string') { 47 | query = JSON.stringify(query); 48 | } 49 | query = query.trim(); 50 | return hash(env.ENVIRONMENT + query + variables + specialCache); 51 | }, 52 | // Checks the caching service to see if a request has been cached 53 | // :param json: the json payload of the incoming worker request 54 | // :return: json results of the item found in the cache or false if not found 55 | get: async (env, options = {}) => { 56 | try { 57 | if (!env.CACHE_BASIC_AUTH) { 58 | console.warn('env.CACHE_BASIC_AUTH is not set; skipping cache check'); 59 | return false; 60 | } 61 | if (cacheIsPaused()) { 62 | console.warn('Cache paused; skipping cache check'); 63 | return false; 64 | } 65 | let query = options.query ?? ''; 66 | query = query.trim(); 67 | let { key, variables = {}, specialCache = '' } = options; 68 | key = key ?? await cacheMachine.createKey(env, query, variables, specialCache); 69 | //console.log('getting cache ', key, typeof query, query); 70 | if (!key) { 71 | console.warn('Skipping cache check; key is empty'); 72 | return false; 73 | } 74 | 75 | const response = await fetch(`${cacheUrl}/api/cache?key=${key}`, { 76 | headers: { 77 | 'Authorization': `Basic ${env.CACHE_BASIC_AUTH}` 78 | }, 79 | signal: AbortSignal.timeout(5000), 80 | }); 81 | cacheFailCount = 0; 82 | if (response.status === 200) { 83 | return response; 84 | } else if (response.status !== 404) { 85 | console.error(`failed to read from cache: ${response.status}`); 86 | } 87 | response.body.cancel(); 88 | 89 | return false 90 | } catch (error) { 91 | if (error.message === 'The operation was aborted due to timeout') { 92 | console.warn('Checking cache timed out'); 93 | cacheRequestFail(); 94 | return false; 95 | } 96 | console.error('checkCache error: ' + error.message); 97 | return false; 98 | } 99 | }, 100 | // Updates the cache with the results of a query 101 | // :param json: the incoming request in json 102 | // :param body: the body to cache 103 | // :return: true if successful, false if not 104 | put: async (env, body, options = {}) => { 105 | try { 106 | if (!env.CACHE_BASIC_AUTH) { 107 | console.warn('env.CACHE_BASIC_AUTH is not set; skipping cache put'); 108 | return false; 109 | } 110 | if (cacheIsPaused()) { 111 | console.warn('Cache paused; skipping cache update'); 112 | return false; 113 | } 114 | if (!options.key && !options.query) { 115 | console.warn('Key or query not provided, skipping cache put'); 116 | return false; 117 | } 118 | let { key, query, variables, ttl = 60 * 5, specialCache = '' } = options; 119 | if (!key) { 120 | query = query.trim(); 121 | key = await cacheMachine.createKey(env, query, variables, specialCache); 122 | } 123 | ttl = String(ttl); 124 | console.log(`Caching ${body.length} byte response for ${env.ENVIRONMENT} environment${ttl ? ` for ${ttl} seconds` : ''}`); 125 | 126 | // Update the cache 127 | const response = await fetch(`${cacheUrl}/api/cache`, { 128 | body: JSON.stringify({ key, value: body, ttl }), 129 | method: 'POST', 130 | headers: { 131 | 'content-type': 'application/json;charset=UTF-8', 132 | 'Authorization': `Basic ${env.CACHE_BASIC_AUTH}` 133 | }, 134 | signal: AbortSignal.timeout(20000), 135 | }); 136 | console.log('Response cached'); 137 | response.body.cancel(); 138 | 139 | // Log non-200 responses 140 | if (response.status !== 200) { 141 | console.error(`failed to write to cache: ${response.status}`); 142 | return false 143 | } 144 | cacheFailCount = 0; 145 | return true 146 | } catch (error) { 147 | if (error.message === 'The operation was aborted due to timeout') { 148 | console.warn('Updating cache timed out'); 149 | cacheRequestFail(); 150 | return false; 151 | } 152 | console.error('updateCache error: ' + error.message); 153 | return false; 154 | } 155 | }, 156 | }; 157 | 158 | export default cacheMachine; 159 | -------------------------------------------------------------------------------- /plugins/plugin-use-cache-machine.mjs: -------------------------------------------------------------------------------- 1 | import cacheMachine from '../utils/cache-machine.mjs'; 2 | import setCors from '../utils/setCors.mjs'; 3 | 4 | export function getSpecialCache(request) { 5 | const contentType = request.headers.get('content-type'); 6 | if (request.method === 'POST' && !contentType?.startsWith('application/json')) { 7 | //return 'application/json'; // don't enforce content type 8 | } 9 | return undefined; 10 | } 11 | 12 | export default function useCacheMachine(env) { 13 | return { 14 | async onParams({params, request, setParams, setResult, fetchAPI}) { 15 | console.log(request.requestId); 16 | request.params = params; 17 | if (env.SKIP_CACHE === 'true' || env.SKIP_CACHE_CHECK === 'true') { 18 | console.log(`Skipping cache check due to SKIP_CACHE or SKIP_CACHE_CHECK`); 19 | return; 20 | } 21 | if (request.headers.has('cache-check-complete')) { 22 | console.log(`Skipping cache check already performed by worker`); 23 | return; 24 | } 25 | const cachedResponse = await cacheMachine.get(env, {query: params.query, variables: params.variables, specialCache: getSpecialCache(request)}); 26 | if (cachedResponse) { 27 | console.log('Request served from cache'); 28 | request.cached = true; 29 | request.resultTtl = cachedResponse.headers.get('X-Cache-Ttl'); 30 | setResult(JSON.parse(await cachedResponse.json())); 31 | } 32 | }, 33 | onValidate({ context, extendContext, params, validateFn, addValidationRule, setValidationFn, setResult }) { 34 | return ({ valid, result, context, extendContext, setResult }) => { 35 | // collect stats on if query was valid 36 | if (valid) { 37 | return; 38 | } 39 | // result is an array of errors we can log 40 | }; 41 | }, 42 | onContextBuilding({context, extendContext, breakContextBuilding}) { 43 | context.request.ctx = context.ctx ?? context.request.ctx; 44 | if (typeof context.waitUntil === 'function') { 45 | context.request.ctx.waitUntil = context.waitUntil; 46 | } 47 | context.request.data = context.data; 48 | context.request.warnings = context.warnings; 49 | context.request.errors = context.errors; 50 | context.request.params = context.params; 51 | console.log(`KVs pre-loaded: ${context.data.kvLoaded.join(', ') || 'none'}`); 52 | extendContext({requestId: context.request.requestId}); 53 | }, 54 | onExecute({ executeFn, setExecuteFn, setResultAndStopExecution, extendContext, args }) { 55 | const executeStart = new Date(); 56 | //extendContext({executeStart: new Date()}); 57 | return { 58 | onExecuteDone: ({ args, result, setResult }) => { 59 | console.log(args.contextValue.requestId, `Executaion time: ${new Date() - executeStart} ms`); 60 | // can check for errors at result.errors 61 | }, 62 | }; 63 | }, 64 | onResultProcess({request, acceptableMediaTypes, result, setResult, resultProcessor, setResultProcessor}) { 65 | if (request.cached) { 66 | return; 67 | } 68 | if (!result.data && !result.errors) { 69 | return; 70 | } 71 | if (request.errors?.length > 0) { 72 | if (!result.errors) { 73 | result = Object.assign({errors: []}, result); // this puts the errors at the start of the result 74 | } 75 | result.errors.push(...request.errors); 76 | } 77 | if (request.warnings?.length > 0) { 78 | if (!result.warnings) { 79 | result = Object.assign({warnings: []}, result); 80 | } 81 | result.warnings.push(...request.warnings); 82 | } 83 | 84 | let ttl = request.data?.getRequestTtl(request.requestId) ?? 60 * 5; 85 | 86 | const sCache = getSpecialCache(request); 87 | if (result.errors?.some(err => err.message === 'Unexpected error.')) { 88 | ttl = 0; 89 | } else if (result.errors?.some(err => err.message.startsWith('Syntax Error'))) { 90 | ttl = 1800; 91 | } else if (sCache === 'application/json') { 92 | if (!result.warnings) { 93 | result = Object.assign({warnings: []}, result); 94 | } 95 | ttl = 30 * 60; 96 | result.warnings.push({message: `Your request does not have a "content-type" header set to "application/json". Requests missing this header are limited to resposnes that update every ${ttl/60} minutes.`}); 97 | } else if (ttl > 1800) { 98 | // if the ttl is greater than a half hour, limit it 99 | ttl = 1800; 100 | } 101 | if (env.SKIP_CACHE !== 'true' && ttl > 0 && env.USE_ORIGIN !== 'true') { 102 | request.resultTtl = String(ttl); 103 | // using waitUntil doesn't hold up returning a response but keeps the worker alive as long as needed 104 | const cacheBody = JSON.stringify(result); 105 | if (cacheBody.length > 0) { 106 | const cachePut = env.RESPONSE_CACHE.put(env, cacheBody, {query: request.params.query, variables: request.params.variables, ttl, specialCache: sCache}); 107 | request.ctx.waitUntil(cachePut); 108 | } else { 109 | console.warn('Skipping cache for zero-length response'); 110 | console.log(`Request method: ${request.method}`); 111 | console.log(`Query: ${request.params.query}`); 112 | console.log(`Variables: ${JSON.stringify(request.params.variables ?? {}, null, 4)}`); 113 | } 114 | } 115 | console.log(`kvs used in request: ${request.data?.requests[request.requestId]?.kvUsed.join(', ') ?? 'none'}`); 116 | request.data?.clearRequestData(request.requestId); 117 | delete request.requestId; 118 | setResult(result); 119 | console.log('generated graphql response'); 120 | }, 121 | onResponse({request, response, serverContext, setResponse, fetchAPI}) { 122 | if (request.data && request.requestId) { 123 | request.data.clearRequestData(request.requestId); 124 | } 125 | if (request.resultTtl) { 126 | response.headers.set('X-Cache-Ttl', request.resultTtl); 127 | response.headers.set('Cache-Control', `public, max-age=${request.resultTtl}`); 128 | } 129 | setCors(response); 130 | }, 131 | } 132 | } -------------------------------------------------------------------------------- /utils/worker-kv.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | class WorkerKV { 4 | constructor(kvName, dataSource) { 5 | this.cache = {}; 6 | this.loading = {}; 7 | this.kvName = kvName; 8 | this.loadingPromises = {}; 9 | this.loadingInterval = false; 10 | this.dataExpires = {}; 11 | this.lastRefresh = {}; 12 | this.refreshCooldown = 1000 * 60; 13 | this.dataSource = dataSource; 14 | this.gameModes = ['regular']; 15 | } 16 | 17 | async getCache(context, info, forceRegular) { 18 | const requestId = typeof context === 'object' ? context.requestId : context; 19 | const gameMode = this.getGameMode(context, info); 20 | let requestKv = this.kvName; 21 | if (gameMode !== 'regular' && !forceRegular) { 22 | requestKv += `_${gameMode}`; 23 | } 24 | let dataNeedsRefresh = false; 25 | if (this.dataExpires[gameMode]) { 26 | const stale = new Date() > this.dataExpires[gameMode]; 27 | const dataAge = new Date() - (this.lastRefresh[gameMode] ?? 0); 28 | dataNeedsRefresh = stale && dataAge > this.refreshCooldown; 29 | } 30 | if (this.cache[gameMode] && !dataNeedsRefresh) { 31 | //console.log(`${requestKv} is fresh; not refreshing`); 32 | this.dataSource.setKvUsedForRequest(requestKv, requestId); 33 | return {cache: this.cache[gameMode], gameMode}; 34 | } 35 | if (!this.loadingPromises[gameMode]) { 36 | this.loadingPromises[gameMode] = {}; 37 | } 38 | if (this.loading[gameMode]) { 39 | if (this.loadingPromises[gameMode][requestId]) { 40 | return this.loadingPromises[gameMode][requestId]; 41 | } 42 | //console.log(`${requestKv} already loading; awaiting load`); 43 | this.loadingPromises[gameMode][requestId] = new Promise((resolve) => { 44 | const startLoad = new Date(); 45 | let loadingTimedOut = false; 46 | const loadingTimeout = setTimeout(() => { 47 | loadingTimedOut = true; 48 | }, 3000); 49 | const loadingInterval = setInterval(() => { 50 | if (this.loading[gameMode] === false) { 51 | clearTimeout(loadingTimeout); 52 | clearInterval(loadingInterval); 53 | console.log(`${requestKv} load: ${new Date() - startLoad} ms (secondary)`); 54 | delete this.loadingPromises[gameMode][requestId]; 55 | this.dataSource.setKvUsedForRequest(requestKv, requestId); 56 | return resolve({cache: this.cache[gameMode], gameMode}); 57 | } 58 | if (loadingTimedOut) { 59 | console.log(`${requestKv} loading timed out; forcing load`); 60 | clearInterval(loadingInterval); 61 | this.loading[gameMode] = false; 62 | delete this.loadingPromises[gameMode][requestId]; 63 | return resolve(this.getCache(context, info)); 64 | } 65 | }, 100); 66 | }); 67 | return this.loadingPromises[gameMode][requestId]; 68 | } 69 | if (this.cache[gameMode]) { 70 | console.log(`${requestKv} is stale; re-loading`); 71 | } else { 72 | //console.log(`${requestKv} loading`); 73 | } 74 | this.loading[gameMode] = true; 75 | const startLoad = new Date(); 76 | this.loadingPromises[gameMode][requestId] = this.dataSource.env.DATA_CACHE.getWithMetadata(requestKv, 'text').then(response => { 77 | console.log(`${requestKv} load: ${new Date() - startLoad} ms`); 78 | const metadata = response.metadata; 79 | let responseValue = response.value; 80 | if (metadata && metadata.compression) { 81 | return Promise.reject(new GraphQLError(`${metadata.compression} compression is not supported`)); 82 | } 83 | const parsedValue = JSON.parse(responseValue); 84 | if (!parsedValue && requestKv !== this.kvName) { 85 | console.warn(`${requestKv} data not found; falling back to ${this.kvName}`); 86 | this.loading[gameMode] = false; 87 | delete this.loadingPromises[gameMode][requestId]; 88 | return this.getCache(context, info, true); 89 | } 90 | this.cache[gameMode] = parsedValue; 91 | let newDataExpires = false; 92 | if (this.cache[gameMode]?.expiration) { 93 | newDataExpires = new Date(this.cache[gameMode].expiration).valueOf(); 94 | } 95 | if (newDataExpires && this.dataExpires === newDataExpires) { 96 | console.log(`${requestKv} is still stale after re-load`); 97 | } 98 | this.lastRefresh[gameMode] = new Date(); 99 | this.dataExpires[gameMode] = newDataExpires; 100 | this.dataSource.setKvLoadedForRequest(requestKv, requestId); 101 | this.loading[gameMode] = false; 102 | delete this.loadingPromises[gameMode][requestId]; 103 | this.postLoad({cache: this.cache[gameMode], gameMode}); 104 | return {cache: this.cache[gameMode], gameMode}; 105 | }).catch(error => { 106 | this.loading[gameMode] = false; 107 | return Promise.reject(error); 108 | }); 109 | return this.loadingPromises[gameMode][requestId]; 110 | } 111 | 112 | getGameMode(context, info) { 113 | let gameMode = context.util.getGameMode(info, context); 114 | if (!this.gameModes.includes(gameMode)) { 115 | gameMode = this.gameModes[0]; 116 | } 117 | return gameMode; 118 | } 119 | 120 | getLocale(key, context, info) { 121 | if (!key) { 122 | return null; 123 | } 124 | const lang = context.util.getLang(info, context); 125 | const gameMode = this.getGameMode(context, info); 126 | const cache = this.cache[gameMode]; 127 | const getTranslation = (k) => { 128 | if (cache?.locale[lang] && typeof cache.locale[lang][k] !== 'undefined') { 129 | return cache.locale[lang][k]; 130 | } 131 | if (cache?.locale.en && typeof cache.locale.en[k] !== 'undefined') { 132 | return cache.locale.en[k]; 133 | } 134 | const errorMessage = `Missing translation for key ${k}`; 135 | if (!context.errors.some(err => err.message === errorMessage)) { 136 | context.errors.push({message: errorMessage}); 137 | } 138 | return k; 139 | }; 140 | if (Array.isArray(key)) { 141 | return key.map(k => getTranslation(k)).filter(Boolean); 142 | } 143 | return getTranslation(key); 144 | } 145 | 146 | postLoad() { /* some KVs may require initial processing after retrieval */ } 147 | } 148 | 149 | export default WorkerKV; 150 | -------------------------------------------------------------------------------- /plugins/plugin-lite-api.mjs: -------------------------------------------------------------------------------- 1 | import DataSource from '../datasources/index.mjs'; 2 | import graphqlUtil from '../utils/graphql-util.mjs'; 3 | import cacheMachine from '../utils/cache-machine.mjs'; 4 | 5 | export const liteApiPathRegex = /\/api\/v1(?\/\w+)?\/(?item[\w\/]*)/; 6 | 7 | export function useLiteApiOnUrl(url) { 8 | return !!url.pathname.match(liteApiPathRegex); 9 | }; 10 | 11 | const currencyMap = { 12 | RUB: '₽', 13 | USD: '$', 14 | EUR: '€', 15 | }; 16 | 17 | export async function getLiteApiResponse(request, url, env, serverContext) { 18 | let q, lang, uid, tags, sort, sort_direction; 19 | if (request.method.toUpperCase() === 'GET') { 20 | q = url.searchParams.get('q'); 21 | lang = url.searchParams.get('lang') ?? 'en'; 22 | uid = url.searchParams.get('uid'); 23 | tags = url.searchParams.get('tags')?.split(','); 24 | sort = url.searchParams.get('sort'); 25 | sort_direction = url.searchParams.get('sort_direction'); 26 | } else if (request.method.toUpperCase() === 'POST') { 27 | const body = await request.json(); 28 | q = body.q; 29 | lang = body.lang ?? 'en'; 30 | uid = body.uid; 31 | tags = body.tags?.split(','); 32 | sort = body.sort; 33 | sort_direction = body.sort_direction; 34 | } else { 35 | return new Response(null, { 36 | status: 405, 37 | headers: { 'cache-control': 'public, max-age=2592000' }, 38 | }); 39 | } 40 | 41 | const pathInfo = url.pathname.match(liteApiPathRegex); 42 | 43 | const gameMode = pathInfo.groups.gameMode || 'regular'; 44 | 45 | const endpoint = pathInfo.groups.endpoint; 46 | 47 | let key; 48 | if (env.SKIP_CACHE !== 'true' && env.SKIP_CACHE_CHECK !== 'true' && !request.headers.has('cache-check-complete')) { 49 | const requestStart = new Date(); 50 | key = await cacheMachine.createKey(env, url.pathname, { q, lang, gameMode, uid, tags, sort, sort_direction }); 51 | const cachedResponse = await cacheMachine.get(env, {key}); 52 | if (cachedResponse) { 53 | console.log(`Request served from cache: ${new Date() - requestStart} ms`); 54 | request.cached = true; 55 | // Construct a new response with the cached data 56 | 57 | return new Response(await cachedResponse.json(), { 58 | headers: { 59 | 'X-CACHE': 'HIT', 60 | 'Cache-Control': `public, max-age=${cachedResponse.headers.get('X-Cache-Ttl')}`, 61 | } 62 | }); 63 | } else { 64 | console.log('no cached response'); 65 | } 66 | } else { 67 | //console.log(`Skipping cache in ${ENVIRONMENT} environment`); 68 | } 69 | 70 | if (env.USE_ORIGIN === 'true') { 71 | try { 72 | const originUrl = new URL(request.url); 73 | if (env.ORIGIN_OVERRIDE) { 74 | originUrl.host = env.ORIGIN_OVERRIDE; 75 | } 76 | const response = await fetch(originUrl, { 77 | method: 'POST', 78 | body: JSON.stringify({ 79 | q, 80 | lang, 81 | uid, 82 | tags: tags?.join(','), 83 | sort, 84 | sort_direction, 85 | }), 86 | headers: { 87 | 'cache-check-complete': 'true', 88 | }, 89 | signal: AbortSignal.timeout(20000), 90 | }); 91 | if (response.status !== 200) { 92 | throw new Error(`${response.status} ${await response.text()}`); 93 | } 94 | console.log('Request served from origin server'); 95 | return response; 96 | } catch (error) { 97 | console.error(`Error getting response from origin server: ${error}`); 98 | } 99 | } 100 | 101 | const data = new DataSource(env); 102 | const context = graphqlUtil.getDefaultContext(data); 103 | 104 | const info = graphqlUtil.getGenericInfo(lang, gameMode); 105 | 106 | function toLiteApiItem(item) { 107 | const bestTraderSell = item.traderPrices.reduce((best, current) => { 108 | if (!best || current.priceRUB > best.priceRUB) { 109 | return current; 110 | } 111 | return best; 112 | }, undefined); 113 | return { 114 | uid: item.id, 115 | name: data.worker.item.getLocale(item.name, context, info), 116 | tags: item.types, 117 | shortName: data.worker.item.getLocale(item.shortName, context, info), 118 | price: item.lastLowPrice, 119 | basePrice: item.basePrice, 120 | avg24hPrice: item.avg24hPrice, 121 | //avg7daysPrice: null, 122 | traderName: bestTraderSell ? bestTraderSell.name : null, 123 | traderPrice: bestTraderSell ? bestTraderSell.price : null, 124 | traderPriceCur: bestTraderSell ? currencyMap[bestTraderSell.currency] : null, 125 | updated: item.updated, 126 | slots: item.width * item.height, 127 | diff24h: item.changeLast48h, 128 | //diff7days: null, 129 | icon: item.iconLink, 130 | link: item.link, 131 | wikiLink: item.wikiLink, 132 | img: item.gridImageLink, 133 | imgBig: item.inspectImageLink, 134 | img512: item.image512pxLink, 135 | image8x: item.image8xLink, 136 | bsgId: item.id, 137 | isFunctional: true, // !item.types.includes('gun'), 138 | reference: 'https://tarkov.dev', 139 | }; 140 | } 141 | 142 | let items, ttl; 143 | const responseOptions = { 144 | headers: { 145 | 'Content-Type': 'application/json', 146 | }, 147 | }; 148 | try { 149 | if (endpoint.startsWith('items')) { 150 | items = await data.worker.item.getAllItems(context, info); 151 | if (endpoint.endsWith('/download')) { 152 | responseOptions.headers['Content-Disposition'] = 'attachment; filename="items.json"'; 153 | } 154 | if (tags) { 155 | items = await data.worker.item.getItemsByTypes(context, info, tags, items); 156 | } 157 | } 158 | if (!items && endpoint.startsWith('item')) { 159 | if (!q && !uid) { 160 | throw new Error('The item request requires either a q or uid parameter'); 161 | } 162 | if (q) { 163 | items = await data.worker.item.getItemsByName(context, info, q); 164 | } else if (uid) { 165 | items = [await data.worker.item.getItem(context, info, uid)]; 166 | } 167 | } 168 | items = items.map(toLiteApiItem); 169 | ttl = data.getRequestTtl(context.requestId); 170 | } catch (error) { 171 | return new Response(error.message, {status: 400}); 172 | } finally { 173 | data.clearRequestData(context.requestId); 174 | } 175 | if (sort && items?.length) { 176 | items.sort((a, b) => { 177 | let aValue = sort_direction === 'desc' ? b[sort] : a[sort]; 178 | let bValue = sort_direction === 'desc' ? a[sort] : b[sort]; 179 | if (sort === 'updated') { 180 | aValue = new Date(aValue); 181 | bValue = new Date(bValue); 182 | } 183 | if (typeof aValue === 'string') { 184 | return aValue.localeCompare(bValue, lang); 185 | } 186 | return aValue - bValue; 187 | }); 188 | } 189 | const responseBody = JSON.stringify(items ?? [], null, 4); 190 | 191 | if (ttl > 0) { 192 | responseOptions.headers['Cache-Control'] = `public, max-age=${ttl}`; 193 | } 194 | 195 | // Update the cache with the results of the query 196 | if (env.SKIP_CACHE !== 'true' && ttl > 0) { 197 | const putCachePromise = cacheMachine.put(env, responseBody, { key, query: url.pathname, variables: { q, lang, gameMode, uid, tags, sort, sort_direction }, ttl: String(ttl)}); 198 | // using waitUntil doens't hold up returning a response but keeps the worker alive as long as needed 199 | if (request.ctx?.waitUntil) { 200 | request.ctx.waitUntil(putCachePromise); 201 | } else if (serverContext.waitUntil) { 202 | serverContext.waitUntil(putCachePromise); 203 | } 204 | } 205 | 206 | return new Response(responseBody, responseOptions); 207 | } 208 | 209 | export default function useLiteApi(env) { 210 | return { 211 | async onRequest({ request, url, endResponse, serverContext, fetchAPI }) { 212 | if (!useLiteApiOnUrl(url)) { 213 | return; 214 | } 215 | const response = await getLiteApiResponse(request, url, env, serverContext); 216 | 217 | endResponse(response); 218 | }, 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /datasources/items.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | import WorkerKV from '../utils/worker-kv.mjs'; 4 | 5 | class ItemsAPI extends WorkerKV { 6 | constructor(dataSource) { 7 | super('item_data', dataSource); 8 | this.gameModes.push('pve'); 9 | } 10 | 11 | postLoad({ cache }) { 12 | for (const item of Object.values(cache.Item)) { 13 | // add trader prices to sellFor 14 | item.sellFor = item.traderPrices.map((traderPrice) => { 15 | return { 16 | price: traderPrice.price, 17 | currency: traderPrice.currency, 18 | currencyItem: traderPrice.currencyItem, 19 | priceRUB: traderPrice.priceRUB, 20 | vendor: { 21 | trader: traderPrice.trader, 22 | trader_id: traderPrice.trader, 23 | traderLevel: 1, 24 | minTraderLevel: 1, 25 | taskUnlock: null 26 | }, 27 | source: traderPrice.source, 28 | requirements: [], 29 | }; 30 | }); 31 | 32 | item.buyFor = []; 33 | // add flea prices to sellFor and buyFor 34 | if (!item.types.includes('noFlea') && item.lastLowPrice) { 35 | item.sellFor.push({ 36 | price: item.lastLowPrice || 0, 37 | currency: 'RUB', 38 | currencyItem: '5449016a4bdc2d6f028b456f', 39 | priceRUB: item.lastLowPrice || 0, 40 | vendor: cache.FleaMarket, 41 | source: 'fleaMarket', 42 | requirements: [{ 43 | type: 'playerLevel', 44 | value: cache.FleaMarket.minPlayerLevel, 45 | }], 46 | }); 47 | 48 | item.buyFor.push({ 49 | price: item.avg24hPrice || item.lastLowPrice || 0, 50 | currency: 'RUB', 51 | currencyItem: '5449016a4bdc2d6f028b456f', 52 | priceRUB: item.avg24hPrice || item.lastLowPrice || 0, 53 | vendor: cache.FleaMarket, 54 | source: 'fleaMarket', 55 | requirements: [{ 56 | type: 'playerLevel', 57 | value: cache.FleaMarket.minPlayerLevel, 58 | }], 59 | }); 60 | } 61 | } 62 | } 63 | 64 | async getItem(context, info, id, contains) { 65 | const { cache } = await this.getCache(context, info); 66 | let item = cache.Item[id]; 67 | if (!item) { 68 | return Promise.reject(new GraphQLError(`No item found with id ${id}`)); 69 | } 70 | 71 | if (contains && Array.isArray(contains)) { 72 | item.containsItems = contains.map((cItem) => { 73 | if (!cItem.attributes) cItem.attributes = []; 74 | if (!cItem.count) cItem.count = 1; 75 | return cItem; 76 | }); 77 | } 78 | return item; 79 | } 80 | 81 | async getAllItems(context, info) { 82 | const { cache } = await this.getCache(context, info); 83 | return Object.values(cache.Item); 84 | } 85 | 86 | async getItemsByIDs(context, info, ids, items = false) { 87 | const { cache } = await this.getCache(context, info); 88 | if (!items) { 89 | items = Object.values(cache.Item); 90 | } 91 | return items.filter((item) => ids.includes(item.id)); 92 | } 93 | 94 | async getItemsByType(context, info, type, items = false) { 95 | const { cache } = await this.getCache(context, info); 96 | if (!items) { 97 | items = Object.values(cache.Item); 98 | } 99 | return items.filter((item) => item.types.includes(type) || type === 'any'); 100 | } 101 | 102 | async getItemsByTypes(context, info, types, items = false) { 103 | const { cache } = await this.getCache(context, info); 104 | if (!items) { 105 | items = Object.values(cache.Item); 106 | } 107 | if (types.includes('any')) { 108 | return items; 109 | } 110 | return items.filter((item) => types.some(type => item.types.includes(type))); 111 | } 112 | 113 | async getItemsByName(context, info, name, items = false) { 114 | const { cache } = await this.getCache(context, info); 115 | if (!items) { 116 | items = Object.values(cache.Item); 117 | } 118 | const searchString = name.toLowerCase(); 119 | if (searchString === '') return Promise.reject(new GraphQLError('Searched item name cannot be blank')); 120 | 121 | return items.filter((item) => { 122 | if (this.getLocale(item.name, context, info).toString().toLowerCase().includes(searchString)) { 123 | return true; 124 | } 125 | if (this.getLocale(item.shortName, context, info).toString().toLowerCase().includes(searchString)) { 126 | return true; 127 | } 128 | return false; 129 | }); 130 | } 131 | 132 | async getItemsByNames(context, info, names, items = false) { 133 | const { cache } = await this.getCache(context, info); 134 | if (!items) { 135 | items = Object.values(cache.Item); 136 | } 137 | const searchStrings = names.map(name => { 138 | if (name === '') throw new GraphQLError('Searched item name cannot be blank'); 139 | return name.toLowerCase(); 140 | }); 141 | return items.filter((item) => { 142 | for (const search of searchStrings) { 143 | if (this.getLocale(item.name, context, info).toString().toLowerCase().includes(search)) { 144 | return true; 145 | } 146 | if (this.getLocale(item.shortName, context, info).toString().toLowerCase().includes(search)) { 147 | return true; 148 | } 149 | } 150 | return false; 151 | }); 152 | } 153 | 154 | async getItemsByBsgCategoryId(context, info, bsgCategoryId, items = false) { 155 | const { cache } = await this.getCache(context, info); 156 | if (!items) { 157 | items = Object.values(cache.Item); 158 | } 159 | return items.filter((item) => item.bsgCategoryId === bsgCategoryId); 160 | } 161 | 162 | async getItemsByBsgCategoryIds(context, info, bsgCategoryIds, items = false) { 163 | const { cache } = await this.getCache(context, info); 164 | if (!items) { 165 | items = Object.values(cache.Item); 166 | } 167 | return items.filter((item) => bsgCategoryIds.some(catId => catId === item.bsgCategoryId)); 168 | } 169 | 170 | async getItemsByCategoryEnums(context, info, names, items = false) { 171 | const { cache } = await this.getCache(context, info); 172 | if (!items) { 173 | items = Object.values(cache.Item); 174 | } 175 | const categories = (await context.data.worker.handbook.getCategories(context, info)).filter(cat => names.includes(cat.enumName)); 176 | return items.filter((item) => { 177 | return item.categories.some(catId => categories.some(cat => cat.id === catId)); 178 | }); 179 | } 180 | 181 | async getItemsByHandbookCategoryEnums(context, info, names, items = false) { 182 | const { cache } = await this.getCache(context, info); 183 | if (!items) { 184 | items = Object.values(cache.Item); 185 | } 186 | const categories = (await context.data.worker.handbook.getHandbookCategories(context, info)).filter(cat => names.includes(cat.enumName)); 187 | return items.filter((item) => { 188 | return item.handbookCategories.some(catId => categories.some(cat => cat.id === catId)); 189 | }); 190 | } 191 | 192 | async getItemsInBsgCategory(context, info, bsgCategoryId, items = false) { 193 | const { cache } = await this.getCache(context, info); 194 | if (!items) { 195 | items = Object.values(cache.Item); 196 | } 197 | return items.filter(item => item.categories.includes(bsgCategoryId)); 198 | } 199 | 200 | async getItemByNormalizedName(context, info, normalizedName) { 201 | const { cache } = await this.getCache(context, info); 202 | const item = Object.values(cache.Item).find((item) => item.normalizedName === normalizedName); 203 | 204 | if (!item) { 205 | return null; 206 | } 207 | 208 | return item; 209 | } 210 | 211 | async getItemsByDiscardLimitedStatus(context, info, limited, items = false) { 212 | const { cache } = await this.getCache(context, info); 213 | if (!items) { 214 | items = Object.values(cache.Item); 215 | } 216 | return items.filter(item => { 217 | return (item.discardLimit > -1 && limited) || (item.discardLimit == -1 && !limited); 218 | }); 219 | } 220 | 221 | async getFleaMarket(context, info) { 222 | const { cache } = await this.getCache(context, info); 223 | return cache.FleaMarket; 224 | } 225 | 226 | async getAmmoList(context, info) { 227 | const allAmmo = await this.getItemsByBsgCategoryId(context, info, '5485a8684bdc2da71d8b4567').then(ammoItems => { 228 | // ignore bb 229 | return ammoItems.filter(item => item.id !== '6241c316234b593b5676b637'); 230 | }); 231 | const itemProperties = await context.data.worker.handbook.getAllItemProperties(context, info); 232 | return allAmmo.map(item => { 233 | return { 234 | ...item, 235 | ...itemProperties[item.id], 236 | }; 237 | }); 238 | } 239 | } 240 | 241 | export default ItemsAPI; 242 | -------------------------------------------------------------------------------- /resolvers/mapResolver.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | export default { 4 | Query: { 5 | async bosses(obj, args, context, info) { 6 | let bosses = false; 7 | let filters = { 8 | name: async names => { 9 | return context.data.worker.map.getBossesByNames(context, info, names, bosses); 10 | }, 11 | } 12 | const nonFilterArgs = ['lang', 'gameMode', 'limit', 'offset']; 13 | for (const argName in args) { 14 | if (nonFilterArgs.includes(argName)) continue; 15 | if (!filters[argName]) return Promise.reject(new GraphQLError(`${argName} is not a recognized argument`)); 16 | bosses = await filters[argName](args[argName], bosses); 17 | } 18 | if (!bosses) { 19 | bosses = context.data.worker.map.getAllBosses(context, info); 20 | } 21 | return context.util.paginate(bosses, args); 22 | }, 23 | async goonReports( obj, args, context, info) { 24 | let reports = context.data.worker.map.getGoonReports(context, info); 25 | return context.util.paginate(reports, args); 26 | }, 27 | async lootContainers(obj, args, context, info) { 28 | let containers = context.data.worker.map.getAllLootContainers(context, info); 29 | return context.util.paginate(containers, args); 30 | }, 31 | async maps(obj, args, context, info) { 32 | let maps = false; 33 | let filters = { 34 | name: async names => { 35 | return context.data.worker.map.getMapsByNames(context, info, names, maps); 36 | }, 37 | enemies: async enemies => { 38 | return context.data.worker.map.getMapsByEnemies(context, info, enemies, maps); 39 | }, 40 | } 41 | const nonFilterArgs = ['lang', 'gameMode', 'limit', 'offset']; 42 | for (const argName in args) { 43 | if (nonFilterArgs.includes(argName)) continue; 44 | if (!filters[argName]) return Promise.reject(new GraphQLError(`${argName} is not a recognized argument`)); 45 | maps = await filters[argName](args[argName], maps); 46 | } 47 | if (!maps) { 48 | maps = context.data.worker.map.getList(context, info); 49 | } 50 | return context.util.paginate(maps, args); 51 | }, 52 | async stationaryWeapons(obj, args, context, info) { 53 | const stationaryWeapons = context.data.worker.map.getAllStationaryWeapons(context, info); 54 | return context.util.paginate(stationaryWeapons, args); 55 | }, 56 | }, 57 | GoonReport: { 58 | map(data, args, context, info) { 59 | return context.data.worker.map.get(context, info, data.map); 60 | } 61 | }, 62 | HealthPart: { 63 | bodyPart(data, args, context, info) { 64 | return context.data.worker.map.getLocale(data.bodyPart, context, info); 65 | } 66 | }, 67 | Lock: { 68 | key(data, args, context, info) { 69 | return context.data.worker.item.getItem(context, info, data.key); 70 | }, 71 | }, 72 | LootContainer: { 73 | name(data, args, context, info) { 74 | return context.data.worker.map.getLocale(data.name, context, info); 75 | }, 76 | }, 77 | LootContainerPosition: { 78 | lootContainer(data, args, context, info) { 79 | return context.data.worker.map.getLootContainer(context, info, data.lootContainer); 80 | }, 81 | }, 82 | LootLoosePosition: { 83 | items(data, args, context, info) { 84 | return context.data.worker.item.getItemsByIDs(context, info, data.items); 85 | }, 86 | }, 87 | Map: { 88 | accessKeys(data, args, context, info) { 89 | return context.data.worker.item.getItemsByIDs(context, info, data.accessKeys); 90 | }, 91 | name(data, args, context, info) { 92 | return context.data.worker.map.getLocale(data.name, context, info); 93 | }, 94 | description(data, args, context, info) { 95 | return context.data.worker.map.getLocale(data.description, context, info); 96 | }, 97 | enemies(data, args, context, info) { 98 | return context.data.worker.map.getLocale(data.enemies, context, info); 99 | } 100 | }, 101 | MapExtract: { 102 | name(data, args, context, info) { 103 | return context.data.worker.map.getLocale(data.name, context, info); 104 | }, 105 | switches(data, args, context, info) { 106 | if (!data.switches) { 107 | return []; 108 | } 109 | return data.switches.map(switchId => context.data.worker.map.getSwitch(context, info, switchId)); 110 | }, 111 | }, 112 | MapHazard: { 113 | name(data, args, context, info) { 114 | return context.data.worker.map.getLocale(data.name, context, info); 115 | }, 116 | }, 117 | MapPositionNamed: { 118 | name(data, args, context, info) { 119 | return context.data.worker.map.getLocale(data.name, context, info); 120 | }, 121 | }, 122 | MapSwitch: { 123 | /*door(data, args, context) { 124 | 125 | },*/ 126 | /*extract(data, args, context) { 127 | return context.data.worker.map.getExtract(context, data.extract); 128 | },*/ 129 | /*extractTip(data, args, context, info) { 130 | if (!data.extractTip) { 131 | return null; 132 | } 133 | return context.data.worker.map.getLocale(data.extractTip, context, info); 134 | },*/ 135 | activatedBy(data, args, context, info) { 136 | return context.data.worker.map.getSwitch(context, info, data.activatedBy); 137 | }, 138 | name(data, args, context, info) { 139 | if (!data.name) { 140 | return null; 141 | } 142 | return context.data.worker.map.getLocale(data.name, context, info); 143 | }, 144 | }, 145 | MapSwitchOperation: { 146 | target(data, args, context, info) { 147 | if (data.switch) { 148 | return context.data.worker.map.getSwitch(context, info, data.switch); 149 | } 150 | return context.data.worker.map.getExtract(context, info, data.extract); 151 | }, 152 | }, 153 | MapSwitchTarget: { 154 | __resolveType(data, args, context) { 155 | if (data.switchType) return 'MapSwitch'; 156 | return 'MapExtract'; 157 | }, 158 | }, 159 | MapTransit: { 160 | conditions(data, args, context, info) { 161 | if (!data.conditions) { 162 | return null; 163 | } 164 | return context.data.worker.map.getLocale(data.conditions, context, info); 165 | }, 166 | description(data, args, context, info) { 167 | if (!data.description) { 168 | return null; 169 | } 170 | return context.data.worker.map.getLocale(data.description, context, info); 171 | }, 172 | map(data, args, context, info) { 173 | return context.data.worker.map.get(context, info, data.map); 174 | }, 175 | }, 176 | MobInfo: { 177 | name(data, args, context, info) { 178 | return context.data.worker.map.getLocale(data.name, context, info); 179 | } 180 | }, 181 | BossSpawn: { 182 | boss(data, args, context, info) { 183 | return context.data.worker.map.getMobInfo(context, info, data.id); 184 | }, 185 | async name(data, args, context, info) { 186 | const boss = await context.data.worker.map.getMobInfo(context, info, data.id); 187 | return context.data.worker.map.getLocale(boss.name, context, info); 188 | }, 189 | async normalizedName(data, args, context, info) { 190 | const boss = await context.data.worker.map.getMobInfo(context, info, data.id); 191 | return boss.normalizedName; 192 | }, 193 | async spawnTrigger(data, args, context, info) { 194 | return context.data.worker.map.getLocale(data.spawnTrigger, context, info); 195 | }, 196 | async switch(data, args, context, info) { 197 | return context.data.worker.map.getSwitch(context, info, data.switch); 198 | }, 199 | }, 200 | BossSpawnLocation: { 201 | name(data, args, context, info) { 202 | return context.data.worker.map.getLocale(data.name, context, info); 203 | }, 204 | }, 205 | BossEscort: { 206 | boss(data, args, context, info) { 207 | return context.data.worker.map.getMobInfo(context, info, data.id); 208 | }, 209 | async name(data, args, context, info) { 210 | const boss = await context.data.worker.map.getMobInfo(context, info, data.id); 211 | return context.data.worker.map.getLocale(boss.name, context, info); 212 | }, 213 | async normalizedName(data, args, context, info) { 214 | const boss = await context.data.worker.map.getMobInfo(context, info, data.id); 215 | return boss.normalizedName; 216 | } 217 | }, 218 | StationaryWeapon: { 219 | name(data, args, context, info) { 220 | return context.data.worker.map.getLocale(data.name, context, info); 221 | }, 222 | shortName(data, args, context, info) { 223 | return context.data.worker.map.getLocale(data.shortName, context, info); 224 | }, 225 | }, 226 | StationaryWeaponPosition: { 227 | stationaryWeapon(data, args, context, info) { 228 | return context.data.worker.map.getStationaryWeapon(context, info, data.stationaryWeapon); 229 | }, 230 | }, 231 | }; 232 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | /*import cacheMachine from './utils/cache-machine.mjs'; 2 | import getYoga from './graphql-yoga.mjs'; 3 | import graphQLOptions from './utils/graphql-options.mjs'; 4 | 5 | export default { 6 | async fetch(request, env, ctx) { 7 | try { 8 | const yoga = await getYoga({...env, RESPONSE_CACHE: cacheMachine}); 9 | return yoga.fetch(request, {...env, ctx, RESPONSE_CACHE: cacheMachine}); 10 | } catch (err) { 11 | console.log(err); 12 | return new Response(graphQLOptions.debug ? err : 'Something went wrong', { status: 500 }); 13 | } 14 | }, 15 | };*/ 16 | import { graphql } from 'graphql'; 17 | import { v4 as uuidv4 } from 'uuid'; 18 | 19 | import DataSource from './datasources/index.mjs'; 20 | //import playground from './handlers/playground.mjs'; 21 | import graphiql from './handlers/graphiql.mjs'; 22 | import setCors from './utils/setCors.mjs'; 23 | import schema from './schema.mjs'; 24 | import graphqlUtil from './utils/graphql-util.mjs'; 25 | import graphQLOptions from './utils/graphql-options.mjs'; 26 | import cacheMachine from './utils/cache-machine.mjs'; 27 | 28 | import { getNightbotResponse, useNightbotOnUrl } from './plugins/plugin-nightbot.mjs'; 29 | import { getTwitchResponse } from './plugins/plugin-twitch.mjs'; 30 | import { getLiteApiResponse, useLiteApiOnUrl } from './plugins/plugin-lite-api.mjs'; 31 | import { getSpecialCache } from './plugins/plugin-use-cache-machine.mjs'; 32 | 33 | let dataAPI; 34 | 35 | async function graphqlHandler(request, env, ctx) { 36 | const url = new URL(request.url); 37 | let query, variables; 38 | 39 | if (request.method === 'POST') { 40 | try { 41 | const requestBody = await request.json(); 42 | query = requestBody.query; 43 | variables = requestBody.variables; 44 | } catch (jsonError) { 45 | console.error(jsonError); 46 | 47 | return new Response(null, { 48 | status: 400, 49 | }); 50 | } 51 | } else if (request.method === 'GET') { 52 | query = url.searchParams.get('query'); 53 | variables = url.searchParams.get('variables'); 54 | } 55 | 56 | // Check for empty /graphql query 57 | if (!query || query.trim() === '') { 58 | return new Response('GraphQL requires a query in the body of the request', 59 | { 60 | status: 400, 61 | headers: { 'cache-control': 'public, max-age=2592000' } 62 | } 63 | ); 64 | } 65 | 66 | if (!dataAPI) { 67 | dataAPI = new DataSource(env); 68 | } 69 | 70 | const requestId = uuidv4(); 71 | console.info(requestId); 72 | console.log(new Date().toLocaleString('en-US', { timeZone: 'UTC' })); 73 | console.log(`KVs pre-loaded: ${dataAPI.kvLoaded.join(', ') || 'none'}`); 74 | //console.log('query', query); 75 | //console.log('variables', variables); 76 | if (request.headers.has('x-newrelic-synthetics')) { 77 | console.log('NewRelic health check'); 78 | //return new Response(JSON.stringify({}), responseOptions); 79 | } 80 | 81 | const specialCache = getSpecialCache(request); 82 | 83 | let key; 84 | // Check the cache service for data first - If cached data exists, return it 85 | // we don't check the cache if we're the http server because the worker already did 86 | if (env.SKIP_CACHE !== 'true' && env.SKIP_CACHE_CHECK !== 'true' && !env.CLOUDFLARE_TOKEN) { 87 | key = await cacheMachine.createKey(env, query, variables, specialCache); 88 | const cachedResponse = await cacheMachine.get(env, {key}); 89 | if (cachedResponse) { 90 | // Construct a new response with the cached data 91 | const newResponse = new Response(await cachedResponse.json(), { 92 | headers: { 93 | 'Content-Type': 'application/json', 94 | 'X-CACHE': 'HIT', // we know request hit the cache 95 | 'Cache-Control': `public, max-age=${cachedResponse.headers.get('X-Cache-Ttl')}`, 96 | }, 97 | }); 98 | console.log('Request served from cache'); 99 | // Return the new cached response 100 | return newResponse; 101 | } 102 | } else { 103 | //console.log(`Skipping cache in ${ENVIRONMENT} environment`); 104 | } 105 | 106 | // if an origin server is configured, pass the request 107 | if (env.USE_ORIGIN === 'true') { 108 | try { 109 | const originUrl = new URL(request.url); 110 | originUrl.search = ''; 111 | if (env.ORIGIN_OVERRIDE) { 112 | originUrl.host = env.ORIGIN_OVERRIDE; 113 | } 114 | if (env.ORIGIN_PROTOCOL) { 115 | originUrl.protocol = env.ORIGIN_PROTOCOL; 116 | } 117 | console.log(`Querying origin server ${originUrl}`); 118 | const originResponse = await fetch(originUrl, { 119 | method: 'POST', 120 | body: JSON.stringify({ 121 | query, 122 | variables, 123 | }), 124 | headers: { 125 | 'Content-Type': 'application/json', 126 | 'cache-check-complete': 'true', 127 | }, 128 | signal: AbortSignal.timeout(20000), 129 | }); 130 | if (originResponse.status !== 200) { 131 | throw new Error(`${originResponse.status} ${await originResponse.text()}`); 132 | } 133 | console.log('Request served from origin server'); 134 | const newResponse = new Response(originResponse.body, { 135 | headers: { 136 | 'Content-Type': 'application/json', 137 | }, 138 | }); 139 | if (originResponse.headers.has('X-Cache-Ttl')) { 140 | newResponse.headers.set('Cache-Control', `public, max-age=${originResponse.headers.get('X-Cache-Ttl')}`); 141 | } 142 | return newResponse; 143 | } catch (error) { 144 | console.error(`Error getting response from origin server: ${error}`); 145 | } 146 | } 147 | 148 | const context = graphqlUtil.getDefaultContext(dataAPI, requestId); 149 | let result, ttl; 150 | try { 151 | result = await graphql({schema: await schema(dataAPI, context), source: query, rootValue: {}, contextValue: context, variableValues: variables}); 152 | ttl = dataAPI.getRequestTtl(requestId); 153 | //console.log(`${requestId} kvs loaded: ${dataAPI.requests[requestId].kvLoaded.join(', ')}`); 154 | } catch (error) { 155 | throw error; 156 | } finally { 157 | dataAPI.clearRequestData(requestId); 158 | } 159 | console.log('generated graphql response'); 160 | if (context.errors.length > 0) { 161 | if (!result.errors) { 162 | result = Object.assign({errors: []}, result); // this puts the errors at the start of the result 163 | } 164 | result.errors.push(...context.errors); 165 | } 166 | if (context.warnings.length > 0) { 167 | if (!result.warnings) { 168 | result = Object.assign({warnings: []}, result); 169 | } 170 | result.warnings.push(...context.warnings); 171 | } 172 | 173 | if (result.errors?.some(err => err.message === 'Unexpected error.')) { 174 | ttl = 0; 175 | } else if (result.errors?.some(err => err.message.startsWith('Syntax Error'))) { 176 | ttl = 1800; 177 | } else if (specialCache === 'application/json') { 178 | if (!result.warnings) { 179 | result = Object.assign({warnings: []}, result); 180 | } 181 | ttl = 30 * 60; 182 | result.warnings.push({message: `Your request does not have a "content-type" header set to "application/json". Requests missing this header are limited to resposnes that update every ${ttl/60} minutes.`}); 183 | } else if (ttl > 1800) { 184 | // if the ttl is greater than a half hour, limit it 185 | ttl = 1800; 186 | } 187 | 188 | const body = JSON.stringify(result); 189 | 190 | const response = new Response(body, { 191 | headers: { 192 | 'Content-Type': 'application/json', 193 | }, 194 | }); 195 | if (ttl > 0) { 196 | response.headers.set('Cache-Control', `public, max-age=${ttl}`); 197 | } 198 | 199 | if (env.SKIP_CACHE !== 'true' && ttl > 0) { 200 | key = key ?? await cacheMachine.createKey(env, query, variables, specialCache); 201 | ctx.waitUntil(cacheMachine.put(env, body, {key})); 202 | } 203 | 204 | return response; 205 | } 206 | 207 | export default { 208 | async fetch(request, env, ctx) { 209 | if (!graphQLOptions.cors.allowMethods.split(', ').includes(request.method.toUpperCase())) { 210 | const errorResponse = new Response(null, { 211 | status: 405, 212 | headers: { 'cache-control': 'public, max-age=2592000' }, 213 | }); 214 | setCors(errorResponse, graphQLOptions.cors); 215 | return errorResponse; 216 | } 217 | if (request.method.toUpperCase() === 'OPTIONS') { 218 | const optionsResponse = new Response(null, { 219 | headers: { 220 | 'cache-control': 'public, max-age=2592000', 221 | 'Access-Control-Max-Age': '86400', 222 | }, 223 | }); 224 | setCors(optionsResponse, graphQLOptions.cors); 225 | return optionsResponse; 226 | } 227 | const requestStart = new Date(); 228 | const url = new URL(request.url); 229 | 230 | try { 231 | if (url.pathname === '/twitch') { 232 | const response = await getTwitchResponse(env); 233 | if (graphQLOptions.cors) { 234 | setCors(response, graphQLOptions.cors); 235 | } 236 | return response; 237 | } 238 | 239 | if (url.pathname === graphQLOptions.playgroundEndpoint) { 240 | //response = playground(request, graphQLOptions); 241 | return graphiql(graphQLOptions); 242 | } 243 | 244 | if (useNightbotOnUrl(url)) { 245 | return await getNightbotResponse(request, url, env, ctx); 246 | } 247 | 248 | if (useLiteApiOnUrl(url)) { 249 | return await getLiteApiResponse(request, url, env, ctx); 250 | } 251 | 252 | if (url.pathname === graphQLOptions.baseEndpoint) { 253 | const response = await graphqlHandler(request, env, ctx); 254 | if (graphQLOptions.cors) { 255 | setCors(response, graphQLOptions.cors); 256 | } 257 | return response; 258 | } 259 | 260 | return new Response('Not found', { status: 404 }); 261 | } catch (err) { 262 | console.log(err); 263 | return new Response(graphQLOptions.debug ? err : 'Something went wrong', { status: 500 }); 264 | } finally { 265 | console.log(`Response time: ${new Date() - requestStart} ms`); 266 | } 267 | }, 268 | }; 269 | -------------------------------------------------------------------------------- /resolvers/itemResolver.mjs: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | export default { 4 | Query: { 5 | item(obj, args, context, info) { 6 | if (args.id) return context.data.worker.item.getItem(context, info, args.id); 7 | if (args.normalizedName) return context.data.worker.item.getItemByNormalizedName(context, info, args.normalizedName); 8 | return Promise.reject(new GraphQLError('You must specify either the id or the normalizedName argument')); 9 | }, 10 | async items(obj, args, context, info) { 11 | let items = false; 12 | let filters = { 13 | ids: async ids => { 14 | return context.data.worker.item.getItemsByIDs(context, info, ids, items); 15 | }, 16 | name: async name => { 17 | return context.data.worker.item.getItemsByName(context, info, name, items); 18 | }, 19 | names: async names => { 20 | return context.data.worker.item.getItemsByNames(context, info, names, items); 21 | }, 22 | type: async type => { 23 | return context.data.worker.item.getItemsByType(context, info, type, items); 24 | }, 25 | types: async types => { 26 | return context.data.worker.item.getItemsByTypes(context, info, types, items); 27 | }, 28 | categoryNames: async bsgcats => { 29 | return context.data.worker.item.getItemsByCategoryEnums(context, info, bsgcats, items); 30 | }, 31 | handbookCategoryNames: async handbookcats => { 32 | return context.data.worker.item.getItemsByHandbookCategoryEnums(context, info, handbookcats, items); 33 | }, 34 | bsgCategoryId: async bsgcat => { 35 | return context.data.worker.item.getItemsByBsgCategoryId(context, info, bsgcat, items); 36 | }, 37 | bsgCategoryIds: async bsgcats => { 38 | return context.data.worker.item.getItemsByBsgCategoryIds(context, info, bsgcats, items); 39 | }, 40 | bsgCategory: async bsgcat => { 41 | return context.data.worker.item.getItemsInBsgCategory(context, info, bsgcat, items); 42 | }, 43 | /*discardLimited: async limited => { 44 | return context.data.worker.item.getItemsByDiscardLimitedStatus(limited, items); 45 | },*/ 46 | } 47 | //if (Object.keys(args).length === 0) return context.data.worker.item.getAllItems(); 48 | for (const argName in args) { 49 | if (!filters[argName]) continue; 50 | items = await filters[argName](args[argName]); 51 | } 52 | if (!items) { 53 | items = context.data.worker.item.getAllItems(context, info); 54 | } 55 | return context.util.paginate(items, args); 56 | }, 57 | itemCategories(obj, args, context, info) { 58 | return context.util.paginate(context.data.worker.handbook.getCategories(context, info), args); 59 | }, 60 | handbookCategories(obj, args, context, info) { 61 | return context.util.paginate(context.data.worker.handbook.getHandbookCategories(context, info), args); 62 | }, 63 | itemsByIDs(obj, args, context, info) { 64 | return context.data.worker.item.getItemsByIDs(context, info, args.ids, false); 65 | }, 66 | itemsByType(obj, args, context, info) { 67 | return context.data.worker.item.getItemsByType(context, info, args.type, false); 68 | }, 69 | itemsByName(obj, args, context, info) { 70 | return context.data.worker.item.getItemsByName(context, info, args.name); 71 | }, 72 | itemByNormalizedName(obj, args, context, info) { 73 | return context.data.worker.item.getItemByNormalizedName(context, info, args.normalizedName); 74 | }, 75 | itemsByBsgCategoryId(obj, args, context, info) { 76 | return context.data.worker.item.getItemsByBsgCategoryId(context, info, args.bsgCategoryId); 77 | }, 78 | async itemPrices(obj, args, context, info) { 79 | const [ 80 | historical, 81 | archived, 82 | ] = await Promise.all([ 83 | context.data.worker.historicalPrice.getByItemId(context, info, args.id, 30), 84 | context.data.worker.archivedPrice.getByItemId(context, info, args.id), 85 | ]); 86 | return context.util.paginate([...archived, ...historical], args); 87 | }, 88 | historicalItemPrices(obj, args, context, info) { 89 | return context.util.paginate(context.data.worker.historicalPrice.getByItemId(context, info, args.id, args.days), args); 90 | }, 91 | archivedItemPrices(obj, args, context, info) { 92 | return context.util.paginate(context.data.worker.archivedPrice.getByItemId(context, info, args.id), args); 93 | }, 94 | armorMaterials(obj, args, context, info) { 95 | return context.data.worker.handbook.getArmorMaterials(context, info); 96 | }, 97 | fleaMarket(obj, args, context, info) { 98 | return context.data.worker.item.getFleaMarket(context, info); 99 | }, 100 | mastering(obj, args, context, info) { 101 | return context.data.worker.handbook.getMasterings(context, info); 102 | }, 103 | playerLevels(obj, args, context, info) { 104 | return context.data.worker.handbook.getPlayerLevels(context, info); 105 | }, 106 | skills(obj, args, context, info) { 107 | return context.data.worker.handbook.getSkills(context, info); 108 | }, 109 | }, 110 | Item: { 111 | name(data, args, context, info) { 112 | return context.data.worker.item.getLocale(data.name, context, info); 113 | }, 114 | shortName(data, args, context, info) { 115 | return context.data.worker.item.getLocale(data.shortName, context, info); 116 | }, 117 | description(data, args, context, info) { 118 | return context.data.worker.item.getLocale(data.description, context, info); 119 | }, 120 | async buyFor(data, args, context, info) { 121 | if (!data.buyFor) data.buyFor = []; 122 | return [ 123 | ...await context.data.worker.traderInventory.getByItemId(context, info, data.id), 124 | ...data.buyFor 125 | ]; 126 | }, 127 | bsgCategory(data, args, context, info) { 128 | if (data.bsgCategoryId) return context.data.worker.handbook.getCategory(context, info, data.bsgCategoryId); 129 | return null; 130 | }, 131 | category(data, args, context, info) { 132 | if (data.bsgCategoryId) return context.data.worker.handbook.getCategory(context, info, data.bsgCategoryId); 133 | return null; 134 | }, 135 | categoryTop(data, args, context, info) { 136 | if (data.bsgCategoryId) return context.data.worker.handbook.getTopCategory(context, info, data.bsgCategoryId); 137 | return null; 138 | }, 139 | categories(data, args, context, info) { 140 | return data.categories.map(id => { 141 | return context.data.worker.handbook.getCategory(context, info, id); 142 | }); 143 | }, 144 | handbookCategories(data, args, context, info) { 145 | return data.handbookCategories.map(id => { 146 | return context.data.worker.handbook.getCategory(context, info, id); 147 | }); 148 | }, 149 | async conflictingItems(data, args, context, info) { 150 | return Promise.all(data.conflictingItems.map(async id => { 151 | const item = await context.data.worker.item.getItem(context, info, id).catch(error => { 152 | console.warn(`item ${id} not found for conflictingItems`); 153 | return null; 154 | }); 155 | 156 | return item; 157 | })); 158 | }, 159 | usedInTasks(data, args, context, info) { 160 | return context.data.worker.task.getTasksRequiringItem(context, info, data.id); 161 | }, 162 | receivedFromTasks(data, args, context, info,) { 163 | return context.data.worker.task.getTasksProvidingItem(context, info, data.id); 164 | }, 165 | bartersFor(data, args, context, info) { 166 | return context.data.worker.barter.getBartersForItem(context, info, data.id); 167 | }, 168 | bartersUsing(data, args, context, info) { 169 | return context.data.worker.barter.getBartersUsingItem(context, info, data.id); 170 | }, 171 | craftsFor(data, args, context, info) { 172 | return context.data.worker.craft.getCraftsForItem(context, info, data.id); 173 | }, 174 | craftsUsing(data, args, context, info) { 175 | return context.data.worker.craft.getCraftsUsingItem(context, info, data.id); 176 | }, 177 | async fleaMarketFee(data, args, context, info) { 178 | if (data.types.includes('noFlea')) return null; 179 | const options = { 180 | price: data.lastLowPrice || data.basePrice, 181 | intelCenterLevel: 0, 182 | hideoutManagementLevel: 0, 183 | count: 1, 184 | requireAll: false, 185 | ...args 186 | }; 187 | const flea = await context.data.worker.item.getFleaMarket(context, info); 188 | const q = options.requireAll ? 1 : options.count; 189 | const vo = data.basePrice * (options.count / q); 190 | const vr = options.price; 191 | let po = Math.log10(vo / vr); 192 | if (vr < vo) po = Math.pow(po, 1.08); 193 | let pr = Math.log10(vr / vo); 194 | if (vr >= vo) pr = Math.pow(pr, 1.08); 195 | const ti = flea.sellOfferFeeRate; 196 | const tr = flea.sellRequirementFeeRate; 197 | let fee = (vo * ti * Math.pow(4.0, po) * q) + (vr * tr * Math.pow(4.0, pr) * q); 198 | if (options.intelCenterLevel >= 3) { 199 | let discount = 0.3; 200 | discount = discount + (discount * options.hideoutManagementLevel * 0.01); 201 | fee = fee - (fee * discount); 202 | } 203 | if (fee > Number.MAX_SAFE_INTEGER) { 204 | fee = Number.MAX_SAFE_INTEGER; 205 | } 206 | if (fee < Number.MIN_SAFE_INTEGER) { 207 | fee = Number.MIN_SAFE_INTEGER; 208 | } 209 | return Math.round(fee); 210 | }, 211 | async historicalPrices(data, args, context, info) { 212 | context.util.testDepthLimit(info, 1); 213 | const warningMessage = `Querying historicalPrices on the Item object will only provide half the prices from the last ${context.data.worker.historicalPrice.itemLimitDays} days. For up to ${context.data.worker.historicalPrice.maxDays} days of historical prices, use the historicalItemPrices query.`; 214 | if (!context.warnings.some(warning => warning.message === warningMessage)) { 215 | context.warnings.push({message: warningMessage}); 216 | } 217 | return context.data.worker.historicalPrice.getByItemId(context, info, data.id, context.data.worker.historicalPrice.itemLimitDays, true); 218 | }, 219 | imageLink(data) { 220 | return data.inspectImageLink; 221 | }, 222 | iconLinkFallback(data) { 223 | return data.iconLink 224 | }, 225 | gridImageLinkFallback(data) { 226 | return data.gridImageLink 227 | }, 228 | imageLinkFallback(data) { 229 | return data.inspectImageLink; 230 | }, 231 | properties(data, args, context, info) { 232 | return context.data.worker.handbook.getItemProperties(context, info, data.id); 233 | } 234 | }, 235 | ItemArmorSlot: { 236 | __resolveType(data) { 237 | if (data.allowedPlates) return 'ItemArmorSlotOpen'; 238 | return 'ItemArmorSlotLocked'; 239 | } 240 | }, 241 | ItemArmorSlotLocked: { 242 | name(data, args, context, info) { 243 | return context.data.worker.handbook.getLocale(data.name, context, info); 244 | }, 245 | zones(data, args, context, info) { 246 | return context.data.worker.handbook.getLocale(data.zones, context, info); 247 | }, 248 | material(data, args, context, info) { 249 | return context.data.worker.handbook.getArmorMaterial(context, info, data.armor_material_id); 250 | }, 251 | }, 252 | ItemArmorSlotOpen: { 253 | name(data, args, context, info) { 254 | return context.data.worker.handbook.getLocale(data.name, context, info); 255 | }, 256 | zones(data, args, context, info) { 257 | return context.data.worker.handbook.getLocale(data.zones, context, info); 258 | }, 259 | allowedPlates(data, args, context, info) { 260 | return data.allowedPlates.map(id => context.data.worker.item.getItem(context, info, id)); 261 | }, 262 | }, 263 | ItemAttribute: { 264 | type(data, args, context) { 265 | if (data.type) return data.type; 266 | return data.name; 267 | }, 268 | name(data) { 269 | if (data.name) return data.name; 270 | return data.type; 271 | } 272 | }, 273 | ItemCategory: { 274 | name(data, args, context, info) { 275 | return context.data.worker.handbook.getLocale(data.name, context, info); 276 | }, 277 | parent(data, args, context, info) { 278 | if (data.parent_id) return context.data.worker.handbook.getCategory(context, info, data.parent_id); 279 | return null; 280 | }, 281 | children(data, args, context, info) { 282 | return data.child_ids.map(id => context.data.worker.handbook.getCategory(context, info, id)); 283 | } 284 | }, 285 | ItemFilters: { 286 | allowedCategories(data, args, context, info) { 287 | return data.allowedCategories.map(id => context.data.worker.handbook.getCategory(context, info, id)); 288 | }, 289 | allowedItems(data, args, context, info) { 290 | return data.allowedItems.map(id => context.data.worker.item.getItem(context, info, id)); 291 | }, 292 | excludedCategories(data, args, context, info) { 293 | return data.excludedCategories.map(id => context.data.worker.handbook.getCategory(context, info, id)); 294 | }, 295 | excludedItems(data, args, context, info) { 296 | return data.excludedItems.map(id => context.data.worker.item.getItem(context, info, id)); 297 | }, 298 | }, 299 | ItemPrice: { 300 | currencyItem(data, args, context, info) { 301 | return context.data.worker.item.getItem(context, info, data.currencyItem); 302 | } 303 | }, 304 | ItemProperties: { 305 | __resolveType(data) { 306 | if (data.propertiesType) return data.propertiesType; 307 | return null; 308 | } 309 | }, 310 | ItemPropertiesArmor: { 311 | armorType(data, args, context, info) { 312 | return context.data.worker.handbook.getLocale(data.armorType, context, info); 313 | }, 314 | material(data, args, context, info) { 315 | return context.data.worker.handbook.getArmorMaterial(context, info, data.armor_material_id); 316 | }, 317 | zones(data, args, context, info) { 318 | return context.data.worker.handbook.getLocale(data.zones, context, info); 319 | }, 320 | }, 321 | ItemPropertiesArmorAttachment: { 322 | material(data, args, context, info) { 323 | return context.data.worker.handbook.getArmorMaterial(context, info, data.armor_material_id); 324 | }, 325 | headZones(data, args, context, info) { 326 | return context.data.worker.handbook.getLocale(data.headZones, context, info); 327 | }, 328 | zones(data, args, context, info) { 329 | return context.data.worker.handbook.getLocale(data.headZones, context, info); 330 | } 331 | }, 332 | ItemPropertiesBackpack: { 333 | pouches(data) { 334 | return data.grids; 335 | } 336 | }, 337 | ItemPropertiesChestRig: { 338 | armorType(data, args, context, info) { 339 | return context.data.worker.handbook.getLocale(data.armorType, context, info); 340 | }, 341 | material(data, args, context, info) { 342 | return context.data.worker.handbook.getArmorMaterial(context, info, data.armor_material_id); 343 | }, 344 | zones(data, args, context, info) { 345 | return context.data.worker.handbook.getLocale(data.zones, context, info); 346 | }, 347 | pouches(data) { 348 | return data.grids; 349 | } 350 | }, 351 | ItemPropertiesGlasses: { 352 | material(data, args, context, info) { 353 | return context.data.worker.handbook.getArmorMaterial(context, info, data.armor_material_id); 354 | }, 355 | }, 356 | ItemPropertiesHelmet: { 357 | armorType(data, args, context, info) { 358 | return context.data.worker.handbook.getLocale(data.armorType, context, info); 359 | }, 360 | material(data, args, context, info) { 361 | return context.data.worker.handbook.getArmorMaterial(context, info, data.armor_material_id); 362 | }, 363 | headZones(data, args, context, info) { 364 | return context.data.worker.handbook.getLocale(data.headZones, context, info); 365 | } 366 | }, 367 | ItemPropertiesMagazine: { 368 | allowedAmmo(data, args, context, info) { 369 | return data.allowedAmmo.map(id => context.data.worker.item.getItem(context, info, id)); 370 | } 371 | }, 372 | ItemPropertiesPreset: { 373 | baseItem(data, args, context, info) { 374 | return context.data.worker.item.getItem(context, info, data.base_item_id); 375 | } 376 | }, 377 | ItemPropertiesWeapon: { 378 | defaultAmmo(data, args, context, info) { 379 | if (!data.default_ammo_id) return null; 380 | return context.data.worker.item.getItem(context, info, data.default_ammo_id); 381 | }, 382 | fireModes(data, args, context, info) { 383 | return context.data.worker.handbook.getLocale(data.fireModes, context, info); 384 | }, 385 | allowedAmmo(data, args, context, info) { 386 | return data.allowedAmmo.map(id => context.data.worker.item.getItem(context, info, id)); 387 | }, 388 | defaultPreset(data, args, context, info) { 389 | if (!data.defaultPreset) return null; 390 | return context.data.worker.item.getItem(context, info, data.defaultPreset); 391 | }, 392 | presets(data, args, context, info) { 393 | return Promise.all(data.presets.map(id => context.data.worker.item.getItem(context, info, id))); 394 | } 395 | }, 396 | ItemSlot: { 397 | name(data, ags, context, info) { 398 | return context.data.worker.handbook.getLocale(data.name, context, info); 399 | } 400 | }, 401 | ContainedItem: { 402 | item(data, args, context, info) { 403 | if (data.contains) return context.data.worker.item.getItem(context, info, data.item, data.contains); 404 | return context.data.worker.item.getItem(context, info, data.item); 405 | }, 406 | quantity(data, args, context) { 407 | return data.count; 408 | } 409 | }, 410 | ArmorMaterial: { 411 | name(data, args, context, info) { 412 | return context.data.worker.handbook.getLocale(data.name, context, info); 413 | } 414 | }, 415 | FleaMarket: { 416 | name(data, args, context, info) { 417 | return context.data.worker.item.getLocale(data.name, context, info); 418 | } 419 | }, 420 | Mastering: { 421 | weapons(data, args, context, info) { 422 | return Promise.all(data.weapons.map(id => context.data.worker.item.getItem(context, info, id))); 423 | }, 424 | }, 425 | RequirementItem: { 426 | item(data, args, context, info) { 427 | return context.data.worker.item.getItem(context, info, data.item); 428 | }, 429 | quantity(data) { 430 | return data.count; 431 | } 432 | }, 433 | Skill: { 434 | name(data, args, context, info) { 435 | return context.data.worker.handbook.getLocale(data.name, context, info); 436 | } 437 | }, 438 | StimEffect: { 439 | type(data, args, context, info) { 440 | return context.data.worker.handbook.getLocale(data.type, context, info); 441 | }, 442 | skill(data, args, context, info) { 443 | return context.data.worker.handbook.getSkill(context, info, data.skillName); 444 | }, 445 | skillName(data, args, context, info) { 446 | return context.data.worker.handbook.getLocale(data.skillName, context, info); 447 | } 448 | }, 449 | Vendor: { 450 | __resolveType(data, args, context) { 451 | if (data.trader) return 'TraderOffer'; 452 | return 'FleaMarket'; 453 | } 454 | } 455 | }; 456 | --------------------------------------------------------------------------------