重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
how-to-build-a-webrtc-signal-server-with-pocketbase by rodydavis/skills
npx skills add https://github.com/rodydavis/skills --skill how-to-build-a-webrtc-signal-server-with-pocketbase如果你是 WebRTC 的新手,我建议先观看这个精彩的 Fireship 视频 WebRTC in 100 seconds:
另外,如果你在寻找 Firebase 的示例,可以查看 这个仓库,本示例在很大程度上基于它。
本示例使用 PocketBase 作为 WebRTC 的信令服务器,并在服务器端运行 SQLite,同时提供了易于使用的、基于服务器发送事件 (SSE) 构建的实时 SDK。
下载 PocketBase 并创建一个我们将用于项目的新目录。
mkdir webrtc-pocketbase-demo
cd webrtc-pocketbase-demo
将 PocketBase 二进制文件复制到你刚刚创建的目录下的 .pb 子目录中。如果你使用的是 MacOS,你需要在设置中允许该可执行文件运行。
使用以下命令启动 PocketBase 服务器:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
.pb/pocketbase serve
如果一切顺利,你应该会看到以下输出:
2023/11/04 15:10:56 Server started at http://127.0.0.1:8090
├─ REST API: http://127.0.0.1:8090/api/
└─ Admin UI: http://127.0.0.1:8090/_/
打开 Admin UI 的 URL 并创建一个新的用户名和密码。
对于本示例,电子邮件和密码设置如下:
键
值
电子邮件
密码
Test123456789
你现在应该看到以下界面:
![]()
创建一个名为 ice_servers 的新集合,包含以下列:
列名
列类型
url
纯文本
![]()
为列表/搜索和查看方法添加以下 API 规则:
@request.auth.id != ''
![]()
集合创建后,为以下每个 url 值添加 2 条记录:
stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302
![]()
创建一个名为 calls 的新集合,包含以下列:
列名
列类型
列设置
user_id
关系
非空,users,级联删除为 true
offer
JSON
answer
JSON
![]()
也可以通过将 user_id 列设置为唯一约束来限制每个用户只能有一个通话。
![]()
为所有方法添加以下 API 规则:
@request.auth.id != ''
![]()
创建一个名为 offer_candidates 的新集合,包含以下列:
列名
列类型
列设置
call_id
关系
非空,calls,级联删除为 true
data
JSON
![]()
为所有方法添加以下 API 规则:
@request.auth.id != ''
创建一个名为 answer_candidates 的新集合,包含以下列:
列名
列类型
列设置
call_id
关系
非空,calls,级联删除为 true
data
JSON
![]()
为所有方法添加以下 API 规则:
@request.auth.id != ''
![]()
出于演示目的,我们不会为用户包含身份验证表单,但为了使示例简单,请使用与管理员相同的登录信息创建一个新用户。
![]()
![]()
导航到目录并运行以下命令开始:
npm init -y
npm i -D vite
npm i pocketbase
将 package.json 更新为以下内容:
{
"name": "webrtc-pocketbase-demo",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"devDependencies": {
"vite": "^4.5.0"
},
"dependencies": {
"pocketbase": "^0.19.0"
}
}
如果你在 Git 仓库中,更新/创建 .gitignore 文件,包含以下内容:
node_modules
.DS_Store
dist
dist-ssr
*.local
.pb
.env
创建 index.html 并添加以下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebRTC Pocketbase Demo</title>
</head>
<body>
<h2>1. 启动你的摄像头</h2>
<div class="videos">
<span>
<h3>本地流</h3>
<video id="webcamVideo" autoplay playsinline></video>
</span>
<span>
<h3>远程流</h3>
<video id="remoteVideo" autoplay playsinline></video>
</span>
</div>
<button id="webcamButton">启动摄像头</button>
<h2>2. 创建新通话</h2>
<button id="callButton" disabled>创建通话 (offer)</button>``
<h2>3. 加入通话</h2>
<p>从不同的浏览器窗口或设备接听通话</p>
<input id="callInput" />
<button id="answerButton" disabled>接听</button>
<h2>4. 挂断</h2>
<button id="hangupButton" disabled>挂断</button>
<script type="module" src="/main.js"></script>
</body>
</html>
创建 style.css 并添加以下内容:
body {
--text-color: #2c3e50;
--video-background-color: #2c3e50;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: var(--text-color);
margin: 80px 10px;
}
video {
width: 40vw;
height: 30vw;
margin: 2rem;
background: var(--video-background-color);
}
.videos {
display: flex;
align-items: center;
justify-content: center;
}
创建 main.js 并添加以下内容:
import "./style.css";
import PocketBase from "pocketbase";
const pb = new PocketBase("http://127.0.0.1:8090");
const calls = pb.collection("calls");
const offerCandidates = pb.collection("offer_candidates");
const answerCandidates = pb.collection("answer_candidates");
const webcamButton = document.getElementById("webcamButton");
const webcamVideo = document.getElementById("webcamVideo");
const callButton = document.getElementById("callButton");
const callInput = document.getElementById("callInput");
const answerButton = document.getElementById("answerButton");
const remoteVideo = document.getElementById("remoteVideo");
const hangupButton = document.getElementById("hangupButton");
const auth = await pb
.collection("users")
.authWithPassword(
import.meta.env.VITE_POCKETBASE_USERNAME,
import.meta.env.VITE_POCKETBASE_PASSWORD
);
const userId = auth.record.id;
const iceServers = await pb.collection("ice_servers").getFullList();
const servers = {
iceServers: [{ urls: iceServers.map((e) => e.url) }],
iceCandidatePoolSize: 10,
};
const pc = new RTCPeerConnection(servers);
let localStream = null;
let remoteStream = null;
webcamButton.onclick = async () => {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
remoteStream = new MediaStream();
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
pc.ontrack = (event) => {
const stream = event.streams[0];
stream.getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};
webcamVideo.srcObject = localStream;
remoteVideo.srcObject = remoteStream;
callButton.disabled = false;
answerButton.disabled = false;
webcamButton.disabled = true;
};
callButton.onclick = async () => {
const call = await calls.create({
user_id: userId,
});
const callId = call.id;
callInput.value = callId;
pc.onicecandidate = (event) => {
event.candidate &&
offerCandidates.create({
call_id: callId,
data: event.candidate.toJSON(),
});
};
const offerDescription = await pc.createOffer();
await pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await calls.update(callId, { offer });
calls.subscribe(callId, (e) => {
const data = e.record;
if (!pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
pc.setRemoteDescription(answerDescription);
}
});
answerCandidates.subscribe("*", (e) => {
if (e.action === "create") {
if (e.record?.call_id === callId) {
const data = e.record.data;
const candidate = new RTCIceCandidate(data);
pc.addIceCandidate(candidate);
}
}
});
hangupButton.disabled = false;
};
answerButton.onclick = async () => {
const callId = callInput.value;
const call = await calls.getOne(callId);
pc.onicecandidate = (event) => {
event.candidate &&
answerCandidates.create({
call_id: call.id,
data: event.candidate.toJSON(),
});
};
const offerDescription = call.offer;
const remoteDescription = new RTCSessionDescription(offerDescription);
await pc.setRemoteDescription(remoteDescription);
const answerDescription = await pc.createAnswer();
await pc.setLocalDescription(answerDescription);
const answer = {
type: answerDescription.type,
sdp: answerDescription.sdp,
};
await calls.update(call.id, { answer });
offerCandidates.subscribe("*", async (e) => {
if (e.record?.call_id === call.id) {
if (e.action === "create") {
const data = e.record.data;
const candidate = new RTCIceCandidate(data);
await pc.addIceCandidate(candidate);
} else if (e.action === "delete") {
await offerCandidates.unsubscribe();
window.location.reload();
}
}
});
};
hangupButton.onclick = async () => {
const callId = callInput.value;
pc.close();
await calls.unsubscribe(callId);
await calls.delete(callId);
await answerCandidates.unsubscribe();
window.location.reload();
};
运行以下命令启动客户端(确保服务器在另一个终端客户端中运行):
npm run dev
如果成功,你应该看到以下输出:
VITE v4.5.0 ready in 547 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help
用相同的 URL 打开两个浏览器窗口:
![]()
在第一个窗口中点击 启动摄像头,然后点击 创建通话 (offer)。
这将请求摄像头权限,然后生成一个新的 ID 并将其添加到 加入通话 文本框中。
![]()
复制新的 ID 并将其粘贴到第二个窗口的文本框中,然后点击 启动摄像头。
![]()
通话结束后,点击 挂断 🎉。
你可以在这里找到源代码。
每周安装量
50
仓库
GitHub 星标数
37
首次出现
2026年2月4日
安全审计
安装于
opencode47
gemini-cli46
codex46
github-copilot44
kimi-cli41
amp41
If you are new to WebRTC then I suggest checking out this great Fireship video on WebRTC in 100 seconds:
Also if you are looking for a Firebase example then check out this repository which this example is largely based on.
This example is built using PocketBase as the signal server for WebRTC and runs SQLite on the server with easy to use realtime SDKs built on top of Server Sent Events (SSE).
Download PocketBase and create a new directory that we will use for the project.
mkdir webrtc-pocketbase-demo
cd webrtc-pocketbase-demo
Copy the PocketBase binary into the directory you just created under a sub directory .pb. If you are on MacOS you will need to allow the executable to run in settings.
Start the PocketBase server with the following command:
.pb/pocketbase serve
If all goes well you should see the following:
2023/11/04 15:10:56 Server started at http://127.0.0.1:8090
├─ REST API: http://127.0.0.1:8090/api/
└─ Admin UI: http://127.0.0.1:8090/_/
Open up the Admin UI url and create a new username and password.
For this example the email and password will be the following:
Key
Value
Password
Test123456789
You should now see the following:
![]()
Create a new collection named ice_servers with the following columns:
Column Name
Column Type
url
Plain text
![]()
Add the following API rule to the List/Search and View:
@request.auth.id != ''
![]()
After the collection is created add 2 records for each of the following values for the url:
stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302
![]()
Create a new collection named calls with the following columns:
Column Name
Column Type
Column Settings
user_id
Relation
Non empty, users, Cascade delete is true
offer
JSON
answer
JSON
![]()
it is also possible to limit the user to one call each by setting the Unique constraint on the user_id column.
![]()
Add the following API rule to all of the methods:
@request.auth.id != ''
![]()
Create a new collection named offer_candidates with the following columns:
Column Name
Column Type
Column Settings
call_id
Relation
Non empty, calls, Cascade delete is true
data
JSON
![]()
Add the following API rule to all of the methods:
@request.auth.id != ''
Create a new collection named answer_candidates with the following columns:
Column Name
Column Type
Column Settings
call_id
Relation
Non empty, calls, Cascade delete is true
data
JSON
![]()
Add the following API rule to all of the methods:
@request.auth.id != ''
![]()
For demo purposes we will not be including an auth form for the user, but to make the example simple create a new user with the same login info for the admin.
![]()
![]()
Navigate to the directory and run the following commands to get started:
npm init -y
npm i -D vite
npm i pocketbase
Update the package.json to be the following:
{
"name": "webrtc-pocketbase-demo",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"devDependencies": {
"vite": "^4.5.0"
},
"dependencies": {
"pocketbase": "^0.19.0"
}
}
If you are in a Git repository update/create the .gitignore to have the following:
node_modules
.DS_Store
dist
dist-ssr
*.local
.pb
.env
Create index.html and add the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebRTC Pocketbase Demo</title>
</head>
<body>
<h2>1. Start your Webcam</h2>
<div class="videos">
<span>
<h3>Local Stream</h3>
<video id="webcamVideo" autoplay playsinline></video>
</span>
<span>
<h3>Remote Stream</h3>
<video id="remoteVideo" autoplay playsinline></video>
</span>
</div>
<button id="webcamButton">Start webcam</button>
<h2>2. Create a new Call</h2>
<button id="callButton" disabled>Create Call (offer)</button>``
<h2>3. Join a Call</h2>
<p>Answer the call from a different browser window or device</p>
<input id="callInput" />
<button id="answerButton" disabled>Answer</button>
<h2>4. Hangup</h2>
<button id="hangupButton" disabled>Hangup</button>
<script type="module" src="/main.js"></script>
</body>
</html>
Create style.css and add the following:
body {
--text-color: #2c3e50;
--video-background-color: #2c3e50;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: var(--text-color);
margin: 80px 10px;
}
video {
width: 40vw;
height: 30vw;
margin: 2rem;
background: var(--video-background-color);
}
.videos {
display: flex;
align-items: center;
justify-content: center;
}
Create main.js and add the following:
import "./style.css";
import PocketBase from "pocketbase";
const pb = new PocketBase("http://127.0.0.1:8090");
const calls = pb.collection("calls");
const offerCandidates = pb.collection("offer_candidates");
const answerCandidates = pb.collection("answer_candidates");
const webcamButton = document.getElementById("webcamButton");
const webcamVideo = document.getElementById("webcamVideo");
const callButton = document.getElementById("callButton");
const callInput = document.getElementById("callInput");
const answerButton = document.getElementById("answerButton");
const remoteVideo = document.getElementById("remoteVideo");
const hangupButton = document.getElementById("hangupButton");
const auth = await pb
.collection("users")
.authWithPassword(
import.meta.env.VITE_POCKETBASE_USERNAME,
import.meta.env.VITE_POCKETBASE_PASSWORD
);
const userId = auth.record.id;
const iceServers = await pb.collection("ice_servers").getFullList();
const servers = {
iceServers: [{ urls: iceServers.map((e) => e.url) }],
iceCandidatePoolSize: 10,
};
const pc = new RTCPeerConnection(servers);
let localStream = null;
let remoteStream = null;
webcamButton.onclick = async () => {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
remoteStream = new MediaStream();
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
pc.ontrack = (event) => {
const stream = event.streams[0];
stream.getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};
webcamVideo.srcObject = localStream;
remoteVideo.srcObject = remoteStream;
callButton.disabled = false;
answerButton.disabled = false;
webcamButton.disabled = true;
};
callButton.onclick = async () => {
const call = await calls.create({
user_id: userId,
});
const callId = call.id;
callInput.value = callId;
pc.onicecandidate = (event) => {
event.candidate &&
offerCandidates.create({
call_id: callId,
data: event.candidate.toJSON(),
});
};
const offerDescription = await pc.createOffer();
await pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await calls.update(callId, { offer });
calls.subscribe(callId, (e) => {
const data = e.record;
if (!pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
pc.setRemoteDescription(answerDescription);
}
});
answerCandidates.subscribe("*", (e) => {
if (e.action === "create") {
if (e.record?.call_id === callId) {
const data = e.record.data;
const candidate = new RTCIceCandidate(data);
pc.addIceCandidate(candidate);
}
}
});
hangupButton.disabled = false;
};
answerButton.onclick = async () => {
const callId = callInput.value;
const call = await calls.getOne(callId);
pc.onicecandidate = (event) => {
event.candidate &&
answerCandidates.create({
call_id: call.id,
data: event.candidate.toJSON(),
});
};
const offerDescription = call.offer;
const remoteDescription = new RTCSessionDescription(offerDescription);
await pc.setRemoteDescription(remoteDescription);
const answerDescription = await pc.createAnswer();
await pc.setLocalDescription(answerDescription);
const answer = {
type: answerDescription.type,
sdp: answerDescription.sdp,
};
await calls.update(call.id, { answer });
offerCandidates.subscribe("*", async (e) => {
if (e.record?.call_id === call.id) {
if (e.action === "create") {
const data = e.record.data;
const candidate = new RTCIceCandidate(data);
await pc.addIceCandidate(candidate);
} else if (e.action === "delete") {
await offerCandidates.unsubscribe();
window.location.reload();
}
}
});
};
hangupButton.onclick = async () => {
const callId = callInput.value;
pc.close();
await calls.unsubscribe(callId);
await calls.delete(callId);
await answerCandidates.unsubscribe();
window.location.reload();
};
Run the following command to start the client (make sure the server is running in a separate terminal client):
npm run dev
If successful you should see the following:
VITE v4.5.0 ready in 547 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help
Open up two browsers with the same url:
![]()
In the first window click Start webcam and then Create Call (offer).
This will ask for camera permission and then generate a new id and add it to the Join a Call text field.
![]()
Copy the new id and paste it in the second window field and click Start webcam.
![]()
Then click Hangup when you are done with the call 🎉.
You can find the source code here.
Weekly Installs
50
Repository
GitHub Stars
37
First Seen
Feb 4, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykFail
Installed on
opencode47
gemini-cli46
codex46
github-copilot44
kimi-cli41
amp41
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
127,000 周安装
antfu 编码规范与工具链配置指南:TypeScript、ESLint、pnpm 最佳实践
7,400 周安装
Agentation 开发工具安装指南:React 组件设置与 MCP 服务器配置教程
7,800 周安装
Better Auth 安全最佳实践指南 | 身份验证安全技能与代码示例
7,900 周安装
Azure Cosmos DB NoSQL 数据建模专家系统 - AI辅助数据库设计与优化
7,900 周安装
Java项目GraalVM原生镜像支持配置指南 - Maven/Gradle/Spring Boot集成
7,900 周安装
Go MCP 服务器项目生成器 - 快速构建生产级AI工具集成服务
7,900 周安装