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.
This commit is contained in:
2026-05-02 17:43:26 +02:00
parent 90c35e0ad5
commit 309333b2d3
9 changed files with 232 additions and 3 deletions
+1 -1
View File
@@ -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) | ⬜ | — |
@@ -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 `<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`.
+16
View File
@@ -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`.
+6
View File
@@ -0,0 +1,6 @@
{
"directusUrl": "/api",
"liveWsUrl": "/ws-live",
"businessWsUrl": "/ws-business",
"env": "dev"
}
+12
View File
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import type { RuntimeConfig } from './schema';
export const RuntimeConfigContext = createContext<RuntimeConfig | null>(null);
export function useRuntimeConfig(): RuntimeConfig {
const ctx = useContext(RuntimeConfigContext);
if (!ctx) {
throw new Error('useRuntimeConfig must be used inside <RuntimeConfigProvider>.');
}
return ctx;
}
+36
View File
@@ -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<RuntimeConfig> {
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;
}
+110
View File
@@ -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<LoadState>({ 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 (
<div className="min-h-screen grid place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (state.status === 'error') {
return (
<div className="min-h-screen grid place-items-center p-6">
<div className="max-w-lg space-y-3">
<h1 className="text-lg font-semibold text-destructive">Runtime config error</h1>
<p className="text-sm">{state.message}</p>
{state.issues && state.issues.length > 0 && (
<ul className="text-sm font-mono bg-muted rounded p-3 space-y-1">
{state.issues.map((iss, idx) => (
<li key={idx}>
<span className="text-muted-foreground">{iss.path.join('.') || '(root)'}:</span>{' '}
{iss.message}
</li>
))}
</ul>
)}
</div>
</div>
);
}
return (
<RuntimeConfigContext.Provider value={state.config}>{children}</RuntimeConfigContext.Provider>
);
}
+32
View File
@@ -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<typeof RuntimeConfigSchema>;
+3
View File
@@ -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(
<StrictMode>
<RuntimeConfigProvider>
<App />
</RuntimeConfigProvider>
</StrictMode>,
);