Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/setup-project.sh

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions templates/snippets/cloudflare/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof envSchema>;

export function parseConfig(env: unknown): Config {
return envSchema.parse(env);
}
17 changes: 14 additions & 3 deletions templates/snippets/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,32 @@ 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;
APP_VERSION: string;
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());
Expand All @@ -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;
55 changes: 32 additions & 23 deletions templates/snippets/cloudflare/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
6 changes: 6 additions & 0 deletions templates/snippets/cloudflare/tests/unit/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
19 changes: 19 additions & 0 deletions templates/snippets/node/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof envSchema>;

export const config: Config = envSchema.parse(process.env);
8 changes: 4 additions & 4 deletions templates/snippets/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions templates/snippets/node/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'),
Expand Down
7 changes: 1 addition & 6 deletions templates/snippets/node/tests/unit/health.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion templates/snippets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
8 changes: 8 additions & 0 deletions templates/snippets/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions templates/snippets/shared/tests/setup.ts
Original file line number Diff line number Diff line change
@@ -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
});
Expand Down
Loading