function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ''); } export type AppVariant = 'development' | 'staging' | 'production'; const MISSING_ENV_HINT = 'Run `npm run use-env -- ` in app-expo, ' + 'then restart Metro or re-run `expo prebuild` before building.'; /** * EXPO_PUBLIC_* must be set at bundle time (Metro / EAS / Xcode Archive). * Refuses silent fallbacks to a hard-coded LAN IP. */ export function requirePublicEnv(name: string): string { const value = process.env[name]?.trim(); if (!value) { throw new Error(`[config] Missing ${name}. ${MISSING_ENV_HINT}`); } return value; } function parseBackendUrl(raw: string, envName: string): URL { let parsed: URL; try { parsed = new URL(raw); } catch { throw new Error(`[config] Invalid ${envName}: ${raw}`); } if (!parsed.protocol || parsed.protocol === ':') { throw new Error(`[config] ${envName} must include a scheme (http/https or ws/wss): ${raw}`); } return parsed; } function resolveApiBaseUrl(): string { const raw = requirePublicEnv('EXPO_PUBLIC_API_URL'); const parsed = parseBackendUrl(raw, 'EXPO_PUBLIC_API_URL'); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error( `[config] EXPO_PUBLIC_API_URL must use http:// or https:// (got ${parsed.protocol})`, ); } return trimTrailingSlashes(raw); } function resolveWsBaseUrl(): string { const raw = requirePublicEnv('EXPO_PUBLIC_WS_URL'); const parsed = parseBackendUrl(raw, 'EXPO_PUBLIC_WS_URL'); if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { throw new Error( `[config] EXPO_PUBLIC_WS_URL must use ws:// or wss:// (got ${parsed.protocol})`, ); } return trimTrailingSlashes(raw); } function resolveAppVariant(): AppVariant { const raw = process.env.EXPO_PUBLIC_APP_VARIANT; if (raw === 'development' || raw === 'staging' || raw === 'production') { return raw; } if (__DEV__) { return 'development'; } // Release 包若 env 未写入 APP_VARIANT(例如仅复制了 env/staging 到 .env 但变量缺失), // 仍按 API 是否为 http 推断预发,避免关于页误隐藏后端地址。 const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? ''; if (apiUrl.startsWith('http://')) { return 'staging'; } return 'production'; } /** Shown on About screen for dev/staging builds only (never production Release). */ export function shouldShowAboutBackendUrl(variant: AppVariant = appVariant): boolean { // Metro / 调试包:始终显示,避免 .env 误用 production variant 时看不到实际 API if (__DEV__) { return true; } return variant === 'development' || variant === 'staging'; } export const appVariant = resolveAppVariant(); export const config = { apiBaseUrl: resolveApiBaseUrl(), wsBaseUrl: resolveWsBaseUrl(), isDebugMode: __DEV__, appVariant, showAboutBackendUrl: shouldShowAboutBackendUrl(), api: { timeoutMs: 30_000, refreshPath: '/api/auth/refresh', }, ws: { reconnectMaxRetries: 10, reconnectBaseDelayMs: 1_000, reconnectMaxDelayMs: 30_000, heartbeatIntervalMs: 30_000, /** * 仅当 App 处于 `background` 连续超过该毫秒数才释放当前会话 WebSocket。 * 短暂切到其它应用再返回时保持连接,避免反复重连。 */ backgroundDisconnectAfterMs: 300_000, }, } as const;