generated from nholtware/service-template-react
Initial commit
This commit is contained in:
commit
69da1d8f34
34 changed files with 5531 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.forgejo
|
||||||
4
.forgejo/template
Normal file
4
.forgejo/template
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
README.md
|
||||||
|
index.html
|
||||||
29
.forgejo/workflows/ci.yaml
Normal file
29
.forgejo/workflows/ci.yaml
Normal 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@v1
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@v1
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: checks
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/build-push@v1
|
||||||
|
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@v1
|
||||||
|
with:
|
||||||
|
app-name: ${{ github.event.repository.name }}
|
||||||
|
gitops-token: ${{ secrets.GITOPS_TOKEN }}
|
||||||
12
.forgejo/workflows/pr.yaml
Normal file
12
.forgejo/workflows/pr.yaml
Normal 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@v1
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@v1
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v22
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
11
CLAUDE.md
Normal file
11
CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# 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.
|
||||||
|
- Feature flags: `useFlag`/`useVariant` from `src/lib/flags.tsx` only (Unleash Frontend API via `@unleash/proxy-client-react`); never import the SDK elsewhere. Empty `UNLEASH_URL` = all flags off.
|
||||||
|
- Backend calls: `apiGet()` in `src/lib/api.ts`, relative to `API_BASE_URL` (`/api` same-host ingress path, stripped before the backend). No CORS.
|
||||||
|
- Logging: nginx access log is JSON in the platform logging contract (`log_format contract` in `nginx/default.conf`; `$app_name`/`$git_sha` from `conf.d/service-vars.conf` written by `10-config.sh`). Keep field names identical to the backend template's pino-http lines.
|
||||||
|
- 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.
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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 ./
|
||||||
|
# Fail fast and retry on a stalled registry fetch (npm defaults can hang ~10 min).
|
||||||
|
RUN npm ci --fetch-timeout=60000 --fetch-retries=5 --fetch-retry-mintimeout=2000 --no-audit --no-fund
|
||||||
|
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
|
||||||
|
# Logged as "version" in every access-log line.
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
ENV GIT_SHA=${GIT_SHA}
|
||||||
|
EXPOSE 8080
|
||||||
73
README.md
Normal file
73
README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# adventure-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 |
|
||||||
|
| `UNLEASH_URL` | (empty) | Unleash base URL for the browser; empty = flags off |
|
||||||
|
| `UNLEASH_FRONTEND_TOKEN` | (empty) | Unleash frontend token (public by design) |
|
||||||
|
|
||||||
|
Everything in `config.js` is public. Never put secrets there.
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
Flags come from Unleash (UI: http://unleash.192.168.1.83.nip.io, environment `development`)
|
||||||
|
through its Frontend API, using the official `@unleash/proxy-client-react` SDK.
|
||||||
|
`src/lib/flags.tsx` wraps it: `FlagsProvider` is mounted once in `main.tsx`;
|
||||||
|
components call the wrappers, never the SDK directly:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFlag, useVariant } from "../lib/flags";
|
||||||
|
const showBanner = useFlag("hello-banner");
|
||||||
|
```
|
||||||
|
|
||||||
|
- In the cluster the deployment sets `UNLEASH_URL=http://unleash.192.168.1.83.nip.io`
|
||||||
|
and `UNLEASH_FRONTEND_TOKEN` from Secret `unleash-client-token` (key `frontendToken`).
|
||||||
|
- The SDK fetches on load and refreshes every 15 s; components re-render on change.
|
||||||
|
A reload always picks up the current state.
|
||||||
|
- With `UNLEASH_URL` unset (local `npm run dev`, tests) every flag reads `false`, nothing is fetched.
|
||||||
|
- Tests: `vi.mock("@unleash/proxy-client-react")` and control `useFlag` (see `src/app.test.tsx`).
|
||||||
|
- `hello-banner` on the home page is the platform demo flag; delete it when you don't need it.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Access log: one JSON line per request in the platform logging contract
|
||||||
|
(`level`, `time`, `service` = `APP_NAME`, `version` = `GIT_SHA`, `req.id`
|
||||||
|
(echoes `X-Request-Id`), `req.method`, `req.url`, `res.statusCode`,
|
||||||
|
`responseTime` in ms, `msg: "request completed"`), the same shape as the backend
|
||||||
|
template. In Kibana (http://kibana.192.168.1.83.nip.io, data view `kube-*`):
|
||||||
|
`service:"adventure-web" and res.statusCode >= 400`. `/healthz` isn't logged.
|
||||||
|
nginx's own startup/error lines stay plain text (field `log`).
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Push to `main`: Forgejo CI runs lint/test/format, builds and pushes
|
||||||
|
`192.168.1.230:3000/nholtware/adventure-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
3
commitlint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
};
|
||||||
34
docker-entrypoint.d/10-config.sh
Executable file
34
docker-entrypoint.d/10-config.sh
Executable file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/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 UNLEASH_URL UNLEASH_FRONTEND_TOKEN"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# nginx variables for the JSON access log (nginx/default.conf). Restricted
|
||||||
|
# charset: these land inside nginx string literals.
|
||||||
|
safe() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._-'; }
|
||||||
|
cat > /etc/nginx/conf.d/service-vars.conf <<NGINX
|
||||||
|
map \$host \$app_name { default "$(safe "$APP_NAME")"; }
|
||||||
|
map \$host \$git_sha { default "$(safe "${GIT_SHA:-dev}")"; }
|
||||||
|
NGINX
|
||||||
|
echo "10-config.sh: wrote $OUT and service-vars.conf"
|
||||||
10
eslint.config.js
Normal file
10
eslint.config.js
Normal 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
14
index.html
Normal 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>adventure-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>
|
||||||
58
nginx/default.conf
Normal file
58
nginx/default.conf
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Access log in the platform logging contract (same shape as the backend
|
||||||
|
# template's pino-http lines), so Fluent Bit turns every key into a Kibana
|
||||||
|
# field. $app_name / $git_sha come from conf.d/service-vars.conf, written at
|
||||||
|
# container start by docker-entrypoint.d/10-config.sh.
|
||||||
|
map $status $log_level {
|
||||||
|
~^5 error;
|
||||||
|
default info;
|
||||||
|
}
|
||||||
|
# Echo X-Request-Id if the caller sent one, else nginx's own id.
|
||||||
|
map $http_x_request_id $req_id {
|
||||||
|
"" $request_id;
|
||||||
|
default $http_x_request_id;
|
||||||
|
}
|
||||||
|
# $request_time is seconds ("0.002"); the contract's responseTime is integer ms.
|
||||||
|
map $request_time $response_ms {
|
||||||
|
"~^0\.0*(?<ms>\d+)$" $ms;
|
||||||
|
"~^(?<s>[1-9]\d*)\.(?<ms>\d{3})$" $s$ms;
|
||||||
|
default 0;
|
||||||
|
}
|
||||||
|
log_format contract escape=json
|
||||||
|
'{"level":"$log_level","time":"$time_iso8601","service":"$app_name",'
|
||||||
|
'"version":"$git_sha","req":{"id":"$req_id","method":"$request_method",'
|
||||||
|
'"url":"$request_uri"},"res":{"statusCode":$status},'
|
||||||
|
'"responseTime":$response_ms,"msg":"request completed"}';
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
access_log /dev/stdout contract;
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4760
package-lock.json
generated
Normal file
4760
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
package.json
Normal file
45
package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"name": "adventure-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": {
|
||||||
|
"@unleash/proxy-client-react": "^6.0.1",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"react-router": "^8.5.1",
|
||||||
|
"unleash-proxy-client": "^3.8.2"
|
||||||
|
},
|
||||||
|
"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
2
public/config.js
Normal 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" };
|
||||||
80
src/app.test.tsx
Normal file
80
src/app.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Flags come from the Unleash SDK; tests control them directly.
|
||||||
|
const flagsOn = vi.hoisted(() => new Set<string>());
|
||||||
|
vi.mock("@unleash/proxy-client-react", () => ({
|
||||||
|
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useFlag: (name: string) => flagsOn.has(name),
|
||||||
|
useVariant: () => ({ name: "disabled", enabled: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
||||||
|
return render(<RouterProvider router={router} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
flagsOn.clear();
|
||||||
|
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("home hides the banner while hello-banner is off", async () => {
|
||||||
|
renderAt("/");
|
||||||
|
await screen.findByRole("heading", { name: "test-app" });
|
||||||
|
expect(screen.queryByTestId("banner")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("home shows the banner while hello-banner is on", async () => {
|
||||||
|
flagsOn.add("hello-banner");
|
||||||
|
renderAt("/");
|
||||||
|
expect(await screen.findByTestId("banner")).toHaveTextContent(
|
||||||
|
"hello-banner flag is on",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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
23
src/lib/api.ts
Normal 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 };
|
||||||
|
}
|
||||||
29
src/lib/config.ts
Normal file
29
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
export interface AppConfig {
|
||||||
|
API_BASE_URL: string;
|
||||||
|
APP_NAME: string;
|
||||||
|
/** Unleash base URL as seen by the browser (empty = flags off). */
|
||||||
|
UNLEASH_URL: string;
|
||||||
|
/** Unleash frontend token: public by design (read-only, one environment). */
|
||||||
|
UNLEASH_FRONTEND_TOKEN: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__APP_CONFIG__?: Partial<AppConfig>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: AppConfig = {
|
||||||
|
API_BASE_URL: "/api",
|
||||||
|
APP_NAME: "app",
|
||||||
|
UNLEASH_URL: "",
|
||||||
|
UNLEASH_FRONTEND_TOKEN: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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";
|
||||||
93
src/lib/flags.test.tsx
Normal file
93
src/lib/flags.test.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock the Unleash React SDK: capture provider props, control flag state.
|
||||||
|
const sdk = vi.hoisted(() => ({
|
||||||
|
on: new Set<string>(),
|
||||||
|
providerProps: [] as Record<string, unknown>[],
|
||||||
|
}));
|
||||||
|
vi.mock("@unleash/proxy-client-react", () => ({
|
||||||
|
FlagProvider: (props: { children: React.ReactNode }) => {
|
||||||
|
sdk.providerProps.push(props);
|
||||||
|
return props.children;
|
||||||
|
},
|
||||||
|
useFlag: (name: string) => sdk.on.has(name),
|
||||||
|
useVariant: () => ({ name: "disabled", enabled: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { FlagsProvider, unleashConfig, useFlag } from "./flags";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
return <p>{useFlag("hello-banner") ? "on" : "off"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sdk.on.clear();
|
||||||
|
sdk.providerProps.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unleashConfig", () => {
|
||||||
|
const base = { API_BASE_URL: "/api", APP_NAME: "web" };
|
||||||
|
it("is null when UNLEASH_URL or token is unset", () => {
|
||||||
|
expect(
|
||||||
|
unleashConfig({ ...base, UNLEASH_URL: "", UNLEASH_FRONTEND_TOKEN: "" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
unleashConfig({
|
||||||
|
...base,
|
||||||
|
UNLEASH_URL: "http://u",
|
||||||
|
UNLEASH_FRONTEND_TOKEN: "",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
it("points at the Frontend API", () => {
|
||||||
|
expect(
|
||||||
|
unleashConfig({
|
||||||
|
...base,
|
||||||
|
UNLEASH_URL: "http://unleash.test/",
|
||||||
|
UNLEASH_FRONTEND_TOKEN: "*:development.x",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
url: "http://unleash.test/api/frontend",
|
||||||
|
clientKey: "*:development.x",
|
||||||
|
appName: "web",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FlagsProvider", () => {
|
||||||
|
it("starts the client when configured", () => {
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
APP_NAME: "web",
|
||||||
|
UNLEASH_URL: "http://unleash.test",
|
||||||
|
UNLEASH_FRONTEND_TOKEN: "tok",
|
||||||
|
};
|
||||||
|
sdk.on.add("hello-banner");
|
||||||
|
render(
|
||||||
|
<FlagsProvider>
|
||||||
|
<Probe />
|
||||||
|
</FlagsProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("on")).toBeInTheDocument();
|
||||||
|
expect(sdk.providerProps[0]).toMatchObject({
|
||||||
|
startClient: true,
|
||||||
|
config: { url: "http://unleash.test/api/frontend", clientKey: "tok" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays offline when UNLEASH_URL is unset", () => {
|
||||||
|
window.__APP_CONFIG__ = { APP_NAME: "web" };
|
||||||
|
const info = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
render(
|
||||||
|
<FlagsProvider>
|
||||||
|
<Probe />
|
||||||
|
</FlagsProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("off")).toBeInTheDocument();
|
||||||
|
expect(sdk.providerProps[0]).toMatchObject({
|
||||||
|
startClient: false,
|
||||||
|
config: { disableRefresh: true, disableMetrics: true },
|
||||||
|
});
|
||||||
|
expect(info).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/lib/flags.tsx
Normal file
55
src/lib/flags.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
FlagProvider,
|
||||||
|
useFlag as useUnleashFlag,
|
||||||
|
useVariant as useUnleashVariant,
|
||||||
|
type IConfig,
|
||||||
|
} from "@unleash/proxy-client-react";
|
||||||
|
import { getConfig, type AppConfig } from "./config";
|
||||||
|
|
||||||
|
// Offline client: no network, no metrics, every flag reads false.
|
||||||
|
const OFFLINE: IConfig = {
|
||||||
|
url: "http://localhost/offline",
|
||||||
|
clientKey: "offline",
|
||||||
|
appName: "offline",
|
||||||
|
bootstrap: [],
|
||||||
|
disableRefresh: true,
|
||||||
|
disableMetrics: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Unleash Frontend API config from runtime config, or null if unset. */
|
||||||
|
export function unleashConfig(cfg: AppConfig): IConfig | null {
|
||||||
|
if (!cfg.UNLEASH_URL || !cfg.UNLEASH_FRONTEND_TOKEN) return null;
|
||||||
|
return {
|
||||||
|
url: `${cfg.UNLEASH_URL.replace(/\/+$/, "")}/api/frontend`,
|
||||||
|
clientKey: cfg.UNLEASH_FRONTEND_TOKEN,
|
||||||
|
appName: cfg.APP_NAME,
|
||||||
|
refreshInterval: 15,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the app once (main.tsx). With UNLEASH_URL/UNLEASH_FRONTEND_TOKEN unset
|
||||||
|
* (local dev, tests) every flag reads false and nothing is fetched.
|
||||||
|
*/
|
||||||
|
export function FlagsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const config = useMemo(() => {
|
||||||
|
const c = unleashConfig(getConfig());
|
||||||
|
if (!c) console.info("feature flags disabled: UNLEASH_URL unset");
|
||||||
|
return c;
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<FlagProvider config={config ?? OFFLINE} startClient={config !== null}>
|
||||||
|
{children}
|
||||||
|
</FlagProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the flag is on; re-renders when Unleash reports a change. */
|
||||||
|
export function useFlag(name: string): boolean {
|
||||||
|
return useUnleashFlag(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVariant(name: string) {
|
||||||
|
return useUnleashVariant(name);
|
||||||
|
}
|
||||||
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||||
|
import { FlagsProvider } from "./lib/flags";
|
||||||
|
import { routes } from "./routes";
|
||||||
|
|
||||||
|
const router = createBrowserRouter(routes);
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<FlagsProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</FlagsProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
18
src/routes.tsx
Normal file
18
src/routes.tsx
Normal 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
16
src/routes/ErrorPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/routes/Home.tsx
Normal file
24
src/routes/Home.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { buildSha, getConfig } from "../lib/config";
|
||||||
|
import { useFlag } from "../lib/flags";
|
||||||
|
|
||||||
|
export function Home() {
|
||||||
|
// Demo flag (Plan 07): toggle "hello-banner" in Unleash, then reload.
|
||||||
|
const banner = useFlag("hello-banner");
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{banner && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
data-testid="banner"
|
||||||
|
style={{ background: "#fff3c4", padding: "0.5rem 1rem" }}
|
||||||
|
>
|
||||||
|
The hello-banner flag is on.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1>{getConfig().APP_NAME}</h1>
|
||||||
|
<p>
|
||||||
|
Build: <code data-testid="build-sha">{buildSha}</code>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/routes/Layout.tsx
Normal file
19
src/routes/Layout.tsx
Normal 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
23
src/routes/Status.tsx
Normal 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} → {r.status}
|
||||||
|
</h2>
|
||||||
|
<pre>{JSON.stringify(r.body, null, 2)}</pre>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/test-setup.ts
Normal file
6
src/test-setup.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
// vitest globals are off, so Testing Library can't register this itself.
|
||||||
|
afterEach(cleanup);
|
||||||
5
src/vite-env.d.ts
vendored
Normal file
5
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_GIT_SHA?: string;
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal 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
19
vite.config.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue