// 小程序 RTMP 网关配置:全部来自环境变量;密钥只保存在内存中,不打印、不回显
/**
* 网关专用 SRS app 命名空间
*
* LiveKit 返回的 EgressInfo 会把 RTMP 地址最后一段(流名与查询参数)脱敏,只剩 scheme / host / port / app 可信,
* 启动回收据此识别本网关的残留 Egress,因此该 app 只能给网关使用
*/
export const GATEWAY_SRS_APP_PATTERN = /^yudao-mp-gw(?:-[\w-]+)?$/
/**
* 读取整数配置
*
* @param {Record} env 环境变量
* @param {string} name 变量名
* @param {number} fallback 默认值
* @param {number} min 最小值
* @param {number} max 最大值
* @returns {number} 配置值
*/
function readInt(env, name, fallback, min, max) {
const raw = env[name]
if (raw === undefined || raw === '') {
return fallback
}
const value = Number(raw)
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`${name} 需为 ${min}~${max} 的整数`)
}
return value
}
/**
* 读取 URL 配置并校验协议
*
* @param {Record} env 环境变量
* @param {string} name 变量名
* @param {string} fallback 默认值
* @param {string[]} protocols 允许的协议,如 ['http:']
* @returns {string} 去掉末尾斜杠的 URL
*/
function readUrl(env, name, fallback, protocols) {
const raw = env[name] || fallback
let url
try {
url = new URL(raw)
} catch {
throw new Error(`${name} 不是合法 URL`)
}
if (!protocols.includes(url.protocol) || url.search || url.hash || url.username || url.password) {
throw new Error(`${name} 仅支持 ${protocols.join('/')} 且不能带查询参数或账号`)
}
return raw.replace(/\/+$/, '')
}
/**
* 读取必填字符串配置
*
* @param {Record} env 环境变量
* @param {string} name 变量名
* @param {number} minLength 最小长度
* @returns {string} 配置值
*/
function readRequired(env, name, minLength) {
const value = env[name]
if (!value || value.length < minLength) {
throw new Error(`${name} 未配置或长度不足 ${minLength}`)
}
return value
}
/**
* 加载网关配置;未显式开启时拒绝启动
*
* @param {Record} env 环境变量
* @returns {Readonly>} 只读配置
*/
export function loadConfig(env = process.env) {
if (env.MP_GATEWAY_ENABLED !== 'true') {
throw new Error('MP_GATEWAY_ENABLED 未设置为 true:小程序网关为默认关闭的试验功能')
}
const srsPublishBaseUrl = readUrl(env, 'MP_GATEWAY_SRS_PUBLISH_BASE_URL', 'rtmp://127.0.0.1:1936/yudao-mp-gw', ['rtmp:'])
const srsPlayBaseUrl = readUrl(env, 'MP_GATEWAY_SRS_PLAY_BASE_URL', 'rtmp://127.0.0.1:1936/yudao-mp-gw', ['rtmp:'])
const srsApp = new URL(srsPublishBaseUrl).pathname.replace(/^\/+/, '')
if (!GATEWAY_SRS_APP_PATTERN.test(srsApp) || new URL(srsPlayBaseUrl).pathname.replace(/^\/+/, '') !== srsApp) {
throw new Error('MP_GATEWAY_SRS_PUBLISH_BASE_URL 与 MP_GATEWAY_SRS_PLAY_BASE_URL 需使用同一个网关专用 app(yudao-mp-gw 或 yudao-mp-gw-<后缀>)')
}
const pollIntervalMs = readInt(env, 'MP_GATEWAY_POLL_INTERVAL_MS', 2000, 500, 10000)
return Object.freeze({
apiHost: env.MP_GATEWAY_API_HOST || '127.0.0.1', // 小程序 API 监听地址;公网部署放在 HTTPS 反向代理之后
apiPort: readInt(env, 'MP_GATEWAY_API_PORT', 8090, 1, 65535), // 小程序 API 端口
hookHost: env.MP_GATEWAY_HOOK_HOST || '127.0.0.1', // SRS 回调监听地址,只允许 SRS 访问
hookPort: readInt(env, 'MP_GATEWAY_HOOK_PORT', 8091, 1, 65535), // SRS 回调端口
livekitHttpUrl: readUrl(env, 'MP_GATEWAY_LIVEKIT_HTTP_URL', 'http://127.0.0.1:7880', ['http:', 'https:']), // LiveKit Server API 地址,只取自部署配置
apiKey: readRequired(env, 'MP_GATEWAY_LIVEKIT_API_KEY', 1), // 与后端签发房间 Token 的 apiKey 一致
apiSecret: readRequired(env, 'MP_GATEWAY_LIVEKIT_API_SECRET', 32), // 与后端签发房间 Token 的 apiSecret 一致
srsPublishBaseUrl, // Egress 推流到 SRS 的内部地址
srsPlayBaseUrl, // 小程序 live-player 拉流的外部地址
srsApp, // SRS app 名,回调时校验
pollIntervalMs, // 建议小程序轮询间隔
syncIntervalMs: pollIntervalMs, // 网关同步房间成员的间隔
leaseTtlMs: readInt(env, 'MP_GATEWAY_LEASE_TTL_MS', 20000, pollIntervalMs * 3, 120000), // 会话租约:超过该时间未轮询即释放
maxSessionMs: readInt(env, 'MP_GATEWAY_MAX_SESSION_MS', 2 * 60 * 60 * 1000, 60000, 24 * 60 * 60 * 1000), // 单会话最长存活时间
maxSessions: readInt(env, 'MP_GATEWAY_MAX_SESSIONS', 4, 1, 100), // 全局最大小程序会话数(每个会话 1 路 Ingress 转码)
maxRemotesPerSession: readInt(env, 'MP_GATEWAY_MAX_REMOTES_PER_SESSION', 4, 1, 16), // 单会话最多拉取的远端路数
maxEgress: readInt(env, 'MP_GATEWAY_MAX_EGRESS', 8, 1, 200), // 全局最大 Egress 路数
egressRetryMs: readInt(env, 'MP_GATEWAY_EGRESS_RETRY_MS', 10000, 1000, 120000), // 单路 Egress 失败后的冷却时间
egressPublishTimeoutMs: readInt(env, 'MP_GATEWAY_EGRESS_PUBLISH_TIMEOUT_MS', 20000, 5000, 120000), // Egress 启动后等待 SRS on_publish 的最长时间
cleanupRetryMs: readInt(env, 'MP_GATEWAY_CLEANUP_RETRY_MS', 5000, 1000, 60000), // 启动回收失败后的重试间隔
releaseRetryMs: readInt(env, 'MP_GATEWAY_RELEASE_RETRY_MS', 5000, 1000, 60000), // Ingress / Egress 释放失败后的重试间隔;确认释放前继续占用配额
requestTimeoutMs: readInt(env, 'MP_GATEWAY_REQUEST_TIMEOUT_MS', 10000, 1000, 60000), // 调用 LiveKit API 超时
clockSkewSec: readInt(env, 'MP_GATEWAY_CLOCK_SKEW_SEC', 30, 0, 300), // 校验 nbf / exp 允许的时钟偏差
egressEncoding: Object.freeze({ // live-player 兼容的 H264 Baseline + AAC 编码参数
width: readInt(env, 'MP_GATEWAY_EGRESS_WIDTH', 640, 160, 1920),
height: readInt(env, 'MP_GATEWAY_EGRESS_HEIGHT', 360, 120, 1080),
framerate: readInt(env, 'MP_GATEWAY_EGRESS_FRAMERATE', 15, 5, 30),
videoBitrate: readInt(env, 'MP_GATEWAY_EGRESS_VIDEO_KBPS', 600, 100, 4000),
audioBitrate: readInt(env, 'MP_GATEWAY_EGRESS_AUDIO_KBPS', 64, 16, 256),
keyFrameInterval: readInt(env, 'MP_GATEWAY_EGRESS_KEYFRAME_SEC', 1, 1, 10),
}),
})
}