fix(dev): proxy api during react development
This commit is contained in:
parent
b1d196e4ca
commit
b8e39348e8
11 changed files with 196 additions and 8 deletions
11
README.md
11
README.md
|
|
@ -18,7 +18,7 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `bun run dev`: start the local development server.
|
- `bun run dev`: start the local Vite development server with a Bun API proxy.
|
||||||
- `bun run check`: run TypeScript checks.
|
- `bun run check`: run TypeScript checks.
|
||||||
- `bun run test`: run Vitest.
|
- `bun run test`: run Vitest.
|
||||||
- `bun run test:unit`: run Vitest explicitly as the unit test stage.
|
- `bun run test:unit`: run Vitest explicitly as the unit test stage.
|
||||||
|
|
@ -63,10 +63,11 @@ can replace them without changing presentation components.
|
||||||
|
|
||||||
## Runtime Shape
|
## Runtime Shape
|
||||||
|
|
||||||
The browser app is built with Vite and React. Production uses a small Bun HTTP
|
The browser app is built with Vite and React. Local development starts Vite for
|
||||||
server at `build/index.js` to serve the Vite `dist/` assets and JSON API routes.
|
HMR and a loopback Bun API server for `/api/*` routes. Production uses a small
|
||||||
The current persistence runtime is Bun because the MVP SQLite driver is
|
Bun HTTP server at `build/index.js` to serve the Vite `dist/` assets and JSON
|
||||||
`bun:sqlite`.
|
API routes. The current persistence runtime is Bun because the MVP SQLite
|
||||||
|
driver is `bun:sqlite`.
|
||||||
|
|
||||||
## Storybook
|
## Storybook
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Dimension Lab dashboard runtime" />
|
||||||
<title>Dimension Lab</title>
|
<title>Dimension Lab</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "bun src/server/dev.ts",
|
||||||
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
|
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
|
||||||
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
|
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
|
||||||
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
|
||||||
|
|
|
||||||
36
src/App.tsx
36
src/App.tsx
|
|
@ -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() {
|
export default function App() {
|
||||||
const [dashboard, setDashboard] =
|
const [dashboard, setDashboard] =
|
||||||
useState<DashboardRuntimeState>(loadingDashboardState);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./lib/ui/tokens.css";
|
@import "./lib/ui/tokens.css";
|
||||||
|
@import "./lib/ui/components/styles.css";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/geist";
|
@import "@fontsource-variable/geist";
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { ModuleCard } from "./ModuleCard";
|
||||||
import { ServicePanel } from "./ServicePanel";
|
import { ServicePanel } from "./ServicePanel";
|
||||||
import { StatusStrip } from "./StatusStrip";
|
import { StatusStrip } from "./StatusStrip";
|
||||||
import { TelemetryGrid } from "./TelemetryGrid";
|
import { TelemetryGrid } from "./TelemetryGrid";
|
||||||
import "./styles.css";
|
|
||||||
|
|
||||||
export interface DashboardFrameProps {
|
export interface DashboardFrameProps {
|
||||||
dashboard: UiDashboardPreview;
|
dashboard: UiDashboardPreview;
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
test("keeps component and story files paired as the UI inventory changes", () => {
|
||||||
const componentStoryFiles = readdirSync(componentsDir)
|
const componentStoryFiles = readdirSync(componentsDir)
|
||||||
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))
|
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { renderToString } from "react-dom/server";
|
import { renderToString } from "react-dom/server";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||||
import { AppStateView } from "./App";
|
import { AppStateView, resolveDocumentMetadata } from "./App";
|
||||||
|
|
||||||
describe("home page model renderer", () => {
|
describe("home page model renderer", () => {
|
||||||
test("renders the active dashboard model from the runtime state", () => {
|
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");
|
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", () => {
|
test("renders empty and loading model states without crashing", () => {
|
||||||
const empty = renderToString(
|
const empty = renderToString(
|
||||||
<AppStateView
|
<AppStateView
|
||||||
|
|
|
||||||
25
src/server/dev.test.ts
Normal file
25
src/server/dev.test.ts
Normal 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
72
src/server/dev.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,23 @@ import { fileURLToPath } from "node:url";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig, configDefaults } from "vitest/config";
|
import { defineConfig, configDefaults } from "vitest/config";
|
||||||
|
import type { UserConfig } from "vite";
|
||||||
|
|
||||||
|
export function createDevServerConfig(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): UserConfig["server"] {
|
||||||
|
const apiTarget = env.DASHBOARD_DEV_API_TARGET;
|
||||||
|
if (!apiTarget) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
@ -10,6 +27,7 @@ export default defineConfig({
|
||||||
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
|
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: createDevServerConfig(),
|
||||||
test: {
|
test: {
|
||||||
exclude: [...configDefaults.exclude, "tests/e2e/**"],
|
exclude: [...configDefaults.exclude, "tests/e2e/**"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue