generated from nholtware/service-template-express
Initial commit
This commit is contained in:
commit
a0c3aa640c
36 changed files with 7676 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.forgejo
|
||||||
11
.env.example
Normal file
11
.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
PORT=3000
|
||||||
|
SERVICE_NAME=adventure
|
||||||
|
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=
|
||||||
|
# Runtime config file, hot-reloaded (optional; unset = schema defaults)
|
||||||
|
# RUNTIME_CONFIG_PATH=./runtime.yaml
|
||||||
5
.forgejo/template
Normal file
5
.forgejo/template
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package.json
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
|
migrations/0001_init.sql
|
||||||
|
package-lock.json
|
||||||
29
.forgejo/workflows/ci.yaml
Normal file
29
.forgejo/workflows/ci.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Full pipeline: checks -> build+push image -> bump tag in gitops repo.
|
||||||
|
# The heavy lifting lives in nholtware/workflows composite actions; ArgoCD
|
||||||
|
# takes over from the gitops commit.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@v1
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@v1
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: checks
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/build-push@v1
|
||||||
|
with:
|
||||||
|
app-name: ${{ github.event.repository.name }}
|
||||||
|
registry-user: ${{ secrets.REGISTRY_USER }}
|
||||||
|
registry-token: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/gitops-bump@v1
|
||||||
|
with:
|
||||||
|
app-name: ${{ github.event.repository.name }}
|
||||||
|
gitops-token: ${{ secrets.GITOPS_TOKEN }}
|
||||||
12
.forgejo/workflows/pr.yaml
Normal file
12
.forgejo/workflows/pr.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# PR checks: lint + test only. No build/deploy -- that's ci.yaml's job on push to main.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@v1
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@v1
|
||||||
23
.github/workflows/pr.yml
vendored
Normal file
23
.github/workflows/pr.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# GitHub-compatibility proof (Plan 10): the same npm scripts the Forgejo
|
||||||
|
# composite actions run, using only stock GitHub actions. Forgejo ignores
|
||||||
|
# .github/ because .forgejo/workflows/ exists.
|
||||||
|
name: pr
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run format:check
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v22
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Service conventions
|
||||||
|
|
||||||
|
- Stack: Express 5, TypeScript ESM (`tsc`), Node 22, `pg` + `node-pg-migrate`, `ioredis`, `pino`/`pino-http`, `zod`, vitest + supertest.
|
||||||
|
- Do NOT rename npm scripts (`lint`, `test`, `format:check`, `build`, `migrate`): the shared CI composite actions call them.
|
||||||
|
- Config: all env parsed once in `src/lib/config.ts` (zod); add new vars there.
|
||||||
|
- 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.
|
||||||
|
- Runtime config: only via `deps.runtimeConfig.get()` (`src/lib/runtime-config.ts`); keys + defaults in `src/runtime-config.schema.ts`. Never secrets. Never mount the ConfigMap with `subPath` (breaks hot reload).
|
||||||
|
- 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.
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Build stage: full toolchain, compile TS -> dist/
|
||||||
|
FROM node:22 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# Fail fast and retry on a stalled registry fetch (npm defaults can hang ~10 min).
|
||||||
|
RUN npm ci --fetch-timeout=60000 --fetch-retries=5 --fetch-retry-mintimeout=2000 --no-audit --no-fund
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage: production deps only (node-pg-migrate is a prod dep so
|
||||||
|
# `npm run migrate` works inside this image for the PreSync Job).
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
ENV GIT_SHA=${GIT_SHA}
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --fetch-timeout=60000 --fetch-retries=5 --fetch-retry-mintimeout=2000 --no-audit --no-fund
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY migrations ./migrations
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/bin/server.js"]
|
||||||
82
README.md
Normal file
82
README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# adventure
|
||||||
|
|
||||||
|
Express 5 + TypeScript backend generated from `nholtware/service-template-express`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `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, `greeting` (runtime config), a `service_info` row, Redis hit counter, and `banner` while the `hello-banner` flag is on
|
||||||
|
- `GET /admin/runtime-config` current runtime config `values`, `loadedAt`, `source` (`file` | `defaults`)
|
||||||
|
- `GET /log-demo?msg=...` logs `{ demo: true, text }` with message `log-demo` (logging-pipeline demo; delete when you don't need it)
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # then export the vars, or use your shell's env loader
|
||||||
|
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?`, `UNLEASH_URL?`, `UNLEASH_API_TOKEN?` (required with `UNLEASH_URL`), `UNLEASH_REFRESH_MS` (default 5000), `RUNTIME_CONFIG_PATH?`.
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Seeing your logs
|
||||||
|
|
||||||
|
Every container's stdout is shipped to Elasticsearch by Fluent Bit; nothing to configure per service.
|
||||||
|
|
||||||
|
1. Open Kibana: http://kibana.192.168.1.83.nip.io, user `elastic`, password from
|
||||||
|
`kubectl get secret -n logging logging-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d`.
|
||||||
|
2. **Discover**, data view `kube-*` (the default).
|
||||||
|
3. Query with KQL, e.g. `service:"adventure" and demo:true`, or `service:"adventure" and level:"error"`.
|
||||||
|
|
||||||
|
Any field you pass to the logger becomes a queryable field:
|
||||||
|
`req.log.info({ orderId }, "order placed")` → KQL `orderId:123`. The message string always
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Runtime config
|
||||||
|
|
||||||
|
Typed values you change with a git commit, live in running pods without a restart
|
||||||
|
(timeouts, limits, copy; not on/off-for-whom, that's a feature flag; never secrets).
|
||||||
|
|
||||||
|
- Schema + defaults: `src/runtime-config.schema.ts` (zod). Every key needs a default.
|
||||||
|
- Read it per request: `deps.runtimeConfig.get().maxItems`. React to changes with
|
||||||
|
`runtimeConfig.onChange((values, changedKeys) => ...)`.
|
||||||
|
- In the cluster: ConfigMap `<app>-runtime-config` (key `runtime.yaml`) in gitops
|
||||||
|
`manifests/<app>/runtime-config.yaml`, mounted as a directory at `/etc/app/`,
|
||||||
|
`RUNTIME_CONFIG_PATH=/etc/app/runtime.yaml`. Edit, commit, push: live within ~4 min
|
||||||
|
(ArgoCD polls every 3 min, kubelet refreshes the volume within ~1 min).
|
||||||
|
Log line `runtime config reloaded` lists `changedKeys`.
|
||||||
|
- A bad value (invalid YAML, fails the schema) keeps the last good values and logs `error`
|
||||||
|
with the zod issues. The pod never crashes on a config commit. Fix it with another commit.
|
||||||
|
- Local dev: `RUNTIME_CONFIG_PATH=./runtime.yaml npm run dev`; edits reload immediately.
|
||||||
|
Unset = schema defaults (`source: "defaults"`).
|
||||||
|
- `GET /admin/runtime-config` is unauthenticated, which is why nothing secret goes in here.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
2. **Set 3 Actions secrets** on the new repo: `REGISTRY_USER` (`nholtware`), `REGISTRY_TOKEN` (registry push token), `GITOPS_TOKEN` (ci-bot gitops token).
|
||||||
|
3. **Add gitops entries**: render `templates/backend/` (see its README) into `manifests/<app>/` and add `apps/<app>.yaml` in `nholtware/gitops`.
|
||||||
|
4. **Push** to `main`. Generating a repo emits no push event, so CI only starts on your first push.
|
||||||
3
commitlint.config.js
Normal file
3
commitlint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
};
|
||||||
8
eslint.config.js
Normal file
8
eslint.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist/**"],
|
||||||
|
},
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
);
|
||||||
10
migrations/0001_init.sql
Normal file
10
migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Up Migration
|
||||||
|
CREATE TABLE service_info (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
INSERT INTO service_info (name) VALUES ('adventure');
|
||||||
|
|
||||||
|
-- Down Migration
|
||||||
|
DROP TABLE service_info;
|
||||||
6359
package-lock.json
generated
Normal file
6359
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46
package.json
Normal file
46
package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"name": "adventure",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend service generated from nholtware/service-template-express",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/app.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/bin/server.js",
|
||||||
|
"dev": "tsx watch src/bin/server.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"migrate": "node-pg-migrate up --migrations-dir migrations --migration-file-language sql",
|
||||||
|
"lint": "eslint . && tsc --noEmit",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"node-pg-migrate": "^8.0.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"pino-http": "^10.3.0",
|
||||||
|
"unleash-client": "^6.12.2",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.5.0",
|
||||||
|
"@commitlint/config-conventional": "^19.5.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
|
"eslint": "^9.13.0",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"typescript-eslint": "^8.15.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/app.test.ts
Normal file
120
src/app.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
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" };
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
it("applies defaults", () => {
|
||||||
|
const c = parseConfig(base);
|
||||||
|
expect(c.PORT).toBe(3000);
|
||||||
|
expect(c.LOG_LEVEL).toBe("info");
|
||||||
|
expect(c.DATABASE_URL).toBeUndefined();
|
||||||
|
});
|
||||||
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routes", () => {
|
||||||
|
const config = parseConfig(base);
|
||||||
|
const app = createApp({ config, logger });
|
||||||
|
|
||||||
|
it("GET /healthz -> 200 ok", async () => {
|
||||||
|
const res = await request(app).get("/healthz");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /readyz with no backends -> 200", async () => {
|
||||||
|
const res = await request(app).get("/readyz");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /readyz with unreachable DATABASE_URL -> 503 with reason", async () => {
|
||||||
|
const db = createDb("postgres://u:p@127.0.0.1:1/none");
|
||||||
|
const res = await request(createApp({ config, logger, db })).get("/readyz");
|
||||||
|
await db.close();
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.status).toBe("not-ready");
|
||||||
|
expect(res.body.postgres).toMatch(/^unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET / has service shape", async () => {
|
||||||
|
const res = await request(app).get("/");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
service: "test-svc",
|
||||||
|
version: "abc123",
|
||||||
|
});
|
||||||
|
expect(typeof res.body.pod).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("echoes or generates X-Request-Id", async () => {
|
||||||
|
const a = await request(app).get("/").set("X-Request-Id", "rid-1");
|
||||||
|
expect(a.headers["x-request-id"]).toBe("rid-1");
|
||||||
|
const b = await request(app).get("/");
|
||||||
|
expect(b.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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>[] = [];
|
||||||
|
const captured = pino(
|
||||||
|
{ base: { service: "test-svc" } },
|
||||||
|
{ write: (l: string) => lines.push(JSON.parse(l)) },
|
||||||
|
);
|
||||||
|
const app = createApp({ config: parseConfig(base), logger: captured });
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/log-demo?msg=hello")
|
||||||
|
.set("X-Request-Id", "rid-demo");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ logged: true, text: "hello" });
|
||||||
|
const line = lines.find((l) => l.msg === "log-demo");
|
||||||
|
expect(line).toMatchObject({
|
||||||
|
service: "test-svc",
|
||||||
|
demo: true,
|
||||||
|
text: "hello",
|
||||||
|
req: { id: "rid-demo" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/app.ts
Normal file
74
src/app.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import express, { type ErrorRequestHandler } from "express";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
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 {
|
||||||
|
staticRuntimeConfig,
|
||||||
|
type RuntimeConfig,
|
||||||
|
} from "./lib/runtime-config.js";
|
||||||
|
import { adminRouter } from "./routes/admin.js";
|
||||||
|
import { healthRouter } from "./routes/health.js";
|
||||||
|
import { logDemoRouter } from "./routes/log-demo.js";
|
||||||
|
import { rootRouter } from "./routes/root.js";
|
||||||
|
|
||||||
|
export interface Deps {
|
||||||
|
config: Config;
|
||||||
|
logger: Logger;
|
||||||
|
db?: Db;
|
||||||
|
cache?: Cache;
|
||||||
|
flags?: Flags;
|
||||||
|
runtimeConfig?: RuntimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp({
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
db,
|
||||||
|
cache,
|
||||||
|
flags,
|
||||||
|
runtimeConfig = staticRuntimeConfig(),
|
||||||
|
}: Deps) {
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
pinoHttp({
|
||||||
|
logger,
|
||||||
|
genReqId: (req, res) => {
|
||||||
|
const incoming = req.headers["x-request-id"];
|
||||||
|
const id =
|
||||||
|
(Array.isArray(incoming) ? incoming[0] : incoming) ?? randomUUID();
|
||||||
|
res.setHeader("X-Request-Id", id);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
// Keep the logging contract flat and small: req.id/method/url,
|
||||||
|
// res.statusCode, responseTime.
|
||||||
|
serializers: {
|
||||||
|
req: (req) => ({ id: req.id, method: req.method, url: req.url }),
|
||||||
|
res: (res) => ({ statusCode: res.statusCode }),
|
||||||
|
},
|
||||||
|
autoLogging: {
|
||||||
|
ignore: (req) => req.url === "/healthz" || req.url === "/readyz",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(healthRouter({ db, cache }));
|
||||||
|
app.use(rootRouter({ config, db, cache, flags, runtimeConfig }));
|
||||||
|
app.use(adminRouter({ runtimeConfig }));
|
||||||
|
app.use(logDemoRouter());
|
||||||
|
|
||||||
|
// Express identifies error handlers by arity; keep all four args.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const onError: ErrorRequestHandler = (err, req, res, _next) => {
|
||||||
|
req.log.error({ err }, "unhandled error");
|
||||||
|
res.status(500).json({ error: "internal error" });
|
||||||
|
};
|
||||||
|
app.use(onError);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
40
src/bin/server.ts
Normal file
40
src/bin/server.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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";
|
||||||
|
import { createRuntimeConfig } from "../lib/runtime-config.js";
|
||||||
|
|
||||||
|
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 runtimeConfig = await createRuntimeConfig({
|
||||||
|
path: config.RUNTIME_CONFIG_PATH,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = createApp({
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
db,
|
||||||
|
cache,
|
||||||
|
flags,
|
||||||
|
runtimeConfig,
|
||||||
|
}).listen(config.PORT, () => {
|
||||||
|
logger.info({ port: config.PORT }, "listening");
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
logger.info({ signal }, "shutting down");
|
||||||
|
runtimeConfig.close();
|
||||||
|
server.close(async () => {
|
||||||
|
await Promise.allSettled([db?.close(), cache?.close(), flags.close()]);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
setTimeout(() => process.exit(1), 10_000).unref();
|
||||||
|
}
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
37
src/lib/config.ts
Normal file
37
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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(),
|
||||||
|
// 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),
|
||||||
|
// Hot-reloaded runtime config file (src/lib/runtime-config.ts).
|
||||||
|
// Unset = schema defaults, no reload.
|
||||||
|
RUNTIME_CONFIG_PATH: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.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>;
|
||||||
|
|
||||||
|
export function parseConfig(env: NodeJS.ProcessEnv): Config {
|
||||||
|
const result = schema.safeParse(env);
|
||||||
|
if (!result.success) {
|
||||||
|
const problems = result.error.issues
|
||||||
|
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("\n");
|
||||||
|
throw new Error(`Invalid environment:\n${problems}`);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
24
src/lib/db.ts
Normal file
24
src/lib/db.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
export interface Db {
|
||||||
|
query: pg.Pool["query"];
|
||||||
|
ping(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDb(connectionString: string): Db {
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 5,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
});
|
||||||
|
// Idle-client errors must not crash the process.
|
||||||
|
pool.on("error", () => {});
|
||||||
|
return {
|
||||||
|
query: pool.query.bind(pool) as pg.Pool["query"],
|
||||||
|
async ping() {
|
||||||
|
await pool.query("SELECT 1");
|
||||||
|
},
|
||||||
|
close: () => pool.end(),
|
||||||
|
};
|
||||||
|
}
|
||||||
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/lib/logger.ts
Normal file
14
src/lib/logger.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import pino from "pino";
|
||||||
|
import type { Config } from "./config.js";
|
||||||
|
|
||||||
|
// Logging contract: one JSON object per line on stdout with level, time,
|
||||||
|
// msg, service, version. Level is emitted as a label, time as ISO-8601.
|
||||||
|
export function createLogger(config: Config): pino.Logger {
|
||||||
|
return pino({
|
||||||
|
level: config.LOG_LEVEL,
|
||||||
|
base: { service: config.SERVICE_NAME, version: config.GIT_SHA },
|
||||||
|
messageKey: "msg",
|
||||||
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
|
formatters: { level: (label) => ({ level: label }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
26
src/lib/redis.ts
Normal file
26
src/lib/redis.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
|
||||||
|
export interface Cache {
|
||||||
|
incr(key: string): Promise<number>;
|
||||||
|
ping(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCache(url: string): Cache {
|
||||||
|
const client = new Redis(url, {
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
connectTimeout: 2000,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
lazyConnect: false,
|
||||||
|
});
|
||||||
|
client.on("error", () => {});
|
||||||
|
return {
|
||||||
|
incr: (key) => client.incr(key),
|
||||||
|
async ping() {
|
||||||
|
await client.ping();
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
client.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
176
src/lib/runtime-config.test.ts
Normal file
176
src/lib/runtime-config.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import {
|
||||||
|
mkdtemp,
|
||||||
|
mkdir,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import pino from "pino";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../app.js";
|
||||||
|
import { parseConfig } from "./config.js";
|
||||||
|
import { createRuntimeConfig, type RuntimeConfig } from "./runtime-config.js";
|
||||||
|
|
||||||
|
type Line = { level: number; msg: string; [k: string]: unknown };
|
||||||
|
|
||||||
|
function capture() {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
const logger = pino({}, { write: (l: string) => lines.push(JSON.parse(l)) });
|
||||||
|
return { lines, logger };
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitFor = async (pred: () => boolean, ms = 3000) => {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (!pred()) {
|
||||||
|
if (Date.now() > end) throw new Error("timed out waiting for condition");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("runtime config", () => {
|
||||||
|
let dir: string;
|
||||||
|
let path: string;
|
||||||
|
let rc: RuntimeConfig | undefined;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "rc-"));
|
||||||
|
path = join(dir, "runtime.yaml");
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
rc?.close();
|
||||||
|
rc = undefined;
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no path: defaults, source defaults", async () => {
|
||||||
|
const { logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ logger });
|
||||||
|
expect(rc.get()).toEqual({
|
||||||
|
greeting: "hello",
|
||||||
|
maxItems: 50,
|
||||||
|
featureHints: [],
|
||||||
|
});
|
||||||
|
expect(rc.snapshot().source).toBe("defaults");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing file: defaults and warns once", async () => {
|
||||||
|
const { lines, logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
expect(rc.snapshot().source).toBe("defaults");
|
||||||
|
expect(rc.get().greeting).toBe("hello");
|
||||||
|
expect(lines.filter((l) => /missing/.test(l.msg))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the file, fills defaults for absent keys", async () => {
|
||||||
|
await writeFile(path, "greeting: hi\n");
|
||||||
|
const { logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
expect(rc.snapshot().source).toBe("file");
|
||||||
|
expect(rc.get()).toEqual({
|
||||||
|
greeting: "hi",
|
||||||
|
maxItems: 50,
|
||||||
|
featureHints: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads on change, notifies onChange with changed keys, logs info", async () => {
|
||||||
|
await writeFile(path, "greeting: one\nmaxItems: 5\n");
|
||||||
|
const { lines, logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
const seen: string[][] = [];
|
||||||
|
rc.onChange((_v, changed) => seen.push(changed));
|
||||||
|
|
||||||
|
await writeFile(path, "greeting: two\nmaxItems: 5\nfeatureHints: [a, b]\n");
|
||||||
|
await waitFor(() => rc!.get().greeting === "two");
|
||||||
|
|
||||||
|
expect(rc.get()).toEqual({
|
||||||
|
greeting: "two",
|
||||||
|
maxItems: 5,
|
||||||
|
featureHints: ["a", "b"],
|
||||||
|
});
|
||||||
|
expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]);
|
||||||
|
const reloaded = lines.find((l) => l.msg === "runtime config reloaded");
|
||||||
|
expect(reloaded).toMatchObject({
|
||||||
|
level: 30,
|
||||||
|
changedKeys: expect.arrayContaining(["greeting"]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bad YAML keeps last good values and logs error", async () => {
|
||||||
|
await writeFile(path, "greeting: good\n");
|
||||||
|
const { lines, logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
|
||||||
|
await writeFile(path, "greeting: [unclosed\n");
|
||||||
|
await waitFor(() => lines.some((l) => l.level === 50));
|
||||||
|
|
||||||
|
expect(rc.get().greeting).toBe("good");
|
||||||
|
expect(lines.find((l) => l.level === 50)?.error).toMatch(/invalid YAML/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schema violation keeps last good values and logs zod issues", async () => {
|
||||||
|
await writeFile(path, "maxItems: 10\n");
|
||||||
|
const { lines, logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
|
||||||
|
await writeFile(path, 'maxItems: "lots"\n');
|
||||||
|
await waitFor(() => lines.some((l) => l.level === 50));
|
||||||
|
|
||||||
|
expect(rc.get().maxItems).toBe(10);
|
||||||
|
const err = lines.find((l) => l.level === 50)!;
|
||||||
|
expect(err.msg).toMatch(/keeping last good/);
|
||||||
|
expect(JSON.stringify(err.issues)).toMatch(/maxItems/);
|
||||||
|
|
||||||
|
// and recovers on the next good commit
|
||||||
|
await writeFile(path, "maxItems: 20\n");
|
||||||
|
await waitFor(() => rc!.get().maxItems === 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sees a kubelet-style ..data symlink swap", async () => {
|
||||||
|
// Mimic a ConfigMap volume: runtime.yaml -> ..data/runtime.yaml,
|
||||||
|
// ..data -> ..v1; an update writes ..v2 and atomically renames ..data.
|
||||||
|
await mkdir(join(dir, "..v1"));
|
||||||
|
await writeFile(join(dir, "..v1", "runtime.yaml"), "greeting: v1\n");
|
||||||
|
await symlink("..v1", join(dir, "..data"));
|
||||||
|
await symlink("..data/runtime.yaml", path);
|
||||||
|
const { logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
expect(rc.get().greeting).toBe("v1");
|
||||||
|
|
||||||
|
await mkdir(join(dir, "..v2"));
|
||||||
|
await writeFile(join(dir, "..v2", "runtime.yaml"), "greeting: v2\n");
|
||||||
|
await symlink("..v2", join(dir, "..data_tmp"));
|
||||||
|
await rename(join(dir, "..data_tmp"), join(dir, "..data"));
|
||||||
|
await rm(join(dir, "..v1"), { recursive: true });
|
||||||
|
|
||||||
|
await waitFor(() => rc!.get().greeting === "v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET / uses greeting; GET /admin/runtime-config serves the snapshot", async () => {
|
||||||
|
await writeFile(path, "greeting: howdy\n");
|
||||||
|
const { logger } = capture();
|
||||||
|
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||||
|
const app = createApp({
|
||||||
|
config: parseConfig({ SERVICE_NAME: "test-svc" }),
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
runtimeConfig: rc,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await request(app).get("/")).body.greeting).toBe("howdy");
|
||||||
|
const admin = await request(app).get("/admin/runtime-config");
|
||||||
|
expect(admin.status).toBe(200);
|
||||||
|
expect(admin.body).toMatchObject({
|
||||||
|
source: "file",
|
||||||
|
values: { greeting: "howdy", maxItems: 50, featureHints: [] },
|
||||||
|
});
|
||||||
|
expect(Date.parse(admin.body.loadedAt)).not.toBeNaN();
|
||||||
|
|
||||||
|
await writeFile(path, "greeting: changed\n");
|
||||||
|
await waitFor(() => rc!.get().greeting === "changed");
|
||||||
|
expect((await request(app).get("/")).body.greeting).toBe("changed");
|
||||||
|
});
|
||||||
|
});
|
||||||
218
src/lib/runtime-config.ts
Normal file
218
src/lib/runtime-config.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { basename, dirname } from "node:path";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import {
|
||||||
|
RuntimeConfigSchema,
|
||||||
|
type RuntimeConfigValues,
|
||||||
|
} from "../runtime-config.schema.js";
|
||||||
|
|
||||||
|
export type RuntimeConfigSource = "file" | "defaults";
|
||||||
|
|
||||||
|
export interface RuntimeConfigSnapshot {
|
||||||
|
values: RuntimeConfigValues;
|
||||||
|
loadedAt: string;
|
||||||
|
source: RuntimeConfigSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
/** Current values. Cheap; call per request, don't cache the result. */
|
||||||
|
get(): RuntimeConfigValues;
|
||||||
|
/** Values plus where/when they came from (served by /admin/runtime-config). */
|
||||||
|
snapshot(): RuntimeConfigSnapshot;
|
||||||
|
/** Called after each successful reload that changed something. Returns an unsubscribe. */
|
||||||
|
onChange(
|
||||||
|
cb: (values: RuntimeConfigValues, changedKeys: string[]) => void,
|
||||||
|
): () => void;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaults(): RuntimeConfigValues {
|
||||||
|
return RuntimeConfigSchema.parse({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema defaults, never reloads. For tests and when RUNTIME_CONFIG_PATH is unset. */
|
||||||
|
export function staticRuntimeConfig(
|
||||||
|
values: RuntimeConfigValues = defaults(),
|
||||||
|
): RuntimeConfig {
|
||||||
|
const snap: RuntimeConfigSnapshot = {
|
||||||
|
values,
|
||||||
|
loadedAt: new Date().toISOString(),
|
||||||
|
source: "defaults",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get: () => snap.values,
|
||||||
|
snapshot: () => snap,
|
||||||
|
onChange: () => () => {},
|
||||||
|
close: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadResult =
|
||||||
|
| { ok: true; values: RuntimeConfigValues }
|
||||||
|
| { ok: false; missing: boolean; error: string; issues?: unknown };
|
||||||
|
|
||||||
|
async function load(path: string): Promise<LoadResult> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(path, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
const missing = (err as NodeJS.ErrnoException).code === "ENOENT";
|
||||||
|
return { ok: false, missing, error: String(err) };
|
||||||
|
}
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = parseYaml(text) ?? {};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, missing: false, error: `invalid YAML: ${String(err)}` };
|
||||||
|
}
|
||||||
|
const result = RuntimeConfigSchema.safeParse(raw);
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
missing: false,
|
||||||
|
error: "schema validation failed",
|
||||||
|
issues: result.error.issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, values: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedKeys(a: RuntimeConfigValues, b: RuntimeConfigValues): string[] {
|
||||||
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||||
|
return [...keys].filter(
|
||||||
|
(k) =>
|
||||||
|
JSON.stringify((a as Record<string, unknown>)[k]) !==
|
||||||
|
JSON.stringify((b as Record<string, unknown>)[k]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the runtime config file at boot and hot-reloads it.
|
||||||
|
*
|
||||||
|
* Kubernetes updates a ConfigMap volume by swapping a `..data` symlink in the
|
||||||
|
* mount directory (never the file itself), so this watches the DIRECTORY and
|
||||||
|
* re-reads the file through its symlink. Never mount with `subPath`: those
|
||||||
|
* mounts are not updated.
|
||||||
|
*
|
||||||
|
* A bad commit (invalid YAML, schema failure, file gone) keeps the last good
|
||||||
|
* values and logs `error`; a running pod never crashes on config.
|
||||||
|
*/
|
||||||
|
export async function createRuntimeConfig(opts: {
|
||||||
|
path?: string;
|
||||||
|
logger: Logger;
|
||||||
|
debounceMs?: number;
|
||||||
|
}): Promise<RuntimeConfig> {
|
||||||
|
const { path, logger } = opts;
|
||||||
|
const debounceMs = opts.debounceMs ?? 200;
|
||||||
|
const log = logger.child({ component: "runtime-config" });
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
log.info("runtime config: RUNTIME_CONFIG_PATH unset, using defaults");
|
||||||
|
return staticRuntimeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
let snap: RuntimeConfigSnapshot;
|
||||||
|
const listeners = new Set<
|
||||||
|
(values: RuntimeConfigValues, changed: string[]) => void
|
||||||
|
>();
|
||||||
|
|
||||||
|
const first = await load(path);
|
||||||
|
if (first.ok) {
|
||||||
|
snap = {
|
||||||
|
values: first.values,
|
||||||
|
loadedAt: new Date().toISOString(),
|
||||||
|
source: "file",
|
||||||
|
};
|
||||||
|
log.info({ path, values: snap.values }, "runtime config loaded");
|
||||||
|
} else {
|
||||||
|
snap = {
|
||||||
|
values: defaults(),
|
||||||
|
loadedAt: new Date().toISOString(),
|
||||||
|
source: "defaults",
|
||||||
|
};
|
||||||
|
if (first.missing) {
|
||||||
|
log.warn({ path }, "runtime config file missing, using defaults");
|
||||||
|
} else {
|
||||||
|
log.error(
|
||||||
|
{ path, error: first.error, issues: first.issues },
|
||||||
|
"runtime config invalid at boot, using defaults",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const r = await load(path!);
|
||||||
|
if (!r.ok) {
|
||||||
|
log.error(
|
||||||
|
{ path, error: r.error, issues: r.issues },
|
||||||
|
"runtime config reload failed, keeping last good values",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changed = changedKeys(snap.values, r.values);
|
||||||
|
if (changed.length === 0 && snap.source === "file") return;
|
||||||
|
snap = {
|
||||||
|
values: r.values,
|
||||||
|
loadedAt: new Date().toISOString(),
|
||||||
|
source: "file",
|
||||||
|
};
|
||||||
|
log.info(
|
||||||
|
{ path, changedKeys: changed, values: r.values },
|
||||||
|
"runtime config reloaded",
|
||||||
|
);
|
||||||
|
for (const cb of listeners) {
|
||||||
|
try {
|
||||||
|
cb(r.values, changed);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "runtime config onChange listener threw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kubelet's swap fires several events in a burst; debounce them.
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const schedule = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => void reload(), debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
let watcher: FSWatcher | undefined;
|
||||||
|
const dir = dirname(path);
|
||||||
|
try {
|
||||||
|
watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
||||||
|
// Plain dirs report the file itself; ConfigMap mounts report `..data`
|
||||||
|
// / `..<timestamp>` entries. Anything else in the dir is ignored.
|
||||||
|
if (
|
||||||
|
!filename ||
|
||||||
|
filename === basename(path) ||
|
||||||
|
filename.startsWith("..")
|
||||||
|
) {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watcher.on("error", (err) =>
|
||||||
|
log.error({ err, dir }, "runtime config watcher error"),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{ err, dir },
|
||||||
|
"runtime config: cannot watch directory, no hot reload",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => snap.values,
|
||||||
|
snapshot: () => snap,
|
||||||
|
onChange(cb) {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
watcher?.close();
|
||||||
|
listeners.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/routes/admin.ts
Normal file
14
src/routes/admin.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import type { RuntimeConfig } from "../lib/runtime-config.js";
|
||||||
|
|
||||||
|
// Unauthenticated by design (LAN homelab). Runtime config holds no secrets
|
||||||
|
// by contract; see src/runtime-config.schema.ts.
|
||||||
|
export function adminRouter(deps: { runtimeConfig: RuntimeConfig }): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/admin/runtime-config", (_req, res) => {
|
||||||
|
res.json(deps.runtimeConfig.snapshot());
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
35
src/routes/health.ts
Normal file
35
src/routes/health.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import type { Db } from "../lib/db.js";
|
||||||
|
import type { Cache } from "../lib/redis.js";
|
||||||
|
|
||||||
|
export function healthRouter(deps: { db?: Db; cache?: Cache }): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Liveness: 200 whenever the process is listening.
|
||||||
|
router.get("/healthz", (_req, res) => {
|
||||||
|
res.type("text/plain").send("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Readiness: every configured backend must answer.
|
||||||
|
router.get("/readyz", async (_req, res) => {
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
let ready = true;
|
||||||
|
const check = async (name: string, fn?: () => Promise<void>) => {
|
||||||
|
if (!fn) return;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
body[name] = "ok";
|
||||||
|
} catch (err) {
|
||||||
|
ready = false;
|
||||||
|
body[name] = `unavailable: ${(err as Error).message || "error"}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await check("postgres", deps.db && (() => deps.db!.ping()));
|
||||||
|
await check("redis", deps.cache && (() => deps.cache!.ping()));
|
||||||
|
res
|
||||||
|
.status(ready ? 200 : 503)
|
||||||
|
.json({ status: ready ? "ready" : "not-ready", ...body });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
17
src/routes/log-demo.ts
Normal file
17
src/routes/log-demo.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
// Developer-story demo for the logging pipeline: every field in the first
|
||||||
|
// argument to req.log.* becomes a queryable field in Kibana (data view kube-*).
|
||||||
|
// The message string always wins over a `msg` key in the object, so the
|
||||||
|
// caller's text goes in `text`.
|
||||||
|
export function logDemoRouter(): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/log-demo", (req, res) => {
|
||||||
|
const text = typeof req.query.msg === "string" ? req.query.msg : "";
|
||||||
|
req.log.info({ demo: true, text }, "log-demo");
|
||||||
|
res.json({ logged: true, text, requestId: req.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
49
src/routes/root.ts
Normal file
49
src/routes/root.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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";
|
||||||
|
import type { RuntimeConfig } from "../lib/runtime-config.js";
|
||||||
|
|
||||||
|
export function rootRouter(deps: {
|
||||||
|
config: Config;
|
||||||
|
db?: Db;
|
||||||
|
cache?: Cache;
|
||||||
|
flags?: Flags;
|
||||||
|
runtimeConfig: RuntimeConfig;
|
||||||
|
}): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
service: deps.config.SERVICE_NAME,
|
||||||
|
version: deps.config.GIT_SHA,
|
||||||
|
pod: hostname(),
|
||||||
|
// Runtime config (Plan 08): edit the ConfigMap in gitops, no restart.
|
||||||
|
greeting: deps.runtimeConfig.get().greeting,
|
||||||
|
};
|
||||||
|
// 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",
|
||||||
|
);
|
||||||
|
body.db = r.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
if (deps.cache) {
|
||||||
|
body.cacheHits = await deps.cache.incr(
|
||||||
|
`${deps.config.SERVICE_NAME}:hits`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json(body);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
22
src/runtime-config.schema.ts
Normal file
22
src/runtime-config.schema.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime config: values that change by a gitops commit, without a restart.
|
||||||
|
* Source: ConfigMap `<app>-runtime-config` (key `runtime.yaml`), mounted at
|
||||||
|
* RUNTIME_CONFIG_PATH. Edit this schema for your service.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Every key MUST have a default: a missing file, or a missing key, still
|
||||||
|
* yields a complete config.
|
||||||
|
* - Secrets never go here. `GET /admin/runtime-config` serves these values
|
||||||
|
* unauthenticated, and the ConfigMap is plain text in git.
|
||||||
|
* - Unknown keys are dropped, not rejected, so a config commit that adds a
|
||||||
|
* key can land before the code that reads it.
|
||||||
|
*/
|
||||||
|
export const RuntimeConfigSchema = z.object({
|
||||||
|
greeting: z.string().min(1).default("hello"),
|
||||||
|
maxItems: z.number().int().positive().default(50),
|
||||||
|
featureHints: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RuntimeConfigValues = z.infer<typeof RuntimeConfigSchema>;
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue