From 52b5a48346c2d07fc24040d916dd26c5bf942ec1 Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 11:19:56 -0600 Subject: [PATCH] feat: Unleash feature flags via src/lib/flags.tsx, hello-banner on home page Ported from service-template-react e6f8827. --- CLAUDE.md | 1 + README.md | 30 +++++++++-- docker-entrypoint.d/10-config.sh | 2 +- package-lock.json | 44 ++++++++++++++- package.json | 4 +- src/app.test.tsx | 23 ++++++++ src/lib/config.ts | 11 +++- src/lib/flags.test.tsx | 93 ++++++++++++++++++++++++++++++++ src/lib/flags.tsx | 55 +++++++++++++++++++ src/main.tsx | 5 +- src/routes/Home.tsx | 12 +++++ src/test-setup.ts | 5 ++ 12 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 src/lib/flags.test.tsx create mode 100644 src/lib/flags.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 5324714..cf57ebb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ - 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`). diff --git a/README.md b/README.md index 2417e60..607a440 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,35 @@ npm run format 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 | +| 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`. diff --git a/docker-entrypoint.d/10-config.sh b/docker-entrypoint.d/10-config.sh index 2aa90ae..5ca5ab0 100755 --- a/docker-entrypoint.d/10-config.sh +++ b/docker-entrypoint.d/10-config.sh @@ -5,7 +5,7 @@ # Never put secrets here: everything in config.js is public. set -eu -VARS="API_BASE_URL APP_NAME" +VARS="API_BASE_URL APP_NAME UNLEASH_URL UNLEASH_FRONTEND_TOKEN" OUT=/usr/share/nginx/html/config.js : "${API_BASE_URL:=/api}" diff --git a/package-lock.json b/package-lock.json index c9ea585..f092101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "hello-web", "version": "1.0.0", "dependencies": { + "@unleash/proxy-client-react": "^6.0.1", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-router": "^8.5.1" + "react-router": "^8.5.1", + "unleash-proxy-client": "^3.8.2" }, "devDependencies": { "@commitlint/cli": "^21.2.4", @@ -1826,6 +1828,22 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@unleash/proxy-client-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@unleash/proxy-client-react/-/proxy-client-react-6.0.1.tgz", + "integrity": "sha512-ghpI3mCs7KMKbry1TaRrkH2aIj+X3O6vaHw5isp3CIOguyN5dXcK5sPG7YOCr9pVcTT99wVGRFgpAwlLaCWLbA==", + "license": "Apache-2.0", + "dependencies": { + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "unleash-proxy-client": "^3.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.2.1.tgz", @@ -4101,6 +4119,12 @@ "node": ">=8" } }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", @@ -4265,6 +4289,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unleash-proxy-client": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/unleash-proxy-client/-/unleash-proxy-client-3.8.2.tgz", + "integrity": "sha512-xwFk4Zq7mdZxWU69g9On1TT3VSARBm2c+U1oY6PbaBA+FZrODpUHw7xMhIg74Ai56w0RlPMZ4znzHZ3R9PBGpA==", + "license": "Apache-2.0", + "dependencies": { + "tiny-emitter": "^2.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", @@ -4306,6 +4339,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/verkit": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", diff --git a/package.json b/package.json index a072d98..236f992 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "node": ">=22" }, "dependencies": { + "@unleash/proxy-client-react": "^6.0.1", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-router": "^8.5.1" + "react-router": "^8.5.1", + "unleash-proxy-client": "^3.8.2" }, "devDependencies": { "@commitlint/cli": "^21.2.4", diff --git a/src/app.test.tsx b/src/app.test.tsx index 7570373..ff5e2b2 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -3,12 +3,21 @@ 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()); +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(); } beforeEach(() => { + flagsOn.clear(); window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "test-app" }; }); @@ -25,6 +34,20 @@ describe("app", () => { 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 = diff --git a/src/lib/config.ts b/src/lib/config.ts index d122db6..5851b99 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,10 @@ 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 { @@ -9,7 +13,12 @@ declare global { } } -const defaults: AppConfig = { API_BASE_URL: "/api", APP_NAME: "app" }; +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. diff --git a/src/lib/flags.test.tsx b/src/lib/flags.test.tsx new file mode 100644 index 0000000..c292f8d --- /dev/null +++ b/src/lib/flags.test.tsx @@ -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(), + providerProps: [] as Record[], +})); +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

{useFlag("hello-banner") ? "on" : "off"}

; +} + +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( + + + , + ); + 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( + + + , + ); + expect(screen.getByText("off")).toBeInTheDocument(); + expect(sdk.providerProps[0]).toMatchObject({ + startClient: false, + config: { disableRefresh: true, disableMetrics: true }, + }); + expect(info).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/flags.tsx b/src/lib/flags.tsx new file mode 100644 index 0000000..b15c3cc --- /dev/null +++ b/src/lib/flags.tsx @@ -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 ( + + {children} + + ); +} + +/** 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); +} diff --git a/src/main.tsx b/src/main.tsx index 740d8ce..c239bd7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +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( - + + + , ); diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 8842383..707332e 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,8 +1,20 @@ 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 (
+ {banner && ( +

+ The hello-banner flag is on. +

+ )}

{getConfig().APP_NAME}

Build: {buildSha} diff --git a/src/test-setup.ts b/src/test-setup.ts index f149f27..412da0d 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1 +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);