Videollamadas WebRTC peer-to-peer en el Workspace (reemplazo de Jitsi)
Estado: Activo en producción · Introducido en s79 (22-05-2026) · Refactor popup en s80 (22-05-2026, PR#68)
Resumen
Sistema de videollamadas directo browser-a-browser (WebRTC P2P) para el equipo CreaRack. Reemplaza el stack anterior basado en JitsiMeetingModal + servidor Docker externo. Sin servidor de medios, sin Docker, sin Jitsi: el video va directo entre browsers usando únicamente Cloudflare D1 como canal de señalización.
Arquitectura
Peer A (TeamChat)
|
| window.open('/reunion?author=Edu&target=Todos')
v
[Popup /reunion] [Popup /reunion]
MeetingRoomShell MeetingRoomShell
| |
MeetingRoom ←── /api/webrtc/signal ──→ MeetingRoom
(browser A) (D1 polling) (browser B)
\ /
──── RTCPeerConnection ────────
(WebRTC directo)
Señalización (D1)
- Tabla
webrtc_signalingen Cloudflare D1. - Endpoint
POST /api/webrtc/signal(Pages Function enfunctions/api/webrtc/signal.ts): recibe mensajes JSON, los escribe en D1 y los lee por polling. - Polling cada ~1.5 s por peer. Mensaje TTL: los registros se limpian tras ser leídos.
- Tipos de mensaje:
peer_joined,peer_left,offer,answer,ice-candidate. - El peer con ID lexicográficamente menor actúa como initiator y envía el
offer.
Componentes
MeetingRoom.tsx + MeetingRoomShell.tsx + /reunion.astro
Componente React (src/components/widgets/MeetingRoom.tsx) que reemplaza a JitsiMeetingModal.tsx (eliminado, 291 LOC → 0).
Modo de uso (refactor s80 · 22-05-2026): la reunión vive en una ventana popup propia del navegador (/reunion, abierta con window.open(..., 'popup=yes,...') desde el botón Video del TeamChat). Antes era un modal flotante dentro del WS, pero al navegar entre páginas Astro se desmontaba y mataba la sesión WebRTC. El popup es un proceso de navegador independiente: sobrevive a cualquier navegación, se puede arrastrar al segundo monitor y cerrarlo cuelga la reunión sin tocar el WS. Ver [[decision—20260522—meetingroom-popup-window]].
| Archivo | Rol |
|---|---|
src/pages/reunion.astro | Ruta standalone, layout minimal (AppLayout sin Sidebar/BottomNav), html/body 100vh + overflow:hidden |
src/components/widgets/MeetingRoomShell.tsx | Lee author/target/silent de query params, monta MeetingRoom, onClose → window.close() |
src/components/widgets/MeetingRoom.tsx | Lógica WebRTC + UI ocupando 100% del viewport del popup |
src/components/widgets/TeamChat.tsx | Botón Video llama a openMeetingPopup(author, target, silent) que hace window.open(...) con name 'crearack-meeting' (singleton) |
Responsabilidades de MeetingRoom:
- Gestiona el array de
RTCPeerConnection(una por peer remoto). - Implementa el bucle de polling de signaling.
- Renderiza el grid de vídeo (local + remotos, layout automático CSS Grid).
- Controles: mute micrófono, mute cámara, screen share, ajustes dispositivos, hang up.
beforeunload+pagehideenvíanpeer_leftvianavigator.sendBeaconpara que los otros peers detecten la salida inmediatamente cuando se cierra el popup desde la X del SO.
/api/webrtc/signal (Pages Function)
functions/api/webrtc/signal.ts
POST: inserta un mensaje de señalización (offer, answer, ice-candidate, peer_joined, peer_left).GET ?room=<id>&for_peer=<id>: devuelve y borra los mensajes pendientes para ese peer.- Sala fija:
team-meeting(no multi-sala por ahora).
Tabla D1: webrtc_signaling
Migración: migrations/0035_webrtc_signaling.sql. Columnas: id, room, from_peer, from_name, to_peer (null = broadcast), kind, payload (JSON), created_at.
Flujo de llamada paso a paso
- Edu hace click en «📹 Video → Llamar a todos» en TeamChat.
- TeamChat llama a
openMeetingPopup('Edu', 'Todos', false)→window.open('/reunion?author=Edu&target=Todos', 'crearack-meeting', 'popup=yes,...'). - Popup
/reunionmontaMeetingRoomShell→MeetingRoom. - MeetingRoom llama a
getUserMedia, obtiene stream local, generapeerId. - Envía
POST /api/webrtc/signal { kind: 'peer_joined', room: 'team-meeting', from_peer: 'eduXXX', ... }. - TeamChat también postea mensaje de aviso al chat con botón «🎥 Unirme».
- Dani hace click en «Unirme» → nuevo popup abre
/reunion?author=Dani&target=Todos&silent=1. - Polling detecta
peer_joineddel otro lado. El peer con ID menor envíaoffer(SDP). - Negociación SDP + ICE candidates via D1. WebRTC directo establecido.
- Al colgar (botón o X del SO):
sendBeacon→peer_left→ el otro peer detecta la salida en el siguiente poll.
Consideraciones de NAT/STUN/TURN
El servidor STUN de Google (stun.l.google.com:19302) solo se usa para el ICE gathering (descubrir IPs públicas). El stream de vídeo va directo entre los browsers sin pasar por Google. Para NAT simétrico estricto (raro en redes de oficina) puede ser necesario un TURN server — pendiente de añadir si se reportan fallos de conexión. Cloudflare ofrece TURN gratuito para volumen bajo.
Tabla de archivos
| Archivo | Cambio |
|---|---|
functions/api/webrtc/signal.ts | ➕ Añadido (143 LOC) |
migrations/0035_webrtc_signaling.sql | ➕ Añadido (29 LOC) |
src/components/widgets/MeetingRoom.tsx | ➕ Añadido (s79) · ✏️ Refactor a fullscreen popup (s80, PR#68) |
src/components/widgets/MeetingRoomShell.tsx | ➕ Añadido (s80, PR#68) |
src/pages/reunion.astro | ➕ Añadido (s80, PR#68) |
src/components/widgets/JitsiMeetingModal.tsx | ❌ Eliminado (291 LOC) |
src/components/widgets/TeamChat.tsx | ✏️ Switch de import + mensaje de anuncio simplificado · ✏️ s80: usa window.open en lugar de render inline del modal |
Véase también
- [[decision—20260522—webrtc-reemplaza-jitsi]] — ADR que introdujo WebRTC P2P en sustitución de Jitsi
- [[decision—20260522—meetingroom-popup-window]] — ADR del cambio modal → popup window (s80, PR#68)
- [[entity—meeting—component—meeting-room]] — Documentación detallada del componente MeetingRoom
- [[entity—meeting—component—meeting-room-shell]] — Wrapper MeetingRoomShell para la ruta /reunion
- [[workspace—que-es-workspace]] — Descripción del workspace Astro donde vive la feature