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).