Skip to content

Commit 190f1a1

Browse files
authored
Merge pull request #94 from PMDevSolutions/60-add-environment-specific-configuration-template
feat(templates): typed env config utility (#60)
2 parents f88f870 + 1a57b7a commit 190f1a1

12 files changed

Lines changed: 119 additions & 40 deletions

File tree

scripts/setup-project.sh

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from 'zod';
2+
3+
export const envSchema = z.object({
4+
ENVIRONMENT: z.enum(['development', 'staging', 'production']).default('development'),
5+
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
6+
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
7+
APP_VERSION: z.string().default('unknown'),
8+
HEALTH_DB_TIMEOUT_MS: z.coerce.number().int().positive().default(2000),
9+
});
10+
11+
export type Config = z.infer<typeof envSchema>;
12+
13+
export function parseConfig(env: unknown): Config {
14+
return envSchema.parse(env);
15+
}

templates/snippets/cloudflare/src/index.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,32 @@ import { etag } from 'hono/etag';
44
import { logger } from 'hono/logger';
55
import { requestId } from 'hono/request-id';
66
import { secureHeaders } from 'hono/secure-headers';
7+
import { parseConfig, type Config } from './config';
78
import { healthRoutes } from './routes/health';
89
// Note: Response compression is handled automatically by Cloudflare's edge network.
910
// No compress() middleware is needed for Workers deployments.
1011

11-
type Bindings = {
12+
export type Bindings = {
1213
DB: D1Database;
1314
KV: KVNamespace;
1415
HYPERDRIVE: Hyperdrive;
1516
APP_VERSION: string;
1617
HEALTH_DB_TIMEOUT_MS: string;
1718
ENVIRONMENT: string;
1819
LOG_LEVEL: string;
20+
JWT_SECRET: string;
1921
};
2022

21-
const app = new Hono<{ Bindings: Bindings }>();
23+
export type Variables = {
24+
config: Config;
25+
};
26+
27+
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
28+
29+
app.use('*', async (c, next) => {
30+
c.set('config', parseConfig(c.env));
31+
await next();
32+
});
2233

2334
app.use('*', logger());
2435
app.use('*', cors());
@@ -29,7 +40,7 @@ app.use('*', requestId());
2940
app.route('/health', healthRoutes);
3041

3142
app.get('/', (c) => {
32-
return c.json({ message: 'Nerva API', version: '0.0.1' });
43+
return c.json({ message: 'Nerva API', version: c.var.config.APP_VERSION });
3344
});
3445

3546
export default app;
Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,44 @@
11
import { Hono } from 'hono';
2+
import { parseConfig, type Config } from '../config';
23
import { pingDatabase } from '../db/ping';
34

45
const startTime = Date.now();
56

67
type Bindings = {
7-
HYPERDRIVE: Hyperdrive;
8+
HYPERDRIVE: Hyperdrive | undefined;
89
APP_VERSION: string;
910
HEALTH_DB_TIMEOUT_MS: string;
1011
};
1112

12-
export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => {
13-
const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS) || 2000;
14-
const timeout = new Promise<'disconnected'>((resolve) =>
15-
setTimeout(() => resolve('disconnected'), timeoutMs),
16-
);
13+
type Variables = {
14+
config: Config;
15+
};
16+
17+
export const healthRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>().get(
18+
'/',
19+
async (c) => {
20+
const config = c.var.config ?? parseConfig(c.env);
21+
22+
const timeout = new Promise<'disconnected'>((resolve) =>
23+
setTimeout(() => resolve('disconnected'), config.HEALTH_DB_TIMEOUT_MS),
24+
);
1725

18-
let database: 'connected' | 'disconnected';
19-
try {
20-
database = await Promise.race([pingDatabase(c.env.HYPERDRIVE), timeout]);
21-
} catch {
22-
database = 'disconnected';
23-
}
26+
let database: 'connected' | 'disconnected';
27+
try {
28+
database = await Promise.race([pingDatabase(c.env.HYPERDRIVE), timeout]);
29+
} catch {
30+
database = 'disconnected';
31+
}
2432

25-
const status = database === 'connected' ? 'healthy' : 'unhealthy';
26-
const body = {
27-
status,
28-
version: c.env.APP_VERSION ?? 'unknown',
29-
uptime: Math.floor((Date.now() - startTime) / 1000),
30-
timestamp: new Date().toISOString(),
31-
requestId: c.get('requestId'),
32-
checks: { database },
33-
};
34-
return c.json(body, status === 'healthy' ? 200 : 503);
35-
});
33+
const status = database === 'connected' ? 'healthy' : 'unhealthy';
34+
const body = {
35+
status,
36+
version: config.APP_VERSION,
37+
uptime: Math.floor((Date.now() - startTime) / 1000),
38+
timestamp: new Date().toISOString(),
39+
requestId: c.get('requestId'),
40+
checks: { database },
41+
};
42+
return c.json(body, status === 'healthy' ? 200 : 503);
43+
},
44+
);

templates/snippets/cloudflare/tests/unit/health.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ type TestEnv = {
2121
APP_VERSION: string;
2222
HEALTH_DB_TIMEOUT_MS: string;
2323
HYPERDRIVE: Hyperdrive | undefined;
24+
JWT_SECRET: string;
25+
ENVIRONMENT: string;
26+
LOG_LEVEL: string;
2427
};
2528

2629
const env: TestEnv = {
2730
APP_VERSION: '0.0.1',
2831
HEALTH_DB_TIMEOUT_MS: '2000',
2932
HYPERDRIVE: undefined,
33+
JWT_SECRET: 'test-secret-for-unit-tests-min-32-characters',
34+
ENVIRONMENT: 'development',
35+
LOG_LEVEL: 'debug',
3036
};
3137

3238
const makeApp = () => {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod';
2+
3+
const isProduction = process.env['NODE_ENV'] === 'production';
4+
5+
const envSchema = z.object({
6+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
7+
PORT: z.coerce.number().int().positive().default(3000),
8+
DATABASE_URL: z.string().url(),
9+
JWT_SECRET: isProduction
10+
? z.string().min(32, 'JWT_SECRET must be at least 32 characters in production')
11+
: z.string().min(32).default('dev-secret-change-me-in-production-min-32-chars'),
12+
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default(isProduction ? 'info' : 'debug'),
13+
APP_VERSION: z.string().default('unknown'),
14+
HEALTH_DB_TIMEOUT_MS: z.coerce.number().int().positive().default(2000),
15+
});
16+
17+
export type Config = z.infer<typeof envSchema>;
18+
19+
export const config: Config = envSchema.parse(process.env);

templates/snippets/node/src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from 'hono/logger';
66
import { requestId } from 'hono/request-id';
77
import { secureHeaders } from 'hono/secure-headers';
88
import { serve } from '@hono/node-server';
9+
import { config } from './config';
910
import { healthRoutes } from './routes/health';
1011

1112
const app = new Hono();
@@ -20,13 +21,12 @@ app.use('*', requestId());
2021
app.route('/health', healthRoutes);
2122

2223
app.get('/', (c) => {
23-
return c.json({ message: 'Nerva API', version: '0.0.1' });
24+
return c.json({ message: 'Nerva API', version: config.APP_VERSION });
2425
});
2526

26-
const port = Number(process.env.PORT) || 3000;
27-
console.log(`Server starting on port ${port}`);
27+
console.log(`Server starting on port ${config.PORT}`);
2828

29-
const server = serve({ fetch: app.fetch, port });
29+
const server = serve({ fetch: app.fetch, port: config.PORT });
3030

3131
// --- Graceful shutdown ---
3232
const SHUTDOWN_TIMEOUT_MS = 10_000;

templates/snippets/node/src/routes/health.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Hono } from 'hono';
2+
import { config } from '../config';
23
import { pingDatabase } from '../db/ping';
34

45
const startTime = Date.now();
56

67
export const healthRoutes = new Hono().get('/', async (c) => {
7-
const timeoutMs = Number(process.env.HEALTH_DB_TIMEOUT_MS) || 2000;
88
const timeout = new Promise<'disconnected'>((resolve) =>
9-
setTimeout(() => resolve('disconnected'), timeoutMs),
9+
setTimeout(() => resolve('disconnected'), config.HEALTH_DB_TIMEOUT_MS),
1010
);
1111

1212
let database: 'connected' | 'disconnected';
@@ -19,7 +19,7 @@ export const healthRoutes = new Hono().get('/', async (c) => {
1919
const status = database === 'connected' ? 'healthy' : 'unhealthy';
2020
const body = {
2121
status,
22-
version: process.env.APP_VERSION ?? 'unknown',
22+
version: config.APP_VERSION,
2323
uptime: Math.floor((Date.now() - startTime) / 1000),
2424
timestamp: new Date().toISOString(),
2525
requestId: c.get('requestId'),

templates/snippets/node/tests/unit/health.test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { Hono } from 'hono';
33
import { requestId } from 'hono/request-id';
44
import { pingDatabase } from '../../src/db/ping';
@@ -26,11 +26,6 @@ const makeApp = () => {
2626

2727
const mockedPing = vi.mocked(pingDatabase);
2828

29-
beforeAll(() => {
30-
process.env.APP_VERSION = '0.0.1';
31-
process.env.HEALTH_DB_TIMEOUT_MS = '2000';
32-
});
33-
3429
describe('Health endpoint', () => {
3530
beforeEach(() => {
3631
mockedPing.mockReset();

templates/snippets/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"drizzle-orm": "^0.45.2",
1616
"hono": "^4.12.14",
1717
"postgres": "^3.4.5",
18-
"typescript": "^6.0.2"
18+
"typescript": "^6.0.2",
19+
"zod": "^4.4.3"
1920
}
2021
}

0 commit comments

Comments
 (0)