@lakutata/webrtc
lakutata 框架的 WebRTC,用 TypeScript 编写,没有原生代码:安装时不需要编译器,能在 Node.js、Electron 的主进程和 Bun 中运行。它有两个入口:
@lakutata/webrtc,给应用用:信令(房间里的成员互相交换 offer、answer 和候选地址)、STUN 和 TURN 服务器、给人多的房间用的媒体服务器(SFU)、用 WHIP 推流和用 WHEP 拉流,以及 Node.js 端的对等连接(数据通道、音视频轨道),接口和浏览器的RTCPeerConnection一致。应用所在房间里的成员可以通过数据通道调用应用的服务动作。应用的多个实例可以通过 NATS 或 Redis 协同工作,媒体服务器也是。@lakutata/webrtc/client,给浏览器和 Electron 的渲染进程用:把浏览器自带的RTCPeerConnection接到信令上。它只使用浏览器的接口,前端打包它时不会带上服务端代码。
协议的实现(ICE、DTLS、SCTP 和数据通道、RTP 和 SRTP、STUN 和 TURN 服务器)改编自 werift,见 THIRD_PARTY_NOTICES.md。
安装
npm install @lakutata/webrtc应用还需要 lakutata(或者 @lakutata/core 和 @lakutata/entrypoint)。信令通过 entrypoint 的适配器提供,走 Socket.IO(npm install socket.io)或 WebSocket 上的 JSON-RPC(npm install ws)。应用作为成员通过 Socket.IO 加入房间时需要 socket.io-client;通过 JSON-RPC 加入时,使用 Node.js 22 和 Bun 自带的 WebSocket。多个实例协同工作时需要 nats 或 ioredis。
前端安装同一个包,引用 @lakutata/webrtc/client;框架不会随之安装。
用法
服务端:信令、TURN 服务器,以及 entrypoint 上的信令控制器。
import {Application} from 'lakutata'
import {buildEntrypoints} from 'lakutata/com/entrypoint'
import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
import {buildSignalingOptions, buildTURNServerOptions, SignalingController} from '@lakutata/webrtc'
Application.run(() => ({
id: 'meet.app',
name: 'Meet',
components: {
//STUN 和 TURN 使用 3478 端口(UDP 和 TCP),中转使用 49160 到 49200 端口
turn: buildTURNServerOptions({publicAddress: '203.0.113.10', relayPortRange: [49160, 49200], secret: process.env.TURN_SECRET}),
//房间成员拿到 turn 的 ICE 服务器,凭据一天内有效
signaling: buildSignalingOptions({turn: 'turn'}),
entrypoint: buildEntrypoints({
controllers: [SignalingController],
service: socketIOServiceEntrypoint({port: 3000})
})
},
bootstrap: ['turn', 'entrypoint']
}))浏览器:加入房间,和其他每个成员各开一个数据通道。
import {io} from 'socket.io-client'
import {type Peer, type Room, SignalingConnection} from '@lakutata/webrtc/client'
//成员的 id 是它的 Socket.IO 连接的 auth.id
const signaling: SignalingConnection = new SignalingConnection(io('https://meet.example.com', {auth: {id: 'alice'}}))
const room: Room = await signaling.join('room-1', {metadata: {name: 'Alice'}})
room.on('peer', (peer: Peer): void => {
const channel: RTCDataChannel = peer.createDataChannel('chat')
channel.addEventListener('open', (): void => channel.send(`hello ${peer.id}`))
peer.on('datachannel', (remote: RTCDataChannel): void => {
remote.addEventListener('message', (event: MessageEvent): void => console.log(peer.id, event.data))
})
})
room.on('peer-left', (peer: Peer): void => console.log(`${peer.id} left`))应用作为成员:一台设备把读数发给进入它房间的每个人。
import {Component} from 'lakutata'
import {type Peer, type PeerChannel, type Room, SignalingClient} from '@lakutata/webrtc'
export class Telemetry extends Component {
protected async init(): Promise<void> {
const signaling: SignalingClient = await this.getObject(SignalingClient, {url: 'https://meet.example.com', id: 'device-7'})
const room: Room = await signaling.join('device-7')
room.on('peer', (peer: Peer): void => {
const channel: PeerChannel = peer.createDataChannel('readings')
const timer: NodeJS.Timeout = setInterval((): void => {
if (channel.readyState === 'open') channel.send(JSON.stringify({temperature: 21.5}))
}, 1000)
peer.on('close', (): void => clearInterval(timer))
})
}
}不用这个包的信令时,可以直接获取一个 PeerConnection(await this.getObject(PeerConnection, {iceServers})),像浏览器里使用 RTCPeerConnection 那样自己交换 offer、answer 和候选地址。连接很多时,PeerThreads 组件可以把它们的引擎放到工作线程里运行,以利用更多的核:把它的名字作为连接的 threads 选项(SignalingClient 也有这个选项)。
房间的行为:
- 连接。 成员为其他每个成员得到一个
Peer,带有到它的连接(peer.connection:浏览器里是RTCPeerConnection,Node.js 里是PeerConnection)。任何一方添加数据通道或轨道时,连接就会建立。offer 和 answer 按 W3C 的“完美协商”(perfect negotiation)交换:双方随时都可以添加通道和轨道,同时添加也没问题。双方只想共用一个通道时,两边都用negotiated: true和相同的id创建它。 - 修复。 连接失败,或者断开超过 5 秒,会自动重启 ICE。信令连接断开时,点对点连接保持不变;信令恢复后,客户端重新加入它的房间,其他成员保留到它的连接。成员刷新页面后以新的会话加入,其他成员会替换到它的连接。
- 离开。
room.leave()关闭这个房间的连接,其他成员收到peer-left。成员的信令连接(Socket.IO,或 WebSocket 上的 JSON-RPC)关闭时,它会离开它的房间;它的点对点连接保持不变,直到它回来或者连接失败。 - ICE 服务器。 成员加入房间时从服务端拿到 ICE 服务器,TURN 凭据在
credentialTtl(默认一天)内有效,到期前客户端会重新获取。凭据过期后,用它建立的中转照常工作。 - 检查。 成员只能给自己所在房间的成员发信令;信令的大小(64 KiB)和频率(平均每秒 50 条)有限制,
authorize选项决定谁能加入哪个房间。
大消息:数据通道和任何一方之间都能传 64 KiB 以内的消息;Chrome 和这个包的连接能接收 256 KiB 以内的消息。两端都套上 ChunkedChannel 后,消息会被切成小块发送,再在对端拼回原样,任意大小的消息和文件都能传:
import {ChunkedChannel, type PeerChannel} from '@lakutata/webrtc'
declare const channel: PeerChannel
const transfer: ChunkedChannel = new ChunkedChannel(channel)
transfer.on('message', (data: string | Uint8Array): void => console.log(`${data.length} received`))
await transfer.send(new Uint8Array(50 * 1024 * 1024))调用应用的动作:webrtcServiceEntrypoint() 通过通往应用的数据通道,把应用的服务动作(@ServiceAction)提供给它所在房间的成员,输入校验和访问控制照常生效。调用是 JSON-RPC 2.0 消息,成员用 PeerServiceProxy 发起:
import {Application} from 'lakutata'
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
import {ServiceAction} from 'lakutata/decorator/ctrl'
import {PeerServiceProxy, webrtcServiceEntrypoint, type Peer} from '@lakutata/webrtc'
class DoorController extends Controller {
@ServiceAction({method: 'door.open'})
public async open(input: {door: string}): Promise<string> {
return `${input.door} is open`
}
}
//设备:房间 door-7 里的成员 door-7
Application.run({
id: 'door.device',
name: 'Door',
components: {
entrypoint: buildEntrypoints({
controllers: [DoorController],
service: webrtcServiceEntrypoint({signaling: {url: 'https://meet.example.com', id: 'door-7'}, rooms: ['door-7']})
})
},
bootstrap: ['entrypoint']
})
//房间里的另一个成员(浏览器从 @lakutata/webrtc/client 导入 PeerServiceProxy)
declare const peer: Peer
const door: PeerServiceProxy = new PeerServiceProxy(peer.createDataChannel('service'))
console.log(await door.invoke({method: 'door.open', door: 'front'}))调用的客户端 id 是调用方的成员 id:在动作依赖它之前,要让信令服务器先认证它的客户端。
音视频:浏览器把摄像头和麦克风的轨道发布到房间里,通过每个 peer 的 track 事件拿到其他成员的轨道。
import {type Peer, type Room} from '@lakutata/webrtc/client'
declare const room: Room
declare const remoteVideo: HTMLVideoElement
const camera: MediaStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true})
for (const track of camera.getTracks()) await room.publish(track, camera)
room.on('peer', (peer: Peer): void => {
peer.on('track', (event: RTCTrackEvent): void => {
remoteVideo.srcObject = event.streams[0]
})
})Node.js 端不采集、不编码也不解码:应用的轨道传的是 RTP 包,也就是编码好的音视频。要发送的 MediaTrack 用 writeRtp() 写入编码器(ffmpeg、GStreamer)输出的包;收到的轨道通过 rtp 事件交出对方发来的包,用来录制或解码,requestKeyFrame() 向对方请求关键帧。把收到的轨道加到另一条连接上,它的包就会转发过去,那条连接的关键帧请求也会转回原来的发送方。下面是一个摄像头应用,发送 ffmpeg 编码的视频:
import {createSocket, type Socket} from 'node:dgram'
import {Component} from 'lakutata'
import {MediaTrack, type Room, SignalingClient} from '@lakutata/webrtc'
//ffmpeg -f v4l2 -i /dev/video0 -c:v libx264 -profile:v baseline -pix_fmt yuv420p -tune zerolatency -g 30
// -bsf:v dump_extra=freq=keyframe -f rtp rtp://127.0.0.1:5004
export class Camera extends Component {
protected async init(): Promise<void> {
const video: MediaTrack = new MediaTrack('video', {codec: 'video/H264'})
const socket: Socket = createSocket('udp4')
socket.on('message', (datagram: Buffer): void => video.writeRtp(datagram))
socket.bind(5004)
const signaling: SignalingClient = await this.getObject(SignalingClient, {url: 'https://meet.example.com', id: 'camera-3'})
const room: Room = await signaling.join('camera-3')
await room.publish(video, 'camera-3')
}
}观看方要从关键帧开始解码:让编码器每一两秒出一个关键帧(ffmpeg 的 -g),或者在编码器支持时,收到轨道的 keyframerequest 事件就生成一个。支持的编码:音频 Opus 和 PCMU,视频 VP8、H.264 和 VP9(PeerConnection 和 SignalingClient 的 codecs 选项)。
给人多的房间用的媒体服务器:没有它时,有几个其他成员,每个成员的视频就要发几份。SFU 组件加入它服务的房间,客户端只连接它:每个客户端的轨道只发一份,再从服务器那里收到其他人的轨道,服务器转发这些包时不解码。房间和 peer 的用法和没有它时一样(room.publish()、peer 的 track 事件、peer.createDataChannel(),后者的消息由服务器转发,服务器也能看到),所以客户端的代码不用改。
import {Application} from 'lakutata'
import {buildEntrypoints} from 'lakutata/com/entrypoint'
import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
import {buildSFUOptions, buildSignalingOptions, SignalingController} from '@lakutata/webrtc'
Application.run(() => ({
id: 'meet.app',
name: 'Meet',
components: {
signaling: buildSignalingOptions(),
//会议经过媒体服务器,它的连接使用 UDP 的 40000 到 40999 端口
sfu: buildSFUOptions({
serves: (room: string): boolean => room.startsWith('meeting-'),
iceAdditionalHostAddresses: ['203.0.113.10'],
icePortRange: [40000, 40999]
}),
entrypoint: buildEntrypoints({
controllers: [SignalingController],
service: socketIOServiceEntrypoint({port: 3000})
})
},
bootstrap: ['sfu', 'entrypoint']
}))select 决定哪个成员收哪条轨道,peer.pause() 和 peer.resume() 暂停和恢复一条轨道,sfu.track() 交出一条轨道的包(用来录制),workers 让工作线程转发房间,以利用更多的核。服务器不转码:给浏览器用时,视频请用 VP8 或 H.264 发布。
用 WHIP 推流(RFC 9725):编码器(OBS、GStreamer、ffmpeg)和浏览器用一个 HTTP POST 提交 offer,就能推一路流。WHIP 组件回答它们,推流开始时发出 publish,带上会话(它的连接和轨道),结束时发出 unpublish;WHIPController 提供 /whip/:stream 接口。配置了 sfu 选项时,推的流改为作为成员进入媒体服务器的一个房间。
import {Application, Component} from 'lakutata'
import {buildEntrypoints} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
import {buildWHIPOptions, type MediaTrack, type WHIP, type WHIPSession, WHIPController} from '@lakutata/webrtc'
export class Studio extends Component {
protected async init(): Promise<void> {
const whip: WHIP = await this.getObject<WHIP>('whip')
whip.on('publish', (session: WHIPSession): void => {
session.tracks?.forEach((track: MediaTrack): void => console.log(`${session.stream}: ${track.kind} ${track.codec}`))
})
}
}
Application.run(() => ({
id: 'studio.app',
name: 'Studio',
components: {
//OBS:服务选 WHIP,服务器填 https://studio.example.com/whip/live,再填上 Bearer 令牌
whip: buildWHIPOptions({authorize: ({token}): boolean => token === process.env.WHIP_TOKEN}),
studio: {class: Studio},
entrypoint: buildEntrypoints({
controllers: [WHIPController],
//offer 的类型是 application/sdp,按文本读取
http: expressHTTPEntrypoint({port: 8080, body: {text: ['application/sdp', 'application/trickle-ice-sdpfrag']}})
})
},
bootstrap: ['studio', 'entrypoint']
}))answer 里带有服务器的全部候选地址:要告诉它自己的公网地址(iceAdditionalHostAddresses)和要放开的 UDP 端口(icePortRange)。会话的 PATCH 请求把推流端的候选地址交给服务器(Trickle ICE),或者重启 ICE;body.text 里也要列上 application/trickle-ice-sdpfrag,否则 Fastify 会在请求到达控制器之前以 415 拒绝它。ffmpeg 用 -f whip -authorization <令牌> <URL> 推流。
用 WHEP 拉流:播放端把一个只收不发的 offer 提交到 /whep/:stream(WHEPController),WHEP 组件把媒体服务器房间里一个成员的音频和视频发给它:默认是用 WHIP 推进这个房间的流,或者 select 选中的成员。观众不是房间的成员,一路流可以有很多观众。
import {buildSFUOptions, buildSignalingOptions, buildWHEPOptions, buildWHIPOptions} from '@lakutata/webrtc'
//OBS 推到 /whip/concert,浏览器从 /whep/concert 拉流(WHIPController 和 WHEPController)
const components = {
signaling: buildSignalingOptions(),
sfu: buildSFUOptions({icePortRange: [40000, 40999]}),
whip: buildWHIPOptions({sfu: 'sfu', authorize: ({token}): boolean => token === process.env.WHIP_TOKEN}),
whep: buildWHEPOptions({maxSessions: 500})
}应用的多个实例(比如一个部署里的多个 pod)通过 Cluster 协同工作:每个实例是一个节点,节点之间通过 NATS 或 Redis 通信。把它的名字告诉其他组件后,信令会把连在各个节点上的成员放进同一批房间,TURN 服务器会把自己的地址和负载告诉其他节点,媒体服务器共同承载房间(每个成员连到某一个节点的媒体服务器上,同一个房间的节点之间互相转发轨道),WHIP 或 WHEP 会话的 DELETE 和 PATCH 请求不管到了哪个节点,都能结束或修改这个会话。总线上只传这些消息:音视频和数据仍然在客户端和节点之间、各媒体服务器之间直接传输。
import {Application} from 'lakutata'
import {buildEntrypoints} from 'lakutata/com/entrypoint'
import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
import {buildClusterOptions, buildSignalingOptions, SignalingController} from '@lakutata/webrtc'
Application.run(() => ({
id: 'meet.app',
name: 'Meet',
components: {
//npm install nats;用 Redis 时写 {type: 'redis', url: process.env.REDIS_URL},并 npm install ioredis
cluster: buildClusterOptions({transport: {type: 'nats', servers: process.env.NATS_URL}}),
//每个成员拿到集群里负载最低的两个 TURN 服务器,它们共用这个密钥
signaling: buildSignalingOptions({cluster: 'cluster', turnSecret: process.env.TURN_SECRET}),
entrypoint: buildEntrypoints({
controllers: [SignalingController],
service: socketIOServiceEntrypoint({port: 3000})
})
},
bootstrap: ['signaling', 'entrypoint']
}))TURN 服务器通过 cluster 选项和共用的 secret 加入集群。某个节点停止,或者其他节点连续三次收不到它的心跳时,它上面的成员以 disconnected 离开房间:它们的连接不会断开,客户端会重新连到别的节点。连在这个节点媒体服务器上的成员会转到另一个节点的媒体服务器,它们在各方的 peer 会重新打开,就像成员刷新了页面一样。maxPeers 统计的是所有节点上的成员;媒体服务器的 maxMembers,以及 WHIP 和 WHEP 的 maxSessions,统计的是每个节点自己的。
部署
- 端口。 TURN 服务器在 3478 端口上监听 UDP 和 TCP(给了证书时还有 TLS);中转使用
relayPortRange里的 UDP 端口。在防火墙里放开这些端口,或者从容器发布它们。 - 地址。
publicAddress是客户端访问服务器用的地址:主机的公网地址,或者转发了这些端口的 NAT 的地址。默认取主机第一个非内部的 IPv4 地址,在局域网里够用。 - 媒体服务器。 客户端直接连接它:放开它
icePortRange的 UDP 端口;NAT 按相同端口号转发这些端口时,把 NAT 的公网地址写进iceAdditionalHostAddresses。WHIP 和 WHEP 也一样。 - 多个实例。 给它们配置
Cluster(见上文),并给它们的 TURN 服务器配置相同的secret。在负载均衡后面,Socket.IO 需要会话保持(sticky session),除非客户端用transports: ['websocket']连接。在 Kubernetes 里,在每个节点的网络上运行一个 TURN 服务器和一个媒体服务器(使用hostNetwork的 DaemonSet);部署清单见指南。 - 其他 TURN 服务器。 配置了
use-auth-secret和相同密钥的 coturn 也接受这个包生成的凭据:把它写进Signaling的iceServers选项。
信令协议
请求是模式为 {method} 的服务调用:Socket.IO 上是带确认回调的 message 事件,内容如 {method: 'webrtc.join', room: 'room-1'};JSON-RPC 上是方法名为 webrtc.join 的请求。成员的 id 是它连接的客户端 id(Socket.IO 的 auth.id,WebSocket 的 id 查询参数或请求头)。服务端把事件推给成员:Socket.IO 的事件,或者 params 里放着内容的 JSON-RPC 通知。
| 请求 | 参数 | 返回 |
|---|---|---|
webrtc.join | room、metadata、session | {v, peerId, peers, iceServers, credentialTtl, heartbeatInterval, sfu} |
webrtc.signal | room、to、description 或 candidate | {delivered} |
webrtc.leave | room | {} |
webrtc.iceServers | — | {iceServers, credentialTtl} |
webrtc.heartbeat | — | {} |
| 事件 | 内容 |
|---|---|
webrtc:peer-joined | {room, peerId, metadata, session, role, sfu} |
webrtc:peer-left | {room, peerId, reason}:left 或 disconnected |
webrtc:signal | {room, from, session, description, streams} 或 {room, from, session, candidate} |
heartbeatInterval 不为 0 时,客户端每隔这么多毫秒发送一次 webrtc.heartbeat:这发生在服务端察觉不到断开的传输方式上(Socket.IO 和 WebSocket 上的 JSON-RPC 返回 0)。session 是客户端自己选的一个随机字符串,只选一次:用同一个会话重新加入时,其他成员会保留到它的连接。在有媒体服务器的房间里,sfu 是客户端要连的媒体服务器的 id(它的 role 是 sfu),服务器发出的 description 带有 streams,说明每个流来自哪个成员;媒体服务器的数据通道见指南。
限制
- Node.js 端不采集、不编码也不解码音视频:它的轨道传的是 RTP 包,来自编码器或者另一条连接。
- 两个 Node.js 进程之间单个数据通道的吞吐量(Apple M4):大消息约 80 MiB/s,100 字节的消息每秒约 62.6 万条(同一台 Mac 上的 arm64 Linux 虚拟机里约 43 MiB/s、54.8 万条)。
- 媒体服务器(Apple M4):它的主线程每秒约转发 87,000 个包(相当于 435 路 2 Mbit/s 的视频),4 个工作线程用 1.7 个核转发了一次测试里全部的每秒 92,000 个包。它不转码,按发布时的样子发送每路视频(还没有 simulcast)。
API
| 导出 | 说明 |
|---|---|
Signaling、buildSignalingOptions | 房间组件:成员、信令、限制、authorize、ICE 服务器 |
Cluster、buildClusterOptions | 通过 NATS 或 Redis 协同工作的多个实例:nodes()、publish()、request()、announce() |
SignalingController | 通过 Socket.IO 和 JSON-RPC 提供信令的控制器 |
TURNServer、buildTURNServerOptions | STUN 和 TURN 服务器:iceServers()、credentials()、allocations |
createTURNCredentials | 短时有效的 TURN 凭据(coturn 的 REST API) |
SignalingClient | 应用作为房间成员:join()、close() |
PeerConnection、PeerChannel | Node.js 端的对等连接和数据通道 |
PeerThreads、buildPeerThreadsOptions | 运行连接引擎的工作线程(哪些连接的 threads 选项是它的名字,就运行哪些连接的引擎) |
MediaTrack、RtpPacket | Node.js 端的音视频轨道和它的 RTP 包 |
RtpTransceiver、RtpSender、RtpReceiver | 连接里的音视频收发器(transceiver) |
WHIP、buildWHIPOptions | WHIP 推流接收:会话,publish 和 unpublish 事件 |
WHIPController | WHIP 的 HTTP 接口:/whip/:stream |
SFU、buildSFUOptions | 房间的媒体服务器:rooms()、track(),publish、unpublish、unsupported 和 congestion 事件 |
WHEP、buildWHEPOptions | 用 WHEP 拉媒体服务器房间里的流:会话,play 和 stop 事件 |
WHEPController | WHEP 的 HTTP 接口:/whep/:stream |
Room、Peer | 加入的房间,以及另一个成员和到它的连接 |
ChunkedChannel | 在数据通道上传任意大小的消息 |
webrtcServiceEntrypoint、webrtcRequestOfContext | 通过房间里的数据通道调用应用的服务动作,以及一次调用背后的成员 |
PeerServiceProxy、PeerServiceError(/client 也有) | 调用这些动作:invoke(),以及接收推送事件的 on() |
setDebugHandler | ICE、DTLS、SCTP 和 TURN 的调试信息 |
SignalingMethod、SignalingEvent | 协议里请求和事件的名称 |
SignalingConnection(/client) | 浏览器端的信令:join()、close() |
错误
| 异常 | 什么时候 |
|---|---|
SignalingDeniedException | authorize 拒绝了这个房间 |
SignalingLimitException | 房间已满、没有媒体服务器肯接收这个成员、信令太大、信令太频繁 |
SignalingNotAMemberException | 发送方或接收方不在这个房间里 |
SignalingClientRequiredException | 请求所在的连接没法推送事件(HTTP),或者请求经由 webrtcServiceEntrypoint 的数据通道发来 |
SignalingConnectionException | SignalingClient 缺少 socket.io-client 或 WebSocket |
WHIPDeniedException | authorize 拒绝了这路流(401) |
WHIPLimitException | 会话已经有 maxSessions 个(503) |
WHIPOfferException | offer 里没有音视频,或者连接不接受它(400) |
WHIPMediaTypeException | offer 不是 application/sdp(415) |
WHIPSessionNotFoundException | 请求的会话不存在(404) |
WHEPDeniedException | authorize 拒绝了这路流(401) |
WHEPLimitException | 会话已经有 maxSessions 个(503) |
WHEPOfferException | offer 里没有音视频、媒体服务器不服务这个房间,或者连接不接受它(400) |
WHEPMediaTypeException | offer 不是 application/sdp(415) |
WHEPSessionNotFoundException | 请求的会话不存在(404) |
ClusterRequestException | Cluster.request() 请求的节点不在集群里、没有对应的处理函数,或者处理函数出错 |
ClusterTimeoutException | Cluster.request() 没有按时得到答复 |
ClusterTransportNotFoundException | 集群用 NATS 或 Redis,但没有安装 nats 或 ioredis 包 |
SignalingRequestError | 客户端上请求的错误,带有服务端异常的 errno |
PeerServiceError | 成员端 PeerServiceProxy 调用的错误,带有动作抛出的错误的 errno,或者是 E_PEER_SERVICE_TIMEOUT、E_PEER_SERVICE_CLOSED、E_PEER_SERVICE_TOO_LARGE |
测试
npm run test:unit 运行协议、信令以及 Node.js 进程之间连接的测试。浏览器的测试使用 Playwright:本机安装的 Chrome,以及 Playwright 自带的 Firefox 和 WebKit(npx playwright install firefox webkit)。能引用 playwright 包(或者在 LAKUTATA_PLAYWRIGHT 指定的目录里找到它)时运行,没装的浏览器的测试会跳过。把编码器的 H.264 发给浏览器的那个测试还需要带 libx264 的 ffmpeg,以及支持 H.264 的浏览器(Playwright 的 Firefox 不支持);WHIP 的测试需要带 WHIP 推流功能(FFmpeg 8 及以上)和 libopus 的 ffmpeg。Playwright 的 WebKit 不接受带传输方式的 TURN URL(?transport=udp),Safari 接受,所以 WebKit 只走中转的那个测试会跳过。
其他运行环境和实现,先编译(npm run compile):
npm run test:bun在 Bun 里运行测试(浏览器的测试除外)。TURN 刷新的 4 个测试在 Bun 里会失败:测试用的假时钟只能在 Node.js 里替换node:timers/promises的导入。npm run test:electron在 Electron 的主进程里运行测试(用仓库里的 Electron)。npm run test:linux在 Linux(Docker 里的node:24-alpine)里运行测试、性能目标检查,以及转发、TURN 服务器和连接的工作线程的基准。npm run test:interop在 Docker 里让 Pion(Go)和 aiortc(Python)连接这个包的信令、TURN 服务器和一个成员:直连和只走中转的数据通道,以及 aiortc 对服务动作的调用;然后把这个成员的连接引擎放到工作线程里,再测一遍。node test-env/safari.mjs在 Safari(macOS)里打开一个页面:数据通道、服务调用、只走中转的连接。测完关掉这个标签页。
npm run test:servers 在 Docker 里启动 NATS 和 Redis,运行集群在它们上面的测试(test-env/cluster.sh)。test-env/k3s/run.sh 在 Docker 里运行有两个节点的 k3s,部署信令,并在每个节点上部署一个 TURN 服务器和一个媒体服务器。它先让两个成员分别通过两个不同的 pod 连接,并且只经 TURN 服务器中转;再让两个成员分别连到两个节点的媒体服务器,并删掉其中一个媒体服务器的 pod。
基准测试在 test-env/bench 里,先编译这个包再运行。targets.mjs 把引擎的实测结果和性能目标对比(大消息 70 MiB/s,每 GiB 最多 40 秒 CPU;100 字节的消息每秒 5 万条;200 个连接 2 秒内建好),有一项没达到,退出码就是 1:发布前运行它。engine.mjs 测量数据通道、RTP 发送和连接的建立,两端各在一个进程里;forward.mjs 测量媒体服务器的转发能力,turn.mjs 测量 TURN 服务器的中转能力(--idle 先建立其他客户端的空闲分配,--workers 是工作线程数,--parallel 是同时中转的连接数),threads.mjs 在一个进程里测量成对的连接,引擎在主线程或工作线程里(--pairs 是对数,--threads 是线程数,--size 是消息大小)。floor.mjs 和 send-modes.mjs 不需要编译:前者测一个包在 JavaScript 里至少要花多少 CPU(加密和 UDP 发送),后者测发送 UDP 包的每种方式各花多少 CPU。
另见
- 网站上的指南:WebRTC。
lakutata包中的doc/zh/WebRTC.md:WebRTC 指南。@lakutata/entrypoint:提供信令的 Socket.IO 和 JSON-RPC 适配器。