ADR-0024: How the web Worker reaches the API
ADR-0024: How the web Worker reaches the API
- Status: Accepted
- Date: 2026-08-06
- Supersedes: —
- Superseded by: —
Context
WI-061 built the public site as Astro SSR on Workers, and ADR-0009 is the reason it exists: a game page must render on the server, because organic search is the only free acquisition channel this project has.
The page reads the catalogue over HTTP, from apps/web/src/lib/api.ts:
const DEFAULT_API = "https://tabletop-tracker-api.tabletop-tracker.workers.dev";const response = await fetch(`${base}/games/${encodeURIComponent(slug)}`);That works from a laptop, from CI, and from wrangler dev --local. It does not work from the deployed Worker. Cloudflare refuses a Worker-to-Worker subrequest within the same workers.dev zone, and answers with a 404 whose body is:
error code: 1042fetchGame treats 404 as “no such game” — correctly, for a real 404 — returns null, and the page renders its 404. There is no exception, nothing in the Worker’s tail, and nothing in the API’s, because the request never reaches the API.
Observed on the real edge with wrangler dev --remote, running the deployed artefact unmodified apart from one log line:
DIAG2 target=https://tabletop-tracker-api.tabletop-tracker.workers.dev/games/catan status=404 ct=text/plain server=cloudflare body=error code: 1042And confirmed from the other side: tailing tabletop-tracker-api while loading the web game page records zero events, while a direct curl to the same URL records one.
This is not fixable by naming. Every Worker on the account shares one *.workers.dev subdomain, so apps/web and apps/api are always in the same zone.
Two things follow. First, every game page has 404’d since the site first deployed successfully — 2026-08-05 16:15 UTC. It is newer than it looks: the four Deploy web runs before that never uploaded at all, failing on the KV token permission WI-062 then removed the need for. No workflow has yet reported this fault. The first run to get past upload failed one assertion earlier, on the home page not having propagated within the smoke test’s window; the game-page assertion has never been reached. Second, this is independent of the API’s own 500 on /games/:slug (WI-063, #117) — that one is an unapplied migration. Both must be fixed before a game page renders, and fixing either alone changes nothing a visitor can see.
Decision
Give apps/web a service binding to the API Worker, and call the API through the binding when it is present, falling back to fetch over PUBLIC_API_URL when it is not.
The seam already exists. apiBase() takes an override and fetchGame(slug, base) takes a base, so dev, vitest and CI keep using a URL; only the deployed Worker takes the binding path.
The binding has to be added to the config @astrojs/cloudflare generates, which is post-processed already — apps/web/scripts/strip-session-binding.mjs runs after the build for the same class of reason (WI-062).
Accepted on 2026-08-06 and implemented in WI-064.
Rationale
A service binding is the mechanism Cloudflare provides for exactly this, and it is the only option that costs nothing and needs nobody. It removes a public hop rather than routing around one: the call never leaves Cloudflare’s network, so it is faster, it is not rate-limited as a public request, and the API does not have to be publicly reachable for the site to work.
It is also the smallest diff. The alternatives either need a domain purchase or duplicate the query layer.
Consequences
- Local development gains a second process.
Corrected 2026-08-06 (WI-066): that was wrong, and it was asserted as measured.astro devandvitesthave no bindings and keep reading overPUBLIC_API_URL, unchanged.astro devdoes resolvecloudflare:workers, and the service binding it provides points at a Worker that is not running — it answers 503, so every catalogue page 500’d underastro dev. Reaching for the binding is now gated onimport.meta.env.PROD.vitestwas correct: it has no binding unless a test provides one.wrangler devagainst the built config does have the binding, and a game page fails there unless the API’s ownwrangler devis running too — with both up, wrangler reportsenv.API (tabletop-tracker-api) Worker local [connected]and the page renders. Measured, not assumed. - What runs in production is no longer what runs by default in development, which is the honest cost here.
wrangler dev --remoteis what exercises the real thing short of a deploy, and theDeploy websmoke test stays what decides. - Two code paths to the catalogue, and the one that runs in production is the one that runs least often in development. That is a real cost and the reason to keep the seam as thin as possible: choose the transport, not the logic.
- Deploy ordering starts to matter. A service binding to a Worker that does not exist fails at deploy time, so
tabletop-tracker-apimust be deployed beforetabletop-webon a from-scratch account. apps/webbecomes coupled to the API Worker’s name, not its URL. Renaming the API Worker now breaks the web deploy.Astro.locals.runtime.envwas removed in Astro v6, so the binding is read withimport { env } from "cloudflare:workers".- The API stays publicly reachable regardless, because
apps/mobileneeds it. This removes a hop for the web app only.
Alternatives considered
A custom domain for the API. Put the API on a domain this project owns and the same fetch starts working, from Workers and from anywhere else. It is the option that changes the least code — none — and it would also give the mobile app a stable URL that is not tied to a Cloudflare subdomain.
It lost on cost and on boundary: buying a domain is money and an external account, which is human-gated work (agent-boundaries, see WI-H04), and it would block a broken production site on a purchase. It also keeps the call going out to the public internet and back, which is slower and counts against the API’s public rate limit for no benefit. Worth revisiting when a domain is bought for other reasons — at which point this ADR should be superseded rather than quietly ignored.
Read the database directly from the web Worker, via its own Hyperdrive binding, and skip the API. This is the fastest option and removes a network hop entirely.
It lost because it duplicates the query layer. The detail route is not a select * — it joins game_field_provenance and computes required credits (ADR-0016), and attribution is a licensing obligation, not a display nicety. Two implementations of that is two places for it to be got wrong, and the second one would be in the app that renders the pictures. It also gives a second Worker database credentials and doubles the Hyperdrive connection pool for one read.