feat: upload latest server source changes

This commit is contained in:
sue
2026-03-03 22:15:19 +08:00
parent a123c7cc40
commit c65062e8f7
13 changed files with 5414 additions and 1768 deletions

View File

@@ -1336,132 +1336,62 @@ export default class APKBuildService {
/**
* 签名APK文件
*/
private async signAPK(apkPath: string, filename: string): Promise<string | null> {
/**
* 签名并对齐 APK 文件 (修复版)
*/
private async signAPK(apkPath: string, filename: string): Promise<string | null> {
try {
this.addBuildLog('info', `准备签名APK: ${filename}`)
// 确保keystore文件存在
this.addBuildLog('info', `准备对齐并签名 APK: ${filename}`)
const outputDir = path.dirname(apkPath);
const alignedApkPath = path.join(outputDir, `aligned_${filename}`);
const signedApkPath = path.join(outputDir, `signed_${filename}`);
const keystorePath = path.join(process.cwd(), 'android', 'app.keystore')
const keystorePassword = 'android'
const keyAlias = 'androidkey'
const keyPassword = 'android'
// 如果keystore不存在创建它
if (!fs.existsSync(keystorePath)) {
this.addBuildLog('info', 'keystore文件不存在正在创建...')
await this.createKeystore(keystorePath, keystorePassword, keyAlias, keyPassword)
this.addBuildLog('success', 'keystore文件创建成功')
// --- 步骤 1: Zipalign 对齐 ---
this.addBuildLog('info', '正在进行 zipalign 对齐...')
const alignCmd = `zipalign -v -f 4 "${apkPath}" "${alignedApkPath}"`
await execAsync(alignCmd);
this.addBuildLog('success', 'zipalign 对齐完成')
// --- 步骤 2: Apksigner V2/V3 签名 ---
this.addBuildLog('info', '正在强制开启 V2/V3 方案进行签名...')
// 显式指定 --v2-signing-enabled true
const signCmd = `apksigner sign --v1-signing-enabled true --v2-signing-enabled true --ks "${keystorePath}" --ks-pass pass:"${keystorePassword}" --ks-key-alias "${keyAlias}" --out "${signedApkPath}" "${alignedApkPath}"`;
await execAsync(signCmd);
this.addBuildLog('success', `APK 签名命令执行完成`)
// --- 步骤 3: 严格验证 ---
const verifyCmd = `apksigner verify -v "${signedApkPath}"`;
const { stdout: verifyOut, stderr: verifyErr } = await execAsync(verifyCmd);
console.log("--- 签名验证详细报告 ---");
console.log(verifyOut);
console.log(verifyErr);
console.log("-----------------------");
// 打印完整验证日志到后端控制台,方便你查看具体的报错
this.logger.info('apksigner verify 输出内容:\n' + verifyOut);
// 检查 V2 验证状态 (兼容大小写和空格)
const isV2 = /Verified using v2 scheme \(true\)/i.test(verifyOut);
const isV3 = /Verified using v3 scheme \(true\)/i.test(verifyOut);
if (isV2 || isV3) {
this.addBuildLog('success', `✅ 签名验证通过: ${isV2 ? '[V2]' : ''} ${isV3 ? '[V3]' : ''}`)
} else {
this.addBuildLog('info', '使用现有的keystore文件')
this.addBuildLog('warn', `⚠️ 签名成功但未检测到 V2/V3 方案,新款手机可能无法安装`)
// 建议在这里把 verifyOut 的前两行输出到 buildLog 方便前端看
this.addBuildLog('info', `验证详情: ${verifyOut.substring(0, 100)}...`)
}
// 使用jarsigner签名APK
this.addBuildLog('info', '使用jarsigner签名APK...')
const isWindows = platform() === 'win32'
const normalizedKeystorePath = path.normalize(keystorePath)
const normalizedApkPath = path.normalize(apkPath)
let signCommand: string
if (isWindows) {
// Windows: 使用引号包裹路径
signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}`
} else {
// Linux: 直接使用路径
signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}`
}
this.addBuildLog('info', `[DEBUG] 签名命令: jarsigner ... ${keyAlias}`)
this.addBuildLog('info', `[DEBUG] keystore路径: ${normalizedKeystorePath}`)
this.addBuildLog('info', `[DEBUG] APK路径: ${normalizedApkPath}`)
// 执行签名命令
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
let processStdout = ''
let processStderr = ''
const signProcess = spawn(signCommand, [], {
cwd: process.cwd(),
shell: true
})
this.addBuildLog('info', `[DEBUG] 签名进程已启动PID: ${signProcess.pid}`)
if (signProcess.stdout) {
signProcess.stdout.on('data', (data: Buffer) => {
const text = data.toString('utf8')
processStdout += text
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
lines.forEach((line: string) => {
const trimmedLine = line.trim()
if (trimmedLine) {
this.addBuildLog('info', `jarsigner: ${trimmedLine}`)
}
})
})
}
if (signProcess.stderr) {
signProcess.stderr.on('data', (data: Buffer) => {
const text = data.toString('utf8')
processStderr += text
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
lines.forEach((line: string) => {
const trimmedLine = line.trim()
if (trimmedLine) {
// jarsigner的输出通常到stderr但这是正常的
if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) {
this.addBuildLog('error', `jarsigner错误: ${trimmedLine}`)
} else {
this.addBuildLog('info', `jarsigner: ${trimmedLine}`)
}
}
})
})
}
signProcess.on('close', (code: number | null) => {
const exitCode = code || 0
if (exitCode === 0) {
this.addBuildLog('info', `[DEBUG] jarsigner执行完成退出码: ${exitCode}`)
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
} else {
this.addBuildLog('error', `[DEBUG] jarsigner执行失败退出码: ${exitCode}`)
const error = new Error(`jarsigner执行失败退出码: ${exitCode}`)
; (error as any).stdout = processStdout
; (error as any).stderr = processStderr
; (error as any).exitCode = exitCode
reject(error)
}
})
signProcess.on('error', (error: Error) => {
this.addBuildLog('error', `jarsigner进程错误: ${error.message}`)
reject(error)
})
})
this.addBuildLog('success', `APK签名成功: ${filename}`)
// 验证签名
this.addBuildLog('info', '验证APK签名...')
await this.verifyAPKSignature(apkPath)
return apkPath
return signedApkPath;
} catch (error: any) {
this.addBuildLog('error', `APK签名失败: ${error.message}`)
if (error.stdout) {
const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim())
stdoutLines.forEach((line: string) => {
this.addBuildLog('error', `jarsigner输出: ${line.trim()}`)
})
}
if (error.stderr) {
const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim())
stderrLines.forEach((line: string) => {
this.addBuildLog('error', `jarsigner错误: ${line.trim()}`)
})
}
this.addBuildLog('error', `APK 对齐或签名失败: ${error.message}`)
return null
}
}

View File

@@ -34,10 +34,10 @@ interface DeviceQualityState {
}
const QUALITY_PROFILES: Record<string, QualityProfile> = {
low: { fps: 5, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' },
medium: { fps: 10, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' },
high: { fps: 15, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' },
ultra: { fps: 20, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' },
low: { fps: 10, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' },
medium: { fps: 15, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' },
high: { fps: 30, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' },
ultra: { fps: 60, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' },
}
export class AdaptiveQualityService {
@@ -143,7 +143,7 @@ export class AdaptiveQualityService {
maxHeight?: number
}): { params: Partial<QualityProfile> } {
const state = this.getOrCreateState(deviceId)
if (params.fps !== undefined) state.fps = Math.max(1, Math.min(30, params.fps))
if (params.fps !== undefined) state.fps = Math.max(1, Math.min(60, params.fps))
if (params.quality !== undefined) state.quality = Math.max(20, Math.min(90, params.quality))
if (params.maxWidth !== undefined) state.maxWidth = Math.max(240, Math.min(1920, params.maxWidth))
if (params.maxHeight !== undefined) state.maxHeight = Math.max(320, Math.min(2560, params.maxHeight))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
import Logger from '../utils/Logger'
/**
* 性能指标接口
*/
export interface PerformanceMetrics {
timestamp: number
memoryUsage: MemoryMetrics
@@ -11,20 +8,14 @@ export interface PerformanceMetrics {
systemMetrics: SystemMetrics
}
/**
* 内存指标
*/
export interface MemoryMetrics {
heapUsed: number // MB
heapTotal: number // MB
external: number // MB
rss: number // MB
heapUsed: number
heapTotal: number
external: number
rss: number
heapUsedPercent: number
}
/**
* 连接指标
*/
export interface ConnectionMetrics {
totalConnections: number
activeConnections: number
@@ -33,100 +24,81 @@ export interface ConnectionMetrics {
disconnectionsPerMinute: number
}
/**
* 消息指标
*/
export interface MessageMetrics {
messagesPerSecond: number
averageLatency: number // ms
p95Latency: number // ms
p99Latency: number // ms
errorRate: number // %
averageLatency: number
p95Latency: number
p99Latency: number
errorRate: number
}
/**
* 系统指标
*/
export interface SystemMetrics {
uptime: number // seconds
cpuUsage: number // %
eventLoopLag: number // ms
uptime: number
cpuUsage: number
eventLoopLag: number
}
/**
* 性能监控服务
*/
export class PerformanceMonitorService {
private logger = new Logger('PerformanceMonitor')
// 指标收集
private metrics: PerformanceMetrics[] = []
private readonly MAX_METRICS_HISTORY = 60 // 保留最近60条记录
// 消息延迟追踪
private readonly MAX_METRICS_HISTORY = 120
private messageLatencies: number[] = []
private readonly MAX_LATENCY_SAMPLES = 1000
// 连接统计
private readonly MAX_LATENCY_SAMPLES = 2000
private connectionsPerMinute = 0
private disconnectionsPerMinute = 0
private lastConnectionCount = 0
// 消息统计
private connectionSnapshot = {
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
}
private messagesThisSecond = 0
private messagesLastSecond = 0
private errorsThisSecond = 0
private errorsLastSecond = 0
// 事件循环监控
private lastEventLoopCheck = Date.now()
private eventLoopLag = 0
private monitoringIntervals: NodeJS.Timeout[] = []
constructor() {
this.startMonitoring()
}
/**
* 记录消息延迟
*/
setConnectionSnapshot(snapshot: Partial<ConnectionMetrics>): void {
this.connectionSnapshot = {
totalConnections: snapshot.totalConnections ?? this.connectionSnapshot.totalConnections,
activeConnections: snapshot.activeConnections ?? this.connectionSnapshot.activeConnections,
idleConnections: snapshot.idleConnections ?? this.connectionSnapshot.idleConnections,
}
}
recordMessageLatency(latency: number): void {
if (!Number.isFinite(latency) || latency < 0) return
this.messageLatencies.push(latency)
if (this.messageLatencies.length > this.MAX_LATENCY_SAMPLES) {
this.messageLatencies.shift()
}
}
/**
* 记录消息
*/
recordMessage(): void {
this.messagesThisSecond++
}
/**
* 记录错误
*/
recordError(): void {
this.errorsThisSecond++
}
/**
* 记录连接
*/
recordConnection(): void {
this.connectionsPerMinute++
}
/**
* 记录断开连接
*/
recordDisconnection(): void {
this.disconnectionsPerMinute++
}
/**
* 获取当前性能指标
*/
getCurrentMetrics(): PerformanceMetrics {
const memUsage = process.memoryUsage()
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024)
@@ -134,21 +106,21 @@ export class PerformanceMonitorService {
const externalMB = Math.round(memUsage.external / 1024 / 1024)
const rssMB = Math.round(memUsage.rss / 1024 / 1024)
const metrics: PerformanceMetrics = {
return {
timestamp: Date.now(),
memoryUsage: {
heapUsed: heapUsedMB,
heapTotal: heapTotalMB,
external: externalMB,
rss: rssMB,
heapUsedPercent: Math.round((heapUsedMB / heapTotalMB) * 100)
heapUsedPercent: heapTotalMB > 0 ? Math.round((heapUsedMB / heapTotalMB) * 100) : 0,
},
connectionMetrics: {
totalConnections: 0, // 由调用者设置
activeConnections: 0,
idleConnections: 0,
totalConnections: this.connectionSnapshot.totalConnections,
activeConnections: this.connectionSnapshot.activeConnections,
idleConnections: this.connectionSnapshot.idleConnections,
newConnectionsPerMinute: this.connectionsPerMinute,
disconnectionsPerMinute: this.disconnectionsPerMinute
disconnectionsPerMinute: this.disconnectionsPerMinute,
},
messageMetrics: {
messagesPerSecond: this.messagesLastSecond,
@@ -156,185 +128,140 @@ export class PerformanceMonitorService {
p95Latency: this.calculatePercentileLatency(95),
p99Latency: this.calculatePercentileLatency(99),
errorRate: this.messagesLastSecond > 0
? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 100 * 100) / 100
: 0
? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 10000) / 100
: 0,
},
systemMetrics: {
uptime: Math.round(process.uptime()),
cpuUsage: this.calculateCpuUsage(),
eventLoopLag: this.eventLoopLag
}
eventLoopLag: this.eventLoopLag,
},
}
return metrics
}
/**
* 计算平均延迟
*/
private calculateAverageLatency(): number {
if (this.messageLatencies.length === 0) return 0
const sum = this.messageLatencies.reduce((a, b) => a + b, 0)
return Math.round(sum / this.messageLatencies.length * 100) / 100
return Math.round((sum / this.messageLatencies.length) * 100) / 100
}
/**
* 计算百分位延迟
*/
private calculatePercentileLatency(percentile: number): number {
if (this.messageLatencies.length === 0) return 0
const sorted = [...this.messageLatencies].sort((a, b) => a - b)
const index = Math.ceil((percentile / 100) * sorted.length) - 1
return sorted[Math.max(0, index)]
return Math.round((sorted[Math.max(0, index)] ?? 0) * 100) / 100
}
/**
* 计算CPU使用率 (简化版)
*/
private calculateCpuUsage(): number {
// 这是一个简化的实现,实际应该使用 os.cpus() 或专门的库
const usage = process.cpuUsage()
return Math.round((usage.user + usage.system) / 1000000 * 100) / 100
return Math.round(((usage.user + usage.system) / 1000000) * 100) / 100
}
/**
* 启动监控任务
*/
private startMonitoring(): void {
// 每秒更新消息统计
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
this.messagesLastSecond = this.messagesThisSecond
this.errorsLastSecond = this.errorsThisSecond
this.messagesThisSecond = 0
this.errorsThisSecond = 0
}, 1000)
}, 1000))
// 每分钟重置连接统计
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
this.connectionsPerMinute = 0
this.disconnectionsPerMinute = 0
}, 60000)
}, 60000))
// 每10秒收集一次完整指标
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
const metrics = this.getCurrentMetrics()
this.metrics.push(metrics)
if (this.metrics.length > this.MAX_METRICS_HISTORY) {
this.metrics.shift()
}
this.logMetrics(metrics)
}, 10000)
}, 10000))
// 监控事件循环延迟
this.monitorEventLoopLag()
}
/**
* 监控事件循环延迟
*/
private monitorEventLoopLag(): void {
let lastCheck = Date.now()
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
const now = Date.now()
const expectedDelay = 1000 // 1秒
const expectedDelay = 1000
const actualDelay = now - lastCheck
this.eventLoopLag = Math.max(0, actualDelay - expectedDelay)
lastCheck = now
}, 1000)
}, 1000))
}
/**
* 输出指标日志
*/
private logMetrics(metrics: PerformanceMetrics): void {
const mem = metrics.memoryUsage
const msg = metrics.messageMetrics
const conn = metrics.connectionMetrics
const sys = metrics.systemMetrics
this.logger.info(`
📊 性能指标 (${new Date(metrics.timestamp).toLocaleTimeString()}):
💾 内存: ${mem.heapUsed}MB / ${mem.heapTotal}MB (${mem.heapUsedPercent}%) | RSS: ${mem.rss}MB
📨 消息: ${msg.messagesPerSecond}/s | 延迟: ${msg.averageLatency}ms (p95: ${msg.p95Latency}ms, p99: ${msg.p99Latency}ms) | 错误率: ${msg.errorRate}%
🔌 连接: ${conn.totalConnections}个 (活跃: ${conn.activeConnections}, 空闲: ${conn.idleConnections}) | 新增: ${conn.newConnectionsPerMinute}/min
⚙️ 系统: 运行时间 ${sys.uptime}s | CPU: ${sys.cpuUsage}% | 事件循环延迟: ${sys.eventLoopLag}ms
`)
this.logger.info(
`\u6027\u80fd ${new Date(metrics.timestamp).toLocaleTimeString()} | ` +
`\u5185\u5b58=${mem.heapUsed}/${mem.heapTotal}MB rss=${mem.rss}MB | ` +
`\u6d88\u606f=${msg.messagesPerSecond}/s p95=${msg.p95Latency}ms p99=${msg.p99Latency}ms \u9519\u8bef\u7387=${msg.errorRate}% | ` +
`\u8fde\u63a5=${conn.totalConnections} \u6d3b\u8dc3=${conn.activeConnections} \u7a7a\u95f2=${conn.idleConnections} ` +
`\u65b0\u5efa=${conn.newConnectionsPerMinute}/m \u65ad\u5f00=${conn.disconnectionsPerMinute}/m | ` +
`\u8fd0\u884c=${sys.uptime}s \u4e8b\u4ef6\u5faa\u73af\u5ef6\u8fdf=${sys.eventLoopLag}ms CPU=${sys.cpuUsage}%`
)
}
/**
* 获取历史指标
*/
getMetricsHistory(limit: number = 10): PerformanceMetrics[] {
return this.metrics.slice(-limit)
const normalizedLimit = Math.max(1, Math.min(500, limit))
return this.metrics.slice(-normalizedLimit)
}
/**
* 获取性能警告
*/
getPerformanceWarnings(): string[] {
const warnings: string[] = []
const latest = this.metrics[this.metrics.length - 1]
if (!latest) return warnings
// 内存警告
if (latest.memoryUsage.heapUsedPercent > 80) {
warnings.push(`⚠️ 内存使用过高: ${latest.memoryUsage.heapUsedPercent}%`)
if (latest.memoryUsage.heapUsedPercent > 85) {
warnings.push(`\u5185\u5b58\u4f7f\u7528\u7387\u8fc7\u9ad8: ${latest.memoryUsage.heapUsedPercent}%`)
}
// 延迟警告
if (latest.messageMetrics.p99Latency > 500) {
warnings.push(`⚠️ 消息延迟过高: P99=${latest.messageMetrics.p99Latency}ms`)
warnings.push(`\u6d88\u606f\u5ef6\u8fdf\u8fc7\u9ad8: p99=${latest.messageMetrics.p99Latency}ms`)
}
// 错误率警告
if (latest.messageMetrics.errorRate > 5) {
warnings.push(`⚠️ 错误率过高: ${latest.messageMetrics.errorRate}%`)
warnings.push(`\u9519\u8bef\u7387\u8fc7\u9ad8: ${latest.messageMetrics.errorRate}%`)
}
// 事件循环延迟警告
if (latest.systemMetrics.eventLoopLag > 100) {
warnings.push(`⚠️ 事件循环延迟过高: ${latest.systemMetrics.eventLoopLag}ms`)
warnings.push(`\u4e8b\u4ef6\u5faa\u73af\u5ef6\u8fdf\u8fc7\u9ad8: ${latest.systemMetrics.eventLoopLag}ms`)
}
return warnings
}
/**
* 获取性能报告
*/
getPerformanceReport(): string {
const warnings = this.getPerformanceWarnings()
const latest = this.metrics[this.metrics.length - 1]
if (!latest) return '\u6682\u65e0\u6027\u80fd\u6570\u636e'
if (!latest) return '暂无数据'
let report = '📈 性能报告\n'
report += '='.repeat(50) + '\n'
report += `时间: ${new Date(latest.timestamp).toLocaleString()}\n`
report += `内存: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n`
report += `消息吞吐: ${latest.messageMetrics.messagesPerSecond}/s\n`
report += `平均延迟: ${latest.messageMetrics.averageLatency}ms\n`
report += `连接数: ${latest.connectionMetrics.totalConnections}\n`
report += `运行时间: ${latest.systemMetrics.uptime}s\n`
let report = '\u6027\u80fd\u62a5\u544a\n'
report += '='.repeat(48) + '\n'
report += `\u65f6\u95f4: ${new Date(latest.timestamp).toLocaleString()}\n`
report += `\u5185\u5b58: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n`
report += `\u6d88\u606f\u541e\u5410: ${latest.messageMetrics.messagesPerSecond}/s\n`
report += `\u5e73\u5747\u5ef6\u8fdf: ${latest.messageMetrics.averageLatency}ms\n`
report += `\u8fde\u63a5\u6570: ${latest.connectionMetrics.totalConnections}\n`
report += `\u8fd0\u884c\u65f6\u957f: ${latest.systemMetrics.uptime}s\n`
if (warnings.length > 0) {
report += '\n⚠️ 警告:\n'
warnings.forEach(w => report += ` ${w}\n`)
report += '\n\u544a\u8b66:\n'
warnings.forEach((warning) => {
report += ` - ${warning}\n`
})
} else {
report += '\n✅ 系统运行正常\n'
report += '\n\u72b6\u6001: \u6b63\u5e38\n'
}
return report
}
/**
* 清理资源
*/
destroy(): void {
this.monitoringIntervals.forEach((timer) => clearInterval(timer))
this.monitoringIntervals = []
this.metrics = []
this.messageLatencies = []
}

View File

@@ -0,0 +1,595 @@
import { ChildProcessWithoutNullStreams, spawn, spawnSync } from 'child_process'
import { randomUUID } from 'crypto'
import type { IncomingMessage, Server as HttpServer } from 'http'
import type { Socket } from 'net'
import { URL } from 'url'
import { WebSocketServer, WebSocket } from 'ws'
import Logger from '../utils/Logger'
type VideoTransportMode = 'srt_mpegts'
interface SrtGatewayConfig {
enabled: boolean
ffmpegPath: string
portBase: number
portSpan: number
latencyMs: number
publicHost: string
playbackPath: string
playbackWsOrigin: string
idleTimeoutMs: number
ticketTtlMs: number
}
interface PlaybackTicket {
token: string
deviceId: string
clientId: string
expiresAt: number
}
interface ViewerMeta {
deviceId: string
clientId: string
}
interface SrtSession {
deviceId: string
ingestPort: number
ffmpegProcess: ChildProcessWithoutNullStreams | null
viewers: Set<WebSocket>
lastActivityAt: number
createdAt: number
restartCount: number
}
export interface SrtSessionInfo {
deviceId: string
ingestHost: string
ingestPort: number
ingestUrl: string
playbackUrl: string
playbackPath: string
latencyMs: number
videoTransport: VideoTransportMode
tokenExpiresAt: number
}
export class SrtGatewayService {
private readonly logger: Logger
private readonly httpServer: HttpServer
private readonly wsServer: WebSocketServer
private readonly config: SrtGatewayConfig
private readonly sessions = new Map<string, SrtSession>()
private readonly usedPorts = new Set<number>()
private readonly tickets = new Map<string, PlaybackTicket>()
private readonly viewerMeta = new Map<WebSocket, ViewerMeta>()
private readonly cleanupTimer: NodeJS.Timeout
private ffmpegProbeCompleted = false
private ffmpegAvailable = false
constructor(httpServer: HttpServer) {
this.httpServer = httpServer
this.logger = new Logger('SrtGateway')
this.config = this.loadConfig()
this.wsServer = new WebSocketServer({ noServer: true })
this.cleanupTimer = setInterval(() => this.cleanupExpiredResources(), 10_000)
this.httpServer.on('upgrade', (request: IncomingMessage, socket: Socket, head: Buffer) => {
this.handleHttpUpgrade(request, socket, head)
})
this.logger.info(
`Initialized: enabled=${this.config.enabled}, playbackPath=${this.config.playbackPath}, portBase=${this.config.portBase}, portSpan=${this.config.portSpan}`
)
}
public isEnabled(): boolean {
return this.config.enabled
}
public getPlaybackPath(): string {
return this.config.playbackPath
}
public prepareStream(deviceId: string, clientId: string, options?: { ingestHostHint?: string, playbackWsOriginHint?: string }): SrtSessionInfo | null {
if (!this.config.enabled) {
return null
}
if (!this.ensureFfmpegAvailable()) {
this.logger.error('ffmpeg not available; cannot prepare SRT session')
return null
}
const session = this.ensureSession(deviceId)
if (!session) {
return null
}
session.lastActivityAt = Date.now()
const ticket = this.issueTicket(deviceId, clientId)
const ingestHost = this.resolveIngestHost(options?.ingestHostHint)
const playbackOrigin = this.resolvePlaybackWsOrigin(options?.playbackWsOriginHint)
return {
deviceId,
ingestHost,
ingestPort: session.ingestPort,
ingestUrl: this.buildIngestUrl(ingestHost, session.ingestPort),
playbackUrl: `${playbackOrigin}${this.config.playbackPath}?token=${encodeURIComponent(ticket.token)}`,
playbackPath: this.config.playbackPath,
latencyMs: this.config.latencyMs,
videoTransport: 'srt_mpegts',
tokenExpiresAt: ticket.expiresAt
}
}
public stopClientStream(deviceId: string, clientId: string): void {
for (const [token, ticket] of this.tickets.entries()) {
if (ticket.deviceId === deviceId && ticket.clientId === clientId) {
this.tickets.delete(token)
}
}
const session = this.sessions.get(deviceId)
if (!session) {
return
}
for (const ws of Array.from(session.viewers)) {
const meta = this.viewerMeta.get(ws)
if (!meta) continue
if (meta.deviceId === deviceId && meta.clientId === clientId) {
try {
ws.close(1000, 'stream_stopped')
} catch {
ws.terminate()
}
}
}
session.lastActivityAt = Date.now()
}
public stopSession(deviceId: string): void {
const session = this.sessions.get(deviceId)
if (!session) {
return
}
for (const [token, ticket] of this.tickets.entries()) {
if (ticket.deviceId === deviceId) {
this.tickets.delete(token)
}
}
for (const ws of session.viewers) {
try {
ws.close(1001, 'session_closed')
} catch {
ws.terminate()
}
this.viewerMeta.delete(ws)
}
session.viewers.clear()
if (session.ffmpegProcess) {
session.ffmpegProcess.removeAllListeners()
try {
session.ffmpegProcess.kill('SIGTERM')
} catch {
session.ffmpegProcess.kill()
}
session.ffmpegProcess = null
}
this.usedPorts.delete(session.ingestPort)
this.sessions.delete(deviceId)
this.logger.info(`Session stopped: device=${deviceId}`)
}
public shutdown(): void {
clearInterval(this.cleanupTimer)
for (const deviceId of Array.from(this.sessions.keys())) {
this.stopSession(deviceId)
}
try {
this.wsServer.close()
} catch {
// ignore
}
}
private loadConfig(): SrtGatewayConfig {
// Stable default: disabled unless explicitly enabled.
const enabled = process.env.SRT_GATEWAY_ENABLED === 'true'
const ffmpegPath = process.env.SRT_FFMPEG_PATH?.trim() || 'ffmpeg'
const portBase = this.parseNumberEnv(process.env.SRT_PORT_BASE, 12000, 1024, 65500)
const portSpan = this.parseNumberEnv(process.env.SRT_PORT_SPAN, 1000, 10, 5000)
const latencyMs = this.parseNumberEnv(process.env.SRT_LATENCY_MS, 80, 20, 800)
const publicHost = process.env.SRT_PUBLIC_HOST?.trim() || ''
const playbackPathRaw = process.env.SRT_PLAYBACK_PATH?.trim() || '/ws/srt-play'
const playbackPath = playbackPathRaw.startsWith('/') ? playbackPathRaw : `/${playbackPathRaw}`
const playbackWsOrigin = process.env.SRT_PLAYBACK_WS_ORIGIN?.trim() || ''
const idleTimeoutMs = this.parseNumberEnv(process.env.SRT_IDLE_TIMEOUT_MS, 45_000, 10_000, 10 * 60_000)
const ticketTtlMs = this.parseNumberEnv(process.env.SRT_TICKET_TTL_MS, 60_000, 5_000, 5 * 60_000)
return {
enabled,
ffmpegPath,
portBase,
portSpan,
latencyMs,
publicHost,
playbackPath,
playbackWsOrigin,
idleTimeoutMs,
ticketTtlMs
}
}
private parseNumberEnv(rawValue: string | undefined, defaultValue: number, min: number, max: number): number {
const parsed = Number.parseInt(rawValue || '', 10)
if (!Number.isFinite(parsed)) {
return defaultValue
}
return Math.max(min, Math.min(max, parsed))
}
private ensureFfmpegAvailable(): boolean {
if (this.ffmpegProbeCompleted) {
return this.ffmpegAvailable
}
this.ffmpegProbeCompleted = true
try {
const probe = spawnSync(this.config.ffmpegPath, ['-version'], {
windowsHide: true,
timeout: 3000,
stdio: 'ignore'
})
this.ffmpegAvailable = probe.status === 0
} catch (error) {
this.ffmpegAvailable = false
this.logger.error('ffmpeg probe failed', error)
}
if (!this.ffmpegAvailable) {
this.logger.error(`ffmpeg unavailable: path=${this.config.ffmpegPath}`)
}
return this.ffmpegAvailable
}
private ensureSession(deviceId: string): SrtSession | null {
const existing = this.sessions.get(deviceId)
if (existing) {
if (!existing.ffmpegProcess) {
this.startFfmpeg(existing)
}
return existing
}
const ingestPort = this.allocatePort(deviceId)
if (!ingestPort) {
this.logger.error(`No free SRT ingest port for device=${deviceId}`)
return null
}
const session: SrtSession = {
deviceId,
ingestPort,
ffmpegProcess: null,
viewers: new Set<WebSocket>(),
lastActivityAt: Date.now(),
createdAt: Date.now(),
restartCount: 0
}
this.sessions.set(deviceId, session)
this.usedPorts.add(ingestPort)
this.startFfmpeg(session)
return session
}
private allocatePort(deviceId: string): number | null {
const span = this.config.portSpan
const base = this.config.portBase
const seed = this.stringHash(deviceId)
for (let i = 0; i < span; i += 1) {
const candidate = base + ((seed + i) % span)
if (!this.usedPorts.has(candidate)) {
return candidate
}
}
return null
}
private stringHash(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
private startFfmpeg(session: SrtSession): void {
if (session.ffmpegProcess || !this.config.enabled) {
return
}
const inputUrl = `srt://0.0.0.0:${session.ingestPort}?mode=listener&latency=${this.config.latencyMs}&transtype=live`
const args = [
'-loglevel', 'warning',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-analyzeduration', '0',
'-probesize', '32768',
'-i', inputUrl,
'-an',
'-c:v', 'copy',
'-f', 'mpegts',
'-mpegts_flags', 'resend_headers',
'pipe:1'
]
this.logger.info(`Start ffmpeg session: device=${session.deviceId}, input=${inputUrl}`)
const child = spawn(this.config.ffmpegPath, args, {
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
})
child.stdin.end()
session.ffmpegProcess = child
child.stdout.on('data', (chunk: Buffer) => {
session.lastActivityAt = Date.now()
session.restartCount = 0
this.broadcastChunk(session, chunk)
})
child.stderr.on('data', (data: Buffer) => {
const line = data.toString().trim()
if (line) {
this.logger.debug(`ffmpeg[${session.deviceId}] ${line}`)
}
})
child.on('error', (error) => {
this.logger.error(`ffmpeg process error: device=${session.deviceId}`, error)
})
child.on('close', (code, signal) => {
const current = this.sessions.get(session.deviceId)
if (!current || current !== session) {
return
}
current.ffmpegProcess = null
this.logger.warn(`ffmpeg exited: device=${session.deviceId}, code=${code}, signal=${signal}`)
if (!this.config.enabled) {
return
}
current.restartCount += 1
if (current.restartCount > 5) {
this.logger.error(`ffmpeg restart limit exceeded, closing session: device=${session.deviceId}`)
this.stopSession(session.deviceId)
return
}
const delayMs = Math.min(1000 * current.restartCount, 5000)
setTimeout(() => {
const aliveSession = this.sessions.get(session.deviceId)
if (aliveSession && !aliveSession.ffmpegProcess) {
this.startFfmpeg(aliveSession)
}
}, delayMs)
})
}
private issueTicket(deviceId: string, clientId: string): PlaybackTicket {
const token = randomUUID()
const expiresAt = Date.now() + this.config.ticketTtlMs
const ticket: PlaybackTicket = { token, deviceId, clientId, expiresAt }
this.tickets.set(token, ticket)
return ticket
}
private consumeTicket(token: string): PlaybackTicket | null {
const ticket = this.tickets.get(token)
if (!ticket) {
return null
}
if (ticket.expiresAt < Date.now()) {
this.tickets.delete(token)
return null
}
this.tickets.delete(token)
return ticket
}
private resolveIngestHost(hostHint?: string): string {
if (this.config.publicHost) {
return this.stripPort(this.config.publicHost)
}
if (hostHint) {
return this.stripPort(hostHint)
}
return '127.0.0.1'
}
private resolvePlaybackWsOrigin(originHint?: string): string {
const source = this.config.playbackWsOrigin || originHint || ''
if (!source) {
return 'ws://127.0.0.1:3001'
}
const trimmed = source.trim().replace(/\/+$/, '')
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
return trimmed
}
if (trimmed.startsWith('http://')) {
return `ws://${trimmed.slice('http://'.length)}`
}
if (trimmed.startsWith('https://')) {
return `wss://${trimmed.slice('https://'.length)}`
}
return `ws://${trimmed}`
}
private stripPort(hostLike: string): string {
const value = hostLike.trim()
if (!value) {
return '127.0.0.1'
}
if (value.startsWith('[')) {
const closeIndex = value.indexOf(']')
if (closeIndex > 0) {
return value.slice(1, closeIndex)
}
}
const firstColon = value.indexOf(':')
const lastColon = value.lastIndexOf(':')
if (firstColon > 0 && firstColon === lastColon) {
return value.slice(0, firstColon)
}
return value
}
private buildIngestUrl(host: string, port: number): string {
return `srt://${host}:${port}?mode=caller&latency=${this.config.latencyMs}&transtype=live`
}
private broadcastChunk(session: SrtSession, chunk: Buffer): void {
if (session.viewers.size === 0) {
return
}
for (const ws of Array.from(session.viewers)) {
if (ws.readyState !== WebSocket.OPEN) {
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
continue
}
try {
ws.send(chunk, { binary: true })
} catch (error) {
this.logger.warn(`Viewer send failed: device=${session.deviceId}`, error)
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
try {
ws.terminate()
} catch {
// ignore
}
}
}
}
private handleHttpUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
if (!this.config.enabled) {
return
}
const parsed = this.safeParseUrl(request)
if (!parsed || parsed.pathname !== this.config.playbackPath) {
return
}
const token = parsed.searchParams.get('token') || ''
const ticket = this.consumeTicket(token)
if (!ticket) {
this.rejectUpgrade(socket, 401, 'invalid_or_expired_token')
return
}
const session = this.sessions.get(ticket.deviceId)
if (!session) {
this.rejectUpgrade(socket, 404, 'stream_not_found')
return
}
this.wsServer.handleUpgrade(request, socket, head, (ws: WebSocket) => {
this.registerViewer(ws, ticket, session)
})
}
private registerViewer(ws: WebSocket, ticket: PlaybackTicket, session: SrtSession): void {
session.viewers.add(ws)
session.lastActivityAt = Date.now()
this.viewerMeta.set(ws, {
deviceId: ticket.deviceId,
clientId: ticket.clientId
})
ws.on('message', () => {
// Ignore upstream data from viewers; playback socket is read-only.
})
ws.on('close', () => {
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
session.lastActivityAt = Date.now()
})
ws.on('error', () => {
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
session.lastActivityAt = Date.now()
try {
ws.terminate()
} catch {
// ignore
}
})
}
private safeParseUrl(request: IncomingMessage): URL | null {
const requestUrl = request.url || ''
const host = request.headers.host || 'localhost'
try {
return new URL(requestUrl, `http://${host}`)
} catch {
return null
}
}
private rejectUpgrade(socket: Socket, statusCode: number, reason: string): void {
try {
socket.write(`HTTP/1.1 ${statusCode} ${reason}\r\nConnection: close\r\n\r\n`)
} finally {
socket.destroy()
}
}
private cleanupExpiredResources(): void {
const now = Date.now()
for (const [token, ticket] of this.tickets.entries()) {
if (ticket.expiresAt < now) {
this.tickets.delete(token)
}
}
for (const [deviceId, session] of this.sessions.entries()) {
const noViewers = session.viewers.size === 0
const idleTooLong = now - session.lastActivityAt > this.config.idleTimeoutMs
if (noViewers && idleTooLong) {
this.logger.info(`Session idle timeout: device=${deviceId}`)
this.stopSession(deviceId)
}
}
}
}
export default SrtGatewayService