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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user