accounts/dashboard/config/runtime-loader.ts
2025-11-02 11:51:46 +08:00

414 lines
11 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import yaml from 'js-yaml'
import baseSource from './runtime-service-config.base.yaml'
import prodSource from './runtime-service-config.prod.yaml'
import sitSource from './runtime-service-config.sit.yaml'
type RuntimeSourceKey = 'base' | 'prod' | 'sit'
export type RuntimeEnvironment = 'prod' | 'sit'
export type RuntimeRegion = 'default' | 'cn' | 'global'
export type RuntimeConfig = {
apiBaseUrl?: string
authUrl?: string
dashboardUrl?: string
internalApiBaseUrl?: string
logLevel?: string
[key: string]: unknown
} & {
environment: RuntimeEnvironment
region: RuntimeRegion
source: RuntimeSourceKey
hostname?: string
detectedBy: string
}
type RuntimeEnvSettings = {
environment: RuntimeEnvironment
region: RuntimeRegion
detectedBy: string
}
type RuntimeEnvGlobal = {
environment?: unknown
region?: unknown
}
const RUNTIME_ENV_CONFIG_BASENAME = '.runtime-env-config.yaml'
const YAML_SOURCES: Record<RuntimeSourceKey, string | undefined> = {
base: baseSource,
prod: prodSource,
sit: sitSource,
}
const parsedYamlCache: Partial<Record<RuntimeSourceKey, Record<string, unknown>>> = {}
const runtimeConfigCache = new Map<string, RuntimeConfig>()
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseYamlSource(sourceKey: RuntimeSourceKey): Record<string, unknown> {
if (parsedYamlCache[sourceKey]) {
return parsedYamlCache[sourceKey]!
}
const source = YAML_SOURCES[sourceKey]
if (!source) {
return {}
}
try {
const parsed = yaml.load(source)
if (isPlainRecord(parsed)) {
parsedYamlCache[sourceKey] = parsed
return parsed
}
console.warn(
`[runtime-config] YAML source "${sourceKey}" did not produce an object. Falling back to empty object.`,
)
} catch (error) {
console.warn(
`[runtime-config] Failed to parse YAML source "${sourceKey}", falling back to empty object.`,
error,
)
}
parsedYamlCache[sourceKey] = {}
return parsedYamlCache[sourceKey]!
}
function mergeConfigs(base: Record<string, unknown>, override?: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {}
const assignValue = (target: Record<string, unknown>, key: string, value: unknown) => {
if (Array.isArray(value)) {
target[key] = value.map((item) => (isPlainRecord(item) ? mergeConfigs({}, item) : item))
return
}
if (isPlainRecord(value)) {
const existing = isPlainRecord(target[key]) ? (target[key] as Record<string, unknown>) : {}
target[key] = mergeConfigs(existing, value)
return
}
target[key] = value
}
for (const [key, value] of Object.entries(base)) {
assignValue(result, key, value)
}
if (!override) {
return result
}
for (const [key, value] of Object.entries(override)) {
assignValue(result, key, value)
}
return result
}
function sanitizeHostname(value?: string): string | undefined {
if (!value) {
return undefined
}
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const maybeUrl = trimmed.match(/^https?:\/\//i) ? trimmed : `https://${trimmed}`
try {
const url = new URL(maybeUrl)
const hostname = url.hostname.replace(/\.+$/, '').toLowerCase()
if (hostname) {
return hostname
}
} catch {
const sanitized = trimmed
.replace(/^[^/]+:\/\//, '')
.split('/')[0]
.split(':')[0]
.replace(/\.+$/, '')
.toLowerCase()
if (sanitized) {
return sanitized
}
}
return undefined
}
function detectHostname(hostnameOverride?: string): { hostname?: string; detectedBy: string } {
const override = sanitizeHostname(hostnameOverride)
if (override) {
return { hostname: override, detectedBy: 'parameter' }
}
if (typeof window !== 'undefined' && typeof window.location?.hostname === 'string') {
const browserHostname = sanitizeHostname(window.location.hostname)
if (browserHostname) {
return { hostname: browserHostname, detectedBy: 'window.location.hostname' }
}
}
const envCandidates: Array<{ source: string; value?: string }> = [
{ source: 'RUNTIME_HOSTNAME', value: process.env.RUNTIME_HOSTNAME },
{ source: 'NEXT_RUNTIME_HOSTNAME', value: process.env.NEXT_RUNTIME_HOSTNAME },
{ source: 'DEPLOYMENT_HOSTNAME', value: process.env.DEPLOYMENT_HOSTNAME },
{ source: 'VERCEL_URL', value: process.env.VERCEL_URL },
{ source: 'NEXT_PUBLIC_VERCEL_URL', value: process.env.NEXT_PUBLIC_VERCEL_URL },
{ source: 'URL', value: process.env.URL },
{ source: 'HOSTNAME', value: process.env.HOSTNAME },
]
for (const candidate of envCandidates) {
const hostname = sanitizeHostname(candidate.value)
if (!hostname) {
continue
}
const likelyMachineHostname = !hostname.includes('.') && !LOCAL_HOSTNAMES.has(hostname)
if (likelyMachineHostname) {
continue
}
if (hostname) {
return { hostname, detectedBy: candidate.source }
}
}
return { hostname: undefined, detectedBy: 'default' }
}
function normalizeEnvironmentValue(value: unknown): RuntimeEnvironment | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
const mapping: Record<string, RuntimeEnvironment> = {
prod: 'prod',
production: 'prod',
release: 'prod',
main: 'prod',
live: 'prod',
sit: 'sit',
staging: 'sit',
test: 'sit',
qa: 'sit',
uat: 'sit',
dev: 'sit',
development: 'sit',
preview: 'sit',
preprod: 'sit',
}
return mapping[normalized]
}
function normalizeRegionValue(value: unknown): RuntimeRegion | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = value.trim().toLowerCase()
if (!normalized) {
return undefined
}
if (normalized === 'cn' || normalized === 'china') {
return 'cn'
}
if (normalized === 'global') {
return 'global'
}
if (normalized === 'default') {
return 'default'
}
return undefined
}
let runtimeEnvSettingsCache: RuntimeEnvSettings | undefined
function readRuntimeEnvSettings(): RuntimeEnvSettings {
if (runtimeEnvSettingsCache) {
return runtimeEnvSettingsCache
}
if (typeof window !== 'undefined') {
const globalCandidate = (window as unknown as { __XCONTROL_RUNTIME_ENV__?: RuntimeEnvGlobal })
.__XCONTROL_RUNTIME_ENV__
const environmentFromGlobal = normalizeEnvironmentValue(globalCandidate?.environment)
const regionFromGlobal = normalizeRegionValue(globalCandidate?.region)
if (environmentFromGlobal) {
runtimeEnvSettingsCache = {
environment: environmentFromGlobal,
region: regionFromGlobal ?? 'default',
detectedBy: 'window.__XCONTROL_RUNTIME_ENV__',
}
return runtimeEnvSettingsCache
}
const environmentFromEnv = normalizeEnvironmentValue(process.env.NEXT_PUBLIC_RUNTIME_ENVIRONMENT)
const regionFromEnv = normalizeRegionValue(process.env.NEXT_PUBLIC_RUNTIME_REGION)
runtimeEnvSettingsCache = {
environment: environmentFromEnv ?? 'prod',
region: regionFromEnv ?? 'default',
detectedBy: environmentFromEnv ? 'client-env' : 'client-default',
}
return runtimeEnvSettingsCache
}
const candidates: Array<{ path: string; detectedBy: string }> = []
const explicitPath = process.env.RUNTIME_ENV_CONFIG_PATH
if (explicitPath) {
const resolved = path.isAbsolute(explicitPath)
? explicitPath
: path.resolve(process.cwd(), explicitPath)
candidates.push({ path: resolved, detectedBy: 'env:RUNTIME_ENV_CONFIG_PATH' })
}
candidates.push({
path: path.resolve(process.cwd(), 'dashboard/config', RUNTIME_ENV_CONFIG_BASENAME),
detectedBy: `file:dashboard/config/${RUNTIME_ENV_CONFIG_BASENAME}`,
})
candidates.push({
path: path.resolve(process.cwd(), RUNTIME_ENV_CONFIG_BASENAME),
detectedBy: `file:${RUNTIME_ENV_CONFIG_BASENAME}`,
})
for (const candidate of candidates) {
if (!fs.existsSync(candidate.path)) {
continue
}
try {
const content = fs.readFileSync(candidate.path, 'utf8')
const parsed = yaml.load(content)
if (!isPlainRecord(parsed)) {
continue
}
const environment = normalizeEnvironmentValue(parsed.environment)
const region = normalizeRegionValue(parsed.region)
if (environment) {
runtimeEnvSettingsCache = {
environment,
region: region ?? 'default',
detectedBy: candidate.detectedBy,
}
return runtimeEnvSettingsCache
}
} catch (error) {
console.warn(`[runtime-config] Failed to read runtime env config at ${candidate.path}`, error)
}
}
runtimeEnvSettingsCache = {
environment: 'prod',
region: 'default',
detectedBy: 'default',
}
return runtimeEnvSettingsCache
}
function splitEnvironmentOverrides(
environment: RuntimeEnvironment,
region: RuntimeRegion,
): { environmentOverrides: Record<string, unknown>; regionOverrides?: Record<string, unknown> } {
const envConfig = parseYamlSource(environment)
const environmentOverrides = mergeConfigs({}, envConfig)
let regionOverrides: Record<string, unknown> | undefined
const maybeRegions = environmentOverrides['regions']
if (isPlainRecord(maybeRegions)) {
const normalizedRegion = region.toLowerCase()
for (const [regionKey, regionValue] of Object.entries(maybeRegions)) {
if (!isPlainRecord(regionValue)) {
continue
}
if (regionKey.trim().toLowerCase() === normalizedRegion) {
regionOverrides = mergeConfigs({}, regionValue)
break
}
}
}
delete environmentOverrides['regions']
return { environmentOverrides, regionOverrides }
}
function buildCacheKey(
hostname?: string,
environment?: RuntimeEnvironment,
region?: RuntimeRegion,
): string {
return [hostname || '<unknown>', environment || '<env>', region || '<region>'].join('|')
}
export function loadRuntimeConfig(options?: { hostname?: string }): RuntimeConfig {
const { hostname, detectedBy: hostnameDetectedBy } = detectHostname(options?.hostname)
const { environment, region, detectedBy: envDetectedBy } = readRuntimeEnvSettings()
const cacheKey = buildCacheKey(hostname, environment, region)
const cached = runtimeConfigCache.get(cacheKey)
if (cached) {
return cached
}
const baseConfig = parseYamlSource('base')
const { environmentOverrides, regionOverrides } = splitEnvironmentOverrides(environment, region)
const merged = mergeConfigs(baseConfig, environmentOverrides)
const finalConfig = regionOverrides ? mergeConfigs(merged, regionOverrides) : merged
const detectionLabel = hostname
? `${envDetectedBy}|hostname:${hostnameDetectedBy}`
: envDetectedBy
const result: RuntimeConfig = {
...(finalConfig as RuntimeConfig),
environment,
region,
source: environment,
hostname,
detectedBy: detectionLabel,
}
runtimeConfigCache.set(cacheKey, result)
const regionLabel = region === 'default' ? '' : `/${region.toUpperCase()} region`
const hostLabel = hostname ? ` @ ${hostname}` : ''
console.info(`[runtime-config] Loaded env: ${environment.toUpperCase()}${regionLabel}${hostLabel}`)
return result
}