Skip to content

v0.54.1 — Hotfix: 5e feats and spells returned HTML in production

The 5e sheet's SRD pickers were empty in production. GET /api/modules/dnd5e/feats?edition=2014 answered with the SPA's index.html and a 200, so the module's res.json() died on Unexpected token '<' — an error that names nothing about the actual problem.

What was wrong

In production the SPA and the API are different origins: the frontend is an S3/CloudFront distribution (map.purplecat.games) and the API is its own service (api.purplecat.games). A relative /api/... fetch never reaches the API; CloudFront matches nothing, falls back to the SPA entry point, and returns HTML with a 200.

The dnd5e module fetches its own content routes relatively, because that is the only way it can address them — it is a separate package with no access to the host's config:

// deployed dnd5e chunk
let De = { baseUrl: "", fetchFn: (e, n) => globalThis.fetch(e, n) };
const l = `${De.baseUrl}/api/modules/dnd5e/${n}${Sn(e, a)}`;

That deployed chunk contains no occurrence of api.purplecat.games at all, while the platform's own bundle has it baked in — the platform's InsertMonster, which calls the very same module route through API_BASE_URL, kept working throughout.

Locally none of this shows, because the Vite dev server proxies /api onto the one origin the page was loaded from. The bug only exists once the origins split.

This is the second time. genai hit it in v0.21.0, and the fix then was resolveUrl inside useAuthFetch — correct, but it only protects callers that go through that hook. A module holding the global fetch never does.

Only dnd5e was affected. system8's client chunk makes no /api calls at all; its feats and powers travel through the platform's dataTableApi, which carries baseUrl.

The fix

resolveApiUrl(url, baseUrl) in packages/shared/src/utils/apiUrl.ts is now the single implementation of the rule: prefix a relative /api path with the API origin, leave absolute URLs, non-/api paths and protocol-relative URLs alone, and stay idempotent so layered resolution cannot double-prefix.

installModuleApiFetch() applies it at the boundary in apps/web, wrapping the global fetch at startup so every relative platform API path is resolved regardless of who issues it. Strings only: a URL is absolute by construction, and rebuilding a Request to change its URL risks dropping body/stream state.

This closes the class, but it is deliberately defense in depth, not the whole fix. A module should resolve its own API URLs rather than depend on the host patching a global; v0.55.0 adds moduleRegistry.setApiBaseUrl / resolveApiUrl for exactly that, and moves dnd5e onto it, so this patch stops being load-bearing.

Verified

Diagnosis confirmed against the deployed artifacts, not just the source: the live index-Ct28dvC3.js module chunk was downloaded and inspected for its baseUrl default, and dnd5e/v0.7.0 was confirmed published (04:27 UTC) ahead of the frontend deploy (04:39 UTC) — so the module in production was the intended one, and the version pinned in modules.hosted.json was never the problem.

pnpm exec eslint packages --quiet exits 0; pnpm test green. 16 new tests across apiUrl.test.ts (the rule, in packages/shared where CI runs it) and installModuleApiFetch.test.ts (the wrapper and its startup wiring).