Skip to content

Bun + WebRTC 实现一个直播平台

Posted on:2024年12月10日 at 18:39

近日。学习 BUN 中,突发奇想,如何实现一个直播平台?

image

0. BUN 的安装

安装 BUN

1. 初始化项目

bun init

2. 实现 serve 信令服务器

index.ts

import Bun from "bun";
import type { ServerWebSocket } from "bun";

type MessageKeys =
  | "join"
  | "create"
  | "offer"
  | "answer"
  | "icecandidate"
  | "error"
  | "success"
  | "leave"
  | "close"
  | "joined"
  | "danmaku"
  | "updateRooms";

class Share {
  constructor() {}

  port = 3000;

  messageHandlers: Partial<
    Record<
      MessageKeys,
      (ws: ServerWebSocket<string>, payload: Record<string, any>) => void
    >
  > = {
    join: (ws, payload) => {
      // 有用户加入房间
      const { roomId, userId, username } = payload;

      this.users.set(userId, {
        ws,
        name: username,
        roomId,
      });

      if (!roomId) return;

      const room = this.rooms.get(roomId);

      if (!room) {
        this.sendMessage(ws, "error", {
          message: "房间不存在或已关闭",
        });
        return;
      }

      this.users.set(userId, {
        ws,
        name: username,
        roomId,
      });

      room.clients.push(ws);

      this.sendMessage(room.host, "joined", {
        userId,
        username,
      });

      this.sendMessage(ws, "success", {
        message: `加入房间 ${room.name} 成功`,
      });
    },
    create: (ws, payload) => {
      const { roomId, roomName, cover } = payload;
      this.rooms.set(roomId, {
        host: ws,
        name: roomName,
        cover,
        clients: [],
      });

      // 通知所有用户 有新房间
      for (const [userId, user] of this.users) {
        const { ws: userWs } = user;
        this.sendMessage(userWs, "updateRooms", {
          roomId,
          type: "create",
        });
      }
    },
    offer: (ws, payload) => {
      const { offer, userId, roomId } = payload;
      const user = this.users.get(userId);
      if (!user) {
        this.sendMessage(ws, "error", {
          message: "用户不存在",
        });
        return;
      }
      const { ws: userWs } = user;
      this.sendMessage(userWs, "offer", {
        offer,
        userId,
        roomId,
      });
    },
    answer: (ws, payload) => {
      const { answer, userId, roomId } = payload;
      const room = this.rooms.get(roomId);
      if (!room) {
        this.sendMessage(ws, "error", {
          message: "房间不存在",
        });
        return;
      }
      this.sendMessage(room.host, "answer", {
        answer,
        userId,
        roomId,
      });
    },
    icecandidate: (ws, payload) => {
      const { candidate, userId, roomId } = payload;
      const user = this.users.get(userId);
      if (!user) {
        this.sendMessage(ws, "error", {
          message: "用户不存在",
        });
        return;
      }
      const { ws: userWs } = user;
      this.sendMessage(userWs, "icecandidate", {
        candidate,
        userId,
        roomId,
      });
    },
    danmaku: (ws, payload) => {
      const { roomId, admin, message, username, userId } = payload;
      const room = this.rooms.get(roomId);
      if (!room) {
        this.sendMessage(ws, "error", {
          message: "房间不存在或已关闭",
        });
        return;
      }
      const vo = {
        admin,
        message,
        username,
        userId,
      };
      for (const client of room.clients) {
        this.sendMessage(client, "danmaku", vo);
      }
      const { host } = room;
      this.sendMessage(host, "danmaku", vo);
    },
  };

  rooms = new Map<
    string,
    {
      host: ServerWebSocket<string>;
      name: string;
      cover: string;
      clients: ServerWebSocket<string>[];
    }
  >(); // 房间
  users = new Map<
    string,
    {
      name: string;
      ws: ServerWebSocket<string>;
      roomId: string;
    }
  >(); // 用户

  get roomData() {
    const data = [];
    for (const [roomId, room] of this.rooms) {
      data.push({
        id: roomId,
        name: room.name,
        cover: room.cover,
      });
    }
    return data;
  }

  sendMessage(
    ws: ServerWebSocket<string>,
    type: MessageKeys,
    data: Record<string, any>
  ) {
    ws.send(
      JSON.stringify({
        type,
        data,
      })
    );
  }

  async start() {
    Bun.serve<string>({
      port: this.port,
      fetch: async (request, server) => {
        const path = new URL(request.url).pathname;
        if (path === "/") {
          const file = Bun.file("./src/index.html");
          const exists = await file.exists();
          if (exists) return new Response(file);
        }
        if (path === "/ws") {
          const success = server.upgrade(request);
          if (success) return new Response("Upgrading...");
        }
        if (path.startsWith("/src/")) {
          const filePath = path.replace("/src/", "./src/");
          const file = Bun.file(filePath);
          const exists = await file.exists();
          if (exists) return new Response(file);
        } else if (path.startsWith("/api/")) {
          if (path === "/api/rooms") {
            return new Response(JSON.stringify(this.roomData));
          }
        }
        return new Response(path);
      },
      websocket: {
        open() {},
        close: (ws, code, reason) => {
          // 用户离开 或者 关闭房间
          // 判断是用户离开还是关闭房间
          // 1. 如果从用户列表中找到用户,说明是用户离开 则需要通知房间内的其他用户 和 房主
          // 2. 如果从房间列表中找到房间,说明是房主关闭房间,则需要通知房间内的其他用户
          const user = Array.from(this.users).find(
            ([userId, user]) => user.ws === ws
          );
          if (user) {
            const [userId, { ws, roomId, name }] = user;
            const room = this.rooms.get(roomId);
            if (room) {
              this.sendMessage(room.host, "leave", {
                userId,
                username: name,
                message: `用户 ${name}(${userId}) 离开了房间`,
              });
              room.clients = room.clients.filter(client => client !== ws);
            }
            this.users.delete(userId);
            return;
          }

          const room = Array.from(this.rooms).find(
            ([roomId, room]) => room.host === ws
          );
          if (room) {
            const [roomId, { name, clients }] = room;
            for (const client of clients) {
              this.sendMessage(client, "close", {
                roomId,
                roomName: name,
                message: `房间 ${name}(${roomId}) 已关闭`,
              });
            }
            this.rooms.delete(roomId);

            // 通知所有用户 有房间关闭
            for (const [userId, user] of this.users) {
              const { ws: userWs } = user;
              this.sendMessage(userWs, "updateRooms", {
                roomId,
                type: "close",
              });
            }
          }
        },
        message: (ws, message: string) => {
          const payload: {
            type: MessageKeys;
            data: Record<string, any>;
          } = JSON.parse(message);
          const { type, data } = payload;

          const handler = this.messageHandlers[type];
          handler?.(ws, data);
        },
      },
    });
    console.log(`bun server is running at http://localhost:${this.port}`);
  }
}

const share = new Share();
await share.start();

3. 实现主播页面

根目录下新建 src 文件夹;src 文件夹下新建 main.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      html,
      body {
        margin: 0;
        padding: 0;
        font-family: Arial, sans-serif;
        height: 100vh;
        width: 100vw;
        overflow: hidden;
      }

      .container {
        width: 100%;
        height: 100%;
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }

      .header {
        display: flex;
        justify-content: space-between;
        padding: 10px;
        border-bottom: 1px solid #ccc;
        align-items: center;
      }

      .header h1 {
        margin: 0;
      }

      .header-main {
        flex: 1;
      }

      .header-main button {
        padding: 5px 10px;
        border: 1px solid #ccc;
        border-radius: 5px;
      }

      .main {
        margin-top: 10px;
        flex: 1;
        display: flex;
        overflow: hidden;
      }

      #preview {
        flex: 1;
        border: 1px solid #ccc;
        overflow: hidden;
      }

      .message {
        width: 300px;
        border: 1px solid #ccc;
        margin: 0 0 0 10px;
        padding: 0;
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }

      .message-list {
        padding: 0 0 0 24px;
        margin: 0;
        flex: 1;
        overflow-y: auto;
      }

      #danmaku {
        display: flex;
        padding: 10px;
        border-top: 1px solid #ccc;
      }

      #danmaku input {
        flex: 1;
        padding: 5px;
        border: 1px solid #ccc;
        border-radius: 5px;
      }

      #danmaku button {
        padding: 5px 10px;
        border: 1px solid #ccc;
        border-radius: 5px;
        margin-left: 10px;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="header">
        <h1 id="title"></h1>
        <div class="header-main">
          <button id="button">开始共享</button>
          <button id="share-link">分享房间</button>
        </div>
      </div>
      <div class="main">
        <video id="preview"></video>
        <div class="message">
          <ul class="message-list"></ul>
          <form id="danmaku">
            <input type="text" placeholder="请输入消息" />
            <button type="submit">发送</button>
          </form>
        </div>
      </div>
    </div>
  </body>
  <script>
    class Main {
      constructor() {
        const params = new URLSearchParams(location.search);
        const url = new URL(location.href);
        this.preview = document.getElementById("preview");
        this.socketUrl = `ws://${url.host}/ws`;
        this.title = document.getElementById("title");
        this.button = document.getElementById("button");
        this.shareButton = document.getElementById("share-link");
        this.messageContainer = document.querySelector(".message-list");
        this.socket = null;
        this.roomId = params.get("id") || this.generateClientId();
        this.roomName = params.get("name") || "";
        this.isSharing = false;
        this.stream = null;
        this.peers = new Map();
        this.cover = "";
        this.danmaku = document.getElementById("danmaku");
      }

      start() {
        this.inputRoomName();
        this.registerEvent();
      }

      messageHandler = {
        joined: async data => {
          console.log("join");
          const li = document.createElement("li");
          li.textContent = `欢迎 ${data.username}(${data.userId}) 加入房间`;
          this.setMessage(li);

          // 给新加入的用户发送offer
          const peer = new RTCPeerConnection();
          this.stream
            .getTracks()
            .forEach(track => peer.addTrack(track, this.stream));
          peer.onicecandidate = e => {
            if (e.candidate) {
              this.sendMessage("icecandidate", {
                candidate: e.candidate,
                userId: data.userId,
                roomId: this.roomId,
              });
            }
          };

          const offer = await peer.createOffer();
          await peer.setLocalDescription(offer);
          this.sendMessage("offer", {
            offer,
            userId: data.userId,
            roomId: this.roomId,
          });

          this.peers.set(data.userId, peer);
        },
        leave: data => {
          console.log("leave");
          const li = document.createElement("li");
          li.classList.add("leave");
          li.textContent = `${data.username}(${data.userId}) 离开房间`;
          this.setMessage(li);
        },
        answer: async data => {
          console.log("answer");
          const { answer, userId } = data;
          const peer = this.peers.get(userId);
          await peer.setRemoteDescription(answer);
        },
        icecandidate: data => {
          console.log("icecandidate");
          const { candidate, userId } = data;
          const peer = this.peers.get(userId);
          peer.addIceCandidate(new RTCIceCandidate(candidate));
        },
        danmaku: data => {
          const li = document.createElement("li");
          const possessor = document.createElement("label");
          const { roomId, admin, message, username, userId } = data;
          let part = "";
          if (admin) part = "";
          else part = `${username}(${userId})`;
          possessor.textContent = `${part}说:`;
          const content = document.createElement("span");
          content.textContent = message;
          li.appendChild(possessor);
          li.appendChild(content);
          this.setMessage(li);
        },
      };

      sendMessage(type, data) {
        this.socket.send(
          JSON.stringify({
            type,
            data,
          })
        );
      }

      generateClientId() {
        return Math.random().toString().substring(2, 9);
      }

      inputRoomName() {
        while (!this.roomName) {
          this.roomName = prompt("请输入");
        }
        this.title.textContent = `hi, ${this.roomName}(${this.roomId})`;
        const params = new URLSearchParams({
          id: this.roomId,
          name: this.roomName,
        });
        history.pushState(null, "", `?${params}`);
      }

      registerEvent() {
        this.button.addEventListener("click", this.buttonClick.bind(this));
        this.danmaku.addEventListener("submit", this.danmakuSubmit.bind(this));
        this.shareButton.addEventListener("click", () => {
          const url = new URL(`${location.origin}/src/watch.html`);
          url.searchParams.set("id", this.roomId);
          url.searchParams.set("name", this.roomName);
          navigator.clipboard.writeText(url.href);
        });
      }

      async buttonClick() {
        this.isSharing ? await this.stopShare() : await this.startShare();
      }

      async stopShare() {
        if (!this.stream) return;
        this.stream.getTracks().forEach(track => track.stop());
        this.stream = null;
        await this.preview.pause();
        this.preview.srcObject = null;
        this.socket?.close();
        this.socket = null;
        this.peers.forEach(peer => peer.close());
        this.peers.clear();

        const li = document.createElement("li");
        li.textContent = `房间 ${this.roomName}(${this.roomId}) 已关闭`;
        this.setMessage(li);

        this.isSharing = false;
        this.button.textContent = "开始共享";
      }

      async startShare() {
        this.stream = await navigator.mediaDevices.getDisplayMedia({
          video: true,
          audio: false,
        });

        this.stream.getTracks().forEach(track => {
          track.onended = this.stopShare.bind(this);
        });

        this.preview.srcObject = this.stream;
        await this.preview.play();

        // 获取第一帧作为封面
        const canvas = document.createElement("canvas");
        const ctx = canvas.getContext("2d");
        canvas.width = this.preview.videoWidth;
        canvas.height = this.preview.videoHeight;
        ctx.drawImage(this.preview, 0, 0, canvas.width, canvas.height);
        this.cover = canvas.toDataURL("image/png");

        this.socket = new WebSocket(this.socketUrl);
        this.socket.onopen = this.socketOnOpen.bind(this);
        this.socket.onmessage = this.socketOnMessage.bind(this);
        this.socket.onerror = this.socketOnError.bind(this);
        this.socket.onclose = this.socketOnClose.bind(this);

        const li = document.createElement("li");
        li.textContent = `房间 ${this.roomName}(${this.roomId}) 已创建`;
        this.setMessage(li);

        this.isSharing = true;
        this.button.textContent = "停止共享";
      }

      socketOnOpen() {
        this.sendMessage("create", {
          roomId: this.roomId,
          roomName: this.roomName,
          cover: this.cover,
        });
      }

      socketOnMessage(e) {
        const payload = JSON.parse(e.data);
        const { data, type } = payload;
        this.messageHandler[type](data);
      }

      socketOnError() {}

      socketOnClose() {
        this.stopShare();
      }

      setMessage(li) {
        this.messageContainer.appendChild(li);
        this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
      }

      danmakuSubmit(e) {
        e.preventDefault();
        if (!this.socket) return;
        const input = this.danmaku.querySelector("input");
        const message = input.value;
        if (!message) return;
        this.sendMessage("danmaku", {
          message,
          roomId: this.roomId,
          admin: true,
        });
        input.value = "";
      }
    }

    const main = new Main();
    main.start();
  </script>
</html>

4. 实现观众页面

/src/watch.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      html,
      body {
        margin: 0;
        padding: 0;
        font-family: Arial, sans-serif;
        height: 100vh;
        width: 100vw;
        overflow: hidden;
      }
      .container {
        width: 100%;
        height: 100%;
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }

      .header {
        display: flex;
        justify-content: space-between;
        padding: 10px;
        border-bottom: 1px solid #ccc;
        align-items: center;
      }

      .header h1 {
        margin: 0;
      }

      .main {
        margin-top: 10px;
        flex: 1;
        display: flex;
        overflow: hidden;
      }

      #preview {
        flex: 1;
        border: 1px solid #ccc;
        overflow: hidden;
      }

      .message {
        width: 300px;
        border: 1px solid #ccc;
        margin: 0 0 0 10px;
        padding: 0;
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }

      .message-list {
        padding: 0 0 0 24px;
        margin: 0;
        flex: 1;
        overflow-y: auto;
      }

      #danmaku {
        display: flex;
        padding: 10px;
        border-top: 1px solid #ccc;
      }

      #danmaku input {
        flex: 1;
        padding: 5px;
        border: 1px solid #ccc;
        border-radius: 5px;
      }

      #danmaku button {
        padding: 5px 10px;
        border: 1px solid #ccc;
        border-radius: 5px;
        margin-left: 10px;
      }

      .room-list {
        padding: 0;
        list-style: none;
        width: 300px;
        border: 1px solid #ccc;
        margin: 0 10px 0 0;
      }

      .room-list li {
        padding: 10px;
        border-bottom: 1px solid #ccc;
      }

      .room-list li img {
        width: 100%;
        display: block;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="header">
        <h1 id="title"></h1>
      </div>
      <div class="main">
        <ul class="room-list"></ul>
        <video id="preview"></video>
        <div class="message">
          <ul class="message-list"></ul>
          <form id="danmaku">
            <input type="text" placeholder="请输入消息" />
            <button type="submit">发送</button>
          </form>
        </div>
      </div>
    </div>
    <script>
      class Watch {
        constructor() {
          const params = new URLSearchParams(location.search);
          const url = new URL(location.href);
          this.video = document.getElementById("preview");
          this.title = document.getElementById("title");
          this.messageContainer = document.querySelector(".message-list");
          this.socket = null;
          this.socketUrl = `ws://${url.host}/ws`;
          this.roomContainer = document.querySelector(".room-list");
          this.peer = null;
          this.username = params.get("uname");
          this.userId = params.get("uid") || this.generateClientId();
          this.danmaku = document.getElementById("danmaku");
        }

        get roomId() {
          const params = new URLSearchParams(location.search);
          return params.get("id");
        }

        set roomId(value) {
          const params = new URLSearchParams(location.search);
          params.set("id", value);
          history.pushState(null, "", `?${params}`);
        }

        messageHandler = {
          offer: async data => {
            const { offer } = data;
            await this.peer.setRemoteDescription(offer);
            const answer = await this.peer.createAnswer();
            await this.peer.setLocalDescription(answer);
            this.sendMessage("answer", {
              answer,
              userId: this.userId,
              roomId: this.roomId,
            });
          },
          answer: data => {
            console.log(data);
          },
          error: e => {
            const li = document.createElement("li");
            li.textContent = e.message;
            this.setMessage(li);
          },
          success: e => {
            const li = document.createElement("li");
            li.textContent = e.message;
            this.setMessage(li);
          },
          close: e => {
            const li = document.createElement("li");
            li.textContent = e.message;
            this.setMessage(li);

            this.video.srcObject?.getTracks().forEach(track => track.stop());
            this.peer.close();
          },
          icecandidate: async data => {
            await this.peer.addIceCandidate(data.candidate);
          },
          updateRooms: data => {
            this.getRoom();
            const { type, roomId } = data;
            if (type === "create" && roomId === this.roomId) {
              this.enterRoom();
            }
          },
          danmaku: data => {
            const li = document.createElement("li");
            const possessor = document.createElement("label");
            const { roomId, admin, message, username, userId } = data;
            let part = "";
            if (admin) part = "UP主";
            else if (userId === this.userId) part = "";
            else part = `${username}(${userId})`;
            possessor.textContent = `${part}说:`;
            const content = document.createElement("span");
            content.textContent = message;
            li.appendChild(possessor);
            li.appendChild(content);
            this.setMessage(li);
          },
        };

        async start() {
          this.inputUsername();
          this.registerEvent();
          await this.getRoom();
          await this.enterRoom();
        }

        setMessage(li) {
          this.messageContainer.appendChild(li);
          this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
        }
        generateClientId() {
          return Math.random().toString().substring(2, 9);
        }

        inputUsername() {
          while (!this.username) {
            this.username = prompt("请输入用户名");
          }
          this.title.textContent = `hi, ${this.username}(${this.userId})`;
          const params = new URLSearchParams(location.search);
          params.set("uid", this.userId);
          params.set("uname", this.username);
          history.pushState(null, "", `?${params}`);
        }

        async getRoom() {
          const res = await fetch("/api/rooms");
          const rooms = await res.json();
          this.roomContainer.innerHTML = "";
          const fragment = document.createDocumentFragment();
          for (const room of rooms) {
            const li = document.createElement("li");
            const a = document.createElement("a");
            const params = new URLSearchParams({
              id: room.id,
              uid: this.userId,
              uname: this.username,
            });

            const cover = document.createElement("img");
            cover.src = room.cover;
            a.href = `?${params}`;
            a.textContent = `${room.name}(${room.id})的房间`;
            li.appendChild(cover);
            li.appendChild(a);
            fragment.appendChild(li);
          }
          this.roomContainer.appendChild(fragment);
        }

        async enterRoom() {
          // if (!this.roomId) return
          this.socket = new WebSocket(this.socketUrl);
          this.peer = new RTCPeerConnection();
          this.peer.ontrack = e => {
            this.video.srcObject = e.streams[0];
            this.video.play().catch(this.play.bind(this));
          };

          this.peer.onicecandidate = e => {
            if (e.candidate) {
              this.sendMessage("icecandidate", {
                candidate: e.candidate,
                userId: this.userId,
                roomId: this.roomId,
              });
            }
          };
          this.socket.onopen = this.socketOnOpen.bind(this);
          this.socket.onmessage = this.socketOnMessage.bind(this);
          this.socket.onerror = this.socketOnError.bind(this);
        }

        // 手动点击播放
        play() {
          const li = document.createElement("li");
          li.style.color = "red";
          const span = document.createElement("span");
          span.textContent = "由于浏览器自动播放策略,";
          const a = document.createElement("a");
          a.href = "javascript:void(0)";
          a.textContent = "点击这里播放";
          a.onclick = () => {
            this.video.play();
            li.remove();
          };
          li.appendChild(span);
          li.appendChild(a);
          this.setMessage(li);
        }

        socketOnOpen() {
          this.sendMessage("join", {
            roomId: this.roomId,
            userId: this.userId,
            username: this.username,
          });
        }

        socketOnMessage(e) {
          const payload = JSON.parse(e.data);
          const { data, type } = payload;
          this.messageHandler[type](data);
        }

        sendMessage(type, data) {
          this.socket.send(
            JSON.stringify({
              type,
              data,
            })
          );
        }

        socketOnError() {
          this.video.srcObject?.getTracks().forEach(track => track.stop());
          this.peer.close();
        }

        registerEvent() {
          this.danmaku.addEventListener(
            "submit",
            this.danmakuSubmit.bind(this)
          );
        }

        danmakuSubmit(e) {
          e.preventDefault();
          if (!this.socket) return;
          const input = this.danmaku.querySelector("input");
          const message = input.value;
          if (!message) return;
          this.sendMessage("danmaku", {
            message,
            roomId: this.roomId,
            username: this.username,
            userId: this.userId,
          });
          input.value = "";
        }
      }

      const watch = new Watch();
      watch.start();
    </script>
  </body>
</html>

5. 入口

/src/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      a {
        display: block;
        width: 100%;
        height: 100px;
        line-height: 100px;
        text-align: center;
        border: 1px solid #ccc;
        border-radius: 4px;
        font-size: 24px;
        font-family: Arial, sans-serif;
        text-decoration: none;
      }

      a + a {
        margin-top: 10px;
      }
    </style>
  </head>
  <body>
    <div>
      <a href="/src/main.html">我要分享</a>
      <a href="/src/watch.html">我要观看</a>
    </div>
  </body>
</html>

6. 运行

bun run ./index.ts

🥰 🥰 🥰