Implementando una plataforma de streaming en vivo con Bun y WebRTC

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.

Etiquetas: bun runtime JavaScript WebRTC streaming

Publicado el 6-29 18:56