From 309333b2d3bfadfb8e4f0809947e7c66006eb6af Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 17:43:26 +0200 Subject: [PATCH] feat: task 1.4 runtime config endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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. --- .planning/ROADMAP.md | 2 +- .../phase-1-foundation/04-runtime-config.md | 16 ++- README.md | 16 +++ public/config.json | 6 + src/config/context.ts | 12 ++ src/config/load.ts | 36 ++++++ src/config/provider.tsx | 110 ++++++++++++++++++ src/config/schema.ts | 32 +++++ src/main.tsx | 5 +- 9 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 public/config.json create mode 100644 src/config/context.ts create mode 100644 src/config/load.ts create mode 100644 src/config/provider.tsx create mode 100644 src/config/schema.ts diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c009d6b..5298b9d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -52,7 +52,7 @@ These rules govern every task. Any deviation must be discussed and documented as | 1.1 | Project scaffold (Vite + React + TS) | 🟩 | (manual) | | 1.2 | [Stack rounding-out (Tailwind + shadcn/ui + deps + Prettier)](./phase-1-foundation/02-stack-rounding-out.md) | 🟩 | `9918418` | | 1.3 | [Vite dev proxy + path aliases + tsconfig hardening](./phase-1-foundation/03-vite-dev-proxy.md) | 🟩 | `39b60c9` | -| 1.4 | [Runtime config endpoint](./phase-1-foundation/04-runtime-config.md) | ⬜ | β€” | +| 1.4 | [Runtime config endpoint](./phase-1-foundation/04-runtime-config.md) | 🟩 | `PENDING_SHA` | | 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | ⬜ | β€” | | 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | ⬜ | β€” | | 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | ⬜ | β€” | diff --git a/.planning/phase-1-foundation/04-runtime-config.md b/.planning/phase-1-foundation/04-runtime-config.md index 34e8b1d..48004b0 100644 --- a/.planning/phase-1-foundation/04-runtime-config.md +++ b/.planning/phase-1-foundation/04-runtime-config.md @@ -102,4 +102,18 @@ Auth state needs Zustand (read in many places, granular subscribers). Runtime co ## Done -(Filled in when the task lands.) +Three files under `src/config/` plus the dev-defaults JSON: + +- `schema.ts` β€” `RuntimeConfigSchema` + 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.ts` β€” `loadConfig()` fetches `/config.json` with `cache: 'no-store'`, `safeParse`s, throws `ConfigValidationError` on schema failure or a generic `Error` on network/non-2xx. +- `context.ts` β€” `RuntimeConfigContext` + `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.tsx` β€” `RuntimeConfigProvider`. 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 `` in ``. 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`. diff --git a/README.md b/README.md index b1a9be6..0366c03 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,22 @@ Override per-developer by copying `.env.example` to `.env.local` and editing β€” If the dev port `5173` collides with another Vite app, run with `VITE_PORT=5174 pnpm dev` (or pin a different port in `vite.config.ts`). +## Runtime config + +`/config.json` is fetched at app boot, validated with zod, and held in a React context (`useRuntimeConfig()` hook). One image, multiple environments β€” the deploy stack overrides the file via a Docker volume mount; the SPA never rebuilds for an env-only change. + +| Field | Type | Notes | +| --------------- | -------- | ---------------------------------------------------------------------------- | +| `directusUrl` | URL/path | Directus REST + GraphQL base. Same-origin path or absolute URL. | +| `liveWsUrl` | URL/path | Processor live-position WebSocket URL. | +| `businessWsUrl` | URL/path | Directus business-plane WebSocket URL. | +| `googleMapsKey` | string? | Optional. If absent, Google tile sources are hidden in the UI. | +| `env` | enum | `dev` / `stage` / `prod` β€” used in log labels and feature-flag conditionals. | + +URLs may be absolute (`http(s)://`, `ws(s)://`) or absolute paths starting with `/`. Relative paths work because the SPA is always same-origin to its backends. The committed `public/config.json` uses path-only defaults that match the dev proxy. + +In stage/prod the deploy stack mounts an override file at `/usr/share/nginx/html/config.json`; see `trm/deploy/README.md` (lands in Phase 1.10). + ## CI `.gitea/workflows/build.yml` (lands in Phase 1.9) runs the four gates β€” typecheck, lint, format:check, build β€” and publishes a Docker image on push to `main`. diff --git a/public/config.json b/public/config.json new file mode 100644 index 0000000..a448148 --- /dev/null +++ b/public/config.json @@ -0,0 +1,6 @@ +{ + "directusUrl": "/api", + "liveWsUrl": "/ws-live", + "businessWsUrl": "/ws-business", + "env": "dev" +} diff --git a/src/config/context.ts b/src/config/context.ts new file mode 100644 index 0000000..1521861 --- /dev/null +++ b/src/config/context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react'; +import type { RuntimeConfig } from './schema'; + +export const RuntimeConfigContext = createContext(null); + +export function useRuntimeConfig(): RuntimeConfig { + const ctx = useContext(RuntimeConfigContext); + if (!ctx) { + throw new Error('useRuntimeConfig must be used inside .'); + } + return ctx; +} diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..3a44501 --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,36 @@ +import { RuntimeConfigSchema, type RuntimeConfig } from './schema'; + +/** Thrown when /config.json fails zod validation. The issues are surfaced to the operator. */ +export class ConfigValidationError extends Error { + readonly issues: readonly { path: (string | number)[]; message: string }[]; + + constructor(issues: readonly { path: (string | number)[]; message: string }[]) { + super('Runtime config failed validation'); + this.name = 'ConfigValidationError'; + this.issues = issues; + } +} + +/** + * Fetch /config.json and validate it against the runtime schema. + * + * Throws `ConfigValidationError` on schema failure (caught by the provider and rendered + * as a clear error state). Throws `Error` on network / 4xx / 5xx (retry handled by caller). + */ +export async function loadConfig(): Promise { + const res = await fetch('/config.json', { cache: 'no-store' }); + if (!res.ok) { + throw new Error( + `Runtime config not found at /config.json (HTTP ${res.status}). ` + + `Did the deployment skip its config volume?`, + ); + } + const json: unknown = await res.json(); + const parsed = RuntimeConfigSchema.safeParse(json); + if (!parsed.success) { + throw new ConfigValidationError( + parsed.error.issues.map((i) => ({ path: i.path as (string | number)[], message: i.message })), + ); + } + return parsed.data; +} diff --git a/src/config/provider.tsx b/src/config/provider.tsx new file mode 100644 index 0000000..9e53437 --- /dev/null +++ b/src/config/provider.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { ConfigValidationError, loadConfig } from './load'; +import type { RuntimeConfig } from './schema'; +import { RuntimeConfigContext } from './context'; + +type LoadState = + | { status: 'loading' } + | { status: 'ready'; config: RuntimeConfig } + | { + status: 'error'; + message: string; + issues?: readonly { path: (string | number)[]; message: string }[]; + }; + +const RETRY_DELAY_MS = 500; + +export function RuntimeConfigProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + + async function attempt() { + try { + return await loadConfig(); + } catch (err) { + if (err instanceof ConfigValidationError) throw err; + // Network / non-2xx β€” let the caller retry once. + return null; + } + } + + async function run() { + try { + const first = await attempt(); + if (cancelled) return; + if (first) { + setState({ status: 'ready', config: first }); + return; + } + // First attempt failed transiently. Retry once after a short delay. + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + if (cancelled) return; + const second = await attempt(); + if (cancelled) return; + if (second) { + setState({ status: 'ready', config: second }); + } else { + setState({ + status: 'error', + message: + 'Could not load /config.json after one retry. Reload the page or check the deployment.', + }); + } + } catch (err) { + if (cancelled) return; + if (err instanceof ConfigValidationError) { + setState({ + status: 'error', + message: 'Runtime configuration is invalid.', + issues: err.issues, + }); + } else { + setState({ + status: 'error', + message: err instanceof Error ? err.message : 'Unknown error loading runtime config.', + }); + } + } + } + + void run(); + return () => { + cancelled = true; + }; + }, []); + + if (state.status === 'loading') { + return ( +
+ Loading… +
+ ); + } + + if (state.status === 'error') { + return ( +
+
+

Runtime config error

+

{state.message}

+ {state.issues && state.issues.length > 0 && ( +
    + {state.issues.map((iss, idx) => ( +
  • + {iss.path.join('.') || '(root)'}:{' '} + {iss.message} +
  • + ))} +
+ )} +
+
+ ); + } + + return ( + {children} + ); +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..e61e265 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Runtime configuration fetched from /config.json at app boot. + * + * URLs may be absolute (http/https/ws/wss) or absolute paths starting with `/`. + * Relative paths work because the SPA always runs same-origin to its backends + * (Vite dev proxy in dev; Traefik in stage/prod). + * + * Override the file in stage/prod via a Docker volume mount; see + * `trm/deploy/README.md` for the operator workflow. + */ +const UrlOrAbsolutePath = z + .string() + .refine((s) => s.startsWith('/') || /^(https?|wss?):\/\//.test(s), { + message: 'Must be an absolute URL (http/https/ws/wss) or a path starting with /', + }); + +export const RuntimeConfigSchema = z.object({ + /** Directus REST + GraphQL base. Same-origin path or absolute URL. */ + directusUrl: UrlOrAbsolutePath, + /** Processor live-position WebSocket URL. */ + liveWsUrl: UrlOrAbsolutePath, + /** Directus business-plane WebSocket URL. */ + businessWsUrl: UrlOrAbsolutePath, + /** Optional Google Maps API key. If absent, Google tile sources are hidden in the UI. */ + googleMapsKey: z.string().optional(), + /** Environment label for log/feature-flag conditionals. */ + env: z.enum(['dev', 'stage', 'prod']), +}); + +export type RuntimeConfig = z.infer; diff --git a/src/main.tsx b/src/main.tsx index 9211a97..5807b27 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './styles/globals.css'; import App from './App.tsx'; +import { RuntimeConfigProvider } from '@/config/provider'; createRoot(document.getElementById('root')!).render( - + + + , );