livekit-api.mjs
9.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
// LiveKit Server API(Twirp JSON)最小客户端:只调用网关需要的 Room / Ingress / Egress 接口
import { signAdminToken } from './jwt.mjs'
const PARTICIPANT_KINDS = ['STANDARD', 'INGRESS', 'EGRESS', 'SIP', 'AGENT'] // ParticipantInfo.Kind 枚举
const PARTICIPANT_STATES = ['JOINING', 'JOINED', 'ACTIVE', 'DISCONNECTED'] // ParticipantInfo.State 枚举
const TRACK_TYPES = ['AUDIO', 'VIDEO', 'DATA'] // TrackType 枚举
const TRACK_SOURCES = ['UNKNOWN', 'CAMERA', 'MICROPHONE', 'SCREEN_SHARE', 'SCREEN_SHARE_AUDIO'] // TrackSource 枚举
const TWIRP_CODE_PATTERN = /^[a-z_]{1,32}$/ // Twirp 错误码格式,如 not_found;不符合时按 unknown 处理
/** LiveKit API 调用失败;message 只含方法、HTTP 状态与错误码,不含上游原文、Token 或推流地址 */
export class LiveKitApiError extends Error {
/**
* @param {number} status HTTP 状态码
* @param {string} code Twirp 错误码
* @param {string} message 错误说明
*/
constructor(status, code, message) {
super(message)
this.status = status
this.code = code
}
}
/**
* 读取 protobuf JSON 字段;兼容 snake_case 与 lowerCamelCase 输出
*
* @param {Record<string, any>} value 对象
* @param {string} snakeName snake_case 字段名
* @returns {any} 字段值
*/
function field(value, snakeName) {
if (value[snakeName] !== undefined) {
return value[snakeName]
}
return value[snakeName.replace(/_([a-z])/g, (_, char) => char.toUpperCase())]
}
/**
* 读取枚举名;兼容名称与数字输出,缺省值按 protobuf 语义取第 0 项
*
* @param {string | number | undefined} value 枚举值
* @param {string[]} names 枚举名称表
* @returns {string} 枚举名
*/
function enumName(value, names) {
if (typeof value === 'number') {
return names[value] ?? String(value)
}
return value || names[0]
}
/**
* 标准化房间参与者
*
* @param {Record<string, any>} participant ParticipantInfo
* @returns {{ sid: string, identity: string, kind: string, state: string, joinedAt: number, tracks: Array<{ sid: string, type: string, source: string, muted: boolean }> }} 参与者
*/
function normalizeParticipant(participant) {
return {
sid: participant.sid,
identity: participant.identity || '',
kind: enumName(participant.kind, PARTICIPANT_KINDS),
state: enumName(participant.state, PARTICIPANT_STATES),
joinedAt: Number(field(participant, 'joined_at') || 0),
tracks: (participant.tracks || []).map(track => ({
sid: track.sid,
type: enumName(track.type, TRACK_TYPES),
source: enumName(track.source, TRACK_SOURCES),
muted: track.muted === true,
})),
}
}
/**
* 创建 LiveKit API 客户端;服务地址只来自部署配置,不接受客户端传入
*
* @param {{ livekitHttpUrl: string, apiKey: string, apiSecret: string, requestTimeoutMs: number }} config 网关配置
* @returns {Record<string, Function>} API 方法
*/
export function createLiveKitApi(config) {
/**
* 调用 Twirp 接口
*
* @param {string} service 服务名,如 RoomService
* @param {string} method 方法名
* @param {Record<string, any>} body 请求体
* @param {Record<string, any>} video 管理 Token 授权
* @returns {Promise<Record<string, any>>} 响应体
*/
async function call(service, method, body, video) {
let response
let text
try {
response = await fetch(`${config.livekitHttpUrl}/twirp/livekit.${service}/${method}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${signAdminToken(config, video)}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(config.requestTimeoutMs),
})
text = await response.text()
} catch (error) {
// 网络错误 / 超时只保留类型,不透传底层信息
const code = error?.name === 'TimeoutError' ? 'timeout' : 'network_error'
throw new LiveKitApiError(0, code, `${service}.${method} 请求失败:${code}`)
}
let data = {}
try {
data = text ? JSON.parse(text) : {}
} catch {
data = {}
}
if (!data || typeof data !== 'object' || Array.isArray(data)) { // null、数组、数字等非对象 JSON 视为空响应,交由状态码与资源编号校验处理
data = {}
}
if (!response.ok) {
// 上游 msg 可能回显请求中的 RTMP 地址等凭证,只保留方法、HTTP 状态与规范化的 Twirp 错误码
const code = typeof data.code === 'string' && TWIRP_CODE_PATTERN.test(data.code) ? data.code : 'unknown'
throw new LiveKitApiError(response.status, code, `${service}.${method} 失败:HTTP ${response.status} ${code}`)
}
return data
}
/**
* 判断是否为「资源不存在 / 已结束」类错误,释放资源时可忽略
*
* @param {unknown} error 错误
* @returns {boolean} 是否可忽略
*/
function isGone(error) {
return error instanceof LiveKitApiError
&& (error.status === 404 || error.code === 'not_found' || error.code === 'failed_precondition')
}
return {
/**
* 查询房间参与者
*
* @param {string} room 房间名
* @returns {Promise<ReturnType<typeof normalizeParticipant>[] | null>} 参与者;房间不存在返回 null
*/
async listParticipants(room) {
try {
const data = await call('RoomService', 'ListParticipants', { room }, { roomAdmin: true, room })
return (data.participants || []).map(normalizeParticipant)
} catch (error) {
if (error instanceof LiveKitApiError && (error.status === 404 || error.code === 'not_found')) {
return null
}
throw error
}
},
/**
* 创建 RTMP Ingress;参与者身份直接使用 Token 内的 userId,保留同 identity 互踢语义
*
* @param {{ room: string, identity: string, name: string, resourceName: string }} options 参数
* @returns {Promise<{ ingressId: string, url: string, streamKey: string }>} Ingress 信息
*/
async createIngress(options) {
const data = await call('Ingress', 'CreateIngress', {
input_type: 'RTMP_INPUT',
name: options.resourceName,
room_name: options.room,
participant_identity: options.identity,
participant_name: options.name,
enable_transcoding: true,
}, { ingressAdmin: true })
const ingressId = field(data, 'ingress_id')
if (typeof ingressId !== 'string' || !ingressId) { // 可能已创建但拿不到编号,交由调用方按结果未知处理
throw new LiveKitApiError(502, 'invalid_response', 'Ingress.CreateIngress 响应缺少资源编号')
}
return {
ingressId,
url: data.url || '',
streamKey: field(data, 'stream_key') || '',
}
},
/**
* 删除 Ingress;不存在时忽略
*
* @param {string} ingressId Ingress 编号
* @returns {Promise<void>}
*/
async deleteIngress(ingressId) {
try {
await call('Ingress', 'DeleteIngress', { ingress_id: ingressId }, { ingressAdmin: true })
} catch (error) {
if (!isGone(error)) {
throw error
}
}
},
/**
* 列出全部 Ingress,用于启动时回收本网关残留资源
*
* @returns {Promise<Array<{ ingressId: string, name: string }>>} Ingress 列表
*/
async listIngress() {
const data = await call('Ingress', 'ListIngress', {}, { ingressAdmin: true })
return (data.items || []).map(item => ({ ingressId: field(item, 'ingress_id'), name: item.name || '' }))
},
/**
* 启动单个远端参与者的 Track Composite Egress,推 RTMP 到 SRS
*
* @param {{ room: string, audioTrackId?: string, videoTrackId?: string, url: string, encoding: Record<string, number> }} options 参数
* @returns {Promise<{ egressId: string }>} Egress 信息
*/
async startTrackCompositeEgress(options) {
const data = await call('Egress', 'StartTrackCompositeEgress', {
room_name: options.room,
audio_track_id: options.audioTrackId || '',
video_track_id: options.videoTrackId || '',
stream_outputs: [{ protocol: 'RTMP', urls: [options.url] }],
advanced: {
width: options.encoding.width,
height: options.encoding.height,
framerate: options.encoding.framerate,
audio_codec: 'AAC',
audio_bitrate: options.encoding.audioBitrate,
video_codec: 'H264_BASELINE',
video_bitrate: options.encoding.videoBitrate,
key_frame_interval: options.encoding.keyFrameInterval,
},
}, { roomRecord: true })
const egressId = field(data, 'egress_id')
if (typeof egressId !== 'string' || !egressId) { // 可能已创建但拿不到编号,交由调用方按结果未知处理
throw new LiveKitApiError(502, 'invalid_response', 'Egress.StartTrackCompositeEgress 响应缺少资源编号')
}
return { egressId }
},
/**
* 停止 Egress;已结束或不存在时忽略
*
* @param {string} egressId Egress 编号
* @returns {Promise<void>}
*/
async stopEgress(egressId) {
try {
await call('Egress', 'StopEgress', { egress_id: egressId }, { roomRecord: true })
} catch (error) {
if (!isGone(error)) {
throw error
}
}
},
/**
* 列出活跃 Egress
*
* @param {string | undefined} room 房间名;为空时列出全部
* @returns {Promise<Array<{ egressId: string, raw: Record<string, any> }>>} 活跃 Egress
*/
async listActiveEgress(room) {
const data = await call('Egress', 'ListEgress', room ? { room_name: room, active: true } : { active: true }, { roomRecord: true })
return (data.items || []).map(item => ({ egressId: field(item, 'egress_id'), raw: item }))
},
}
}