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:
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"directusUrl": "/api",
|
||||
"liveWsUrl": "/ws-live",
|
||||
"businessWsUrl": "/ws-business",
|
||||
"env": "dev"
|
||||
}
|
||||
@@ -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