You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
5.3 KiB
156 lines
5.3 KiB
import { join } from 'node:path';
|
|
import type { AnalyticsCategoryConfig } from './analytics.js';
|
|
|
|
/**
|
|
* Central env-var parsing/validation for this app. Lives in `server/core`
|
|
* (not `core/`) because it reads `process.env` directly rather than
|
|
* SvelteKit's `$env/static/private` — same reasoning as `tokens.ts`: this
|
|
* module tree is compiled standalone (`tsc -p tsconfig.server.json`) for
|
|
* `server.js` to import, outside Vite's module graph.
|
|
*/
|
|
|
|
export interface AnalyticsConfig extends AnalyticsCategoryConfig {
|
|
enabled: boolean;
|
|
dbPath: string;
|
|
}
|
|
|
|
export interface SessionRuntimeConfig {
|
|
/** Overrides `SessionPersona.model` (see $lib/project/persona.ts) when set. */
|
|
modelOverride?: string;
|
|
defaultVoice?: string;
|
|
clientToolTimeoutMs: number;
|
|
tokenTtlMs: number;
|
|
}
|
|
|
|
export interface AppConfig {
|
|
port: number;
|
|
/** Which `RealtimeProvider` to construct — see `providers/index.ts`'s `selectProvider`. */
|
|
realtimeProvider: 'openai' | 'mock';
|
|
/** Only required (and only meaningful) when `realtimeProvider` is `'openai'`. */
|
|
openaiApiKey: string;
|
|
analytics: AnalyticsConfig;
|
|
session: SessionRuntimeConfig;
|
|
}
|
|
|
|
let cached: AppConfig | null = null;
|
|
|
|
/**
|
|
* Parses and validates every env var this app reads, once, into a single
|
|
* typed object — memoized, since env vars don't change mid-process and this
|
|
* is called from both composition roots (server.js, dev-plugin.ts) plus the
|
|
* session-token route. Throws one Error listing every problem found, not
|
|
* just the first, so a misconfigured deployment fails loudly and completely
|
|
* at startup rather than one env var at a time.
|
|
*
|
|
* `REALTIME_TOKEN_SECRET` is checked for presence here (so a missing secret
|
|
* fails at the same startup point as everything else) but its value is
|
|
* deliberately NOT stored on `AppConfig` — `tokens.ts` remains the sole
|
|
* reader of the actual secret, reading `process.env` directly and lazily.
|
|
* Routing it through this memoized singleton would break `tokens.spec.ts`'s
|
|
* pattern of mutating `process.env.REALTIME_TOKEN_SECRET` per test to
|
|
* exercise `mintSessionToken`/`verifySessionToken` against different
|
|
* secrets — don't "simplify" this away.
|
|
*/
|
|
export function loadConfig(): AppConfig {
|
|
if (cached) return cached;
|
|
|
|
const errors: string[] = [];
|
|
|
|
const port = parseIntEnv('PORT', 3000, { min: 1, max: 65535 }, errors);
|
|
const realtimeProvider = parseEnumEnv(
|
|
'REALTIME_PROVIDER',
|
|
'openai',
|
|
['openai', 'mock'] as const,
|
|
errors
|
|
);
|
|
// Only required when actually talking to OpenAI — the mock provider (see
|
|
// providers/mock.ts) exists specifically so animation/UI work doesn't need
|
|
// a real key at all.
|
|
const openaiApiKey =
|
|
realtimeProvider === 'openai'
|
|
? requireEnv('OPENAI_API_KEY', errors)
|
|
: (optionalEnv('OPENAI_API_KEY') ?? '');
|
|
requireEnv('REALTIME_TOKEN_SECRET', errors); // presence only — see doc comment above
|
|
|
|
const analytics: AnalyticsConfig = {
|
|
enabled: parseBoolEnv('ANALYTICS_ENABLED', true, errors),
|
|
dbPath: process.env.ANALYTICS_DB_PATH || join(process.cwd(), 'data', 'analytics.db'),
|
|
messages: parseBoolEnv('ANALYTICS_LOG_MESSAGES', true, errors),
|
|
toolCalls: parseBoolEnv('ANALYTICS_LOG_TOOL_CALLS', true, errors),
|
|
events: parseBoolEnv('ANALYTICS_LOG_EVENTS', true, errors)
|
|
};
|
|
|
|
const session: SessionRuntimeConfig = {
|
|
modelOverride: optionalEnv('SESSION_MODEL'),
|
|
defaultVoice: optionalEnv('SESSION_DEFAULT_VOICE'),
|
|
clientToolTimeoutMs: parseIntEnv('CLIENT_TOOL_TIMEOUT_MS', 10_000, { min: 1 }, errors),
|
|
tokenTtlMs: parseIntEnv('REALTIME_TOKEN_TTL_MS', 60_000, { min: 1 }, errors)
|
|
};
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(`Invalid configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
}
|
|
|
|
cached = { port, realtimeProvider, openaiApiKey, analytics, session };
|
|
return cached;
|
|
}
|
|
|
|
function parseBoolEnv(name: string, def: boolean, errors: string[]): boolean {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === '') return def;
|
|
if (raw === 'true') return true;
|
|
if (raw === 'false') return false;
|
|
errors.push(`${name} must be "true" or "false" if set (got "${raw}").`);
|
|
return def;
|
|
}
|
|
|
|
function parseEnumEnv<T extends string>(
|
|
name: string,
|
|
def: T,
|
|
allowed: readonly T[],
|
|
errors: string[]
|
|
): T {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === '') return def;
|
|
if ((allowed as readonly string[]).includes(raw)) return raw as T;
|
|
errors.push(`${name} must be one of ${allowed.join(', ')} if set (got "${raw}").`);
|
|
return def;
|
|
}
|
|
|
|
function parseIntEnv(
|
|
name: string,
|
|
def: number,
|
|
opts: { min?: number; max?: number },
|
|
errors: string[]
|
|
): number {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === '') return def;
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n)) {
|
|
errors.push(`${name} must be an integer if set (got "${raw}").`);
|
|
return def;
|
|
}
|
|
if (opts.min !== undefined && n < opts.min) {
|
|
errors.push(`${name} must be >= ${opts.min} (got ${n}).`);
|
|
return def;
|
|
}
|
|
if (opts.max !== undefined && n > opts.max) {
|
|
errors.push(`${name} must be <= ${opts.max} (got ${n}).`);
|
|
return def;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function requireEnv(name: string, errors: string[]): string {
|
|
const raw = process.env[name];
|
|
if (!raw) {
|
|
errors.push(`${name} is not set. See .env.example.`);
|
|
return '';
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function optionalEnv(name: string): string | undefined {
|
|
const raw = process.env[name];
|
|
return raw === undefined || raw === '' ? undefined : raw;
|
|
}
|
|
|