feat: add session API routes, in-memory store, and E2E test utilities; update styles and refactor components for consistency

This commit is contained in:
Cesar Mendivil 2025-11-21 21:09:14 -07:00
parent 8b458a3ddf
commit d162014030
99 changed files with 7893 additions and 1929 deletions

110
app/api/session/README.md Normal file
View File

@ -0,0 +1,110 @@
# API /api/session
Este README documenta la API de sesión incluida en `app/api/session` del proyecto.
Objetivo
- Proveer endpoints simples para crear y recuperar sesiones E2E que devuelven un token LiveKit y una `studioUrl` construida con la convención `/rooms/<roomName>`.
Endpoints
1) POST /api/session
- Uso: crea una sesión (genera token JWT con `livekit-server-sdk`) y guarda una entrada en el store en memoria.
- Request body (JSON):
- room (string) — nombre de la sala (opcional, por defecto `e2e-room`).
- username (string) — identidad del participante (opcional).
- ttl (number) — tiempo de vida en segundos del token (opcional, por defecto 300).
- Response (200):
```json
{ "id": "sess_<ts>_<rand>", "token": "<JWT>", "studioUrl": "https://mi-dominio/rooms/<room>" }
```
2) GET /api/session/:id
- Uso: recuperar la sesión previamente creada por `POST /api/session`.
- Response (200): retorna el objeto de sesión guardado en memoria, por ejemplo:
```json
{
"id": "sess_...",
"token": "...",
"room": "mi-room",
"username": "visual-runner",
"studioUrl": "https://mi-dominio/rooms/mi-room",
"createdAt": 169...
}
```
Variables de entorno relevantes
- LIVEKIT_API_KEY (required): clave pública para LiveKit.
- LIVEKIT_API_SECRET (required): secreto para firmar tokens.
- STUDIO_BASE (optional): base URL del frontend; si está definida, `studioUrl` se construye como `${STUDIO_BASE}/rooms/${room}`.
- Alternativas atendidas: `STUDIO_URL` o `BROADCAST_URL` si `STUDIO_BASE` no existe.
Notas técnicas
- El store es un Map en memoria (`app/api/session/store.ts`). Es volátil: si el servidor se reinicia, las sesiones se perderán. Para entornos reales usa Redis o una DB.
- El token se genera con `AccessToken` del paquete `livekit-server-sdk` y contiene los grants para `roomJoin`, `canPublish` y `canSubscribe`.
Comandos de ejemplo
1) Crear sesión (POST):
```bash
curl -sS -X POST http://localhost:3000/api/session \
-H 'Content-Type: application/json' \
-d '{"room":"mi-room-e2e","username":"tester","ttl":300}' | jq .
```
2) Recuperar sesión (GET):
```bash
curl -sS http://localhost:3000/api/session/sess_169... | jq .
```
3) Decodificar el token (inspección del payload JWT):
```bash
TOKEN="eyJhbGciOiJ..."
echo $TOKEN | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
```
4) Probar manualmente en el frontend (DevTools)
- Abre: `https://mi-dominio/rooms/mi-room-e2e`.
- En la consola ejecuta:
```js
window.postMessage({ type: 'LIVEKIT_TOKEN', token: 'TOKEN_AQUI', room: '' }, window.location.origin);
```
El frontend debe escuchar `message` y conectar usando el token.
Probar con el E2E script
- Arrancar Chrome con remote-debugging (usa `packages/broadcast-panel/e2e/start-chrome-remote.sh`).
- Ejecutar el script:
```bash
REMOTE_DEBUG_ADDRESS=127.0.0.1 REMOTE_DEBUG_PORT=9222 \
TOKEN_SERVER="http://localhost:3000" \
ROOM="mi-room-e2e" \
BROADCAST_BASE="https://mi-dominio" \
node packages/broadcast-panel/e2e/generate_visual_baseline.js
```
Artefactos generados por el E2E script
- Logs: `packages/broadcast-panel/e2e/e2e/out/generate_visual_baseline.log` (o `e2e/out/...` en la raíz del repo)
- Captura: `packages/broadcast-panel/e2e/e2e/out/visual_<timestamp>/studio.png`
- Baseline: `packages/broadcast-panel/e2e/e2e/baseline/studio.png`
Depuración rápida
- Si `POST /api/session` devuelve 500: revisa `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` en el entorno del backend.
- Si `studioUrl` es vacío: exporta `STUDIO_BASE` antes de hacer POST.
- Si la página no reacciona al `postMessage`: revisa el listener `window.addEventListener('message', ...)` en el frontend.
Siguientes pasos recomendados
- Reemplazar el store en memoria por Redis para persistencia en entornos multi-instanacia.
- Añadir autenticación y control de acceso para la creación/consulta de sesiones.
Si quieres, puedo:
- Añadir un mock token server con `tools/mock-token-server.js` para pruebas locales sin credenciales reales.
- Añadir un endpoint para listar sesiones (solo para debugging).
---
**Fin del README de `app/api/session`**

View File

@ -0,0 +1,18 @@
import { getSession } from '../store';
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const parts = url.pathname.split('/');
const id = parts[parts.length - 1] || '';
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
const sess = getSession(id);
if (!sess) return new Response(JSON.stringify({ error: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } });
return new Response(JSON.stringify(sess), { status: 200, headers: { 'content-type': 'application/json' } });
} catch (err) {
console.error('Error in GET /api/session/:id', err);
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
}

View File

@ -0,0 +1,22 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const parts = url.pathname.split('/');
const id = parts[parts.length - 1] || '';
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || '';
if (!backend) {
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
const resp = await fetch(`${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(id)}`);
const text = await resp.text();
const headers = { 'content-type': 'application/json' };
return new Response(text, { status: resp.status, headers });
} catch (err) {
console.error('proxy GET /api/session/:id error', err);
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
}

View File

@ -0,0 +1,19 @@
export async function POST(req: Request) {
try {
const body = await req.json();
const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || '';
if (!backend) {
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
const url = `${backend.replace(/\/$/, '')}/api/session`;
const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const text = await resp.text();
const headers = { 'content-type': 'application/json' };
return new Response(text, { status: resp.status, headers });
} catch (err) {
console.error('proxy /api/session error', err);
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
}

44
app/api/session/route.ts Normal file
View File

@ -0,0 +1,44 @@
import { AccessToken } from 'livekit-server-sdk';
import { saveSession } from './store';
// API Route (POST) que crea una sesión y devuelve token + studioUrl + id
// Env required: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, optionally STUDIO_URL or STUDIO_BASE_URL
export async function POST(req: Request) {
try {
const body = await req.json();
const room = body.room || 'e2e-room';
const username = body.username || 'visual-runner';
const ttl = body.ttl || 300; // segundos
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
if (!apiKey || !apiSecret) {
return new Response(JSON.stringify({ error: 'LIVEKIT_API_KEY and LIVEKIT_API_SECRET required' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
const at = new AccessToken(apiKey, apiSecret, { identity: username, name: username, ttl: `${ttl}s` });
at.addGrant({
roomJoin: true,
room: room,
canPublish: true,
canSubscribe: true,
canPublishSources: ['camera', 'microphone', 'screen'],
} as any);
const token = at.toJwt();
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
// Construir studioUrl usando la estructura rooms/[roomName] por defecto
const studioBase = (process.env.STUDIO_BASE_URL || process.env.STUDIO_URL || process.env.BROADCAST_URL || '').trim();
const studioUrl = studioBase ? `${studioBase.replace(/\/$/, '')}/rooms/${encodeURIComponent(room)}` : '';
// Guardar sesión en store en memoria
saveSession(id, { token, room, username, studioUrl });
return new Response(JSON.stringify({ id, token, studioUrl }), { status: 200, headers: { 'content-type': 'application/json' } });
} catch (err) {
console.error('Error in /api/session', err);
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
}
}

36
app/api/session/store.ts Normal file
View File

@ -0,0 +1,36 @@
export type SessionData = {
id: string;
token?: string | null;
room?: string | null;
username?: string | null;
studioUrl?: string | null;
createdAt: number;
};
const sessionStore = new Map<string, SessionData>();
export function saveSession(id: string, data: Omit<Partial<SessionData>, 'id'>) {
const payload: SessionData = {
id,
token: data.token ?? null,
room: data.room ?? null,
username: data.username ?? null,
studioUrl: data.studioUrl ?? null,
createdAt: Date.now(),
};
sessionStore.set(id, payload);
return payload;
}
export function getSession(id: string): SessionData | null {
return sessionStore.get(id) || null;
}
export function deleteSession(id: string): boolean {
return sessionStore.delete(id);
}
export function listSessions() {
return Array.from(sessionStore.values());
}

View File

@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
type SessionResp = { id?: string; token?: string; studioUrl?: string; username?: string; [k: string]: any };
async function resolveIdentity(): Promise<string | null> {
// 1) try NextAuth standard endpoint
try {
const r = await fetch('/api/auth/session');
if (r.ok) {
const j = await r.json();
// NextAuth returns { user: { name, email }, expires }
if (j && j.user) {
return j.user.name || j.user.email || null;
}
}
} catch (e) { /* ignore */ }
// 2) try a global set by the app
try {
// @ts-ignore
if (typeof window !== 'undefined' && (window as any).__AVANZACAST_USER) {
// @ts-ignore
const u = (window as any).__AVANZACAST_USER;
if (typeof u === 'string') return u;
if (u && (u.name || u.id || u.email)) return u.name || u.id || u.email;
}
} catch (e) { /* ignore */ }
// 3) fallback: prompt the user (only in dev/testing)
try {
const answer = window.prompt('Identifícate (nombre de usuario) para ligar con la room', 'visual-runner');
if (answer && answer.trim()) return answer.trim();
} catch (e) { /* ignore */ }
return null;
}
export default function AutoRequestAndInject({ roomName }: { roomName: string }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function run() {
setLoading(true);
setError(null);
try {
const identity = await resolveIdentity();
if (!identity) throw new Error('No identity resolved; please login or set window.__AVANZACAST_USER');
if (mounted) setUsername(identity);
const resp = await fetch('/api/session/proxy', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: roomName, username: identity, ttl: 300 }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json: SessionResp = await resp.json();
// Try to get token if not in POST response
if (!json.token) {
if (json.id) {
const getResp = await fetch(`/api/session/proxy/${encodeURIComponent(json.id)}`);
if (getResp.ok) {
const getJson = await getResp.json();
if (getJson && getJson.token) {
json.token = getJson.token;
}
}
}
}
if (!json.token) throw new Error('No token returned from session proxy');
// Post message with token to same-origin listeners
window.postMessage({ type: 'LIVEKIT_TOKEN', token: json.token, room: roomName, username: identity }, window.location.origin);
console.log('AutoRequestAndInject: posted LIVEKIT_TOKEN to window', { room: roomName, username: identity });
} catch (err: any) {
console.error('AutoRequestAndInject error', err);
if (mounted) setError(String(err));
} finally {
if (mounted) setLoading(false);
}
}
run();
return () => { mounted = false; };
}, [roomName]);
if (loading) return <div aria-live="polite">Solicitando token de sesión para room "{roomName}"...</div>;
if (error) return <div role="alert">Error solicitando token: {error}</div>;
return (
<div>
{username ? <div>Identidad: <strong>{username}</strong></div> : null}
</div>
);
}

View File

@ -0,0 +1,18 @@
import AutoRequestAndInject from './AutoRequestAndInject';
export default function RoomPage({ params }: { params: { roomName: string } }) {
const { roomName } = params;
return (
<div>
<h1>Room: {roomName}</h1>
<AutoRequestAndInject roomName={roomName} />
{/* Aquí va el componente del estudio real (VideoConference) */}
<div id="studio-root">
{/* El estudio debe estar preparado para escuchar postMessage LIVEKIT_TOKEN */}
<p>Esperando token para conectar al studio...</p>
</div>
</div>
);
}

BIN
docs/img_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
docs/img_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

215
docs/portal_livekit.md Normal file
View File

@ -0,0 +1,215 @@
## 🛠️ Integración de LiveKit en Next.js con Componentes y Estilo Similar a StreamYard
Para lograr una interfaz similar a la captura de StreamYard en tu proyecto **Next.js** con **Vite** (aunque Next.js ya tiene su propio *bundler*, si usas Vite para otras partes o pruebas, los componentes de LiveKit para React son la clave), la mejor estrategia es utilizar la librería oficial de **LiveKit React Components** y luego aplicar estilos para simular la disposición.
### 1\. ⚙️ Configuración e Instalación
Aunque Next.js no usa Vite para el *bundling* principal, la integración de las librerías sigue los mismos pasos que cualquier aplicación React:
* **Instala el SDK y los Componentes de React:**
```bash
npm install livekit-client @livekit/components-react @livekit/components-styles livekit-server-sdk
# O usa yarn/pnpm:
# yarn add livekit-client @livekit/components-react @livekit/components-styles livekit-server-sdk
```
* `livekit-client`: El SDK principal para la conectividad WebRTC.
* `@livekit/components-react`: Componentes de UI preconstruidos (la base para la interfaz).
* `@livekit/components-styles`: Estilos base para los componentes.
* `livekit-server-sdk`: Necesario para generar los **tokens de acceso** en tu API de Next.js.
* **Generación de Tokens (Backend):**
Crea una **API Route** en Next.js (por ejemplo, en `/app/api/token/route.ts` para Next.js 13+) que use `livekit-server-sdk` para generar un token de acceso (JWT). Este token es necesario para que el cliente se conecte a la sala.
> **Nota:** Necesitarás tu `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` y `LIVEKIT_URL` configurados en tu archivo `.env.local`.
### 2\. 🏗️ Estructura de Componentes de LiveKit
La interfaz de StreamYard que muestras se puede replicar usando una combinación de componentes de LiveKit y **estilos CSS/Tailwind** para el diseño de la cuadrícula.
1. **Contenedor Principal de la Sala:**
Envuelve tu aplicación de video en el componente `<LiveKitRoom>`.
```tsx
import '@livekit/components-styles';
import { LiveKitRoom, VideoConference } from '@livekit/components-react';
export default function MiSala({ token, serverUrl }) {
return (
<LiveKitRoom
token={token}
serverUrl={serverUrl}
connect={true}
data-lk-theme="default" // Aplica el tema base
>
{/* Aquí van tus componentes personalizados */}
<VideoConference />
</LiveKitRoom>
);
}
```
2. **Paneles y Diseño (Simulación de la Interfaz):**
La clave para simular la interfaz es usar CSS Grid o Flexbox en tu página de Next.js para colocar los componentes en las áreas que se ven en la captura:
| Área en StreamYard (Captura) | Componente de LiveKit o Custom | Descripción |
| :--- | :--- | :--- |
| **Escenas/Mis Escenas (Izquierda)** | **Custom React Component** | Un componente personalizado de Next.js para gestionar escenas, **fuera** de LiveKitRoom, pero que interactúa con el estado de la sala (por ejemplo, publicando/despublicando pistas o cambiando el *layout*). |
| **Vídeo Central (720p)** | **GridLayout / FocusLayout** | Usa componentes de *Layout* de LiveKit, como `<GridLayout>` o `<FocusLayout>` para renderizar la vista principal de los participantes. |
| **Controles (Micrófono, Cámara, etc.)** | **ControlBar / TrackToggle** | El componente `<ControlBar>` de LiveKit es ideal para la barra de control inferior, ya que incluye botones listos para Mute/Cámara/Pantalla Compartida. |
| **Participantes (Cesar Mendivil)** | **ParticipantTile** | Puedes usar `<ParticipantLoop>` para iterar sobre los participantes y renderizar un `<ParticipantTile>` para cada uno en la vista inferior o principal. |
| **Comentarios (Derecha)** | **Chat** | El componente `<Chat>` de LiveKit proporciona una funcionalidad de chat lista para usar que se ajusta a la sección de "Comentarios". |
### 3\. ✨ Estilizado y Componentes Personalizados
* **Estilos:** Importa los estilos base de LiveKit (`import '@livekit/components-styles';`) y utiliza **CSS Modules** o **Tailwind CSS** (muy común en Next.js) para crear el diseño de cuadrícula principal y los estilos específicos (bordes, fondos, etc.) que se asemejan a StreamYard. Utiliza *slots* o envoltorios personalizados alrededor de los componentes de LiveKit si necesitas un estilo muy específico que no se puede lograr con la personalización de LiveKit.
* **Vite:** Si estás usando Vite en paralelo para el frontend, la instalación y el uso de los componentes de React son idénticos.
Aquí tienes un video que muestra cómo hacer una aplicación de videollamadas con Next.js y LiveKit.
[Video Call Application with Next js and LiveKit | 2024](https://www.youtube.com/watch?v=-dfLbXOAoa8) es un tutorial que te guiará a través de la creación de una aplicación de videollamadas con Next.js y LiveKit.
http://googleusercontent.com/youtube_content/0
## 🔑 Generación de Tokens con `livekit-server-sdk`
Para generar un token de acceso con `livekit-server-sdk` en tu backend (como en una API Route de Next.js), necesitas seguir tres pasos clave: configurar el SDK, definir la identidad del usuario y generar el token con los permisos (roles) adecuados.
Aquí te muestro un ejemplo completo en TypeScript/JavaScript:
### 1\. 🛠️ Instalación y Configuración
Asegúrate de tener el SDK instalado:
```bash
npm install livekit-server-sdk
```
Y que tus credenciales estén configuradas (usualmente en variables de entorno):
* **`LIVEKIT_API_KEY`**
* **`LIVEKIT_API_SECRET`**
* **`LIVEKIT_URL`** (la URL de tu servidor LiveKit, e.g., `wss://misuperapp.livekit.cloud`)
-----
### 2\. 📝 Ejemplo de Código para Generar un Token
Utiliza la clase **`AccessToken`** para crear un nuevo token, incluyendo los permisos para la sala.
```typescript
import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
import type { VideoGrant } from 'livekit-server-sdk';
// ----------------------------------------------------
// 1. Obtén las credenciales de tus variables de entorno
// ----------------------------------------------------
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
// const livekitUrl = process.env.LIVEKIT_URL; // Solo es necesaria si usas RoomServiceClient
if (!apiKey || !apiSecret) {
throw new Error('LIVEKIT_API_KEY y LIVEKIT_API_SECRET deben estar configuradas.');
}
/**
* Genera un token de acceso para un usuario específico en una sala.
* @param roomName El nombre de la sala a la que se conectará el usuario.
* @param identity La identidad única del usuario (e.g., ID de usuario o nombre de usuario).
* @param canPublish Permiso para publicar video/audio/pantalla (típico de un participante).
* @param canSubscribe Permiso para ver/escuchar a otros participantes (siempre debe ser true).
* @param roomJoin Permiso para unirse a la sala.
* @returns El token JWT generado (string).
*/
export function generateLiveKitToken(
roomName: string,
identity: string,
canPublish: boolean,
isModerator: boolean = false,
): string {
// ----------------------------------------------------
// 2. Crea una instancia de AccessToken
// ----------------------------------------------------
const at = new AccessToken(apiKey!, apiSecret!, {
identity: identity,
// (Opcional) Nombre del usuario que se mostrará en la UI
name: identity,
// (Opcional) Define un tiempo de expiración
ttl: '10m',
});
// ----------------------------------------------------
// 3. Define los permisos (VideoGrant)
// ----------------------------------------------------
const grant: VideoGrant = {
roomJoin: true, // Permitir unirse a la sala
room: roomName, // El nombre de la sala específica
// Permisos de publicación/suscripción
canPublish: canPublish, // Permite publicar audio/video/pantalla
canSubscribe: true, // Permite recibir pistas de otros
// (Opcional) Permisos avanzados para moderadores
canUpdate: isModerator, // Permite actualizar la sala (mutear a otros, etc.)
roomAdmin: isModerator, // Permisos de administrador de sala
canPublishSources: ['camera', 'microphone', 'screen'], // Fuentes permitidas
};
at.addGrant(grant);
// ----------------------------------------------------
// 4. To string: genera el token JWT
// ----------------------------------------------------
const token = at.toJwt();
console.log(`Token generado para ${identity} en sala ${roomName}:`, token);
return token;
}
```
-----
### 3\. 🌐 Uso Típico en Next.js (API Route)
En una API Route de Next.js (`/api/livekit/token`):
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { generateLiveKitToken } from './utils'; // Asume que el código anterior está en 'utils'
export async function POST(req: NextRequest) {
try {
const { roomName, identity, isPublisher } = await req.json();
if (!roomName || !identity) {
return NextResponse.json({ error: 'Faltan parámetros: roomName e identity' }, { status: 400 });
}
// Un publicador tiene 'canPublish: true', un espectador tendría 'canPublish: false'
const token = generateLiveKitToken(roomName, identity, isPublisher);
return NextResponse.json({ token }, { status: 200 });
} catch (error) {
console.error('Error generando token:', error);
return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 });
}
}
```
### Variables de entorno útiles
Además de las variables necesarias para la generación de tokens (`LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_URL`), el paquete del Broadcast Panel expone una variable específica para controlar un "preflight" que provoca el prompt de permisos de cámara/micrófono antes de intentar conectar al estudio.
- `VITE_STUDIO_PREFLIGHT` (opcional): controla si se ejecuta un preflight que llama a `navigator.mediaDevices.getUserMedia` para forzar el prompt de permisos antes de la negociación WebRTC.
- Valores válidos: `1` / `true` (habilitado por defecto), `0` / `false` (deshabilitado).
- Uso típico: en desarrollo puedes dejarlo habilitado para asegurar que el navegador solicite permisos al entrar al estudio; en entornos de pruebas automáticas o cuando no quieres el prompt automático, pon `VITE_STUDIO_PREFLIGHT=0`.
Hemos añadido un ejemplo de archivo de entorno en:
- `packages/broadcast-panel/.env.example`
que incluye `VITE_STUDIO_PREFLIGHT=1` y otras variables de ejemplo que el paquete puede leer (p. ej. `VITE_BACKEND_API_URL`, `VITE_BACKEND_TOKENS_URL`, `VITE_BROADCASTPANEL_URL`, `VITE_LIVEKIT_WS_URL`).

BIN
docs/sequence_livekit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View File

@ -76,6 +76,35 @@ node validate-flow-remote-chrome.js
```
Salida: `e2e/validate-flow-remote-chrome-result.json` + screenshot
## Ejecutar contra Chrome Remoto (remote debugging port 9222)
Si tienes un Chrome con `--remote-debugging-port=9222` (local o remoto), puedes ejecutar el validador que se conecta a ese navegador.
1) Si conoces la websocket URL completa (ej: `ws://1.2.3.4:9222/devtools/browser/xxxx`):
```bash
export CHROME_WS="ws://1.2.3.4:9222/devtools/browser/xxxx"
export BROADCAST_URL="https://your-broadcast.example.com"
export STUDIO_URL="https://your-studio.example.com"
export TOKEN="optional-token-if-you-want"
node validate-flow-remote-chrome.js
```
2) Si sólo conoces el host (ej: `1.2.3.4` o `1.2.3.4:9222`) usa el helper:
```bash
# desde la raíz del repo
CHROME_HOST="1.2.3.4" BROADCAST_URL="https://your-broadcast.example.com" STUDIO_URL="https://your-studio.example.com" TOKEN="..." \
bash run-remote-chrome.sh
```
3) Shortcut npm desde la raíz del monorepo (usa el helper creado `e2e/run-remote-chrome.sh`):
```bash
CHROME_HOST="1.2.3.4" BROADCAST_URL="https://your-broadcast.example.com" STUDIO_URL="https://your-studio.example.com" TOKEN="..." \
npm run e2e:remote-chrome
```
### Validar flujo `/:id` (session id stored in DB -> broadcast panel)
Hemos añadido un test E2E específico que valida el flujo en el que el token-server crea una sesión y el Broadcast Panel la consume a través de la ruta `/:id`.

View File

@ -0,0 +1,26 @@
// Launch a Chromium instance via puppeteer-core and print the WebSocket endpoint to stdout
// Also write the launcher PID to /tmp/puppeteer_chrome_launcher.pid
const fs = require('fs');
(async () => {
try {
const puppeteer = require('puppeteer-core');
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const ws = browser.wsEndpoint();
const pidFile = '/tmp/puppeteer_chrome_launcher.pid';
try { fs.writeFileSync(pidFile, String(process.pid)); } catch(e) { }
console.log('PUPPETEER_WS=' + ws);
console.log('LAUNCHER_PID=' + process.pid);
// keep process alive until killed
process.on('SIGINT', async () => { try { await browser.close(); } catch(e){}; process.exit(0); });
process.on('SIGTERM', async () => { try { await browser.close(); } catch(e){}; process.exit(0); });
// prevent exit
await new Promise(() => {});
} catch (err) {
console.error('LAUNCH_ERROR', String(err));
process.exit(2);
}
})();

34
e2e/run-remote-chrome.sh Normal file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# e2e/run-remote-chrome.sh
# Resolve Chrome websocket endpoint from CHROME_HOST (host[:port]) or use CHROME_WS if provided.
# Then run e2e/validate-flow-remote-chrome.js with the resolved CHROME_WS env var.
set -eu
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR" || exit 1
if [ -n "${CHROME_WS:-}" ]; then
echo "Using provided CHROME_WS"
export CHROME_WS="$CHROME_WS"
else
if [ -z "${CHROME_HOST:-}" ]; then
echo "Error: set CHROME_HOST (host[:port]) or CHROME_WS (websocket url)" >&2
exit 2
fi
HOST="$CHROME_HOST"
# If port not present, default to 9222
if [[ "$HOST" != *":"* ]]; then
HOST="$HOST:9222"
fi
URL="http://${HOST}/json/version"
echo "Fetching websocket endpoint from $URL"
WS="$(curl -sS "$URL" | node -e "const s=fs.readFileSync(0,'utf8'); try{const j=JSON.parse(s); console.log(j.webSocketDebuggerUrl||'')}catch(e){console.error('failed parse json',e); process.exit(2)}")" || true
if [ -z "$WS" ]; then
echo "Failed to get webSocketDebuggerUrl from $URL" >&2
curl -sS "$URL" || true
exit 3
fi
export CHROME_WS="$WS"
echo "Resolved CHROME_WS=$CHROME_WS"
fi
# Run the validator
node e2e/validate-flow-remote-chrome.js

116
e2e/run-smoke-local.sh Normal file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env bash
# e2e/run-smoke-local.sh
# Smoke test helper: arranca mock server, arranca Chrome (opcional) o fallback via puppeteer launcher,
# ejecuta `validate-flow-remote-chrome.js` y limpia los procesos.
# Usage:
# CHROME_CMD="/usr/bin/google-chrome" ./run-smoke-local.sh
# or set CHROME_HOST/CHROME_WS to reuse an existing remote Chrome
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR"
LOGDIR="$ROOT_DIR/logs"
mkdir -p "$LOGDIR"
MOCK_LOG="$LOGDIR/mock_server.log"
CHROME_LOG="$LOGDIR/chrome_launch.log"
VALIDATOR_LOG="$LOGDIR/validator.log"
echo "[smoke] working dir: $ROOT_DIR"
# Start mock server
echo "[smoke] starting mock server... (logs: $MOCK_LOG)"
node mock_server.js > "$MOCK_LOG" 2>&1 &
MOCK_PID=$!
sleep 0.6
if ! kill -0 "$MOCK_PID" 2>/dev/null; then
echo "[smoke][error] failed to start mock server; see $MOCK_LOG"
tail -n 200 "$MOCK_LOG" || true
exit 2
fi
echo "[smoke] mock server pid=$MOCK_PID"
# Determine Chrome websocket endpoint
WS=""
if [ -n "${CHROME_WS:-}" ]; then
WS="$CHROME_WS"
echo "[smoke] using CHROME_WS from env"
fi
# If user provided CHROME_HOST, resolve ws
if [ -z "$WS" ] && [ -n "${CHROME_HOST:-}" ]; then
echo "[smoke] resolving websocket endpoint from CHROME_HOST=$CHROME_HOST"
URL="http://${CHROME_HOST%:*}:${CHROME_HOST##*:}/json/version"
# if CHROME_HOST already has port include it directly
if [[ "$CHROME_HOST" == *":"* ]]; then URL="http://$CHROME_HOST/json/version"; fi
if ! curl -sS "$URL" > /dev/null 2>&1; then
echo "[smoke] warning: cannot reach $URL"
else
WS=$(curl -sS "$URL" | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") || true
echo "[smoke] resolved WS=$WS"
fi
fi
# If still no WS, try to launch Chrome via CHROME_CMD if provided
LAUNCHED_CHROME_PID=""
if [ -z "$WS" ]; then
if [ -n "${CHROME_CMD:-}" ]; then
echo "[smoke] launching Chrome via CHROME_CMD=$CHROME_CMD"
"$CHROME_CMD" --remote-debugging-port=9222 --user-data-dir=/tmp/avz_smoke_profile --no-first-run --no-default-browser-check > "$CHROME_LOG" 2>&1 &
LAUNCHED_CHROME_PID=$!
sleep 1
# try to fetch ws
for i in 1 2 3 4 5; do
if curl -sS http://localhost:9222/json/version >/dev/null 2>&1; then
WS=$(curl -sS http://localhost:9222/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") || true
break
fi
sleep 1
done
echo "[smoke] chrome launched pid=$LAUNCHED_CHROME_PID ws=$WS"
else
# fallback: use puppeteer launcher script
echo "[smoke] no CHROME_CMD provided; launching Chromium via puppeteer (fallback)"
node _launch_chrome_puppeteer.js > "$CHROME_LOG" 2>&1 &
LAUNCHER_PID=$!
sleep 0.8
# read ws from log
for i in 1 2 3 4 5; do
if grep -m1 PUPPETEER_WS= "$CHROME_LOG" >/dev/null 2>&1; then
WS_LINE=$(grep -m1 PUPPETEER_WS= "$CHROME_LOG" || true)
WS=${WS_LINE#PUPPETEER_WS=}
break
fi
sleep 1
done
echo "[smoke] puppeteer-launched chrome pid=$LAUNCHER_PID ws=$WS"
LAUNCHED_CHROME_PID=${LAUNCHER_PID:-}
fi
fi
if [ -z "$WS" ]; then
echo "[smoke][warn] could not resolve Chrome websocket endpoint; you can set CHROME_WS or CHROME_HOST to skip launch"
echo "[smoke] continuing to try validator using CHROME_HOST if set"
fi
# Run validator using CHROME_WS if available else CHROME_HOST
echo "[smoke] running validator (logs: $VALIDATOR_LOG)"
export CHROME_WS="$WS"
export BROADCAST_URL="http://localhost:4001/broadcast"
export STUDIO_URL="http://localhost:4001/studio"
export TOKEN="smoketest-token-123"
node validate-flow-remote-chrome.js > "$VALIDATOR_LOG" 2>&1 || echo "[smoke] validator exited non-zero; see $VALIDATOR_LOG"
# show result snippets
echo "[smoke] validator log (tail 200):"
tail -n 200 "$VALIDATOR_LOG" || true
# cleanup
echo "[smoke] cleaning up..."
if [ -n "${MOCK_PID:-}" ]; then kill $MOCK_PID >/dev/null 2>&1 || true; fi
if [ -n "${LAUNCHED_CHROME_PID:-}" ]; then kill $LAUNCHED_CHROME_PID >/dev/null 2>&1 || true; fi
if [ -n "${LAUNCHER_PID:-}" ]; then kill $LAUNCHER_PID >/dev/null 2>&1 || true; fi
echo "[smoke] logs stored in $LOGDIR"
exit 0

44
e2e/run-tests.js Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env node
// e2e/run-tests.js
// Lee e2e/tests.json y ejecuta validate-flow-remote-chrome.js para cada caso en serie.
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const testsPath = path.resolve(__dirname, 'tests.json');
if (!fs.existsSync(testsPath)) {
console.error('tests.json not found at', testsPath);
process.exit(2);
}
const tests = JSON.parse(fs.readFileSync(testsPath, 'utf8'));
async function runTest(t) {
return new Promise((resolve) => {
console.log('\n=== Running test:', t.name, '===');
const env = Object.assign({}, process.env, t);
// Use the helper script if CHROME_HOST provided; else require CHROME_WS
const script = path.resolve(__dirname, 'run-remote-chrome.sh');
const proc = spawn('bash', [script], { env, stdio: 'inherit' });
proc.on('close', (code) => {
console.log(`Test ${t.name} finished with code ${code}`);
resolve(code);
});
proc.on('error', (err) => {
console.error('Failed to spawn process for', t.name, err);
resolve(3);
});
});
}
(async () => {
for (const t of tests) {
const code = await runTest(t);
if (code !== 0) {
console.warn('Test failed, stopping further tests. Last code:', code);
process.exit(code || 1);
}
}
console.log('\nAll tests passed (or completed).');
})();

23
e2e/tests.json Normal file
View File

@ -0,0 +1,23 @@
[
{
"name": "basic-flow",
"BROADCAST_URL": "http://localhost:5176",
"STUDIO_URL": "http://localhost:5176/studio",
"TOKEN": "testtoken123",
"EXPECTED_ROOM": "avanzacast-studio",
"EXPECTED_USERNAME": "Demo User",
"MIN_TTL_SECONDS": 30,
"CHROME_HOST": "localhost:9222"
},
{
"name": "mock-flow",
"BROADCAST_URL": "http://localhost:4001/broadcast",
"STUDIO_URL": "http://localhost:4001/studio",
"TOKEN": "mocktoken",
"EXPECTED_ROOM": "mock-room",
"EXPECTED_USERNAME": "mock-user",
"MIN_TTL_SECONDS": 10,
"CHROME_HOST": "localhost:9222"
}
]

View File

@ -11,6 +11,151 @@ const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-core');
// Helper: evaluate sessionStorage and backend token assertions
async function evaluateSessionAssertions(broadcastPage, studioPage, results, BROADCAST_URL, TOKEN) {
results.assertions = results.assertions || [];
try {
const storeKey = 'avanzacast_studio_session';
const stored = await broadcastPage.evaluate((k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } }, storeKey);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (parsed && parsed.token) {
const note = (typeof TOKEN === 'string' && TOKEN.length && String(parsed.token).includes(TOKEN.slice(0,6))) ? 'sessionStorage contains token (matches provided TOKEN prefix)' : 'sessionStorage contains token (from backend)';
results.assertions.push({ name: 'sessionStorage_has_token', ok: true, detail: note });
try {
const parts = String(parsed.token).split('.');
const isJwt = parts.length >= 3;
results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' });
if (isJwt) {
const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4);
const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8');
try {
const payloadObj = JSON.parse(decoded);
const nowSec = Math.floor(Date.now() / 1000);
if (payloadObj.exp && typeof payloadObj.exp === 'number') {
results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` });
} else {
results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' });
}
if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) });
} catch (e) {
results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) });
}
}
} catch (e) {
results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) });
}
} else {
results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' });
}
if (parsed && parsed.room) {
results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) });
} else {
results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' });
}
if (parsed && (parsed.url || parsed.serverUrl)) {
const serverUrl = parsed.url || parsed.serverUrl;
const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://'));
results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' });
} else {
results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' });
}
try {
const expectedRoom = process.env.EXPECTED_ROOM || null;
const expectedUsername = process.env.EXPECTED_USERNAME || null;
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
if (expectedRoom) {
const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom);
results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` });
}
if (expectedUsername) {
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
const unameMatches = !!uname && String(uname) === String(expectedUsername);
results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` });
} else {
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' });
}
let ttlSeconds = null;
if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds;
else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl;
else if (parsed && parsed.expiresAt) {
const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt));
if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000));
}
if (ttlSeconds !== null) {
results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` });
results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` });
} else {
results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' });
}
} catch (e) {
results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) });
}
if (parsed && (parsed.id || parsed.sessionId)) {
const sid = parsed.id || parsed.sessionId;
try {
const base = (BROADCAST_URL || '').replace(/\/$/, '');
const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`;
let fetched = null;
try {
const fetchMod = await import('node-fetch');
const r = await fetchMod.default(tokenEndpoint).catch(() => null);
if (r && r.ok) fetched = await r.json().catch(() => null);
} catch (fe) { fetched = null }
if (!fetched) {
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null;
if (TOKEN_SERVER) {
const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`;
try {
const fetchMod2 = await import('node-fetch');
const r2 = await fetchMod2.default(abs).catch(() => null);
if (r2 && r2.ok) fetched = await r2.json().catch(() => null);
} catch (fe2) { fetched = null }
}
}
if (fetched && fetched.token) {
results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' });
try {
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
const bttl = fetched.ttlSeconds || fetched.ttl || null;
if (typeof bttl === 'number') {
results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` });
results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` });
} else {
results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' });
}
} catch (e) {
results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) });
}
} else {
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' });
}
} catch (e) {
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) });
}
}
} catch (e) {
results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) });
}
} else {
results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' });
}
} catch (e) {
results.assertions.push({ name: 'sessionStorage_check_failed', ok: false, detail: String(e) });
}
}
(async () => {
const outDir = path.resolve(__dirname);
const results = { startedAt: new Date().toISOString(), console: [], navigations: [] };
@ -92,6 +237,8 @@ const puppeteer = require('puppeteer-core');
await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' });
results.navigations.push({ type: 'studio_opened', url: studioPage.url() });
await studioPage.waitForTimeout(2500);
// Run assertions (sessionStorage + backend checks)
await evaluateSessionAssertions(page, studioPage, results, BROADCAST_URL, TOKEN);
const shot = path.join(outDir, 'studio_flow_browserless_result.png');
await studioPage.screenshot({ path: shot, fullPage: true });
results.screenshot = shot;
@ -125,6 +272,8 @@ const puppeteer = require('puppeteer-core');
const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`;
await page.goto(directUrl, { waitUntil: 'networkidle2' });
await page.waitForTimeout(1500);
// Run assertions using current page as both broadcast and studio contexts
await evaluateSessionAssertions(page, page, results, BROADCAST_URL, TOKEN);
const shot = path.join(outDir, 'studio_flow_browserless_result.png');
await page.screenshot({ path: shot, fullPage: true });
results.screenshot = shot;

View File

@ -116,18 +116,149 @@ async function getWsEndpointFromHost(host) {
try {
const parsed = JSON.parse(stored);
// Accept any token returned by backend; if TOKEN env is set we also note if it matches
// Basic presence checks
if (parsed && parsed.token) {
const note = (typeof TOKEN === 'string' && TOKEN.length && String(parsed.token).includes(TOKEN.slice(0,6))) ? 'sessionStorage contains token (matches provided TOKEN prefix)' : 'sessionStorage contains token (from backend)';
results.assertions.push({ name: 'sessionStorage_has_token', ok: true, detail: note });
// Token format: JWT-like (3 parts)
try {
const parts = String(parsed.token).split('.');
const isJwt = parts.length >= 3;
results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' });
if (isJwt) {
// decode payload safely (base64url)
const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4);
const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8');
try {
const payloadObj = JSON.parse(decoded);
const nowSec = Math.floor(Date.now() / 1000);
if (payloadObj.exp && typeof payloadObj.exp === 'number') {
results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` });
} else {
results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' });
}
if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) });
} catch (e) {
results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) });
}
}
} catch (e) {
results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) });
}
} else {
results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' });
}
} catch(e) {
results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) });
}
} else {
results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' });
}
// Check room and url fields if present
if (parsed && parsed.room) {
results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) });
} else {
results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' });
}
if (parsed && (parsed.url || parsed.serverUrl)) {
const serverUrl = parsed.url || parsed.serverUrl;
const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://'));
results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' });
} else {
results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' });
}
// Additional optional assertions: username and TTL
try {
const expectedRoom = process.env.EXPECTED_ROOM || null;
const expectedUsername = process.env.EXPECTED_USERNAME || null;
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
if (expectedRoom) {
const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom);
results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` });
}
if (expectedUsername) {
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
const unameMatches = !!uname && String(uname) === String(expectedUsername);
results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` });
} else {
// still assert presence of username if not explicitly expected
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' });
}
// TTL checks: looks for ttlSeconds, ttl or expiresAt
let ttlSeconds = null;
if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds;
else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl;
else if (parsed && parsed.expiresAt) {
// expiresAt may be timestamp or Date string
const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt));
if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000));
}
if (ttlSeconds !== null) {
results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` });
results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` });
} else {
results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' });
}
} catch (e) {
results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) });
}
// If there's an id/sessionId available, try to query backend token endpoint for extra validation
if (parsed && (parsed.id || parsed.sessionId)) {
const sid = parsed.id || parsed.sessionId;
try {
// attempt to fetch from broadcast origin first
const base = (BROADCAST_URL || '').replace(/\/$/, '');
const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`;
let fetched = null;
try {
const r = await (await import('node-fetch'))(tokenEndpoint);
if (r && r.ok) fetched = await r.json().catch(() => null);
} catch (fe) {
// ignore - will try absolute later
fetched = null;
}
if (!fetched) {
// try absolute token server env if available
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null;
if (TOKEN_SERVER) {
const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`;
try {
const r2 = await (await import('node-fetch'))(abs, { mode: 'cors' });
if (r2 && r2.ok) fetched = await r2.json().catch(() => null);
} catch (fe2) { fetched = null }
}
}
if (fetched && fetched.token) {
results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' });
// If backend returned ttlSeconds, validate against minTtl
try {
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
const bttl = fetched.ttlSeconds || fetched.ttl || null;
if (typeof bttl === 'number') {
results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` });
results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` });
} else {
results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' });
}
} catch (e) {
results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) });
}
} else {
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' });
}
} catch (e) {
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) });
}
}
} catch(e) {
results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) });
}
} else {
results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' });
}
} catch (e) {
results.assertions.push({ name: 'sessionStorage_check_failed', ok: false, detail: String(e) });
}

3
package-lock.json generated
View File

@ -31279,7 +31279,8 @@
"puppeteer-core": "^20.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0"
"react-icons": "^5.5.0",
"ws": "^8.13.0"
},
"devDependencies": {
"@types/react": "^18.2.0",

View File

@ -18,6 +18,7 @@
],
"scripts": {
"dev": "concurrently \"npm:dev:*\"",
"e2e:remote-chrome": "bash e2e/run-remote-chrome.sh",
"dev:landing": "npm run dev --workspace=packages/landing-page",
"dev:api": "npm run dev --workspace=packages/backend-api",
"dev:studio": "npm run dev --workspace=packages/broadcast-studio",

View File

@ -0,0 +1,7 @@
# Debug env for backend-api
# Enables decoded JWT header in responses and some extra debug verification info
ALLOW_DEBUG_TOKEN_HEADER=1
NODE_ENV=development
# Optional: enable extra debug verify step using LIVEKIT_API_SECRET (if configured)
# LIVEKIT_API_SECRET=your_livekit_api_secret_here

View File

@ -0,0 +1,52 @@
# Debugging backend-api: enable token header debug
This small guide shows how to enable JWT debug headers in `/api/session/:id/token` responses.
What it does
- When `ALLOW_DEBUG_TOKEN_HEADER=1` or NODE_ENV=development, the backend will include `debugHeader` in the JSON response for `/api/session/:id/token`.
- This helps to inspect `alg`, `typ` and other header fields when LiveKit rejects tokens.
How to enable locally
1) Copy the debug env file (optional):
```bash
cd packages/backend-api
cp .env.debug .env
# Edit .env to set LIVEKIT_API_SECRET or LIVEKIT_API_KEY if required
```
2) Start the backend in development mode (or restart if it's already running):
```bash
# if you run directly with node
npm install
npm run dev
# or if you use ts-node / nodemon adjust accordingly
```
How to enable in docker-compose
- If `backend-api` runs in docker-compose, set environment in the compose file or use an env_file:
```yaml
services:
backend-api:
image: ...
env_file:
- packages/backend-api/.env.debug
```
Then `docker compose up -d backend-api`.
How to test
1) Create or obtain a session id from the token server or the UI.
2) Request token details with debug:
```bash
curl -s "http://localhost:4000/api/session/<id>/token" | jq .
```
Look for `debugHeader` and `debugVerifyHS256` fields.
If you prefer I can (A) add an endpoint `/debug/verify-token` that runs jwt verify using the configured secret (only when ALLOW_DEBUG_TOKEN_HEADER=1) or (B) prepare a docker-compose snippet that injects the debug env automatically. Reply A or B to proceed.

View File

@ -305,6 +305,26 @@ async function createLivekitTokenFor(room: string, username: string) {
try { h = JSON.parse(decoded); }
catch (e) { /* keep raw */ }
console.log('[LIVEKIT] token header:', h);
// If SDK produced a token with non-HS256 alg, and we have LIVEKIT_API_SECRET, re-sign payload with HS256
try {
const secretForResign = LIVEKIT_API_SECRET || process.env.LIVEKIT_API_SECRET;
if (secretForResign && typeof h === 'object' && h.alg && String(h.alg).toUpperCase() !== 'HS256') {
try {
const parts = (token as string).split('.');
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
const payloadRaw = parts[1] || '';
const payloadJson = JSON.parse(Buffer.from(pad(payloadRaw).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
// preserve iat/exp if present, stringify payload as-is and sign with HS256
const jwtLib = require('jsonwebtoken');
const newToken = jwtLib.sign(payloadJson, secretForResign, { algorithm: 'HS256' });
console.log('[LIVEKIT] SDK token used alg=' + (h.alg || 'unknown') + ' — re-signed with HS256 fallback');
return { token: newToken, url: LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880') };
} catch (e) {
console.warn('[LIVEKIT] failed to re-sign token with HS256 fallback', String(e));
}
}
} catch (e) { /* ignore fallback errors */ }
}
} catch (e) { console.warn('[LIVEKIT] failed to decode token header', String(e)); }
// Prefer explicit websocket URL env, else derive from LIVEKIT_URL (http(s) -> ws(s))
@ -349,32 +369,58 @@ app.get('/api/token', async (req, res) => {
}
});
// New endpoint: connection-details
app.get('/api/connection-details', async (req, res) => {
try {
// Accept either 'room' or 'roomName' for compatibility with frontend
const room = (req.query.room && typeof req.query.room === 'string') ? req.query.room : undefined;
const roomName = (req.query.roomName && typeof req.query.roomName === 'string') ? req.query.roomName : undefined;
const participantName = (req.query.participantName && typeof req.query.participantName === 'string') ? req.query.participantName : undefined;
const finalRoom = room || roomName;
if (!finalRoom) return res.status(400).json({ error: 'room or roomName query param is required' });
if (!participantName) return res.status(400).json({ error: 'participantName query param is required' });
const { token, url } = await createLivekitTokenFor(finalRoom, participantName);
// create a session id to store the token for short-lived retrieval if needed
const sessionId = generateShortId(9);
await saveSession(sessionId, { token, url, room: finalRoom, username: participantName }, SESSION_TTL);
// Return roomName for frontend compatibility
return res.json({ serverUrl: url, participantToken: token, sessionId, roomName: finalRoom });
} catch (err) {
console.error('[backend-api] /api/connection-details failed', err);
return res.status(500).json({ error: 'failed', details: String(err) });
}
});
async function validateTokenWithLiveKit(token: string) {
try {
// derive http(s) origin from LIVEKIT_WS_URL or LIVEKIT_URL
const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host';
let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
try { const u = new URL(httpUrl); httpUrl = `${u.protocol}//${u.host}`; } catch (e) {}
const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`;
const resp = await fetch(validateUrl, { method: 'GET' });
const ct = resp.headers.get('content-type') || '';
const text = await resp.text();
if (ct.includes('application/json')) {
try { return { status: resp.status, ok: resp.ok, body: JSON.parse(text) }; } catch (e) { return { status: resp.status, ok: resp.ok, body: text }; }
}
return { status: resp.status, ok: resp.ok, body: text };
} catch (e) {
return { ok: false, error: String(e) };
}
}
// Debug/validate endpoint: validate a token against LiveKit from the backend
app.all('/api/session/validate', async (req, res) => {
try {
const token = (req.method === 'GET' ? req.query.token : req.body?.token) || req.query.token || req.body?.token;
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' });
// derive http(s) origin from LIVEKIT_WS_URL
const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host';
let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
try {
const u = new URL(httpUrl);
httpUrl = `${u.protocol}//${u.host}`;
} catch (e) {
// keep as-is
}
const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`;
console.log('[backend-api] proxy validate to', validateUrl.slice(0, 200));
const resp = await fetch(validateUrl, { method: 'GET' });
const ct = resp.headers.get('content-type') || '';
const text = await resp.text();
if (ct.includes('application/json')) {
try { const json = JSON.parse(text); return res.status(resp.status).json({ ok: resp.ok, status: resp.status, body: json }); } catch (e) {}
}
return res.status(resp.status).send(text);
const result = await validateTokenWithLiveKit(token);
return res.status(result.status).json({ ok: result.ok, status: result.status, body: result.body });
} catch (err) {
console.error('[backend-api] validate proxy failed', err);
return res.status(500).json({ error: 'validate_failed', details: String(err) });
@ -413,14 +459,55 @@ app.post('/api/session', async (req, res) => {
? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`
: `${studioBase}/?session=${encodeURIComponent(id)}`;
return res.json({
// Prepare base response
const responsePayload: any = {
id,
studioUrl: `${studioBase}/${id}`,
// Ruta amigable para el frontend: /studio/:id
studioUrl: `${studioBase}/studio/${id}`,
redirectUrl,
ttlSeconds: ttlSec,
room,
username,
});
};
// In dev or when explicitly enabled, attach decoded header of generated token to help debugging
try {
if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
if (token && token.split('.').length >= 2) {
const header = token.split('.')[0];
const padded = header + '='.repeat((4 - (header.length % 4)) % 4);
const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
try { responsePayload.debugHeader = JSON.parse(decoded); } catch (e) { responsePayload.debugHeader = decoded }
// Extra debug: attempt to verify HS256 signature locally using LIVEKIT_API_SECRET (only for debug)
try {
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
if (LIVEKIT_API_SECRET) {
// do a best-effort verify using jsonwebtoken
try {
const jwt = require('jsonwebtoken');
let verified = null;
try {
verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
responsePayload.debugVerifyHS256 = { ok: true, payload: verified };
} catch (verifyErr) {
responsePayload.debugVerifyHS256 = { ok: false, error: (verifyErr && (verifyErr as any).message) || String(verifyErr) };
}
} catch (e) {
responsePayload.debugVerifyHS256 = { ok: false, error: 'jsonwebtoken_not_available' };
}
} else {
responsePayload.debugVerifyHS256 = { ok: false, error: 'no_livekit_api_secret_configured' };
}
} catch (e) {
// swallow debug verification errors
responsePayload.debugVerifyHS256 = { ok: false, error: String(e) };
}
}
}
} catch (e) { /* ignore debug attach errors */ }
return res.json(responsePayload);
} catch (err) {
console.error('Failed to create session', err);
return res.status(500).json({ error: String(err) });
@ -450,7 +537,19 @@ app.get('/api/session/:id/token', async (req, res) => {
const s = await getSession(id);
if (!s) return res.status(404).json({ error: 'not_found' });
const ttlLeft = Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000));
return res.json({ token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url });
const payload: any = { token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url };
try {
if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
const token = s.token || '';
if (typeof token === 'string' && token.split('.').length >= 2) {
const header = token.split('.')[0];
const padded = header + '='.repeat((4 - (header.length % 4)) % 4);
const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
try { payload.debugHeader = JSON.parse(decoded); } catch (e) { payload.debugHeader = decoded }
}
}
} catch (e) { /* ignore debug attach errors */ }
return res.json(payload);
} catch (err) {
console.error('GET /api/session/:id/token failed', err);
return res.status(500).json({ error: 'failed', details: String(err) });
@ -678,7 +777,7 @@ app.post('/api/broadcasts/:id/session', async (req, res) => {
const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
const redirectUrl = `${studioBase}/?session=${encodeURIComponent(sid)}`;
return res.json({ id: sid, studioUrl: `${studioBase}/${sid}`, redirectUrl, ttlSeconds: ttlSec });
return res.json({ id: sid, studioUrl: `${studioBase}/studio/${sid}`, redirectUrl, ttlSeconds: ttlSec });
} catch (err) {
console.error('Failed to create broadcast session', err);
return res.status(500).json({ error: 'failed', details: String(err) });
@ -724,7 +823,7 @@ app.post('/api/internal/session', async (req, res) => {
const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
const redirectUrl = `${studioBase}/?session=${encodeURIComponent(id)}`;
return res.json({ id, studioUrl: `${studioBase}/${id}`, redirectUrl, ttlSeconds: ttlSec, token });
return res.json({ id, studioUrl: `${studioBase}/studio/${id}`, redirectUrl, ttlSeconds: ttlSec, token });
} catch (err) {
console.error('Failed to create internal session', err);
return res.status(500).json({ error: 'failed', details: String(err) });
@ -782,3 +881,138 @@ app.get('/debug/session/:id/header', async (req, res) => {
}
});
// Debug endpoint: verify a token locally (only enabled in development or when ALLOW_DEBUG_TOKEN_HEADER=1)
app.post('/debug/verify-token', async (req, res) => {
try {
if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') {
return res.status(403).json({ error: 'not_allowed' });
}
const token = (req.body && req.body.token) || req.query.token || '';
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' });
const out: any = { tokenPreview: token.slice(0, 32) + '...' };
// decode header and payload
try {
const parts = token.split('.');
if (parts.length >= 2) {
const header = parts[0];
const payload = parts[1];
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
try { out.header = JSON.parse(Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
try { out.payload = JSON.parse(Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
} else {
out.header = null; out.payload = null;
}
} catch (e) {
out.decodeError = String(e);
}
// Local verify using configured secret (HS256) if available
try {
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
if (LIVEKIT_API_SECRET) {
try {
const jwt = require('jsonwebtoken');
const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
out.localVerify = { ok: true, payload: verified };
} catch (e) {
out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) };
}
} else {
out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' };
}
} catch (e) {
out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) };
}
// Proxy to LiveKit validate endpoint to get server-side validation result
try {
const result = await validateTokenWithLiveKit(token);
out.livekit = { status: result.status, ok: result.ok, body: result.body };
} catch (e) {
out.livekit = { ok: false, error: String(e) };
}
return res.json(out);
} catch (err) {
console.error('[DEBUG] /debug/verify-token failed', err);
return res.status(500).json({ error: 'failed', details: String(err) });
}
});
// Debug endpoint: verify and return full session info (token, decoded parts, local and LiveKit verification)
app.get('/debug/session/:id/full', async (req, res) => {
try {
// Only allow in development or when explicitly enabled
if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') {
return res.status(403).json({ error: 'not_allowed' });
}
const id = String(req.params.id || '');
if (!id) return res.status(400).json({ error: 'missing_id' });
const s = await getSession(id);
if (!s) return res.status(404).json({ error: 'not_found' });
const token = s.token || '';
const out: any = {
id,
room: s.room,
username: s.username,
url: s.url,
expiresAt: s.expiresAt,
ttlSeconds: Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)),
tokenPreview: typeof token === 'string' ? (token.length > 64 ? token.slice(0, 32) + '...' + token.slice(-16) : token) : token,
};
// decode header/payload if JWT-like
try {
if (typeof token === 'string' && token.split('.').length >= 2) {
const parts = token.split('.');
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
try { out.header = JSON.parse(Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
try { out.payload = JSON.parse(Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
} else {
out.header = null; out.payload = null;
}
} catch (e) {
out.decodeError = String(e);
}
// Local verify using configured secret (HS256) if available
try {
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
if (LIVEKIT_API_SECRET) {
try {
const jwt = require('jsonwebtoken');
const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
out.localVerify = { ok: true, payload: verified };
} catch (e) {
out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) };
}
} else {
out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' };
}
} catch (e) {
out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) };
}
// Proxy to LiveKit validate endpoint to get server-side validation result
try {
const result = await validateTokenWithLiveKit(token);
out.livekit = { status: result.status, ok: result.ok, body: result.body };
} catch (e) {
out.livekit = { ok: false, error: String(e) };
}
// Attach raw token only in development to avoid leaking secrets in other envs
try {
if (process.env.NODE_ENV === 'development' || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
out.token = token;
}
} catch (e) {}
return res.json(out);
} catch (err) {
console.error('[DEBUG] /debug/session/:id/full failed', err);
return res.status(500).json({ error: 'failed', details: String(err) });
}
});

View File

@ -1,7 +1,11 @@
# LiveKit Configuration
VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
VITE_TOKEN_SERVER_URL=https://avanzacast-studio.bfzqqk.easypanel.host
# Example env for broadcast-panel (Vite)
# Enable/disable automatic getUserMedia preflight in StudioRoom
# Set to 1/true to enable (default), 0/false to disable
VITE_STUDIO_PREFLIGHT=1
# Other env examples used by the package (adjust as needed)
# VITE_BACKEND_API_URL=https://api.example.com
# VITE_BACKEND_TOKENS_URL=https://tokens.example.com
# VITE_BROADCASTPANEL_URL=https://broadcastpanel.example.com
# VITE_LIVEKIT_WS_URL=wss://livekit.example.com
# Application Configuration
VITE_APP_NAME=AvanzaCast Broadcast Panel
VITE_APP_VERSION=1.0.0

View File

@ -1,38 +1,86 @@
Local E2E runner
# E2E visual baseline — instrucciones de uso
This E2E runner automates the UI flow for Broadcast Panel -> StudioPortal -> LiveKit token handshake.
Este README explica cómo verificar que un `token` y una `room` habilitan la videollamada con el flujo E2E del proyecto.
Prereqs
- Node 18+
- npm packages installed in `packages/broadcast-panel` (run `npm install` there)
- Either a local Chrome launched with remote debugging or access to a remote browser service like browserless
Requisitos
- Node.js (>=16)
- Chrome/Chromium instalado en el host donde corras la prueba
- Opcional: browserless si no quieres ejecutar Chrome local
Start local Chrome with remote debugging (example):
Archivos relevantes
- `packages/broadcast-panel/e2e/generate_visual_baseline.js` — script E2E que crea una sesión en el token server, se conecta a Chrome remoto/browserless, navega al estudio y publica token vía postMessage.
- `packages/broadcast-panel/e2e/start-chrome-remote.sh` — script para arrancar Chrome/Chromium en modo remote-debugging.
Flujo de verificación paso a paso
1) Levantar token server (opcional si ya tienes uno)
Si no tienes un token server, puedes usar la ruta Next.js incluida: `/app/api/session/route.ts`.
- Asegúrate de tener `LIVEKIT_API_KEY` y `LIVEKIT_API_SECRET` en el entorno (o usa un mock server local).
2) Arrancar Chrome con remote debugging
Opción segura (localhost):
```bash
cd packages/broadcast-panel/e2e
chmod +x start-chrome-remote.sh
./start-chrome-remote.sh
# verify
curl http://localhost:9222/json/version
```
Run the E2E runner connecting to a remote Chrome (default) or browserless:
Opción con exposición pública (NO RECOMENDADA):
```bash
REMOTE_DEBUG_PUBLIC=1 REMOTE_DEBUG_PORT=9222 ./start-chrome-remote.sh
```
3) Comprobar endpoint CDP
```bash
# Connect to local remote-debugging chrome
cd packages/broadcast-panel
node e2e/run_local_e2e.js --ws http://localhost:9222 --show
# Or connect to browserless remote (example)
REMOTE_WS="wss://browserless.bfzqqk.easypanel.host?token=e2e098863b912f6a178b68e71ec3c58d" node e2e/run_local_e2e.js --show
# To point to specific backend/broadcast hosts (useful when running remote browserless):
REMOTE_WS="..." BROADCAST_URL="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" TOKEN_SERVER="https://avanzacast-servertokens.bfzqqk.easypanel.host" node e2e/run_local_e2e.js --show
curl -sS http://localhost:9222/json/version
curl -sS http://localhost:9222/json/list
```
Notes
- The script will postMessage the LIVEKIT_TOKEN to the StudioPortal when a token is created in backend-api.
- If StudioPortal does not auto-connect, the runner will attempt to click a "Conectar" button (class .btn-small).
- Logs and screenshots are saved to `packages/broadcast-panel/e2e/out`.
Busca `webSocketDebuggerUrl` en la salida.
4) Ejecutar el script E2E
```bash
# ejemplo apuntando al chrome local y al token server local (Next.js en 3000 o 3001)
REMOTE_DEBUG_ADDRESS=127.0.0.1 REMOTE_DEBUG_PORT=9222 TOKEN_SERVER="http://localhost:3001" ROOM="e2e-room-1" BROADCAST_URL="http://url_dominio_publico/room/:idroom" node packages/broadcast-panel/e2e/generate_visual_baseline.js
```
El script escribirá logs a `e2e/out/generate_visual_baseline.log` y guardará capturas en `e2e/out/visual_<timestamp>/studio.png`.
5) Validar en el frontend que el token es recibido
El script hace `window.postMessage({ type: 'LIVEKIT_TOKEN', token })`. En el frontend (estudio) debes tener un listener similar a:
```js
window.addEventListener('message', (ev) => {
if (ev.origin !== window.location.origin) return;
if (ev.data && ev.data.type === 'LIVEKIT_TOKEN') {
const token = ev.data.token;
// conectar a LiveKit con token: room.connect(serverUrl, token) o usando components-react
}
});
```
6) Debug rápido
- Si `createSession` falla: revisa `TOKEN_SERVER` y mira `curl -v TOKEN_SERVER/api/session`.
- Si no se resuelve CDP: valida `curl http://localhost:9222/json/version`.
- Si el frontend no conecta: abre la consola del navegador (o inspecciona la ejecución en Puppeteer con page.on('console') logs en `e2e/out/generate_visual_baseline.log`).
7) Qué revisar si la videollamada no se inicia
- El token tiene que ser válido (LIVEKIT_API_KEY/SECRET correctos y server LiveKit accesible desde el frontend).
- `studioUrl` debe corresponder al dominio público donde está el frontend. `BROADCAST_URL` o `studioUrl` devuelto por el token server debe ser exactamente la URL que abrirás (por ejemplo: `https://miapp.example/room/abc123`).
- El frontend debe usar el token para conectar al room (Room.connect o LiveKit components).
Si quieres, puedo:
- Añadir una ruta de verificación GET `/api/session/:id` que devuelva la sesión guardada (facilita recuperar token tras POST).
- Añadir un mock server `tools/mock-token-server.js` para pruebas locales sin credenciales.
Dime si quieres que cree el mock-server o adapte la API a un flujo GET/POST adicional.

View File

@ -0,0 +1,155 @@
#!/usr/bin/env node
// E2E script: connect to remote Chrome on localhost:9222 and run token flow (create session -> open Broadcast Panel -> Entrar al Estudio)
// Usage: BROWSERLESS_WS not used. Instead set REMOTE_DEBUGGER_URL (e.g. http://localhost:9222) or default to http://localhost:9222
// Environment variables:
// TOKEN_SERVER (default: https://avanzacast-servertokens.bfzqqk.easypanel.host)
// ROOM, USERNAME
// OUT_DIR to save artifacts
const fetch = require('node-fetch');
const puppeteer = require('puppeteer-core');
const fs = require('fs');
const path = require('path');
async function main() {
const REMOTE_DEBUGGER = process.env.REMOTE_DEBUGGER_URL || 'http://localhost:9222';
const TOKEN_SERVER = process.env.TOKEN_SERVER || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
const ROOM = process.env.ROOM || 'e2e-room';
const USERNAME = process.env.USERNAME || 'e2e-runner';
const OUT_DIR = process.env.OUT_DIR || '/tmp';
function outLog(...args) {
console.log(...args);
if (OUT_DIR) {
try { fs.appendFileSync(path.join(OUT_DIR, 'e2e_remote_9222.log'), args.map(String).join(' ') + '\n'); } catch (e) {}
}
}
outLog('E2E remote 9222 starting', { REMOTE_DEBUGGER, TOKEN_SERVER, ROOM, USERNAME, OUT_DIR });
// Create session on token server
let sessResp;
try {
sessResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: ROOM, username: USERNAME, ttl: 300 })
});
} catch (err) {
outLog('Network error when creating session:', String(err));
process.exit(3);
}
outLog('Token server status', sessResp.status);
let sessBodyText = await sessResp.text().catch(() => '');
let sessJson = null;
try { sessJson = JSON.parse(sessBodyText); } catch(e) { sessJson = null }
outLog('Session response body start:', String(sessBodyText).slice(0,400));
if (!sessResp.ok) {
outLog('Failed to create session');
process.exit(4);
}
if (!sessJson) {
outLog('Invalid JSON from token server');
process.exit(5);
}
const sessionId = sessJson.id || sessJson.sessionId || null;
const token = sessJson.token || null;
const studioUrl = sessJson.studioUrl || sessJson.redirectUrl || sessJson.url || null;
outLog('Session created', { sessionId: sessionId ? sessionId.slice(0,10) + '...' : null, studioUrl, hasToken: !!token });
// Connect to remote Chrome DevTools
// We expect a running Chrome with --remote-debugging-port=9222. puppeteer.connect accepts websocket endpoint; fetch the ws endpoint from /json/version
let wsEndpoint;
try {
const versionResp = await fetch(`${REMOTE_DEBUGGER.replace(/\/$/, '')}/json/version`);
const verJson = await versionResp.json();
wsEndpoint = verJson.webSocketDebuggerUrl || null;
} catch (err) {
outLog('Failed to query remote debugger /json/version:', String(err));
process.exit(6);
}
if (!wsEndpoint) {
outLog('Remote debugger did not return webSocketDebuggerUrl. Check Chrome is running with --remote-debugging-port=9222');
process.exit(7);
}
outLog('Connecting to remote chrome wsEndpoint', wsEndpoint);
let browser;
try {
browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1366, height: 768 }, timeout: 20000 });
} catch (err) {
outLog('puppeteer.connect failed:', String(err));
process.exit(8);
}
try {
const page = await browser.newPage();
page.on('console', msg => outLog('[BROWSER]', msg.type(), msg.text()));
page.on('pageerror', err => outLog('[PAGEERROR]', String(err)));
// Navigate to either the studioUrl returned or to broadcast panel root and click 'Entrar al Estudio'
let targetUrl = studioUrl;
if (!targetUrl && sessionId) {
// use broadcast panel host from environment or default
const BROADCAST_HOST = process.env.BROADCAST_HOST || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
targetUrl = `${BROADCAST_HOST.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`;
}
if (!targetUrl) {
outLog('No target URL to open');
process.exit(9);
}
outLog('Opening target URL:', targetUrl);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(err => { outLog('page.goto error', String(err)); throw err; });
// If page has a visible button/link text 'Entrar' or 'Entrar al Estudio', click it
const clickSelectors = ["button[aria-label='Entrar al Estudio']","button:has-text('Entrar al Estudio')","button:has-text('Entrar')","a:has-text('Entrar al Estudio')","a:has-text('Entrar')"];
let clicked = false;
for (const sel of clickSelectors) {
try {
// Puppeteer does not support :has-text in older versions; use evaluate to find by text
const found = await page.evaluate((text) => {
const els = Array.from(document.querySelectorAll('button,a'));
const el = els.find(e => (e.innerText || '').trim().toLowerCase().includes(text.toLowerCase()));
if (el) { el.scrollIntoView(); el.click(); return true; }
return false;
}, sel.replace(/.*:has-text\('(.+)'\).*/, '$1'));
if (found) { clicked = true; outLog('Clicked element matching', sel); break; }
} catch (e) { /* ignore */ }
}
// If token is present, postMessage it
if (token) {
outLog('Posting token via postMessage (len=' + String(token.length) + ')');
try {
await page.evaluate((tk) => { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, url: window.location.href }, window.location.origin); }, token);
} catch (e) { outLog('postMessage failed', String(e)); }
} else {
outLog('No token present in session response; relying on redirect flow');
}
// Wait for token received indicator in page
const gotToken = await page.waitForFunction(() => document.body && document.body.innerText && (document.body.innerText.includes('Token recibido') || document.body.innerText.includes('Token recibido desde Broadcast Panel') || document.body.innerText.includes('Esperando token')), { timeout: 10000 }).catch(() => false);
outLog('Token indicator found:', !!gotToken);
// Save artifacts
if (OUT_DIR) {
try { const html = await page.content(); fs.writeFileSync(path.join(OUT_DIR, `page_${sessionId || 'noid'}.html`), html); outLog('Saved page HTML'); } catch(e) { outLog('Failed to save HTML', String(e)); }
try { await page.screenshot({ path: path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`), fullPage: true }); outLog('Saved screenshot'); } catch(e) { outLog('Failed to save screenshot', String(e)); }
}
await page.close();
} finally {
try { await browser.disconnect(); } catch(e) {}
}
outLog('E2E remote 9222 finished');
}
main().catch(err => { console.error('Unhandled error in main:', err && err.stack ? err.stack : String(err)); process.exit(99); });

View File

@ -0,0 +1,222 @@
#!/usr/bin/env node
/*
generate_visual_baseline.js
- Creates a session on the token server
- Connects to a remote Chrome with debugging enabled (default http://localhost:9222),
or to browserless via WS/CDP using puppeteer-core if remote-debug is not available
- Navigates to studioUrl and posts token if present
- Saves a screenshot to e2e/baseline/studio.png and e2e/out/<timestamp>/studio.png
*/
// Diagnostic helpers early to surface environment issues quickly
try {
console.log('=== generate_visual_baseline start ===');
console.log('cwd:', process.cwd());
console.log('node version:', process.version);
console.log('SCRIPT: generate_visual_baseline.js');
console.log('ENV: REMOTE_DEBUG, REMOTE_DEBUG_WS, BROWSERLESS_WS, BROWSERLESS_TOKEN, TOKEN_SERVER, BROADCAST_URL');
console.log('REMOTE_DEBUG=', process.env.REMOTE_DEBUG, 'REMOTE_DEBUG_WS=', process.env.REMOTE_DEBUG_WS || process.env.REMOTE_WS, 'REMOTE_DEBUG_PORT=', process.env.REMOTE_DEBUG_PORT);
console.log('BROWSERLESS_WS=', process.env.BROWSERLESS_WS, 'BROWSERLESS_TOKEN=', !!process.env.BROWSERLESS_TOKEN);
console.log('TOKEN_SERVER=', process.env.TOKEN_SERVER || process.env.BACKEND);
console.log('BROADCAST_URL=', process.env.BROADCAST_URL);
} catch (e) { /* ignore */ }
process.on('uncaughtException', (err) => {
console.error('UNCAUGHT EXCEPTION:', err && err.stack ? err.stack : err);
process.exit(2);
});
process.on('unhandledRejection', (reason) => {
console.error('UNHANDLED REJECTION:', reason && reason.stack ? reason.stack : reason);
process.exit(2);
});
const fetch = global.fetch ? global.fetch : (...args) => import('node-fetch').then(m => m.default(...args));
const puppeteer = require('puppeteer-core');
const fs = require('fs');
const path = require('path');
// Enhanced remote debug resolution: support REMOTE_DEBUG_ADDRESS + REMOTE_DEBUG_PORT
const REMOTE_DEBUG_ADDRESS = process.env.REMOTE_DEBUG_ADDRESS || process.env.REMOTE_DEBUG_HOST || 'localhost';
const REMOTE_DEBUG_PORT = process.env.REMOTE_DEBUG_PORT || process.env.REMOTE_PORT || '';
let REMOTE_DEBUG_WS = process.env.REMOTE_WS || process.env.REMOTE_DEBUG || '';
if (!REMOTE_DEBUG_WS) {
if (REMOTE_DEBUG_PORT) {
REMOTE_DEBUG_WS = `http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT}`;
} else {
REMOTE_DEBUG_WS = 'http://localhost:9222';
}
}
const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.REMOTE_WSE || 'wss://browserless.bfzqqk.easypanel.host';
const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || process.env.BROWSERLESS || '';
const TOKEN_SERVER = process.env.TOKEN_SERVER || process.env.BACKEND || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
// Cambiar BROADCAST default para seguir la estructura rooms/<room>
const BROADCAST_BASE = process.env.BROADCAST_BASE || process.env.STUDIO_BASE || 'http://localhost:5175';
const ROOM = process.env.ROOM || 'iuqiw-aksjka';
const BROADCAST = process.env.BROADCAST_URL || `${BROADCAST_BASE.replace(/\/$/, '')}/rooms/${encodeURIComponent(ROOM)}`;
const CDP_RESOLVE_RETRIES = Number(process.env.CDP_RESOLVE_RETRIES || 6);
const CDP_RESOLVE_INTERVAL = Number(process.env.CDP_RESOLVE_INTERVAL_MS || 2000);
function log(...args){
try { console.log(...args); } catch (e) {}
try {
const outdir = path.join(process.cwd(), 'e2e', 'out');
try { fs.mkdirSync(outdir, { recursive: true }); } catch(e) {}
const logfile = path.join(outdir, 'generate_visual_baseline.log');
try { fs.appendFileSync(logfile, `[${new Date().toISOString()}] ${args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}\n`); } catch(e) {}
} catch (e) {}
}
async function createSession(){
log('Creating session on token server', TOKEN_SERVER, ROOM);
const res = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: ROOM, username: 'visual-runner', ttl: 300 }),
});
const text = await res.text(); try { return JSON.parse(text); } catch(e){ return null; }
}
async function resolveRemoteWSEndpoint(raw){
if (!raw) return null;
raw = String(raw).trim();
// if already a ws:// or wss:// endpoint, return as-is
if (raw.startsWith('ws://') || raw.startsWith('wss://')) return raw;
// if given as a numeric port, assume localhost:port
if (/^\d+$/.test(raw)) raw = `http://localhost:${raw}`;
// if it's missing scheme, assume http
if (!raw.startsWith('http://') && !raw.startsWith('https://')) raw = `http://${raw}`;
try{
const ver = await fetch(raw.replace(/\/$/, '') + '/json/version');
if (ver && ver.ok){ const j = await ver.json(); if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; }
}catch(e){ log('resolveRemoteWSEndpoint /json/version error', String(e)); }
try{
const list = await fetch(raw.replace(/\/$/, '') + '/json/list');
if (list && list.ok){ const arr = await list.json(); if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; }
}catch(e){ log('resolveRemoteWSEndpoint /json/list error', String(e)); }
try{
const j = await fetch(raw.replace(/\/$/, '') + '/json');
if (j && j.ok){ const arr = await j.json(); if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; }
}catch(e){ log('resolveRemoteWSEndpoint /json error', String(e)); }
return null;
}
async function waitForCDP(raw, retries = CDP_RESOLVE_RETRIES, interval = CDP_RESOLVE_INTERVAL){
let attempt = 0;
while(attempt < retries){
attempt++;
try{
log(`CDP resolve attempt ${attempt}/${retries} for ${raw}`);
const resolved = await resolveRemoteWSEndpoint(raw);
if (resolved) return resolved;
}catch(e){ log('waitForCDP attempt error', String(e)); }
await new Promise(r => setTimeout(r, interval));
}
return null;
}
(async ()=>{
try{
const session = await createSession();
if (!session) throw new Error('Failed to create session');
log('Session created:', session.id, 'studioUrl=', session.studioUrl || session.url);
const studioUrl = session.studioUrl || session.redirectUrl || session.url || BROADCAST;
let token = session.token || null;
if (!token && session.id) {
// try GET
try{
const getResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(session.id)}`);
const txt = await getResp.text(); const json = JSON.parse(txt);
if (json && json.token) token = json.token;
}catch(e){ log('GET session token failed', String(e)); }
}
// Try to resolve CDP endpoint from remote debug first
let connectEndpoint = null;
if (REMOTE_DEBUG_WS) {
try{
const resolved = await waitForCDP(REMOTE_DEBUG_WS);
if (resolved) {
connectEndpoint = resolved;
log('Resolved remote-debug CDP endpoint:', connectEndpoint);
} else {
log('No CDP endpoint discovered at remote-debug URL after retries:', REMOTE_DEBUG_WS);
}
} catch(e){ log('Error resolving remote-debug endpoint', String(e)); }
}
// If remote debug not available, try browserless
if (!connectEndpoint && BROWSERLESS_WS) {
try{
if ((BROWSERLESS_WS.startsWith('ws://') || BROWSERLESS_WS.startsWith('wss://')) && BROWSERLESS_TOKEN) {
connectEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`;
} else {
connectEndpoint = await waitForCDP(BROWSERLESS_WS + (BROWSERLESS_TOKEN ? (BROWSERLESS_WS.includes('?') ? '&' : '?') + `token=${encodeURIComponent(BROWSERLESS_TOKEN)}` : ''));
if (!connectEndpoint) connectEndpoint = await waitForCDP(BROWSERLESS_WS);
}
log('Browserless resolution result:', connectEndpoint || '(none)');
}catch(e){ log('Browserless resolve error', String(e)); }
}
log('connectEndpoint resolved to', connectEndpoint || '(none)');
let browser = null;
if (connectEndpoint) {
try{
browser = await puppeteer.connect({ browserWSEndpoint: connectEndpoint, timeout: 30000, ignoreHTTPSErrors: true });
log('Connected to remote browser via puppeteer (CDP)');
}catch(e){
log('puppeteer.connect failed:', String(e));
browser = null;
}
}
if (!browser) {
log('Launching local puppeteer fallback');
browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
}
const page = await browser.newPage();
page.on('console', msg => { try{ log('[BROWSER]', msg.type(), msg.text()); } catch(e){} });
page.on('pageerror', err => log('[PAGEERROR]', err && err.stack ? err.stack : String(err)));
log('Navigating to', studioUrl);
await page.goto(studioUrl, { waitUntil: 'networkidle2' }).catch(e=>{ log('page.goto failed', String(e)); });
await page.waitForTimeout(1200);
if (token) {
try{
log('Posting token to page via postMessage (length', token.length, ')');
await page.evaluate((tk)=>{ try{ window.postMessage({ type:'LIVEKIT_TOKEN', token: tk, room: '', url: window.location.href }, window.location.origin); } catch(e){} }, token);
}catch(e){ log('postMessage evaluate failed', String(e)); }
}
await page.waitForTimeout(1200);
const baselineDir = path.join(process.cwd(), 'e2e', 'baseline');
try{ fs.mkdirSync(baselineDir, { recursive: true }); } catch(e){}
const outDir = path.join(process.cwd(), 'e2e', 'out', `visual_${Date.now()}`);
try{ fs.mkdirSync(outDir, { recursive: true }); } catch(e){}
const baselinePath = path.join(baselineDir, 'studio.png');
const outPath = path.join(outDir, 'studio.png');
await page.screenshot({ path: outPath, fullPage: false });
log('Saved capture to', outPath);
// Copy to baseline if not exists or if FORCE_BASELINE=1
const force = process.env.FORCE_BASELINE === '1';
if (!fs.existsSync(baselinePath) || force) {
try{ fs.copyFileSync(outPath, baselinePath); log('Wrote baseline to', baselinePath); } catch(e){ log('Failed writing baseline', String(e)); }
} else {
log('Baseline exists at', baselinePath, '(not overwritten)');
}
try{ await page.close(); } catch(e){}
try{ await browser.close(); } catch(e){}
log('Done');
process.exit(0);
}catch(err){
console.error('Fatal', err && err.stack ? err.stack : err);
process.exit(2);
}
})();

View File

@ -0,0 +1,18 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './',
timeout: 60_000,
expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.02 } },
use: {
headless: true,
viewport: { width: 1280, height: 800 },
actionTimeout: 10_000,
trace: 'on-first-retry',
ignoreHTTPSErrors: true,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});

View File

@ -0,0 +1,124 @@
#!/usr/bin/env zsh
set -euo pipefail
# run_e2e_auto.sh
# Script helper para ejecutar los runners E2E del broadcast-panel (browserless o local)
# - Crea un OUT_DIR con timestamp
# - Exporta variables necesarias y ejecuta el runner seleccionado
# - Guarda stdout/stderr en OUT_DIR/e2e.log
# Defaults (puedes sobrescribir por env o con flags)
BROWSERLESS_WS_DEFAULT="wss://browserless.bfzqqk.easypanel.host"
BROWSERLESS_TOKEN_DEFAULT=""
ROOM_DEFAULT="e2e-room"
TOKEN_SERVER_DEFAULT="https://avanzacast-servertokens.bfzqqk.easypanel.host"
BROADCAST_URL_DEFAULT="http://avanzacast-studio.bfzqqk.easypanel.host"
# Parse args simples
LOCAL_MODE=0
SHOW=0
BROWSERLESS_WS="${BROWSERLESS_WS:-$BROWSERLESS_WS_DEFAULT}"
BROWSERLESS_TOKEN="${BROWSERLESS_TOKEN:-$BROWSERLESS_TOKEN_DEFAULT}"
ROOM="${ROOM:-$ROOM_DEFAULT}"
TOKEN_SERVER="${TOKEN_SERVER:-$TOKEN_SERVER_DEFAULT}"
BROADCAST_URL="${BROADCAST_URL:-$BROADCAST_URL_DEFAULT}"
OUT_DIR="${OUT_DIR:-}"
usage() {
cat <<EOF
Usage: run_e2e_auto.sh [--local] [--show] [--room NAME] [--ws URL] [--token TOKEN] [--token-server URL] [--broadcast-url URL]
Options:
--local Use run_local_e2e.js (con --ws o REMOTE_WS para apuntar a Chrome remoto)
--show Pass flag --show to the local runner (visual mode)
--room NAME Room name to create (env ROOM)
--ws URL Browserless/remote ws base (env BROWSERLESS_WS / REMOTE_WS)
--token TOKEN Browserless token (env BROWSERLESS_TOKEN)
--token-server URL Token server (env TOKEN_SERVER)
--broadcast-url URL Broadcast panel URL (env BROADCAST_URL)
--out DIR Directory where logs/screenshots will be stored (overrides default)
-h, --help Show this help
Examples:
# Run against browserless remote (recommended)
BROWSERLESS_TOKEN=xxx ./run_e2e_auto.sh --room iuqiw-aksjka
# Run local remote-debugging chrome
./run_e2e_auto.sh --local --ws http://localhost:9222 --show
EOF
}
# simple arg loop
while [[ $# -gt 0 ]]; do
case "$1" in
--local) LOCAL_MODE=1; shift ;;
--show) SHOW=1; shift ;;
--room) ROOM="$2"; shift 2 ;;
--ws) BROWSERLESS_WS="$2"; shift 2 ;;
--token) BROWSERLESS_TOKEN="$2"; shift 2 ;;
--token-server) TOKEN_SERVER="$2"; shift 2 ;;
--broadcast-url) BROADCAST_URL="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1"; usage; exit 2 ;;
esac
done
# prepare outdir with timestamp if not provided
if [[ -z "${OUT_DIR}" ]]; then
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
OUT_DIR="./e2e/out/$TIMESTAMP"
fi
mkdir -p "$OUT_DIR"
# decide runner
if [[ "$LOCAL_MODE" -eq 1 ]]; then
RUNNER_JS="e2e/run_local_e2e.js"
# If SHOW requested, pass flag through env to the runner via --show
RUN_CMD=(node "$RUNNER_JS")
if [[ "$SHOW" -eq 1 ]]; then RUN_CMD+=(--show); fi
else
RUNNER_JS="e2e/run_browserless_e2e.js"
RUN_CMD=(node "$RUNNER_JS")
fi
echo "[run_e2e_auto] Starting E2E runner"
echo " Runner: $RUNNER_JS"
echo " OUT_DIR: $OUT_DIR"
echo " ROOM: $ROOM"
if [[ -n "$BROWSERLESS_TOKEN" ]]; then
echo " BROWSERLESS_TOKEN: (present)"
else
echo " BROWSERLESS_TOKEN: (empty)"
fi
# Build environment for the child process
export BROWSERLESS_WS BROWSERLESS_TOKEN ROOM TOKEN_SERVER BROADCAST_URL OUT_DIR
# Show final command (for debugging)
echo "[run_e2e_auto] Executing: ${RUN_CMD[@]}"
# Run and tee output
LOGFILE="$OUT_DIR/e2e.log"
(
echo "=== START $(date -u) ==="
echo "Command: ${RUN_CMD[@]}"
echo "Environment: BROWSERLESS_WS=$BROWSERLESS_WS ROOM=$ROOM TOKEN_SERVER=$TOKEN_SERVER BROADCAST_URL=$BROADCAST_URL"
echo "--- OUTPUT ---"
) > "$LOGFILE"
# run and capture both stdout and stderr
{
"${RUN_CMD[@]}" 2>&1
} | tee -a "$LOGFILE"
EXIT_CODE=${PIPESTATUS[1]:-0}
echo "=== FINISH $(date -u) exit=$EXIT_CODE ===" | tee -a "$LOGFILE"
if [[ $EXIT_CODE -ne 0 ]]; then
echo "Runner exited with code $EXIT_CODE. Revisa $LOGFILE y los archivos en $OUT_DIR"
exit $EXIT_CODE
fi
echo "E2E runner finished OK. Revisa logs y capturas en: $OUT_DIR"
exit 0

View File

@ -0,0 +1,115 @@
#!/usr/bin/env node
// E2E test: session_loader
// - Creates a session via backend-api POST /api/session
// - Opens broadcast panel at /<sessionId>
// - Waits for sessionStorage key and verifies token/url present
const puppeteer = require('puppeteer');
const fetch = require('node-fetch');
(async () => {
const BACKEND = process.env.BACKEND || 'http://localhost:4000';
const BROADCAST = process.env.BROADCAST || 'http://localhost:5175';
const HEADLESS = process.env.HEADLESS !== '0'; // set HEADLESS=0 to see browser
console.log('Using BACKEND=%s BROADCAST=%s HEADLESS=%s', BACKEND, BROADCAST, HEADLESS);
// 1) create session via backend
const room = 'e2e-test-room-' + Math.random().toString(36).slice(2,8);
const username = 'e2e-user';
console.log('Creating session for room=%s username=%s', room, username);
let resp;
try {
resp = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username })
});
} catch (err) {
console.error('Failed to reach backend to create session:', String(err));
process.exit(2);
}
if (!resp.ok) {
console.error('Failed to create session on backend:', resp.status);
const txt = await resp.text().catch(()=>null);
console.error('Body:', txt);
process.exit(2);
}
const js = await resp.json();
const sessionId = js.id || js.sessionId || js.id;
if (!sessionId) {
console.error('Session creation response missing id:', js);
process.exit(2);
}
console.log('Created session id=', sessionId);
// 2) open broadcast panel at /<id>
const url = `${BROADCAST.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`;
console.log('Opening', url);
const browser = await puppeteer.launch({ headless: HEADLESS, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage();
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
page.on('pageerror', err => console.error('PAGE ERROR:', err.message));
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
} catch (e) {
console.error('Failed to open broadcast panel url:', e.message);
// capture screenshot for debugging if possible
try { await page.screenshot({ path: '/tmp/e2e_page_error.png', fullPage: false }); console.log('Saved screenshot to /tmp/e2e_page_error.png'); } catch(e){}
await browser.close();
process.exit(3);
}
// wait for sessionStorage key with retry
const STORE_KEY = process.env.STORE_KEY || 'avanzacast_studio_session';
console.log('Waiting for sessionStorage key=', STORE_KEY);
let found = null;
try {
found = await page.waitForFunction((k) => { try { return !!sessionStorage.getItem(k); } catch (e) { return false } }, { timeout: 15000 }, STORE_KEY).catch(()=>null);
} catch (err) { found = null }
if (!found) {
console.error('sessionStorage key not found within timeout');
try { const html = await page.content(); console.error('Page content snapshot length:', html.length); } catch(e){}
try { await page.screenshot({ path: '/tmp/e2e_no_sessionstorage.png' }); console.log('Saved screenshot to /tmp/e2e_no_sessionstorage.png'); } catch(e){}
await browser.close();
process.exit(4);
}
const stored = await page.evaluate((k) => sessionStorage.getItem(k), STORE_KEY);
console.log('Stored value (truncated):', stored && stored.slice(0, 400));
let parsed = null;
try { parsed = JSON.parse(stored); } catch (e) { console.error('Failed to parse stored JSON', e); }
if (!parsed) {
console.error('No valid session stored');
await browser.close();
process.exit(5);
}
// Accept either token or participantToken
const token = parsed.token || parsed.participantToken || parsed.access_token || parsed.token;
const urlField = parsed.url || parsed.serverUrl || parsed.studioUrl || parsed.redirectUrl;
console.log('Extracted token prefix:', token ? token.slice(0, 24) + '...' : '(none)');
console.log('Extracted url:', urlField || '(none)');
if (!token) {
console.error('Token not found in stored session');
await browser.close();
process.exit(6);
}
// Optionally validate token via backend validate proxy
try {
const v = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session/validate?token=${encodeURIComponent(token)}`);
const vt = await v.text();
console.log('Validate response (first 400 chars):', vt && vt.slice(0,400));
} catch (e) {
console.warn('Validate proxy call failed:', e.message);
}
console.log('E2E test succeeded');
await browser.close();
process.exit(0);
})();

91
packages/broadcast-panel/e2e/start-chrome-remote.sh Normal file → Executable file
View File

@ -4,25 +4,84 @@
# Uso:
# chmod +x start-chrome-remote.sh
# ./start-chrome-remote.sh
#
# Mejoras:
# - Permite configurar REMOTE_DEBUG_ADDRESS (por defecto 127.0.0.1).
# - Si REMOTE_DEBUG_PUBLIC=1 se fuerza 0.0.0.0 (exponer a red pública, INSEGURO).
# - Permite configurar REMOTE_DEBUG_PORT (por defecto 9222).
# - Permite pasar flags adicionales con EXTRA_CHROME_FLAGS.
# - Mantiene comportamiento headless cuando no hay DISPLAY.
PROFILE_DIR="$HOME/.config/avanzacast-e2e-profile"
mkdir -p "$PROFILE_DIR"
echo "Chrome arrancado (si el binario es válido). Remote debugging en: http://localhost:9222/"
--window-size=1280,900 "$@" &
--disable-extensions \
--disable-backgrounding-occluded-windows \
--no-first-run \
--user-data-dir="$PROFILE_DIR" \
--remote-debugging-port=9222 \
"$CHROME_BIN" \
# Ejecutar Chrome con puerto 9222 (remote debugging) y perfil persistente
fi
CHROME_BIN=/usr/bin/google-chrome
echo "Advertencia: no se encontró $CHROME_BIN ejecutable, intentando /usr/bin/google-chrome"
# Permite sobreescribir binario con variable de entorno CHROME_BIN
CHROME_BIN=${CHROME_BIN:-/usr/bin/google-chrome}
if [ ! -x "$CHROME_BIN" ]; then
CHROME_BIN=${CHROME_BIN:-/usr/bin/google-chrome-stable}
# Ajusta la ruta al binario de Chrome si tu sistema usa otra ruta
CHROME_BIN=/usr/bin/google-chrome-stable
fi
if [ ! -x "$CHROME_BIN" ]; then
CHROME_BIN=/usr/bin/chromium-browser
fi
if [ ! -x "$CHROME_BIN" ]; then
CHROME_BIN=/usr/bin/chromium
fi
if [ ! -x "$CHROME_BIN" ]; then
echo "No se encontró un binario de Chrome/Chromium. Exporta CHROME_BIN o instala Chrome/Chromium." >&2
exit 1
fi
# Configurables
REMOTE_DEBUG_PORT=${REMOTE_DEBUG_PORT:-9222}
# By default restrict debugging to localhost; set REMOTE_DEBUG_PUBLIC=1 to allow 0.0.0.0
if [ "${REMOTE_DEBUG_PUBLIC:-0}" = "1" ]; then
REMOTE_DEBUG_ADDRESS=${REMOTE_DEBUG_ADDRESS:-0.0.0.0}
else
REMOTE_DEBUG_ADDRESS=${REMOTE_DEBUG_ADDRESS:-127.0.0.1}
fi
# Extra flags puede ser usado para ajustar comportamiento en CI o debug
EXTRA_CHROME_FLAGS=${EXTRA_CHROME_FLAGS:-}
echo "Arrancando Chrome/Chromium con remote-debugging en http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT} usando perfil: $PROFILE_DIR"
if [ "${REMOTE_DEBUG_PUBLIC:-0}" = "1" ]; then
echo "ADVERTENCIA: REMOTE_DEBUG_PUBLIC=1 permite conexiones desde cualquier host. Asegúrate de usar firewall/SSH tunneling para seguridad." >&2
fi
# Opciones: usa headless nuevo por defecto si se ejecuta en CI (no hay DISPLAY), pero permite pasar flags extra.
FLAGS=(
--remote-debugging-port=${REMOTE_DEBUG_PORT}
--remote-debugging-address=${REMOTE_DEBUG_ADDRESS}
--user-data-dir="$PROFILE_DIR"
--no-first-run
--disable-extensions
--disable-backgrounding-occluded-windows
--window-size=1280,900
--enable-logging
--v=1
)
# Si no hay DISPLAY, arrancamos headless; si hay DISPLAY, arrancamos en modo normal
if [ -z "$DISPLAY" ]; then
FLAGS+=(--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage)
fi
# Añadir flags extra si se proporcionan
if [ -n "$EXTRA_CHROME_FLAGS" ]; then
# Separa por espacio, permite pasar múltiples flags
FLAGS+=(${=EXTRA_CHROME_FLAGS})
fi
# Ejecutar en background y devolver control
"$CHROME_BIN" "${(@)FLAGS}" "$@" &
CHROME_PID=$!
echo "Chrome PID: $CHROME_PID"
echo "Remote debugging URL: http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT}/"
# Notas de uso:
# - Para exponer a otra máquina de forma segura, crea un tunnel SSH en lugar de exponer el puerto públicamente:
# ssh -L 9222:localhost:9222 user@remoteserver
# - Para probar la disponibilidad del endpoint:
# curl -sS http://localhost:${REMOTE_DEBUG_PORT}/json/version

View File

@ -0,0 +1,17 @@
(async () => {
const { chromium } = require('playwright');
const ws = process.env.BROWSERLESS_WS || 'wss://browserless.bfzqqk.easypanel.host';
const token = process.env.BROWSERLESS_TOKEN || '';
const endpoint = ws + (ws.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
console.log('Attempting CDP connect to', endpoint);
try {
const browser = await chromium.connectOverCDP(endpoint, { timeout: 20000 });
console.log('Connected! version:', await browser.version());
await browser.close();
process.exit(0);
} catch (e) {
console.error('CDP connect failed:', e && e.stack ? e.stack : e);
process.exit(1);
}
})();

View File

@ -0,0 +1,132 @@
import { test, expect } from '@playwright/test';
import { chromium as pwChromium } from 'playwright';
// Defaults point to public deployments (overrideable via env)
const BACKEND = process.env.TOKEN_SERVER || process.env.BACKEND || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
const BROADCAST = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
const ROOM = process.env.ROOM || `e2e-visual-${Date.now()}`;
const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.REMOTE_WS || '';
const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || '';
async function createSession() {
const res = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: ROOM, username: 'visual-runner', ttl: 300 }),
});
const text = await res.text();
try { return JSON.parse(text); } catch (e) { return null; }
}
async function resolveRemoteWSEndpoint(raw: string | undefined) {
if (!raw) return null;
let r = String(raw).trim();
// if starts with ws or wss, return as-is (may still need token appended)
if (r.startsWith('ws://') || r.startsWith('wss://')) return r;
// numeric port -> assume localhost
if (/^\d+$/.test(r)) r = `http://localhost:${r}`;
if (!r.startsWith('http://') && !r.startsWith('https://')) r = `http://${r}`;
try {
const ver = await fetch(r.replace(/\/$/, '') + '/json/version');
if (ver && ver.ok) {
const j = await ver.json();
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
}
} catch (e) { /* ignore */ }
try {
const list = await fetch(r.replace(/\/$/, '') + '/json/list');
if (list && list.ok) {
const arr = await list.json();
if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl;
}
} catch (e) { /* ignore */ }
try {
const j = await fetch(r.replace(/\/$/, '') + '/json');
if (j && j.ok) {
const arr = await j.json();
if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl;
}
} catch (e) { /* ignore */ }
return null;
}
test('Studio visual snapshot (browserless-aware)', async () => {
const session = await createSession();
const target = session && session.studioUrl ? session.studioUrl : BROADCAST;
// Create browser/page either via browserless CDP or local chromium
let browser: any = null;
let context: any = null;
let page: any = null;
let usedRemote = false;
try {
if (BROWSERLESS_WS) {
// try resolve to webSocketDebuggerUrl (CDP)
let connectEndpoint: string | null = null;
// if BROWSERLESS_WS already contains token param, use as-is; otherwise append token
if ((BROWSERLESS_WS.startsWith('ws://') || BROWSERLESS_WS.startsWith('wss://')) && BROWSERLESS_TOKEN) {
connectEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`;
} else {
// try to resolve via http endpoints
connectEndpoint = await resolveRemoteWSEndpoint(BROWSERLESS_WS + (BROWSERLESS_TOKEN ? (BROWSERLESS_WS.includes('?') ? '&' : '?') + `token=${encodeURIComponent(BROWSERLESS_TOKEN)}` : ''));
if (!connectEndpoint) connectEndpoint = await resolveRemoteWSEndpoint(BROWSERLESS_WS);
}
if (connectEndpoint) {
// connect via CDP
try {
browser = await pwChromium.connectOverCDP(connectEndpoint, { timeout: 20000 });
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
usedRemote = true;
console.log('Connected to remote browser via CDP:', connectEndpoint);
} catch (err) {
console.warn('connectOverCDP failed, will fallback to local chromium:', String(err));
}
} else {
console.warn('Could not resolve remote CDP endpoint for', BROWSERLESS_WS);
}
}
if (!page) {
// fallback: launch local chromium
browser = await pwChromium.launch({ headless: true });
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
console.log('Launched local chromium for visual test');
}
console.log('Navigating to', target);
await page.goto(target, { waitUntil: 'networkidle' });
// Wait a bit for UI to settle
await page.waitForTimeout(1500);
// Attempt to post message if we have token
if (session && session.token) {
try {
await page.evaluate((tk) => {
window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, room: '', url: window.location.href }, window.location.origin);
}, session.token);
} catch (e) { console.warn('postMessage failed', e); }
}
// Wait for connection indicators or token received text
await page.waitForTimeout(1500);
// Take screenshot and compare to baseline
const now = Date.now();
const outDir = `${process.cwd()}/e2e/out/visual_${now}`;
const fs = require('fs');
try { fs.mkdirSync(outDir, { recursive: true }); } catch (e) {}
const capturePath = `${outDir}/studio.png`;
await page.screenshot({ path: capturePath, fullPage: false });
console.log('Saved visual capture to', capturePath);
// Playwright snapshot assertion (baseline management)
await expect(page).toHaveScreenshot('studio.png', { fullPage: false });
} finally {
try { if (context) await context.close(); } catch (e) {}
try { if (browser && usedRemote && typeof browser.close === 'function') await browser.close(); else if (browser) await browser.close(); } catch (e) {}
}
});

View File

@ -7,10 +7,16 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png"
"test:connect-backend": "node scripts/connect-via-backend.js",
"e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png",
"e2e:session-loader": "node e2e/session_loader.e2e.js",
"e2e:visual": "npx playwright test e2e/visual-studio.spec.ts",
"e2e:visual:update": "BROWSERLESS_WS=\"wss://browserless.bfzqqk.easypanel.host\" BROWSERLESS_TOKEN=\"e2e098863b912f6a178b68e71ec3c58d\" node e2e/generate_visual_baseline.js",
"create:session": "node scripts/create_session.js"
},
"dependencies": {
"@livekit/components-react": "^2.9.15",
"ws": "^8.13.0",
"@livekit/components-styles": "^1.1.6",
"avanza-ui": "file:../avanza-ui",
"livekit-client": "^2.15.14",

View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Crear sesión — Broadcast Panel</title>
<style>
body { font-family: Inter, system-ui, Arial; background:#0b0b0d; color:#eee; display:flex; align-items:center; justify-content:center; height:100vh; }
.card { background:#0f1724; padding:20px;border-radius:8px; width:520px; box-shadow:0 6px 18px rgba(2,6,23,0.6); }
label{display:block;margin-top:12px;font-size:13px;color:#cbd5e1}
input{width:100%;padding:8px;border-radius:6px;border:1px solid #334155;background:#020617;color:#fff}
button{margin-top:14px;padding:10px 14px;border-radius:6px;border:none;background:#2563eb;color:#fff;cursor:pointer}
pre{background:#020617;padding:10px;border-radius:6px;overflow:auto;color:#9aa8ff}
</style>
</head>
<body>
<div class="card">
<h2>Crear sesión (Broadcast Panel)</h2>
<p>Genera una sessionId en el token-server y abre la ruta del broadcast panel: <code>https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/&lt;sessionId&gt;</code></p>
<label>Room (opcional)</label>
<input id="room" placeholder="broadcast-room-name (default: random)" />
<label>Username</label>
<input id="username" placeholder="Xesar" value="Guest" />
<label>Token server (opcional)</label>
<input id="backend" placeholder="https://avanzacast-servertokens.bfzqqk.easypanel.host" value="https://avanzacast-servertokens.bfzqqk.easypanel.host" />
<div style="display:flex;gap:8px">
<button id="createBtn">Crear sesión y abrir Studio</button>
<button id="createOnly">Crear sin abrir</button>
</div>
<div id="log" style="margin-top:12px;display:none">
<div style="font-size:13px;color:#9ca3af;margin-bottom:6px">Resultado (JSON):</div>
<pre id="out"></pre>
</div>
<div style="margin-top:12px;color:#94a3b8;font-size:13px">Nota: este script intenta POST `/api/session` al token-server y abrir la URL del broadcast panel producida.</div>
</div>
<script>
async function createSession(openAfter = false) {
const room = document.getElementById('room').value || undefined;
const username = document.getElementById('username').value || 'Guest';
let backend = document.getElementById('backend').value || '';
if (!backend) backend = 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
const url = backend.replace(/\/$/, '') + '/api/session';
const out = document.getElementById('out');
const log = document.getElementById('log');
log.style.display = 'block';
out.textContent = 'Enviando petición...';
try {
const body = { username };
if (room) body.room = room;
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const txt = await resp.text();
let json;
try { json = JSON.parse(txt); } catch (e) { json = { raw: txt } }
out.textContent = JSON.stringify({ status: resp.status, body: json }, null, 2);
if (!resp.ok) return;
const id = json && (json.id || json.sessionId) ? (json.id || json.sessionId) : null;
const studioHost = (new URL(window.location.href)).origin || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
const sessionUrl = json && json.studioUrl ? json.studioUrl : (studioHost.replace(/\/$/, '') + '/' + encodeURIComponent(id || ''));
out.textContent += '\n\nOpen URL:\n' + sessionUrl;
if (openAfter && sessionUrl) {
window.location.href = sessionUrl;
}
} catch (err) {
out.textContent = 'Error: ' + String(err);
}
}
document.getElementById('createBtn').addEventListener('click', () => createSession(true));
document.getElementById('createOnly').addEventListener('click', () => createSession(false));
</script>
</body>
</html>

View File

@ -0,0 +1,82 @@
#!/usr/bin/env node
/* connect-via-backend.js
Simple script de prueba que solicita al backend /api/connection-details y luego intenta una conexión WebSocket al servidor LiveKit
USO: node scripts/connect-via-backend.js --backend http://localhost:4000 --room test_room --name tester
*/
import fetch from 'node-fetch';
import WebSocket from 'ws';
import { argv } from 'process';
function parseArgs() {
const out = { backend: 'http://localhost:4000', room: 'test_room', name: 'tester' };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--backend' && argv[i+1]) { out.backend = argv[i+1]; i++; continue }
if (a === '--room' && argv[i+1]) { out.room = argv[i+1]; i++; continue }
if (a === '--name' && argv[i+1]) { out.name = argv[i+1]; i++; continue }
}
return out;
}
(async () => {
const { backend, room, name } = parseArgs();
console.log('Requesting token from backend:', backend, 'room=', room, 'name=', name);
try {
const resp = await fetch(`${backend}/api/connection-details?room=${encodeURIComponent(room)}&participantName=${encodeURIComponent(name)}`);
if (!resp.ok) {
console.error('Backend responded', resp.status, await resp.text());
process.exit(2);
}
const json = await resp.json();
console.log('Backend returned:', json);
const { serverUrl, participantToken } = json;
if (!serverUrl || !participantToken) {
console.error('Missing serverUrl or participantToken in backend response');
process.exit(3);
}
// Build wss url param if serverUrl is http(s)
let wsUrl = serverUrl;
if (wsUrl.startsWith('http://')) wsUrl = wsUrl.replace(/^http:\/\//, 'ws://');
if (wsUrl.startsWith('https://')) wsUrl = wsUrl.replace(/^https:\/\//, 'wss://');
// append path if not present
if (!wsUrl.endsWith('/')) wsUrl = wsUrl.replace(/\/$/, '');
const connectUrl = `${wsUrl}/rtc?access_token=${encodeURIComponent(participantToken)}`;
console.log('Attempting WebSocket connect to', connectUrl.slice(0,200));
const ws = new WebSocket(connectUrl, { rejectUnauthorized: false });
let connected = false;
ws.on('open', () => {
connected = true;
console.log('WebSocket opened OK');
// close after a short timeout
setTimeout(() => { ws.close(); }, 1500);
});
ws.on('message', (msg) => {
try { console.log('Message:', msg.toString().slice(0,200)); } catch (e) { }
});
ws.on('close', (code, reason) => {
console.log('WebSocket closed', code, reason && reason.toString ? reason.toString() : reason);
process.exit(0);
});
ws.on('error', (err) => {
console.error('WebSocket error', err && err.message ? err.message : err);
if (!connected) process.exit(4);
});
// Timeout if no connect
setTimeout(() => {
if (!connected) {
console.error('Connection timeout');
process.exit(5);
}
}, 8000);
} catch (e) {
console.error('Script failed', String(e));
process.exit(1);
}
})();

View File

@ -0,0 +1,67 @@
#!/usr/bin/env node
// create_session.js
// Usage: node scripts/create_session.js [room] [username] [--open]
// Environment overrides: BACKEND (default http://localhost:4000) or TOKEN_SERVER env
const fetch = require('node-fetch');
const child = require('child_process');
async function main() {
console.log('[create_session] starting');
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
const openFlag = process.argv.slice(2).some(a => a === '--open');
const room = args[0] || `broadcast-${Math.random().toString(36).slice(2,8)}`;
const username = args[1] || 'browser-user';
const backend = process.env.BACKEND || process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || 'http://localhost:4000';
const base = backend.replace(/\/$/, '');
const url = `${base}/api/session`;
console.log('[create_session] Creating session on', url);
console.log('[create_session] room=', room, 'username=', username);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username }), signal: controller.signal });
clearTimeout(timeout);
console.log('[create_session] response status=', r.status);
const text = await r.text().catch(() => null);
console.log('[create_session] response body (raw):', text && text.slice(0,2000));
let json = null;
try { json = text ? JSON.parse(text) : null; } catch (e) { json = null }
if (!r.ok) {
console.error('[create_session] Request failed status=', r.status);
console.error('[create_session] Body:', text);
process.exit(2);
}
const id = (json && (json.id || json.sessionId)) || null;
if (!id) {
console.error('[create_session] No session id in response:', json || text);
process.exit(3);
}
const studioHost = process.env.BROADCAST_HOST || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
const sessionUrl = `${studioHost.replace(/\/$/, '')}/${encodeURIComponent(id)}`;
console.log('[create_session] Session created:', JSON.stringify(json, null, 2));
console.log('[create_session] Open Studio URL:', sessionUrl);
if (openFlag) {
// xdg-open for linux
try {
child.execSync(`xdg-open "${sessionUrl}"`);
} catch (e) {
console.warn('[create_session] Failed to open browser automatically:', String(e.message || e));
}
}
process.exit(0);
} catch (err) {
if (err.name === 'AbortError') {
console.error('[create_session] request timed out');
} else {
console.error('[create_session] Failed to create session:', String(err));
}
process.exit(1);
}
}
main();

View File

@ -1,22 +1,55 @@
import React from 'react'
import React from "react";
interface BannerProps {
children: React.ReactNode
type?: 'info' | 'error' | 'warning'
onClose?: () => void
actionLabel?: string
onAction?: () => void
actionDisabled?: boolean
children: React.ReactNode;
type?: "info" | "error" | "warning";
onClose?: () => void;
actionLabel?: string;
onAction?: () => void;
actionDisabled?: boolean;
}
const Banner: React.FC<BannerProps> = ({ children, type = 'info', onClose, actionLabel, onAction, actionDisabled = false }) => {
const bg = type === 'error' ? 'bg-red-700' : type === 'warning' ? 'bg-amber-500' : 'bg-sky-600'
const icon = type === 'error' ? (
<svg className="w-5 h-5 mr-3 text-white/90" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path><path strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01"></path></svg>
) : null
const Banner: React.FC<BannerProps> = ({
children,
type = "info",
onClose,
actionLabel,
onAction,
actionDisabled = false,
}) => {
const bg =
type === "error"
? "bg-red-700"
: type === "warning"
? "bg-amber-500"
: "bg-sky-600";
const icon =
type === "error" ? (
<svg
className="w-5 h-5 mr-3 text-white/90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
></path>
<path
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v4m0 4h.01"
></path>
</svg>
) : null;
return (
<div className={`${bg} text-white px-4 py-3 rounded-md mb-4 shadow-sm flex items-center justify-between`}>
<div
className={`${bg} text-white px-4 py-3 rounded-md mb-4 shadow-sm flex items-center justify-between`}
>
<div className="flex items-center">
{icon}
<div className="text-sm font-medium">{children}</div>
@ -26,17 +59,22 @@ const Banner: React.FC<BannerProps> = ({ children, type = 'info', onClose, actio
<button
onClick={onAction}
disabled={actionDisabled}
className={`px-3 py-1 rounded-md text-sm font-semibold ${actionDisabled ? 'bg-white/20 text-white/60 cursor-not-allowed' : 'bg-white/10 hover:bg-white/20 text-white'}`}
className={`px-3 py-1 rounded-md text-sm font-semibold ${actionDisabled ? "bg-white/20 text-white/60 cursor-not-allowed" : "bg-white/10 hover:bg-white/20 text-white"}`}
>
{actionLabel}
</button>
)}
{onClose && (
<button onClick={onClose} className="text-white/80 underline ml-2 text-sm">Cerrar</button>
<button
onClick={onClose}
className="text-white/80 underline ml-2 text-sm"
>
Cerrar
</button>
)}
</div>
</div>
)
}
);
};
export default Banner
export default Banner;

View File

@ -8,20 +8,20 @@
top: calc(100% + 6px);
right: 0;
background-color: var(--surface-color);
border: 1px solid rgba(0,0,0,0.04);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 8px;
/* lighter shadow so it's less pronounced */
box-shadow: 0 6px 14px rgba(2,6,23,0.10);
box-shadow: 0 6px 14px rgba(2, 6, 23, 0.1);
/* match the create-card min width (220px) so menus align visually */
width: 220px;
padding: 4px 0;
z-index: 1200;
animation: slideDown 0.18s cubic-bezier(.2,.9,.2,1);
animation: slideDown 0.18s cubic-bezier(0.2, 0.9, 0.2, 1);
}
/* caret (little pointer) under the trigger */
.dropdownMenu::before {
content: '';
content: "";
position: absolute;
top: -6px;
/* horizontal offset from the right edge; configurable via --dropdown-caret-right */
@ -30,8 +30,8 @@
height: 12px;
background: var(--surface-color);
transform: rotate(45deg);
border-left: 1px solid rgba(0,0,0,0.04);
border-top: 1px solid rgba(0,0,0,0.04);
border-left: 1px solid rgba(0, 0, 0, 0.04);
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
@keyframes slideDown {
@ -72,12 +72,12 @@
}
.dropdownItem.deleteItem:hover {
background-color: rgba(234,67,53,0.04);
background-color: rgba(234, 67, 53, 0.04);
}
.dropdownItem:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59,130,246,0.12);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
/* ensure header (non-interactive) doesn't get hover background */
@ -103,12 +103,12 @@
padding: 8px 14px;
color: var(--text-primary);
font-size: 14px;
opacity: 0.90; /* user requested opacity */
opacity: 0.9; /* user requested opacity */
cursor: default;
}
.dropdownHeaderLabel {
opacity: 0.90;
opacity: 0.9;
color: var(--text-primary);
}
@ -134,7 +134,7 @@
/* make delete icon sit inside a small rounded red box like the screenshot */
.deleteItem .icon {
background: rgba(234,67,53,0.08); /* subtle red bg */
background: rgba(234, 67, 53, 0.08); /* subtle red bg */
padding: 4px;
border-radius: 6px;
display: inline-flex;
@ -145,6 +145,6 @@
/* tighten divider */
.divider {
height: 1px;
background-color: rgba(0,0,0,0.04);
background-color: rgba(0, 0, 0, 0.04);
margin: 6px 0;
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import styles from './Dropdown.module.css';
import React, { useEffect, useRef, useState } from "react";
import styles from "./Dropdown.module.css";
interface DropdownItem {
label: string;
@ -22,26 +22,27 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
return (
<div className={styles.dropdown} ref={dropdownRef}>
<div onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
{isOpen && (
<div className={styles.dropdownMenu}>
{items.map((item, index) => (
@ -52,17 +53,31 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
(() => {
const cp = item.containerProps || {};
const { className: cpClassName, ...cpRest } = cp as any;
const headerClasses = [styles.dropdownHeader, cpClassName].filter(Boolean).join(' ');
const headerClasses = [styles.dropdownHeader, cpClassName]
.filter(Boolean)
.join(" ");
return (
<div className={headerClasses} {...cpRest}>
{item.icon && <span className={styles.icon}>{item.icon}</span>}
{item.icon && (
<span className={styles.icon}>{item.icon}</span>
)}
{
// merge className for the header label
(() => {
const lp = item.labelProps || {};
const { className: lpClassName, ...lpOther } = lp as any;
const classes = [styles.dropdownHeaderLabel, lpClassName].filter(Boolean).join(' ');
return <span className={classes} {...lpOther}>{item.label}</span>;
const { className: lpClassName, ...lpOther } =
lp as any;
const classes = [
styles.dropdownHeaderLabel,
lpClassName,
]
.filter(Boolean)
.join(" ");
return (
<span className={classes} {...lpOther}>
{item.label}
</span>
);
})()
}
</div>
@ -72,7 +87,9 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
(() => {
const cp = item.containerProps || {};
const { className: cpClassName, ...cpRest } = cp as any;
const btnClasses = [styles.dropdownItem, cpClassName].filter(Boolean).join(' ');
const btnClasses = [styles.dropdownItem, cpClassName]
.filter(Boolean)
.join(" ");
return (
<button
className={btnClasses}
@ -82,15 +99,20 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
}}
{...cpRest}
>
{item.icon && <span className={styles.icon}>{item.icon}</span>}
{
(() => {
const lp = item.labelProps || {};
const { className: lpClassName, ...lpOther } = lp as any;
const classes = [lpClassName].filter(Boolean).join(' ');
return <span className={classes} {...lpOther}>{item.label}</span>;
})()
}
{item.icon && (
<span className={styles.icon}>{item.icon}</span>
)}
{(() => {
const lp = item.labelProps || {};
const { className: lpClassName, ...lpOther } =
lp as any;
const classes = [lpClassName].filter(Boolean).join(" ");
return (
<span className={classes} {...lpOther}>
{item.label}
</span>
);
})()}
</button>
);
})()

View File

@ -1,9 +1,15 @@
import React, { useState } from 'react'
import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components'
import React, { useState } from "react";
import {
Modal,
ModalLink,
ModalCopyInput,
ModalShareButtons,
ModalToggle,
} from "@shared/components";
interface Props {
open: boolean
onClose: () => void
open: boolean;
onClose: () => void;
}
/**
@ -11,7 +17,7 @@ interface Props {
* Demuestra cómo reutilizar las partes creadas
*/
const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
const [enabled, setEnabled] = useState(true)
const [enabled, setEnabled] = useState(true);
return (
<Modal
@ -21,14 +27,14 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
width="md"
footer={
<>
<button
<button
onClick={onClose}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #dadce0',
borderRadius: '4px',
cursor: 'pointer'
padding: "8px 16px",
background: "transparent",
border: "1px solid #dadce0",
borderRadius: "4px",
cursor: "pointer",
}}
>
Cancelar
@ -36,12 +42,12 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
<button
onClick={onClose}
style={{
padding: '8px 24px',
background: '#1a73e8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
padding: "8px 24px",
background: "#1a73e8",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Aceptar
@ -49,21 +55,25 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ margin: 0, color: '#5f6368', fontSize: '14px' }}>
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer más en{' '}
<ModalLink href="https://example.com/docs">nuestra documentación</ModalLink>.
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<p style={{ margin: 0, color: "#5f6368", fontSize: "14px" }}>
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer
más en{" "}
<ModalLink href="https://example.com/docs">
nuestra documentación
</ModalLink>
.
</p>
<ModalCopyInput
<ModalCopyInput
value="https://example.com/invite/abc123"
buttonText="Copiar enlace"
/>
<ModalShareButtons
onGmail={() => console.log('Gmail')}
onEmail={() => console.log('Email')}
onMessenger={() => console.log('Messenger')}
onGmail={() => console.log("Gmail")}
onEmail={() => console.log("Email")}
onMessenger={() => console.log("Messenger")}
/>
<ModalToggle
@ -74,7 +84,7 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
/>
</div>
</Modal>
)
}
);
};
export default ExampleModal
export default ExampleModal;

View File

@ -9,7 +9,9 @@
position: sticky;
top: 0;
z-index: 50;
transition: background-color 0.3s ease, border-color 0.3s ease;
transition:
background-color 0.3s ease,
border-color 0.3s ease;
}
.headerActions {
@ -36,7 +38,7 @@
.segmentControl {
display: inline-flex;
background-color: rgba(0,0,0,0.06);
background-color: rgba(0, 0, 0, 0.06);
border-radius: 999px;
padding: 4px;
gap: 6px;
@ -62,8 +64,12 @@
}
.activeSegment {
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.04));
box-shadow: 0 6px 14px rgba(2,6,23,0.4);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.02),
rgba(0, 0, 0, 0.04)
);
box-shadow: 0 6px 14px rgba(2, 6, 23, 0.4);
color: var(--surface-color);
}
@ -114,7 +120,7 @@
}
.userEmail {
opacity: 0.90;
opacity: 0.9;
font-size: 14px;
color: var(--text-secondary);
}
@ -126,7 +132,7 @@
/* Dropdown trigger style polished */
.userMenu:after {
content: '';
content: "";
}
.userMenu svg {

View File

@ -1,52 +1,76 @@
import React from 'react'
import { MdLightMode, MdDarkMode, MdNotifications, MdPerson, MdLogout, MdHelpOutline } from 'react-icons/md'
import { useTheme } from './ThemeProvider'
import { Tooltip } from './Tooltip'
import { Dropdown } from './Dropdown'
import styles from './Header.module.css'
import React from "react";
import {
MdLightMode,
MdDarkMode,
MdNotifications,
MdPerson,
MdLogout,
MdHelpOutline,
} from "react-icons/md";
import { useTheme } from "./ThemeProvider";
import { Tooltip } from "./Tooltip";
import { Dropdown } from "./Dropdown";
import styles from "./Header.module.css";
const Header: React.FC = () => {
const { theme, resolvedTheme, setThemeMode } = useTheme()
const { theme, resolvedTheme, setThemeMode } = useTheme();
const handleLogout = () => {
localStorage.removeItem('mock_user')
window.location.href = '/auth/login'
}
localStorage.removeItem("mock_user");
window.location.href = "/auth/login";
};
// Read mock user from localStorage (if set elsewhere in the app)
const storedUser = typeof window !== 'undefined' ? localStorage.getItem('mock_user') : null
let email = 'Usuario'
let avatarUrl: string | null = null
let displayName: string | null = null
const storedUser =
typeof window !== "undefined" ? localStorage.getItem("mock_user") : null;
let email = "Usuario";
let avatarUrl: string | null = null;
let displayName: string | null = null;
try {
if (storedUser) {
const parsed = JSON.parse(storedUser)
email = parsed.email || storedUser || 'Usuario'
avatarUrl = parsed.avatar || parsed.photo || parsed.picture || null
displayName = parsed.name || parsed.fullname || null
const parsed = JSON.parse(storedUser);
email = parsed.email || storedUser || "Usuario";
avatarUrl = parsed.avatar || parsed.photo || parsed.picture || null;
displayName = parsed.name || parsed.fullname || null;
}
} catch (e) {
email = storedUser || 'Usuario'
email = storedUser || "Usuario";
}
const computeInitials = (nameOrEmail: string) => {
if (!nameOrEmail) return 'U'
const name = nameOrEmail.split('@')[0]
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return nameOrEmail.slice(0, 2).toUpperCase()
}
if (!nameOrEmail) return "U";
const name = nameOrEmail.split("@")[0];
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return nameOrEmail.slice(0, 2).toUpperCase();
};
const initials = computeInitials(displayName || email)
const initials = computeInitials(displayName || email);
const avatarElement = avatarUrl ? (
<img src={avatarUrl} alt="avatar" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
<img
src={avatarUrl}
alt="avatar"
style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover" }}
/>
) : (
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--primary-blue)', color: 'white', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 700 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "var(--primary-blue)",
color: "white",
display: "grid",
placeItems: "center",
fontSize: 12,
fontWeight: 700,
}}
>
{initials}
</div>
)
);
const userMenuItems = [
// non-interactive header with email
@ -54,56 +78,53 @@ const Header: React.FC = () => {
label: email,
icon: avatarElement,
disabled: true,
containerProps: { 'data-test': 'user-email', id: 'user-email' },
labelProps: { style: { opacity: 0.7 } }
containerProps: { "data-test": "user-email", id: "user-email" },
labelProps: { style: { opacity: 0.7 } },
},
{
label: 'Mi perfil',
label: "Mi perfil",
icon: <MdPerson size={18} />,
onClick: () => console.log('Ir a perfil'),
divider: true // separator after the header
onClick: () => console.log("Ir a perfil"),
divider: true, // separator after the header
},
{
label: 'Ayuda',
label: "Ayuda",
icon: <MdHelpOutline size={18} />,
onClick: () => console.log('Ir a ayuda')
onClick: () => console.log("Ir a ayuda"),
},
{
label: 'Cerrar sesión',
label: "Cerrar sesión",
icon: <MdLogout size={18} />,
onClick: handleLogout,
divider: true
}
]
divider: true,
},
];
return (
<header className={styles.header}>
<div></div> {/* Spacer */}
<div className={styles.headerActions}>
<button className={styles.planButton}>
Mejora tu plan
</button>
<button className={styles.planButton}>Mejora tu plan</button>
{/* Segmented theme control: Sistema / Claro / Oscuro */}
<div className={styles.segmentControl} role="tablist" aria-label="Tema">
<button
className={`${styles.segmentButton} ${theme === 'system' ? styles.activeSegment : ''}`}
onClick={() => setThemeMode('system')}
<button
className={`${styles.segmentButton} ${theme === "system" ? styles.activeSegment : ""}`}
onClick={() => setThemeMode("system")}
title="Usar tema del sistema"
>
<span className={styles.segmentIcon}></span>
</button>
<button
className={`${styles.segmentButton} ${theme === 'light' ? styles.activeSegment : ''}`}
onClick={() => setThemeMode('light')}
<button
className={`${styles.segmentButton} ${theme === "light" ? styles.activeSegment : ""}`}
onClick={() => setThemeMode("light")}
title="Modo claro"
>
<MdLightMode size={16} />
</button>
<button
className={`${styles.segmentButton} ${theme === 'dark' ? styles.activeSegment : ''}`}
onClick={() => setThemeMode('dark')}
<button
className={`${styles.segmentButton} ${theme === "dark" ? styles.activeSegment : ""}`}
onClick={() => setThemeMode("dark")}
title="Modo oscuro"
>
<MdDarkMode size={16} />
@ -116,7 +137,7 @@ const Header: React.FC = () => {
<span className={styles.notificationDot}></span>
</button>
</Tooltip>
<Dropdown
trigger={
<div className={styles.userMenu}>
@ -127,7 +148,7 @@ const Header: React.FC = () => {
/>
</div>
</header>
)
}
);
};
export default Header
export default Header;

View File

@ -57,7 +57,6 @@
border-top-color: #5f6368;
}
/* Dark mode */
[data-theme="dark"] .helpText,
[data-theme="dark"] .planText {
@ -67,4 +66,3 @@
[data-theme="dark"] .toggle {
border-top-color: #5f6368;
}

View File

@ -1,27 +1,36 @@
import React, { useState } from 'react'
import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components'
import styles from './InviteGuestsModal.module.css'
import React, { useState } from "react";
import {
Modal,
ModalLink,
ModalCopyInput,
ModalShareButtons,
ModalToggle,
} from "@shared/components";
import styles from "./InviteGuestsModal.module.css";
interface Props {
open: boolean
onClose: () => void
link: string
open: boolean;
onClose: () => void;
link: string;
}
const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
const [guestPermissions, setGuestPermissions] = useState(true)
const [guestPermissions, setGuestPermissions] = useState(true);
const handleGmailShare = () => {
window.open(`https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`, '_blank')
}
window.open(
`https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`,
"_blank",
);
};
const handleEmailShare = () => {
window.location.href = `mailto:?body=${encodeURIComponent(link)}`
}
window.location.href = `mailto:?body=${encodeURIComponent(link)}`;
};
const handleMessengerShare = () => {
console.log('Compartir por Messenger:', link)
}
console.log("Compartir por Messenger:", link);
};
return (
<Modal
@ -32,19 +41,21 @@ const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
>
<div className={styles.content}>
<p className={styles.helpText}>
Envía este enlace a tus invitados. Es posible que también quieras compartir nuestras {' '}
Envía este enlace a tus invitados. Es posible que también quieras
compartir nuestras{" "}
<ModalLink href="https://support.streamyard.com/hc/en-us/articles/360043291612">
instrucciones para invitados
</ModalLink>.
</ModalLink>
.
</p>
<p className={styles.planText}>
Puedes tener hasta 6 personas en pantalla a la vez. {' '}
<ModalLink href="/pricing">Mejora tu plan</ModalLink> {' '}
si necesitas más.
Puedes tener hasta 6 personas en pantalla a la vez.{" "}
<ModalLink href="/pricing">Mejora tu plan</ModalLink> si necesitas
más.
</p>
<ModalCopyInput
<ModalCopyInput
value={link}
buttonText="Copiar"
className={styles.copyInput}
@ -66,7 +77,7 @@ const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
/>
</div>
</Modal>
)
}
);
};
export default InviteGuestsModal
export default InviteGuestsModal;

View File

@ -65,7 +65,7 @@
}
.selectedDestination::before {
content: '';
content: "";
position: absolute;
top: 0;
left: 50%;

View File

@ -53,14 +53,17 @@
align-items: center;
gap: 12px;
cursor: pointer;
transition: box-shadow 0.18s ease, transform 0.12s ease, border-color 0.12s ease;
transition:
box-shadow 0.18s ease,
transform 0.12s ease,
border-color 0.12s ease;
font-size: 15px;
font-weight: 600;
position: relative; /* for plus overlay */
}
.createCard:hover {
box-shadow: 0 6px 18px rgba(16,24,40,0.06);
box-shadow: 0 6px 18px rgba(16, 24, 40, 0.06);
transform: translateY(-3px);
border-color: var(--primary-blue);
}
@ -81,7 +84,9 @@
transform: translateY(-50%) translateX(8px) scale(0.85);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
transition:
transform 0.18s ease,
opacity 0.18s ease;
display: flex;
align-items: center;
}
@ -103,16 +108,34 @@
/* color variants for left icon box and plus badge */
.cardBlue .createIconBox,
.cardBlue .plusBadge { background: rgba(26,115,232,0.12); }
.cardBlue .createIconBox svg { color: var(--primary-blue); fill: var(--primary-blue); stroke: var(--primary-blue); }
.cardBlue .plusBadge {
background: rgba(26, 115, 232, 0.12);
}
.cardBlue .createIconBox svg {
color: var(--primary-blue);
fill: var(--primary-blue);
stroke: var(--primary-blue);
}
.cardRed .createIconBox,
.cardRed .plusBadge { background: rgba(234,67,53,0.12); }
.cardRed .createIconBox svg { color: #ea4335; fill: #ea4335; stroke: #ea4335; }
.cardRed .plusBadge {
background: rgba(234, 67, 53, 0.12);
}
.cardRed .createIconBox svg {
color: #ea4335;
fill: #ea4335;
stroke: #ea4335;
}
.cardGreen .createIconBox,
.cardGreen .plusBadge { background: rgba(52,168,83,0.12); }
.cardGreen .createIconBox svg { color: #34a853; fill: #34a853; stroke: #34a853; }
.cardGreen .plusBadge {
background: rgba(52, 168, 83, 0.12);
}
.cardGreen .createIconBox svg {
color: #34a853;
fill: #34a853;
stroke: #34a853;
}
/* ensure icons are solid and not dimmed */
.createIconBox svg {

View File

@ -1,69 +1,88 @@
import React, { useEffect, useState } from 'react'
import { MdVideocam, MdFiberManualRecord, MdSchool } from 'react-icons/md'
import PlusLarge from './icons/PlusLarge'
import { ThemeProvider } from './ThemeProvider'
import { Skeleton, SkeletonCard } from './Skeleton'
import styles from './PageContainer.module.css'
import Sidebar from './Sidebar'
import Header from './Header'
import TransmissionsTable from './TransmissionsTable'
import { NewTransmissionModal } from '@shared/components'
import Studio from './Studio'
import StudioConnector from './StudioConnector'
import type { Transmission } from '@shared/types'
import React, { useEffect, useState } from "react";
import { MdVideocam, MdFiberManualRecord, MdSchool } from "react-icons/md";
import PlusLarge from "./icons/PlusLarge";
import { ThemeProvider } from "./ThemeProvider";
import { SkeletonCard } from "./Skeleton";
import styles from "./PageContainer.module.css";
import Sidebar from "./Sidebar";
import Header from "./Header";
import TransmissionsTable from "./TransmissionsTable";
import { NewTransmissionModal } from "@shared/components";
import Studio from "./Studio";
import type { Transmission } from "@shared/types";
const STORAGE_KEY = 'broadcast_transmissions'
const STORAGE_KEY = "broadcast_transmissions";
const PageContainer: React.FC = () => {
const [transmissions, setTransmissions] = useState<Transmission[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [currentPage, setCurrentPage] = useState<string>('inicio')
const [transmissions, setTransmissions] = useState<Transmission[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Determine initial page: default 'inicio' but allow opening the Studio when feature enabled
const initialPage = (() => {
try {
// feature via Vite env at build time
const envFlag =
(import.meta.env.VITE_FEATURE_STUDIOPORTAL as string) === "1";
if (envFlag) return "studio";
// runtime toggle via localStorage (useful during development)
if (
typeof window !== "undefined" &&
window.localStorage.getItem("feature_studioportal") === "1"
)
return "studio";
} catch (e) {
// ignore
}
return "inicio";
})();
const [currentPage, setCurrentPage] = useState<string>(initialPage);
useEffect(() => {
// Simular carga de datos
setTimeout(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) setTransmissions(JSON.parse(raw))
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setTransmissions(JSON.parse(raw));
} catch (e) {
console.error('Failed to load transmissions', e)
console.error("Failed to load transmissions", e);
}
setIsLoading(false)
}, 800)
}, [])
setIsLoading(false);
}, 800);
}, []);
useEffect(() => {
if (!isLoading) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(transmissions))
localStorage.setItem(STORAGE_KEY, JSON.stringify(transmissions));
} catch (e) {
console.error('Failed to save transmissions', e)
console.error("Failed to save transmissions", e);
}
}
}, [transmissions, isLoading])
}, [transmissions, isLoading]);
const handleCreate = (t: Transmission) => {
setTransmissions(prev => [t, ...prev])
setIsModalOpen(false)
}
setTransmissions((prev) => [t, ...prev]);
setIsModalOpen(false);
};
const handleDelete = (id: string) => {
setTransmissions(prev => prev.filter(p => p.id !== id))
}
setTransmissions((prev) => prev.filter((p) => p.id !== id));
};
const handleUpdate = (updated: Transmission) => {
setTransmissions(prev => prev.map(p => p.id === updated.id ? updated : p))
}
setTransmissions((prev) =>
prev.map((p) => (p.id === updated.id ? updated : p)),
);
};
const handleNavigate = (page: string) => {
setCurrentPage(page)
}
setCurrentPage(page);
};
// Renderizar página según navegación
if (currentPage === 'studio') {
if (currentPage === "studio") {
// Dev: render StudioConnector for quick testing of the session flow
return <StudioConnector />
return <Studio />;
}
return (
@ -76,8 +95,16 @@ const PageContainer: React.FC = () => {
<div className={styles.wrapContent}>
<div className={styles.leftStack}>
{/* Sección Crear */}
<section style={{ marginBottom: '0' }}>
<h2 style={{ fontSize: '22px', fontWeight: 600, marginBottom: '18px' }}>Crear</h2>
<section style={{ marginBottom: "0" }}>
<h2
style={{
fontSize: "22px",
fontWeight: 600,
marginBottom: "18px",
}}
>
Crear
</h2>
{isLoading ? (
<div className={styles.createGrid}>
<SkeletonCard />
@ -91,41 +118,63 @@ const PageContainer: React.FC = () => {
className={`${styles.createCard} ${styles.cardBlue}`}
>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(26,115,232,0.08)' }}>
<div
className={styles.createIconBox}
style={{ background: "rgba(26,115,232,0.08)" }}
>
<MdVideocam size={20} />
</div>
<span>Transmisión en vivo</span>
</div>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(26,115,232,0.08)' }}>
<div
className={styles.plusBadge}
style={{ background: "rgba(26,115,232,0.08)" }}
>
<PlusLarge size={20} color="var(--primary-blue)" />
</div>
</span>
</button>
<button className={`${styles.createCard} ${styles.cardRed}`}>
<button
className={`${styles.createCard} ${styles.cardRed}`}
>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(234,67,53,0.08)' }}>
<div
className={styles.createIconBox}
style={{ background: "rgba(234,67,53,0.08)" }}
>
<MdFiberManualRecord size={20} />
</div>
<span>Grabación</span>
</div>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(234,67,53,0.08)' }}>
<div
className={styles.plusBadge}
style={{ background: "rgba(234,67,53,0.08)" }}
>
<PlusLarge size={20} color="#ea4335" />
</div>
</span>
</button>
<button className={`${styles.createCard} ${styles.cardGreen}`}>
<button
className={`${styles.createCard} ${styles.cardGreen}`}
>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(52,168,83,0.08)' }}>
<div
className={styles.createIconBox}
style={{ background: "rgba(52,168,83,0.08)" }}
>
<MdSchool size={20} />
</div>
<span>Seminario web On-Air</span>
</div>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(52,168,83,0.08)' }}>
<div
className={styles.plusBadge}
style={{ background: "rgba(52,168,83,0.08)" }}
>
<PlusLarge size={20} color="#34a853" />
</div>
</span>
@ -136,7 +185,13 @@ const PageContainer: React.FC = () => {
{/* Sección Transmisiones y grabaciones */}
<section>
<h2 style={{ fontSize: '22px', fontWeight: 600, marginBottom: '24px' }}>
<h2
style={{
fontSize: "22px",
fontWeight: 600,
marginBottom: "24px",
}}
>
Transmisiones y grabaciones
</h2>
<TransmissionsTable
@ -162,7 +217,7 @@ const PageContainer: React.FC = () => {
</div>
</div>
</ThemeProvider>
)
}
);
};
export default PageContainer
export default PageContainer;

View File

@ -10,7 +10,10 @@
flex-direction: column;
overflow-y: auto;
z-index: 100;
transition: transform 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
transition:
transform 0.3s ease,
background-color 0.3s ease,
border-color 0.3s ease;
}
.logoSection {
@ -57,7 +60,10 @@
/* move slightly to the left (-1 spacing ≈ 4px) for a tighter alignment */
width: calc(100% + 26px);
margin-left: -10px;
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
transition:
background-color 0.15s ease,
color 0.15s ease,
transform 0.12s ease;
}
.navLink:hover {

View File

@ -1,34 +1,61 @@
import React from 'react'
import { MdHome, MdVideoLibrary, MdLink, MdPeople, MdCardGiftcard, MdSettings, MdAssessment, MdVideocam } from 'react-icons/md'
import { Tooltip } from './Tooltip'
import { Logo } from '../../../../shared/components/Logo'
import styles from './Sidebar.module.css'
import React from "react";
import {
MdHome,
MdVideoLibrary,
MdLink,
MdPeople,
MdCardGiftcard,
MdSettings,
MdAssessment,
MdVideocam,
} from "react-icons/md";
import { Tooltip } from "./Tooltip";
import { Logo } from "../../../../shared/components/Logo";
import styles from "./Sidebar.module.css";
interface SidebarProps {
activeLink?: string
onNavigate?: (page: string) => void
activeLink?: string;
onNavigate?: (page: string) => void;
}
const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate }) => {
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault()
const Sidebar: React.FC<SidebarProps> = ({
activeLink = "inicio",
onNavigate,
}) => {
const handleNavClick = (
e: React.MouseEvent<HTMLAnchorElement>,
id: string,
) => {
e.preventDefault();
if (onNavigate) {
onNavigate(id)
onNavigate(id);
}
}
};
const navItems = [
{ id: 'inicio', label: 'Inicio', icon: <MdHome size={20} /> },
{ id: 'biblioteca', label: 'Biblioteca', icon: <MdVideoLibrary size={20} /> },
{ id: 'destinos', label: 'Destinos', icon: <MdLink size={20} /> },
{ id: 'miembros', label: 'Miembros', icon: <MdPeople size={20} /> },
]
{ id: "inicio", label: "Inicio", icon: <MdHome size={20} /> },
{
id: "biblioteca",
label: "Biblioteca",
icon: <MdVideoLibrary size={20} />,
},
{ id: "destinos", label: "Destinos", icon: <MdLink size={20} /> },
{ id: "miembros", label: "Miembros", icon: <MdPeople size={20} /> },
];
const secondaryNavItems = [
{ id: 'referidos', label: 'Referidos', icon: <MdCardGiftcard size={20} /> },
{ id: 'configuracion', label: 'Configuración del equipo', icon: <MdSettings size={20} /> },
{ id: 'sistema', label: 'Estado del sistema', icon: <MdAssessment size={20} /> },
]
{ id: "referidos", label: "Referidos", icon: <MdCardGiftcard size={20} /> },
{
id: "configuracion",
label: "Configuración del equipo",
icon: <MdSettings size={20} />,
},
{
id: "sistema",
label: "Estado del sistema",
icon: <MdAssessment size={20} />,
},
];
return (
<aside className={styles.sidebar}>
@ -40,38 +67,47 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate })
{/* Main Navigation */}
<nav className={styles.navMenu}>
<ul className={styles.navList}>
{navItems.map(item => (
<li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}>
<a
href={`#${item.id}`}
className={styles.navLink}
onClick={(e) => handleNavClick(e, item.id)}
>
<span className={styles.navIcon}>{item.icon}</span>
<span>{item.label}</span>
</a>
{navItems.map((item) => (
<li
key={item.id}
className={
activeLink === item.id ? styles.activeLink : styles.navItem
}
>
<a
href={`#${item.id}`}
className={styles.navLink}
onClick={(e) => handleNavClick(e, item.id)}
>
<span className={styles.navIcon}>{item.icon}</span>
<span>{item.label}</span>
</a>
</li>
))}
</ul>
</nav>
{/* Secondary Navigation moved closer to storage */}
<div className={styles.secondaryNavGroup}>
<ul className={styles.navList}>
{secondaryNavItems.map(item => (
<li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}>
<a
href={`#${item.id}`}
className={styles.navLink}
onClick={(e) => handleNavClick(e, item.id)}
>
<span className={styles.navIcon}>{item.icon}</span>
<span>{item.label}</span>
</a>
</li>
))}
</ul>
</div>
{/* Secondary Navigation moved closer to storage */}
<div className={styles.secondaryNavGroup}>
<ul className={styles.navList}>
{secondaryNavItems.map((item) => (
<li
key={item.id}
className={
activeLink === item.id ? styles.activeLink : styles.navItem
}
>
<a
href={`#${item.id}`}
className={styles.navLink}
onClick={(e) => handleNavClick(e, item.id)}
>
<span className={styles.navIcon}>{item.icon}</span>
<span>{item.label}</span>
</a>
</li>
))}
</ul>
</div>
{/* Storage Info */}
<div className={styles.storageInfo}>
<div className={styles.storageTitle}>
@ -79,13 +115,15 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate })
<span className={styles.infoIcon}>?</span>
</div>
<div className={styles.progressBarContainer}>
<div className={styles.progressBarFill} style={{ width: '0%' }}></div>
<div className={styles.progressBarFill} style={{ width: "0%" }}></div>
</div>
<div className={styles.storageUsage}>0 de 5 horas</div>
<a href="#agregar" className={styles.addMoreLink}>Agregar más</a>
<a href="#agregar" className={styles.addMoreLink}>
Agregar más
</a>
</div>
</aside>
)
}
);
};
export default Sidebar
export default Sidebar;

View File

@ -1,5 +1,5 @@
import React from 'react';
import styles from './Skeleton.module.css';
import React from "react";
import styles from "./Skeleton.module.css";
interface SkeletonProps {
width?: string;
@ -8,14 +8,14 @@ interface SkeletonProps {
className?: string;
}
export const Skeleton: React.FC<SkeletonProps> = ({
width = '100%',
height = '20px',
borderRadius = '4px',
className = ''
export const Skeleton: React.FC<SkeletonProps> = ({
width = "100%",
height = "20px",
borderRadius = "4px",
className = "",
}) => {
return (
<div
<div
className={`${styles.skeleton} ${className}`}
style={{ width, height, borderRadius }}
/>

View File

@ -98,7 +98,7 @@
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-family: "Courier New", monospace;
}
.devNote a {
@ -240,7 +240,8 @@
}
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@ -451,7 +452,7 @@
.studioContainer {
flex-direction: column;
}
.studioSidebar {
position: absolute;
right: 0;
@ -462,16 +463,16 @@
z-index: 10;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
}
.connectionForm {
margin: 20px;
padding: 24px;
}
.controlBar {
padding: 12px 16px;
}
.controlsLeft,
.controlsRight {
display: none;

View File

@ -1,54 +1,81 @@
import React, { useEffect, useState } from 'react'
import { ThemeProvider } from './ThemeProvider'
import Sidebar from './Sidebar'
import Header from './Header'
import styles from './Studio.module.css'
import { StudioPortal } from '../features/studio'
import React, { useEffect, useState } from "react";
import { ThemeProvider } from "./ThemeProvider";
import Sidebar from "./Sidebar";
import Header from "./Header";
import styles from "./Studio.module.css";
import { StudioPortal } from "../features/studio";
const Studio: React.FC = () => {
const [tokenData, setTokenData] = useState<{ token?: string; url?: string } | null>(null)
const [tokenData, setTokenData] = useState<{
token?: string;
url?: string;
} | null>(null);
useEffect(() => {
const userName = localStorage.getItem('avanzacast_user') || 'Usuario'
const roomName = localStorage.getItem('avanzacast_room') || 'avanzacast-studio'
localStorage.setItem('avanzacast_user', userName)
localStorage.setItem('avanzacast_room', roomName)
const userName = localStorage.getItem("avanzacast_user") || "Usuario";
const roomName =
localStorage.getItem("avanzacast_room") || "avanzacast-studio";
localStorage.setItem("avanzacast_user", userName);
localStorage.setItem("avanzacast_room", roomName);
// Request session token via backend API: POST /api/session then GET /api/session/:id
;(async () => {
(async () => {
try {
// Create session (POST)
const createResp = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const createResp = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ room: roomName, username: userName }),
credentials: 'include',
})
credentials: "include",
});
if (!createResp.ok) {
console.warn('studio session create failed', createResp.status)
return
console.warn("studio session create failed", createResp.status);
return;
}
const created = await createResp.json()
const sessionId = created && created.id
const created = await createResp.json();
const sessionId = created && created.id;
if (!sessionId) {
console.warn('studio session create returned no id', created)
return
console.warn("studio session create returned no id", created);
return;
}
// Fetch full session data (token and url)
const fetchResp = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { method: 'GET', credentials: 'include' })
const fetchResp = await fetch(
`/api/session/${encodeURIComponent(sessionId)}`,
{ method: "GET", credentials: "include" },
);
if (!fetchResp.ok) {
console.warn('failed fetching session data', fetchResp.status)
return
console.warn("failed fetching session data", fetchResp.status);
return;
}
const data = await fetchResp.json()
const data = await fetchResp.json();
// expected { token, url }
setTokenData({ token: data.token, url: data.url })
return
setTokenData({ token: data.token, url: data.url });
return;
} catch (e) {
console.warn('failed requesting studio session', e)
console.warn("failed requesting studio session", e);
}
})()
}, [])
})();
}, []);
// New: optionally use the LiveKit React components wrapper (dynamically imported)
const useLiveKitComponents =
(import.meta.env.VITE_USE_LIVEKIT_COMPONENTS as string) === "1";
const [LKPortal, setLKPortal] = useState<any | null>(null);
useEffect(() => {
if (!useLiveKitComponents)
return; // dynamic import so bundler only includes it when requested
(async () => {
try {
const mod = await import(
"../features/studio/StudioPortalWithComponents"
);
setLKPortal(mod.default || null);
} catch (e) {
console.warn("Failed to load StudioPortalWithComponents", e);
}
})();
}, [useLiveKitComponents]);
return (
<ThemeProvider>
@ -58,13 +85,35 @@ const Studio: React.FC = () => {
<Header />
<main className={styles.studioMain}>
{tokenData ? (
<StudioPortal serverUrl={tokenData.url || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''} token={tokenData.token || ''} />
useLiveKitComponents && LKPortal ? (
<LKPortal
serverUrl={
tokenData.url ||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
""
}
token={tokenData.token || ""}
/>
) : (
<StudioPortal
serverUrl={
tokenData.url ||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
""
}
token={tokenData.token || ""}
/>
)
) : (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent mb-4"></div>
<h2 className="text-xl font-semibold">Preparando tu estudio...</h2>
<p className="text-gray-500 mt-2">Solicitando credenciales seguras</p>
<h2 className="text-xl font-semibold">
Preparando tu estudio...
</h2>
<p className="text-gray-500 mt-2">
Solicitando credenciales seguras
</p>
</div>
</div>
)}
@ -72,7 +121,7 @@ const Studio: React.FC = () => {
</div>
</div>
</ThemeProvider>
)
}
);
};
export default Studio
export default Studio;

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +1,56 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = 'light' | 'dark' | 'system';
type Theme = "light" | "dark" | "system";
interface ThemeContextType {
theme: Theme;
resolvedTheme: 'light' | 'dark';
resolvedTheme: "light" | "dark";
setThemeMode: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('avanzacast-theme-mode');
return (saved as Theme) || 'system';
const saved = localStorage.getItem("avanzacast-theme-mode");
return (saved as Theme) || "system";
});
const getSystem = () => (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const getSystem = () =>
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const apply = (mode: Theme) => {
const root = document.documentElement;
const resolved = mode === 'system' ? getSystem() : mode;
root.setAttribute('data-theme', resolved);
const resolved = mode === "system" ? getSystem() : mode;
root.setAttribute("data-theme", resolved);
};
useEffect(() => {
apply(theme);
localStorage.setItem('avanzacast-theme-mode', theme);
localStorage.setItem("avanzacast-theme-mode", theme);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
// only react if mode is system
if (theme === 'system') apply('system');
if (theme === "system") apply("system");
};
if (mq.addEventListener) mq.addEventListener('change', handler);
if (mq.addEventListener) mq.addEventListener("change", handler);
else mq.addListener(handler);
return () => {
if (mq.removeEventListener) mq.removeEventListener('change', handler);
if (mq.removeEventListener) mq.removeEventListener("change", handler);
else mq.removeListener(handler);
};
}, [theme]);
const setThemeMode = (t: Theme) => setTheme(t);
const resolvedTheme = theme === 'system' ? getSystem() : theme;
const resolvedTheme = theme === "system" ? getSystem() : theme;
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setThemeMode }}>
@ -56,7 +62,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme debe usarse dentro de ThemeProvider');
throw new Error("useTheme debe usarse dentro de ThemeProvider");
}
return context;
};

View File

@ -1,20 +1,43 @@
import React from 'react'
import React from "react";
export type ToastVariant = 'info'|'success'|'error'|'warning'
export type ToastVariant = "info" | "success" | "error" | "warning";
export function Toast({ message, variant = 'info' }: { message: string, variant?: ToastVariant }) {
const bg = variant === 'success' ? '#d1fae5' : variant === 'error' ? '#fee2e2' : variant === 'warning' ? '#fff7ed' : '#eef2ff'
const color = variant === 'success' ? '#065f46' : variant === 'error' ? '#991b1b' : variant === 'warning' ? '#92400e' : '#3730a3'
export function Toast({
message,
variant = "info",
}: {
message: string;
variant?: ToastVariant;
}) {
const bg =
variant === "success"
? "#d1fae5"
: variant === "error"
? "#fee2e2"
: variant === "warning"
? "#fff7ed"
: "#eef2ff";
const color =
variant === "success"
? "#065f46"
: variant === "error"
? "#991b1b"
: variant === "warning"
? "#92400e"
: "#3730a3";
return (
<div style={{
background: bg,
color,
padding: '10px 14px',
borderRadius: 8,
boxShadow: '0 6px 18px rgba(15,23,42,0.08)'
}}>{message}</div>
)
<div
style={{
background: bg,
color,
padding: "10px 14px",
borderRadius: 8,
boxShadow: "0 6px 18px rgba(15,23,42,0.08)",
}}
>
{message}
</div>
);
}
export default Toast
export default Toast;

View File

@ -1,17 +1,21 @@
import React, { useState } from 'react';
import styles from './Tooltip.module.css';
import React, { useState } from "react";
import styles from "./Tooltip.module.css";
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
position?: "top" | "bottom" | "left" | "right";
}
export const Tooltip: React.FC<TooltipProps> = ({ content, children, position = 'bottom' }) => {
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = "bottom",
}) => {
const [isVisible, setIsVisible] = useState(false);
return (
<div
<div
className={styles.tooltipWrapper}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}

View File

@ -51,7 +51,7 @@
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid rgba(0,0,0,0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.tableRow {
@ -130,7 +130,7 @@
border-radius: 12px;
padding: 12px 16px;
/* subtle elevation to match mock */
box-shadow: 0 6px 18px rgba(2,6,23,0.04);
box-shadow: 0 6px 18px rgba(2, 6, 23, 0.04);
/* constrain width to visually match the Create cards and center */
max-width: 980px;
margin: 0 auto;
@ -204,20 +204,28 @@
/* Spinner used in the Entrar button */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: rgba(0,0,0,0.8);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 10px 14px;
border-radius: 8px;
z-index: 9999;
box-shadow: 0 6px 20px rgba(2,6,23,0.2);
box-shadow: 0 6px 20px rgba(2, 6, 23, 0.2);
}
.toast.error {
background: rgba(160, 40, 40, 0.95);
}
.toast.info {
background: rgba(30, 120, 255, 0.95);
}
.toast.error { background: rgba(160, 40, 40, 0.95); }
.toast.info { background: rgba(30, 120, 255, 0.95); }

View File

@ -1,14 +1,18 @@
import React from 'react'
import React from "react";
type Props = {
className?: string
size?: number
color?: string
}
className?: string;
size?: number;
color?: string;
};
export const PlusLarge: React.FC<Props> = ({ className = '', size = 20, color = 'var(--primary-blue)' }) => {
const half = size / 2
const stroke = Math.max(1, Math.round(size * 0.12))
export const PlusLarge: React.FC<Props> = ({
className = "",
size = 20,
color = "var(--primary-blue)",
}) => {
const half = size / 2;
const stroke = Math.max(1, Math.round(size * 0.12));
return (
<svg
className={className}
@ -19,11 +23,34 @@ export const PlusLarge: React.FC<Props> = ({ className = '', size = 20, color =
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect x="0" y="0" width={size} height={size} rx={Math.round(size * 0.18)} fill="none" />
<line x1={half} y1={size * 0.18} x2={half} y2={size * 0.82} stroke={color} strokeWidth={stroke} strokeLinecap="round" />
<line x1={size * 0.18} y1={half} x2={size * 0.82} y2={half} stroke={color} strokeWidth={stroke} strokeLinecap="round" />
<rect
x="0"
y="0"
width={size}
height={size}
rx={Math.round(size * 0.18)}
fill="none"
/>
<line
x1={half}
y1={size * 0.18}
x2={half}
y2={size * 0.82}
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
/>
<line
x1={size * 0.18}
y1={half}
x2={size * 0.82}
y2={half}
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
/>
</svg>
)
}
);
};
export default PlusLarge
export default PlusLarge;

View File

@ -0,0 +1,68 @@
import React from "react";
type Props = { size?: number; color?: string; className?: string };
// Stylized spinner: circular arc with smooth rotation; uses <animateTransform>
export const Spinner: React.FC<Props> = ({
size = 18,
color = "#0b5cff",
className = "",
}) => {
const stroke = Math.max(2, Math.round(size * 0.12));
const r = (size - stroke) / 2;
const cx = size / 2;
const cy = size / 2;
const dashArray = Math.round(Math.PI * r * 1.5);
return (
<svg
className={className}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
role="img"
style={{ display: "inline-block", verticalAlign: "middle" }}
>
<defs>
<linearGradient id="spinnerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="100%" stopColor={color} stopOpacity="0.3" />
</linearGradient>
</defs>
<g transform={`translate(0,0)`}>
<circle
cx={cx}
cy={cy}
r={r}
stroke="#e6eefc"
strokeWidth={stroke}
fill="none"
/>
<circle
cx={cx}
cy={cy}
r={r}
stroke="url(#spinnerGrad)"
strokeWidth={stroke}
strokeLinecap="round"
fill="none"
strokeDasharray={`${dashArray} ${Math.round(Math.PI * r * 2)}`}
>
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from={`0 ${cx} ${cy}`}
to={`360 ${cx} ${cy}`}
dur="0.9s"
repeatCount="indefinite"
/>
</circle>
</g>
</svg>
);
};
export default Spinner;

View File

@ -1,10 +1,10 @@
// Local env declarations for broadcast-panel (Vite-style import.meta.env)
interface ImportMetaEnv {
readonly VITE_TOKEN_SERVER_URL?: string
readonly VITE_STUDIO_URL?: string
[key: string]: string | undefined
readonly VITE_TOKEN_SERVER_URL?: string;
readonly VITE_STUDIO_URL?: string;
[key: string]: string | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
type SessionResp = {
id?: string;
token?: string;
studioUrl?: string;
username?: string;
[k: string]: any;
};
async function resolveIdentity(): Promise<string | null> {
try {
const r = await fetch("/api/auth/session");
if (r.ok) {
const j = await r.json();
if (j && j.user) return j.user.name || j.user.email || null;
}
} catch (e) {
/* ignore */
}
try {
// @ts-ignore
if (typeof window !== "undefined" && (window as any).__AVANZACAST_USER) {
// @ts-ignore
const u = (window as any).__AVANZACAST_USER;
if (typeof u === "string") return u;
if (u && (u.name || u.id || u.email)) return u.name || u.id || u.email;
}
} catch (e) {
/* ignore */
}
try {
const answer =
typeof window !== "undefined"
? window.prompt(
"Identifícate (nombre de usuario) para ligar con la room",
"visual-runner",
)
: null;
if (answer && answer.trim()) return answer.trim();
} catch (e) {
/* ignore */
}
return null;
}
export default function AutoRequestAndInject({
roomName,
}: {
roomName: string;
}) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function run() {
setLoading(true);
setError(null);
try {
const identity = await resolveIdentity();
if (!identity)
throw new Error(
"No identity resolved; please login or set window.__AVANZACAST_USER",
);
if (mounted) setUsername(identity);
const resp = await fetch("/api/session/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
room: roomName,
username: identity,
ttl: 300,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json: SessionResp = await resp.json();
if (!json.token) {
if (json.id) {
const getResp = await fetch(
`/api/session/proxy/${encodeURIComponent(json.id)}`,
);
if (getResp.ok) {
const getJson = await getResp.json();
if (getJson && getJson.token) json.token = getJson.token;
}
}
}
if (!json.token)
throw new Error("No token returned from session proxy");
window.postMessage(
{
type: "LIVEKIT_TOKEN",
token: json.token,
room: roomName,
username: identity,
},
window.location.origin,
);
console.log("AutoRequestAndInject: posted LIVEKIT_TOKEN to window", {
room: roomName,
username: identity,
});
} catch (err: any) {
console.error("AutoRequestAndInject error", err);
if (mounted) setError(String(err));
} finally {
if (mounted) setLoading(false);
}
}
run();
return () => {
mounted = false;
};
}, [roomName]);
if (loading)
return (
<div aria-live="polite">
Solicitando token de sesión para room "{roomName}"...
</div>
);
if (error) return <div role="alert">Error solicitando token: {error}</div>;
return (
<div>
{username ? (
<div>
Identidad: <strong>{username}</strong>
</div>
) : null}
</div>
);
}

View File

@ -1,9 +1,9 @@
import React, { useState, useContext } from 'react'
import { RoomContext } from '@livekit/components-react'
import { Room } from 'livekit-client'
import { ControlButton, ControlGroup, IconButton } from 'avanza-ui'
import IconCameraOn from './icons/IconCameraOn'
import IconMicOff from './icons/IconMicOff'
import React, { useState, useContext } from "react";
import { RoomContext } from "@livekit/components-react";
import { Room } from "livekit-client";
import { ControlButton, ControlGroup, IconButton } from "avanza-ui";
import IconCameraOn from "./icons/IconCameraOn";
import IconMicOff from "./icons/IconMicOff";
interface BottomControlsProps {
onToggleMute?: (muted: boolean) => void;
@ -11,129 +11,166 @@ interface BottomControlsProps {
onToggleRecording?: (recording: boolean) => void;
}
let idCounter = 0
function uniqueId(prefix = 'id'){
idCounter += 1
return `${prefix}-${idCounter}`
let idCounter = 0;
function uniqueId(prefix = "id") {
idCounter += 1;
return `${prefix}-${idCounter}`;
}
export default function BottomControls({ onToggleMute, onToggleCamera, onToggleRecording }: BottomControlsProps){
const [muted, setMuted] = useState(false)
const [cameraOn, setCameraOn] = useState(true)
const [recording, setRecording] = useState(false)
export default function BottomControls({
onToggleMute,
onToggleCamera,
onToggleRecording,
}: BottomControlsProps) {
const [muted, setMuted] = useState(false);
const [cameraOn, setCameraOn] = useState(true);
const [recording, setRecording] = useState(false);
const ctxRoom = useContext(RoomContext) as Room | null
const ctxRoom = useContext(RoomContext) as Room | null;
React.useEffect(() => {
function onGoLive(e: any) {
try {
const d = e?.detail || {};
if (d.action === 'start') setRecording(true);
else if (d.action === 'stop') setRecording(false);
} catch (err) { console.warn('go-live handler error', err) }
if (d.action === "start") setRecording(true);
else if (d.action === "stop") setRecording(false);
} catch (err) {
console.warn("go-live handler error", err);
}
}
window.addEventListener('avz:request:go-live', onGoLive as EventListener);
return () => window.removeEventListener('avz:request:go-live', onGoLive as EventListener);
window.addEventListener("avz:request:go-live", onGoLive as EventListener);
return () =>
window.removeEventListener(
"avz:request:go-live",
onGoLive as EventListener,
);
}, []);
const muteTipId = React.useMemo(() => uniqueId('tip-mute'), [])
const camTipId = React.useMemo(() => uniqueId('tip-cam'), [])
const recTipId = React.useMemo(() => uniqueId('tip-rec'), [])
const muteTipId = React.useMemo(() => uniqueId("tip-mute"), []);
const camTipId = React.useMemo(() => uniqueId("tip-cam"), []);
const recTipId = React.useMemo(() => uniqueId("tip-rec"), []);
const safeSetMic = async (enabled: boolean) => {
try {
const r = ctxRoom as any
if (!r) return
const lp = r.localParticipant
if (!lp) return
if (typeof lp.setMicrophoneEnabled === 'function') {
await lp.setMicrophoneEnabled(enabled)
return
const r = ctxRoom as any;
if (!r) return;
const lp = r.localParticipant;
if (!lp) return;
if (typeof lp.setMicrophoneEnabled === "function") {
await lp.setMicrophoneEnabled(enabled);
return;
}
if (lp.audioTracks && Array.isArray(lp.audioTracks)) {
for (const tpub of lp.audioTracks) {
try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){}
try {
tpub.track &&
typeof tpub.track.enable === "function" &&
tpub.track.enable(enabled);
} catch (e) {}
}
}
} catch (e) {
console.warn('safeSetMic failed', e)
console.warn("safeSetMic failed", e);
}
}
};
const safeSetCamera = async (enabled: boolean) => {
try {
const r = ctxRoom as any
if (!r) return
const lp = r.localParticipant
if (!lp) return
if (typeof lp.setCameraEnabled === 'function') {
await lp.setCameraEnabled(enabled)
return
const r = ctxRoom as any;
if (!r) return;
const lp = r.localParticipant;
if (!lp) return;
if (typeof lp.setCameraEnabled === "function") {
await lp.setCameraEnabled(enabled);
return;
}
if (lp.videoTracks && Array.isArray(lp.videoTracks)) {
for (const tpub of lp.videoTracks) {
try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){}
try {
tpub.track &&
typeof tpub.track.enable === "function" &&
tpub.track.enable(enabled);
} catch (e) {}
}
}
} catch (e) {
console.warn('safeSetCamera failed', e)
console.warn("safeSetCamera failed", e);
}
}
};
const safeToggleRecording = async (start: boolean) => {
try {
const r = ctxRoom as any
if (!r) return
const lp = r.localParticipant
if (!lp) return
if (typeof lp.publishData === 'function') {
const payload = JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop', ts: Date.now() })
const enc = new TextEncoder().encode(payload)
try { await lp.publishData(enc, { reliable: true }) } catch(e) { console.warn('publishData failed', e) }
return
const r = ctxRoom as any;
if (!r) return;
const lp = r.localParticipant;
if (!lp) return;
if (typeof lp.publishData === "function") {
const payload = JSON.stringify({
type: "RECORDING",
action: start ? "start" : "stop",
ts: Date.now(),
});
const enc = new TextEncoder().encode(payload);
try {
await lp.publishData(enc, { reliable: true });
} catch (e) {
console.warn("publishData failed", e);
}
return;
}
if (typeof r.sendData === 'function') {
try { r.sendData(JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop' })) } catch(e) {}
if (typeof r.sendData === "function") {
try {
r.sendData(
JSON.stringify({
type: "RECORDING",
action: start ? "start" : "stop",
}),
);
} catch (e) {}
}
} catch (e) {
console.warn('safeToggleRecording failed', e)
console.warn("safeToggleRecording failed", e);
}
}
};
const handleToggleMute = async () => {
const next = !muted
setMuted(next)
onToggleMute?.(next)
await safeSetMic(!next ? true : false)
}
const next = !muted;
setMuted(next);
onToggleMute?.(next);
await safeSetMic(!next ? true : false);
};
const handleToggleCamera = async () => {
const next = !cameraOn
setCameraOn(next)
onToggleCamera?.(next)
await safeSetCamera(next)
}
const next = !cameraOn;
setCameraOn(next);
onToggleCamera?.(next);
await safeSetCamera(next);
};
const handleToggleRecording = async () => {
const next = !recording
setRecording(next)
onToggleRecording?.(next)
}
const next = !recording;
setRecording(next);
onToggleRecording?.(next);
};
return (
<div className="fixed bottom-4 left-4 right-4">
<ControlGroup className="controls-inner" style={{boxShadow: '0 10px 30px rgba(0,0,0,0.35)'}}>
<ControlGroup
className="controls-inner"
style={{ boxShadow: "0 10px 30px rgba(0,0,0,0.35)" }}
>
<div className="control-wrapper">
<IconButton
id={`btn-mic`}
icon={<IconMicOff />}
active={!muted}
title={muted ? 'Activar micrófono' : 'Silenciar'}
title={muted ? "Activar micrófono" : "Silenciar"}
onClick={handleToggleMute}
size="sm"
/>
<span id={muteTipId} role="tooltip" className="tooltip">{muted ? 'Activar micrófono' : 'Silenciar'}</span>
<span id={muteTipId} role="tooltip" className="tooltip">
{muted ? "Activar micrófono" : "Silenciar"}
</span>
</div>
<div className="control-wrapper">
@ -141,31 +178,35 @@ export default function BottomControls({ onToggleMute, onToggleCamera, onToggleR
id={`btn-cam`}
icon={<IconCameraOn />}
active={cameraOn}
title={cameraOn ? 'Apagar cámara' : 'Encender cámara'}
title={cameraOn ? "Apagar cámara" : "Encender cámara"}
onClick={handleToggleCamera}
size="sm"
/>
<span id={camTipId} role="tooltip" className="tooltip">{cameraOn ? 'Apagar cámara' : 'Encender cámara'}</span>
<span id={camTipId} role="tooltip" className="tooltip">
{cameraOn ? "Apagar cámara" : "Encender cámara"}
</span>
</div>
<div className="control-wrapper">
<ControlButton
id="recBtn"
icon={recording ? <span className="record-dot" /> : undefined}
label={recording ? 'Stop' : 'Start'}
label={recording ? "Stop" : "Start"}
active={recording}
danger={true}
title={recording ? 'Detener grabación' : 'Iniciar grabación'}
title={recording ? "Detener grabación" : "Iniciar grabación"}
onClick={handleToggleRecording}
size="md"
/>
<span id={recTipId} role="tooltip" className="tooltip">{recording ? 'Detener grabación' : 'Iniciar grabación'}</span>
<span id={recTipId} role="tooltip" className="tooltip">
{recording ? "Detener grabación" : "Iniciar grabación"}
</span>
</div>
<span className="visually-hidden" aria-live="polite">{recording ? 'Grabación iniciada' : 'Grabación detenida'}</span>
<span className="visually-hidden" aria-live="polite">
{recording ? "Grabación iniciada" : "Grabación detenida"}
</span>
</ControlGroup>
</div>
)
);
}

View File

@ -1,17 +1,184 @@
// moved from studio-panel
.studio-portal{display:flex;height:100vh;gap:12px;background:var(--studio-bg-primary)}
.studio-portal__left{width:280px;background:var(--studio-bg-secondary);padding:12px;border-right:1px solid var(--studio-border)}
.studio-portal__right{width:320px;background:var(--studio-bg-secondary);padding:12px;border-left:1px solid var(--studio-border)}
.studio-portal__center{flex:1;display:flex;flex-direction:column;align-items:stretch;padding:12px}
.preview-wrapper{flex:1;background:#0f0f0f;border-radius:8px;display:flex;align-items:center;justify-content:center}
.controls-bar{display:flex;justify-content:space-between;align-items:center;padding:12px 0}
.scenes-header{font-weight:700;margin-bottom:8px}
.scenes-list{display:flex;flex-direction:column;gap:8px}
.scene-item{padding:10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)}
.scene-item.active{background:var(--studio-accent);color:#fff}
.layout-presets{display:flex;gap:8px}
.layout-btn{padding:8px 10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)}
.layout-btn.active{background:var(--studio-accent);color:#fff}
.actions .btn-record{background:#2563eb;color:#fff;padding:10px 16px;border-radius:8px;border:none}
.actions .btn-stop{background:#dc2626;color:#fff;padding:10px 16px;border-radius:8px;border:none}
/* moved from studio-panel */
.studio-portal {
display: flex;
height: 100vh;
gap: 12px;
background: var(--studio-bg-primary);
align-items: stretch;
}
/* Left column: Scenes */
.studio-portal__left {
width: 280px;
background: var(--studio-bg-secondary);
padding: 16px;
border-right: 1px solid var(--studio-border);
display: flex;
flex-direction: column;
}
.scenes-header {
font-weight: 700;
margin-bottom: 12px;
}
.scenes-list {
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
padding-right: 4px;
}
.scene-item {
padding: 10px;
border-radius: 6px;
background: var(--studio-bg-primary);
border: 1px solid var(--studio-border);
display: flex;
align-items: center;
gap: 8px;
}
.scene-item.active {
background: var(--studio-accent);
color: #fff;
}
.btn-new-scene {
margin-top: auto;
padding: 10px;
border-radius: 8px;
border: none;
background: #fff;
}
/* Center column: main studio area */
.studio-portal__center {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
gap: 12px;
}
.preview-wrapper {
flex: 1;
background: #0b0b0b;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
padding: 12px;
}
/* Large video stage area */
.studio-room__content {
flex: 1;
display: flex;
align-items: stretch;
justify-content: center;
}
.studio-room__grid {
width: 100%;
height: 100%;
border-radius: 6px;
overflow: hidden;
}
/* Thumbnails / presenter row */
.preview-thumbs {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 8px;
background: transparent;
}
.preview-thumb {
width: 160px;
height: 90px;
background: #111;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.preview-thumb .name {
margin-top: 6px;
font-size: 13px;
color: #d1d5db;
}
/* Controls bar below central area */
.controls-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.layout-presets {
display: flex;
gap: 8px;
}
.layout-btn {
padding: 8px 10px;
border-radius: 6px;
background: var(--studio-bg-primary);
border: 1px solid var(--studio-border);
}
.layout-btn.active {
background: var(--studio-accent);
color: #fff;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
}
.actions .btn-record {
background: #2563eb;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
border: none;
}
.actions .btn-stop {
background: #dc2626;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
border: none;
}
/* Right column: comments / assets */
.studio-portal__right {
width: 320px;
background: var(--studio-bg-secondary);
padding: 12px;
border-left: 1px solid var(--studio-border);
display: flex;
flex-direction: column;
}
.sidebar-section {
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
font-size: 14px;
}
/* Responsive behavior */
@media (max-width: 1100px) {
.studio-portal {
flex-direction: column;
height: 100%;
}
.studio-portal__left,
.studio-portal__right {
width: 100%;
height: auto;
}
.studio-portal__center {
order: 2;
}
}
/* Small utility */
.scene-item img {
max-width: 36px;
border-radius: 4px;
}

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import StudioRoom from './StudioRoom';
import './StudioPortal.css';
import { Room } from 'livekit-client';
import React, { useState, useEffect, useRef } from "react";
import StudioRoom from "./StudioRoom";
import "./StudioPortal.css";
import { Room } from "livekit-client";
import AutoRequestAndInject from "./AutoRequestAndInject";
export interface StudioPortalProps {
serverUrl: string;
@ -15,17 +16,27 @@ export interface StudioPortalProps {
}
const LAYOUTS = [
{ id: 'layout-1', label: 'Individual' },
{ id: 'layout-2', label: 'Gallery' },
{ id: 'layout-3', label: 'Speaker' },
{ id: 'layout-4', label: 'Wide' },
{ id: "layout-1", label: "Individual" },
{ id: "layout-2", label: "Gallery" },
{ id: "layout-3", label: "Speaker" },
{ id: "layout-4", label: "Wide" },
];
export default function StudioPortal({ serverUrl, token, roomName, onRoomConnected, onRoomDisconnected, onRoomConnectError, room }: StudioPortalProps) {
export default function StudioPortal({
serverUrl,
token,
roomName,
onRoomConnected,
onRoomDisconnected,
onRoomConnectError,
room,
}: StudioPortalProps) {
const [activeLayout, setActiveLayout] = useState(LAYOUTS[0].id);
const [live, setLive] = useState(false);
// allow override of serverUrl via postMessage (useful for e2e)
const [serverUrlOverride, setServerUrlOverride] = useState<string | null>(null);
const [serverUrlOverride, setServerUrlOverride] = useState<string | null>(
null,
);
// Local room management when App does not provide a room prop
const localRoomRef = useRef<Room | null>(null);
@ -47,7 +58,9 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
setIsConnecting(true);
// cleanup previous
if (localRoomRef.current) {
try { localRoomRef.current.disconnect(); } catch(e) {}
try {
localRoomRef.current.disconnect();
} catch (e) {}
localRoomRef.current = null;
}
const r = new Room();
@ -56,11 +69,13 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
setIsConnected(true);
onRoomConnected && onRoomConnected();
} catch (err) {
console.error('StudioPortal: failed to connect local room', err);
console.error("StudioPortal: failed to connect local room", err);
setIsConnected(false);
const msg = (err as any)?.message ?? String(err);
setConnectError(msg);
try { onRoomConnectError && onRoomConnectError(err) } catch(e){}
try {
onRoomConnectError && onRoomConnectError(err);
} catch (e) {}
} finally {
setIsConnecting(false);
}
@ -72,14 +87,22 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
localRoomRef.current.disconnect();
localRoomRef.current = null;
}
} catch (e) { /* ignore */ }
} catch (e) {
/* ignore */
}
setIsConnected(false);
onRoomDisconnected && onRoomDisconnected();
};
// Auto-connect when token becomes available and there is no external room
useEffect(() => {
if (!isExternalRoom && (tokenFromMessage || token) && (tokenFromMessage || token).trim() && !isConnected && !isConnecting) {
if (
!isExternalRoom &&
(tokenFromMessage || token) &&
(tokenFromMessage || token).trim() &&
!isConnected &&
!isConnecting
) {
connectWithToken();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -91,46 +114,79 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
try {
const data = e.data || {};
// respond to ping from launcher so it knows we are ready
if (data && data.type === 'LIVEKIT_PING') {
try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch(e) { /* ignore */ }
return
if (data && data.type === "LIVEKIT_PING") {
try {
(e.source as Window)?.postMessage?.(
{ type: "LIVEKIT_READY" },
e.origin || "*",
);
} catch (e) {
/* ignore */
}
return;
}
// accept object messages with type LIVEKIT_TOKEN and token
if (data && data.type === 'LIVEKIT_TOKEN' && data.token) {
console.info('StudioPortal received token via postMessage', { origin: e.origin })
setTokenFromMessage(String(data.token))
if (data && data.type === "LIVEKIT_TOKEN" && data.token) {
console.info("StudioPortal received token via postMessage", {
origin: e.origin,
});
setTokenFromMessage(String(data.token));
// optionally accept serverUrl override for e2e flows
if (data.serverUrl) setServerUrlOverride(String(data.serverUrl));
// reply ack to sender
try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_ACK', room: data.room || '' }, e.origin || '*') } catch(e) { /* ignore */ }
try {
(e.source as Window)?.postMessage?.(
{ type: "LIVEKIT_ACK", room: data.room || "" },
e.origin || "*",
);
} catch (e) {
/* ignore */
}
}
} catch (err) { console.warn('postMessage handler error', err) }
} catch (err) {
console.warn("postMessage handler error", err);
}
}
window.addEventListener('message', onMessage);
return () => { window.removeEventListener('message', onMessage) }
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
try { if (localRoomRef.current) { localRoomRef.current.disconnect(); localRoomRef.current = null; } } catch (e) {}
try {
if (localRoomRef.current) {
localRoomRef.current.disconnect();
localRoomRef.current = null;
}
} catch (e) {}
};
}, []);
const handleStartLive = () => {
window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'start' } }));
window.dispatchEvent(
new CustomEvent("avz:request:go-live", { detail: { action: "start" } }),
);
setLive(true);
};
const handleStopLive = () => {
window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'stop' } }));
window.dispatchEvent(
new CustomEvent("avz:request:go-live", { detail: { action: "stop" } }),
);
setLive(false);
};
const changeLayout = (id: string) => {
setActiveLayout(id);
try {
window.dispatchEvent(new CustomEvent('avz:layout:change', { detail: { layoutId: id } }));
} catch (e) { console.warn('layout dispatch failed', e); }
window.dispatchEvent(
new CustomEvent("avz:layout:change", { detail: { layoutId: id } }),
);
} catch (e) {
console.warn("layout dispatch failed", e);
}
};
// Determine which room to pass into StudioRoom: external first, fallback to local
@ -149,49 +205,97 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
</aside>
<main className="studio-portal__center">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
marginBottom: 12,
}}
>
<div style={{ fontSize: 14 }}>
<strong>LiveKit:</strong> {serverUrlOverride || serverUrl}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{!isExternalRoom && (
<>
<button className="btn-small" onClick={() => connectWithToken()} disabled={isConnecting || isConnected}>
{isConnecting ? 'Conectando...' : isConnected ? 'Conectado' : 'Conectar'}
<button
className="btn-small"
onClick={() => connectWithToken()}
disabled={isConnecting || isConnected}
>
{isConnecting
? "Conectando..."
: isConnected
? "Conectado"
: "Conectar"}
</button>
<button className="btn-small" onClick={disconnectLocalRoom} disabled={!isConnected}>
<button
className="btn-small"
onClick={disconnectLocalRoom}
disabled={!isConnected}
>
Desconectar
</button>
</>
)}
{isExternalRoom && (
<div style={{ fontSize: 13, color: '#6b7280' }}>Usando Room externo</div>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Usando Room externo
</div>
)}
</div>
</div>
{/* show token status / errors for E2E debugging */}
<div style={{ marginBottom: 8 }}>
{/* Optional auto-inject helper for integrated flows (controlled via Vite env VITE_ENABLE_AUTO_INJECT=1) */}
{(import.meta.env.VITE_ENABLE_AUTO_INJECT as string) === "1" && (
<AutoRequestAndInject roomName={roomName || "avanzacast-studio"} />
)}
{tokenFromMessage ? (
<div style={{ fontSize: 13, color: isConnected ? '#10b981' : '#6b7280' }}>Token recibido desde Broadcast Panel (length {tokenFromMessage.length})</div>
<div
style={{
fontSize: 13,
color: isConnected ? "#10b981" : "#6b7280",
}}
>
Token recibido desde Broadcast Panel (length{" "}
{tokenFromMessage.length})
</div>
) : (
<div style={{ fontSize: 13, color: '#9ca3af' }}>Esperando token...</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Esperando token...
</div>
)}
{connectError && (
<div style={{ marginTop: 6, color: '#ef4444', fontSize: 13 }} className="studio-error-modal">Error de conexión: {connectError}</div>
<div
style={{ marginTop: 6, color: "#ef4444", fontSize: 13 }}
className="studio-error-modal"
>
Error de conexión: {connectError}
</div>
)}
</div>
<div className={`preview-wrapper ${activeLayout}`}>
<StudioRoom serverUrl={serverUrl} token={tokenFromMessage || token} roomName={roomName} onConnected={onRoomConnected} onDisconnected={onRoomDisconnected} room={effectiveRoom} />
<StudioRoom
serverUrl={serverUrl}
token={tokenFromMessage || token}
roomName={roomName}
onConnected={onRoomConnected}
onDisconnected={onRoomDisconnected}
room={effectiveRoom}
/>
</div>
<div className="controls-bar">
<div className="layout-presets">
{LAYOUTS.map(l => (
{LAYOUTS.map((l) => (
<button
key={l.id}
className={`layout-btn ${activeLayout === l.id ? 'active' : ''}`}
className={`layout-btn ${activeLayout === l.id ? "active" : ""}`}
onClick={() => changeLayout(l.id)}
>
{l.label}
@ -201,9 +305,19 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
<div className="actions">
{!live ? (
<button className="btn-record btn-go-live" onClick={handleStartLive}>Ir en vivo</button>
<button
className="btn-record btn-go-live"
onClick={handleStartLive}
>
Ir en vivo
</button>
) : (
<button className="btn-stop btn-end-live" onClick={handleStopLive}>Finalizar transmisión</button>
<button
className="btn-stop btn-end-live"
onClick={handleStopLive}
>
Finalizar transmisión
</button>
)}
</div>
</div>

View File

@ -0,0 +1,39 @@
import React from "react";
import { LiveKitRoom, VideoConference } from "@livekit/components-react";
export interface StudioPortalWithComponentsProps {
serverUrl: string;
token: string;
connect?: boolean;
roomName?: string;
}
const StudioPortalWithComponents: React.FC<StudioPortalWithComponentsProps> = ({
serverUrl,
token,
connect = true,
}) => {
// This component is a thin wrapper around LiveKit components. It expects a valid token and serverUrl.
// Usage: <StudioPortalWithComponents serverUrl={URL} token={TOKEN} connect={true} />
if (!token || !serverUrl) {
return (
<div style={{ padding: 16 }}>
<div style={{ fontWeight: 600 }}>Studio (LiveKit)</div>
<div style={{ color: "#6b7280", marginTop: 8 }}>
Esperando token y/o URL del servidor...
</div>
</div>
);
}
return (
<div style={{ width: "100%", height: "100%" }}>
<LiveKitRoom token={token} serverUrl={serverUrl} connect={connect}>
<VideoConference />
</LiveKitRoom>
</div>
);
};
export default StudioPortalWithComponents;

View File

@ -1,7 +1,110 @@
/* Minimal placeholder styles for StudioRoom used in tests */
.studio-room { font-family: sans-serif; }
.studio-room__header { display:flex; justify-content:space-between; }
.studio-room__content { height: 400px; }
.controls-inner { display:flex; gap:8px; }
.tooltip { display:none; }
.studio-room {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial;
color: #e5e7eb;
}
.studio-room__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 8px;
}
.studio-room__title h1 {
margin: 0;
font-size: 18px;
}
.studio-room__status {
display: flex;
align-items: center;
gap: 8px;
color: #9ca3af;
}
.studio-room__content {
flex: 1;
display: flex;
align-items: stretch;
justify-content: center;
min-height: 420px;
}
.preview-wrapper .studio-room__grid {
width: 100%;
height: 100%;
background: #000;
}
/* Grid layout for participants */
.studio-room__grid {
display: grid;
grid-template-columns: 1fr;
grid-auto-rows: minmax(200px, 1fr);
gap: 12px;
}
/* Participant tile overrides to make them occupy full area */
.lk-participant-tile {
background: #0b1220;
border-radius: 6px;
overflow: hidden;
}
.lk-participant-name {
font-size: 13px;
color: #d1d5db;
}
/* Controls */
.studio-room__controls {
padding: 12px 8px;
display: flex;
justify-content: flex-end;
}
.control-bar {
display: flex;
gap: 8px;
align-items: center;
}
/* Thumbnails row under main stage */
.preview-thumbs {
display: flex;
gap: 12px;
padding: 12px 8px;
align-items: center;
}
.preview-thumb {
width: 160px;
height: 90px;
background: #0f1724;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-thumb .name {
font-size: 13px;
color: #cbd5e1;
}
/* Overlay svg lines */
.connections-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Small helper for connecting state */
.studio-room__connecting {
padding: 24px;
text-align: center;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
// @vitest-environment jsdom
import React from 'react'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import React from "react";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
// Ensure livekit UI lib is mocked before any module imports that may load it
vi.mock('@livekit/components-react', () => {
const React = require('react');
const noop = () => React.createElement('div', null);
vi.mock("@livekit/components-react", () => {
const React = require("react");
const noop = () => React.createElement("div", null);
return {
GridLayout: noop,
ParticipantTile: noop,
@ -21,69 +21,106 @@ vi.mock('@livekit/components-react', () => {
usePrefetchRoom: () => ({}),
};
});
vi.mock('@livekit/components-styles', () => ({}));
vi.mock("@livekit/components-styles", () => ({}));
// Stub the actual StudioRoom implementation in the studio-panel package to avoid heavy hooks
vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({
__esModule: true,
default: (props: any) => React.createElement('div', { id: 'studio-room-mock' })
}))
vi.mock(
"../../../../studio-panel/src/components/StudioRoom/StudioRoom",
() => ({
__esModule: true,
default: (props: any) =>
React.createElement("div", { id: "studio-room-mock" }),
}),
);
// Also stub the local re-export module to be safe
vi.mock('../StudioRoom/StudioRoom', () => ({
vi.mock("../StudioRoom/StudioRoom", () => ({
__esModule: true,
default: (props: any) => React.createElement('div', { id: 'studio-room-mock' })
}))
default: (props: any) =>
React.createElement("div", { id: "studio-room-mock" }),
}));
import StudioPortal from '../StudioPortal'
const livekitMock: any = require('livekit-client');
import StudioPortal from "../StudioPortal";
const livekitMock: any = require("livekit-client");
describe('StudioPortal', () => {
describe("StudioPortal", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.clearAllMocks();
// reset instances array
if (livekitMock && livekitMock.__mocks && Array.isArray(livekitMock.__mocks.instances)) {
if (
livekitMock &&
livekitMock.__mocks &&
Array.isArray(livekitMock.__mocks.instances)
) {
livekitMock.__mocks.instances.length = 0;
}
})
});
it('creates a local Room and connects when token is provided and no external room', async () => {
render(<StudioPortal serverUrl="wss://example" token="FAKE_TOKEN" roomName="r" />)
it("creates a local Room and connects when token is provided and no external room", async () => {
render(
<StudioPortal
serverUrl="wss://example"
token="FAKE_TOKEN"
roomName="r"
/>,
);
// wait for the connect to be called
await waitFor(() => {
expect(livekitMock.__mocks.instances[0]).toBeDefined()
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://example', 'FAKE_TOKEN')
})
})
expect(livekitMock.__mocks.instances[0]).toBeDefined();
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith(
"wss://example",
"FAKE_TOKEN",
);
});
});
it('does not create a local Room when external room is provided', async () => {
const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() }
render(<StudioPortal serverUrl="wss://example" token="FAKE_TOKEN" roomName="r" room={fakeRoom} />)
it("does not create a local Room when external room is provided", async () => {
const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() };
render(
<StudioPortal
serverUrl="wss://example"
token="FAKE_TOKEN"
roomName="r"
room={fakeRoom}
/>,
);
// local constructor should not be called
await new Promise((r) => setTimeout(r, 50))
expect(livekitMock.__mocks.instances.length).toBe(0)
})
await new Promise((r) => setTimeout(r, 50));
expect(livekitMock.__mocks.instances.length).toBe(0);
});
it('connect/disconnect buttons call connectWithToken and disconnect', async () => {
it("connect/disconnect buttons call connectWithToken and disconnect", async () => {
// render without auto token to test manual connect: pass empty token first
const { rerender } = render(<StudioPortal serverUrl="wss://example" token="" roomName="r" />)
const { rerender } = render(
<StudioPortal serverUrl="wss://example" token="" roomName="r" />,
);
// Click connect button -> nothing happens since token empty, ensure no constructor called
const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, { exact: false })
fireEvent.click(connectBtn)
expect(livekitMock.__mocks.instances.length).toBe(0)
const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, {
exact: false,
});
fireEvent.click(connectBtn);
expect(livekitMock.__mocks.instances.length).toBe(0);
// Rerender with token to enable connect via button
rerender(<StudioPortal serverUrl="wss://example" token="MANUAL_TOKEN" roomName="r" />)
rerender(
<StudioPortal
serverUrl="wss://example"
token="MANUAL_TOKEN"
roomName="r"
/>,
);
// Wait for auto connect (effect) or click button to trigger connect
await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined())
await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined());
// Now test disconnect button triggers disconnect
const disconnectBtn = screen.getByText('Desconectar')
fireEvent.click(disconnectBtn)
await waitFor(() => expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled())
})
})
const disconnectBtn = screen.getByText("Desconectar");
fireEvent.click(disconnectBtn);
await waitFor(() =>
expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled(),
);
});
});

View File

@ -1,126 +1,181 @@
// @vitest-environment jsdom
import React, { useEffect, useState } from 'react'
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import React, { useEffect, useState } from "react";
import { render, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import useStudioLauncher from '../../../hooks/useStudioLauncher'
import useStudioLauncher from "../../../hooks/useStudioLauncher";
// Ensure we don't import the heavy StudioRoom implementation that requires ResizeObserver/WebRTC
vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({
vi.mock(
"../../../../studio-panel/src/components/StudioRoom/StudioRoom",
() => ({
__esModule: true,
default: (_props: any) =>
React.createElement("div", { id: "studio-room-mock" }),
}),
);
vi.mock("../StudioRoom/StudioRoom", () => ({
__esModule: true,
default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' })
}))
vi.mock('../StudioRoom/StudioRoom', () => ({
__esModule: true,
default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' })
}))
default: (_props: any) =>
React.createElement("div", { id: "studio-room-mock" }),
}));
import StudioPortal from '../StudioPortal'
import StudioPortal from "../StudioPortal";
// Simple component that exposes openStudio via a button using the hook
function LauncherButton({ room = 'sim-room', username = 'tester' }: { room?: string; username?: string }) {
const { openStudio } = useStudioLauncher()
function LauncherButton({
room = "sim-room",
username = "tester",
}: {
room?: string;
username?: string;
}) {
const { openStudio } = useStudioLauncher();
return (
<button onClick={() => openStudio({ room, username })} data-testid="open-studio">
<button
onClick={() => openStudio({ room, username })}
data-testid="open-studio"
>
Open Studio
</button>
)
);
}
// App side that listens for LIVEKIT_TOKEN and renders StudioPortal when token arrives
function TestApp() {
const [token, setToken] = useState<string>('')
const [serverUrl, setServerUrl] = useState<string>('')
const [token, setToken] = useState<string>("");
const [serverUrl, setServerUrl] = useState<string>("");
useEffect(() => {
function onMessage(e: MessageEvent) {
try {
const d = e.data || {}
if (d?.type === 'LIVEKIT_TOKEN' && d.token) {
const d = e.data || {};
if (d?.type === "LIVEKIT_TOKEN" && d.token) {
// set token and optional url
setToken(String(d.token))
if (d.url) setServerUrl(String(d.url))
setToken(String(d.token));
if (d.url) setServerUrl(String(d.url));
// Reply ACK via the message source if available
try {
const ack = { type: 'LIVEKIT_ACK', status: 'connected', room: d.room }
if (e.source && typeof (e.source as any).postMessage === 'function') {
try { (e.source as any).postMessage(ack, e.origin || '*') } catch (err) {}
const ack = {
type: "LIVEKIT_ACK",
status: "connected",
room: d.room,
};
if (
e.source &&
typeof (e.source as any).postMessage === "function"
) {
try {
(e.source as any).postMessage(ack, e.origin || "*");
} catch (err) {}
}
// also post to opener/parent just in case
try { window.postMessage(ack, e.origin || '*') } catch (err) {}
try {
window.postMessage(ack, e.origin || "*");
} catch (err) {}
} catch (err) {}
}
} catch (err) {}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return (
<div>
<div data-testid="status">{token ? 'token:' + token : 'no-token'}</div>
{token ? <StudioPortal serverUrl={serverUrl || 'wss://example'} token={token} roomName={'sim-room'} /> : null}
<div data-testid="status">{token ? "token:" + token : "no-token"}</div>
{token ? (
<StudioPortal
serverUrl={serverUrl || "wss://example"}
token={token}
roomName={"sim-room"}
/>
) : null}
</div>
)
);
}
describe('E2E simulated flow: Broadcast -> Studio', () => {
let originalOpen: any
let popupMock: any
describe("E2E simulated flow: Broadcast -> Studio", () => {
let originalOpen: any;
let popupMock: any;
beforeEach(() => {
vi.clearAllMocks()
originalOpen = (window as any).open
vi.clearAllMocks();
originalOpen = (window as any).open;
// popup mock that simply dispatches message events to window when postMessage is called
// make popupMock a callable function object so TS doesn't complain if it's invoked
const pm: any = function() { /* noop callable */ };
pm.location = { href: '' };
const pm: any = function () {
/* noop callable */
};
pm.location = { href: "" };
pm.closed = false;
pm.postMessage = (message: any, targetOrigin: string) => {
// simulate asynchronous arrival in the popup (studio)
setTimeout(() => {
// Message arrives to the studio (which in our test is the same window)
const ev = new MessageEvent('message', { data: message, origin: targetOrigin, source: pm })
window.dispatchEvent(ev)
}, 20)
const ev = new MessageEvent("message", {
data: message,
origin: targetOrigin,
source: pm,
});
window.dispatchEvent(ev);
}, 20);
};
popupMock = pm
// Replace window.open to return our popup mock
(window as any).open = vi.fn(() => popupMock)
popupMock = pm(
// Replace window.open to return our popup mock
window as any,
).open = vi.fn(() => popupMock);
// mock fetch to token server to return a session with token and studioUrl
globalThis.fetch = vi.fn(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ token: 'SIM_TOKEN', room: 'sim-room', studioUrl: window.location.origin + '/studio', url: 'wss://livekit-server.example' })
})) as any
})
globalThis.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
token: "SIM_TOKEN",
room: "sim-room",
studioUrl: window.location.origin + "/studio",
url: "wss://livekit-server.example",
}),
}),
) as any;
});
afterEach(() => {
(window as any).open = originalOpen
(window as any).open = originalOpen;
// @ts-ignore
globalThis.fetch = undefined
})
globalThis.fetch = undefined;
});
it('opens popup, sends token, studio receives it and StudioPortal connects (mock Room)', async () => {
it("opens popup, sends token, studio receives it and StudioPortal connects (mock Room)", async () => {
// render both launcher and app
render(
<div>
<LauncherButton />
<TestApp />
</div>
)
</div>,
);
// click launcher which calls openStudio -> will call fetch and popup.postMessage repeatedly
const btn = screen.getByTestId('open-studio')
fireEvent.click(btn)
const btn = screen.getByTestId("open-studio");
fireEvent.click(btn);
// Wait for TestApp to receive token and render StudioPortal status
await waitFor(() => expect(screen.getByTestId('status').textContent).toContain('SIM_TOKEN'), { timeout: 2000 })
await waitFor(
() =>
expect(screen.getByTestId("status").textContent).toContain("SIM_TOKEN"),
{ timeout: 2000 },
);
// Now assert that the mocked livekit Room was instantiated and connect called.
// The project-level vitest setup provides a mock for 'livekit-client' with __mocks.instances
const livekitMock: any = require('livekit-client')
await waitFor(() => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0), { timeout: 2000 })
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://livekit-server.example', 'SIM_TOKEN')
})
})
const livekitMock: any = require("livekit-client");
await waitFor(
() => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0),
{ timeout: 2000 },
);
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith(
"wss://livekit-server.example",
"SIM_TOKEN",
);
});
});

View File

@ -1,11 +1,14 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
// load the postMessage util from broadcast-panel
import * as pm from '../../../utils/postMessage';
import * as pm from "../../../utils/postMessage";
describe('postMessage utils', () => {
it('exposes environment parsing helpers', () => {
describe("postMessage utils", () => {
it("exposes environment parsing helpers", () => {
// basic existence
expect(typeof pm.getAllowedOriginsFromEnv === 'function' || typeof pm.isAllowedOrigin === 'function').toBeTruthy();
})
})
expect(
typeof pm.getAllowedOriginsFromEnv === "function" ||
typeof pm.isAllowedOrigin === "function",
).toBeTruthy();
});
});

View File

@ -1,32 +1,40 @@
// @vitest-environment jsdom
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
// Stub Studio implementation to a minimal status element to avoid heavy dependencies
vi.mock('../../../components/Studio', () => ({
vi.mock("../../../components/Studio", () => ({
__esModule: true,
default: () => React.createElement('div', { id: 'status' }, 'Conectado')
}))
default: () => React.createElement("div", { id: "status" }, "Conectado"),
}));
import Studio from '../../../components/Studio'
import Studio from "../../../components/Studio";
describe('smoke test - Broadcast Studio integration', () => {
describe("smoke test - Broadcast Studio integration", () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
vi.clearAllMocks();
document.body.innerHTML = "";
});
it('renders Studio and auto connects via proxy token', async () => {
it("renders Studio and auto connects via proxy token", async () => {
// Mock fetch to return token
(globalThis as any).fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ token: 'SMOKE', url: 'wss://example' }) }))
(globalThis as any).fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ token: "SMOKE", url: "wss://example" }),
}),
);
render(<Studio />)
render(<Studio />);
await waitFor(() => {
const el = document.getElementById('status')
expect(el).toBeTruthy()
}, { timeout: 3000 })
})
})
await waitFor(
() => {
const el = document.getElementById("status");
expect(el).toBeTruthy();
},
{ timeout: 3000 },
);
});
});

View File

@ -1,11 +1,22 @@
import React from 'react'
import React from "react";
export default function IconCameraOn(){
export default function IconCameraOn() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 7H5L7 4H17L19 7H21C22.1046 7 23 7.89543 23 9V17C23 18.1046 22.1046 19 21 19H3C1.89543 19 1 18.1046 1 17V9C1 7.89543 1.89543 7 3 7Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="12" cy="12" r="3" fill="currentColor"/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 7H5L7 4H17L19 7H21C22.1046 7 23 7.89543 23 9V17C23 18.1046 22.1046 19 21 19H3C1.89543 19 1 18.1046 1 17V9C1 7.89543 1.89543 7 3 7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" fill="currentColor" />
</svg>
)
);
}

View File

@ -1,12 +1,35 @@
import React from 'react'
import React from "react";
export default function IconMicOff(){
export default function IconMicOff() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 11V7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 11C19 14.3137 16.3137 17 13 17H11C7.68629 17 5 14.3137 5 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 1V11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 11V7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7V11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 11C19 14.3137 16.3137 17 13 17H11C7.68629 17 5 14.3137 5 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
);
}

View File

@ -1,3 +1,2 @@
export { default as StudioPortal } from './StudioPortal';
export { default as StudioRoom } from './StudioRoom';
export { default as StudioPortal } from "./StudioPortal";
export { default as StudioRoom } from "./StudioRoom";

View File

@ -0,0 +1,6 @@
// Barrel exports for hooks
export * from "./useStudioSession";
export { default as useStudioLauncher } from "./useStudioLauncher";
export { default as useStudioMessageListener } from "./useStudioMessageListener";
export { useToast, ToastProvider } from "./useToast";
// Note: `useLayouts.ts` currently has no exports; add it here once it exports symbols

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,48 @@
import { useEffect } from 'react'
import { useEffect } from "react";
export type LivekitMessage = {
type: 'LIVEKIT_TOKEN'
token?: string
room?: string
url?: string
}
type: "LIVEKIT_TOKEN";
token?: string;
room?: string;
url?: string;
};
export default function useStudioMessageListener(onReceive: (msg: LivekitMessage) => void) {
export default function useStudioMessageListener(
onReceive: (msg: LivekitMessage) => void,
) {
useEffect(() => {
// Build allowed origins list from env or default to current origin
const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS || import.meta.env.VITE_BROADCASTPANEL_URL || '') as string
const allowedOrigins = envAllowed ? envAllowed.split(',').map(s => s.trim()) : [window.location.origin]
const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS ||
import.meta.env.VITE_BROADCASTPANEL_URL ||
"") as string;
const allowedOrigins = envAllowed
? envAllowed.split(",").map((s) => s.trim())
: [window.location.origin];
function handler(e: MessageEvent) {
try {
// Validate origin: allow same-window messages (origin may equal window.location.origin)
const origin = e.origin || window.location.origin
if (!allowedOrigins.includes(origin) && origin !== window.location.origin) return
const origin = e.origin || window.location.origin;
if (
!allowedOrigins.includes(origin) &&
origin !== window.location.origin
)
return;
const data = e.data || {}
if (data && data.type === 'LIVEKIT_TOKEN') {
onReceive({ type: 'LIVEKIT_TOKEN', token: data.token, room: data.room, url: data.url })
const data = e.data || {};
if (data && data.type === "LIVEKIT_TOKEN") {
onReceive({
type: "LIVEKIT_TOKEN",
token: data.token,
room: data.room,
url: data.url,
});
}
} catch (err) {
// ignore malformed messages
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [onReceive])
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [onReceive]);
}

View File

@ -2,7 +2,7 @@
// - GET /api/session/:id/token
// - Returns { token, url, room, username } or error
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
export type StudioSession = {
token: string;
@ -24,17 +24,28 @@ export function useStudioSession(sessionId?: string | null) {
setLoading(true);
setError(null);
try {
const base = import.meta.env.VITE_BACKEND_TOKENS_URL || import.meta.env.VITE_BROADCASTPANEL_URL || '';
const base =
import.meta.env.VITE_BACKEND_TOKENS_URL ||
import.meta.env.VITE_BROADCASTPANEL_URL ||
"";
// Prefer absolute backend URL env var; else assume same origin + port 4000
const backend = import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`;
const url = `${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/token`;
const backend =
import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`;
const url = `${backend.replace(/\/$/, "")}/api/session/${encodeURIComponent(sessionId)}/token`;
// Helpful debug information for E2E and development
console.debug('[useStudioSession] fetching token from', url, { sessionId, backend, envBase: base });
console.debug("[useStudioSession] fetching token from", url, {
sessionId,
backend,
envBase: base,
});
const resp = await fetch(url, { method: 'GET', credentials: 'include' });
const resp = await fetch(url, {
method: "GET",
credentials: "include",
});
if (!resp.ok) {
const txt = await resp.text().catch(() => '(<no body>)');
const txt = await resp.text().catch(() => "(<no body>)");
const msg = `fetch failed status=${resp.status} body=${txt}`;
throw new Error(msg);
}
@ -45,10 +56,17 @@ export function useStudioSession(sessionId?: string | null) {
// Enriquecer el mensaje para ayudar a diagnosticar 'Failed to fetch'
let enriched = String(err?.message || err);
// If it's a network error (TypeError: Failed to fetch) give common causes hints
if (enriched.includes('Failed to fetch') || enriched.includes('NetworkError') || enriched.includes('TypeError')) {
if (
enriched.includes("Failed to fetch") ||
enriched.includes("NetworkError") ||
enriched.includes("TypeError")
) {
enriched = `${enriched} — posible causa: backend no accesible, CORS bloqueando la petición, mixed-content (https página -> http backend), o error de red. Comprueba que VITE_BACKEND_TOKENS_URL apunta al backend correcto y que el backend está levantado en esa URL.`;
}
console.error('[useStudioSession] error fetching token', { sessionId, error: enriched });
console.error("[useStudioSession] error fetching token", {
sessionId,
error: enriched,
});
setError(enriched);
}
} finally {
@ -56,7 +74,9 @@ export function useStudioSession(sessionId?: string | null) {
}
}
fetchToken();
return () => { aborted = true };
return () => {
aborted = true;
};
}, [sessionId]);
return { loading, data, error };

View File

@ -1,44 +1,63 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import Toast from '../components/Toast'
import React, { createContext, useContext, useState, useEffect } from "react";
import Toast from "../components/Toast";
type ToastItem = { id: string, message: string, variant?: 'info'|'success'|'error'|'warning' }
type ToastItem = {
id: string;
message: string;
variant?: "info" | "success" | "error" | "warning";
};
const ToastContext = createContext<{ show: (m: string, v?: ToastItem['variant']) => void } | undefined>(undefined)
const ToastContext = createContext<
{ show: (m: string, v?: ToastItem["variant"]) => void } | undefined
>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [list, setList] = useState<ToastItem[]>([])
const [list, setList] = useState<ToastItem[]>([]);
function show(message: string, variant: ToastItem['variant'] = 'info') {
const id = Math.random().toString(36).slice(2,9)
setList(l => [...l, { id, message, variant }])
setTimeout(() => setList(l => l.filter(x => x.id !== id)), 5000)
function show(message: string, variant: ToastItem["variant"] = "info") {
const id = Math.random().toString(36).slice(2, 9);
setList((l) => [...l, { id, message, variant }]);
setTimeout(() => setList((l) => l.filter((x) => x.id !== id)), 5000);
}
useEffect(() => {
function onGlobal(e: Event) {
try {
const ce = e as CustomEvent
if (!ce?.detail) return
const { message, variant } = ce.detail as any
if (message) show(message, variant)
const ce = e as CustomEvent;
if (!ce?.detail) return;
const { message, variant } = ce.detail as any;
if (message) show(message, variant);
} catch (err) {}
}
window.addEventListener('AVZ_TOAST', onGlobal as EventListener)
return () => window.removeEventListener('AVZ_TOAST', onGlobal as EventListener)
}, [])
window.addEventListener("AVZ_TOAST", onGlobal as EventListener);
return () =>
window.removeEventListener("AVZ_TOAST", onGlobal as EventListener);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
<div style={{ position: 'fixed', right: 20, top: 20, zIndex: 9999, display: 'flex', flexDirection: 'column', gap: 10 }}>
{list.map(i => <Toast key={i.id} message={i.message} variant={i.variant} />)}
<div
style={{
position: "fixed",
right: 20,
top: 20,
zIndex: 9999,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{list.map((i) => (
<Toast key={i.id} message={i.message} variant={i.variant} />
))}
</div>
</ToastContext.Provider>
)
);
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within ToastProvider')
return ctx
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}

View File

@ -1,4 +1,4 @@
export { default as PageContainer } from './components/PageContainer'
export { default as Sidebar } from './components/Sidebar'
export { default as Header } from './components/Header'
export { default as TransmissionsTable } from './components/TransmissionsTable'
export { default as PageContainer } from "./components/PageContainer";
export { default as Sidebar } from "./components/Sidebar";
export { default as Header } from "./components/Header";
export { default as TransmissionsTable } from "./components/TransmissionsTable";

View File

@ -0,0 +1,17 @@
export function encodePassphrase(passphrase: string) {
return encodeURIComponent(passphrase);
}
export function decodePassphrase(base64String: string) {
return decodeURIComponent(base64String);
}
export function randomString(length: number): string {
let result = "";
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

File diff suppressed because it is too large Load Diff

View File

@ -42,12 +42,22 @@
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--background-color);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
button {

View File

@ -1,2 +1 @@
export type { Transmission } from '@shared/types'
export type { Transmission } from "@shared/types";

View File

@ -2,21 +2,33 @@
export function getAllowedOriginsFromEnv(): string[] {
const allowed = new Set<string>();
try {
const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || '';
const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || "";
if (raw) {
raw.split(',').map(s => s.trim()).filter(Boolean).forEach(o => allowed.add(o));
raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.forEach((o) => allowed.add(o));
}
} catch (e) { /* ignore */ }
} catch (e) {
/* ignore */
}
try {
const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || '';
const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || "";
if (studioUrl) {
try {
const u = new URL(studioUrl);
allowed.add(u.origin);
} catch (e) { /* ignore */ }
} catch (e) {
/* ignore */
}
}
} catch (e) { /* ignore */ }
try { allowed.add(window.location.origin); } catch (e) {}
} catch (e) {
/* ignore */
}
try {
allowed.add(window.location.origin);
} catch (e) {}
return Array.from(allowed);
}
@ -26,7 +38,11 @@ export function isAllowedOrigin(origin: string | null | undefined): boolean {
return list.includes(origin);
}
export function safePostMessage(target: Window | null | undefined, message: any, targetOrigin: string) {
export function safePostMessage(
target: Window | null | undefined,
message: any,
targetOrigin: string,
) {
if (!target) return false;
try {
target.postMessage(message, targetOrigin);
@ -36,4 +52,3 @@ export function safePostMessage(target: Window | null | undefined, message: any,
return false;
}
}

View File

@ -1,100 +1,172 @@
export interface StudioTokenPayload {
token: string
serverUrl?: string
room?: string
user?: string
token: string;
serverUrl?: string;
room?: string;
user?: string;
}
export async function openStudioWithToken(tokenData: StudioTokenPayload, opts?: { studioUrl?: string, shortIdLength?: number, onAck?: (ack: any) => void, forceRedirect?: boolean }) {
const STUDIO_URL = opts?.studioUrl || (import.meta.env.VITE_STUDIO_URL as string) || 'https://avanzacast-studio.bfzqqk.easypanel.host'
const shortId = Math.random().toString(36).slice(2, 2 + (opts?.shortIdLength || 8))
const studioBase = STUDIO_URL.replace(/\/$/, '')
const studioPath = `${studioBase}/${shortId}`
export async function openStudioWithToken(
tokenData: StudioTokenPayload,
opts?: {
studioUrl?: string;
shortIdLength?: number;
onAck?: (ack: any) => void;
forceRedirect?: boolean;
},
) {
const STUDIO_URL =
opts?.studioUrl ||
(import.meta.env.VITE_STUDIO_URL as string) ||
"https://avanzacast-studio.bfzqqk.easypanel.host";
const shortId = Math.random()
.toString(36)
.slice(2, 2 + (opts?.shortIdLength || 8));
const studioBase = STUDIO_URL.replace(/\/$/, "");
const studioPath = `${studioBase}/${shortId}`;
const payload = {
type: 'LIVEKIT_TOKEN',
type: "LIVEKIT_TOKEN",
token: tokenData.token,
url: tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || '',
room: tokenData.room || '',
user: tokenData.user || '',
}
url:
tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || "",
room: tokenData.room || "",
user: tokenData.user || "",
};
const paramsForFallback = () => {
const p = new URLSearchParams({ token: tokenData.token || '', room: tokenData.room || '', username: tokenData.user || '' })
if (tokenData.serverUrl) p.set('serverUrl', tokenData.serverUrl)
return p.toString()
}
const p = new URLSearchParams({
token: tokenData.token || "",
room: tokenData.room || "",
username: tokenData.user || "",
});
if (tokenData.serverUrl) p.set("serverUrl", tokenData.serverUrl);
return p.toString();
};
const forceRedirectEnv = (import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || ''
const forceRedirect = opts?.forceRedirect !== undefined ? opts.forceRedirect : (forceRedirectEnv === '0' ? false : true)
const forceRedirectEnv =
(import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || "";
const forceRedirect =
opts?.forceRedirect !== undefined
? opts.forceRedirect
: forceRedirectEnv === "0"
? false
: true;
try {
if (forceRedirect) {
const q = paramsForFallback()
window.location.href = `${studioPath}?${q}`
return
const q = paramsForFallback();
window.location.href = `${studioPath}?${q}`;
return;
}
const originAllowed = (() => {
try { return new URL(STUDIO_URL).origin } catch { return '*' }
})()
try {
return new URL(STUDIO_URL).origin;
} catch {
return "*";
}
})();
try {
const win = window.open(studioPath, '_blank')
const win = window.open(studioPath, "_blank");
if (win) {
let ackTimeout: number | null = null
let ackTimeout: number | null = null;
const ackListener = (e: MessageEvent) => {
try {
if (!e?.data) return
const d = e.data
if (d?.type === 'LIVEKIT_ACK') {
try { opts?.onAck?.(d) } catch (err) { console.error('onAck callback error', err) }
if (!opts?.onAck) {
window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: d.status === 'connected' ? 'Studio conectado' : `Studio error: ${d?.error || d?.status}`, variant: d.status === 'connected' ? 'success' : 'error' } }))
if (!e?.data) return;
const d = e.data;
if (d?.type === "LIVEKIT_ACK") {
try {
opts?.onAck?.(d);
} catch (err) {
console.error("onAck callback error", err);
}
if (ackTimeout) { clearTimeout(ackTimeout); ackTimeout = null }
window.removeEventListener('message', ackListener as unknown as EventListener)
if (!opts?.onAck) {
window.dispatchEvent(
new CustomEvent("AVZ_TOAST", {
detail: {
message:
d.status === "connected"
? "Studio conectado"
: `Studio error: ${d?.error || d?.status}`,
variant: d.status === "connected" ? "success" : "error",
},
}),
);
}
if (ackTimeout) {
clearTimeout(ackTimeout);
ackTimeout = null;
}
window.removeEventListener(
"message",
ackListener as unknown as EventListener,
);
}
} catch (err) { console.error('[studioLauncher] ackListener error', err) }
}
} catch (err) {
console.error("[studioLauncher] ackListener error", err);
}
};
window.addEventListener('message', ackListener as unknown as EventListener)
window.addEventListener(
"message",
ackListener as unknown as EventListener,
);
ackTimeout = window.setTimeout(() => {
try {
window.removeEventListener('message', ackListener as unknown as EventListener)
window.removeEventListener(
"message",
ackListener as unknown as EventListener,
);
if (!opts?.onAck) {
window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: 'No se recibió confirmación del Studio', variant: 'warning' } }))
window.dispatchEvent(
new CustomEvent("AVZ_TOAST", {
detail: {
message: "No se recibió confirmación del Studio",
variant: "warning",
},
}),
);
}
} catch (err) { console.error('[studioLauncher] ack timeout cleanup error', err) }
}, 20000)
} catch (err) {
console.error("[studioLauncher] ack timeout cleanup error", err);
}
}, 20000);
const post = () => {
try {
win.postMessage(payload, originAllowed)
console.debug('[studioLauncher] postMessage ->', originAllowed)
win.postMessage(payload, originAllowed);
console.debug("[studioLauncher] postMessage ->", originAllowed);
} catch (err) {
try { (win as any).postMessage(payload, '*') } catch (err2) { console.error('[studioLauncher] postMessage failed', err2) }
try {
(win as any).postMessage(payload, "*");
} catch (err2) {
console.error("[studioLauncher] postMessage failed", err2);
}
}
}
};
setTimeout(post, 300)
setTimeout(post, 800)
setTimeout(post, 1500)
return
setTimeout(post, 300);
setTimeout(post, 800);
setTimeout(post, 1500);
return;
}
// popup blocked -> fallback to redirect
const q = paramsForFallback()
window.location.href = `${studioPath}?${q}`
const q = paramsForFallback();
window.location.href = `${studioPath}?${q}`;
} catch (err) {
console.error('[studioLauncher] Error opening studio panel, fallback to redirect', err)
const q = paramsForFallback()
window.location.href = `${studioPath}?${q}`
console.error(
"[studioLauncher] Error opening studio panel, fallback to redirect",
err,
);
const q = paramsForFallback();
window.location.href = `${studioPath}?${q}`;
}
} catch (err) {
console.error('[studioLauncher] Unexpected error', err)
console.error("[studioLauncher] Unexpected error", err);
}
}
export default openStudioWithToken
export default openStudioWithToken;

View File

@ -1,4 +1,4 @@
declare module '*.module.css' {
const classes: { [key: string]: string }
export default classes
declare module "*.module.css" {
const classes: { [key: string]: string };
export default classes;
}

189
scripts/restart-livekit-server.sh Executable file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env zsh
# ...existing code...
# scripts/restart-livekit-server.sh
# Reinicia/arranca livekit-server con la clave API/SECRET adecuada, redirige logs y realiza checks básicos.
# Uso típico:
# ./scripts/restart-livekit-server.sh --api-key devkey --api-secret secret --method dev --logs /tmp/livekit.log
set -euo pipefail
API_KEY="${LIVEKIT_API_KEY:-devkey}"
API_SECRET="${LIVEKIT_API_SECRET:-secret}"
METHOD="auto" # auto | systemd | docker | dev
SERVICE_NAME="livekit-server" # nombre del servicio para systemd / docker-compose
COMPOSE_PATH="./docker-compose.yml"
BINARY="${LIVEKIT_BINARY:-livekit-server}"
LOGS="${LOGS_FILE:-livekit-server.log}"
HTTP_URL="${LIVEKIT_HTTP_URL:-http://localhost:7880}"
WS_URL="${LIVEKIT_WS_URL:-ws://localhost:7880}"
SKIP_TESTS=false
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--api-key KEY LiveKit API key (default: $API_KEY)
--api-secret SECRET LiveKit API secret (default: $API_SECRET)
--method METHOD Restart method: auto|systemd|docker|dev (default: auto)
--service-name NAME Systemd / compose service name (default: $SERVICE_NAME)
--compose-path PATH docker-compose.yml path (default: $COMPOSE_PATH)
--binary PATH livekit-server binary (default: $BINARY)
--logs PATH File to append logs (default: $LOGS)
--http-url URL Base HTTP URL for health/validate checks (default: $HTTP_URL)
--ws-url URL WebSocket URL for handshake test (default: $WS_URL)
--skip-tests Skip curl/wscat tests
-h, --help Show this help
EOF
}
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) API_KEY="$2"; shift 2;;
--api-secret) API_SECRET="$2"; shift 2;;
--method) METHOD="$2"; shift 2;;
--service-name) SERVICE_NAME="$2"; shift 2;;
--compose-path) COMPOSE_PATH="$2"; shift 2;;
--binary) BINARY="$2"; shift 2;;
--logs) LOGS="$2"; shift 2;;
--http-url) HTTP_URL="$2"; shift 2;;
--ws-url) WS_URL="$2"; shift 2;;
--skip-tests) SKIP_TESTS=true; shift 1;;
-h|--help) usage; exit 0;;
*) echo "Unknown option: $1"; usage; exit 1;;
esac
done
echo "[restart-livekit] método=$METHOD service=$SERVICE_NAME binary=$BINARY logs=$LOGS"
# Helpers
has_systemd_service() {
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl list-unit-files --type=service --no-legend | grep -q "$SERVICE_NAME" || return 1
return 0
fi
return 1
}
restart_systemd() {
echo "[restart-livekit] intentando reiniciar servicio systemd: $SERVICE_NAME"
if ! sudo systemctl restart "$SERVICE_NAME"; then
echo "[restart-livekit] fallo al reiniciar systemd service $SERVICE_NAME" >&2
return 1
fi
echo "[restart-livekit] systemd restart pedido correctamente. Esperando 2s para estabilizar..."
sleep 2
return 0
}
restart_docker_compose() {
if [ -f "$COMPOSE_PATH" ]; then
echo "[restart-livekit] intentando restart con docker-compose ($COMPOSE_PATH)"
if command -v docker-compose >/dev/null 2>&1; then
docker-compose -f "$COMPOSE_PATH" restart "$SERVICE_NAME" || docker-compose -f "$COMPOSE_PATH" up -d "$SERVICE_NAME"
sleep 2
return 0
elif command -v docker >/dev/null 2>&1 && command -v docker-compose >/dev/null 2>&1; then
docker-compose -f "$COMPOSE_PATH" restart "$SERVICE_NAME" || docker-compose -f "$COMPOSE_PATH" up -d "$SERVICE_NAME"
sleep 2
return 0
else
echo "[restart-livekit] docker-compose no encontrado" >&2
return 1
fi
fi
return 1
}
start_dev_process() {
echo "[restart-livekit] arrancando '$BINARY --dev' con LIVEKIT_KEYS='${API_KEY}:${API_SECRET}'"
# Matar procesos previos (intento seguro)
if pgrep -f "$BINARY" >/dev/null 2>&1; then
echo "[restart-livekit] matando procesos previos de $BINARY"
pkill -f "$BINARY" || true
sleep 1
fi
mkdir -p "$(dirname "$LOGS")" || true
echo "[restart-livekit] lanzando en background; logs -> $LOGS"
nohup env LIVEKIT_KEYS="${API_KEY}:${API_SECRET}" "$BINARY" --dev > "$LOGS" 2>&1 &
sleep 2
}
# Decide el método
if [ "$METHOD" = "auto" ]; then
if has_systemd_service; then
CHOSEN=systemd
elif [ -f "$COMPOSE_PATH" ]; then
CHOSEN=docker
else
CHOSEN=dev
fi
else
CHOSEN="$METHOD"
fi
echo "[restart-livekit] método elegido: $CHOSEN"
case "$CHOSEN" in
systemd)
if ! restart_systemd; then
echo "[restart-livekit] systemd falló, intentando método dev como fallback"
start_dev_process
fi
;;
docker)
if ! restart_docker_compose; then
echo "[restart-livekit] docker-compose falló, intentando método dev como fallback"
start_dev_process
fi
;;
dev)
start_dev_process
;;
*)
echo "Método desconocido: $CHOSEN"; exit 2
;;
esac
# Wait breve y realizar checks
if [ "$SKIP_TESTS" = true ]; then
echo "[restart-livekit] skip tests activado, terminado. Revisa logs: $LOGS"
exit 0
fi
echo "[restart-livekit] comprobaciones básicas: curl y wscat (si está instalado)"
# 1) curl a /rtc/validate (si el usuario lo desea, la URL es configurable)
if command -v curl >/dev/null 2>&1; then
echo "\n--- curl ${HTTP_URL}/rtc/validate (timeout 5s) ---"
set +e
HTTP_OUT=$(curl -sS --max-time 5 -w "HTTP_STATUS:%{http_code}" "${HTTP_URL}/rtc/validate" 2>&1)
CURL_STATUS=$?
set -e
if [ $CURL_STATUS -ne 0 ]; then
echo "[restart-livekit] curl fallo (exit $CURL_STATUS). Salida:\n$HTTP_OUT"
else
echo "$HTTP_OUT" | sed -n '1,200p'
fi
else
echo "[restart-livekit] curl no está instalado; omitiendo check HTTP"
fi
# 2) intento rápido con wscat para ver handshake (no envía token)
if command -v wscat >/dev/null 2>&1; then
echo "\n--- wscat -c ${WS_URL} (intento corto 4s) ---"
# usar timeout para no colgar
if command -v timeout >/dev/null 2>&1; then
timeout 4s wscat -c "${WS_URL}" || true
else
echo "(nota: no se encontró 'timeout', wscat puede esperar indefinidamente)"
wscat -c "${WS_URL}" || true
fi
else
echo "[restart-livekit] wscat no instalado; para una prueba WebSocket instálalo con 'npm i -g wscat' o utilice websocat"
fi
echo "[restart-livekit] FIN. Revisa $LOGS para logs completos (tail -n 200 $LOGS)"
# ...existing code...

171
scripts/validate-livekit-token.sh Executable file
View File

@ -0,0 +1,171 @@
#!/usr/bin/env zsh
# scripts/validate-livekit-token.sh
# Reinicia/arranca livekit-server con la clave API/SECRET adecuada, redirige logs y realiza checks básicos.
# Uso:
# ./scripts/validate-livekit-token.sh --api-key KEY --api-secret SECRET --server https://example.com --room test_room --identity test_user
set -euo pipefail
API_KEY="${LIVEKIT_API_KEY:-}"
API_SECRET="${LIVEKIT_API_SECRET:-}"
SERVER_URL="${LIVEKIT_SERVER_URL:-https://livekit-server.bfzqqk.easypanel.host}"
ROOM="test_room"
IDENTITY="test_user"
usage(){
cat <<EOF
Usage: $0 [options]
Options:
--api-key KEY LiveKit API key (or set LIVEKIT_API_KEY env)
--api-secret SECRET LiveKit API secret (or set LIVEKIT_API_SECRET env)
--server URL Base server URL (default: $SERVER_URL)
--room NAME Room name (default: test_room)
--identity ID Participant identity (default: test_user)
-h, --help Show this help
This script requires node and livekit-server-sdk installed (npm i livekit-server-sdk).
It will: generate a token, call $SERVER_URL/rtc/validate with Authorization: Bearer <token>,
try a WebSocket handshake to wss://<host>/rtc?access_token=<token> (if wscat/websocat available),
and print decoded JWT payload.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) API_KEY="$2"; shift 2;;
--api-secret) API_SECRET="$2"; shift 2;;
--server) SERVER_URL="$2"; shift 2;;
--room) ROOM="$2"; shift 2;;
--identity) IDENTITY="$2"; shift 2;;
-h|--help) usage; exit 0;;
*) echo "Unknown arg: $1"; usage; exit 1;;
esac
done
if [ -z "$API_KEY" ] || [ -z "$API_SECRET" ]; then
echo "ERROR: API_KEY and API_SECRET must be set (via args or env)." >&2
usage
exit 2
fi
echo "[validate-livekit-token] api_key=${API_KEY} server=${SERVER_URL} room=${ROOM} identity=${IDENTITY}"
# Temp files for outputs
OUT_DIR="/tmp/validate-livekit"
mkdir -p "$OUT_DIR"
TOKEN_FILE="$OUT_DIR/token.txt"
CURL_FILE="$OUT_DIR/curl_strict.txt"
CURLK_FILE="$OUT_DIR/curl_insecure.txt"
WS_FILE="$OUT_DIR/ws_attempt.txt"
PAYLOAD_FILE="$OUT_DIR/payload.json"
# Generar token via Node inline
# Pass credentials and params via environment to avoid issues with heredoc args
TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" IDENTITY="$IDENTITY" ROOM="$ROOM" node - <<'NODE'
try{
const sdk = require('livekit-server-sdk');
const AccessToken = sdk.AccessToken || (sdk.default && sdk.default.AccessToken);
const VideoGrant = sdk.VideoGrant || (sdk.default && sdk.default.VideoGrant);
const identity = process.env.IDENTITY || 'anon';
const room = process.env.ROOM || 'default';
const apiKey = process.env.API_KEY;
const apiSecret = process.env.API_SECRET;
if (!apiKey || !apiSecret) {
console.error('missing API_KEY/API_SECRET in env');
process.exit(2);
}
const at = new AccessToken(apiKey, apiSecret, { identity: identity, name: identity });
if (VideoGrant) at.addGrant(new VideoGrant({ room: room }));
else if (typeof at.addGrant === 'function') at.addGrant({ room: room, roomJoin: true, canPublish: true, canSubscribe: true });
(async ()=>{ const t = (typeof at.toJwt === 'function') ? await at.toJwt() : at.jwt; console.log(t); })();
}catch(e){ console.error('ERRGEN', e && e.stack ? e.stack : e); process.exit(2); }
NODE
) || { echo "[error] token generation failed" >&2; exit 3; }
if [ -z "$TOKEN" ]; then
echo "[error] empty token" >&2; exit 4
fi
# Save token
echo "$TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
echo; echo "=== Token generated and saved to: $TOKEN_FILE ==="
echo "Token preview: ${TOKEN:0:140}..."
VALIDATE_URL="$SERVER_URL/rtc/validate"
echo; echo "=== CURL validate (strict TLS) -> $CURL_FILE ==="
# store headers and body
set +e
curl -i -sS --max-time 15 -H "Authorization: Bearer $TOKEN" "$VALIDATE_URL" > "$CURL_FILE" 2>&1
CURL_EXIT=$?
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "[warn] curl strict failed (exit $CURL_EXIT). See $CURL_FILE"
else
echo "[ok] curl strict completed. See $CURL_FILE"
fi
# insecure curl (diagnostic)
echo; echo "=== CURL validate (insecure -k) -> $CURLK_FILE ==="
set +e
curl -i -sS -k --max-time 15 -H "Authorization: Bearer $TOKEN" "$VALIDATE_URL" > "$CURLK_FILE" 2>&1
CURLK_EXIT=$?
set -e
if [ $CURLK_EXIT -ne 0 ]; then
echo "[warn] curl -k failed (exit $CURLK_EXIT). See $CURLK_FILE"
else
echo "[ok] curl -k completed. See $CURLK_FILE"
fi
# 2) WebSocket handshake attempt
WS_HOST=$(echo "$SERVER_URL" | sed -E 's#https?://##' | sed -E 's#/$##')
WS_URL="wss://$WS_HOST/rtc?access_token=$TOKEN"
echo; echo "=== WS handshake attempt to: $WS_URL (output -> $WS_FILE) ==="
if command -v wscat >/dev/null 2>&1; then
if command -v timeout >/dev/null 2>&1; then
timeout 10s wscat -c "$WS_URL" > "$WS_FILE" 2>&1 || true
else
wscat -c "$WS_URL" > "$WS_FILE" 2>&1 || true
fi
echo "[info] wscat output saved to $WS_FILE"
elif command -v websocat >/dev/null 2>&1; then
if command -v timeout >/dev/null 2>&1; then
timeout 10s websocat "$WS_URL" > "$WS_FILE" 2>&1 || true
else
websocat "$WS_URL" > "$WS_FILE" 2>&1 || true
fi
echo "[info] websocat output saved to $WS_FILE"
else
echo "[info] wscat/websocat not installed; skipping WS handshake test"
fi
# 3) decode payload (no signature verification)
echo "$TOKEN" | awk '{print $0}' > /dev/null
node -e 'try{const t=process.argv[1]; const p=t.split(".")[1]; const b=Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString(); console.log(b);}catch(e){console.error("failed decode",e);} ' "$TOKEN" > "$PAYLOAD_FILE" 2>&1 || true
echo; echo "=== DECODED PAYLOAD SAVED TO: $PAYLOAD_FILE ==="
# Print short summary and file locations for user to copy
cat <<EOF
SUMMARY (files saved):
Token: $TOKEN_FILE
Curl (strict TLS): $CURL_FILE
Curl (insecure -k): $CURLK_FILE
WS attempt: $WS_FILE
Decoded payload: $PAYLOAD_FILE
Commands to view outputs:
cat $TOKEN_FILE
sed -n '1,200p' $CURL_FILE
sed -n '1,200p' $CURLK_FILE
sed -n '1,200p' $WS_FILE
sed -n '1,200p' $PAYLOAD_FILE
Paste the contents of those files here and I will interpret the results and guide next steps.
EOF
echo; echo "=== DONE ==="

View File

@ -0,0 +1,34 @@
Archivos generados por la validación de LiveKit
Contenido:
- token.txt: token JWT completo (línea única)
- payload.json: payload (claims) decodificado del token
- curl_strict.txt: resultado del curl estricto a /rtc/validate (HTTP 200 + body: success)
Comandos listos para usar (zsh)
# exportar token
export TOKEN='eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdF91c2VyIiwidmlkZW8iOnsicm9vbSI6InRlc3Rfcm9vbSIsInJvb21Kb2luIjp0cnVlLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWV9LCJpc3MiOiJBUElCVHFUR3hmOWh0TUsiLCJleHAiOjE3NjM3OTg3MjgsIm5iZiI6MCwic3ViIjoidGVzdF91c2VyIn0.gyu2VR1jAFlbrbJIpsAeocrUSHTWWszED7KfM3lAwZU'
# validar con curl (estricto TLS)
curl -i -v -H "Authorization: Bearer $TOKEN" "https://livekit-server.bfzqqk.easypanel.host/rtc/validate"
# validar (ignorar verificación TLS) - solo para diagnóstico
echo "(no recomendado en producción)"
curl -i -v -k -H "Authorization: Bearer $TOKEN" "https://livekit-server.bfzqqk.easypanel.host/rtc/validate"
# probar WebSocket con wscat (instalar: npm i -g wscat)
# timeout 6s wscat -c "wss://livekit-server.bfzqqk.easypanel.host/rtc?access_token=$TOKEN" || true
# ejemplo cliente JS (browser) - usar livekit-client
# import { connect } from 'livekit-client';
# const serverUrl = 'wss://livekit-server.bfzqqk.easypanel.host';
# const token = '...el token completo...';
# (async () => { const room = await connect(serverUrl, token); console.log('Conectado a room:', room.name); })();
Recomendaciones de seguridad:
- No subir tokens ni secretos a repositorios públicos.
- Generar tokens en backend y entregar tokens cortos al cliente.
- Usar TTL corto en producción (10-30 min) y permisos mínimos por rol.
- Guardar LIVEKIT_API_SECRET en un gestor de secretos.

View File

@ -0,0 +1,4 @@
HTTP/2 200
body: success

View File

@ -0,0 +1,2 @@
{ "name": "test_user", "video": { "room": "test_room", "roomJoin": true, "canPublish": true, "canSubscribe": true }, "iss": "APIBTqTGxf9htMK", "exp": 1763798728, "nbf": 0, "sub": "test_user" }

View File

@ -0,0 +1,2 @@
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdF91c2VyIiwidmlkZW8iOnsicm9vbSI6InRlc3Rfcm9vbSIsInJvb21Kb2luIjp0cnVlLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWV9LCJpc3MiOiJBUElCVHFUR3hmOWh0TUsiLCJleHAiOjE3NjM3OTg3MjgsIm5iZiI6MCwic3ViIjoidGVzdF91c2VyIn0.gyu2VR1jAFlbrbJIpsAeocrUSHTWWszED7KfM3lAwZU

52
tools/request_session.sh Normal file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# tools/request_session.sh
# Uso: ./tools/request_session.sh <room> [username]
# Hace POST a BACKEND_URL/api/session (usando env BACKEND_URL o VITE_BACKEND_TOKENS_URL)
# e imprime JSON prettified y un snippet para inyectar token en la consola del navegador.
set -euo pipefail
ROOM=${1:-d13oie2}
USERNAME=${2:-visual-runner}
BACKEND=${BACKEND_URL:-${VITE_BACKEND_TOKENS_URL:-https://avanzacast-servertokens.bfzqqk.easypanel.host}}
if [ -z "$BACKEND" ]; then
echo "ERROR: BACKEND_URL or VITE_BACKEND_TOKENS_URL must be set in env" >&2
exit 1
fi
echo "POSTing session to $BACKEND/api/session (room=$ROOM username=$USERNAME)"
RESP=$(curl -sS -X POST "$BACKEND/api/session" -H 'Content-Type: application/json' -d "{\"room\":\"$ROOM\",\"username\":\"$USERNAME\",\"ttl\":300}") || true
if [ -z "$RESP" ]; then
echo "No response from $BACKEND/api/session" >&2
exit 2
fi
# Pretty print JSON if jq available
if command -v jq >/dev/null 2>&1; then
echo "$RESP" | jq .
else
echo "$RESP"
fi
# Try to extract token and studioUrl
TOKEN=$(echo "$RESP" | grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"token"\s*:\s*"([^"]*)"/\1/' || true)
STUDIOURL=$(echo "$RESP" | grep -o '"studioUrl"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"studioUrl"\s*:\s*"([^"]*)"/\1/' || true)
ID=$(echo "$RESP" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"id"\s*:\s*"([^"]*)"/\1/' || true)
if [ -n "$TOKEN" ]; then
echo
echo "--- Copy-paste this into your browser console (on the broadcast panel page) to inject the token ---"
echo "window.postMessage({ type: 'LIVEKIT_TOKEN', token: '$TOKEN', room: '$ROOM' }, window.location.origin);"
echo "---"
fi
if [ -n "$STUDIOURL" ]; then
echo "studioUrl: $STUDIOURL"
fi
if [ -n "$ID" ]; then
echo "session id: $ID"
fi
exit 0