From 2ce34c9a7f2973d6ff32ef327eb253e3b7b95c35 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:30:57 -0400 Subject: [PATCH 1/7] docs: plan for type-checking embedded templates in CI (#57) Captures the design decisions (snippets as source of truth, setup-project.sh cp's them at runtime, dedicated CI job typechecks against current dep versions) and breaks implementation into eight task-scoped commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-14-typecheck-embedded-templates.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/plans/2026-05-14-typecheck-embedded-templates.md diff --git a/docs/plans/2026-05-14-typecheck-embedded-templates.md b/docs/plans/2026-05-14-typecheck-embedded-templates.md new file mode 100644 index 0000000..8e9a894 --- /dev/null +++ b/docs/plans/2026-05-14-typecheck-embedded-templates.md @@ -0,0 +1,261 @@ +# Type-check embedded templates in CI — design & implementation plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. + +**Issue:** #57 +**Goal:** Catch TypeScript regressions in `setup-project.sh`'s embedded TS *during development* rather than after users run the script. Extract the 11 `write_file ... << 'EOF'` heredoc bodies into real `.ts` files, have `setup-project.sh` `cp` them at runtime, and run `tsc --noEmit` on them in CI for both Workers and Node target shapes. + +--- + +## Design decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Source of truth | Real `.ts` files in `templates/snippets/`; `setup-project.sh` `cp`s them | Zero drift possible — one copy. Free editor LSP + formatting. Shorter shell script | +| Scope | All 11 embedded TS heredocs (Workers + Node + shared) | One CI job catches regressions across `index.ts`, `routes/health.ts`, `db/ping.ts`, etc. — not just `db/schema.ts` | +| Test file typecheck | Skipped (out of scope) | `tests/unit/health.test.ts` needs `vitest` types + globals — added complexity. Test *correctness* is already verified by running them on generated projects | +| CI integration | New dedicated `typecheck-templates` job, single Node version | One typecheck pass is enough; no value running it twice in the existing 20/22 matrix | +| Dep-version sync | Cross-reference comment in `setup-project.sh` near `pnpm add` lines | Two places (script's `pnpm add` and the typecheck scaffold's `package.json`) must agree. Drift is caught the next time anyone touches either | +| `tests/unit/health.test.ts` location | Out of scope for typecheck, but its **runtime correctness** is already covered | The health PR's smoke test runs these on generated projects | + +--- + +## File layout (deltas) + +``` +templates/ +├── shared/ # EXISTING — configs (eslint, prettier, tsconfig, vitest) +├── cloudflare-workers/ # EXISTING — wrangler.toml +├── node-server/ # EXISTING — Dockerfile, docker-compose.yml +├── docker/ # EXISTING +└── snippets/ # NEW — runtime TS extracted from setup-project.sh + ├── shared/ + │ ├── src/db/schema.ts + │ ├── src/db/client.ts + │ ├── src/db/seed.ts + │ └── tests/setup.ts + ├── cloudflare/ + │ ├── src/index.ts + │ ├── src/db/ping.ts + │ ├── src/routes/health.ts + │ └── tests/unit/health.test.ts + └── node/ + ├── src/index.ts + ├── src/db/ping.ts + ├── src/routes/health.ts + └── tests/unit/health.test.ts + +tests/typecheck-templates/ # NEW — CI typecheck scaffold +├── package.json # only deps needed to typecheck (drizzle-orm, hono, postgres, zod, @hono/zod-validator, @cloudflare/workers-types, @types/node, typescript) +├── pnpm-lock.yaml # generated +├── tsconfig.cloudflare.json # extends ../../templates/shared/tsconfig.json, types: ["@cloudflare/workers-types"] +└── tsconfig.node.json # extends ../../templates/shared/tsconfig.json, types: ["node"] +``` + +`setup-project.sh`: 11 heredocs replaced with `copy_file` calls. Estimated ~250 lines removed. + +--- + +## tsconfig shapes + +**`tests/typecheck-templates/tsconfig.cloudflare.json`:** +```json +{ + "extends": "../../templates/shared/tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"], + "moduleResolution": "bundler", + "noEmit": true, + "rootDir": "../../templates/snippets" + }, + "include": [ + "../../templates/snippets/shared/src/**/*.ts", + "../../templates/snippets/cloudflare/src/**/*.ts" + ] +} +``` + +**`tsconfig.node.json`:** same shape with `"types": ["node"]` and `node/` included. + +Both **exclude** `tests/**/*.ts` from the snippet trees — vitest tests are out of scope for this PR. + +--- + +## CI job + +Added to `.github/workflows/ci.yml`: + +```yaml +typecheck-templates: + name: Type-check Embedded Templates + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - run: corepack enable + - run: pnpm install --frozen-lockfile + working-directory: tests/typecheck-templates + - run: pnpm exec tsc --noEmit -p tsconfig.cloudflare.json + working-directory: tests/typecheck-templates + - run: pnpm exec tsc --noEmit -p tsconfig.node.json + working-directory: tests/typecheck-templates +``` + +--- + +## Task 1: Extract heredocs to `templates/snippets/` + +**Files created (11):** + +| Snippet file | Source heredoc in setup-project.sh | +|---|---| +| `templates/snippets/shared/src/db/schema.ts` | line ~286 (`SEOF`) | +| `templates/snippets/shared/src/db/client.ts` | line ~298 (`CEOF`) | +| `templates/snippets/shared/src/db/seed.ts` | line ~312 (`SEEDEOF`) | +| `templates/snippets/shared/tests/setup.ts` | line ~439 (`TSEOF`) | +| `templates/snippets/cloudflare/src/index.ts` | line ~171 (`SRCEOF`, cloudflare branch) | +| `templates/snippets/cloudflare/src/db/ping.ts` | line ~333 (`PEOF`, cloudflare branch) | +| `templates/snippets/cloudflare/src/routes/health.ts` | line ~355 (`HEOF`, cloudflare branch) | +| `templates/snippets/cloudflare/tests/unit/health.test.ts` | line ~452 (`HTEOF`, cloudflare branch) | +| `templates/snippets/node/src/index.ts` | line ~209 (`SRCEOF`, node branch) | +| `templates/snippets/node/src/db/ping.ts` | line ~393 (`PEOF`, node branch) | +| `templates/snippets/node/src/routes/health.ts` | line ~406 (`HEOF`, node branch) | +| `templates/snippets/node/tests/unit/health.test.ts` | line ~558 (`HTEOF`, node branch) | + +Content extracted **verbatim** — no rewrites. No changes to `setup-project.sh` yet. + +**Verify:** `git diff` shows only new files; `bash -n scripts/setup-project.sh` still passes. + +**Commit:** `chore(template): extract embedded TS heredocs to templates/snippets/` + +--- + +## Task 2: Add `tests/typecheck-templates/` scaffold + +**Files created:** +- `tests/typecheck-templates/package.json` — `typescript`, `drizzle-orm`, `hono`, `postgres`, `zod`, `@hono/zod-validator`, `@cloudflare/workers-types`, `@types/node`. Versions match `setup-project.sh` `pnpm add` lines. +- `tests/typecheck-templates/tsconfig.cloudflare.json` (see shape above) +- `tests/typecheck-templates/tsconfig.node.json` (see shape above) + +**Run locally:** +```bash +cd tests/typecheck-templates +pnpm install +pnpm exec tsc --noEmit -p tsconfig.cloudflare.json +pnpm exec tsc --noEmit -p tsconfig.node.json +``` + +**If errors surface, fix them in the snippets** — that's the bug this PR exists to catch. Commit the snippet fixes in this task. + +**Verify:** both `tsc` invocations exit 0. + +**Commit:** `feat(ci): scaffold for type-checking embedded templates` + +--- + +## Task 3: Refactor `setup-project.sh` to consume snippet files + +Replace 11 `write_file ... << 'EOF'` blocks with `copy_file` calls. Pattern: + +```bash +# OLD +write_file "$API_DIR/src/db/schema.ts" << 'SEOF' +import { pgTable, ... } from 'drizzle-orm/pg-core'; +... +SEOF + +# NEW +copy_file "$TEMPLATES_DIR/snippets/shared/src/db/schema.ts" "$API_DIR/src/db/schema.ts" +``` + +For platform-specific files (`index.ts`, `ping.ts`, `health.ts`, test files), the source path differs by branch: +- Cloudflare branch: `$TEMPLATES_DIR/snippets/cloudflare/...` +- Node branch: `$TEMPLATES_DIR/snippets/node/...` + +**Verify:** +- `bash -n scripts/setup-project.sh` passes +- Generate Node project: `bash scripts/setup-project.sh /tmp/x --node` → `cd /tmp/x/api && pnpm test` → 7/7 health tests pass +- Generate Cloudflare project: `bash scripts/setup-project.sh /tmp/y --cloudflare` → `cd /tmp/y/api && pnpm test` → 7/7 health tests pass + +**Commit:** `refactor(setup): replace TS heredocs with copy_file from templates/snippets/` + +--- + +## Task 4: Add `typecheck-templates` CI job + +Add the YAML block (see "CI job" section above) to `.github/workflows/ci.yml`. Place it alongside the existing `node-compat` and `check-links` jobs. + +**Verify:** +- `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"` — valid YAML +- Existing `validate` job's `Check required files exist` step is unaffected (the new dir isn't required there) + +**Commit:** `ci: type-check embedded templates against current dep versions` + +--- + +## Task 5: Cross-reference comment in `setup-project.sh` + +Add a comment near each `pnpm add` line: + +```bash +# NOTE: keep deps in sync with tests/typecheck-templates/package.json +run_cmd pnpm add hono drizzle-orm postgres zod @hono/zod-validator +``` + +**Verify:** `bash -n` passes. + +**Commit:** `docs(setup): cross-reference typecheck-templates dep versions` + +--- + +## Task 6: Document in CONTRIBUTING.md + +Add a short section: "Modifying generated-project templates". Explain that runtime TS lives in `templates/snippets/`; bumping a dep in `setup-project.sh` needs the matching bump in `tests/typecheck-templates/package.json`; the `typecheck-templates` CI job catches type drift against the current Drizzle/Hono APIs. + +**Verify:** new section renders cleanly in GitHub's markdown preview (no broken anchors / lychee passes). + +**Commit:** `docs(contributing): explain templates/snippets/ + dep version sync` + +--- + +## Task 7: Smoke test full pipeline locally + +```bash +rm -rf /tmp/nerva-typecheck-node /tmp/nerva-typecheck-cf +bash scripts/setup-project.sh /tmp/nerva-typecheck-node --node +bash scripts/setup-project.sh /tmp/nerva-typecheck-cf --cloudflare +(cd /tmp/nerva-typecheck-node/api && pnpm test) # must show 7/7 +(cd /tmp/nerva-typecheck-cf/api && pnpm test) # must show 7/7 + +cd tests/typecheck-templates +pnpm install --frozen-lockfile +pnpm exec tsc --noEmit -p tsconfig.cloudflare.json +pnpm exec tsc --noEmit -p tsconfig.node.json +``` + +All four commands must exit 0. + +**No commit** — this is a verification step. + +--- + +## Task 8: Push branch and open PR + +**Verify CI:** +- Validate Structure: green +- Node.js 20 / 22 Compatibility: green +- Check Markdown Links: green +- **Type-check Embedded Templates (new): green** + +**PR title:** `feat(ci): type-check embedded TS templates` +**Closes #57.** + +--- + +## Out of scope (follow-up issues) + +- Type-checking `tests/unit/health.test.ts` snippets — requires `vitest` types + globals; small but added complexity. Test correctness is already covered by running them on generated projects. +- Pre-existing `templates/shared/tsconfig.json` gaps (missing `"types": ["node"]` for Node target, missing `@cloudflare/workers-types` install for Cloudflare target). Surfaces if/when those template tsconfigs get included in the typecheck. +- A pre-commit hook that runs `tsc -p tsconfig.cloudflare.json` locally — `husky` is already installed; could add later. From 03cfbe4e3e426893332cb6e44f08c7abd2591e4d Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:36:12 -0400 Subject: [PATCH 2/7] chore(template): extract embedded TS heredocs to templates/snippets/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the 12 .ts heredoc bodies from setup-project.sh into real files under templates/snippets/{shared,cloudflare,node}/. Content is verbatim from the heredocs — no rewrites, no behavior change. setup-project.sh still uses heredocs; the next commit wires it to copy these files. Layout: templates/snippets/ ├── shared/ │ ├── src/db/{schema,client,seed}.ts │ └── tests/setup.ts ├── cloudflare/ │ ├── src/index.ts │ ├── src/db/ping.ts │ ├── src/routes/health.ts │ └── tests/unit/health.test.ts └── node/ ├── src/index.ts ├── src/db/ping.ts ├── src/routes/health.ts └── tests/unit/health.test.ts First step toward type-checking embedded templates in CI (#57). Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/snippets/cloudflare/src/db/ping.ts | 19 ++++ templates/snippets/cloudflare/src/index.ts | 35 ++++++ .../snippets/cloudflare/src/routes/health.ts | 35 ++++++ .../cloudflare/tests/unit/health.test.ts | 103 ++++++++++++++++++ templates/snippets/node/src/db/ping.ts | 10 ++ templates/snippets/node/src/index.ts | 57 ++++++++++ templates/snippets/node/src/routes/health.ts | 29 +++++ .../snippets/node/tests/unit/health.test.ts | 96 ++++++++++++++++ templates/snippets/shared/src/db/client.ts | 11 ++ templates/snippets/shared/src/db/schema.ts | 9 ++ templates/snippets/shared/src/db/seed.ts | 17 +++ templates/snippets/shared/tests/setup.ts | 9 ++ 12 files changed, 430 insertions(+) create mode 100644 templates/snippets/cloudflare/src/db/ping.ts create mode 100644 templates/snippets/cloudflare/src/index.ts create mode 100644 templates/snippets/cloudflare/src/routes/health.ts create mode 100644 templates/snippets/cloudflare/tests/unit/health.test.ts create mode 100644 templates/snippets/node/src/db/ping.ts create mode 100644 templates/snippets/node/src/index.ts create mode 100644 templates/snippets/node/src/routes/health.ts create mode 100644 templates/snippets/node/tests/unit/health.test.ts create mode 100644 templates/snippets/shared/src/db/client.ts create mode 100644 templates/snippets/shared/src/db/schema.ts create mode 100644 templates/snippets/shared/src/db/seed.ts create mode 100644 templates/snippets/shared/tests/setup.ts diff --git a/templates/snippets/cloudflare/src/db/ping.ts b/templates/snippets/cloudflare/src/db/ping.ts new file mode 100644 index 0000000..ea0bcd7 --- /dev/null +++ b/templates/snippets/cloudflare/src/db/ping.ts @@ -0,0 +1,19 @@ +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(() => {}); + } +} diff --git a/templates/snippets/cloudflare/src/index.ts b/templates/snippets/cloudflare/src/index.ts new file mode 100644 index 0000000..f23f8a7 --- /dev/null +++ b/templates/snippets/cloudflare/src/index.ts @@ -0,0 +1,35 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +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. + +type Bindings = { + DB: D1Database; + KV: KVNamespace; + HYPERDRIVE: Hyperdrive; + APP_VERSION: string; + HEALTH_DB_TIMEOUT_MS: string; + ENVIRONMENT: string; + LOG_LEVEL: string; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.use('*', logger()); +app.use('*', cors()); +app.use('*', etag()); +app.use('*', secureHeaders()); +app.use('*', requestId()); + +app.route('/health', healthRoutes); + +app.get('/', (c) => { + return c.json({ message: 'Nerva API', version: '0.0.1' }); +}); + +export default app; diff --git a/templates/snippets/cloudflare/src/routes/health.ts b/templates/snippets/cloudflare/src/routes/health.ts new file mode 100644 index 0000000..8289a11 --- /dev/null +++ b/templates/snippets/cloudflare/src/routes/health.ts @@ -0,0 +1,35 @@ +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); +}); diff --git a/templates/snippets/cloudflare/tests/unit/health.test.ts b/templates/snippets/cloudflare/tests/unit/health.test.ts new file mode 100644 index 0000000..f4137f3 --- /dev/null +++ b/templates/snippets/cloudflare/tests/unit/health.test.ts @@ -0,0 +1,103 @@ +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; + 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.route('/health', healthRoutes); + return app; +}; + +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()) 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('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/templates/snippets/node/src/db/ping.ts b/templates/snippets/node/src/db/ping.ts new file mode 100644 index 0000000..46b4c62 --- /dev/null +++ b/templates/snippets/node/src/db/ping.ts @@ -0,0 +1,10 @@ +import { client } from './client.js'; + +export async function pingDatabase(): Promise<'connected' | 'disconnected'> { + try { + await client`SELECT 1`; + return 'connected'; + } catch { + return 'disconnected'; + } +} diff --git a/templates/snippets/node/src/index.ts b/templates/snippets/node/src/index.ts new file mode 100644 index 0000000..e8d9b5c --- /dev/null +++ b/templates/snippets/node/src/index.ts @@ -0,0 +1,57 @@ +import { Hono } from 'hono'; +import { compress } from 'hono/compress'; +import { cors } from 'hono/cors'; +import { etag } from 'hono/etag'; +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(); + +app.use('*', logger()); +app.use('*', cors()); +app.use('*', compress()); +app.use('*', etag()); +app.use('*', secureHeaders()); +app.use('*', requestId()); + +app.route('/health', healthRoutes); + +app.get('/', (c) => { + return c.json({ message: 'Nerva API', version: '0.0.1' }); +}); + +const port = Number(process.env.PORT) || 3000; +console.log(`Server starting on port ${port}`); + +const server = serve({ fetch: app.fetch, port }); + +// --- Graceful shutdown --- +const SHUTDOWN_TIMEOUT_MS = 10_000; +let isShuttingDown = false; + +const shutdown = (signal: string) => { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log(`\n${signal} received. Shutting down gracefully...`); + + const forceExit = setTimeout(() => { + console.error(`Forced shutdown after ${SHUTDOWN_TIMEOUT_MS / 1000}s timeout.`); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + forceExit.unref(); + + server.close(() => { + console.log('HTTP server closed.'); + // TODO: Close database connection pool when configured + // await pool.end(); + console.log('Shutdown complete.'); + process.exit(0); + }); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/templates/snippets/node/src/routes/health.ts b/templates/snippets/node/src/routes/health.ts new file mode 100644 index 0000000..e97db47 --- /dev/null +++ b/templates/snippets/node/src/routes/health.ts @@ -0,0 +1,29 @@ +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); +}); diff --git a/templates/snippets/node/tests/unit/health.test.ts b/templates/snippets/node/tests/unit/health.test.ts new file mode 100644 index 0000000..158986b --- /dev/null +++ b/templates/snippets/node/tests/unit/health.test.ts @@ -0,0 +1,96 @@ +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('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(); + }); +}); diff --git a/templates/snippets/shared/src/db/client.ts b/templates/snippets/shared/src/db/client.ts new file mode 100644 index 0000000..07dde16 --- /dev/null +++ b/templates/snippets/shared/src/db/client.ts @@ -0,0 +1,11 @@ +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 }); diff --git a/templates/snippets/shared/src/db/schema.ts b/templates/snippets/shared/src/db/schema.ts new file mode 100644 index 0000000..d10468d --- /dev/null +++ b/templates/snippets/shared/src/db/schema.ts @@ -0,0 +1,9 @@ +import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').defaultRandom().primaryKey(), + email: text('email').notNull().unique(), + name: text('name').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); diff --git a/templates/snippets/shared/src/db/seed.ts b/templates/snippets/shared/src/db/seed.ts new file mode 100644 index 0000000..e1b02ff --- /dev/null +++ b/templates/snippets/shared/src/db/seed.ts @@ -0,0 +1,17 @@ +import { client, db } from './client.js'; +import * as schema from './schema.js'; + +async function seed(): Promise { + console.log('Seeding database...'); + await db.insert(schema.users).values([ + { email: 'admin@example.com', name: 'Admin User' }, + { email: 'user@example.com', name: 'Test User' }, + ]); + console.log('Database seeded successfully.'); + await client.end(); +} + +seed().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/templates/snippets/shared/tests/setup.ts b/templates/snippets/shared/tests/setup.ts new file mode 100644 index 0000000..dcc69a6 --- /dev/null +++ b/templates/snippets/shared/tests/setup.ts @@ -0,0 +1,9 @@ +import { beforeAll, afterAll } from 'vitest'; + +beforeAll(() => { + // Global setup before all tests +}); + +afterAll(() => { + // Global cleanup after all tests +}); From ee56c3edb88d79a28ca160f9d55dcb763d06656c Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:41:38 -0400 Subject: [PATCH 3/7] feat(ci): scaffold for type-checking embedded templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds package.json + two tsconfigs at templates/snippets/ so the snippet files can be type-checked against the deps the generated projects ship with. Co-located with the snippets so node_modules sits where tsc's upward-walk module resolution can find it. Each tsconfig uses TypeScript's `rootDirs` to merge `shared/src` and the respective platform's `src` into one virtual root — that's what lets `node/src/db/ping.ts`'s `import { client } from './client.js'` resolve to `shared/src/db/client.ts` (in a generated project those files are siblings; in the snippet layout they live in different platform dirs). Both tsconfigs declare `["@cloudflare/workers-types", "node"]` types (Cloudflare) or `["node"]` (Node). Cloudflare needs Node types too because the shared seed.ts runs under tsx — `process.env`, `process.exit` are reachable even on Workers projects via the seed CLI. Local results: `pnpm run typecheck` exits 0 against both tsconfigs. Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/snippets/.gitignore | 1 + templates/snippets/package.json | 20 +++ templates/snippets/pnpm-lock.yaml | 178 ++++++++++++++++++++ templates/snippets/tsconfig.cloudflare.json | 20 +++ templates/snippets/tsconfig.node.json | 20 +++ 5 files changed, 239 insertions(+) create mode 100644 templates/snippets/.gitignore create mode 100644 templates/snippets/package.json create mode 100644 templates/snippets/pnpm-lock.yaml create mode 100644 templates/snippets/tsconfig.cloudflare.json create mode 100644 templates/snippets/tsconfig.node.json diff --git a/templates/snippets/.gitignore b/templates/snippets/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/templates/snippets/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/templates/snippets/package.json b/templates/snippets/package.json new file mode 100644 index 0000000..1454d34 --- /dev/null +++ b/templates/snippets/package.json @@ -0,0 +1,20 @@ +{ + "name": "typecheck-templates", + "version": "0.0.0", + "private": true, + "description": "Type-checks templates/snippets/ against the deps generated projects will install. Keep versions in sync with the pnpm add lines in scripts/setup-project.sh.", + "scripts": { + "typecheck:cf": "tsc --noEmit -p tsconfig.cloudflare.json", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json", + "typecheck": "pnpm run typecheck:cf && pnpm run typecheck:node" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260415.1", + "@hono/node-server": "^1.16.0", + "@types/node": "^25.6.0", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.14", + "postgres": "^3.4.5", + "typescript": "^6.0.2" + } +} diff --git a/templates/snippets/pnpm-lock.yaml b/templates/snippets/pnpm-lock.yaml new file mode 100644 index 0000000..58e5f09 --- /dev/null +++ b/templates/snippets/pnpm-lock.yaml @@ -0,0 +1,178 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260415.1 + version: 4.20260511.1 + '@hono/node-server': + specifier: ^1.16.0 + version: 1.19.14(hono@4.12.18) + '@types/node': + specifier: ^25.6.0 + version: 25.8.0 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@cloudflare/workers-types@4.20260511.1)(postgres@3.4.9) + hono: + specifier: ^4.12.14 + version: 4.12.18 + postgres: + specifier: ^3.4.5 + version: 3.4.9 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + +packages: + + '@cloudflare/workers-types@4.20260511.1': + resolution: {integrity: sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@types/node@25.8.0': + resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + +snapshots: + + '@cloudflare/workers-types@4.20260511.1': {} + + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + + '@types/node@25.8.0': + dependencies: + undici-types: 7.24.6 + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(postgres@3.4.9): + optionalDependencies: + '@cloudflare/workers-types': 4.20260511.1 + postgres: 3.4.9 + + hono@4.12.18: {} + + postgres@3.4.9: {} + + typescript@6.0.3: {} + + undici-types@7.24.6: {} diff --git a/templates/snippets/tsconfig.cloudflare.json b/templates/snippets/tsconfig.cloudflare.json new file mode 100644 index 0000000..0ac4652 --- /dev/null +++ b/templates/snippets/tsconfig.cloudflare.json @@ -0,0 +1,20 @@ +{ + "extends": "../shared/tsconfig.json", + "compilerOptions": { + "noEmit": true, + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "rootDir": ".", + "rootDirs": ["shared/src", "cloudflare/src"], + "paths": {} + }, + "include": [ + "shared/src/**/*.ts", + "cloudflare/src/**/*.ts" + ], + "exclude": [ + "node_modules", + "**/tests/**", + "node/**" + ] +} diff --git a/templates/snippets/tsconfig.node.json b/templates/snippets/tsconfig.node.json new file mode 100644 index 0000000..8899b3e --- /dev/null +++ b/templates/snippets/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "extends": "../shared/tsconfig.json", + "compilerOptions": { + "noEmit": true, + "moduleResolution": "bundler", + "types": ["node"], + "rootDir": ".", + "rootDirs": ["shared/src", "node/src"], + "paths": {} + }, + "include": [ + "shared/src/**/*.ts", + "node/src/**/*.ts" + ], + "exclude": [ + "node_modules", + "**/tests/**", + "cloudflare/**" + ] +} From c67caca0c969739dc37fe44987b3ac6dd804b198 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:46:02 -0400 Subject: [PATCH 4/7] refactor(setup): replace TS heredocs with copy_file from templates/snippets/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 12 write_file ... << 'EOF' blocks in scripts/setup-project.sh with copy_file calls pointing at the real .ts files extracted to templates/snippets/ in the previous commit. Net delta: 446 lines removed from the shell script. Generation output is byte-identical to the heredoc version. Verified by generating both Node and Cloudflare projects from the refactored script: each produces 7/7 passing health tests on the generated tree. drizzle.config.ts stays as a heredoc — it's tiny, has no app-code imports, and its only failure mode would be a drizzle-kit defineConfig signature change (caught downstream by drizzle-kit's own typecheck if it ever happens). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 470 +-------------------------------------- 1 file changed, 12 insertions(+), 458 deletions(-) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index a331e78..1b43267 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -168,103 +168,9 @@ success "Dev dependencies installed." step "Creating initial source files..." if [[ "$PLATFORM" == "cloudflare" ]]; then - write_file "$API_DIR/src/index.ts" << 'SRCEOF' -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -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. - -type Bindings = { - DB: D1Database; - KV: KVNamespace; - HYPERDRIVE: Hyperdrive; - APP_VERSION: string; - HEALTH_DB_TIMEOUT_MS: string; - ENVIRONMENT: string; - LOG_LEVEL: string; -}; - -const app = new Hono<{ Bindings: Bindings }>(); - -app.use('*', logger()); -app.use('*', cors()); -app.use('*', etag()); -app.use('*', secureHeaders()); -app.use('*', requestId()); - -app.route('/health', healthRoutes); - -app.get('/', (c) => { - return c.json({ message: 'Nerva API', version: '0.0.1' }); -}); - -export default app; -SRCEOF + copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/index.ts" "$API_DIR/src/index.ts" else - write_file "$API_DIR/src/index.ts" << 'SRCEOF' -import { Hono } from 'hono'; -import { compress } from 'hono/compress'; -import { cors } from 'hono/cors'; -import { etag } from 'hono/etag'; -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(); - -app.use('*', logger()); -app.use('*', cors()); -app.use('*', compress()); -app.use('*', etag()); -app.use('*', secureHeaders()); -app.use('*', requestId()); - -app.route('/health', healthRoutes); - -app.get('/', (c) => { - return c.json({ message: 'Nerva API', version: '0.0.1' }); -}); - -const port = Number(process.env.PORT) || 3000; -console.log(`Server starting on port ${port}`); - -const server = serve({ fetch: app.fetch, port }); - -// --- Graceful shutdown --- -const SHUTDOWN_TIMEOUT_MS = 10_000; -let isShuttingDown = false; - -const shutdown = (signal: string) => { - if (isShuttingDown) return; - isShuttingDown = true; - - console.log(`\n${signal} received. Shutting down gracefully...`); - - const forceExit = setTimeout(() => { - console.error(`Forced shutdown after ${SHUTDOWN_TIMEOUT_MS / 1000}s timeout.`); - process.exit(1); - }, SHUTDOWN_TIMEOUT_MS); - forceExit.unref(); - - server.close(() => { - console.log('HTTP server closed.'); - // TODO: Close database connection pool when configured - // await pool.end(); - console.log('Shutdown complete.'); - process.exit(0); - }); -}; - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); -SRCEOF + copy_file "$TEMPLATES_DIR/snippets/node/src/index.ts" "$API_DIR/src/index.ts" run_cmd pnpm add @hono/node-server fi @@ -283,376 +189,24 @@ export default defineConfig({ }); DEOF -write_file "$API_DIR/src/db/schema.ts" << 'SEOF' -import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; - -export const users = pgTable('users', { - id: uuid('id').defaultRandom().primaryKey(), - email: text('email').notNull().unique(), - name: text('name').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); -SEOF - -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 { - console.log('Seeding database...'); - await db.insert(schema.users).values([ - { email: 'admin@example.com', name: 'Admin User' }, - { email: 'user@example.com', name: 'Test User' }, - ]); - console.log('Database seeded successfully.'); - await client.end(); -} - -seed().catch((err) => { - console.error('Seed failed:', err); - process.exit(1); -}); -SEEDEOF +copy_file "$TEMPLATES_DIR/snippets/shared/src/db/schema.ts" "$API_DIR/src/db/schema.ts" +copy_file "$TEMPLATES_DIR/snippets/shared/src/db/client.ts" "$API_DIR/src/db/client.ts" +copy_file "$TEMPLATES_DIR/snippets/shared/src/db/seed.ts" "$API_DIR/src/db/seed.ts" 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 + copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/db/ping.ts" "$API_DIR/src/db/ping.ts" + copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/routes/health.ts" "$API_DIR/src/routes/health.ts" 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 + copy_file "$TEMPLATES_DIR/snippets/node/src/db/ping.ts" "$API_DIR/src/db/ping.ts" + copy_file "$TEMPLATES_DIR/snippets/node/src/routes/health.ts" "$API_DIR/src/routes/health.ts" fi -write_file "$API_DIR/tests/setup.ts" << 'TSEOF' -import { beforeAll, afterAll } from 'vitest'; - -beforeAll(() => { - // Global setup before all tests -}); - -afterAll(() => { - // Global cleanup after all tests -}); -TSEOF +copy_file "$TEMPLATES_DIR/snippets/shared/tests/setup.ts" "$API_DIR/tests/setup.ts" 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' }; -} - -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.route('/health', healthRoutes); - return app; -}; - -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()) 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('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 + copy_file "$TEMPLATES_DIR/snippets/cloudflare/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts" 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('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 + copy_file "$TEMPLATES_DIR/snippets/node/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts" fi success "Initial source files created." From e5dc25f1f4ab0d0db618117ae1225c4e18c14217 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:48:30 -0400 Subject: [PATCH 5/7] ci: type-check embedded templates against current dep versions (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a typecheck-templates job that installs the snippet typecheck scaffold (templates/snippets/package.json) with --frozen-lockfile and runs tsc --noEmit against both tsconfig.cloudflare.json and tsconfig.node.json. This catches type drift between setup-project.sh's templates and the Drizzle/Hono/postgres APIs the generated projects will actually install — without needing to run the full setup-project.sh + pnpm install + pnpm typecheck on a freshly-generated project. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03607dd..e91eb5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,31 @@ jobs: echo "pnpm version: $(pnpm --version)" pnpm ls --depth 0 + typecheck-templates: + name: Type-check Embedded Templates + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + - run: corepack enable + + - name: Install typecheck dependencies + run: pnpm install --frozen-lockfile + working-directory: templates/snippets + + - name: Type-check Cloudflare snippets + run: pnpm exec tsc --noEmit -p tsconfig.cloudflare.json + working-directory: templates/snippets + + - name: Type-check Node snippets + run: pnpm exec tsc --noEmit -p tsconfig.node.json + working-directory: templates/snippets + check-links: name: Check Markdown Links runs-on: ubuntu-latest From 7daec26a32ff981670a23fcfbe82c3b693b9f84b Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:49:04 -0400 Subject: [PATCH 6/7] docs(setup): cross-reference typecheck-templates dep versions Adds NOTE comments near setup-project.sh's pnpm add lines pointing at templates/snippets/package.json. Bumping a dep on one side without the other is exactly what the typecheck-templates CI job is meant to catch, but a comment helps future maintainers update both proactively. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup-project.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 1b43267..ab340e8 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -157,10 +157,14 @@ PKGJSON success "package.json created." step "Installing production dependencies..." +# NOTE: keep these versions in sync with templates/snippets/package.json — the +# typecheck-templates CI job runs tsc against the snippets using those deps. run_cmd pnpm add hono drizzle-orm postgres zod @hono/zod-validator success "Production dependencies installed." step "Installing dev dependencies..." +# NOTE: @types/node and @cloudflare/workers-types (added per-platform below) +# also live in templates/snippets/package.json — bump in both places. run_cmd pnpm add -D vitest typescript eslint prettier drizzle-kit @types/node tsx \ @eslint/js typescript-eslint @vitest/coverage-v8 success "Dev dependencies installed." From b504de8b2bec9e0beee3c18a0793d1ab79a3eaa9 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 14 May 2026 13:51:03 -0400 Subject: [PATCH 7/7] docs(contributing): explain templates/snippets/ + dep version sync Documents the new template authoring model: - TS templates live in templates/snippets/, not in setup-project.sh heredocs - shared/cloudflare/node split matches generation logic - Bumping deps requires updating templates/snippets/package.json too - typecheck-templates CI job catches drift; local equivalent is `cd templates/snippets && pnpm run typecheck` Helps future contributors land template changes without surprising CI failures (#57). Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df86cc1..42947e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,30 @@ All checks must pass before a pull request will be reviewed. --- +## Modifying generated-project templates + +`scripts/setup-project.sh` generates a fresh API project by copying TypeScript files out of `templates/snippets/`. Edit those files — not heredocs in the shell script — when you want to change what a generated project ships with. + +The layout under `templates/snippets/`: + +- `shared/` — files identical in both target platforms (Drizzle schema, postgres client, seed script, vitest setup) +- `cloudflare/` — Workers-specific source (entry point, Hyperdrive ping, health route, test) +- `node/` — Node-specific source (entry point, postgres-client ping, health route, test) + +A small typecheck scaffold (`templates/snippets/package.json`, `tsconfig.cloudflare.json`, `tsconfig.node.json`) lets you run `tsc --noEmit` against the snippets against the same dep versions the generated projects use. The `typecheck-templates` CI job runs this on every push. + +**When you bump a dep version,** update it in **both** `scripts/setup-project.sh`'s `pnpm add` lines **and** `templates/snippets/package.json` — the CI job catches type drift, but matching versions up front avoids the CI failure entirely. Both files have `NOTE:` comments cross-referencing each other. + +To run the typecheck locally: + +```bash +cd templates/snippets +pnpm install +pnpm run typecheck # runs both tsconfig.cloudflare.json and tsconfig.node.json +``` + +--- + ## Branch Naming Conventions Use the following prefixes when creating branches: