Files
spa/.planning/phase-1-foundation/04-runtime-config.md
T
julian 309333b2d3 feat: task 1.4 runtime config endpoint
Three-file config module under src/config/:
- schema.ts: RuntimeConfigSchema (directusUrl, liveWsUrl, businessWsUrl,
  optional googleMapsKey, env enum). Custom UrlOrAbsolutePath validator
  accepts http(s)/ws(s) URLs plus paths starting with /.
- load.ts: loadConfig() fetches /config.json with cache: 'no-store',
  safeParse, throws ConfigValidationError on schema failure.
- context.ts: RuntimeConfigContext + useRuntimeConfig() hook (no JSX).
- provider.tsx: RuntimeConfigProvider — loading / ready / error states.
  Single retry after 500ms on network failure; renders zod issues on
  validation failure for debuggability.

public/config.json: committed dev defaults (relative paths matching the
Vite proxy from 1.3).

src/main.tsx wraps App in <RuntimeConfigProvider>.
README gains a Runtime config section with the schema table.

Deviation: spec sketched provider+hook in one context.tsx. Split into
context.ts (hook only) and provider.tsx (component only) to satisfy
react-refresh/only-export-components without adding an eslint override.
2026-05-02 18:43:08 +02:00

8.6 KiB

Task 1.4 — Runtime config endpoint

Phase: 1 — Foundation Status: Not started Depends on: 1.2 Wiki refs: docs/wiki/entities/react-spa.md §Auth pattern; .planning/ROADMAP.md design rule 7

Goal

Establish the runtime-config pattern: a /config.json file fetched at app boot, validated with zod, made available via a typed React context. Per-environment values (Directus URL, processor WS URL, Google Maps key, feature flags) live there — not in build-time import.meta.env. One built image, many deploy environments.

Deliverables

  • src/config/schema.ts — zod schema:
    export const RuntimeConfigSchema = z.object({
      directusUrl: z.string().url(), // public-facing Directus URL (e.g. https://stage.trmtracking.org/api)
      liveWsUrl: z.string().url(), // public-facing Processor WS URL (wss://stage.trmtracking.org/ws-live)
      businessWsUrl: z.string().url(), // public-facing Directus WS URL (wss://stage.trmtracking.org/ws-business)
      googleMapsKey: z.string().optional(), // BYOK; if absent, Google tile sources are hidden in the UI
      env: z.enum(['dev', 'stage', 'prod']), // for log labels and feature flag conditionals
    });
    export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
    
  • src/config/load.ts:
    • async function loadConfig(): Promise<RuntimeConfig> — fetches /config.json, parses with zod, throws on validation failure.
    • On dev, the file is served directly from public/config.json by Vite. On stage/prod, the proxy serves it (Traefik file-middleware or a tiny nginx rule).
  • src/config/context.tsx:
    • RuntimeConfigProvider — fetches config in a useEffect on mount, holds it in state, passes via context.
    • useRuntimeConfig() hook — throws if used outside the provider, returns the typed config.
    • While loading, render a minimal "Loading…" placeholder (one centred div, no shadcn dependency).
    • On error, render an error state with the validation issues — clear enough that a misconfigured config.json doesn't masquerade as a generic crash.
  • public/config.json — checked-in file with dev defaults:
    {
      "directusUrl": "/api",
      "liveWsUrl": "/ws-live",
      "businessWsUrl": "/ws-business",
      "env": "dev"
    }
    
    Note: relative URLs work because the SPA always runs same-origin (Vite dev proxy or Traefik in stage/prod). Document this in the file's accompanying README.
  • src/main.tsx updated to wrap <App /> in <RuntimeConfigProvider>.
  • README.md updated with a "Runtime config" section explaining: where the file lives in dev vs stage/prod, the schema, how to add a new field, the dev defaults.

Specification

Why runtime config not build-time

Build-time means one bundle per environment. That breaks reproducibility (dev image ≠ stage image ≠ prod image), prevents promoting a known-good build to prod ("ship the stage build to prod after stage soak"), and forces a rebuild for any env-only change (add a feature flag → rebuild → reupload). Runtime config lets one image serve all environments — the proxy or stack overrides the file.

The Directus-side runtime config (DIRECTUS_PUBLIC_URL, etc.) and Processor-side (DIRECTUS_BASE_URL, etc.) follow the same pattern. SPA consistency.

How the file is overridden in stage/prod

Two reasonable options:

Option A: Static file in the SPA's nginx serve config. The Dockerfile (1.9) bakes a default config.json into the image. Override happens at deploy time: a Traefik file-middleware (or a sibling nginx rule) intercepts /config.json and serves a different file from a host volume. Requires zero rebuilds.

Option B: Server-rendered config. Directus could serve /config.json via a custom endpoint that reads env vars. Rebuildable through Directus.

Pick Option A. Simpler, zero new code, deploy-time override is clean. Document the override pattern in trm/deploy/README.md (1.10).

What goes in config vs what doesn't

In config:

  • URLs the SPA calls (Directus, Processor WS, business WS).
  • Optional API keys the operator brings (Google Maps).
  • Environment label for log/feature use.

NOT in config:

  • Anything that varies per-user (the user's role, their subscriptions).
  • Anything operational secret (no JWT signing keys, no DB passwords).
  • Anything that changes during the session (that's state, not config).

Failure modes

  • config.json missing (404): error state with "Runtime config not found at /config.json. Did the deployment skip its config volume?" Don't fall back to defaults silently — that hides misconfiguration.
  • config.json invalid (zod fails): error state listing the validation issues. Useful for catching typos in stage configs.
  • Network failure on first load: retry once after 500ms, then show error. Don't infinite-retry — the user can reload the page if the network heals.

Why a context provider rather than a Zustand store

Auth state needs Zustand (read in many places, granular subscribers). Runtime config is read once and never changes during the session — context is exactly right for that. Don't over-engineer.

Acceptance criteria

  • pnpm dev shows a brief "Loading…" then renders the placeholder content.
  • Manually editing public/config.json to invalid shape and reloading shows the error state with the validation issues, not a blank screen.
  • Manually editing public/config.json to invalidate URL format (e.g. directusUrl: "not-a-url") shows the same error state.
  • useRuntimeConfig() is callable from any descendant of <RuntimeConfigProvider> and returns typed config.
  • Calling useRuntimeConfig() outside the provider throws a clear error (not "cannot read property X of undefined").
  • The default public/config.json validates against the schema.

Risks / open questions

  • Same-origin assumption. The relative URLs (/api, /ws-live) only work if everything is same-origin. If a deploy environment ever wants to point the SPA at a different-origin Directus (e.g. for a partner integration), the config schema accepts absolute URLs — but cookie auth breaks at that point. Document the constraint.
  • Field projection in zod. If we ever add a sensitive field (a per-tenant analytics key), it would be visible to anyone hitting /config.json directly. Don't put anything sensitive there. The schema's RuntimeConfigSchema.optional() discipline helps but doesn't enforce.
  • Hot reload of config in dev. Editing public/config.json requires a hard reload (Vite watches the file but the React Provider only fetches on mount). Acceptable for dev.

Done

Three files under src/config/ plus the dev-defaults JSON:

  • schema.tsRuntimeConfigSchema + inferred RuntimeConfig type. Custom UrlOrAbsolutePath validator accepts http(s)://, ws(s)://, or paths starting with / (so the dev defaults' relative paths validate while still catching obvious typos).
  • load.tsloadConfig() fetches /config.json with cache: 'no-store', safeParses, throws ConfigValidationError on schema failure or a generic Error on network/non-2xx.
  • context.tsRuntimeConfigContext + useRuntimeConfig() hook (split from the provider so the file has no component exports — keeps react-refresh/only-export-components happy without an eslint override).
  • provider.tsxRuntimeConfigProvider. Three states (loading / ready / error). Network failure retries once after 500 ms; a second failure surfaces the error to the operator with a clear "reload or check the deployment" message. Validation failures render the zod issues so misconfigured config.json is debuggable, not a generic crash.
  • public/config.json — committed dev defaults: { directusUrl: '/api', liveWsUrl: '/ws-live', businessWsUrl: '/ws-business', env: 'dev' }. Vite serves it from public/ at /config.json automatically.

src/main.tsx updated to wrap <App /> in <RuntimeConfigProvider>. README "Runtime config" section added with the schema table + override pattern.

Deviation from spec: the spec sketched the provider in src/config/context.tsx exporting both the provider and the hook. That triggers react-refresh/only-export-components because hooks aren't components. Split into context.ts (hook + context object, no JSX) and provider.tsx (component only). Cleaner than adding an eslint override and matches conventional React patterns.

Smoke check: pnpm typecheck, pnpm lint, pnpm format:check, pnpm build all green. Bundle: 283KB raw / 87KB gzipped (up from 222KB / 70KB after zod entered the runtime path). Could not browser-smoke useRuntimeConfig without running pnpm dev interactively — flagged as a manual sanity step before 1.5 lands.

Landed in PENDING_SHA.