Skip to content

Commit 4f1f711

Browse files
committed
Split monolithic config into lazy per-section accessors
Replace the single ConfigSchema that parsed all env vars at import time with per-section files (jwt.ts, postgate.ts, github.ts, etc.) using lazy cached accessors. Eliminates the $app/environment import and buildConfig() stub, reducing cold start overhead.
1 parent a53bac2 commit 4f1f711

File tree

28 files changed

+273
-235
lines changed

28 files changed

+273
-235
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openworkers-api",
3-
"version": "1.6.1",
3+
"version": "1.7.0",
44
"license": "MIT",
55
"type": "module",
66
"repository": {

src/lib/config/email.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { z } from 'zod';
2+
import { getEnv } from './env';
3+
4+
const Schema = z.object({
5+
provider: z.enum(['scaleway']).optional(),
6+
from: z.string().default('noreply@openworkers.dev'),
7+
secretKey: z.string().optional(),
8+
projectId: z.string().optional(),
9+
region: z.string().default('fr-par'),
10+
appUrl: z.string().url().default('http://localhost:4200')
11+
});
12+
13+
export type EmailConfig = z.infer<typeof Schema>;
14+
15+
let cached: EmailConfig | null = null;
16+
17+
export function getEmailConfig(): EmailConfig {
18+
if (cached) return cached;
19+
20+
cached = Schema.parse({
21+
provider: getEnv('EMAIL_PROVIDER'),
22+
from: getEnv('EMAIL_FROM'),
23+
secretKey: getEnv('SCW_SECRET_KEY'),
24+
projectId: getEnv('SCW_PROJECT_ID'),
25+
region: getEnv('SCW_REGION'),
26+
appUrl: getEnv('APP_URL')
27+
});
28+
return cached;
29+
}

src/lib/config/env.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Read an environment variable from worker runtime (globalThis.env) or Bun/Node (process.env).
3+
*/
4+
export function getEnv(key: string): string | undefined {
5+
if (typeof globalThis !== 'undefined' && (globalThis as any).env) {
6+
const val = (globalThis as any).env[key];
7+
8+
if (val !== undefined) {
9+
return String(val);
10+
}
11+
}
12+
13+
if (typeof process !== 'undefined' && process.env) {
14+
return process.env[key];
15+
}
16+
17+
return undefined;
18+
}

src/lib/config/github.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from 'zod';
2+
import { getEnv } from './env';
3+
4+
const Schema = z.object({
5+
clientId: z.string().optional(),
6+
clientSecret: z.string().optional()
7+
});
8+
9+
export type GithubConfig = z.infer<typeof Schema>;
10+
11+
let cached: GithubConfig | null = null;
12+
13+
export function getGithubConfig(): GithubConfig {
14+
if (cached) return cached;
15+
16+
cached = Schema.parse({
17+
clientId: getEnv('GITHUB_CLIENT_ID'),
18+
clientSecret: getEnv('GITHUB_CLIENT_SECRET')
19+
});
20+
return cached;
21+
}

src/lib/config/index.ts

Lines changed: 15 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -1,197 +1,15 @@
1-
import { building } from '$app/environment';
2-
import { z } from 'zod';
3-
4-
/**
5-
* Read an environment variable from worker runtime (globalThis.env) or Bun/Node (process.env).
6-
*/
7-
function getEnv(key: string): string | undefined {
8-
if (typeof globalThis !== 'undefined' && (globalThis as any).env) {
9-
const val = (globalThis as any).env[key];
10-
11-
if (val !== undefined) {
12-
return String(val);
13-
}
14-
}
15-
16-
if (typeof process !== 'undefined' && process.env) {
17-
return process.env[key];
18-
}
19-
20-
return undefined;
21-
}
22-
23-
// UUID-like pattern (less strict than RFC 4122)
24-
const uuidLike = z
25-
.string()
26-
.regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, 'Invalid UUID format');
27-
28-
// Environment schema
29-
const EnvironmentSchema = z.enum(['development', 'staging', 'production', 'test']);
30-
31-
// Check if DATABASE binding is available (worker runtime)
32-
const hasDatabaseBinding = !!(globalThis as any).env?.DATABASE?.query;
33-
34-
// Configuration schema
35-
const ConfigSchema = z.object({
36-
// Environment
37-
nodeEnv: EnvironmentSchema.default('development'),
38-
39-
// Server
40-
port: z.coerce.number().int().positive().default(7000),
41-
42-
// JWT
43-
jwt: z.object({
44-
access: z.object({
45-
secret: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'),
46-
expiresIn: z.string().default('15m')
47-
}),
48-
refresh: z.object({
49-
secret: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
50-
expiresIn: z.string().default('18h')
51-
})
52-
}),
53-
54-
// GitHub OAuth
55-
github: z.object({
56-
clientId: z.string().optional(),
57-
clientSecret: z.string().optional()
58-
}),
59-
60-
// Postgate (SQL proxy)
61-
postgate: z.object({
62-
url: z.url().default('http://localhost:6080'),
63-
// Token (pg_xxx format) - for accessing OpenWorkers database
64-
token: (() => {
65-
const pgToken = z.string().regex(/^pg_[a-f0-9]{64}$/, 'POSTGATE_TOKEN must be a valid pg_xxx token');
66-
return hasDatabaseBinding ? pgToken.optional() : pgToken;
67-
})(),
68-
// Secret for generating deterministic system tokens
69-
systemTokenSecret: z.string().min(32, 'POSTGATE_SYSTEM_TOKEN_SECRET must be at least 32 characters')
70-
}),
71-
72-
// Shared S3/R2 for assets and storage bindings
73-
sharedStorage: z.object({
74-
bucket: z.string().optional(),
75-
endpoint: z.string().optional(),
76-
accessKeyId: z.string().optional(),
77-
secretAccessKey: z.string().optional(),
78-
publicUrl: z.string().optional()
79-
}),
80-
81-
// AI Services
82-
mistral: z.object({
83-
apiKey: z.string().optional()
84-
}),
85-
86-
// Email provider (verification, password reset)
87-
email: z.object({
88-
provider: z.enum(['scaleway']).optional(),
89-
from: z.string().default('noreply@openworkers.dev'),
90-
// Scaleway-specific
91-
secretKey: z.string().optional(),
92-
projectId: z.string().optional(),
93-
region: z.string().default('fr-par')
94-
}),
95-
96-
// App URLs for email links
97-
appUrl: z.string().url().default('http://localhost:4200')
98-
});
99-
100-
// Type inference
101-
export type Config = z.infer<typeof ConfigSchema>;
102-
export type Environment = z.infer<typeof EnvironmentSchema>;
103-
104-
// Parse and validate environment variables
105-
function loadConfig(): Config {
106-
const rawConfig = {
107-
nodeEnv: getEnv('NODE_ENV'),
108-
port: getEnv('PORT'),
109-
jwt: {
110-
access: {
111-
secret: getEnv('JWT_ACCESS_SECRET'),
112-
expiresIn: getEnv('JWT_ACCESS_EXP')
113-
},
114-
refresh: {
115-
secret: getEnv('JWT_REFRESH_SECRET'),
116-
expiresIn: getEnv('JWT_REFRESH_EXP')
117-
}
118-
},
119-
github: {
120-
clientId: getEnv('GITHUB_CLIENT_ID'),
121-
clientSecret: getEnv('GITHUB_CLIENT_SECRET')
122-
},
123-
postgate: {
124-
url: getEnv('POSTGATE_URL'),
125-
token: getEnv('POSTGATE_TOKEN'),
126-
systemTokenSecret: getEnv('POSTGATE_SYSTEM_TOKEN_SECRET')
127-
},
128-
sharedStorage: {
129-
bucket: getEnv('SHARED_STORAGE_BUCKET'),
130-
endpoint: getEnv('SHARED_STORAGE_ENDPOINT'),
131-
accessKeyId: getEnv('SHARED_STORAGE_ACCESS_KEY_ID'),
132-
secretAccessKey: getEnv('SHARED_STORAGE_SECRET_ACCESS_KEY'),
133-
publicUrl: getEnv('SHARED_STORAGE_PUBLIC_URL')
134-
},
135-
mistral: {
136-
apiKey: getEnv('MISTRAL_API_KEY')
137-
},
138-
email: {
139-
provider: getEnv('EMAIL_PROVIDER'),
140-
from: getEnv('EMAIL_FROM'),
141-
secretKey: getEnv('SCW_SECRET_KEY'),
142-
projectId: getEnv('SCW_PROJECT_ID'),
143-
region: getEnv('SCW_REGION')
144-
},
145-
appUrl: getEnv('APP_URL')
146-
};
147-
148-
try {
149-
const config = ConfigSchema.parse(rawConfig);
150-
151-
// Log configuration status
152-
if (config.nodeEnv === 'development') {
153-
console.log('Running in DEVELOPMENT mode');
154-
} else if (config.nodeEnv === 'production') {
155-
console.log('Running in PRODUCTION mode');
156-
}
157-
158-
// Warn about missing GitHub OAuth
159-
if (!config.github.clientId || !config.github.clientSecret) {
160-
console.warn('GitHub OAuth not configured (GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET missing)');
161-
}
162-
163-
// Warn about missing email provider
164-
if (!config.email.provider) {
165-
console.warn('Email not configured (EMAIL_PROVIDER missing) - email features disabled');
166-
}
167-
168-
return config;
169-
} catch (error) {
170-
if (error instanceof z.ZodError) {
171-
console.error('Configuration validation failed:', error);
172-
throw new Error('Invalid configuration');
173-
}
174-
throw error;
175-
}
176-
}
177-
178-
// At build time, env vars are unavailable — use safe defaults
179-
function buildConfig(): Config {
180-
return {
181-
nodeEnv: 'development',
182-
port: 7000,
183-
jwt: { access: { secret: '-' }, refresh: { secret: '-' } },
184-
github: {},
185-
postgate: { url: '-', token: '-', systemTokenSecret: '-' },
186-
sharedStorage: {},
187-
email: {},
188-
mistral: {},
189-
appUrl: 'http://localhost:4200'
190-
} as Config;
191-
}
192-
193-
// Export singleton config instance
194-
export const config: Config = building ? buildConfig() : loadConfig();
195-
196-
// Export individual sections for convenience
197-
export const { nodeEnv, port, jwt, github, postgate, sharedStorage, mistral, email, appUrl } = config;
1+
export { getEnv } from './env';
2+
export { getNodeEnv, getPort } from './server';
3+
export type { Environment } from './server';
4+
export { getJwtConfig } from './jwt';
5+
export type { JwtConfig } from './jwt';
6+
export { getPostgateConfig } from './postgate';
7+
export type { PostgateConfig } from './postgate';
8+
export { getGithubConfig } from './github';
9+
export type { GithubConfig } from './github';
10+
export { getStorageConfig } from './storage';
11+
export type { StorageConfig } from './storage';
12+
export { getMistralConfig } from './mistral';
13+
export type { MistralConfig } from './mistral';
14+
export { getEmailConfig } from './email';
15+
export type { EmailConfig } from './email';

src/lib/config/jwt.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { z } from 'zod';
2+
import { getEnv } from './env';
3+
4+
const Schema = z.object({
5+
access: z.object({
6+
secret: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'),
7+
expiresIn: z.string().default('15m')
8+
}),
9+
refresh: z.object({
10+
secret: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
11+
expiresIn: z.string().default('18h')
12+
})
13+
});
14+
15+
export type JwtConfig = z.infer<typeof Schema>;
16+
17+
let cached: JwtConfig | null = null;
18+
19+
export function getJwtConfig(): JwtConfig {
20+
if (cached) return cached;
21+
22+
cached = Schema.parse({
23+
access: {
24+
secret: getEnv('JWT_ACCESS_SECRET'),
25+
expiresIn: getEnv('JWT_ACCESS_EXP')
26+
},
27+
refresh: {
28+
secret: getEnv('JWT_REFRESH_SECRET'),
29+
expiresIn: getEnv('JWT_REFRESH_EXP')
30+
}
31+
});
32+
return cached;
33+
}

src/lib/config/mistral.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod';
2+
import { getEnv } from './env';
3+
4+
const Schema = z.object({
5+
apiKey: z.string().optional()
6+
});
7+
8+
export type MistralConfig = z.infer<typeof Schema>;
9+
10+
let cached: MistralConfig | null = null;
11+
12+
export function getMistralConfig(): MistralConfig {
13+
if (cached) return cached;
14+
15+
cached = Schema.parse({
16+
apiKey: getEnv('MISTRAL_API_KEY')
17+
});
18+
return cached;
19+
}

src/lib/config/postgate.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { z } from 'zod';
2+
import { getEnv } from './env';
3+
4+
const pgToken = z.string().regex(/^pg_[a-f0-9]{64}$/, 'POSTGATE_TOKEN must be a valid pg_xxx token');
5+
6+
function buildPostgateSchema() {
7+
const hasDatabaseBinding = !!(globalThis as any).env?.DATABASE?.query;
8+
9+
return z.object({
10+
url: z.url().default('http://localhost:6080'),
11+
token: hasDatabaseBinding ? pgToken.optional() : pgToken,
12+
systemTokenSecret: z.string().min(32, 'POSTGATE_SYSTEM_TOKEN_SECRET must be at least 32 characters')
13+
});
14+
}
15+
16+
export type PostgateConfig = z.infer<ReturnType<typeof buildPostgateSchema>>;
17+
18+
let cached: PostgateConfig | null = null;
19+
20+
export function getPostgateConfig(): PostgateConfig {
21+
if (cached) return cached;
22+
23+
cached = buildPostgateSchema().parse({
24+
url: getEnv('POSTGATE_URL'),
25+
token: getEnv('POSTGATE_TOKEN'),
26+
systemTokenSecret: getEnv('POSTGATE_SYSTEM_TOKEN_SECRET')
27+
});
28+
return cached;
29+
}

0 commit comments

Comments
 (0)