feat: config + codegen env handling (#242)

* feat: better INLINE & PROD env handling

* chore: bump server version

* feat: refactor config ts better
This commit is contained in:
Nikhil
2026-01-16 16:53:06 -08:00
committed by GitHub
parent f63af69883
commit eacdfaf579
13 changed files with 155 additions and 109 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.41",
"version": "0.0.42",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",

View File

@@ -11,6 +11,7 @@ import { MCPServerConfig } from '@google/gemini-cli-core'
import type { HonoSSEStream } from '../../agent/provider-adapter/types'
import type { SessionManager } from '../../agent/session'
import type { ProviderConfig, ResolvedAgentConfig } from '../../agent/types'
import { INLINED_ENV } from '../../env'
import {
fetchBrowserOSConfig,
getLLMConfigFromProvider,
@@ -106,7 +107,7 @@ export class ChatService {
request: ChatRequest,
): Promise<ProviderConfig> {
if (request.provider === LLM_PROVIDERS.BROWSEROS) {
const configUrl = process.env.BROWSEROS_CONFIG_URL
const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL
if (!configUrl) {
throw new Error(
'BROWSEROS_CONFIG_URL environment variable is required for BrowserOS provider',

View File

@@ -11,6 +11,7 @@ import path from 'node:path'
import { Command, InvalidArgumentError } from 'commander'
import { z } from 'zod'
import { INLINED_ENV, REQUIRED_FOR_PRODUCTION } from './env'
import { VERSION } from './version'
const portSchema = z.number().int()
@@ -32,52 +33,47 @@ export const ServerConfigSchema = z.object({
export type ServerConfig = z.infer<typeof ServerConfigSchema>
type PartialConfig = {
cdpPort?: number | null
serverPort?: number
agentPort?: number
extensionPort?: number
resourcesDir?: string
executionDir?: string
mcpAllowRemote?: boolean
codegenServiceUrl?: string
instanceClientId?: string
instanceInstallId?: string
instanceBrowserosVersion?: string
instanceChromiumVersion?: string
}
type PartialConfig = Partial<z.input<typeof ServerConfigSchema>>
export type ConfigResult<T> =
| { ok: true; value: T }
| { ok: false; error: string }
interface ParsedCliArgs {
configPath?: string
cwd: string
overrides: PartialConfig
}
export function loadServerConfig(
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
): ConfigResult<ServerConfig> {
// 1. Parse CLI (commander with exitOverride - throws instead of exit)
const cli = parseCli(argv)
// 1. Parse CLI args
const cli = parseCliArgs(argv)
if (!cli.ok) return cli
// 2. Load config file (only if --config provided)
const file = loadConfigFile(cli.value.configPath)
// 2. Parse config file (only if --config provided)
const file = parseConfigFile(cli.value.configPath)
if (!file.ok) return file
// 3. Load from environment
const envConfig = loadEnv(env)
// 3. Parse runtime environment variables
const runtimeEnv = parseRuntimeEnv()
// 4. Merge: Defaults < Env < File < CLI
const merged = merge(
defaults(cli.value.cwd),
envConfig,
const merged = mergeConfigs(
getDefaults(cli.value.cwd),
runtimeEnv,
file.value,
cli.value.overrides,
)
// 5. agentPort is deprecated - always use serverPort
// 5. Add build-time inlined values
merged.codegenServiceUrl = INLINED_ENV.CODEGEN_SERVICE_URL
// 6. agentPort is deprecated - always equals serverPort
merged.agentPort = merged.serverPort
// 6. Validate with Zod (single source of truth)
// 7. Validate with Zod
const result = ServerConfigSchema.safeParse(merged)
if (!result.success) {
const errors = result.error.issues
@@ -89,16 +85,14 @@ export function loadServerConfig(
}
}
// 8. Validate required inlined env vars for production
const inlinedValidation = validateInlinedEnv()
if (!inlinedValidation.ok) return inlinedValidation
return { ok: true, value: result.data }
}
interface CliResult {
configPath?: string
cwd: string
overrides: PartialConfig
}
function parseCli(argv: string[]): ConfigResult<CliResult> {
function parseCliArgs(argv: string[]): ConfigResult<ParsedCliArgs> {
const program = new Command()
try {
@@ -179,15 +173,15 @@ function parseCli(argv: string[]): ConfigResult<CliResult> {
value: {
configPath: opts.config,
cwd,
overrides: filterUndefined({
overrides: omitUndefined({
cdpPort: opts.cdpPort,
serverPort: opts.serverPort ?? opts.httpMcpPort,
extensionPort: opts.extensionPort,
resourcesDir: opts.resourcesDir
? resolvePath(opts.resourcesDir, cwd)
? toAbsolutePath(opts.resourcesDir, cwd)
: undefined,
executionDir: opts.executionDir
? resolvePath(opts.executionDir, cwd)
? toAbsolutePath(opts.executionDir, cwd)
: undefined,
mcpAllowRemote: opts.allowRemoteInMcp || undefined,
}),
@@ -203,14 +197,14 @@ function parsePortArg(value: string): number {
return port
}
function loadConfigFile(explicitPath?: string): ConfigResult<PartialConfig> {
if (!explicitPath) {
function parseConfigFile(filePath?: string): ConfigResult<PartialConfig> {
if (!filePath) {
return { ok: true, value: {} }
}
const absPath = path.isAbsolute(explicitPath)
? explicitPath
: path.resolve(process.cwd(), explicitPath)
const absPath = path.isAbsolute(filePath)
? filePath
: path.resolve(process.cwd(), filePath)
if (!fs.existsSync(absPath)) {
return { ok: false, error: `Config file not found: ${absPath}` }
@@ -223,18 +217,12 @@ function loadConfigFile(explicitPath?: string): ConfigResult<PartialConfig> {
return {
ok: true,
value: filterUndefined({
value: omitUndefined({
cdpPort: cfg.ports?.cdp,
serverPort: cfg.ports?.server ?? cfg.ports?.http_mcp,
extensionPort: cfg.ports?.extension,
resourcesDir: resolvePathIfString(
cfg.directories?.resources,
configDir,
),
executionDir: resolvePathIfString(
cfg.directories?.execution,
configDir,
),
resourcesDir: parseAbsolutePath(cfg.directories?.resources, configDir),
executionDir: parseAbsolutePath(cfg.directories?.execution, configDir),
mcpAllowRemote:
cfg.flags?.allow_remote_in_mcp === true ? true : undefined,
instanceClientId:
@@ -261,36 +249,52 @@ function loadConfigFile(explicitPath?: string): ConfigResult<PartialConfig> {
}
}
function loadEnv(env: NodeJS.ProcessEnv): PartialConfig {
function parseRuntimeEnv(): PartialConfig {
const cwd = process.cwd()
return filterUndefined({
cdpPort: env.BROWSEROS_CDP_PORT
? safeParseInt(env.BROWSEROS_CDP_PORT)
return omitUndefined({
cdpPort: process.env.BROWSEROS_CDP_PORT
? safeParseInt(process.env.BROWSEROS_CDP_PORT)
: undefined,
serverPort: env.BROWSEROS_SERVER_PORT
? safeParseInt(env.BROWSEROS_SERVER_PORT)
serverPort: process.env.BROWSEROS_SERVER_PORT
? safeParseInt(process.env.BROWSEROS_SERVER_PORT)
: undefined,
extensionPort: env.BROWSEROS_EXTENSION_PORT
? safeParseInt(env.BROWSEROS_EXTENSION_PORT)
extensionPort: process.env.BROWSEROS_EXTENSION_PORT
? safeParseInt(process.env.BROWSEROS_EXTENSION_PORT)
: undefined,
resourcesDir: env.BROWSEROS_RESOURCES_DIR
? resolvePath(env.BROWSEROS_RESOURCES_DIR, cwd)
resourcesDir: process.env.BROWSEROS_RESOURCES_DIR
? toAbsolutePath(process.env.BROWSEROS_RESOURCES_DIR, cwd)
: undefined,
executionDir: env.BROWSEROS_EXECUTION_DIR
? resolvePath(env.BROWSEROS_EXECUTION_DIR, cwd)
executionDir: process.env.BROWSEROS_EXECUTION_DIR
? toAbsolutePath(process.env.BROWSEROS_EXECUTION_DIR, cwd)
: undefined,
codegenServiceUrl: env.CODEGEN_SERVICE_URL,
instanceInstallId: env.BROWSEROS_INSTALL_ID,
instanceClientId: env.BROWSEROS_CLIENT_ID,
instanceInstallId: process.env.BROWSEROS_INSTALL_ID,
instanceClientId: process.env.BROWSEROS_CLIENT_ID,
})
}
function safeParseInt(value: string): number | undefined {
const num = parseInt(value, 10)
return Number.isNaN(num) ? undefined : num
function validateInlinedEnv(): ConfigResult<void> {
if (process.env.NODE_ENV !== 'production') {
return { ok: true, value: undefined }
}
const missing: string[] = []
for (const varName of REQUIRED_FOR_PRODUCTION) {
if (!INLINED_ENV[varName]) {
missing.push(varName)
}
}
if (missing.length > 0) {
return {
ok: false,
error: `Missing required environment variables for production:\n${missing.map((v) => ` - ${v}`).join('\n')}`,
}
}
return { ok: true, value: undefined }
}
function defaults(cwd: string): PartialConfig {
function getDefaults(cwd: string): PartialConfig {
return {
cdpPort: null,
resourcesDir: cwd,
@@ -299,7 +303,7 @@ function defaults(cwd: string): PartialConfig {
}
}
function merge(...configs: PartialConfig[]): PartialConfig {
function mergeConfigs(...configs: PartialConfig[]): PartialConfig {
const result: PartialConfig = {}
for (const config of configs) {
for (const [key, value] of Object.entries(config)) {
@@ -311,22 +315,22 @@ function merge(...configs: PartialConfig[]): PartialConfig {
return result
}
function filterUndefined<T extends Record<string, unknown>>(
obj: T,
): Partial<T> {
function safeParseInt(value: string): number | undefined {
const num = parseInt(value, 10)
return Number.isNaN(num) ? undefined : num
}
function omitUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined),
) as Partial<T>
}
function resolvePath(target: string, baseDir: string): string {
function toAbsolutePath(target: string, baseDir: string): string {
return path.isAbsolute(target) ? target : path.resolve(baseDir, target)
}
function resolvePathIfString(
val: unknown,
baseDir: string,
): string | undefined {
function parseAbsolutePath(val: unknown, baseDir: string): string | undefined {
if (typeof val !== 'string') return undefined
return resolvePath(val, baseDir)
return toAbsolutePath(val, baseDir)
}

29
apps/server/src/env.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 BrowserOS
*
* Build-time inlined environment variables.
*
* IMPORTANT: Values here are replaced at build time by Bun's `--env inline` flag.
* The `process.env.X` access MUST be direct (not via a variable) for inlining to work.
*
* These variables are:
* - Replaced with literal strings in production builds
* - Read from actual env vars during development
*
* For runtime-only env vars (like BROWSEROS_CDP_PORT), use process.env directly.
*/
export const INLINED_ENV = {
SENTRY_DSN: process.env.SENTRY_DSN,
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
} as const
export const REQUIRED_FOR_PRODUCTION = [
'SENTRY_DSN',
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'BROWSEROS_CONFIG_URL',
] as const satisfies readonly (keyof typeof INLINED_ENV)[]

View File

@@ -17,6 +17,7 @@ if (typeof Bun === 'undefined') {
// Import polyfills first
import './lib/polyfill'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { CommanderError } from 'commander'
import { loadServerConfig } from './config'
import { Sentry } from './lib/sentry'
@@ -27,7 +28,7 @@ const configResult = loadServerConfig()
if (!configResult.ok) {
Sentry.captureException(new Error(configResult.error))
console.error(configResult.error)
process.exit(1)
process.exit(EXIT_CODES.GENERAL_ERROR)
}
const app = new Application(configResult.value)
@@ -40,7 +41,7 @@ try {
}
Sentry.captureException(error)
console.error('Failed to start server:', error)
process.exit(1)
process.exit(EXIT_CODES.GENERAL_ERROR)
}
process.on('SIGINT', () => app.stop())

View File

@@ -7,6 +7,7 @@
*/
import { LLM_PROVIDERS, type LLMConfig } from '@browseros/shared/schemas/llm'
import { INLINED_ENV } from '../../../env'
import { logger } from '../../logger'
import { fetchBrowserOSConfig, getLLMConfigFromProvider } from '../gateway'
import type { ResolvedLLMConfig } from './types'
@@ -22,7 +23,7 @@ export async function resolveLLMConfig(
return config as ResolvedLLMConfig
}
const configUrl = process.env.BROWSEROS_CONFIG_URL
const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL
if (!configUrl) {
throw new Error(
'BROWSEROS_CONFIG_URL environment variable is required for BrowserOS provider',

View File

@@ -5,7 +5,9 @@
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { PostHog } from 'posthog-node'
const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY
import { INLINED_ENV } from '../env'
const POSTHOG_API_KEY = INLINED_ENV.POSTHOG_API_KEY
const EVENT_PREFIX = 'browseros.server.'
export interface MetricsConfig {

View File

@@ -8,6 +8,7 @@
import { RATE_LIMITS } from '@browseros/shared/constants/limits'
import { INLINED_ENV } from '../../env'
import { fetchBrowserOSConfig } from '../clients/gateway'
import { logger } from '../logger'
@@ -26,7 +27,7 @@ export async function fetchDailyRateLimit(
return RATE_LIMITS.DEV_DAILY
}
const configUrl = process.env.BROWSEROS_CONFIG_URL
const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL
if (!configUrl) {
logger.info('No BROWSEROS_CONFIG_URL, using default rate limit', {
dailyRateLimit: RATE_LIMITS.DEFAULT_DAILY,

View File

@@ -4,13 +4,14 @@
*/
import * as Sentry from '@sentry/bun'
import { INLINED_ENV } from '../env'
import { VERSION } from '../version'
const SENTRY_ENVIRONMENT = process.env.NODE_ENV || 'development'
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.SENTRY_DSN,
dsn: INLINED_ENV.SENTRY_DSN,
// Adds request headers and IP for users, for more info visit:
// https://docs.sentry.io/platforms/javascript/guides/bun/configuration/options/#sendDefaultPii
sendDefaultPii: true,

View File

@@ -18,6 +18,7 @@ import { McpContext } from './browser/cdp/context'
import { ControllerBridge } from './browser/extension/bridge'
import { ControllerContext } from './browser/extension/context'
import type { ServerConfig } from './config'
import { INLINED_ENV } from './env'
import { initializeDb } from './lib/db'
import { identity } from './lib/identity'
import { logger } from './lib/logger'
@@ -139,7 +140,7 @@ export class Application {
logger.warn('Metrics disabled: missing POSTHOG_API_KEY')
}
if (!process.env.SENTRY_DSN) {
if (!INLINED_ENV.SENTRY_DSN) {
logger.warn('Sentry disabled: missing SENTRY_DSN')
}

View File

@@ -84,11 +84,11 @@ describe('loadServerConfig', () => {
describe('environment variables', () => {
it('reads from env when CLI not provided', () => {
const result = loadServerConfig(['bun', 'src/index.ts'], {
BROWSEROS_CDP_PORT: '9222',
BROWSEROS_SERVER_PORT: '9223',
BROWSEROS_EXTENSION_PORT: '9224',
})
process.env.BROWSEROS_CDP_PORT = '9222'
process.env.BROWSEROS_SERVER_PORT = '9223'
process.env.BROWSEROS_EXTENSION_PORT = '9224'
const result = loadServerConfig(['bun', 'src/index.ts'])
assert.strictEqual(result.ok, true)
if (!result.ok) return
@@ -100,13 +100,15 @@ describe('loadServerConfig', () => {
})
it('CLI takes precedence over env', () => {
const result = loadServerConfig(
['bun', 'src/index.ts', '--server-port=1111', '--extension-port=3333'],
{
BROWSEROS_SERVER_PORT: '9999',
BROWSEROS_EXTENSION_PORT: '9999',
},
)
process.env.BROWSEROS_SERVER_PORT = '9999'
process.env.BROWSEROS_EXTENSION_PORT = '9999'
const result = loadServerConfig([
'bun',
'src/index.ts',
'--server-port=1111',
'--extension-port=3333',
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
@@ -188,10 +190,13 @@ describe('loadServerConfig', () => {
}),
)
const result = loadServerConfig(
['bun', 'src/index.ts', `--config=${configPath}`],
{ BROWSEROS_SERVER_PORT: '9999' },
)
process.env.BROWSEROS_SERVER_PORT = '9999'
const result = loadServerConfig([
'bun',
'src/index.ts',
`--config=${configPath}`,
])
assert.strictEqual(result.ok, true)
if (!result.ok) return

View File

@@ -9,5 +9,5 @@
export const EXTERNAL_URLS = {
KLAVIS_PROXY: 'https://llm.browseros.com/klavis',
POSTHOG_DEFAULT: 'https://us.i.posthog.com',
CODEGEN_SERVICE: 'https://browseros-codegen.fly.dev',
CODEGEN_SERVICE: 'https://graph.browseros.com',
} as const

View File

@@ -13,8 +13,8 @@
* bun scripts/build/server.ts --mode=dev [--target=all]
*
* Modes:
* prod - Clean environment build using only .env.prod
* dev - Normal build using shell environment + .env.dev
* prod - Clean environment build using only .env.production
* dev - Normal build using shell environment + .env.development
*
* Targets:
* linux-x64, linux-arm64, windows-x64, darwin-arm64, darwin-x64, all
@@ -135,7 +135,7 @@ function validateProdEnv(envVars: Record<string, string>): void {
for (const varName of missing) {
console.error(` - ${varName}`)
}
console.error(`\n Please set these in .env.prod`)
console.error(`\n Please set these in .env.production`)
process.exit(1)
}
}
@@ -283,7 +283,7 @@ async function main() {
`\n Tip: bun run version:server [patch|minor|major] to bump version`,
)
const envFile = mode === 'prod' ? '.env.prod' : '.env.dev'
const envFile = mode === 'prod' ? '.env.production' : '.env.development'
const envPath = join(rootDir, envFile)
console.log(`\n📄 Loading environment from ${envFile}...`)