generated from nholtware/service-template-express
feat: Unleash feature flags via src/lib/flags.ts, hello-banner on GET /
Ported from service-template-express 9b63993.
This commit is contained in:
parent
28ec495a98
commit
ec4b769be2
12 changed files with 675 additions and 17 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -6,7 +6,7 @@ Express 5 + TypeScript backend, generated from `nholtware/service-template-expre
|
|||
|
||||
- `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). `hello-svc` is substituted into `package.json`, this README, `.env.example`, the CI workflow, and the first migration.
|
||||
|
|
|
|||
439
package-lock.json
generated
439
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>[] = [];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,7 +10,15 @@ 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
86
src/lib/flags.test.ts
Normal 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
64
src/lib/flags.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue