Initial commit

This commit is contained in:
nholtware 2026-11-05 02:28:09 -06:00
commit 3a8e7184d5
32 changed files with 5218 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
.git
.forgejo

4
.forgejo/template Normal file
View file

@ -0,0 +1,4 @@
package.json
package-lock.json
README.md
index.html

View file

@ -0,0 +1,29 @@
# Full pipeline: checks -> build+push image -> bump tag in gitops repo.
# The heavy lifting lives in nholtware/workflows composite actions; ArgoCD
# takes over from the gitops commit.
on:
push:
branches: [main]
jobs:
checks:
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@main
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@main
deploy:
needs: checks
runs-on: host
steps:
- uses: actions/checkout@v4
- uses: http://192.168.1.230:3000/nholtware/workflows/build-push@main
with:
app-name: ${{ github.event.repository.name }}
registry-user: ${{ secrets.REGISTRY_USER }}
registry-token: ${{ secrets.REGISTRY_TOKEN }}
- uses: http://192.168.1.230:3000/nholtware/workflows/gitops-bump@main
with:
app-name: ${{ github.event.repository.name }}
gitops-token: ${{ secrets.GITOPS_TOKEN }}

View file

@ -0,0 +1,12 @@
# PR checks: lint + test only. No build/deploy -- that's ci.yaml's job on push to main.
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
checks:
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@main
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@main

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
.env

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v22

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
dist
node_modules
package-lock.json

4
.prettierrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"semi": true,
"trailingComma": "all"
}

9
CLAUDE.md Normal file
View file

@ -0,0 +1,9 @@
# Service conventions
- Stack: Vite + React 19 + TypeScript + React Router v8 in data mode (`createBrowserRouter`, SPA, no SSR), Node 22 for build only, vitest + Testing Library, nginx-unprivileged at runtime.
- Do NOT rename npm scripts (`lint`, `test`, `format:check`, `build`): the shared CI composite actions call them.
- Routes: table in `src/routes.tsx`, one component per file in `src/routes/`, loaders exported next to their component.
- Runtime config: read only via `getConfig()` (`src/lib/config.ts`). A new browser-visible env var must be added to `VARS` in `docker-entrypoint.d/10-config.sh`, `AppConfig`, and the gitops Deployment. No secrets: config.js is public.
- Backend calls: `apiGet()` in `src/lib/api.ts`, relative to `API_BASE_URL` (`/api` same-host ingress path, stripped before the backend). No CORS.
- Serving contract: port 8080, `/healthz` 200, SPA fallback, immutable `/assets/`, no-cache `index.html`/`config.js` (`nginx/default.conf`).
- Deploy: push to `main` -> Forgejo CI -> image -> gitops bump -> ArgoCD. Never `kubectl apply` by hand.

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# Build stage: Vite -> static dist/. GIT_SHA is passed by the build-push action.
FROM node:22.23.4-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG GIT_SHA=dev
ENV VITE_GIT_SHA=${GIT_SHA}
RUN npx vite build
# Runtime stage: static files only, no Node. Runs as uid 101, listens on 8080.
FROM nginxinc/nginx-unprivileged:1.31.7-alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --chmod=0755 docker-entrypoint.d/10-config.sh /docker-entrypoint.d/10-config.sh
# html dir owned by nginx so 10-config.sh can write config.js at start.
COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html
EXPOSE 8080

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# hello-web
Frontend service generated from `nholtware/service-template-react`: a Vite +
React 19 + React Router (data mode) SPA, served as static files by
unprivileged nginx.
## Develop
```sh
npm install
npm run dev # http://localhost:5173, /api proxied to API_PROXY_TARGET (default http://localhost:3000)
npm test
npm run lint
npm run format
```
## Runtime config
`/config.js` sets `window.__APP_CONFIG__`. In the container,
`docker-entrypoint.d/10-config.sh` writes it at start from env vars, so one
image runs in every environment. Read values through `getConfig()` in
`src/lib/config.ts`.
| Env var | Default | Meaning |
| -------------- | ------- | ----------------------------------------------- |
| `API_BASE_URL` | `/api` | Backend base URL; `/api` is the same-host route |
| `APP_NAME` | `app` | Display name |
Everything in `config.js` is public. Never put secrets there.
## Container
- `GIT_SHA` build arg is baked in as `import.meta.env.VITE_GIT_SHA`.
- nginx listens on 8080: `/healthz` returns 200, unknown paths fall back to
`index.html`, `/assets/*` are cached as immutable, and `index.html` and
`config.js` are not cached.
## Deploy
Push to `main`: Forgejo CI runs lint/test/format, builds and pushes
`192.168.1.230:3000/nholtware/hello-web:<sha>`, and bumps the tag in the
gitops repo. ArgoCD then syncs it. Manifests come from gitops
`templates/frontend/`. The repo needs Actions secrets `REGISTRY_USER`,
`REGISTRY_TOKEN`, and `GITOPS_TOKEN`.

3
commitlint.config.js Normal file
View file

@ -0,0 +1,3 @@
export default {
extends: ["@commitlint/config-conventional"],
};

View file

@ -0,0 +1,26 @@
#!/bin/sh
# Run by the nginx image's entrypoint before nginx starts. Writes the runtime
# config the SPA reads as window.__APP_CONFIG__, so one image serves every
# environment. To expose a new env var to the browser, add its name to VARS.
# Never put secrets here: everything in config.js is public.
set -eu
VARS="API_BASE_URL APP_NAME"
OUT=/usr/share/nginx/html/config.js
: "${API_BASE_URL:=/api}"
: "${APP_NAME:=app}"
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
body=""
for name in $VARS; do
eval "value=\${$name:-}"
[ -n "$body" ] && body="$body,"
body="$body\"$name\":\"$(json_escape "$value")\""
done
printf 'window.__APP_CONFIG__ = {%s};\n' "$body" > "$OUT"
echo "10-config.sh: wrote $OUT"

10
eslint.config.js Normal file
View file

@ -0,0 +1,10 @@
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["dist/**", "public/**"],
},
...tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
);

14
index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>hello-web</title>
<!-- Runtime config, generated at container start (docker-entrypoint.d/10-config.sh). -->
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
nginx/default.conf Normal file
View file

@ -0,0 +1,32 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
# Liveness/readiness for k8s probes.
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# Runtime config, written at container start; never cache.
location = /config.js {
add_header Cache-Control "no-store" always;
}
# Hashed build assets: cache forever; a missing asset is a real 404.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# SPA fallback: unknown paths get index.html; the router handles them.
location / {
try_files $uri /index.html;
}
}

4718
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "hello-web",
"version": "1.0.0",
"private": true,
"description": "Frontend service generated from nholtware/service-template-react",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint . && tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"engines": {
"node": ">=22"
},
"dependencies": {
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-router": "^8.5.1"
},
"devDependencies": {
"@commitlint/cli": "^21.2.4",
"@commitlint/config-conventional": "^21.2.4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/node": "^22.20.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.2.1",
"eslint": "^10.14.0",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^30.1.3",
"prettier": "^3.9.10",
"typescript": "^6.0.3",
"typescript-eslint": "^8.72.1",
"vite": "^8.3.6",
"vitest": "^5.1.0"
}
}

2
public/config.js Normal file
View file

@ -0,0 +1,2 @@
// Dev-only default. In the container this file is regenerated at start from env vars.
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "dev" };

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;
}

15
tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts", "eslint.config.js"]
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
server: {
// Local dev: proxy /api to a backend (e.g. the service-template-express dev server).
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET ?? "http://localhost:3000",
rewrite: (p) => p.replace(/^\/api/, ""),
},
},
},
test: {
environment: "jsdom",
setupFiles: ["./src/test-setup.ts"],
},
});