feat: Unleash feature flags via src/lib/flags.ts, hello-banner on GET /

This commit is contained in:
nholtware 2026-11-05 11:07:19 -06:00
parent 99a48cd65b
commit 9b63993f12
12 changed files with 675 additions and 17 deletions

View file

@ -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=

View file

@ -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.

View file

@ -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).

439
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -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<string>();
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<string, unknown>[] = [];

View file

@ -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.

View file

@ -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();

View file

@ -1,6 +1,7 @@
import { z } from "zod";
const schema = z.object({
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
@ -9,6 +10,14 @@ const schema = z.object({
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<typeof schema>;

86
src/lib/flags.test.ts Normal file
View file

@ -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<string>(),
}));
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();
});
});

64
src/lib/flags.ts Normal file
View file

@ -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<void>;
}
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();
},
};
}

View file

@ -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",