// 小程序网关会话:1 路 Ingress 接收小程序推流 + 每个远端参与者 1 路 Track Composite Egress 推到 SRS import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' import { GATEWAY_SRS_APP_PATTERN } from './config.mjs' import { LiveKitApiError } from './livekit-api.mjs' const RESOURCE_PREFIX = 'yudao-mp-gw-' // 网关创建的 Ingress 名称前缀;单实例启动时据此回收残留 const USER_IDENTITY_PATTERN = /^[1-9]\d{0,18}$/ // 业务参与者 identity:数字 userId const EXCLUDED_KINDS = new Set(['EGRESS', 'AGENT', 'SIP']) // 非业务参与者类型;其它小程序成员的 INGRESS 属于业务参与者 const EGRESS_LIST_GRACE_MS = 10000 // 新建 Egress 在活跃列表中可见前的宽限期 const SESSION_ID_PATTERN = /^[0-9a-f]{32}$/ // 会话编号格式 const STREAM_PREFIX = 'yudaompgw_' // 网关推往 SRS 的流名前缀 const STREAM_NAME_PATTERN = /^yudaompgw_[0-9a-f]{12}_[1-9]\d{0,18}_[0-9a-f]{16}$/ // 网关流名完整格式 const RELEASE_CONCURRENCY = 4 // 定期重试时同时进行的释放尝试上限 const SHUTDOWN_RELEASE_RETRY_MS = 500 // 退出时释放失败后的重试间隔;整体时长由入口的退出超时兜底 const REDACTED_STREAM_PATTERN = /^\{yud\.\.\.[\w-]{3}\}$/ // LiveKit 脱敏后的网关流名:首 3 位 + ... + 末 3 位(末位来自推流密钥) /** 网关业务错误;status 为返回给客户端的 HTTP 状态码 */ export class GatewayError extends Error { /** * @param {number} status HTTP 状态码 * @param {string} code 错误码 * @param {string} message 错误说明 */ constructor(status, code, message) { super(message) this.status = status this.code = code } } /** * 计算 SHA-256 摘要;会话凭证、推拉流密钥只保存摘要用于比对 * * @param {string} value 原文 * @returns {Buffer} 摘要 */ function digest(value) { return createHash('sha256').update(value).digest() } /** * 常量时间比较凭证 * * @param {unknown} value 待校验原文 * @param {Buffer} expectedDigest 期望摘要 * @returns {boolean} 是否一致 */ function matches(value, expectedDigest) { return typeof value === 'string' && value.length > 0 && timingSafeEqual(digest(value), expectedDigest) } /** * 是否为业务参与者:数字 userId、非 Egress 等内部参与者、未断开 * * @param {{ identity: string, kind: string, state: string }} participant 参与者 * @returns {boolean} 是否业务参与者 */ function isBusinessParticipant(participant) { return USER_IDENTITY_PATTERN.test(participant.identity) && !EXCLUDED_KINDS.has(participant.kind) && participant.state !== 'DISCONNECTED' } /** * 选择远端参与者要转出的媒体:视频屏幕共享优先、摄像头回退;音频取麦克风 *

* 摄像头静音不更换 Egress,只回传 videoVisible,避免反复重建推流 * * @param {{ tracks: Array<{ sid: string, type: string, source: string, muted: boolean }> }} participant 参与者 * @returns {{ audioSid?: string, videoSid?: string, videoSource?: string, videoVisible: boolean }} 媒体选择 */ function pickMedia(participant) { const screenShare = participant.tracks.find(track => track.type === 'VIDEO' && track.source === 'SCREEN_SHARE' && !track.muted) const camera = participant.tracks.find(track => track.type === 'VIDEO' && (track.source === 'CAMERA' || track.source === 'UNKNOWN')) const video = screenShare || camera const audio = participant.tracks.find(track => track.type === 'AUDIO' && (track.source === 'MICROPHONE' || track.source === 'UNKNOWN')) return { audioSid: audio?.sid, videoSid: video?.sid, videoSource: video?.source, videoVisible: !!video && !video.muted, } } /** * 创建调用的结果是否未知:超时、网络错误、HTTP 5xx(含代理 502 / 504 与响应缺少资源编号)时, * 请求可能已被 LiveKit 受理、资源已建但编号丢失 *

* 4xx 等明确拒绝的失败按未创建处理,沿用冷却重试 * * @param {unknown} error 错误 * @returns {boolean} 是否结果未知 */ function isAmbiguousCreateError(error) { return error instanceof LiveKitApiError && (error.status === 0 || error.status >= 500) } /** * 解析 SRS 回调中的查询参数 * * @param {unknown} param 如 "?token=xxx" * @returns {URLSearchParams} 参数 */ function parseHookParam(param) { return new URLSearchParams(typeof param === 'string' ? param.replace(/^\?/, '') : '') } /** * 创建会话管理器(单实例) * * @param {Readonly>} config 网关配置 * @param {ReturnType} liveKit LiveKit API * @param {{ info: Function, warn: Function }} logger 日志;只记录编号与房间,不记录凭证 * @returns {Record} 会话管理方法 */ export function createSessionManager(config, liveKit, logger) { const sessions = new Map() // 会话编号 -> 会话 const sessionsByUser = new Map() // 房间 + identity -> 会话;同一用户同一房间只允许一份会话 const streams = new Map() // SRS 流名 -> { session, entry };会话关闭或远端移除时立即删除,撤销推拉流授权 const pendingReleases = new Map() // 「类型:资源编号」-> 待确认释放的 Ingress / Egress;释放成功前持续占用配额并定期重试 const inflightCreates = new Set() // 进行中的 Ingress / Egress 创建;结果返回前占用配额,退出时等待其完成 const unknownCreates = new Set() // 结果未知的创建(超时 / 网络错误);资源可能已在 LiveKit 侧存在,进程内永久占用配额与用户维度,只能重启后由启动回收清理 let shuttingDown = false // 是否正在退出 const reaper = setInterval(reapSessions, 1000) // 租约、Token 过期扫描与释放重试 /** * 会话的用户维度键 * * @param {{ room: string, identity: string }} value 会话或授权 * @returns {string} 键 */ function userKey(value) { return `${value.room}\n${value.identity}` } /** * 当前占用的 Ingress 数:存活会话 + 待确认释放 + 归属已失效但仍在创建中 + 结果未知的 Ingress * * @returns {number} 数量 */ function countIngress() { let count = sessions.size pendingReleases.forEach((item) => { if (item.kind === 'ingress') { count++ } }) inflightCreates.forEach((item) => { if (item.kind === 'ingress' && !item.isCurrent()) { count++ } }) unknownCreates.forEach((item) => { if (item.kind === 'ingress') { count++ } }) return count } /** * 当前占用的 Egress 路数:存活会话中的远端流(含启动中)+ 待确认释放 + 归属已失效但仍在创建中 + 结果未知的 Egress * * @returns {number} 路数 */ function countEgress() { let count = 0 sessions.forEach((session) => { count += session.remotes.size }) pendingReleases.forEach((item) => { if (item.kind === 'egress') { count++ } }) inflightCreates.forEach((item) => { if (item.kind === 'egress' && !item.isCurrent()) { count++ } }) unknownCreates.forEach((item) => { if (item.kind === 'egress') { count++ } }) return count } /** * 同一房间 + identity 是否仍有未释放的会话或推流资源(含释放中、创建中、结果未知) * * @param {string} key 用户维度键 * @returns {boolean} 是否占用 */ function isUserBusy(key) { if (sessionsByUser.has(key)) { return true } for (const item of pendingReleases.values()) { if (item.ownerKey === key) { return true } } for (const item of inflightCreates) { if (item.ownerKey === key) { return true } } for (const item of unknownCreates) { if (item.ownerKey === key) { return true } } return false } /** * 尝试释放一个待释放资源;同一资源同时只有一次尝试,成功后移出待释放列表,失败则等待下次重试 * * @param {{ kind: 'ingress' | 'egress', id: string, attempt?: Promise, nextAt: number }} item 待释放资源 * @returns {Promise} 是否已确认释放 */ function attemptRelease(item) { if (item.attempt) { return item.attempt } const key = `${item.kind}:${item.id}` const attempt = (item.kind === 'ingress' ? liveKit.deleteIngress(item.id) : liveKit.stopEgress(item.id)) .then(() => { if (pendingReleases.get(key) === item) { pendingReleases.delete(key) } return true }, (error) => { item.nextAt = Date.now() + config.releaseRetryMs logger.warn('资源释放失败,稍后重试', { kind: item.kind, id: item.id, error: error.message }) return false }) .finally(() => { if (item.attempt === attempt) { item.attempt = undefined } }) item.attempt = attempt return attempt } /** * 登记并立即释放资源;释放确认前资源保留在待释放列表中,继续占用配额 * * @param {{ kind: 'ingress' | 'egress', id: string, ownerKey?: string }} resource 资源;Ingress 带用户维度键 * @returns {Promise} 首次尝试是否已确认释放 */ function releaseResource(resource) { const key = `${resource.kind}:${resource.id}` let item = pendingReleases.get(key) if (!item) { item = { ...resource, attempt: undefined, nextAt: 0 } pendingReleases.set(key, item) } return attemptRelease(item) } /** * 重试到期的待释放资源,同时进行的释放尝试不超过上限 * * @param {boolean} force 是否忽略重试间隔(退出时使用) */ function retryReleases(force) { const now = Date.now() let active = 0 // 本轮扫描时正在进行的释放尝试 pendingReleases.forEach((item) => { if (item.attempt) { active++ } }) pendingReleases.forEach((item) => { if (active < RELEASE_CONCURRENCY && !item.attempt && (force || item.nextAt <= now)) { active++ void attemptRelease(item) } }) } /** * 跟踪资源创建:结果返回时若归属已失效(会话关闭、远端已更换),在移出创建列表前转入待释放,配额不出现空档; * 结果未知(超时 / 网络错误)时转入未知列表,继续占用配额与用户维度,不再盲目重建 * * @param {{ kind: 'ingress' | 'egress', ownerKey: string, isCurrent: () => boolean }} item 创建项 * @param {Promise>} promise LiveKit 创建调用 * @returns {Promise>} 创建结果 */ function trackCreate(item, promise) { inflightCreates.add(item) const task = promise.then((result) => { const id = item.kind === 'ingress' ? result.ingressId : result.egressId if (id && !item.isCurrent()) { void releaseResource({ kind: item.kind, id, ownerKey: item.ownerKey }) } inflightCreates.delete(item) return result }, (error) => { if (isAmbiguousCreateError(error)) { unknownCreates.add(item) logger.warn('创建结果未知,保留占位直到网关重启后启动回收', { kind: item.kind, error: error.message }) } inflightCreates.delete(item) throw error }) item.settled = task.then(() => undefined, () => undefined) return task } /** * 创建会话:同一房间 + identity 已有任何未释放的会话或推流资源时返回 409,不替换正在推流的会话 *

* 替换已进房的 Ingress 会触发 participant_left,后端会据此结束私聊通话,因此由原会话 DELETE 或租约到期后才允许新建 * * @param {{ room: string, identity: string, name: string, exp: number }} claims 房间 Token 授权 * @returns {Promise<{ session: Record, sessionToken: string }>} 会话与会话凭证 */ async function createSession(claims) { if (shuttingDown) { throw new GatewayError(503, 'shutting_down', '网关正在退出') } if (isUserBusy(userKey(claims))) { throw new GatewayError(409, 'session_busy', '该用户在本房间已有小程序通话会话或资源未释放;请先结束已有会话,持续出现请联系管理员') } if (countIngress() >= config.maxSessions) { throw new GatewayError(429, 'session_limit', '小程序网关会话数已达上限') } const now = Date.now() const sessionToken = randomBytes(32).toString('base64url') const session = { id: randomBytes(16).toString('hex'), // 会话编号,同时作为资源名后缀 tokenDigest: digest(sessionToken), // 会话凭证摘要 room: claims.room, // 房间名,取自 Token identity: claims.identity, // 用户 identity,取自 Token status: 'starting', // starting / active / closed closed: false, // 是否已关闭;异步步骤完成后据此清理迟到资源 closeReason: undefined, // 关闭原因 releaseTask: undefined, // 资源释放任务(首次尝试完成即结束,未确认的资源转入待释放重试) ingressId: undefined, // 小程序推流 Ingress pushUrl: undefined, // 小程序推流地址(含 stream key,仅返回给创建者) publishing: false, // 小程序推流是否已作为参与者进房 everPublished: false, // 是否曾经进房;之后房间消失即视为通话结束 remotes: new Map(), // 远端 userId -> 远端流 waitingUserIds: [], // 已进房但尚无可转出轨道的远端 overflowUserIds: [], // 超出单会话路数上限的远端 capacityUserIds: [], // 因全局 Egress 上限暂未转出的远端 remoteErrors: new Map(), // 远端 userId -> 最近一次转出失败原因 unknownUserIds: new Set(), // Egress 创建结果未知的远端;本会话内不再为其重建,避免叠加未知资源 presentUserIds: new Set(), // 最近一次同步时仍在房间内的远端 retryAfter: new Map(), // 远端 userId -> 允许重试的时间 syncing: false, // 是否正在同步房间 syncError: undefined, // 最近一次同步失败原因 syncTimer: undefined, // 同步定时器 leaseExpiresAt: now + config.leaseTtlMs, // 租约到期时间 expiresAt: Math.min(claims.exp * 1000, now + config.maxSessionMs), // 会话最晚结束时间 } sessions.set(session.id, session) sessionsByUser.set(userKey(session), session) try { const ingress = await trackCreate({ kind: 'ingress', ownerKey: userKey(session), isCurrent: () => !session.closed, }, liveKit.createIngress({ room: session.room, identity: session.identity, name: claims.name, resourceName: `${RESOURCE_PREFIX}${session.id}`, })) if (session.closed) { // 创建期间会话已关闭:转入待释放(与 trackCreate 的登记幂等) if (ingress.ingressId) { void releaseResource({ kind: 'ingress', id: ingress.ingressId, ownerKey: userKey(session) }) } throw new GatewayError(409, 'session_closed', '会话在创建过程中已关闭') } session.ingressId = ingress.ingressId if (!ingress.ingressId || !ingress.url || !ingress.streamKey) { throw new GatewayError(502, 'ingress_url_missing', 'LiveKit 未返回 RTMP 推流地址,请检查 ingress.rtmp_base_url') } session.pushUrl = `${ingress.url}/${ingress.streamKey}` session.status = 'active' } catch (error) { await closeSession(session, 'create_failed') if (error instanceof GatewayError) { throw error } logger.warn('创建 Ingress 失败', { room: session.room, error: error.message }) throw new GatewayError(502, 'livekit_unavailable', '创建小程序推流失败') } session.syncTimer = setInterval(() => void syncSession(session), config.syncIntervalMs) void syncSession(session) logger.info('会话已创建', { session: session.id, room: session.room, userId: session.identity, ingressId: session.ingressId }) return { session, sessionToken } } /** * 关闭会话:立即撤销推拉流授权与会话查询,释放 Ingress、Egress;重复调用返回同一个释放任务 *

* 释放任务在首次尝试完成后结束;未确认释放的资源保留在待释放列表中重试,并继续占用配额与用户维度 * * @param {Record} session 会话 * @param {string} reason 关闭原因 * @returns {Promise} 释放任务 */ function closeSession(session, reason) { if (session.releaseTask) { return session.releaseTask } session.closed = true session.closeReason = reason session.status = 'closed' clearInterval(session.syncTimer) sessions.delete(session.id) if (sessionsByUser.get(userKey(session)) === session) { sessionsByUser.delete(userKey(session)) } const entries = Array.from(session.remotes.values()) session.remotes.clear() entries.forEach((entry) => { if (streams.get(entry.streamName)?.entry === entry) { streams.delete(entry.streamName) } }) const resources = entries.filter(entry => entry.egressId).map(entry => ({ kind: 'egress', id: entry.egressId })) if (session.ingressId) { resources.push({ kind: 'ingress', id: session.ingressId, ownerKey: userKey(session) }) } session.releaseTask = Promise.all(resources.map(releaseResource)).then((results) => { logger.info('会话已关闭', { session: session.id, room: session.room, reason, pendingRelease: results.filter(released => !released).length }) }) return session.releaseTask } /** * 移除单路远端流:立即撤销授权并释放 Egress;启动中的 Egress 由 trackCreate 在返回后转入待释放 * * @param {Record} session 会话 * @param {Record} entry 远端流 */ function removeRemoteStream(session, entry) { if (session.remotes.get(entry.userId) === entry) { session.remotes.delete(entry.userId) } if (streams.get(entry.streamName)?.entry === entry) { streams.delete(entry.streamName) } if (entry.egressId) { void releaseResource({ kind: 'egress', id: entry.egressId }) } } /** * 为远端参与者启动 Egress;每次启动使用新的流名与推拉流密钥 * * @param {Record} session 会话 * @param {string} userId 远端 userId * @param {ReturnType} media 媒体选择 * @returns {Promise} */ async function startRemoteStream(session, userId, media) { const publishSecret = randomBytes(24).toString('base64url') const playToken = randomBytes(24).toString('base64url') const entry = { userId, // 远端 userId audioSid: media.audioSid, // 转出的音频轨道 videoSid: media.videoSid, // 转出的视频轨道 videoSource: media.videoSource, // 视频来源(CAMERA / SCREEN_SHARE),仅用于日志排查 videoVisible: media.videoVisible, // 视频是否可见(摄像头静音时为 false) streamName: `${STREAM_PREFIX}${session.id.slice(0, 12)}_${userId}_${randomBytes(8).toString('hex')}`, // SRS 流名 publishDigest: digest(publishSecret), // Egress 推流密钥摘要 playToken, // 小程序拉流凭证明文,仅保存在内存中,用于在会话查询中重新下发 playUrl playDigest: digest(playToken), // 拉流凭证摘要 egressId: undefined, // Egress 编号 publishing: false, // SRS 是否正在接收推流 everPublished: false, // SRS 是否收到过推流;启动超时只看该标记 startedAt: Date.now(), // 启动时间 } session.remotes.set(userId, entry) streams.set(entry.streamName, { session, entry }) const createItem = { // Egress 创建项;结果未知时作为占位保留 kind: 'egress', ownerKey: userKey(session), isCurrent: () => !session.closed && session.remotes.get(userId) === entry, } try { const egress = await trackCreate(createItem, liveKit.startTrackCompositeEgress({ room: session.room, audioTrackId: media.audioSid, videoTrackId: media.videoSid, url: `${config.srsPublishBaseUrl}/${entry.streamName}?secret=${publishSecret}`, encoding: config.egressEncoding, })) if (session.closed || session.remotes.get(userId) !== entry) { // 启动期间远端已更换:转入待释放(与 trackCreate 的登记幂等) if (egress.egressId) { void releaseResource({ kind: 'egress', id: egress.egressId }) } return } entry.egressId = egress.egressId session.remoteErrors.delete(userId) logger.info('远端转出已启动', { session: session.id, userId, egressId: egress.egressId, audio: !!media.audioSid, video: !!media.videoSid }) } catch (error) { if (unknownCreates.has(createItem)) { session.unknownUserIds.add(userId) } if (session.closed || session.remotes.get(userId) !== entry) { return } removeRemoteStream(session, entry) if (!session.unknownUserIds.has(userId)) { // 结果未知的远端不再重试,状态由 unknownUserIds 返回 failed session.retryAfter.set(userId, Date.now() + config.egressRetryMs) session.remoteErrors.set(userId, 'egress_failed') } logger.warn('远端转出启动失败', { session: session.id, userId, error: error.message }) } } /** * 按房间现状同步远端转出:新增、按媒体组合重建、回收已结束的 Egress * * @param {Record} session 会话 * @returns {Promise} */ async function syncSession(session) { if (session.closed || session.syncing) { return } session.syncing = true try { const participants = await liveKit.listParticipants(session.room) if (session.closed) { return } if (participants === null) { session.publishing = false if (session.everPublished) { void closeSession(session, 'room_gone') } return } session.publishing = participants.some(participant => participant.identity === session.identity && participant.kind === 'INGRESS' && participant.state !== 'DISCONNECTED' && participant.tracks.length > 0) session.everPublished = session.everPublished || session.publishing const desired = new Map() // 远端 userId -> 媒体选择 const waiting = [] const overflow = [] participants .filter(participant => isBusinessParticipant(participant) && participant.identity !== session.identity) .sort((a, b) => a.joinedAt - b.joinedAt) .forEach((participant) => { const media = pickMedia(participant) if (!media.audioSid && !media.videoSid) { waiting.push(participant.identity) } else if (desired.size >= config.maxRemotesPerSession) { overflow.push(participant.identity) } else { desired.set(participant.identity, media) } }) Array.from(session.remotes.values()).forEach((entry) => { const media = desired.get(entry.userId) if (!media || media.audioSid !== entry.audioSid || media.videoSid !== entry.videoSid) { removeRemoteStream(session, entry) } else { entry.videoVisible = media.videoVisible } }) const now = Date.now() // Egress 已启动但始终没有推到 SRS(如编码管线一直拿不到可输出的媒体):停止、冷却后重试,如实返回 failed Array.from(session.remotes.values()).forEach((entry) => { if (entry.egressId && !entry.everPublished && now - entry.startedAt > config.egressPublishTimeoutMs) { removeRemoteStream(session, entry) session.retryAfter.set(entry.userId, now + config.egressRetryMs) session.remoteErrors.set(entry.userId, 'egress_no_media') logger.warn('远端转出启动超时,未收到 SRS 推流', { session: session.id, userId: entry.userId, egressId: entry.egressId, videoSource: entry.videoSource || 'NONE', audio: !!entry.audioSid }) } }) if (Array.from(session.remotes.values()).some(entry => entry.egressId && now - entry.startedAt > EGRESS_LIST_GRACE_MS)) { const activeIds = new Set((await liveKit.listActiveEgress(session.room)).map(item => item.egressId)) if (session.closed) { return } Array.from(session.remotes.values()).forEach((entry) => { if (entry.egressId && now - entry.startedAt > EGRESS_LIST_GRACE_MS && !activeIds.has(entry.egressId)) { removeRemoteStream(session, entry) session.retryAfter.set(entry.userId, now + config.egressRetryMs) session.remoteErrors.set(entry.userId, 'egress_ended') } }) } const capacity = [] desired.forEach((media, userId) => { if (session.remotes.has(userId) || session.unknownUserIds.has(userId) || (session.retryAfter.get(userId) || 0) > now) { return } if (shuttingDown) { return } if (countEgress() >= config.maxEgress) { capacity.push(userId) return } void startRemoteStream(session, userId, media) }) Array.from(session.retryAfter.keys()).forEach((userId) => { if (!desired.has(userId)) { session.retryAfter.delete(userId) session.remoteErrors.delete(userId) } }) session.presentUserIds = new Set([...desired.keys(), ...waiting, ...overflow]) session.waitingUserIds = waiting session.overflowUserIds = overflow session.capacityUserIds = capacity session.syncError = undefined } catch (error) { session.syncError = 'livekit_unavailable' logger.warn('同步房间失败', { session: session.id, room: session.room, error: error.message }) } finally { session.syncing = false } } /** 扫描租约与 Token 过期的会话,并重试到期的资源释放 */ function reapSessions() { retryReleases(false) const now = Date.now() sessions.forEach((session) => { if (now > session.leaseExpiresAt) { void closeSession(session, 'lease_expired') } else if (now > session.expiresAt) { void closeSession(session, 'token_expired') } }) } /** * 按会话编号与会话凭证查找会话;任何不匹配都按不存在处理 * * @param {string} sessionId 会话编号 * @param {string | undefined} sessionToken 会话凭证 * @returns {Record} 会话 */ function authorize(sessionId, sessionToken) { const session = SESSION_ID_PATTERN.test(sessionId) ? sessions.get(sessionId) : undefined if (!session || !matches(sessionToken, session.tokenDigest)) { throw new GatewayError(404, 'session_not_found', '会话不存在或已结束') } return session } /** * 续租会话并返回小程序可见状态 * * @param {Record} session 会话 * @returns {Record} 会话状态 */ function touch(session) { session.leaseExpiresAt = Date.now() + config.leaseTtlMs const remotes = Array.from(session.remotes.values()).map(entry => ({ userId: Number(entry.userId), state: entry.publishing ? 'ready' : 'starting', ready: entry.publishing, playUrl: entry.publishing ? `${config.srsPlayBaseUrl}/${entry.streamName}?token=${entry.playToken}` : null, // SRS 收到推流后才下发,避免播放器提前拉空流 hasAudio: !!entry.audioSid, hasVideo: !!entry.videoSid, videoVisible: entry.videoVisible, })) const listed = new Set(session.remotes.keys()) // 已输出的远端,避免同一远端重复出现 const appendState = (userIds, state) => userIds.forEach((userId) => { if (!listed.has(userId)) { listed.add(userId) remotes.push({ userId: Number(userId), state, ready: false, playUrl: null, hasAudio: false, hasVideo: false, videoVisible: false }) } }) appendState(session.waitingUserIds, 'waiting') appendState(session.overflowUserIds, 'overflow') appendState(session.capacityUserIds, 'capacity') appendState(Array.from(session.remoteErrors.keys()), 'failed') appendState(Array.from(session.unknownUserIds).filter(userId => session.presentUserIds.has(userId)), 'failed') return { sessionId: session.id, status: session.status, publishing: session.publishing, remotes, error: session.syncError || null, leaseTtlMs: config.leaseTtlMs, pollIntervalMs: config.pollIntervalMs, expiresAt: session.expiresAt, } } /** * 查找 SRS 回调对应的远端流 * * @param {Record} hook SRS 回调体 * @returns {{ session: Record, entry: Record } | undefined} 远端流 */ function findStream(hook) { if (!hook || hook.app !== config.srsApp || typeof hook.stream !== 'string') { return undefined } const ref = streams.get(hook.stream) if (!ref || ref.session.closed || ref.session.remotes.get(ref.entry.userId) !== ref.entry) { return undefined } return ref } /** * SRS on_publish:只允许网关启动的 Egress 携带对应密钥推流 * * @param {Record} hook SRS 回调体 * @returns {boolean} 是否允许 */ function authorizePublish(hook) { const ref = findStream(hook) if (!ref || !matches(parseHookParam(hook.param).get('secret'), ref.entry.publishDigest)) { return false } ref.entry.publishing = true ref.entry.everPublished = true return true } /** * SRS on_unpublish:标记远端流未就绪 * * @param {Record} hook SRS 回调体 */ function handleUnpublish(hook) { const ref = findStream(hook) if (ref && matches(parseHookParam(hook.param).get('secret'), ref.entry.publishDigest)) { ref.entry.publishing = false } } /** * SRS on_play:只允许持有本会话拉流凭证的小程序播放对应远端流 * * @param {Record} hook SRS 回调体 * @returns {boolean} 是否允许 */ function authorizePlay(hook) { const ref = findStream(hook) return !!ref && matches(parseHookParam(hook.param).get('token'), ref.entry.playDigest) } /** * 判断 Egress 是否推往本网关的 SRS 流 *

* LiveKit 会把 RTMP 地址最后一段脱敏为「{首 3 位...末 3 位}」,只有 scheme / host / port / app 可信; * 因此归属以网关专用 app 为准:地址需为「SRS 推流根地址 / 单段流名」,且单段为完整网关流名或其脱敏形式 * * @param {unknown} value EgressInfo 原始 JSON * @returns {boolean} 是否本网关创建 */ function isGatewayEgress(value) { if (typeof value === 'string') { const prefix = `${config.srsPublishBaseUrl}/` if (!GATEWAY_SRS_APP_PATTERN.test(config.srsApp) || !value.startsWith(prefix)) { return false } const segment = value.slice(prefix.length) return STREAM_NAME_PATTERN.test(segment.split('?')[0]) || REDACTED_STREAM_PATTERN.test(segment) } if (value && typeof value === 'object') { return Object.values(value).some(isGatewayEgress) } return false } /** * 单实例启动时回收上次异常退出残留的 Ingress / Egress;任一资源释放失败都抛出,由调用方重试 * * @returns {Promise} */ async function cleanupOrphans() { const ingresses = (await liveKit.listIngress()).filter(item => item.name.startsWith(RESOURCE_PREFIX)) const egresses = (await liveKit.listActiveEgress()).filter(item => isGatewayEgress(item.raw)) const results = await Promise.allSettled([ ...ingresses.map(item => liveKit.deleteIngress(item.ingressId)), ...egresses.map(item => liveKit.stopEgress(item.egressId)), ]) const failed = results.filter(result => result.status === 'rejected').length if (failed > 0) { throw new Error(`残留资源回收失败 ${failed} 项`) } logger.info('残留资源回收完成', { ingress: ingresses.length, egress: egresses.length }) } /** * 退出:拒绝新会话,关闭全部会话,等待进行中的创建返回并确认全部资源释放 *

* 释放持续失败时不会返回,由入口的退出超时以非零状态结束;存在结果未知的创建时抛出错误,同样以非零状态退出,避免误报干净退出 * * @returns {Promise} */ async function shutdown() { shuttingDown = true clearInterval(reaper) Array.from(sessions.values()).forEach(session => void closeSession(session, 'shutdown')) while (inflightCreates.size > 0 || pendingReleases.size > 0) { if (inflightCreates.size > 0) { await Promise.allSettled(Array.from(inflightCreates, item => item.settled)) continue } retryReleases(true) await Promise.allSettled(Array.from(pendingReleases.values(), item => item.attempt)) if (pendingReleases.size > 0) { await delay(SHUTDOWN_RELEASE_RETRY_MS) } } if (unknownCreates.size > 0) { throw new Error(`存在 ${unknownCreates.size} 个结果未知的 Ingress / Egress 创建,需重启网关由启动回收清理`) } } return { createSession, closeSession, authorize, touch, authorizePublish, handleUnpublish, authorizePlay, cleanupOrphans, shutdown, } }