feat: initial Vite/React frontend service template

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 02:27:23 -06:00
commit 9fd6fd1d26
32 changed files with 5218 additions and 0 deletions

57
src/app.test.tsx Normal file
View file

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { routes } from "./routes";
function renderAt(path: string) {
const router = createMemoryRouter(routes, { initialEntries: [path] });
return render(<RouterProvider router={router} />);
}
beforeEach(() => {
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "test-app" };
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("app", () => {
it("home shows the app name from runtime config and the build sha", async () => {
renderAt("/");
expect(
await screen.findByRole("heading", { name: "test-app" }),
).toBeInTheDocument();
expect(screen.getByTestId("build-sha")).toHaveTextContent("dev");
});
it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => {
const fetchMock = vi.fn(async (url: string) => {
const body =
url === "/api/readyz" ? { status: "ready" } : { service: "svc" };
return new Response(JSON.stringify(body), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
renderAt("/status");
expect(await screen.findByText(/GET \/readyz/)).toBeInTheDocument();
expect(screen.getByText(/"status": "ready"/)).toBeInTheDocument();
expect(screen.getByText(/"service": "svc"/)).toBeInTheDocument();
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
"/api/",
"/api/readyz",
]);
});
it("status shows the error page when the backend is unreachable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network down");
}),
);
renderAt("/status");
expect(await screen.findByRole("alert")).toHaveTextContent("network down");
});
});

23
src/lib/api.ts Normal file
View file

@ -0,0 +1,23 @@
import { getConfig } from "./config";
export interface ApiResult {
path: string;
status: number;
body: unknown;
}
// Fetch a backend path through the same-host /api ingress route.
export async function apiGet(path: string): Promise<ApiResult> {
const base = getConfig().API_BASE_URL.replace(/\/$/, "");
const res = await fetch(`${base}${path}`, {
headers: { Accept: "application/json" },
});
const text = await res.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
// non-JSON response; keep the raw text
}
return { path, status: res.status, body };
}

20
src/lib/config.ts Normal file
View file

@ -0,0 +1,20 @@
export interface AppConfig {
API_BASE_URL: string;
APP_NAME: string;
}
declare global {
interface Window {
__APP_CONFIG__?: Partial<AppConfig>;
}
}
const defaults: AppConfig = { API_BASE_URL: "/api", APP_NAME: "app" };
// Runtime config comes from /config.js (window.__APP_CONFIG__), so one image
// serves every environment. Read it at call time so tests can override it.
export function getConfig(): AppConfig {
return { ...defaults, ...(window.__APP_CONFIG__ ?? {}) };
}
export const buildSha: string = import.meta.env.VITE_GIT_SHA ?? "dev";

12
src/main.tsx Normal file
View file

@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { routes } from "./routes";
const router = createBrowserRouter(routes);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

18
src/routes.tsx Normal file
View file

@ -0,0 +1,18 @@
import type { RouteObject } from "react-router";
import { ErrorPage } from "./routes/ErrorPage";
import { Home } from "./routes/Home";
import { Layout } from "./routes/Layout";
import { Status, statusLoader } from "./routes/Status";
// Data-mode route table. Add pages here; loaders live next to their component.
export const routes: RouteObject[] = [
{
path: "/",
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
{ path: "status", element: <Status />, loader: statusLoader },
],
},
];

16
src/routes/ErrorPage.tsx Normal file
View file

@ -0,0 +1,16 @@
import { isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorPage() {
const error = useRouteError();
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: String(error);
return (
<section role="alert">
<h1>Something went wrong</h1>
<p>{message}</p>
</section>
);
}

12
src/routes/Home.tsx Normal file
View file

@ -0,0 +1,12 @@
import { buildSha, getConfig } from "../lib/config";
export function Home() {
return (
<section>
<h1>{getConfig().APP_NAME}</h1>
<p>
Build: <code data-testid="build-sha">{buildSha}</code>
</p>
</section>
);
}

19
src/routes/Layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import { NavLink, Outlet } from "react-router";
import { getConfig } from "../lib/config";
export function Layout() {
return (
<>
<header>
<strong>{getConfig().APP_NAME}</strong>
<nav>
<NavLink to="/">Home</NavLink> |{" "}
<NavLink to="/status">Status</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
</>
);
}

23
src/routes/Status.tsx Normal file
View file

@ -0,0 +1,23 @@
import { useLoaderData } from "react-router";
import { apiGet, type ApiResult } from "../lib/api";
export async function statusLoader(): Promise<ApiResult[]> {
return Promise.all([apiGet("/"), apiGet("/readyz")]);
}
export function Status() {
const results = useLoaderData<typeof statusLoader>();
return (
<section>
<h1>Backend status</h1>
{results.map((r) => (
<article key={r.path}>
<h2>
GET {r.path} &rarr; {r.status}
</h2>
<pre>{JSON.stringify(r.body, null, 2)}</pre>
</article>
))}
</section>
);
}

1
src/test-setup.ts Normal file
View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

5
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GIT_SHA?: string;
}