npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lakutata/webrtc

v3.2.0

Published

WebRTC for the lakutata framework, in TypeScript without native code: the signaling, a STUN/TURN server, a media server (SFU) for rooms of many members, WHIP publishing and WHEP playback, peer connections with data channels and audio and video tracks in N

Readme

@lakutata/webrtc

English | 中文

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,没有时使用 ws 的 WebSocket(Node.js 18 到 21)。多个实例协同工作时需要 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 适配器。