mcpField
H
how-to-build-a-webrtc-signal-server-with-pocketbase

how-to-build-a-webrtc-signal-server-with-pocketbase

github.com/rodydavis开发6065 次安装2 周前更新
代码仓库

关于 how-to-build-a-webrtc-signal-server-with-pocketbase

这是一个使用 PocketBase 构建 WebRTC 信令服务的示例教程,面向希望实现浏览器间视频通话的开发者。内容包括服务端集合配置、Vite 客户端搭建及双浏览器通话流程。

主要功能

  1. 01使用 PocketBase 作为信令服务
  2. 02通过 SQLite 保存通话相关数据
  3. 03配置 ICE 服务器与候选数据集合
  4. 04使用 Vite 和 PocketBase SDK 搭建客户端

适用场景

  1. 01搭建 WebRTC 浏览器视频通话演示
  2. 02保存 offer、answer 与 ICE 候选数据
  3. 03通过两个浏览器测试加入和结束通话

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rodydavis/skills --skill how-to-build-a-webrtc-signal-server-with-pocketbase

安装后在 Claude Code 中输入/how-to-build-a-webrtc-signal-server-with-pocketbase确认已加载。

详细介绍

如何使用 PocketBase 构建 WebRTC 信令服务器

概览

如果你刚开始接触 WebRTC,建议观看这段精彩的 Fireship 视频:100 秒了解 WebRTC:

另外,如果你想看一个 Firebase 示例,可以查看这个仓库,本示例很大程度上基于该仓库。

本示例使用 PocketBase 作为 WebRTC 的信令服务器,在服务器上运行 SQLite,并基于 服务器发送事件(SSE)构建易于使用的实时 SDK。

设置服务器

下载 PocketBase 并创建一个新目录,用作项目目录。

mkdir webrtc-pocketbase-demo
cd webrtc-pocketbase-demo

将 PocketBase 二进制文件复制到刚创建的目录下的子目录 .pb 中。如果你使用 MacOS,需要在设置中允许该可执行文件运行。

使用以下命令启动 PocketBase 服务器:

.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/_/

打开管理界面的 URL,并创建新的用户名和密码。

在本示例中,邮箱和密码如下:

键值
邮箱[email protected]
密码Test123456789

现在你应该会看到以下内容:

README 配图

创建集合
ice_servers

创建一个名为 ice_servers 的新集合,并添加以下列:

列名列类型
url纯文本

README 配图

将以下 API 规则添加到 List/Search 和 View:

@request.auth.id != ''

README 配图

创建集合后,为以下每个 url 值添加 2 条记录:

stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302

README 配图

calls

创建一个名为 calls 的新集合,并添加以下列:

列名列类型列设置
user_id关联非空,users,级联删除为 true
offerJSON
answerJSON

README 配图

也可以通过为 user_id 列设置唯一约束,将每个用户的通话限制为一次。

README 配图

将以下 API 规则添加到所有方法:

@request.auth.id != ''

README 配图

offer_candidates

创建一个名为 offer_candidates 的新集合,并添加以下列:

列名列类型列设置
call_id关联非空,calls,级联删除为 true
dataJSON

README 配图

将以下 API 规则添加到所有方法:

@request.auth.id != ''
答案候选项

创建一个名为 answer_candidates 的新集合,并添加以下列:

列名

列类型

列设置

call_id

关联

非空,calls,级联删除为 true

data

JSON

README 配图

为所有方法添加以下 API 规则:

@request.auth.id != ''

README 配图

用户

出于演示目的,我们不会为用户添加身份验证表单。为了简化示例,请使用与管理员相同的登录信息创建一个新用户。

README 配图

README 配图

设置客户端

进入该目录并运行以下命令以开始:

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
HTML

创建 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. 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>
CSS

创建 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;
}
JS

创建 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 打开两个浏览器:

README 配图

在第一个窗口中,点击 Start webcam,然后点击 Create Call (offer)。

这会请求摄像头权限,然后生成一个新 ID,并将其添加到 Join a Call 文本框中。

README 配图

复制新 ID,将其粘贴到第二个窗口的文本框中,然后点击 Start webcam。

README 配图

通话结束后,点击 Hangup 🎉。

总结

你可以在这里找到源代码。

评论

支持 Markdown