diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index ab340e8..0491092 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -173,8 +173,10 @@ step "Creating initial source files..." if [[ "$PLATFORM" == "cloudflare" ]]; then copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/index.ts" "$API_DIR/src/index.ts" + copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/config.ts" "$API_DIR/src/config.ts" else copy_file "$TEMPLATES_DIR/snippets/node/src/index.ts" "$API_DIR/src/index.ts" + copy_file "$TEMPLATES_DIR/snippets/node/src/config.ts" "$API_DIR/src/config.ts" run_cmd pnpm add @hono/node-server fi @@ -233,6 +235,8 @@ LOG_LEVEL=debug DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db APP_VERSION=0.0.1 HEALTH_DB_TIMEOUT_MS=2000 +# Required: at least 32 characters. Generate with: openssl rand -hex 32 +JWT_SECRET=replace-me-with-a-secure-32+-character-secret DVEOF success "Cloudflare Workers configured. Edit wrangler.toml with your resource IDs." @@ -248,6 +252,8 @@ DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db LOG_LEVEL=debug APP_VERSION=0.0.1 HEALTH_DB_TIMEOUT_MS=2000 +# Required in production (min 32 chars). Generate with: openssl rand -hex 32 +JWT_SECRET=dev-secret-change-me-in-production-min-32-chars ENVEOF success "Node.js / Docker configured." diff --git a/templates/snippets/cloudflare/src/config.ts b/templates/snippets/cloudflare/src/config.ts new file mode 100644 index 0000000..06a1a79 --- /dev/null +++ b/templates/snippets/cloudflare/src/config.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const envSchema = z.object({ + ENVIRONMENT: z.enum(['development', 'staging', 'production']).default('development'), + JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'), + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), + APP_VERSION: z.string().default('unknown'), + HEALTH_DB_TIMEOUT_MS: z.coerce.number().int().positive().default(2000), +}); + +export type Config = z.infer; + +export function parseConfig(env: unknown): Config { + return envSchema.parse(env); +} diff --git a/templates/snippets/cloudflare/src/index.ts b/templates/snippets/cloudflare/src/index.ts index f23f8a7..70243dd 100644 --- a/templates/snippets/cloudflare/src/index.ts +++ b/templates/snippets/cloudflare/src/index.ts @@ -4,11 +4,12 @@ import { etag } from 'hono/etag'; import { logger } from 'hono/logger'; import { requestId } from 'hono/request-id'; import { secureHeaders } from 'hono/secure-headers'; +import { parseConfig, type Config } from './config'; 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 = { +export type Bindings = { DB: D1Database; KV: KVNamespace; HYPERDRIVE: Hyperdrive; @@ -16,9 +17,19 @@ type Bindings = { HEALTH_DB_TIMEOUT_MS: string; ENVIRONMENT: string; LOG_LEVEL: string; + JWT_SECRET: string; }; -const app = new Hono<{ Bindings: Bindings }>(); +export type Variables = { + config: Config; +}; + +const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); + +app.use('*', async (c, next) => { + c.set('config', parseConfig(c.env)); + await next(); +}); app.use('*', logger()); app.use('*', cors()); @@ -29,7 +40,7 @@ app.use('*', requestId()); app.route('/health', healthRoutes); app.get('/', (c) => { - return c.json({ message: 'Nerva API', version: '0.0.1' }); + return c.json({ message: 'Nerva API', version: c.var.config.APP_VERSION }); }); export default app; diff --git a/templates/snippets/cloudflare/src/routes/health.ts b/templates/snippets/cloudflare/src/routes/health.ts index 8289a11..72a51f9 100644 --- a/templates/snippets/cloudflare/src/routes/health.ts +++ b/templates/snippets/cloudflare/src/routes/health.ts @@ -1,35 +1,44 @@ import { Hono } from 'hono'; +import { parseConfig, type Config } from '../config'; import { pingDatabase } from '../db/ping'; const startTime = Date.now(); type Bindings = { - HYPERDRIVE: Hyperdrive; + HYPERDRIVE: Hyperdrive | undefined; 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), - ); +type Variables = { + config: Config; +}; + +export const healthRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>().get( + '/', + async (c) => { + const config = c.var.config ?? parseConfig(c.env); + + const timeout = new Promise<'disconnected'>((resolve) => + setTimeout(() => resolve('disconnected'), config.HEALTH_DB_TIMEOUT_MS), + ); - let database: 'connected' | 'disconnected'; - try { - database = await Promise.race([pingDatabase(c.env.HYPERDRIVE), timeout]); - } catch { - database = 'disconnected'; - } + 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); -}); + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: config.APP_VERSION, + 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 index f4137f3..3f22085 100644 --- a/templates/snippets/cloudflare/tests/unit/health.test.ts +++ b/templates/snippets/cloudflare/tests/unit/health.test.ts @@ -21,12 +21,18 @@ type TestEnv = { APP_VERSION: string; HEALTH_DB_TIMEOUT_MS: string; HYPERDRIVE: Hyperdrive | undefined; + JWT_SECRET: string; + ENVIRONMENT: string; + LOG_LEVEL: string; }; const env: TestEnv = { APP_VERSION: '0.0.1', HEALTH_DB_TIMEOUT_MS: '2000', HYPERDRIVE: undefined, + JWT_SECRET: 'test-secret-for-unit-tests-min-32-characters', + ENVIRONMENT: 'development', + LOG_LEVEL: 'debug', }; const makeApp = () => { diff --git a/templates/snippets/node/src/config.ts b/templates/snippets/node/src/config.ts new file mode 100644 index 0000000..08942fb --- /dev/null +++ b/templates/snippets/node/src/config.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const isProduction = process.env['NODE_ENV'] === 'production'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + DATABASE_URL: z.string().url(), + JWT_SECRET: isProduction + ? z.string().min(32, 'JWT_SECRET must be at least 32 characters in production') + : z.string().min(32).default('dev-secret-change-me-in-production-min-32-chars'), + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default(isProduction ? 'info' : 'debug'), + APP_VERSION: z.string().default('unknown'), + HEALTH_DB_TIMEOUT_MS: z.coerce.number().int().positive().default(2000), +}); + +export type Config = z.infer; + +export const config: Config = envSchema.parse(process.env); diff --git a/templates/snippets/node/src/index.ts b/templates/snippets/node/src/index.ts index e8d9b5c..9bc5e59 100644 --- a/templates/snippets/node/src/index.ts +++ b/templates/snippets/node/src/index.ts @@ -6,6 +6,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 { config } from './config'; import { healthRoutes } from './routes/health'; const app = new Hono(); @@ -20,13 +21,12 @@ app.use('*', requestId()); app.route('/health', healthRoutes); app.get('/', (c) => { - return c.json({ message: 'Nerva API', version: '0.0.1' }); + return c.json({ message: 'Nerva API', version: config.APP_VERSION }); }); -const port = Number(process.env.PORT) || 3000; -console.log(`Server starting on port ${port}`); +console.log(`Server starting on port ${config.PORT}`); -const server = serve({ fetch: app.fetch, port }); +const server = serve({ fetch: app.fetch, port: config.PORT }); // --- Graceful shutdown --- const SHUTDOWN_TIMEOUT_MS = 10_000; diff --git a/templates/snippets/node/src/routes/health.ts b/templates/snippets/node/src/routes/health.ts index e97db47..16c1e4e 100644 --- a/templates/snippets/node/src/routes/health.ts +++ b/templates/snippets/node/src/routes/health.ts @@ -1,12 +1,12 @@ import { Hono } from 'hono'; +import { config } from '../config'; 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), + setTimeout(() => resolve('disconnected'), config.HEALTH_DB_TIMEOUT_MS), ); let database: 'connected' | 'disconnected'; @@ -19,7 +19,7 @@ export const healthRoutes = new Hono().get('/', async (c) => { const status = database === 'connected' ? 'healthy' : 'unhealthy'; const body = { status, - version: process.env.APP_VERSION ?? 'unknown', + version: config.APP_VERSION, uptime: Math.floor((Date.now() - startTime) / 1000), timestamp: new Date().toISOString(), requestId: c.get('requestId'), diff --git a/templates/snippets/node/tests/unit/health.test.ts b/templates/snippets/node/tests/unit/health.test.ts index 158986b..d52e51b 100644 --- a/templates/snippets/node/tests/unit/health.test.ts +++ b/templates/snippets/node/tests/unit/health.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; import { requestId } from 'hono/request-id'; import { pingDatabase } from '../../src/db/ping'; @@ -26,11 +26,6 @@ const makeApp = () => { 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(); diff --git a/templates/snippets/package.json b/templates/snippets/package.json index 1454d34..e3a9842 100644 --- a/templates/snippets/package.json +++ b/templates/snippets/package.json @@ -15,6 +15,7 @@ "drizzle-orm": "^0.45.2", "hono": "^4.12.14", "postgres": "^3.4.5", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "zod": "^4.4.3" } } diff --git a/templates/snippets/pnpm-lock.yaml b/templates/snippets/pnpm-lock.yaml index 58e5f09..c240b27 100644 --- a/templates/snippets/pnpm-lock.yaml +++ b/templates/snippets/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 packages: @@ -152,6 +155,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@cloudflare/workers-types@4.20260511.1': {} @@ -176,3 +182,5 @@ snapshots: typescript@6.0.3: {} undici-types@7.24.6: {} + + zod@4.4.3: {} diff --git a/templates/snippets/shared/tests/setup.ts b/templates/snippets/shared/tests/setup.ts index dcc69a6..8e5e295 100644 --- a/templates/snippets/shared/tests/setup.ts +++ b/templates/snippets/shared/tests/setup.ts @@ -1,5 +1,14 @@ import { beforeAll, afterAll } from 'vitest'; +// Set required environment variables before any module imports them. +// The Node config module parses process.env at module load, so these must +// be present before the first route/service import. +process.env['NODE_ENV'] ??= 'test'; +process.env['DATABASE_URL'] ??= 'postgresql://test:test@localhost:5432/test'; +process.env['JWT_SECRET'] ??= 'test-secret-for-unit-tests-min-32-characters'; +process.env['APP_VERSION'] ??= '0.0.1'; +process.env['HEALTH_DB_TIMEOUT_MS'] ??= '2000'; + beforeAll(() => { // Global setup before all tests });