Skip to content

Commit 331e26d

Browse files
authored
Merge pull request #92 from PMDevSolutions/54-add-health-check-endpoint-with-dependency-status
feat(template): dependency-checking /health endpoint (#54)
2 parents a03f77a + a0e034e commit 331e26d

9 files changed

Lines changed: 743 additions & 64 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Health endpoint with dependency checks — design & implementation plan
2+
3+
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task.
4+
5+
**Issue:** #54
6+
**Goal:** Replace the static `{ status: 'ok' }` health endpoint in `setup-project.sh` with a real liveness check that pings the database, reports version and uptime, and returns `503` when a dependency is down. Apply to both Cloudflare Workers and Node.js templates and mirror in `examples/todo-api-cloudflare`.
7+
8+
---
9+
10+
## Response shape
11+
12+
Healthy (HTTP `200`):
13+
14+
```json
15+
{
16+
"status": "healthy",
17+
"version": "0.0.1",
18+
"uptime": 3600,
19+
"timestamp": "2026-04-08T12:00:00Z",
20+
"requestId": "01HXYZ...",
21+
"checks": { "database": "connected" }
22+
}
23+
```
24+
25+
Unhealthy (HTTP `503`): same shape with `status: "unhealthy"` and the failing check set to `"disconnected"`.
26+
27+
`checks` is an object so future dependencies (Redis, S3, external API) drop in as `checks.redis: "connected"` without breaking consumers. Overall `status` is `"unhealthy"` if any check is `"disconnected"`.
28+
29+
---
30+
31+
## Design decisions
32+
33+
| Decision | Choice | Rationale |
34+
|---|---|---|
35+
| Uptime on Workers | `Date.now() - startTime` captured at module load | Honest about isolate semantics; zero cost; no extra bindings |
36+
| Version source | `APP_VERSION` env var (both templates) | One pattern across Workers and Node; deploys can override |
37+
| File layout | `src/routes/health.ts` + `src/db/ping.ts` | Matches existing route-extraction pattern (e.g. `routes/todos.ts`); keeps `index.ts` tidy |
38+
| DB ping timeout | 2 s default, `HEALTH_DB_TIMEOUT_MS` overrides | Well under k8s `livenessProbe` default (10 s) and Workers CPU budget |
39+
| Missing binding/env | Counts as `disconnected` | A config-level outage is still an outage from the prober's view |
40+
| Internal throw | Caught and returned as `503` | Health endpoint must never `500` — load balancers treat `5xx` as "needs restart" |
41+
| Node DB connection | New `src/db/client.ts` singleton; refactor `seed.ts` to import it | First feature to need a shared client; pays off for future routes too |
42+
43+
---
44+
45+
## File tree (deltas, both templates unless noted)
46+
47+
```
48+
api/
49+
├── src/
50+
│ ├── index.ts # MODIFIED: mount healthRoutes
51+
│ ├── routes/
52+
│ │ └── health.ts # NEW
53+
│ └── db/
54+
│ ├── client.ts # NEW (Node only)
55+
│ ├── ping.ts # NEW (template-specific body)
56+
│ └── seed.ts # MODIFIED (Node only): import client
57+
├── tests/
58+
│ └── unit/
59+
│ └── health.test.ts # EXPANDED
60+
├── .dev.vars.example # Workers: + APP_VERSION, HEALTH_DB_TIMEOUT_MS
61+
├── .env.example # Node: + APP_VERSION, HEALTH_DB_TIMEOUT_MS
62+
└── wrangler.toml # Workers: APP_VERSION in [vars]
63+
```
64+
65+
---
66+
67+
## Task 1: Add Node `src/db/client.ts` shared singleton, refactor `seed.ts`
68+
69+
**Setup-project.sh changes (Node branch only):**
70+
71+
Add a `write_file "$API_DIR/src/db/client.ts"` block:
72+
73+
```typescript
74+
import { drizzle } from 'drizzle-orm/postgres-js';
75+
import postgres from 'postgres';
76+
import * as schema from './schema.js';
77+
78+
const databaseUrl = process.env.DATABASE_URL;
79+
if (!databaseUrl) {
80+
throw new Error('DATABASE_URL environment variable is required');
81+
}
82+
83+
export const client = postgres(databaseUrl);
84+
export const db = drizzle(client, { schema });
85+
```
86+
87+
Refactor `seed.ts` to import `client` and `db` from `./client.js` instead of constructing its own `postgres()` call. Keep `client.end()` at the end of `seed()`.
88+
89+
**Verify:** generate a Node project, run `pnpm db:seed` — must connect and seed without errors.
90+
91+
**Commit:** `refactor(template): extract shared postgres client for Node template`
92+
93+
---
94+
95+
## Task 2: Add `src/db/ping.ts` to both templates
96+
97+
**Workers branch:**
98+
99+
```typescript
100+
import type { Context } from 'hono';
101+
import postgres from 'postgres';
102+
103+
type Bindings = { HYPERDRIVE: Hyperdrive };
104+
105+
export async function pingDatabase(c: Context<{ Bindings: Bindings }>): Promise<'connected' | 'disconnected'> {
106+
if (!c.env.HYPERDRIVE?.connectionString) return 'disconnected';
107+
const sql = postgres(c.env.HYPERDRIVE.connectionString, { max: 1, fetch_types: false });
108+
try {
109+
await sql`SELECT 1`;
110+
return 'connected';
111+
} catch {
112+
return 'disconnected';
113+
} finally {
114+
await sql.end({ timeout: 1 }).catch(() => {});
115+
}
116+
}
117+
```
118+
119+
**Node branch:**
120+
121+
```typescript
122+
import { client } from './client.js';
123+
124+
export async function pingDatabase(): Promise<'connected' | 'disconnected'> {
125+
try {
126+
await client`SELECT 1`;
127+
return 'connected';
128+
} catch {
129+
return 'disconnected';
130+
}
131+
}
132+
```
133+
134+
**Commit:** `feat(template): add pingDatabase helper for health checks`
135+
136+
---
137+
138+
## Task 3: Add `src/routes/health.ts` to both templates
139+
140+
Common skeleton (Workers shown; Node omits the `c` arg to `pingDatabase()`):
141+
142+
```typescript
143+
import { Hono } from 'hono';
144+
import { pingDatabase } from '../db/ping';
145+
146+
const startTime = Date.now();
147+
148+
type Bindings = {
149+
APP_VERSION: string;
150+
HEALTH_DB_TIMEOUT_MS: string;
151+
HYPERDRIVE: Hyperdrive;
152+
};
153+
154+
export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => {
155+
const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS ?? 2000);
156+
const timeout = new Promise<'disconnected'>((resolve) =>
157+
setTimeout(() => resolve('disconnected'), timeoutMs),
158+
);
159+
160+
let database: 'connected' | 'disconnected';
161+
try {
162+
database = await Promise.race([pingDatabase(c), timeout]);
163+
} catch {
164+
database = 'disconnected';
165+
}
166+
167+
const status = database === 'connected' ? 'healthy' : 'unhealthy';
168+
const body = {
169+
status,
170+
version: c.env.APP_VERSION ?? 'unknown',
171+
uptime: Math.floor((Date.now() - startTime) / 1000),
172+
timestamp: new Date().toISOString(),
173+
requestId: c.get('requestId'),
174+
checks: { database },
175+
};
176+
return c.json(body, status === 'healthy' ? 200 : 503);
177+
});
178+
```
179+
180+
**Wire into `index.ts`** (both templates): replace the inline `app.get('/health', ...)` with:
181+
182+
```typescript
183+
import { healthRoutes } from './routes/health';
184+
// ...
185+
app.route('/health', healthRoutes);
186+
```
187+
188+
**Commit:** `feat(template): replace static /health with dependency-checking handler`
189+
190+
---
191+
192+
## Task 4: Update env/config templates
193+
194+
**Workers (`.dev.vars.example`):**
195+
196+
```
197+
APP_VERSION=0.0.1
198+
HEALTH_DB_TIMEOUT_MS=2000
199+
```
200+
201+
**Workers (`wrangler.toml` `[vars]` block):**
202+
203+
```toml
204+
[vars]
205+
ENVIRONMENT = "development"
206+
APP_VERSION = "0.0.1"
207+
HEALTH_DB_TIMEOUT_MS = "2000"
208+
```
209+
210+
**Node (`.env.example`):**
211+
212+
```
213+
APP_VERSION=0.0.1
214+
HEALTH_DB_TIMEOUT_MS=2000
215+
```
216+
217+
**Commit:** `chore(template): add APP_VERSION and HEALTH_DB_TIMEOUT_MS to env templates`
218+
219+
---
220+
221+
## Task 5: Expand `tests/unit/health.test.ts`
222+
223+
Test cases (both templates):
224+
225+
1. **Healthy path**`pingDatabase` mocked → `'connected'`. Asserts `status: 'healthy'`, `checks.database: 'connected'`, HTTP `200`.
226+
2. **Version from env**`APP_VERSION=0.0.1` in test env. Asserts `body.version === '0.0.1'`.
227+
3. **Uptime numeric** — Asserts `typeof body.uptime === 'number'` and `body.uptime >= 0`.
228+
4. **Unhealthy when DB disconnected**`pingDatabase` mocked → `'disconnected'`. Asserts `status: 'unhealthy'`, `checks.database: 'disconnected'`, HTTP `503`.
229+
5. **Unhealthy on timeout**`pingDatabase` mocked to return a never-resolving promise; fake timers advance past `HEALTH_DB_TIMEOUT_MS`. Asserts `503`.
230+
6. **Unhealthy on thrown error**`pingDatabase` mocked to throw. Asserts `503` (never `500`).
231+
7. **Preserves requestId + X-Request-Id header** — keeps the existing three assertions but updates the status assertion from `'ok'``'healthy'`.
232+
233+
Use `vi.mock('../../src/db/ping')` to swap `pingDatabase`. No real postgres needed in CI.
234+
235+
**Commit:** `test(template): cover health endpoint dependency status, timeout, and error paths`
236+
237+
---
238+
239+
## Task 6: Mirror in `examples/todo-api-cloudflare`
240+
241+
This example has its own `src/index.ts`, `src/routes/`, and `tests/unit/health.test.ts`. Apply Tasks 2, 3, and 5 to the example (its DB is D1, not Hyperdrive, so `pingDatabase` uses `c.env.DB.prepare('SELECT 1').first()` instead of `postgres()`).
242+
243+
Update `wrangler.toml` `[vars]` and `.dev.vars.example` in the example accordingly.
244+
245+
**Commit:** `feat(example): wire dependency-checking /health into todo-api-cloudflare`
246+
247+
---
248+
249+
## Task 7: Smoke-test generated templates
250+
251+
```bash
252+
# Node smoke test
253+
./scripts/setup-project.sh /tmp/nerva-health-node --node
254+
cd /tmp/nerva-health-node && pnpm install && pnpm typecheck && pnpm test
255+
256+
# Workers smoke test
257+
./scripts/setup-project.sh /tmp/nerva-health-cf --cloudflare
258+
cd /tmp/nerva-health-cf && pnpm install && pnpm typecheck && pnpm test
259+
```
260+
261+
Both must pass typecheck and the health tests. If a generated project doesn't compile, fix the template — not the generated output.
262+
263+
**Verify also:** existing CI matrix (`Node.js 20 Compatibility`, `Node.js 22 Compatibility`, `Check Markdown Links`, `Validate Structure`) passes on the PR.
264+
265+
---
266+
267+
## Out of scope
268+
269+
- Separate `/health/live` vs `/health/ready` (k8s-style split) — single endpoint matches the issue's example response.
270+
- External-service checks beyond DB — pattern is in place for future additions.
271+
- Auto-injecting `APP_VERSION` from `package.json` at build time — env var pattern is the contract; build-time substitution is a follow-up.

examples/todo-api-cloudflare/api/.dev.vars.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
# cp .dev.vars.example .dev.vars
44

55
ENVIRONMENT=development
6+
APP_VERSION=0.0.1
7+
HEALTH_DB_TIMEOUT_MS=2000
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export async function pingDatabase(db: D1Database | undefined): Promise<'connected' | 'disconnected'> {
2+
if (!db) return 'disconnected';
3+
try {
4+
await db.prepare('SELECT 1').first();
5+
return 'connected';
6+
} catch {
7+
return 'disconnected';
8+
}
9+
}

examples/todo-api-cloudflare/api/src/index.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { etag } from 'hono/etag';
44
import { logger } from 'hono/logger';
55
import { requestId } from 'hono/request-id';
66
import { secureHeaders } from 'hono/secure-headers';
7+
import { healthRoutes } from './routes/health';
78
import { todosRoutes } from './routes/todos';
89
// Note: Response compression is handled automatically by Cloudflare's edge network.
910
// No compress() middleware is needed for Workers deployments.
1011

1112
interface Bindings {
1213
DB: D1Database;
1314
ENVIRONMENT: string;
15+
APP_VERSION: string;
16+
HEALTH_DB_TIMEOUT_MS: string;
1417
}
1518

1619
const app = new Hono<{ Bindings: Bindings }>();
@@ -22,16 +25,8 @@ app.use('*', etag());
2225
app.use('*', secureHeaders());
2326
app.use('*', requestId());
2427

25-
// --- Health check ---
26-
app.get('/health', (c) => {
27-
return c.json({
28-
status: 'ok',
29-
requestId: c.get('requestId'),
30-
timestamp: new Date().toISOString(),
31-
});
32-
});
33-
3428
// --- Routes ---
29+
app.route('/health', healthRoutes);
3530
app.route('/todos', todosRoutes);
3631

3732
// --- Root ---
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Hono } from 'hono';
2+
import { pingDatabase } from '../db/ping';
3+
4+
const startTime = Date.now();
5+
6+
interface Bindings {
7+
DB: D1Database;
8+
APP_VERSION: string;
9+
HEALTH_DB_TIMEOUT_MS: string;
10+
}
11+
12+
export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => {
13+
const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS) || 2000;
14+
const timeout = new Promise<'disconnected'>((resolve) =>
15+
setTimeout(() => resolve('disconnected'), timeoutMs),
16+
);
17+
18+
let database: 'connected' | 'disconnected';
19+
try {
20+
database = await Promise.race([pingDatabase(c.env.DB), timeout]);
21+
} catch {
22+
database = 'disconnected';
23+
}
24+
25+
const status = database === 'connected' ? 'healthy' : 'unhealthy';
26+
const body = {
27+
status,
28+
version: c.env.APP_VERSION ?? 'unknown',
29+
uptime: Math.floor((Date.now() - startTime) / 1000),
30+
timestamp: new Date().toISOString(),
31+
requestId: c.get('requestId'),
32+
checks: { database },
33+
};
34+
return c.json(body, status === 'healthy' ? 200 : 503);
35+
});

0 commit comments

Comments
 (0)