/** * Every knob is an environment variable with a working default, so the * container runs correctly with no configuration at all. */ function int(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw === '') return fallback; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) { throw new Error(`${name} must be an integer, got ${JSON.stringify(raw)}`); } return parsed; } function str(name: string, fallback: string): string { const raw = process.env[name]; return raw === undefined || raw === '' ? fallback : raw; } export const config = { host: str('HOST', '0.0.0.0'), port: int('PORT', 8080), logLevel: str('LOG_LEVEL', 'info'), /** Where the persistent Chromium profile lives. Survives restarts so that * cookies and dismissed consent banners accumulate the way a real * browser's do. */ profileDir: str('PROFILE_DIR', './profile'), /** Concurrent page loads. Chromium is the memory ceiling on this box, so * requests queue rather than opening an unbounded number of tabs. */ maxConcurrent: int('MAX_CONCURRENT', 2), /** Budget for a single navigation, and for a whole resolve including * extraction. */ navigationTimeoutMs: int('NAVIGATION_TIMEOUT_MS', 20_000), resolveTimeoutMs: int('RESOLVE_TIMEOUT_MS', 30_000), /** Resolved posts are cached in memory only. Reloading or hitting back * must not drive Chromium again. */ cacheTtlMs: int('CACHE_TTL_MS', 60 * 60 * 1000), cacheMax: int('CACHE_MAX', 200), /** How long a signed /m/ media token stays valid. Long enough to finish * watching a video, short enough that a leaked URL goes stale. */ mediaTokenTtlMs: int('MEDIA_TOKEN_TTL_MS', 6 * 60 * 60 * 1000), /** Where this instance is reachable. Only used to print the rewrite rules * on the index page with the right hostname in them. */ publicOrigin: str('PUBLIC_ORIGIN', 'http://localhost:8080'), } as const; export type Config = typeof config;