近日。学习 BUN 中,突发奇想,如何实现一个直播平台?
0. 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
🥰 🥰 🥰