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
+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>;
+4 -1
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>
<App />
<RuntimeConfigProvider>
<App />
</RuntimeConfigProvider>
</StrictMode>,
);