diff --git a/docs/plans/2026-05-14-health-endpoint-dependency-checks.md b/docs/plans/2026-05-14-health-endpoint-dependency-checks.md new file mode 100644 index 0000000..cba3d29 --- /dev/null +++ b/docs/plans/2026-05-14-health-endpoint-dependency-checks.md @@ -0,0 +1,271 @@ +# Health endpoint with dependency checks — design & implementation plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. + +**Issue:** #54 +**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`. + +--- + +## Response shape + +Healthy (HTTP `200`): + +```json +{ + "status": "healthy", + "version": "0.0.1", + "uptime": 3600, + "timestamp": "2026-04-08T12:00:00Z", + "requestId": "01HXYZ...", + "checks": { "database": "connected" } +} +``` + +Unhealthy (HTTP `503`): same shape with `status: "unhealthy"` and the failing check set to `"disconnected"`. + +`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"`. + +--- + +## Design decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Uptime on Workers | `Date.now() - startTime` captured at module load | Honest about isolate semantics; zero cost; no extra bindings | +| Version source | `APP_VERSION` env var (both templates) | One pattern across Workers and Node; deploys can override | +| File layout | `src/routes/health.ts` + `src/db/ping.ts` | Matches existing route-extraction pattern (e.g. `routes/todos.ts`); keeps `index.ts` tidy | +| DB ping timeout | 2 s default, `HEALTH_DB_TIMEOUT_MS` overrides | Well under k8s `livenessProbe` default (10 s) and Workers CPU budget | +| Missing binding/env | Counts as `disconnected` | A config-level outage is still an outage from the prober's view | +| Internal throw | Caught and returned as `503` | Health endpoint must never `500` — load balancers treat `5xx` as "needs restart" | +| 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 | + +--- + +## File tree (deltas, both templates unless noted) + +``` +api/ +├── src/ +│ ├── index.ts # MODIFIED: mount healthRoutes +│ ├── routes/ +│ │ └── health.ts # NEW +│ └── db/ +│ ├── client.ts # NEW (Node only) +│ ├── ping.ts # NEW (template-specific body) +│ └── seed.ts # MODIFIED (Node only): import client +├── tests/ +│ └── unit/ +│ └── health.test.ts # EXPANDED +├── .dev.vars.example # Workers: + APP_VERSION, HEALTH_DB_TIMEOUT_MS +├── .env.example # Node: + APP_VERSION, HEALTH_DB_TIMEOUT_MS +└── wrangler.toml # Workers: APP_VERSION in [vars] +``` + +--- + +## Task 1: Add Node `src/db/client.ts` shared singleton, refactor `seed.ts` + +**Setup-project.sh changes (Node branch only):** + +Add a `write_file "$API_DIR/src/db/client.ts"` block: + +```typescript +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema.js'; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); +} + +export const client = postgres(databaseUrl); +export const db = drizzle(client, { schema }); +``` + +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()`. + +**Verify:** generate a Node project, run `pnpm db:seed` — must connect and seed without errors. + +**Commit:** `refactor(template): extract shared postgres client for Node template` + +--- + +## Task 2: Add `src/db/ping.ts` to both templates + +**Workers branch:** + +```typescript +import type { Context } from 'hono'; +import postgres from 'postgres'; + +type Bindings = { HYPERDRIVE: Hyperdrive }; + +export async function pingDatabase(c: Context<{ Bindings: Bindings }>): Promise<'connected' | 'disconnected'> { + if (!c.env.HYPERDRIVE?.connectionString) return 'disconnected'; + const sql = postgres(c.env.HYPERDRIVE.connectionString, { max: 1, fetch_types: false }); + try { + await sql`SELECT 1`; + return 'connected'; + } catch { + return 'disconnected'; + } finally { + await sql.end({ timeout: 1 }).catch(() => {}); + } +} +``` + +**Node branch:** + +```typescript +import { client } from './client.js'; + +export async function pingDatabase(): Promise<'connected' | 'disconnected'> { + try { + await client`SELECT 1`; + return 'connected'; + } catch { + return 'disconnected'; + } +} +``` + +**Commit:** `feat(template): add pingDatabase helper for health checks` + +--- + +## Task 3: Add `src/routes/health.ts` to both templates + +Common skeleton (Workers shown; Node omits the `c` arg to `pingDatabase()`): + +```typescript +import { Hono } from 'hono'; +import { pingDatabase } from '../db/ping'; + +const startTime = Date.now(); + +type Bindings = { + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; + HYPERDRIVE: Hyperdrive; +}; + +export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => { + const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS ?? 2000); + const timeout = new Promise<'disconnected'>((resolve) => + setTimeout(() => resolve('disconnected'), timeoutMs), + ); + + let database: 'connected' | 'disconnected'; + try { + database = await Promise.race([pingDatabase(c), timeout]); + } catch { + database = 'disconnected'; + } + + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: c.env.APP_VERSION ?? 'unknown', + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + requestId: c.get('requestId'), + checks: { database }, + }; + return c.json(body, status === 'healthy' ? 200 : 503); +}); +``` + +**Wire into `index.ts`** (both templates): replace the inline `app.get('/health', ...)` with: + +```typescript +import { healthRoutes } from './routes/health'; +// ... +app.route('/health', healthRoutes); +``` + +**Commit:** `feat(template): replace static /health with dependency-checking handler` + +--- + +## Task 4: Update env/config templates + +**Workers (`.dev.vars.example`):** + +``` +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 +``` + +**Workers (`wrangler.toml` `[vars]` block):** + +```toml +[vars] +ENVIRONMENT = "development" +APP_VERSION = "0.0.1" +HEALTH_DB_TIMEOUT_MS = "2000" +``` + +**Node (`.env.example`):** + +``` +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 +``` + +**Commit:** `chore(template): add APP_VERSION and HEALTH_DB_TIMEOUT_MS to env templates` + +--- + +## Task 5: Expand `tests/unit/health.test.ts` + +Test cases (both templates): + +1. **Healthy path** — `pingDatabase` mocked → `'connected'`. Asserts `status: 'healthy'`, `checks.database: 'connected'`, HTTP `200`. +2. **Version from env** — `APP_VERSION=0.0.1` in test env. Asserts `body.version === '0.0.1'`. +3. **Uptime numeric** — Asserts `typeof body.uptime === 'number'` and `body.uptime >= 0`. +4. **Unhealthy when DB disconnected** — `pingDatabase` mocked → `'disconnected'`. Asserts `status: 'unhealthy'`, `checks.database: 'disconnected'`, HTTP `503`. +5. **Unhealthy on timeout** — `pingDatabase` mocked to return a never-resolving promise; fake timers advance past `HEALTH_DB_TIMEOUT_MS`. Asserts `503`. +6. **Unhealthy on thrown error** — `pingDatabase` mocked to throw. Asserts `503` (never `500`). +7. **Preserves requestId + X-Request-Id header** — keeps the existing three assertions but updates the status assertion from `'ok'` → `'healthy'`. + +Use `vi.mock('../../src/db/ping')` to swap `pingDatabase`. No real postgres needed in CI. + +**Commit:** `test(template): cover health endpoint dependency status, timeout, and error paths` + +--- + +## Task 6: Mirror in `examples/todo-api-cloudflare` + +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()`). + +Update `wrangler.toml` `[vars]` and `.dev.vars.example` in the example accordingly. + +**Commit:** `feat(example): wire dependency-checking /health into todo-api-cloudflare` + +--- + +## Task 7: Smoke-test generated templates + +```bash +# Node smoke test +./scripts/setup-project.sh /tmp/nerva-health-node --node +cd /tmp/nerva-health-node && pnpm install && pnpm typecheck && pnpm test + +# Workers smoke test +./scripts/setup-project.sh /tmp/nerva-health-cf --cloudflare +cd /tmp/nerva-health-cf && pnpm install && pnpm typecheck && pnpm test +``` + +Both must pass typecheck and the health tests. If a generated project doesn't compile, fix the template — not the generated output. + +**Verify also:** existing CI matrix (`Node.js 20 Compatibility`, `Node.js 22 Compatibility`, `Check Markdown Links`, `Validate Structure`) passes on the PR. + +--- + +## Out of scope + +- Separate `/health/live` vs `/health/ready` (k8s-style split) — single endpoint matches the issue's example response. +- External-service checks beyond DB — pattern is in place for future additions. +- Auto-injecting `APP_VERSION` from `package.json` at build time — env var pattern is the contract; build-time substitution is a follow-up. diff --git a/examples/todo-api-cloudflare/api/.dev.vars.example b/examples/todo-api-cloudflare/api/.dev.vars.example index b579349..0e73b8a 100644 --- a/examples/todo-api-cloudflare/api/.dev.vars.example +++ b/examples/todo-api-cloudflare/api/.dev.vars.example @@ -3,3 +3,5 @@ # cp .dev.vars.example .dev.vars ENVIRONMENT=development +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 diff --git a/examples/todo-api-cloudflare/api/src/db/ping.ts b/examples/todo-api-cloudflare/api/src/db/ping.ts new file mode 100644 index 0000000..acaeb75 --- /dev/null +++ b/examples/todo-api-cloudflare/api/src/db/ping.ts @@ -0,0 +1,9 @@ +export async function pingDatabase(db: D1Database | undefined): Promise<'connected' | 'disconnected'> { + if (!db) return 'disconnected'; + try { + await db.prepare('SELECT 1').first(); + return 'connected'; + } catch { + return 'disconnected'; + } +} diff --git a/examples/todo-api-cloudflare/api/src/index.ts b/examples/todo-api-cloudflare/api/src/index.ts index df0c8c7..70c1b3a 100644 --- a/examples/todo-api-cloudflare/api/src/index.ts +++ b/examples/todo-api-cloudflare/api/src/index.ts @@ -4,6 +4,7 @@ import { etag } from 'hono/etag'; import { logger } from 'hono/logger'; import { requestId } from 'hono/request-id'; import { secureHeaders } from 'hono/secure-headers'; +import { healthRoutes } from './routes/health'; import { todosRoutes } from './routes/todos'; // Note: Response compression is handled automatically by Cloudflare's edge network. // No compress() middleware is needed for Workers deployments. @@ -11,6 +12,8 @@ import { todosRoutes } from './routes/todos'; interface Bindings { DB: D1Database; ENVIRONMENT: string; + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; } const app = new Hono<{ Bindings: Bindings }>(); @@ -22,16 +25,8 @@ app.use('*', etag()); app.use('*', secureHeaders()); app.use('*', requestId()); -// --- Health check --- -app.get('/health', (c) => { - return c.json({ - status: 'ok', - requestId: c.get('requestId'), - timestamp: new Date().toISOString(), - }); -}); - // --- Routes --- +app.route('/health', healthRoutes); app.route('/todos', todosRoutes); // --- Root --- diff --git a/examples/todo-api-cloudflare/api/src/routes/health.ts b/examples/todo-api-cloudflare/api/src/routes/health.ts new file mode 100644 index 0000000..b41b96f --- /dev/null +++ b/examples/todo-api-cloudflare/api/src/routes/health.ts @@ -0,0 +1,35 @@ +import { Hono } from 'hono'; +import { pingDatabase } from '../db/ping'; + +const startTime = Date.now(); + +interface Bindings { + DB: D1Database; + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; +} + +export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => { + const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS) || 2000; + const timeout = new Promise<'disconnected'>((resolve) => + setTimeout(() => resolve('disconnected'), timeoutMs), + ); + + let database: 'connected' | 'disconnected'; + try { + database = await Promise.race([pingDatabase(c.env.DB), timeout]); + } catch { + database = 'disconnected'; + } + + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: c.env.APP_VERSION ?? 'unknown', + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + requestId: c.get('requestId'), + checks: { database }, + }; + return c.json(body, status === 'healthy' ? 200 : 503); +}); diff --git a/examples/todo-api-cloudflare/api/tests/unit/health.test.ts b/examples/todo-api-cloudflare/api/tests/unit/health.test.ts index c361f20..06e2c0e 100644 --- a/examples/todo-api-cloudflare/api/tests/unit/health.test.ts +++ b/examples/todo-api-cloudflare/api/tests/unit/health.test.ts @@ -1,24 +1,103 @@ -import { describe, it, expect } from 'vitest'; -import app from '../../src/index'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { requestId } from 'hono/request-id'; +import { pingDatabase } from '../../src/db/ping'; +import { healthRoutes } from '../../src/routes/health'; + +vi.mock('../../src/db/ping', () => ({ + pingDatabase: vi.fn(), +})); + +interface HealthBody { + status: 'healthy' | 'unhealthy'; + version: string; + uptime: number; + timestamp: string; + requestId: string; + checks: { database: 'connected' | 'disconnected' }; +} + +type TestEnv = { + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; + DB: D1Database | undefined; +}; + +const env: TestEnv = { + APP_VERSION: '0.0.1', + HEALTH_DB_TIMEOUT_MS: '2000', + DB: undefined, +}; + +const makeApp = (): Hono<{ Bindings: TestEnv }> => { + const app = new Hono<{ Bindings: TestEnv }>(); + app.use('*', requestId()); + app.route('/health', healthRoutes); + return app; +}; + +const mockedPing = vi.mocked(pingDatabase); describe('Health endpoint', () => { - it('should return 200 with ok status', async () => { - const res = await app.request('/health'); + beforeEach(() => { + mockedPing.mockReset(); + }); + + it('returns 200 + healthy when DB is connected', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); expect(res.status).toBe(200); - const body = (await res.json()) as { status: string }; - expect(body.status).toBe('ok'); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('healthy'); + expect(body.checks.database).toBe('connected'); }); - it('should include a requestId in the response body', async () => { - const res = await app.request('/health'); - const body = (await res.json()) as { requestId: string }; - expect(body.requestId).toBeDefined(); - expect(typeof body.requestId).toBe('string'); - expect(body.requestId.length).toBeGreaterThan(0); + it('returns version from APP_VERSION binding', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); + const body = (await res.json()) as HealthBody; + expect(body.version).toBe('0.0.1'); }); - it('should include X-Request-Id response header', async () => { - const res = await app.request('/health'); + it('returns numeric uptime >= 0', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); + const body = (await res.json()) as HealthBody; + expect(typeof body.uptime).toBe('number'); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('returns 503 + unhealthy when DB is disconnected', async () => { + mockedPing.mockResolvedValue('disconnected'); + const res = await makeApp().request('/health', {}, env); + expect(res.status).toBe(503); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('unhealthy'); + expect(body.checks.database).toBe('disconnected'); + }); + + it('returns 503 when ping exceeds timeout', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + mockedPing.mockImplementation(() => new Promise(() => {})); + const reqPromise = makeApp().request('/health', {}, env); + await vi.advanceTimersByTimeAsync(2001); + const res = await reqPromise; + expect(res.status).toBe(503); + vi.useRealTimers(); + }); + + it('returns 503 when ping throws (never 500)', async () => { + mockedPing.mockRejectedValue(new Error('boom')); + const res = await makeApp().request('/health', {}, env); + expect(res.status).toBe(503); + }); + + it('preserves requestId in body and X-Request-Id header', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); + const body = (await res.json()) as HealthBody; + expect(typeof body.requestId).toBe('string'); + expect(body.requestId.length).toBeGreaterThan(0); expect(res.headers.get('X-Request-Id')).not.toBeNull(); }); }); diff --git a/examples/todo-api-cloudflare/api/wrangler.toml b/examples/todo-api-cloudflare/api/wrangler.toml index 9b30549..bde6c20 100644 --- a/examples/todo-api-cloudflare/api/wrangler.toml +++ b/examples/todo-api-cloudflare/api/wrangler.toml @@ -17,3 +17,5 @@ database_id = "" [vars] ENVIRONMENT = "development" +APP_VERSION = "0.0.1" +HEALTH_DB_TIMEOUT_MS = "2000" diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 9e9c119..a331e78 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -175,6 +175,7 @@ import { etag } from 'hono/etag'; import { logger } from 'hono/logger'; import { requestId } from 'hono/request-id'; import { secureHeaders } from 'hono/secure-headers'; +import { healthRoutes } from './routes/health'; // Note: Response compression is handled automatically by Cloudflare's edge network. // No compress() middleware is needed for Workers deployments. @@ -182,6 +183,8 @@ type Bindings = { DB: D1Database; KV: KVNamespace; HYPERDRIVE: Hyperdrive; + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; ENVIRONMENT: string; LOG_LEVEL: string; }; @@ -194,13 +197,7 @@ app.use('*', etag()); app.use('*', secureHeaders()); app.use('*', requestId()); -app.get('/health', (c) => { - return c.json({ - status: 'ok', - requestId: c.get('requestId'), - timestamp: new Date().toISOString(), - }); -}); +app.route('/health', healthRoutes); app.get('/', (c) => { return c.json({ message: 'Nerva API', version: '0.0.1' }); @@ -218,6 +215,7 @@ import { logger } from 'hono/logger'; import { requestId } from 'hono/request-id'; import { secureHeaders } from 'hono/secure-headers'; import { serve } from '@hono/node-server'; +import { healthRoutes } from './routes/health'; const app = new Hono(); @@ -228,13 +226,7 @@ app.use('*', etag()); app.use('*', secureHeaders()); app.use('*', requestId()); -app.get('/health', (c) => { - return c.json({ - status: 'ok', - requestId: c.get('requestId'), - timestamp: new Date().toISOString(), - }); -}); +app.route('/health', healthRoutes); app.get('/', (c) => { return c.json({ message: 'Nerva API', version: '0.0.1' }); @@ -303,18 +295,25 @@ export const users = pgTable('users', { }); SEOF -write_file "$API_DIR/src/db/seed.ts" << 'SEEDEOF' +write_file "$API_DIR/src/db/client.ts" << 'CEOF' import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema.js'; +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); +} + +export const client = postgres(databaseUrl); +export const db = drizzle(client, { schema }); +CEOF + +write_file "$API_DIR/src/db/seed.ts" << 'SEEDEOF' +import { client, db } from './client.js'; +import * as schema from './schema.js'; + async function seed(): Promise { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is required'); - } - const client = postgres(databaseUrl); - const db = drizzle(client, { schema }); console.log('Seeding database...'); await db.insert(schema.users).values([ { email: 'admin@example.com', name: 'Admin User' }, @@ -330,6 +329,113 @@ seed().catch((err) => { }); SEEDEOF +if [[ "$PLATFORM" == "cloudflare" ]]; then + write_file "$API_DIR/src/db/ping.ts" << 'PEOF' +import postgres from 'postgres'; + +export async function pingDatabase( + hyperdrive: Hyperdrive | undefined, +): Promise<'connected' | 'disconnected'> { + if (!hyperdrive?.connectionString) return 'disconnected'; + const sql = postgres(hyperdrive.connectionString, { + max: 1, + fetch_types: false, + }); + try { + await sql`SELECT 1`; + return 'connected'; + } catch { + return 'disconnected'; + } finally { + void sql.end({ timeout: 1 }).catch(() => {}); + } +} +PEOF + + write_file "$API_DIR/src/routes/health.ts" << 'HEOF' +import { Hono } from 'hono'; +import { pingDatabase } from '../db/ping'; + +const startTime = Date.now(); + +type Bindings = { + HYPERDRIVE: Hyperdrive; + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; +}; + +export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => { + const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS) || 2000; + const timeout = new Promise<'disconnected'>((resolve) => + setTimeout(() => resolve('disconnected'), timeoutMs), + ); + + let database: 'connected' | 'disconnected'; + try { + database = await Promise.race([pingDatabase(c.env.HYPERDRIVE), timeout]); + } catch { + database = 'disconnected'; + } + + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: c.env.APP_VERSION ?? 'unknown', + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + requestId: c.get('requestId'), + checks: { database }, + }; + return c.json(body, status === 'healthy' ? 200 : 503); +}); +HEOF +else + write_file "$API_DIR/src/db/ping.ts" << 'PEOF' +import { client } from './client.js'; + +export async function pingDatabase(): Promise<'connected' | 'disconnected'> { + try { + await client`SELECT 1`; + return 'connected'; + } catch { + return 'disconnected'; + } +} +PEOF + + write_file "$API_DIR/src/routes/health.ts" << 'HEOF' +import { Hono } from 'hono'; +import { pingDatabase } from '../db/ping'; + +const startTime = Date.now(); + +export const healthRoutes = new Hono().get('/', async (c) => { + const timeoutMs = Number(process.env.HEALTH_DB_TIMEOUT_MS) || 2000; + const timeout = new Promise<'disconnected'>((resolve) => + setTimeout(() => resolve('disconnected'), timeoutMs), + ); + + let database: 'connected' | 'disconnected'; + try { + database = await Promise.race([pingDatabase(), timeout]); + } catch { + database = 'disconnected'; + } + + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: process.env.APP_VERSION ?? 'unknown', + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + requestId: c.get('requestId'), + checks: { database }, + }; + return c.json(body, status === 'healthy' ? 200 : 503); +}); +HEOF +fi + write_file "$API_DIR/tests/setup.ts" << 'TSEOF' import { beforeAll, afterAll } from 'vitest'; @@ -342,42 +448,212 @@ afterAll(() => { }); TSEOF -write_file "$API_DIR/tests/unit/health.test.ts" << 'HTEOF' -import { describe, it, expect } from 'vitest'; +if [[ "$PLATFORM" == "cloudflare" ]]; then + write_file "$API_DIR/tests/unit/health.test.ts" << 'HTEOF' +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; import { requestId } from 'hono/request-id'; +import { pingDatabase } from '../../src/db/ping'; +import { healthRoutes } from '../../src/routes/health'; + +vi.mock('../../src/db/ping', () => ({ + pingDatabase: vi.fn(), +})); + +interface HealthBody { + status: 'healthy' | 'unhealthy'; + version: string; + uptime: number; + timestamp: string; + requestId: string; + checks: { database: 'connected' | 'disconnected' }; +} -describe('Health endpoint', () => { - const app = new Hono(); +type TestEnv = { + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; + HYPERDRIVE: Hyperdrive | undefined; +}; + +const env: TestEnv = { + APP_VERSION: '0.0.1', + HEALTH_DB_TIMEOUT_MS: '2000', + HYPERDRIVE: undefined, +}; + +const makeApp = () => { + const app = new Hono<{ Bindings: TestEnv }>(); app.use('*', requestId()); - app.get('/health', (c) => - c.json({ - status: 'ok', - requestId: c.get('requestId'), - }), - ); + app.route('/health', healthRoutes); + return app; +}; - it('should return ok status', async () => { - const res = await app.request('/health'); +const mockedPing = vi.mocked(pingDatabase); + +describe('Health endpoint', () => { + beforeEach(() => { + mockedPing.mockReset(); + }); + + it('returns 200 + healthy when DB is connected', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe('ok'); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('healthy'); + expect(body.checks.database).toBe('connected'); + }); + + it('returns version from APP_VERSION binding', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); + const body = (await res.json()) as HealthBody; + expect(body.version).toBe('0.0.1'); + }); + + it('returns numeric uptime >= 0', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); + const body = (await res.json()) as HealthBody; + expect(typeof body.uptime).toBe('number'); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('returns 503 + unhealthy when DB is disconnected', async () => { + mockedPing.mockResolvedValue('disconnected'); + const res = await makeApp().request('/health', {}, env); + expect(res.status).toBe(503); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('unhealthy'); + expect(body.checks.database).toBe('disconnected'); + }); + + it('returns 503 when ping exceeds timeout', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + mockedPing.mockImplementation(() => new Promise(() => {})); + const reqPromise = makeApp().request('/health', {}, env); + await vi.advanceTimersByTimeAsync(2001); + const res = await reqPromise; + expect(res.status).toBe(503); + vi.useRealTimers(); + }); + + it('returns 503 when ping throws (never 500)', async () => { + mockedPing.mockRejectedValue(new Error('boom')); + const res = await makeApp().request('/health', {}, env); + expect(res.status).toBe(503); }); - it('should return a requestId', async () => { - const res = await app.request('/health'); - const body = await res.json(); - expect(body.requestId).toBeDefined(); + it('preserves requestId in body and X-Request-Id header', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health', {}, env); + const body = (await res.json()) as HealthBody; expect(typeof body.requestId).toBe('string'); expect(body.requestId.length).toBeGreaterThan(0); + expect(res.headers.get('X-Request-Id')).not.toBeNull(); }); +}); +HTEOF +else + write_file "$API_DIR/tests/unit/health.test.ts" << 'HTEOF' +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { requestId } from 'hono/request-id'; +import { pingDatabase } from '../../src/db/ping'; +import { healthRoutes } from '../../src/routes/health'; + +vi.mock('../../src/db/ping', () => ({ + pingDatabase: vi.fn(), +})); + +interface HealthBody { + status: 'healthy' | 'unhealthy'; + version: string; + uptime: number; + timestamp: string; + requestId: string; + checks: { database: 'connected' | 'disconnected' }; +} - it('should include X-Request-Id response header', async () => { - const res = await app.request('/health'); +const makeApp = () => { + const app = new Hono(); + app.use('*', requestId()); + app.route('/health', healthRoutes); + return app; +}; + +const mockedPing = vi.mocked(pingDatabase); + +beforeAll(() => { + process.env.APP_VERSION = '0.0.1'; + process.env.HEALTH_DB_TIMEOUT_MS = '2000'; +}); + +describe('Health endpoint', () => { + beforeEach(() => { + mockedPing.mockReset(); + }); + + it('returns 200 + healthy when DB is connected', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + expect(res.status).toBe(200); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('healthy'); + expect(body.checks.database).toBe('connected'); + }); + + it('returns version from APP_VERSION env', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + const body = (await res.json()) as HealthBody; + expect(body.version).toBe('0.0.1'); + }); + + it('returns numeric uptime >= 0', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + const body = (await res.json()) as HealthBody; + expect(typeof body.uptime).toBe('number'); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('returns 503 + unhealthy when DB is disconnected', async () => { + mockedPing.mockResolvedValue('disconnected'); + const res = await makeApp().request('/health'); + expect(res.status).toBe(503); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('unhealthy'); + expect(body.checks.database).toBe('disconnected'); + }); + + it('returns 503 when ping exceeds timeout', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + mockedPing.mockImplementation(() => new Promise(() => {})); + const reqPromise = makeApp().request('/health'); + await vi.advanceTimersByTimeAsync(2001); + const res = await reqPromise; + expect(res.status).toBe(503); + vi.useRealTimers(); + }); + + it('returns 503 when ping throws (never 500)', async () => { + mockedPing.mockRejectedValue(new Error('boom')); + const res = await makeApp().request('/health'); + expect(res.status).toBe(503); + }); + + it('preserves requestId in body and X-Request-Id header', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + const body = (await res.json()) as HealthBody; + expect(typeof body.requestId).toBe('string'); + expect(body.requestId.length).toBeGreaterThan(0); expect(res.headers.get('X-Request-Id')).not.toBeNull(); }); }); HTEOF +fi success "Initial source files created." @@ -397,6 +673,8 @@ if [[ "$PLATFORM" == "cloudflare" ]]; then ENVIRONMENT=development LOG_LEVEL=debug DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 DVEOF success "Cloudflare Workers configured. Edit wrangler.toml with your resource IDs." @@ -410,6 +688,8 @@ NODE_ENV=development PORT=3000 DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db LOG_LEVEL=debug +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 ENVEOF success "Node.js / Docker configured." diff --git a/templates/cloudflare-workers/wrangler.toml b/templates/cloudflare-workers/wrangler.toml index c8492af..9d8f3c3 100644 --- a/templates/cloudflare-workers/wrangler.toml +++ b/templates/cloudflare-workers/wrangler.toml @@ -29,6 +29,8 @@ id = "" [vars] ENVIRONMENT = "development" LOG_LEVEL = "debug" +APP_VERSION = "0.0.1" +HEALTH_DB_TIMEOUT_MS = "2000" # Staging environment [env.staging] @@ -36,6 +38,8 @@ name = "nerva-api-staging" [env.staging.vars] ENVIRONMENT = "staging" LOG_LEVEL = "info" +APP_VERSION = "0.0.1" +HEALTH_DB_TIMEOUT_MS = "2000" [[env.staging.d1_databases]] binding = "DB" @@ -59,6 +63,8 @@ routes = [ [env.production.vars] ENVIRONMENT = "production" LOG_LEVEL = "warn" +APP_VERSION = "0.0.1" +HEALTH_DB_TIMEOUT_MS = "2000" [[env.production.d1_databases]] binding = "DB"