Agris, blindfolded, raising a glowing GraphQL logo in one hand and holding a scale under a full moon
  • 100% free and open source
  • MIT
  • Cloudflare Workers

Black Desert Online Central Market Proxy

Agris turns the Black Desert Online Central Market into a typed, federated GraphQL API. Prices, stock, order books and price history for every region, with item names in 12 languages. You run it on your own Cloudflare account, with no API key and nobody else's traffic.

GraphiQL and the examples run on a small shared demo. It is a courtesy and may go away, so deploy your own for anything real.

  • Apollo Federation v2
  • Hive Gateway
  • GraphQL Mesh
  • GraphQL Yoga
  • Pothos
  • Envelop
  • Cloudflare Workers
  • Durable Objects
  • TypeScript

Free as in freedom

100% free and open source

No open core, no paid tier, no catch. All of Agris's code is on GitHub under the MIT license, and the only bill is the one Cloudflare sends you for your own Workers.

  • MIT licensed

    All of the code: the API, the Workers, the tests and this site. Use it, fork it, build on it.

  • No account, no API key

    There is nothing to sign up for and nothing to pay us. Your copy answers to you.

  • No tracking

    No analytics and no cookies here. Your gateway logs to your own Cloudflare account only.

  • Run it yourself

    Five commands put your own copy on your Cloudflare account, on any hostname you like.

  • Pull requests welcome

    Issues and pull requests are open to everyone, and the contributing guide fits on one screen.

  • Nothing hidden

    TypeScript end to end, with the behaviour written down as plain-English test scenarios.

Star it on GitHub Open an issue Send a pull request

Why Agris

Why not call Pearl Abyss directly?

Anyone who calls the Central Market's servers runs into the same few problems. Here is each one, and what Agris does about it on Cloudflare.

  1. The problem

    Undocumented rows of numbers

    Pearl Abyss documents none of its trade endpoints. They answer with compact rows of numbers split by dashes and pipes, some of them Huffman-compressed, and only a number's position tells you what it means.

    What Agris does

    A clear GraphQL schema

    Agris decodes every row into named, typed fields that GraphiQL can show you. Each row's shape is checked first, so a format change upstream turns into an error, never a wrong price.

    • Pothos
    • GraphQL Yoga
  2. The problem

    IDs without names

    The market knows items only by number. It sends no names or grades and has no search, so 44195 means nothing until you bring your own item list.

    What Agris does

    Names in 12 languages, built in

    The item catalog ships with Agris and answers from memory, with a search by part of a name. Federation joins names to prices, so one query gets both.

    • Apollo Federation
    • Hive Gateway
    • GraphQL Mesh
  3. The problem

    A different server for every region

    Each region has its own host in its own datacenter, often far from you. Every call pays for that distance, and a slow answer holds up everything waiting on it.

    What Agris does

    A coordinator next to each server

    Agris keeps one Durable Object per Pearl Abyss host and uses a location hint to place it near that host's datacenter. Your query enters at the Cloudflare location closest to you, and the gateway reaches its subgraphs over Service Bindings, with no public hop in between.

    • Durable Objects
    • Service Bindings
  4. The problem

    Easy to get throttled

    Pearl Abyss throttles callers who ask too much, and it publishes no limits. A script that hammers it gets slow answers and errors, and risks being blocked.

    What Agris does

    Polite by default

    Each coordinator runs at most 6 calls at once and queues the rest in order, with a limit of 100 per 10 s behind it for bursts. A failed call is not retried. Both numbers are settings.

    • Durable Objects
    • Workers rate limiting
  5. The problem

    Everyone asks for the same thing

    When an item is in demand, many people look it up at the same moment. Sent straight to Pearl Abyss, each lookup is one more call to a server that is already busy.

    What Agris does

    Asked once, answered many times

    Identical calls in flight share one fetch. Answers then wait in Cloudflare's edge cache for as long as their fields allow, from 5 s to 1 year. After that the old answer is served while a new one loads, and if Pearl Abyss is down it can stand in for 1 day. Every window is a setting, and caching can be turned off.

    • Workers cache
    • Durable Objects
  6. The problem

    Closed to browsers

    The trade servers send no CORS headers, so a web page cannot call them. Every price tracker needs a server of its own in the middle.

    What Agris does

    CORS and GraphiQL at the edge

    Your gateway answers browsers from any origin, so a static page can query it directly. Open /graphql in a browser to explore the schema in GraphiQL, or switch GraphiQL off.

    • Cloudflare Workers
    • GraphQL Yoga
  7. The problem

    One client can crowd out the rest

    An open endpoint attracts scrapers and runaway loops, and every request they send can become another call upstream.

    What Agris does

    Rate limits at the door

    Workers rate limiting counts each visitor at the door, before any other work. Past 60 a minute, they get a 429 with a Retry-After header, and nothing reaches Pearl Abyss. Turn on the exact market limit too, and a Durable Object counts each visitor's market reads exactly, however their requests burst or spread across Cloudflare locations; cached answers never wait for it. Change the limits, or remove them.

    • Workers rate limiting
    • Durable Objects

See it work

Follow one query through the machine

Here Agris is drawn as a counting-house machine, and a query is a punched card. Press Send and watch it pass the rate limit, get its cache key and check the edge cache. On a miss it splits between the two subgraphs, and only one of them talks to Pearl Abyss. Point at any part to see what it is.

  1. Rate limit
  2. Cache key
  3. Edge cache
  4. Federation
  5. Pearl Abyss
  6. Answer
STALE-IF-ERROR1 DAY

Pick a query and press Send. The card starts in the tray on the left.

HIT: answered from the cacheMISS: fetched, then filedSTALE: served old while refreshing429: rate limitedLeft to right: the tray, the turnstile (the rate limit), the punch (the cache key), the gauge (the query limits), the pigeonhole rack (the edge cache), the stamp, and the splitter that sends a miss down two Service Bindings: to the catalog's drawers, which answer from memory, and to the market's carousel, whose line is the only one that leaves the bench, for Pearl Abyss. Windows are a self-hosted gateway's defaults; time is simulated.

The stack

Federated GraphQL, running at the edge

Agris is a real federated graph: two subgraphs, composed into one supergraph and executed by Hive Gateway inside a Cloudflare Worker. Each subgraph is its own Worker, and they talk over Service Bindings, so the whole graph lives in Cloudflare's network with no server to run.

How a query flows

  1. The gateway takes the query

    One public Worker receives it, checks it against the schema and the query limits, and counts it against the rate limit.

  2. It gets a cache key

    The query and its variables are printed in one canonical form and hashed, so the same question asked differently shares one answer.

  3. The edge cache answers if it can

    Cacheable queries run behind a cached entrypoint of the same Worker, so Cloudflare's cache sits in front of all the work. A hit stops here.

  4. Hive Gateway plans the federation

    On a miss, the gateway splits the query between the catalog and market subgraphs and joins their answers on the shared Item entity.

  5. The subgraphs answer

    The catalog answers names from memory. The market asks Pearl Abyss through one Durable Object per server, which merges identical calls.

The graph

A real Apollo Federation v2 supergraph, built from open-source parts.

Apollo Federation v2
The catalog owns the Item entity; the market extends it with prices. Clients see one schema.
Hive Gateway
The Guild's open-source federation gateway, embedded as a runtime inside a Worker.
GraphQL Mesh Compose
Composes both subgraphs into the supergraph at build time, so the gateway starts with its plan ready.
Pothos
Type-safe, code-first subgraph schemas, with its federation, dataloader and directives plugins.
GraphQL Yoga
Serves each subgraph, and GraphiQL when you open the endpoint in a browser.
Envelop
The plugin system Hive Gateway and Yoga share. Tracing, auth or stricter limits are one line away.

The edge

Three Cloudflare Workers and no server to look after.

Cloudflare Workers
A public gateway and two private subgraphs, deployed side by side to your account.
Service Bindings
The gateway calls the subgraphs Worker to Worker. They have no public URL at all.
Durable Objects
One per Pearl Abyss server, placed near it: identical calls in flight share one fetch, and the rest wait their turn.
Rate limiting
Workers rate-limiting bindings turn a flood away with a cheap 429, before it reaches a subgraph or Pearl Abyss.
Workers cache
A named entrypoint with caching turned on puts Cloudflare's cache in front of execution.

The code

Strict TypeScript from the request to the response.

TypeScript
Strict everywhere, with no any. Upstream answers are unknown until they are validated.
XState
State machines for the Service Binding connection pool and the Pearl Abyss transport.
Effect
Typed effects in the gateway and through the test harness.
Effect Schema
Validates configuration and JSON at the edges.

The workshop

Fast tools that keep a small project honest.

moon and proto
Every task runs through moon, cached where it can be; proto pins Node.js, pnpm and moon for everyone.
pnpm
One workspace, one catalog of versions.
oxlint and oxfmt
Linting and formatting in milliseconds.
Vitest
Tests run headless, as an in-process federated graph, and inside the real Workers runtime.
Gherkin
Every promise the API makes is a plain-English scenario first.
Astro
This page: static HTML, a little TypeScript, no framework.

Make it yours

Extend it with Envelop plugins

Hive Gateway runs Envelop plugins, the same hooks GraphQL Yoga is built on, and your gateway keeps a list of them in apps/gateway/src/plugins.ts. Install a plugin, add one line, deploy, and it runs on every query your gateway executes. Answers from the edge cache never reach it.

The Envelop catalogue has plugins for tracing with OpenTelemetry, error reports to Sentry, auth, logging and stricter query limits, and writing your own takes a few lines more.

Browse the Envelop plugin hub
Your gateway's plugin list
import type { GatewayPlugin } from "@graphql-hive/gateway-runtime";
import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens";

export const gatewayPlugins: readonly GatewayPlugin[] = [
  maxTokensPlugin(), // the one line you add
];

Caching

How Agris caches

Answers are kept in Cloudflare's cache, close to the people asking. When the same question comes back, the cache answers it and Pearl Abyss gets no call.

  1. Each field has a default

    Order books stay fresh for 5 s, prices for 60 s and item names for 1 year.

  2. The shortest one wins

    Ask for item names and their prices in one query, and the answer is cached for 60 s, the shorter of the two.

  3. Old answers fill the gaps

    When an answer expires, the cache keeps serving it while a new one loads. If Pearl Abyss is down, the last good answer is served for up to 1 day.

See it for yourself

Every answer has a Cf-Cache-Status header that says what the cache did:

  • MISS Your gateway did the work.
  • HIT The cache answered.
  • STALE An older answer, served while a new one loads.

The examples below show the same chip next to each result. Run one twice and the second answer usually says HIT.

Response headers, from your gateway
AGRIS='https://agris.<your-subdomain>.workers.dev'
curl -s -o /dev/null -D - "$AGRIS/graphql" \
  -H 'content-type: application/json' \
  --data '{"query":"{ item(id: \"44195\") { name } }"}'

Change the defaults

Every cache time below is a default. Set its variable when you deploy, and your value replaces it. Set a field's fresh time to 0 and answers that include it are never cached. To turn the cache off, setCACHE_ENABLED=false.

Set them again on every deploy. A deploy without them goes back to the defaults.

Change two defaults
# Keep Market.items fresh for 2 min instead of 60 s
export CACHE_MARKET_ITEMS_MAX_AGE_SECONDS=120
# Never cache answers that include order books
export CACHE_MARKET_ITEM_ORDER_BOOK_MAX_AGE_SECONDS=0

moon run agris:deploy
Applies toVariableDefaultShared demo
Every answerCACHE_ENABLEDCachingOnOn
CACHE_STALE_IF_ERROR_SECONDSServed while Pearl Abyss is down1 day1 day
CACHE_MINIMUM_SECONDSShortest time an answer stays cachedOff10 min
Each field has two settings: _MAX_AGE_SECONDS is how long an answer stays fresh, and _STALE_WHILE_REVALIDATE_SECONDS is how long it is still served while a new one loads.
Market.registrationsCACHE_MARKET_REGISTRATIONS_MAX_AGE_SECONDS5 s10 min
CACHE_MARKET_REGISTRATIONS_STALE_WHILE_REVALIDATE_SECONDS5 s10 min
MarketItem.orderBookCACHE_MARKET_ITEM_ORDER_BOOK_MAX_AGE_SECONDS5 s10 min
CACHE_MARKET_ITEM_ORDER_BOOK_STALE_WHILE_REVALIDATE_SECONDS5 s10 min
Market.catalogCACHE_MARKET_CATALOG_MAX_AGE_SECONDS30 s10 min
CACHE_MARKET_CATALOG_STALE_WHILE_REVALIDATE_SECONDS30 s10 min
Market.hotItemsCACHE_MARKET_HOT_ITEMS_MAX_AGE_SECONDS30 s10 min
CACHE_MARKET_HOT_ITEMS_STALE_WHILE_REVALIDATE_SECONDS30 s10 min
Item.marketSummaryCACHE_ITEM_MARKET_SUMMARY_MAX_AGE_SECONDS60 s10 min
CACHE_ITEM_MARKET_SUMMARY_STALE_WHILE_REVALIDATE_SECONDS70 s10 min
Market.itemsCACHE_MARKET_ITEMS_MAX_AGE_SECONDS60 s10 min
CACHE_MARKET_ITEMS_STALE_WHILE_REVALIDATE_SECONDS70 s10 min
MarketItem.listingsCACHE_MARKET_ITEM_LISTINGS_MAX_AGE_SECONDS60 s10 min
CACHE_MARKET_ITEM_LISTINGS_STALE_WHILE_REVALIDATE_SECONDS70 s10 min
MarketItem.priceHistoryCACHE_MARKET_ITEM_PRICE_HISTORY_MAX_AGE_SECONDS1 h1 h
CACHE_MARKET_ITEM_PRICE_HISTORY_STALE_WHILE_REVALIDATE_SECONDS1 h1 h
Query.itemCACHE_QUERY_ITEM_MAX_AGE_SECONDS1 year1 year
CACHE_QUERY_ITEM_STALE_WHILE_REVALIDATE_SECONDS1 day1 day
Query.itemsCACHE_QUERY_ITEMS_MAX_AGE_SECONDS1 year1 year
CACHE_QUERY_ITEMS_STALE_WHILE_REVALIDATE_SECONDS1 day1 day
Query.searchItemsCACHE_QUERY_SEARCH_ITEMS_MAX_AGE_SECONDS1 year1 year
CACHE_QUERY_SEARCH_ITEMS_STALE_WHILE_REVALIDATE_SECONDS1 day1 day

Try a query

Example queries

Each one is checked against the schema and the public limits when this site is built. Run it right here on the shared demo, which takes 10 a minute from each visitor, open it in GraphiQL, or copy it as curl for your own gateway. Item IDs are the game's own numeric IDs.

Prices with names

Three items on the EU market in one request: price, stock and trades from Pearl Abyss, names and grades joined in from the catalog.

Default cache
Fresh for 60 s, then 70 s while refreshing
Shared demo
Fresh for 10 min
Upstream
1 Pearl Abyss call on a miss
Query
query Prices {
  market(region: EU) {
    observedAt
    items(ids: ["44195", "16001", "721003"]) {
      item { id name grade }
      basePrice
      currentStock
      totalTrades
    }
  }
}
Open in GraphiQL

Order book and price history

Buy and sell orders at each price, and the recorded daily prices. Price history alone keeps much longer than the order book, and an answer keeps only as long as its shortest window.

Default cache
Fresh for 5 s, then 5 s while refreshing
Shared demo
Fresh for 10 min
Upstream
3 Pearl Abyss calls on a miss
Query
query OrderBook {
  market(region: EU) {
    items(ids: ["16001"]) {
      item { name }
      orderBook { price sellOrders buyOrders }
      priceHistory { offsetFromLatest price }
    }
  }
}
Open in GraphiQL

What is moving right now

The market's hot list: which way each price moved and by how much.

Default cache
Fresh for 30 s, then 30 s while refreshing
Shared demo
Fresh for 10 min
Upstream
1 Pearl Abyss call on a miss
Query
query HotItems {
  market(region: NA) {
    observedAt
    hotItems {
      direction
      amount
      marketItem { item { id name } basePrice currentStock }
    }
  }
}
Open in GraphiQL

The registration queue

Items waiting to be listed: enhancement level, price and when they go on sale.

Default cache
Fresh for 5 s, then 5 s while refreshing
Shared demo
Fresh for 10 min
Upstream
1 Pearl Abyss call on a miss
Query
query Registrations {
  market(region: EU) {
    registrations {
      item { id name }
      enhancementLevel
      price
      registeredAt
    }
  }
}
Open in GraphiQL

Names in Korean and French

Catalog lookups never leave the catalog Worker: no Pearl Abyss call, and the answer keeps longer than any other.

Default cache
Fresh for 1 year, then 1 day while refreshing
Shared demo
Fresh for 1 year
Upstream
None: answered from memory
Query
query LocalizedNames {
  ko: items(ids: ["44195", "16001", "721003"], locale: KO) { id name grade }
  fr: items(ids: ["44195", "16001", "721003"], locale: FR) { id name }
}
Open in GraphiQL

One item across four regions

Aliases put four markets side by side in one response. Each region is its own summary call; this query uses 4 of the 15 aliases a public query may use.

Default cache
Fresh for 60 s, then 70 s while refreshing
Shared demo
Fresh for 10 min
Upstream
4 Pearl Abyss calls on a miss
Query
query CompareRegions {
  eu: market(region: EU) { items(ids: ["44195"]) { item { name } basePrice currentStock } }
  na: market(region: NA) { items(ids: ["44195"]) { basePrice currentStock } }
  kr: market(region: KR) { items(ids: ["44195"]) { basePrice currentStock } }
  asia: market(region: ASIA) { items(ids: ["44195"]) { basePrice currentStock } }
}
Open in GraphiQL

Deploy your own

Your own gateway in five commands

Self-hosting is the way to use Agris: your own limits, your own cache, your own hostname, and nobody else's traffic. It takes about five minutes.

  1. Install proto

    It brings the pinned Node.js, pnpm and moon. The installer is the pinned release’s own and checks what it downloads; open a new shell afterwards.

    curl -fsSLO https://github.com/moonrepo/proto/releases/download/v0.63.1/proto_cli-installer.sh
    echo "e33cc6e6e1834ac3d769e311415b4a12013ae33fe2b27cdcf34340a62d97c2b6  proto_cli-installer.sh" | sha256sum -c
    PROTO_HOME="$HOME/.proto" PROTO_CLI_NO_MODIFY_PATH=1 sh proto_cli-installer.sh && ~/.proto/bin/proto setup
  2. Get the code

    Clone the repository.

    git clone https://github.com/Hebilicious/agris.git && cd agris
  3. Install

    Toolchain first, then dependencies.

    proto install && pnpm install
  4. Point it at your account

    Your account ID and an “Edit Cloudflare Workers” API token. They stay in your shell, never in the repository.

    export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=...
  5. Deploy

    Alchemy creates the gateway, its cache, the market and the catalog Workers.

    moon run agris:deploy
  6. Check it answers

    Your gateway is live on workers.dev. Open /graphql on it for GraphiQL.

    curl https://agris.<your-subdomain>.workers.dev/health

About the shared demo

The demo is small on purpose

The demo API behind GraphiQL and the examples is shared by everyone who visits, so it runs with tight limits and long cache times. It may be slowed down or switched off at any time. Look around, then deploy your own.

SettingDefaultShared demoTo turn it off
Requests per visitorCLIENT_RATE_LIMIT_REQUESTS60 requests10 requests0
Period of the per-visitor limitCLIENT_RATE_LIMIT_PERIOD_SECONDS60 s60 sIt has no off value
Requests per Cloudflare locationGLOBAL_RATE_LIMIT_REQUESTSOff120 requests0
Period of the per-location limitGLOBAL_RATE_LIMIT_PERIOD_SECONDS60 s60 sIt has no off value
Market queries per visitor, counted exactlyMARKET_CLIENT_LIMIT_REQUESTSOff10 requests0
Period of the exact market limitMARKET_CLIENT_LIMIT_PERIOD_SECONDS60 s60 sIt has no off value
Pearl Abyss calls, per serverPA_RATE_LIMIT_REQUESTS100 calls20 calls0
Period of the Pearl Abyss limitPA_RATE_LIMIT_PERIOD_SECONDS10 s60 sIt has no off value
Pearl Abyss calls at once, per serverPA_MAX_CONCURRENT_REQUESTS6 calls2 calls0
Subrequests per requestSUBREQUEST_LIMIT50 subrequests50 subrequestsIt has no off value
Shortest time an answer stays cachedCACHE_MINIMUM_SECONDSOff10 min0
Query depthQUERY_MAX_DEPTH8 fields8 fields0
Fields per queryQUERY_MAX_FIELDS150 fields150 fields0
Aliases per queryQUERY_MAX_ALIASES15 aliases15 aliases0
Type listings on one pathQUERY_MAX_TYPE_EXPANSIONS2 listings2 listings0