generated from nholtware/service-template-react
feat: Unleash feature flags via src/lib/flags.tsx, hello-banner on home page
Ported from service-template-react e6f8827.
This commit is contained in:
parent
444044b99a
commit
52b5a48346
12 changed files with 276 additions and 9 deletions
|
|
@ -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`).
|
||||
|
|
|
|||
30
README.md
30
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`.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
44
package-lock.json
generated
44
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<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" };
|
||||
});
|
||||
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<FlagsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</FlagsProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue