fix(dev): proxy api during react development

This commit is contained in:
vince 2026-06-20 00:10:26 +02:00
parent b1d196e4ca
commit b8e39348e8
11 changed files with 196 additions and 8 deletions

View file

@ -49,10 +49,46 @@ export function AppStateView({
);
}
export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
description: string;
title: string;
} {
if (dashboard.state === "ready") {
const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document);
return {
title: uiDashboard.title,
description:
uiDashboard.subtitle ||
dashboard.document.metadata.description ||
uiDashboard.title,
};
}
return {
title: dashboard.title,
description: dashboard.subtitle || dashboard.message,
};
}
export default function App() {
const [dashboard, setDashboard] =
useState<DashboardRuntimeState>(loadingDashboardState);
useEffect(() => {
const metadata = resolveDocumentMetadata(dashboard);
document.title = metadata.title;
let description = document.querySelector<HTMLMetaElement>(
'meta[name="description"]',
);
if (!description) {
description = document.createElement("meta");
description.name = "description";
document.head.append(description);
}
description.content = metadata.description;
}, [dashboard]);
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;

View file

@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./lib/ui/tokens.css";
@import "./lib/ui/components/styles.css";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";

View file

@ -4,7 +4,6 @@ import { ModuleCard } from "./ModuleCard";
import { ServicePanel } from "./ServicePanel";
import { StatusStrip } from "./StatusStrip";
import { TelemetryGrid } from "./TelemetryGrid";
import "./styles.css";
export interface DashboardFrameProps {
dashboard: UiDashboardPreview;

View file

@ -63,6 +63,17 @@ describe("Storybook inventory", () => {
}
});
test("loads dashboard component styles through the global app stylesheet", () => {
const appStyles = readFileSync(join(root, "src/app.css"), "utf8");
const dashboardFrame = readFileSync(
join(componentsDir, "DashboardFrame.tsx"),
"utf8",
);
expect(appStyles).toContain('./lib/ui/components/styles.css');
expect(dashboardFrame).not.toContain('./styles.css');
});
test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = readdirSync(componentsDir)
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))

View file

@ -1,7 +1,7 @@
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
import { AppStateView } from "./App";
import { AppStateView, resolveDocumentMetadata } from "./App";
describe("home page model renderer", () => {
test("renders the active dashboard model from the runtime state", () => {
@ -43,6 +43,30 @@ describe("home page model renderer", () => {
expect(body).toContain("/metadata/title is required");
});
test("derives browser metadata from ready and fallback runtime states", () => {
const ready = resolveDocumentMetadata({
state: "ready",
document: genericDashboardFixture,
schemaVersion: "dashboard.v1",
currentRevisionId: "revision-1234567890",
});
const empty = resolveDocumentMetadata({
state: "empty",
title: "No Dashboard Model",
subtitle: "No active document",
message: "No validated dashboard document is active yet.",
});
expect(ready).toEqual({
title: "Operations Console",
description: "Generic environment",
});
expect(empty).toEqual({
title: "No Dashboard Model",
description: "No active document",
});
});
test("renders empty and loading model states without crashing", () => {
const empty = renderToString(
<AppStateView

25
src/server/dev.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { createDevServerConfig } from "../../vite.config";
describe("local development runtime", () => {
test("starts the Bun API server together with the Vite dev server", () => {
const packageJson = JSON.parse(
readFileSync(join(process.cwd(), "package.json"), "utf8"),
) as { scripts?: Record<string, string> };
expect(packageJson.scripts?.dev).toBe("bun src/server/dev.ts");
});
test("proxies dashboard API requests from Vite to the Bun API server", () => {
const server = createDevServerConfig({
DASHBOARD_DEV_API_TARGET: "http://127.0.0.1:5174",
});
expect(server?.proxy?.["/api"]).toMatchObject({
target: "http://127.0.0.1:5174",
changeOrigin: true,
});
});
});

72
src/server/dev.ts Normal file
View file

@ -0,0 +1,72 @@
const webHost = process.env.HOST || "0.0.0.0";
const webPort = process.env.PORT || "5173";
const apiHost = process.env.DASHBOARD_DEV_API_HOST || "127.0.0.1";
const apiPort = process.env.DASHBOARD_DEV_API_PORT || "5174";
const apiTarget = `http://${apiHost}:${apiPort}`;
if (import.meta.main) {
runDevServers();
}
export function runDevServers(): void {
const children: Array<ReturnType<typeof Bun.spawn>> = [];
let shuttingDown = false;
function spawn(
label: string,
command: string[],
env: Record<string, string> = {},
): void {
const child = Bun.spawn(command, {
env: {
...process.env,
...env,
},
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
children.push(child);
void child.exited.then((code) => {
if (shuttingDown) return;
console.error(`${label} exited with status ${code}`);
shutdown(code || 1);
});
}
function shutdown(code = 0): void {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) {
child.kill();
}
void Promise.allSettled(children.map((child) => child.exited)).then(() => {
process.exit(code);
});
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
spawn("api server", [process.execPath, "src/server/index.ts"], {
HOST: apiHost,
PORT: apiPort,
});
spawn("vite dev server", [
process.execPath,
"x",
"vite",
"--host",
webHost,
"--port",
webPort,
], {
DASHBOARD_DEV_API_TARGET: apiTarget,
});
console.info(`Dashboard API proxy target: ${apiTarget}`);
}