如何使用 NestJS 作为 WebRTC 视频聊天的信令服务器

在本文中,我们将使用 WebRTC(用于浏览器直接通信)和 NestJS(作为信令服务器)构建一个点对点视频聊天应用程序。您将了解浏览器如何建立直接连接以及信令服务器在此过程中的作用。

本视频聊天应用程序将有三个主要特点:

  • 两个浏览器将直接连接,无需通过服务器传递视频数据。
  • NestJS(我们的信令服务器)只帮助浏览器找到对方并建立连接。
  • 不会使用插件或外部 API。

为什么选择 WebRTC?

WebRTC 是一个免费的开源项目,旨在促进浏览器之间的直接通信。这消除了对中间服务器的需求,从而实现了更快、更经济的流程。它基于 Web 标准,并与 JavaScript 无缝集成。它可以管理视频、音频和其他形式的数据。

此外,它还提供内置工具,帮助用户跨各种网络连接。这些功能使其成为实时Web应用程序的理想选择。

下图展示了如何创建视频聊天的流程。

创建视频聊天的流程

连接过程可分为四个阶段:

  • 呼叫发起:呼叫者首先向信令服务器发送 WebRTC 请求。然后,服务器通过广播此请求来提醒潜在的接收者。
  • 通话接听:当被叫方选择接听时,他们会通过服务器发送 WebRTC 应答。服务器通过通知原始呼叫者(“他们已接听”)完成握手。
  • 网络准备:然后,两个设备都与服务器共享其网络详细信息,服务器再将其转发给对方。
  • 直接连接:一旦交换了足够的网络详细信息,WebRTC 就会建立点对点连接,信令服务器的工作就完成了。

为什么需要信令服务器?

信令是建立对等连接所必需的,因为它允许共享会话详细信息,例如请求、应答和网络元数据。如果没有信令,浏览器将无法相互定位或协商所需的协议,因为 WebRTC 本身不提供消息传递功能。使用 NestJS,可以通过 WebSocket 安全地管理信令,从而确保所有通信都经过加密。信令很容易被忽视,但它对于实现点对点交互至关重要。

项目设置

这就是我们项目的文件夹结构。

如何使用 NestJS 作为 WebRTC 视频聊天的信令服务器

后端设置

在终端中运行以下命令来设置 NestJS 项目:

nest new signaling-server
cd signaling-server

安装项目的依赖项:

npm install @nestjs/websockets @nestjs/platform-socket.io socket.io

如上面的项目骨架图所示,创建一个带有 signaling.gateway.ts 文件和 offer.interface.ts 文件的信令模块。

WebRTC 需要 HTTPS

WebRTC 需要 HTTPS 来安全地访问摄像头、麦克风和直接对等连接。在开发过程中,由于我们处于受控环境中,因此可以绕过安全阻止。为了实现这一点,我们使用了 mkcert(一款用于创建 SSL 证书的工具)。我们将前端和后端配置为使用这些证书,这样我们就可以安全地进行测试,而无需承担 HTTPS 的全部开销。

要连接笔记本电脑和手机,我们将使用本地 IP 地址,两台设备应连接到同一个 WiFi 网络。

现在,运行以下命令生成证书文件。

npm install -g mkcert
mkcert create-ca
mkcert create-cert

接下来,用以下内容更新 main.ts 文件:(TypeScript)

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import * as fs from "fs";
import { IoAdapter } from "@nestjs/platform-socket.io";

async function bootstrap() {
  const httpsOptions = {
    key: fs.readFileSync("./cert.key"),
    cert: fs.readFileSync("./cert.crt"),
  };

  const app = await NestFactory.create(AppModule, { httpsOptions });
  app.useWebSocketAdapter(new IoAdapter(app));

  // Replace with your local IP (e.g., 192.168.1.10)
  const localIp = "YOUR-LOCAL-IP-ADDRESS";
  app.enableCors({
    origin: [`https://${localIp}:3000`, "https://localhost:3000"],
    credentials: true,
  });

  await app.listen(8181);
  console.log(`Signaling server running on https://${localIp}:8181`);
}

bootstrap();

在上面的代码中,我们使用 HTTPS 和 WebSockets 设置了信令服务器。首先,我们使用 cert.keycert.crt 文件定义一个 httpsOptions 对象,在 NestFactory.create 方法中创建应用程序时使用该对象。接下来,我们用 IoAdapter 配置应用程序,它允许通过 Socket.IO 支持 WebSocket 通信。

为了只允许来自特定来源的前端客户端访问我们的后台,我们启用了 CORS,并支持自定义来源设置和凭据。最后,我们在端口 8181 上启动服务器,并记录一条信息以确认服务器正在运行,并已准备好处理安全的实时通信。

前端设置

设置前端,运行以下命令:

cd .. && mkdir webrtc-client && cd webrtc-client && touch index.html scripts.js styles.css socketListeners.js package.json

然后,将 NestJS 项目中的 cert.keycert.crt 文件复制到 webrtc-client 文件夹中。

后台逻辑

用以下代码更新 offer.interface.ts 文件:

export interface ConnectedSocket {
  socketId: string;
  userName: string;
}

export interface Offer {
  offererUserName: string;
  offer: any;
  offerIceCandidates: any[];
  answererUserName: string | null;
  answer: any | null;
  answererIceCandidates: any[];
  socketId: string;
  answererSocketId?: string;
}

signaling.gateway.ts文件监听 WebRTC 事件并连接对等点,同时管理会话和候选人的状态,从而无需中断媒体流即可提供有效的协调。

接下来设置信令网关的核心,然后再介绍必要的方法。

import {
  WebSocketGateway,
  WebSocketServer,
  OnGatewayConnection,
  OnGatewayDisconnect,
  SubscribeMessage,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { Offer, ConnectedSocket } from "./interfaces/offer.interface";

@WebSocketGateway({
  cors: {
    origin: ["https://localhost:3000", "https://YOUR-LOCAL-IP-ADDRESS:3000"],
    methods: ["GET", "POST"],
    credentials: true,
  },
})
export class SignalingGateway
  implements OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer() server: Server;
  private offers: Offer[] = [];
  private connectedSockets: ConnectedSocket[] = [];
}

@WebSocketGateway 器包含限制访问特定客户端来源的 CORS 设置。将凭证设置为 true 时,cookie、授权标头或 TLS 客户端证书可随请求一起发送。

SignalingGateway 类通过实现 OnGatewayConnectionOnGatewayDisconnect 自动处理客户端连接和断开连接。

在该类中,@WebSocketServer() 提供了对活动 Socket.IO 服务器实例的访问,而 offers 数组存储了 WebRTC offer 对象,其中包括会话描述和 ICE candidates。

connectedSockets 数组维护着已连接用户的列表,该列表由用户的套接字 ID 和用户名标识,以便服务器正确引导信令消息。

什么是 ICE Candidates?

ICE(交互式连接建立)candidates 是指一些网络信息(如 IP 地址、端口和协议),可帮助 WebRTC 对等方找到建立直接对等连接的最有效方式。它们在 offer/answer 协商后进行交换,对于穿越 NAT 和防火墙至关重要。没有它们,WebRTC 通信可能会因网络障碍而失败。

接下来,我们将实现 handleConnectionhandleDisconnect 方法,以验证用户身份,在内存中注册用户,并在用户断开连接时干净利落地删除其数据。

用以下内容更新 signaling.gateway.ts 文件:

// Connection handler
handleConnection(socket: Socket) {
  const userName = socket.handshake.auth.userName;
  const password = socket.handshake.auth.password;

  if (password !== 'x') {
    socket.disconnect(true);
    return;
  }

  this.connectedSockets.push({ socketId: socket.id, userName });
  if (this.offers.length) socket.emit('availableOffers', this.offers);
}
// Disconnection handler
handleDisconnect(socket: Socket) {
  this.connectedSockets = this.connectedSockets.filter(
    (s) => s.socketId !== socket.id,
  );
  this.offers = this.offers.filter((o) => o.socketId !== socket.id);
}

handleConnection方法从客户端的身份验证数据中获取userNamepassword。如果密码不正确,则连接终止;如果密码正确,则用户的socketIduserName将被添加到connectedSockets数组中。

如果有尚未处理的提议,服务器会通过 availableOffers 事件将其发送给新连接的用户。

handleDisconnect 方法会将断开连接的套接字从 connectedSockets 数组和 offers 列表中删除。这种清理可防止陈旧数据的积累,并只保留活动连接。

过滤逻辑会保留所有与断开连接的 socketId不匹配的条目。

接下来,我们将实现处理特定 WebSocket 事件的方法:offer、answeres 和 ICE candidates,这些对于在 WebRTC 中创建点对点连接至关重要。

用以下内容更新 signaling.gateway.ts 文件:

// New offer handler
@SubscribeMessage('newOffer')
handleNewOffer(socket: Socket, newOffer: any) {
  const userName = socket.handshake.auth.userName;
  const newOfferEntry: Offer = {
    offererUserName: userName,
    offer: newOffer,
    offerIceCandidates: [],
    answererUserName: null,
    answer: null,
    answererIceCandidates: [],
    socketId: socket.id,
  };

  this.offers = this.offers.filter((o) => o.offererUserName !== userName);
  this.offers.push(newOfferEntry);
  socket.broadcast.emit('newOfferAwaiting', [newOfferEntry]);
}
// Answer handler with ICE candidate acknowledgment
@SubscribeMessage('newAnswer')
async handleNewAnswer(socket: Socket, offerObj: any) {
  const userName = socket.handshake.auth.userName;
  const offerToUpdate = this.offers.find(
    (o) => o.offererUserName === offerObj.offererUserName,
  );

  if (!offerToUpdate) return;

  // Send existing ICE candidates to answerer
  socket.emit('existingIceCandidates', offerToUpdate.offerIceCandidates);

  // Update offer with answer information
  offerToUpdate.answer = offerObj.answer;
  offerToUpdate.answererUserName = userName;
  offerToUpdate.answererSocketId = socket.id;

  // Notify both parties
  this.server
    .to(offerToUpdate.socketId)
    .emit('answerResponse', offerToUpdate);
  socket.emit('answerConfirmation', offerToUpdate);
}
// ICE candidate handler with storage
@SubscribeMessage('sendIceCandidateToSignalingServer')
handleIceCandidate(socket: Socket, iceCandidateObj: any) {
  const { didIOffer, iceUserName, iceCandidate } = iceCandidateObj;

  // Store candidate in the offer object
  const offer = this.offers.find((o) =>
    didIOffer
      ? o.offererUserName === iceUserName
      : o.answererUserName === iceUserName,
  );

  if (offer) {
    if (didIOffer) {
      offer.offerIceCandidates.push(iceCandidate);
    } else {
      offer.answererIceCandidates.push(iceCandidate);
    }
  }

  // Forward candidate to other peer
  const targetUserName = didIOffer
    ? offer?.answererUserName
    : offer?.offererUserName;
  const targetSocket = this.connectedSockets.find(
    (s) => s.userName === targetUserName,
  );

  if (targetSocket) {
    this.server
      .to(targetSocket.socketId)
      .emit('receivedIceCandidateFromServer', iceCandidate);
  }
}

Offer Handler

该方法处理从呼叫方传入的 WebRTC offer,创建一个包含呼叫方用户名、会话描述和空 ICE 候选数组的新 offer 对象。服务器会删除来自同一用户的任何现有 offer,以防止重复,然后存储新 offer 并将其广播给所有已连接的客户端,供潜在来电者 answer 。

Answer Handler

服务器在收到对 offer 的 answer 后,会查找原始 offer。它将主叫方现有的 ICE Candidate 对象发送给应答客户端,以加快连接速度。服务器会用 answer 详情更新 offer 对象,包括被叫方的用户名和socket ID。双方都会收到通知:原呼叫方收到 answer,应答客户端得到确认。

ICE Candidate Handler

该方法处理来自对等方的 ICE Candidate。它使用 didIOffer 标志确定每个 Candidate 是来自 offer 方还是来自 answer 方,并将其存储在发价对象中的相应数组中。服务器会通过查找每个 Candidate 的socket ID 将其转发给相应的对等方,直到对等方建立起直接连接。

然后运行此命令启动服务器:

npm run start:dev

前端逻辑

使用以下内容更新您的 Index.html 文件:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <title>WebRTC with NestJS Signaling</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
    <script>
      // Request camera permission immediately
      document.addEventListener("DOMContentLoaded", async () => {
        try {
          const stream = await navigator.mediaDevices.getUserMedia({
            video: { facingMode: "user" }, // Front camera on mobile
            audio: false,
          });
          stream.getTracks().forEach((track) => track.stop());
        } catch (err) {
          console.log("Pre-permission error:", err);
        }
      });
    </script>
  </head>
  <body>
    <div class="container">
      <div class="row mb-3 mt-3 justify-content-md-center">
        <div id="user-name" class="col-12 text-center mb-2"></div>
        <button id="call" class="btn btn-primary col-3">Start Call</button>
        <div id="answer" class="col-6"></div>
      </div>
      <div id="videos">
        <div id="video-wrapper">
          <div id="waiting">Waiting for answer...</div>
          <video
            class="video-player"
            id="local-video"
            autoplay
            playsinline
            muted
          ></video>
        </div>
        <video
          class="video-player"
          id="remote-video"
          autoplay
          playsinline
        ></video>
      </div>
    </div>
    <!-- Socket.io client library -->
    <script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
    <script src="scripts.js"></script>
    <script src="socketListeners.js"></script>
  </body>
</html>

然后使用以下内容更新您的 styles.css 文件:

#videos {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2em;
}
.video-player {
  background-color: black;
  width: 100%;
  height: 300px;
  border-radius: 8px;
}
#video-wrapper {
  position: relative;
}
#waiting {
  display: none;
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  margin: auto;
  width: 200px;
  height: 40px;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  text-align: center;
  line-height: 40px;
  border-radius: 5px;
}
#answer {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}
#user-name {
  font-weight: bold;
  font-size: 1.2em;
}

把 script.js 文件的代码分为两部分:初始化和设置以及核心功能和事件监听器。

用初始化和设置的代码更新 script.js 文件:

const userName = "User-" + Math.floor(Math.random() * 1000);
const password = "x";
document.querySelector("#user-name").textContent = userName;
const localIp = "YOUR-LOCAL-IP-ADDRESS";
const socket = io(`https://${localIp}:8181`, {
  auth: { userName, password },
  transports: ["websocket"],
  secure: true,
  rejectUnauthorized: false,
});
// DOM Elements
const localVideoEl = document.querySelector("#local-video");
const remoteVideoEl = document.querySelector("#remote-video");
const waitingEl = document.querySelector("#waiting");
// WebRTC Configuration
const peerConfiguration = {
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
  iceTransportPolicy: "all",
};
// WebRTC Variables
let localStream;
let remoteStream;
let peerConnection;
let didIOffer = false;

该代码创建与 NestJS 信令服务器的安全 WebSocket 连接。WebRTC 配置包含用于网络遍历的基本 ICE 服务器,旨在实现最大程度的连接。它还初始化变量以管理媒体流并跟踪活动的对等连接。

现在,让我们添加核心功能和事件监听器:

// Core Functions
const startCall = async () => {
  try {
    await getLocalStream();
    await createPeerConnection();
    const offer = await peerConnection.createOffer();
    await peerConnection.setLocalDescription(offer);
    didIOffer = true;
    socket.emit("newOffer", offer);
    waitingEl.style.display = "block";
  } catch (err) {
    console.error("Call error:", err);
  }
};
const answerCall = async (offerObj) => {
  try {
    await getLocalStream();
    await createPeerConnection(offerObj);
    const answer = await peerConnection.createAnswer();
    await peerConnection.setLocalDescription(answer);
    // Get existing ICE candidates from server
    const offerIceCandidates = await new Promise((resolve) => {
      socket.emit(
        "newAnswer",
        {
          ...offerObj,
          answer,
          answererUserName: userName,
        },
        resolve
      );
    });
    // Add pre-existing ICE candidates
    offerIceCandidates.forEach((c) => {
      peerConnection
        .addIceCandidate(c)
        .catch((err) => console.error("Error adding ICE candidate:", err));
    });
  } catch (err) {
    console.error("Answer error:", err);
  }
};
const getLocalStream = async () => {
  const constraints = {
    video: {
      facingMode: "user",
      width: { ideal: 1280 },
      height: { ideal: 720 },
    },
    audio: false,
  };
  try {
    localStream = await navigator.mediaDevices.getUserMedia(constraints);
    localVideoEl.srcObject = localStream;
    localVideoEl.play().catch((e) => console.log("Video play error:", e));
  } catch (err) {
    alert("Camera error: " + err.message);
    throw err;
  }
};
const createPeerConnection = async (offerObj) => {
  peerConnection = new RTCPeerConnection(peerConfiguration);
  remoteStream = new MediaStream();
  remoteVideoEl.srcObject = remoteStream;
  // Add local tracks
  localStream.getTracks().forEach((track) => {
    peerConnection.addTrack(track, localStream);
  });
  // ICE Candidate handling
  peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      socket.emit("sendIceCandidateToSignalingServer", {
        iceCandidate: event.candidate,
        iceUserName: userName,
        didIOffer,
      });
    }
  };
  // Track handling
  peerConnection.ontrack = (event) => {
    event.streams[0].getTracks().forEach((track) => {
      if (!remoteStream.getTracks().some((t) => t.id === track.id)) {
        remoteStream.addTrack(track);
      }
    });
    waitingEl.style.display = "none";
  };
  // Connection state handling
  peerConnection.onconnectionstatechange = () => {
    console.log("Connection state:", peerConnection.connectionState);
    if (peerConnection.connectionState === "failed") {
      alert("Connection failed! Please try again.");
    }
  };
  // Set remote description if answering
  if (offerObj) {
    await peerConnection
      .setRemoteDescription(offerObj.offer)
      .catch((err) => console.error("setRemoteDescription error:", err));
  }
};
// Event Listeners
document.querySelector("#call").addEventListener("click", startCall);

此部分管理整个 WebRTC 呼叫流程。它建立对等连接,创建会话描述,并与信令服务器协作共享 offer/answer SDP 数据包。

应答过程会同步 ICE candidate 节点,以交换网络路径详细信息。它还会跟踪新增节点、生成 ICE candidate 节点、监控连接状态并更新用户界面。

接下来,将以下内容添加到您的 socketListeners.js 文件中:

// Handle available offers
socket.on("availableOffers", (offers) => {
  console.log("Received available offers:", offers);
  createOfferElements(offers);
});
// Handle new incoming offers
socket.on("newOfferAwaiting", (offers) => {
  console.log("Received new offers awaiting:", offers);
  createOfferElements(offers);
});
// Handle answer responses
socket.on("answerResponse", (offerObj) => {
  console.log("Received answer response:", offerObj);
  peerConnection
    .setRemoteDescription(offerObj.answer)
    .catch((err) => console.error("setRemoteDescription failed:", err));
  waitingEl.style.display = "none";
});
// Handle ICE candidates
socket.on("receivedIceCandidateFromServer", (iceCandidate) => {
  console.log("Received ICE candidate:", iceCandidate);
  peerConnection
    .addIceCandidate(iceCandidate)
    .catch((err) => console.error("Error adding ICE candidate:", err));
});
// Handle existing ICE candidates
socket.on("existingIceCandidates", (candidates) => {
  console.log("Receiving existing ICE candidates:", candidates);
  candidates.forEach((c) => {
    peerConnection
      .addIceCandidate(c)
      .catch((err) =>
        console.error("Error adding existing ICE candidate:", err)
      );
  });
});
// Helper function to create offer buttons
function createOfferElements(offers) {
  const answerEl = document.querySelector("#answer");
  answerEl.innerHTML = ""; // Clear existing buttons
  offers.forEach((offer) => {
    const button = document.createElement("button");
    button.className = "btn btn-success";
    button.textContent = `Answer ${offer.offererUserName}`;
    button.onclick = () => answerCall(offer);
    answerEl.appendChild(button);
  });
}

该文件使用 Socket.IO 事件处理 WebRTC 信令流程的客户端。它监听来电请求(“availableOffers” 和 “newOfferAwaiting”),并动态生成“Answer”按钮,允许用户响应并建立连接。

当收到答复(“answerResponse”)时,设置远程对等会话描述并隐藏等待指示器。

ICE candidates 的处理分为两部分:

  • 通过“receivedIceCandidateFromServer”获取实时candidates 
  • 之前由“existingIceCandidates”交换的candidates 

两者都添加到当前的 RTCPeerConnection,并包含错误处理。

最后,使用以下内容更新您的package.json文件:

{
  "name": "webrtc-client",
  "version": "1.0.0",
  "scripts": {
    "start": "http-server -S -C cert.crt -K cert.key -p 3000"
  },
  "dependencies": {
    "http-server": "^14.1.1"
  }
}

然后安装并运行:

npm install
npm start

测试

在两个浏览器上,打开 https://YOUR-LOCAL-IP-ADDRESS:3000,然后在一个浏览器上发起呼叫,在另一个浏览器上接听。

如何使用 NestJS 作为 WebRTC 视频聊天的信令服务器

确保摄像头正常工作,方法是允许浏览器访问摄像头,并确认您使用的是 HTTPS。某些网络可能会阻止 STUN 服务器,从而阻止直接的点对点连接。如果发生这种情况,您可能需要部署 TURN 服务器进行连接。如果两端的 socket ID 不一致,可能会导致信令问题,因此请在故障排除时检查这一点。

结论

我们创建了一个用于点对点视频通话的基本信令服务器和客户端,涵盖了诸如 offer/answer 协商和 ICE candidate 交换等关键功能。以此展示了 WebRTC 的基本概念,以及信令服务器如何在不处理媒体流的情况下建立直接连接,从而优化性能和隐私。为了改进应用,您可以考虑通过 WebRTC 数据通道添加文本聊天功能并启用屏幕共享功能。

作者:Chris Nwamba

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/60322.html

(0)

相关推荐

发表回复

登录后才能评论