前言:
近日。学习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://localhost:3000/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