From 9b63993f12e8f6e2a607f2c3d9e0ce9508a56a8f Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 11:07:19 -0600 Subject: [PATCH] feat: Unleash feature flags via src/lib/flags.ts, hello-banner on GET / --- .env.example | 3 + CLAUDE.md | 1 + README.md | 23 ++- package-lock.json | 439 +++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/app.test.ts | 28 +++ src/app.ts | 6 +- src/bin/server.ts | 6 +- src/lib/config.ts | 29 ++- src/lib/flags.test.ts | 86 +++++++++ src/lib/flags.ts | 64 ++++++ src/routes/root.ts | 6 + 12 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 src/lib/flags.test.ts create mode 100644 src/lib/flags.ts diff --git a/.env.example b/.env.example index 6d58167..65f0693 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ LOG_LEVEL=info # Optional: set to enable the backing services DATABASE_URL=postgresql://app:app@localhost:5432/app REDIS_URL=redis://localhost:6379 +# Feature flags (optional; unset = all flags off) +# UNLEASH_URL=http://unleash.192.168.1.83.nip.io/api +# UNLEASH_API_TOKEN= diff --git a/CLAUDE.md b/CLAUDE.md index 2fe9b53..60907a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,5 +6,6 @@ - Logging contract (log pipeline depends on it): JSON per line on stdout with `level,time,msg,service,version`; requests add `req.id,req.method,req.url,res.statusCode,responseTime`. - Health contract: `/healthz` liveness (always 200), `/readyz` readiness (checks configured Postgres/Redis, else 503 JSON). - Seams: cross-cutting modules in `src/lib/`, endpoints in `src/routes/` wired in `src/app.ts`. +- Feature flags: only via `deps.flags` (`src/lib/flags.ts`, Unleash); never import `unleash-client` elsewhere. Unset `UNLEASH_URL` = all flags off. - Migrations: `migrations/*.sql`, run in-cluster by an ArgoCD PreSync Job using this same image. - Deploy: push to `main` -> Forgejo CI -> image -> gitops bump -> ArgoCD. Never `kubectl apply` by hand. diff --git a/README.md b/README.md index b5c84e8..8532ef8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Express 5 + TypeScript backend generated from `nholtware/service-template-expres - `GET /healthz` liveness, always 200 once listening - `GET /readyz` readiness: Postgres `SELECT 1` and Redis `PING` for whichever of `DATABASE_URL` / `REDIS_URL` are set; 503 + JSON reason otherwise -- `GET /` service name, version (git sha), pod, a `service_info` row, Redis hit counter +- `GET /` service name, version (git sha), pod, a `service_info` row, Redis hit counter, and `banner` while the `hello-banner` flag is on - `GET /log-demo?msg=...` logs `{ demo: true, text }` with message `log-demo` (logging-pipeline demo; delete when you don't need it) ## Local dev @@ -17,7 +17,7 @@ npm ci && npm run dev npm run lint && npm test ``` -Env is validated at boot (`src/lib/config.ts`): `PORT`, `SERVICE_NAME` (required), `LOG_LEVEL`, `DATABASE_URL?`, `REDIS_URL?`. +Env is validated at boot (`src/lib/config.ts`): `PORT`, `SERVICE_NAME` (required), `LOG_LEVEL`, `DATABASE_URL?`, `REDIS_URL?`, `UNLEASH_URL?`, `UNLEASH_API_TOKEN?` (required with `UNLEASH_URL`), `UNLEASH_REFRESH_MS` (default 5000). Migrations: SQL files in `migrations/`, applied with `npm run migrate`. Logs: one JSON object per line on stdout (`level,time,msg,service,version`, plus `req.*`/`res.statusCode`/`responseTime` per request). @@ -35,6 +35,25 @@ Any field you pass to the logger becomes a queryable field: overrides a `msg` key in the object, so don't name a field `msg`. Lines are searchable within a few seconds; retention is 14 days. +## Feature flags + +Flags come from Unleash (UI: http://unleash.192.168.1.83.nip.io, environment `development`). +`src/lib/flags.ts` wraps the official `unleash-client` SDK; routes get it as `deps.flags`: + +```ts +if (deps.flags?.isEnabled("my-flag", { userId })) { ... } +const v = deps.flags?.getVariant("my-experiment"); // v.name, v.enabled, v.payload +``` + +- In the cluster the deployment sets `UNLEASH_URL=http://unleash.unleash.svc:4242/api` and + `UNLEASH_API_TOKEN` from Secret `unleash-client-token` (key `token`) in the service's namespace. +- The SDK polls every 5 s; flipping a flag in the UI changes behaviour within seconds, no redeploy. +- With `UNLEASH_URL` unset (local dev, tests) every flag reads `false` and one log line says so. + If Unleash is down, flags keep their last fetched value (or `false`) and the service keeps serving. +- Create the flag in the Unleash UI (project `default`) before using it; unknown flags read `false`. +- Tests: pass a fake `Flags` to `createApp` (see `src/app.test.ts`); never hit a real Unleash. +- `hello-banner` on `GET /` is the platform demo flag; delete it when you don't need it. + ## Using this template 1. **Generate** a repo from this template in Forgejo (public). The repo name is substituted into `package.json`, this README, `.env.example`, and the first migration (CI derives the app name from the repo name itself). diff --git a/package-lock.json b/package-lock.json index 451f94c..cef49f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "pg": "^8.13.0", "pino": "^9.5.0", "pino-http": "^10.3.0", + "unleash-client": "^6.12.2", "zod": "^3.24.0" }, "devDependencies": { @@ -927,6 +928,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1028,6 +1038,43 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -2070,6 +2117,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2219,6 +2275,80 @@ "node": ">=8" } }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3400,6 +3530,18 @@ "node": ">= 0.8" } }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3653,6 +3795,12 @@ "node": ">= 0.4" } }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3673,6 +3821,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -3785,6 +3959,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.7.6", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz", + "integrity": "sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3988,6 +4171,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/launchdarkly-eventsource": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/launchdarkly-eventsource/-/launchdarkly-eventsource-2.2.0.tgz", + "integrity": "sha512-u38fYlLSq/m6oFz0MS1/76Sj2xzlYhTKZ+sf/vju6PA86PMc6fPlY5k8CdU79edLXjNwsvIQTDvDNy3llDqB8A==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4114,6 +4306,29 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4241,12 +4456,122 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash3js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/murmurhash3js/-/murmurhash3js-3.0.1.tgz", + "integrity": "sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -4410,6 +4735,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -4771,6 +5108,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process-warning": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", @@ -4804,6 +5150,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4864,6 +5216,15 @@ "node": ">= 0.10" } }, + "node_modules/re2js": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.9.1.tgz", + "integrity": "sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -5004,7 +5365,6 @@ "version": "7.8.6", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5176,6 +5536,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -5204,6 +5602,18 @@ "node": ">= 10.x" } }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5564,6 +5974,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unleash-client": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/unleash-client/-/unleash-client-6.12.2.tgz", + "integrity": "sha512-aId4dG9E8zEOlOYLVy7YTMHG/gMKQYKDldWvWGt+x1z32a9g+IjlYBsFbqwOpD6T6CvMpCHzX1aONTW5MLVcHA==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "ip-address": "^10.7.2", + "launchdarkly-eventsource": "2.2.0", + "lru-cache": "^11.2.5", + "make-fetch-happen": "^15.0.3", + "murmurhash3js": "^3.0.1", + "proxy-from-env": "^1.1.0", + "re2js": "^2.3.2", + "semver": "^7.7.3" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5853,6 +6284,12 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index e21de81..82684eb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "pg": "^8.13.0", "pino": "^9.5.0", "pino-http": "^10.3.0", + "unleash-client": "^6.12.2", "zod": "^3.24.0" }, "devDependencies": { diff --git a/src/app.test.ts b/src/app.test.ts index 2cddb5e..2ce046e 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -4,6 +4,7 @@ import pino from "pino"; import { createApp } from "./app.js"; import { parseConfig } from "./lib/config.js"; import { createDb } from "./lib/db.js"; +import { disabledFlags, type Flags } from "./lib/flags.js"; const logger = pino({ level: "silent" }); const base = { SERVICE_NAME: "test-svc", GIT_SHA: "abc123" }; @@ -18,6 +19,11 @@ describe("config", () => { it("rejects missing SERVICE_NAME with a clear message", () => { expect(() => parseConfig({})).toThrow(/SERVICE_NAME/); }); + it("requires UNLEASH_API_TOKEN when UNLEASH_URL is set", () => { + expect(() => + parseConfig({ ...base, UNLEASH_URL: "http://unleash.test/api" }), + ).toThrow(/UNLEASH_API_TOKEN/); + }); it("rejects an invalid LOG_LEVEL", () => { expect(() => parseConfig({ ...base, LOG_LEVEL: "loud" })).toThrow( /LOG_LEVEL/, @@ -68,6 +74,28 @@ describe("routes", () => { }); }); +describe("feature flags on GET /", () => { + const config = parseConfig(base); + const on = new Set(); + const fake: Flags = { + ...disabledFlags(), + isEnabled: (name) => on.has(name), + }; + const app = createApp({ config, logger, flags: fake }); + + it("no banner while hello-banner is off", async () => { + on.clear(); + const res = await request(app).get("/"); + expect(res.body.banner).toBeUndefined(); + }); + + it("banner while hello-banner is on, no restart needed", async () => { + on.add("hello-banner"); + const res = await request(app).get("/"); + expect(res.body.banner).toMatch(/hello-banner flag is on/); + }); +}); + describe("log-demo", () => { it("logs one JSON line with demo fields and the request id", async () => { const lines: Record[] = []; diff --git a/src/app.ts b/src/app.ts index ba2d677..9bf8582 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import { pinoHttp } from "pino-http"; import type { Logger } from "pino"; import type { Config } from "./lib/config.js"; import type { Db } from "./lib/db.js"; +import type { Flags } from "./lib/flags.js"; import type { Cache } from "./lib/redis.js"; import { healthRouter } from "./routes/health.js"; import { logDemoRouter } from "./routes/log-demo.js"; @@ -14,9 +15,10 @@ export interface Deps { logger: Logger; db?: Db; cache?: Cache; + flags?: Flags; } -export function createApp({ config, logger, db, cache }: Deps) { +export function createApp({ config, logger, db, cache, flags }: Deps) { const app = express(); app.disable("x-powered-by"); @@ -43,7 +45,7 @@ export function createApp({ config, logger, db, cache }: Deps) { ); app.use(healthRouter({ db, cache })); - app.use(rootRouter({ config, db, cache })); + app.use(rootRouter({ config, db, cache, flags })); app.use(logDemoRouter()); // Express identifies error handlers by arity; keep all four args. diff --git a/src/bin/server.ts b/src/bin/server.ts index 56f1e26..4adb3a7 100644 --- a/src/bin/server.ts +++ b/src/bin/server.ts @@ -1,6 +1,7 @@ import { createApp } from "../app.js"; import { parseConfig } from "../lib/config.js"; import { createDb } from "../lib/db.js"; +import { createFlags } from "../lib/flags.js"; import { createLogger } from "../lib/logger.js"; import { createCache } from "../lib/redis.js"; @@ -8,8 +9,9 @@ const config = parseConfig(process.env); const logger = createLogger(config); const db = config.DATABASE_URL ? createDb(config.DATABASE_URL) : undefined; const cache = config.REDIS_URL ? createCache(config.REDIS_URL) : undefined; +const flags = createFlags(config, logger); -const server = createApp({ config, logger, db, cache }).listen( +const server = createApp({ config, logger, db, cache, flags }).listen( config.PORT, () => { logger.info({ port: config.PORT }, "listening"); @@ -19,7 +21,7 @@ const server = createApp({ config, logger, db, cache }).listen( function shutdown(signal: string) { logger.info({ signal }, "shutting down"); server.close(async () => { - await Promise.allSettled([db?.close(), cache?.close()]); + await Promise.allSettled([db?.close(), cache?.close(), flags.close()]); process.exit(0); }); setTimeout(() => process.exit(1), 10_000).unref(); diff --git a/src/lib/config.ts b/src/lib/config.ts index acc8aa3..fa205ba 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,15 +1,24 @@ import { z } from "zod"; -const schema = z.object({ - PORT: z.coerce.number().int().positive().default(3000), - SERVICE_NAME: z.string().min(1, "SERVICE_NAME is required"), - LOG_LEVEL: z - .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) - .default("info"), - GIT_SHA: z.string().default("dev"), - DATABASE_URL: z.string().url().optional(), - REDIS_URL: z.string().url().optional(), -}); +const schema = z + .object({ + PORT: z.coerce.number().int().positive().default(3000), + SERVICE_NAME: z.string().min(1, "SERVICE_NAME is required"), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + GIT_SHA: z.string().default("dev"), + DATABASE_URL: z.string().url().optional(), + REDIS_URL: z.string().url().optional(), + // Feature flags (Unleash). Unset UNLEASH_URL = every flag reads false. + UNLEASH_URL: z.string().url().optional(), + UNLEASH_API_TOKEN: z.string().min(1).optional(), + UNLEASH_REFRESH_MS: z.coerce.number().int().positive().default(5000), + }) + .refine((c) => !c.UNLEASH_URL || c.UNLEASH_API_TOKEN, { + message: "UNLEASH_API_TOKEN is required when UNLEASH_URL is set", + path: ["UNLEASH_API_TOKEN"], + }); export type Config = z.infer; diff --git a/src/lib/flags.test.ts b/src/lib/flags.test.ts new file mode 100644 index 0000000..eba293f --- /dev/null +++ b/src/lib/flags.test.ts @@ -0,0 +1,86 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import pino from "pino"; + +// Mock the Unleash SDK: no network, full control over flag state. +const sdk = vi.hoisted(() => ({ + initialize: vi.fn(), + enabled: new Set(), +})); +vi.mock("unleash-client", () => ({ initialize: sdk.initialize })); + +import { createFlags } from "./flags.js"; + +function fakeUnleash() { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + isEnabled: vi.fn((name: string) => sdk.enabled.has(name)), + getVariant: vi.fn((name: string, _ctx: unknown, fallback: unknown) => + sdk.enabled.has(name) + ? { name: "blue", enabled: true, feature_enabled: true } + : fallback, + ), + destroy: vi.fn(), + }); +} + +const base = { + SERVICE_NAME: "test-svc", + UNLEASH_REFRESH_MS: 5000, +} as const; + +describe("createFlags", () => { + beforeEach(() => { + sdk.initialize.mockReset(); + sdk.enabled.clear(); + }); + + it("without UNLEASH_URL: all flags off, logs once, never touches the SDK", () => { + const lines: { msg: string }[] = []; + const logger = pino( + {}, + { write: (l: string) => lines.push(JSON.parse(l)) }, + ); + const flags = createFlags(base, logger); + expect(flags.isEnabled("hello-banner")).toBe(false); + expect(flags.isEnabled("hello-banner")).toBe(false); + expect(flags.getVariant("hello-banner").enabled).toBe(false); + expect(sdk.initialize).not.toHaveBeenCalled(); + expect(lines.filter((l) => /flags disabled/.test(l.msg))).toHaveLength(1); + }); + + it("with UNLEASH_URL: initializes the SDK and delegates", async () => { + const unleash = fakeUnleash(); + sdk.initialize.mockReturnValue(unleash); + const flags = createFlags( + { + ...base, + UNLEASH_URL: "http://unleash.test/api", + UNLEASH_API_TOKEN: "*:development.abc", + }, + pino({ level: "silent" }), + ); + expect(sdk.initialize).toHaveBeenCalledWith({ + url: "http://unleash.test/api", + appName: "test-svc", + customHeaders: { Authorization: "*:development.abc" }, + refreshInterval: 5000, + }); + + expect(flags.isEnabled("hello-banner")).toBe(false); + sdk.enabled.add("hello-banner"); + expect(flags.isEnabled("hello-banner", { userId: "u1" })).toBe(true); + expect(unleash.isEnabled).toHaveBeenLastCalledWith( + "hello-banner", + { userId: "u1" }, + false, + ); + expect(flags.getVariant("hello-banner").name).toBe("blue"); + + // SDK errors are logged, not thrown (no unhandled 'error' event). + expect(() => unleash.emit("error", new Error("down"))).not.toThrow(); + + await flags.close(); + expect(unleash.destroy).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/flags.ts b/src/lib/flags.ts new file mode 100644 index 0000000..68dad80 --- /dev/null +++ b/src/lib/flags.ts @@ -0,0 +1,64 @@ +import { initialize, type Context, type Variant } from "unleash-client"; +import type { Logger } from "pino"; +import type { Config } from "./config.js"; + +export type { Context as FlagContext, Variant as FlagVariant }; + +/** + * Thin wrapper over the Unleash Node SDK. Routes depend on this interface, + * not on unleash-client, so tests can pass a fake and the SDK can be swapped. + */ +export interface Flags { + isEnabled(name: string, context?: Context): boolean; + getVariant(name: string, context?: Context): Variant; + close(): Promise; +} + +const DISABLED: Variant = { name: "disabled", enabled: false }; + +/** Every flag off. Used when UNLEASH_URL is unset (local dev, tests). */ +export function disabledFlags(): Flags { + return { + isEnabled: () => false, + getVariant: () => ({ ...DISABLED, feature_enabled: false }), + close: async () => {}, + }; +} + +export function createFlags( + config: Pick< + Config, + "SERVICE_NAME" | "UNLEASH_URL" | "UNLEASH_API_TOKEN" | "UNLEASH_REFRESH_MS" + >, + logger: Logger, +): Flags { + if (!config.UNLEASH_URL) { + logger.info("feature flags disabled: UNLEASH_URL unset, all flags off"); + return disabledFlags(); + } + + const unleash = initialize({ + url: config.UNLEASH_URL, + appName: config.SERVICE_NAME, + customHeaders: { Authorization: config.UNLEASH_API_TOKEN ?? "" }, + refreshInterval: config.UNLEASH_REFRESH_MS, + }); + // An EventEmitter 'error' with no listener would crash the process. + // Unleash being down must never take the service down: flags read false + // (or their last fetched value) until it comes back. + unleash.on("error", (err: Error) => + logger.warn({ err }, "unleash client error"), + ); + unleash.on("warn", (msg: string) => logger.warn({ msg }, "unleash warning")); + unleash.once("synchronized", () => + logger.info({ url: config.UNLEASH_URL }, "feature flags synchronized"), + ); + + return { + isEnabled: (name, context) => unleash.isEnabled(name, context, false), + getVariant: (name, context) => unleash.getVariant(name, context, DISABLED), + async close() { + unleash.destroy(); + }, + }; +} diff --git a/src/routes/root.ts b/src/routes/root.ts index ca5ce5b..190962e 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -2,12 +2,14 @@ import { Router } from "express"; import { hostname } from "node:os"; import type { Config } from "../lib/config.js"; import type { Db } from "../lib/db.js"; +import type { Flags } from "../lib/flags.js"; import type { Cache } from "../lib/redis.js"; export function rootRouter(deps: { config: Config; db?: Db; cache?: Cache; + flags?: Flags; }): Router { const router = Router(); @@ -18,6 +20,10 @@ export function rootRouter(deps: { version: deps.config.GIT_SHA, pod: hostname(), }; + // Demo flag (Plan 07): toggle "hello-banner" in Unleash, no redeploy. + if (deps.flags?.isEnabled("hello-banner")) { + body.banner = `Hello from ${deps.config.SERVICE_NAME}: the hello-banner flag is on`; + } if (deps.db) { const r = await deps.db.query( "SELECT id, name, created_at FROM service_info ORDER BY id LIMIT 1",