From 7ab8db89e659a91c4437d5d10e138461b97aade8 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 12:49:37 -0400 Subject: [PATCH 1/7] docs: plan for /health dependency-status endpoint (#54) Captures the design decisions (isolate uptime on Workers, APP_VERSION env var, extracted routes/health.ts, 2s DB ping timeout, 503 on any dependency failure) and breaks implementation into seven task-scoped commits across both setup-project.sh templates and the todo-api-cloudflare example. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...05-14-health-endpoint-dependency-checks.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/plans/2026-05-14-health-endpoint-dependency-checks.md 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. From a24b937348e09ba33ae1a9b67a7aed1a564f2396 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 12:54:31 -0400 Subject: [PATCH 2/7] refactor(template): extract shared postgres client for Node template Adds src/db/client.ts with a postgres-js singleton and shared drizzle instance reading DATABASE_URL at module load. seed.ts now imports from client.ts instead of constructing its own connection. The client module is generated for both templates because seed.ts runs under tsx (Node) regardless of deploy target; the Workers runtime never imports it. First step toward the dependency-checking /health endpoint (#54). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 9e9c119..8b01e37 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -303,18 +303,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' }, From 83a1ceaf53683170b7377278e99d1a93651d2b8f Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 12:54:54 -0400 Subject: [PATCH 3/7] feat(template): add pingDatabase helper for health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates src/db/ping.ts with a platform-specific body. The Workers variant builds a per-request postgres client over the HYPERDRIVE binding's connection string and runs SELECT 1; the Node variant uses the shared client singleton from db/client.ts. Both return 'connected' | 'disconnected' and never throw — the caller is the /health endpoint, which must never 5xx the prober. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 8b01e37..3875808 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -337,6 +337,46 @@ seed().catch((err) => { }); SEEDEOF +if [[ "$PLATFORM" == "cloudflare" ]]; then + write_file "$API_DIR/src/db/ping.ts" << 'PEOF' +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 { + void sql.end({ timeout: 1 }).catch(() => {}); + } +} +PEOF +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 +fi + write_file "$API_DIR/tests/setup.ts" << 'TSEOF' import { beforeAll, afterAll } from 'vitest'; From e9947e1ad9920a8285bcd06c139100de9a4a337f Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 12:56:22 -0400 Subject: [PATCH 4/7] feat(template): replace static /health with dependency-checking handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline app.get('/health', ...) in both Workers and Node templates with a mounted routes/health.ts handler. The new handler: - Pings the database via pingDatabase() with a configurable timeout (HEALTH_DB_TIMEOUT_MS, default 2000 ms) - Reports overall status as 'healthy' / 'unhealthy' and returns 503 when any dependency is disconnected - Includes version (from APP_VERSION env), uptime (seconds since module load — isolate uptime on Workers, process uptime on Node), timestamp, requestId, and a checks.database field - Never throws to the caller: any internal error is caught and reported as 'disconnected' + 503, so load balancers never see a 5xx that wasn't caused by an actual dependency outage Workers Bindings now declare APP_VERSION and HEALTH_DB_TIMEOUT_MS so the template type-checks against wrangler.toml [vars] entries (added next). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 90 +++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 3875808..de0d1fd 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' }); @@ -362,6 +354,44 @@ export async function pingDatabase( } } 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), 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'; @@ -375,6 +405,38 @@ export async function pingDatabase(): Promise<'connected' | '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' From dd3518e07f65de947585aaa548188fbede8d9121 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 12:58:18 -0400 Subject: [PATCH 5/7] chore(template): add APP_VERSION and HEALTH_DB_TIMEOUT_MS to env templates The new /health endpoint reads these from c.env (Workers) or process.env (Node). Adds them to: - .dev.vars.example (Workers local dev) - .env.example (Node local dev) - wrangler.toml [vars], [env.staging.vars], [env.production.vars] APP_VERSION mirrors package.json so generated projects ship with a real value out of the box; HEALTH_DB_TIMEOUT_MS defaults to 2000 ms so the DB ping fails fast under network partition. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 4 ++++ templates/cloudflare-workers/wrangler.toml | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index de0d1fd..11dd275 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -506,6 +506,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." @@ -519,6 +521,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" From fb6f7e8d10cb4f685c3ad134585e90eb89395c1e Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:00:08 -0400 Subject: [PATCH 6/7] test(template): cover health endpoint dependency status, timeout, and error paths Replaces the standalone Hono fixture in tests/unit/health.test.ts with a test that mounts the real healthRoutes inside a Hono parent + requestId middleware (mirroring index.ts wiring). pingDatabase is mocked via vi.mock so no real postgres is needed in CI. Coverage (both Workers and Node test bodies): - 200 + healthy when DB connected - version sourced from APP_VERSION (c.env on Workers, process.env on Node) - numeric uptime >= 0 - 503 + unhealthy when DB disconnected - 503 on timeout via fake-timer-advanced Promise.race - 503 on thrown error (never 500) - requestId preserved in body and X-Request-Id header Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 210 +++++++++++++++++++++++++++++++++++---- 1 file changed, 190 insertions(+), 20 deletions(-) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 11dd275..af66d7b 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -451,42 +451,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; +}; + +const mockedPing = vi.mocked(pingDatabase); + +describe('Health endpoint', () => { + beforeEach(() => { + mockedPing.mockReset(); + }); - it('should return ok status', async () => { - const res = await app.request('/health'); + 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('should return a requestId', async () => { - const res = await app.request('/health'); - const body = await res.json(); - expect(body.requestId).toBeDefined(); + 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(); + }); +}); +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' }; +} + +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('should include X-Request-Id response header', async () => { - const res = await app.request('/health'); + 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." From a0e034e53c4dea7c619675a6915dfa95e78f93a7 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:03:37 -0400 Subject: [PATCH 7/7] feat(example): wire dependency-checking /health into todo-api-cloudflare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the new health endpoint design in the example: - examples/todo-api-cloudflare/api/src/db/ping.ts (D1: SELECT 1 via c.env.DB.prepare('SELECT 1').first()) - examples/todo-api-cloudflare/api/src/routes/health.ts (same handler shape as the Workers template, but typed for the example's D1 binding) - src/index.ts: drop inline /health handler, mount healthRoutes - wrangler.toml [vars] and .dev.vars.example: add APP_VERSION and HEALTH_DB_TIMEOUT_MS - tests/unit/health.test.ts: full mocked coverage matching the template Also refactors pingDatabase in both the Workers template and the example to take the binding directly (Hyperdrive | undefined / D1Database | undefined) instead of the whole Hono Context — the Context variance caused a typecheck error when the route's wider Bindings included APP_VERSION/HEALTH_DB_TIMEOUT_MS. Decoupling db/ping.ts from Hono's type also makes it easier to reuse outside HTTP handlers. Example tests: 19/19 passing (7 health + 12 todos integration). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../todo-api-cloudflare/api/.dev.vars.example | 2 + .../todo-api-cloudflare/api/src/db/ping.ts | 9 ++ examples/todo-api-cloudflare/api/src/index.ts | 13 +-- .../api/src/routes/health.ts | 35 ++++++ .../api/tests/unit/health.test.ts | 107 +++++++++++++++--- .../todo-api-cloudflare/api/wrangler.toml | 2 + scripts/setup-project.sh | 11 +- 7 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 examples/todo-api-cloudflare/api/src/db/ping.ts create mode 100644 examples/todo-api-cloudflare/api/src/routes/health.ts 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 af66d7b..a331e78 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -331,16 +331,13 @@ SEEDEOF if [[ "$PLATFORM" == "cloudflare" ]]; then write_file "$API_DIR/src/db/ping.ts" << 'PEOF' -import type { Context } from 'hono'; import postgres from 'postgres'; -type Bindings = { HYPERDRIVE: Hyperdrive }; - export async function pingDatabase( - c: Context<{ Bindings: Bindings }>, + hyperdrive: Hyperdrive | undefined, ): Promise<'connected' | 'disconnected'> { - if (!c.env.HYPERDRIVE?.connectionString) return 'disconnected'; - const sql = postgres(c.env.HYPERDRIVE.connectionString, { + if (!hyperdrive?.connectionString) return 'disconnected'; + const sql = postgres(hyperdrive.connectionString, { max: 1, fetch_types: false, }); @@ -375,7 +372,7 @@ export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c let database: 'connected' | 'disconnected'; try { - database = await Promise.race([pingDatabase(c), timeout]); + database = await Promise.race([pingDatabase(c.env.HYPERDRIVE), timeout]); } catch { database = 'disconnected'; }