Introducción: Recientemente, mientras aprendía Bun, me surgió una pregunta interesante: ¿cómo podría implementar una plataforma de streaming en vivo?
0. Instalación de Bun
Para instalar Bun, ejecuta el siguiente comando en tu terminal:
curl -fsSL https://bun.sh/install | bash
1. Inicialización del proyecto
bun init
2. Implementación del servidor de señales
index.ts
import Bun from 'bun';
import type {ServerWebSocket} from 'bun';
type MessageType =
'entrar'
| 'crear'
| 'oferta'
| 'respuesta'
| 'candidato_ice'
| 'error'
| 'exito'
| 'salir'
| 'cerrar'
| 'ingresado'
| 'chat'
| 'actualizar_salas'
class StreamingServer {
constructor() {
}
puerto = 3000;
manejadoresMensajes: Partial<Record<
MessageType,
(ws: ServerWebSocket<string>, payload: Record<string, any>) => void
>> = {
entrar: (ws, payload) => {
// Un usuario ingresa a la sala
const {roomId, userId, username} = payload;
this.usuarios.set(userId, {
ws,
nombre: username,
roomId
})
if (!roomId) return;
const sala = this.salas.get(roomId);
if (!sala) {
this.enviarMensaje(ws, 'error', {
mensaje: 'La sala no existe o ha sido cerrada'
})
return
}
this.usuarios.set(userId, {
ws,
nombre: username,
roomId
})
sala.clientes.push(ws)
this.enviarMensaje(sala.anfitrion, 'ingresado', {
userId,
username
})
this.enviarMensaje(ws, 'exito', {
mensaje: `Te uniste a la sala ${sala.nombre} exitosamente`
})
},
crear: (ws, payload) => {
const {roomId, roomName, portada} = payload;
this.salas.set(roomId, {
anfitrion: ws,
nombre: roomName,
portada,
clientes: []
})
// Notificar a todos los usuarios sobre la nueva sala
for (const [userId, usuario] of this.usuarios) {
const {ws: userWs} = usuario;
this.enviarMensaje(userWs, 'actualizar_salas', {
roomId,
tipo: 'crear'
})
}
},
oferta: (ws, payload) => {
const {oferta, userId, roomId} = payload
const usuario = this.usuarios.get(userId);
if (!usuario) {
this.enviarMensaje(ws, 'error', {
mensaje: 'El usuario no existe'
})
return
}
const {ws: userWs} = usuario;
this.enviarMensaje(userWs, 'oferta', {
oferta,
userId,
roomId
})
},
respuesta: (ws, payload) => {
const {respuesta, userId, roomId} = payload
const sala = this.salas.get(roomId);
if (!sala) {
this.enviarMensaje(ws, 'error', {
mensaje: 'La sala no existe'
})
return
}
this.enviarMensaje(sala.anfitrion, 'respuesta', {
respuesta,
userId,
roomId
})
},
candidato_ice: (ws, payload) => {
const {candidato, userId, roomId} = payload
const usuario = this.usuarios.get(userId);
if (!usuario) {
this.enviarMensaje(ws, 'error', {
mensaje: 'El usuario no existe'
})
return
}
const {ws: userWs} = usuario;
this.enviarMensaje(userWs, 'candidato_ice', {
candidato,
userId,
roomId
})
},
chat: (ws, payload) => {
const {roomId, esAdmin, mensaje, username, userId} = payload
const sala = this.salas.get(roomId)
if (!sala) {
this.enviarMensaje(ws, 'error', {
mensaje: 'La sala no existe o ha sido cerrada'
})
return
}
const datos = {
esAdmin,
mensaje,
username,
userId
}
for (const cliente of sala.clientes) {
this.enviarMensaje(cliente, 'chat', datos)
}
const {anfitrion} = sala;
this.enviarMensaje(anfitrion, 'chat', datos)
}
}
salas = new Map<string, {
anfitrion: ServerWebSocket<string>;
nombre: string;
portada: string;
clientes: ServerWebSocket<string>;
}>(); // Salas disponibles
usuarios = new Map<string, {
nombre: string;
ws: ServerWebSocket<string>;
roomId: string;
}>(); // Usuarios conectados
get datosSalas() {
const datos = []
for (const [roomId, sala] of this.salas) {
datos.push({
id: roomId,
nombre: sala.nombre,
portada: sala.portada
})
}
return datos
}
enviarMensaje(ws: ServerWebSocket<string>, tipo: MessageType, datos: Record<string, any>) {
ws.send(JSON.stringify({
tipo,
datos
}))
}
async iniciar() {
Bun.serve<string>({
port: this.puerto,
fetch: async (solicitud, servidor) => {
const ruta = new URL(solicitud.url).pathname
if (ruta === "/") {
const archivo = Bun.file("./src/index.html")
const existe = await archivo.exists();
if (existe) return new Response(archivo)
}
if (ruta === '/ws') {
const exito = servidor.upgrade(solicitud)
if (exito) return new Response("Actualizando...")
}
if (ruta.startsWith("/src/")) {
const rutaArchivo = ruta.replace("/src/", "./src/");
const archivo = Bun.file(rutaArchivo)
const existe = await archivo.exists();
if (existe) return new Response(archivo)
} else if (ruta.startsWith('/api/')) {
if (ruta === '/api/salas') {
return new Response(JSON.stringify(this.datosSalas))
}
}
return new Response(ruta);
},
websocket: {
open() {
},
close: (ws, codigo, razon) => {
// Usuario sale o cierra la sala
// Determinar si es un usuario que sale o el anfitrión que cierra
// 1. Si se encuentra en la lista de usuarios, notificar a otros en la sala y al anfitrión
// 2. Si se encuentra en la lista de salas, notificar a los usuarios de esa sala
const usuario = Array.from(this.usuarios).find(([userId, usuario]) => usuario.ws === ws);
if (usuario) {
const [userId, {ws, roomId, nombre}] = usuario;
const sala = this.salas.get(roomId);
if (sala) {
this.enviarMensaje(sala.anfitrion, 'salir', {
userId,
username: nombre,
mensaje: `El usuario ${nombre}(${userId}) salió de la sala`
})
sala.clientes = sala.clientes.filter(cliente => cliente !== ws)
}
this.usuarios.delete(userId)
return
}
const sala = Array.from(this.salas).find(([roomId, sala]) => sala.anfitrion === ws);
if (sala) {
const [roomId, {nombre, clientes}] = sala;
for (const cliente of clientes) {
this.enviarMensaje(cliente, 'cerrar', {
roomId,
nombreSala: nombre,
mensaje: `La sala ${nombre}(${roomId}) ha sido cerrada`
})
}
this.salas.delete(roomId)
// Notificar a todos los usuarios sobre el cierre de la sala
for (const [userId, usuario] of this.usuarios) {
const {ws: userWs} = usuario;
this.enviarMensaje(userWs, 'actualizar_salas', {
roomId,
tipo: 'cerrar'
})
}
}
},
message: (ws, mensaje: string) => {
const contenido: {
tipo: MessageType;
datos: Record<string, any>;
} = JSON.parse(mensaje);
const {tipo, datos} = contenido;
const manejador = this.manejadoresMensajes[tipo];
manejador?.(ws, datos)
}
},
})
console.log(`Servidor Bun ejecutándose en http://localhost:${this.puerto}`)
}
}
const servidor = new StreamingServer()
await servidor.iniciar()
3. Implementación de la página del retransmisor
Crear carpeta src en el directorio raíz; dentro de src, crear main.html
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Retransmisor</title>
<style>
html, body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.contenedor {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.encabezado {
display: flex;
justify-content: space-between;
padding: 10px;
border-bottom: 1px solid #ccc;
align-items: center;
}
.encabezado h1 {
margin: 0;
}
.encabezado-principal {
flex: 1;
}
.encabezado-principal button {
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
.principal {
margin-top: 10px;
flex: 1;
display: flex;
overflow: hidden;
}
#vista_previa {
flex: 1;
border: 1px solid #ccc;
overflow: hidden;
}
.mensajes {
width: 300px;
border: 1px solid #ccc;
margin: 0 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.lista_mensajes {
padding: 0 0 0 24px;
margin: 0;
flex: 1;
overflow-y: auto;
}
#chat {
display: flex;
padding: 10px;
border-top: 1px solid #ccc;
}
#chat input {
flex: 1;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
}
#chat button {
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 5px;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="contenedor">
<div class="encabezado">
<h1 id="titulo"></h1>
<div class="encabezado-principal">
<button id="boton">Iniciar retransmisión</button>
<button id="compartir_enlace">Compartir sala</button>
</div>
</div>
<div class="principal">
<video id="vista_previa"></video>
<div class="mensajes">
<form id="chat">
<input type="text" placeholder="Escribe un mensaje">
<button type="submit">Enviar</button>
</form>
</div>
</div>
</div>
</body>
<script>
class Retransmisor {
constructor() {
const parametros = new URLSearchParams(location.search)
const url = new URL(location.href)
this.vistaPrevia = document.getElementById('vista_previa')
this.urlSocket = `ws://${url.host}/ws`
this.titulo = document.getElementById('titulo')
this.boton = document.getElementById('boton')
this.botonCompartir = document.getElementById('compartir_enlace')
this.contenedorMensajes = document.querySelector('.lista_mensajes')
this.socket = null
this.roomId = parametros.get('id') || this.generarIdCliente()
this.roomName = parametros.get('name') || ''
this.estaRetransmitiendo = false
this.stream = null
this.conexiones = new Map
this.portada = ''
this.chat = document.getElementById('chat')
}
iniciar() {
this.solicitarNombreSala()
this.registrarEventos()
}
manejarMensajes = {
ingress: async (datos) => {
console.log('Ingreso detectado')
const li = document.createElement('li')
li.textContent = `Bienvenido ${datos.username}(${datos.userId}) a la sala`
this.agregarMensaje(li)
// Enviar oferta al nuevo usuario
const peer = new RTCPeerConnection()
this.stream.getTracks().forEach(track => peer.addTrack(track, this.stream))
peer.onicecandidate = (e) => {
if (e.candidate) {
this.enviarMensaje('candidato_ice', {
candidato: e.candidate,
userId: datos.userId,
roomId: this.roomId
})
}
}
const oferta = await peer.createOffer()
await peer.setLocalDescription(oferta)
this.enviarMensaje('oferta', {
oferta,
userId: datos.userId,
roomId: this.roomId
})
this.conexiones.set(datos.userId, peer)
},
salida: (datos) => {
console.log('Salida detectada')
const li = document.createElement('li')
li.classList.add('salida')
li.textContent = `${datos.username}(${datos.userId}) salió de la sala`
this.agregarMensaje(li)
},
respuesta: async (datos) => {
console.log('Respuesta recibida')
const {respuesta, userId} = datos
const peer = this.conexiones.get(userId)
await peer.setRemoteDescription(respuesta)
},
candidato_ice: (datos) => {
console.log('Candidato ICE recibido')
const {candidato, userId} = datos
const peer = this.conexiones.get(userId)
peer.addIceCandidate(new RTCIceCandidate(candidato))
},
chat: (datos) => {
const li = document.createElement('li')
const etiqueta = document.createElement('label')
const {roomId, esAdmin, mensaje, username, userId} = datos
let parte = ''
if (esAdmin) parte = 'Yo'
else parte = `${username}(${userId})`
etiqueta.textContent = `${parte} dice: `
const contenido = document.createElement('span')
contenido.textContent = mensaje
li.appendChild(etiqueta)
li.appendChild(contenido)
this.agregarMensaje(li)
}
}
enviarMensaje(tipo, datos) {
this.socket.send(JSON.stringify({
tipo,
datos
}))
}
generarIdCliente() {
return Math.random().toString().substring(2, 9)
}
solicitarNombreSala() {
while (!this.roomName) {
this.roomName = prompt('Ingresa el nombre de la sala')
}
this.titulo.textContent = `Hola, ${this.roomName}(${this.roomId})`
const parametros = new URLSearchParams({
id: this.roomId,
name: this.roomName
})
history.pushState(null, '', `?${parametros}`)
}
registrarEventos() {
this.boton.addEventListener('click', this.clicBoton.bind(this))
this.chat.addEventListener('submit', this.enviarChat.bind(this))
this.botonCompartir.addEventListener('click', () => {
const url = new URL(`${location.origin}/src/ver.html`)
url.searchParams.set('id', this.roomId)
url.searchParams.set('name', this.roomName)
navigator.clipboard.writeText(url.href)
})
}
async clicBoton() {
this.estaRetransmitiendo ? await this.detenerRetransmision() : await this.iniciarRetransmision()
}
async detenerRetransmision() {
if (!this.stream) return
this.stream.getTracks().forEach(track => track.stop())
this.stream = null
await this.vistaPrevia.pause()
this.vistaPrevia.srcObject = null
this.socket?.close()
this.socket = null
this.conexiones.forEach(peer => peer.close())
this.conexiones.clear()
const li = document.createElement('li')
li.textContent = `Sala ${this.roomName}(${this.roomId}) cerrada`
this.agregarMensaje(li)
this.estaRetransmitiendo = false
this.boton.textContent = 'Iniciar retransmisión'
}
async iniciarRetransmision() {
this.stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
})
this.stream.getTracks().forEach(track => {
track.onended = this.detenerRetransmision.bind(this)
})
this.vistaPrevia.srcObject = this.stream
await this.vistaPrevia.play()
// Capturar primer frame como portada
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = this.vistaPrevia.videoWidth
canvas.height = this.vistaPrevia.videoHeight
ctx.drawImage(this.vistaPrevia, 0, 0, canvas.width, canvas.height)
this.portada = canvas.toDataURL('image/png')
this.socket = new WebSocket(this.urlSocket)
this.socket.onopen = this.socketAbierto.bind(this)
this.socket.onmessage = this.socketMensaje.bind(this)
this.socket.onerror = this.socketError.bind(this)
this.socket.onclose = this.socketCerrado.bind(this)
const li = document.createElement('li')
li.textContent = `Sala ${this.roomName}(${this.roomId}) creada`
this.agregarMensaje(li)
this.estaRetransmitiendo = true
this.boton.textContent = 'Detener retransmisión'
}
socketAbierto() {
this.enviarMensaje('crear', {
roomId: this.roomId,
roomName: this.roomName,
portada: this.portada
})
}
socketMensaje(e) {
const contenido = JSON.parse(e.data)
const {datos, tipo} = contenido
this.manejarMensajes[tipo](datos)
}
socketError() {
}
socketCerrado() {
this.detenerRetransmision()
}
agregarMensaje(li) {
this.contenedorMensajes.appendChild(li)
this.contenedorMensajes.scrollTop = this.contenedorMensajes.scrollHeight
}
enviarChat(e) {
e.preventDefault()
if (!this.socket) return
const input = this.chat.querySelector('input')
const mensaje = input.value
if (!mensaje) return
this.enviarMensaje('chat', {
mensaje,
roomId: this.roomId,
esAdmin: true
})
input.value = ''
}
}
const retransmisor = new Retransmisor()
retransmisor.iniciar()
</script>
</html>
4. Implementación de la página del espectador
/src/ver.html
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Espectador</title>
<style>
html, body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.contenedor {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.encabezado {
display: flex;
justify-content: space-between;
padding: 10px;
border-bottom: 1px solid #ccc;
align-items: center;
}
.encabezado h1 {
margin: 0;
}
.principal {
margin-top: 10px;
flex: 1;
display: flex;
overflow: hidden;
}
#vista_previa {
flex: 1;
border: 1px solid #ccc;
overflow: hidden;
}
.mensajes {
width: 300px;
border: 1px solid #ccc;
margin: 0 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.lista_mensajes {
padding: 0 0 0 24px;
margin: 0;
flex: 1;
overflow-y: auto;
}
#chat {
display: flex;
padding: 10px;
border-top: 1px solid #ccc;
}
#chat input {
flex: 1;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
}
#chat button {
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 5px;
margin-left: 10px;
}
.lista_salas {
padding: 0;
list-style: none;
width: 300px;
border: 1px solid #ccc;
margin: 0 10px 0 0;
}
.lista_salas li {
padding: 10px;
border-bottom: 1px solid #ccc;
}
.lista_salas li img {
width: 100%;
display: block;
}
</style>
</head>
<body>
<div class="contenedor">
<div class="encabezado">
<h1 id="titulo"></h1>
</div>
<div class="principal">
<video id="vista_previa"></video>
<div class="mensajes">
<form id="chat">
<input type="text" placeholder="Escribe un mensaje">
<button type="submit">Enviar</button>
</form>
</div>
</div>
</div>
<script>
class Espectador {
constructor() {
const parametros = new URLSearchParams(location.search)
const url = new URL(location.href)
this.video = document.getElementById('vista_previa')
this.titulo = document.getElementById('titulo')
this.contenedorMensajes = document.querySelector('.lista_mensajes')
this.socket = null
this.urlSocket = `ws://${url.host}/ws`
this.contenedorSalas = document.querySelector('.lista_salas')
this.peer = null
this.username = parametros.get('uname')
this.userId = parametros.get('uid') || this.generarIdCliente()
this.chat = document.getElementById('chat')
}
get roomId() {
const parametros = new URLSearchParams(location.search)
return parametros.get('id')
}
set roomId(valor) {
const parametros = new URLSearchParams(location.search)
parametros.set('id', valor)
history.pushState(null, '', `?${parametros}`)
}
manejarMensajes = {
oferta: async (datos) => {
const {oferta} = datos
await this.peer.setRemoteDescription(oferta)
const respuesta = await this.peer.createAnswer()
await this.peer.setLocalDescription(respuesta)
this.enviarMensaje('respuesta', {
respuesta,
userId: this.userId,
roomId: this.roomId
})
},
respuesta: (datos) => {
console.log(datos)
},
error: (e) => {
const li = document.createElement('li')
li.textContent = e.mensaje
this.agregarMensaje(li)
},
exito: (e) => {
const li = document.createElement('li')
li.textContent = e.mensaje
this.agregarMensaje(li)
},
cerrar: (e) => {
const li = document.createElement('li')
li.textContent = e.mensaje
this.agregarMensaje(li)
this.video.srcObject?.getTracks().forEach(track => track.stop())
this.peer.close()
},
candidato_ice: async (datos) => {
await this.peer.addIceCandidate(datos.candidato)
},
actualizar_salas: (datos) => {
this.obtenerSalas()
const {tipo, roomId} = datos
if (tipo === 'crear' && roomId === this.roomId) {
this.entrarSala()
}
},
chat: (datos) => {
const li = document.createElement('li')
const etiqueta = document.createElement('label')
const {roomId, esAdmin, mensaje, username, userId} = datos
let parte = ''
if (esAdmin) parte = 'Creador'
else if (userId === this.userId) parte = 'Yo'
else parte = `${username}(${userId})`
etiqueta.textContent = `${parte} dice: `
const contenido = document.createElement('span')
contenido.textContent = mensaje
li.appendChild(etiqueta)
li.appendChild(contenido)
this.agregarMensaje(li)
}
}
async iniciar() {
this.solicitarNombreUsuario()
this.registrarEventos()
await this.obtenerSalas()
await this.entrarSala()
}
agregarMensaje(li) {
this.contenedorMensajes.appendChild(li)
this.contenedorMensajes.scrollTop = this.contenedorMensajes.scrollHeight
}
generarIdCliente() {
return Math.random().toString().substring(2, 9)
}
solicitarNombreUsuario() {
while (!this.username) {
this.username = prompt('Ingresa tu nombre de usuario')
}
this.titulo.textContent = `Hola, ${this.username}(${this.userId})`
const parametros = new URLSearchParams(location.search)
parametros.set('uid', this.userId)
parametros.set('uname', this.username)
history.pushState(null, '', `?${parametros}`)
}
async obtenerSalas() {
const res = await fetch('/api/salas')
const salas = await res.json()
this.contenedorSalas.innerHTML = ''
const fragmento = document.createDocumentFragment()
for (const sala of salas) {
const li = document.createElement('li')
const enlace = document.createElement('a')
const parametros = new URLSearchParams({
id: sala.id,
uid: this.userId,
uname: this.username
})
const portada = document.createElement('img')
portada.src = sala.portada
enlace.href = `?${parametros}`
enlace.textContent = `Sala de ${sala.nombre}(${sala.id})`
li.appendChild(portada)
li.appendChild(enlace)
fragmento.appendChild(li)
}
this.contenedorSalas.appendChild(fragmento)
}
async entrarSala() {
this.socket = new WebSocket(this.urlSocket)
this.peer = new RTCPeerConnection()
this.peer.ontrack = (e) => {
this.video.srcObject = e.streams[0]
this.video.play().catch(this.reproducir.bind(this))
}
this.peer.onicecandidate = (e) => {
if (e.candidate) {
this.enviarMensaje('candidato_ice', {
candidato: e.candidate,
userId: this.userId,
roomId: this.roomId
})
}
}
this.socket.onopen = this.socketAbierto.bind(this)
this.socket.onmessage = this.socketMensaje.bind(this)
this.socket.onerror = this.socketError.bind(this)
}
// Reproducción manual debido a políticas del navegador
reproducir() {
const li = document.createElement('li')
li.style.color = 'red'
const span = document.createElement('span')
span.textContent = 'Por políticas de autoplay del navegador,'
const enlace = document.createElement('a')
enlace.href = 'javascript:void(0)'
enlace.textContent = 'haz clic aquí para reproducir'
enlace.onclick = () => {
this.video.play()
li.remove()
}
li.appendChild(span)
li.appendChild(enlace)
this.agregarMensaje(li)
}
socketAbierto() {
this.enviarMensaje('entrar', {
roomId: this.roomId,
userId: this.userId,
username: this.username
})
}
socketMensaje(e) {
const contenido = JSON.parse(e.data)
const {datos, tipo} = contenido
this.manejarMensajes[tipo](datos)
}
enviarMensaje(tipo, datos) {
this.socket.send(JSON.stringify({
tipo,
datos
}))
}
socketError() {
this.video.srcObject?.getTracks().forEach(track => track.stop())
this.peer.close()
}
registrarEventos() {
this.chat.addEventListener('submit', this.enviarChat.bind(this))
}
enviarChat(e) {
e.preventDefault()
if (!this.socket) return
const input = this.chat.querySelector('input')
const mensaje = input.value
if (!mensaje) return
this.enviarMensaje('chat', {
mensaje,
roomId: this.roomId,
username: this.username,
userId: this.userId
})
input.value = ''
}
}
const espectador = new Espectador()
espectador.iniciar()
</script>
</body>
</html>
5. Punto de entrada
/src/index.html
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Plataforma de Streaming</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">Quiero retransmitir</a>
<a href="/src/ver.html">Quiero ver</a>
</div>
</body>
</html>
6. Ejecución
Para ejecutar el proyecot, utiliza el siguiente comando:
bun run ./index.ts
Una vez iniciado, puedes acceder a la plataforma en http://localhost:3000. Desde allí, podrás elegir entre crear una retransmisión como anfitrión o unirte a una sala existente como espectador.