payfri-video-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Payfri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # payfri-video-sdk
2
+
3
+ SDK del **servicio de video de Payfri**. Integra videollamadas en tu producto en minutos: tu
4
+ backend emite tokens contra la API del servicio y tu frontend se une a la llamada — sin implementar
5
+ WebRTC ni montar infraestructura de medios propia. Tres entry points con una **separación estricta
6
+ de seguridad**:
7
+
8
+ | Entry | Dónde corre | Credencial que recibe | Para qué |
9
+ | --- | --- | --- | --- |
10
+ | `payfri-video-sdk/server` | Tu **backend** | **API key** del tenant (secreta) | Crear salas y emitir tokens |
11
+ | `payfri-video-sdk/client` | **Navegador** | Solo un **token** de corta duración | Unirse a la llamada (web) |
12
+ | `payfri-video-sdk/native` | **React Native** | Solo un **token** de corta duración | Unirse a la llamada (móvil) |
13
+
14
+ > La API key **jamás** debe llegar al navegador ni al dispositivo. Los tipos de `/client` y
15
+ > `/native` hacen **imposible** pasarla (no compila), y además hay una verificación en runtime.
16
+
17
+ ## Cómo funciona
18
+
19
+ ```
20
+ Tu backend ──(API key)──▶ API de video ──▶ { token, livekitUrl }
21
+ │ │
22
+ └────────── pasa token + livekitUrl ─────────┘
23
+
24
+ Navegador / app móvil se une a la llamada con el token
25
+ ```
26
+
27
+ 1. Tu backend usa la API key para pedir un **token de acceso** (`tokens.issue`).
28
+ 2. Le pasas `{ token, livekitUrl }` a tu frontend (respuesta de tu propio endpoint).
29
+ 3. El frontend se une a la sala con `/client` (web) o `/native` (React Native).
30
+
31
+ Los tokens expiran (default **1 hora**, configurable de 60 s a 12 h), así que emite uno nuevo por
32
+ participante y por sesión.
33
+
34
+ ## Qué necesitas antes de empezar
35
+
36
+ Te lo entrega el equipo que administra el servicio de video:
37
+
38
+ - **`baseUrl`** — URL de la API de video, p. ej. `https://api.video.tuempresa.com`
39
+ - **`apiKey`** — API key de tu tenant (formato `{prefijo}_live_{aleatorio}`). Se muestra **una sola
40
+ vez** al crearla: guárdala en un gestor de secretos, nunca en el código.
41
+ - **`embedBaseUrl`** (solo para `/native`) — URL de la página embebible, p. ej.
42
+ `https://embed.tuempresa.com`
43
+ - **Alta de CORS** — pide que agreguen el dominio de tu frontend a los orígenes permitidos de la API.
44
+
45
+ Requisitos técnicos: Node 18+ en el backend (usa `fetch` global; en runtimes sin él puedes inyectar
46
+ uno propio). El paquete se distribuye en **ESM + CJS con tipos incluidos**.
47
+
48
+ ## Instalación
49
+
50
+ ```bash
51
+ # Backend (emitir tokens) — sin dependencias extra
52
+ npm install payfri-video-sdk
53
+
54
+ # Navegador (unirse a la llamada) — livekit-client es peer dependency
55
+ npm install payfri-video-sdk livekit-client
56
+
57
+ # React Native / Expo
58
+ npm install payfri-video-sdk react-native-webview
59
+ ```
60
+
61
+ Todas las peer dependencies son **opcionales**: instala solo lo que exige el entry que uses.
62
+
63
+ > `livekit-client` aparece porque el motor de medios del servicio es [LiveKit](https://livekit.io);
64
+ > es un detalle interno de implementación — tú solo interactúas con la API del servicio y con este SDK.
65
+
66
+ ---
67
+
68
+ ## Backend — `payfri-video-sdk/server`
69
+
70
+ ```ts
71
+ import { createVideoClient } from "payfri-video-sdk/server";
72
+
73
+ const video = createVideoClient({
74
+ baseUrl: process.env.VIDEO_API_BASE_URL!, // https://api.video.tuempresa.com
75
+ apiKey: process.env.VIDEO_API_KEY!, // secreta, SOLO en el servidor
76
+ });
77
+
78
+ // Emitir un token para un participante (crea la sala si no existe)
79
+ const { token, livekitUrl } = await video.tokens.issue({
80
+ roomName: "consulta-123",
81
+ identity: "doctor-45",
82
+ displayName: "Dra. López",
83
+ createIfMissing: true,
84
+ });
85
+
86
+ // Devuelve { token, livekitUrl } a tu frontend. NUNCA la apiKey.
87
+ ```
88
+
89
+ ### `createVideoClient(options)`
90
+
91
+ | Opción | Tipo | Requerido | Descripción |
92
+ | --- | --- | --- | --- |
93
+ | `baseUrl` | `string` | ✅ | Base de la API de video (sin `/` final) |
94
+ | `apiKey` | `string` | ✅ | API key del tenant. Solo en servidores |
95
+ | `fetch` | `typeof fetch` | — | `fetch` personalizado (tests / runtimes sin fetch global) |
96
+
97
+ ### Métodos
98
+
99
+ | Método | Descripción |
100
+ | --- | --- |
101
+ | `tokens.issue(input)` | **El central.** Emite el token de acceso a la videollamada |
102
+ | `rooms.create(input?)` | Crea una sala |
103
+ | `rooms.get(roomName)` | Estado de la sala + participantes conectados |
104
+ | `rooms.end(roomName)` | Cierra la sala y desconecta a todos |
105
+
106
+ ### `tokens.issue(input)`
107
+
108
+ | Campo | Tipo | Default | Descripción |
109
+ | --- | --- | --- | --- |
110
+ | `roomName` | `string` | requerido | Alfanumérico con `.` `_` `-` (máx. 120, sin `__`) |
111
+ | `identity` | `string` | requerido | Identidad **única** del participante en la sala (máx. 120) |
112
+ | `displayName` | `string` | — | Nombre visible en la UI |
113
+ | `canPublish` | `boolean` | `true` | Puede publicar cámara/micrófono |
114
+ | `canSubscribe` | `boolean` | `true` | Puede ver/oír a los demás |
115
+ | `canPublishData` | `boolean` | `true` | Puede enviar mensajes de datos |
116
+ | `ttlSeconds` | `number` | `3600` | Vigencia del token (60–43200) |
117
+ | `metadata` | `object` | — | Metadata adjunta al participante |
118
+ | `createIfMissing` | `boolean` | `false` | Crea la sala si no existe (si no, `404`) |
119
+
120
+ Devuelve `{ token, livekitUrl, roomName, identity, expiresAt }`.
121
+
122
+ > Tip: para un espectador (webinar, supervisión) emite `{ canPublish: false }`.
123
+
124
+ ### `rooms.create(input?)`
125
+
126
+ | Campo | Tipo | Descripción |
127
+ | --- | --- | --- |
128
+ | `name` | `string?` | Nombre de la sala (se genera uno si lo omites) |
129
+ | `maxParticipants` | `number?` | Límite de participantes |
130
+ | `emptyTimeoutSeconds` | `number?` | Segundos que la sala vive vacía antes de cerrarse |
131
+ | `metadata` | `object?` | Metadata de la sala |
132
+
133
+ ### Errores
134
+
135
+ Toda respuesta no-2xx lanza **`VideoApiError`** con `{ code, message, status }`:
136
+
137
+ ```ts
138
+ import { createVideoClient, VideoApiError } from "payfri-video-sdk/server";
139
+
140
+ try {
141
+ await video.tokens.issue({ roomName: "no-existe", identity: "u1" });
142
+ } catch (err) {
143
+ if (err instanceof VideoApiError) {
144
+ console.error(err.status, err.code, err.message); // 404 ROOM_NOT_FOUND ...
145
+ }
146
+ }
147
+ ```
148
+
149
+ | `status` | Causa típica |
150
+ | --- | --- |
151
+ | 401 | API key inválida o revocada |
152
+ | 403 | Tenant suspendido |
153
+ | 404 | Sala inexistente (usa `createIfMissing: true` si aplica) |
154
+ | 400 | Body inválido (revisa `message`) |
155
+
156
+ ---
157
+
158
+ ## Navegador — `payfri-video-sdk/client`
159
+
160
+ ```ts
161
+ import { joinRoom } from "payfri-video-sdk/client";
162
+
163
+ const handle = await joinRoom({
164
+ token, // recibido desde TU backend
165
+ livekitUrl, // recibido desde TU backend
166
+ container: document.getElementById("call")!,
167
+ });
168
+
169
+ handle.on("participantJoined", (p) => console.log("entró", p.identity));
170
+ handle.on("error", (e) => console.error(e));
171
+
172
+ await handle.toggleMic(); // → boolean (nuevo estado)
173
+ await handle.toggleCamera(); // → boolean (nuevo estado)
174
+ await handle.leave(); // salir y liberar recursos
175
+ ```
176
+
177
+ ### `joinRoom(options)`
178
+
179
+ | Opción | Tipo | Default | Descripción |
180
+ | --- | --- | --- | --- |
181
+ | `token` | `string` | requerido | Token de acceso emitido por tu backend |
182
+ | `livekitUrl` | `string` | requerido | URL `wss` del servidor de medios (la devuelve `tokens.issue`; pásala tal cual) |
183
+ | `container` | `HTMLElement` | requerido | Elemento donde se montan los videos |
184
+ | `enableCamera` | `boolean` | `true` | Publicar cámara al conectar |
185
+ | `enableMicrophone` | `boolean` | `true` | Publicar micrófono al conectar |
186
+ | `roomOptions` | `RoomOptions` | — | Opciones avanzadas de `livekit-client` |
187
+ | `connectOptions` | `RoomConnectOptions` | — | Opciones de conexión de `livekit-client` |
188
+
189
+ ### Eventos del `RoomHandle`
190
+
191
+ | Evento | Payload | Cuándo |
192
+ | --- | --- | --- |
193
+ | `connected` | — | Conectó a la sala |
194
+ | `disconnected` | — | Se perdió/cerró la conexión |
195
+ | `participantJoined` | `{ identity }` | Entró un participante remoto |
196
+ | `participantLeft` | `{ identity }` | Salió un participante remoto |
197
+ | `error` | `Error` | Fallo de conexión o de dispositivos |
198
+
199
+ Para casos avanzados, `handle.room` expone el `Room` de `livekit-client` subyacente.
200
+
201
+ ### Seguridad de tipos
202
+
203
+ `joinRoom` **no tiene** campo para la API key. Esto no compila:
204
+
205
+ ```ts
206
+ // @ts-expect-error — apiKey no existe en JoinRoomOptions
207
+ joinRoom({ token, livekitUrl, container, apiKey: "..." });
208
+ ```
209
+
210
+ Y en runtime, `joinRoom` lanza si detecta `apiKey`/`apiSecret`/`secret` en las opciones.
211
+
212
+ ---
213
+
214
+ ## React Native — `payfri-video-sdk/native`
215
+
216
+ Componente `<VideoCall>` que incrusta la videollamada en un `WebView` (carga la página embebible
217
+ del servicio). **No requiere `react-native-webrtc`** ni código nativo propio.
218
+
219
+ ```tsx
220
+ import { VideoCall } from "payfri-video-sdk/native";
221
+
222
+ <VideoCall
223
+ token={token} // recibido desde TU backend
224
+ livekitUrl={livekitUrl} // recibido desde TU backend
225
+ embedBaseUrl="https://embed.tuempresa.com"
226
+ onConnected={() => console.log("conectado")}
227
+ onLeave={() => navigation.goBack()}
228
+ onError={(e) => console.warn(e.message)}
229
+ style={{ flex: 1 }}
230
+ />;
231
+ ```
232
+
233
+ ### Props de `<VideoCall>`
234
+
235
+ | Prop | Tipo | Requerido | Descripción |
236
+ | --- | --- | --- | --- |
237
+ | `token` | `string` | ✅ | Token de acceso emitido por tu backend |
238
+ | `livekitUrl` | `string` | ✅ | URL `wss` del servidor de medios (la devuelve `tokens.issue`; pásala tal cual) |
239
+ | `embedBaseUrl` | `string` | ✅ | Base de la página embebible (**HTTPS obligatorio**) |
240
+ | `theme` | `string` | — | Color de acento para branding (ej. `#4f7cff`) |
241
+ | `onConnected` | `() => void` | — | La videollamada conectó |
242
+ | `onDisconnected` | `() => void` | — | El usuario se desconectó o se cerró la sala |
243
+ | `onLeave` | `() => void` | — | El usuario pulsó "salir" en la UI |
244
+ | `onParticipantJoined` | `({ identity }) => void` | — | Entró un participante remoto |
245
+ | `onParticipantLeft` | `({ identity }) => void` | — | Salió un participante remoto |
246
+ | `onError` | `(error: Error) => void` | — | Permisos denegados, token inválido, fallo de sala |
247
+ | `style` | `StyleProp<ViewStyle>` | — | Estilo del contenedor (default: ocupa todo) |
248
+
249
+ Igual que `/client`, `VideoCallProps` **no tiene** campo para la API key (no compila si la pasas).
250
+
251
+ ### Permisos de cámara y micrófono
252
+
253
+ **Expo** — activa el config plugin en `app.json` y no toques archivos nativos:
254
+
255
+ ```jsonc
256
+ {
257
+ "expo": {
258
+ "plugins": [
259
+ ["payfri-video-sdk", { "cameraText": "Para videollamadas", "microphoneText": "Para videollamadas" }]
260
+ ]
261
+ }
262
+ }
263
+ ```
264
+
265
+ El plugin inyecta en el prebuild `NSCameraUsageDescription` / `NSMicrophoneUsageDescription` (iOS)
266
+ y `CAMERA` / `RECORD_AUDIO` (Android).
267
+
268
+ **React Native "bare"** (sin Expo) — agrégalos a mano en `Info.plist` y `AndroidManifest.xml`.
269
+
270
+ > App de ejemplo completa (Expo):
271
+ > [`examples/expo-videocall`](https://github.com/Payfri/VideoCallsGateway/tree/main/examples/expo-videocall)
272
+
273
+ ---
274
+
275
+ ## Solución de problemas
276
+
277
+ | Síntoma | Causa probable | Solución |
278
+ | --- | --- | --- |
279
+ | `401 UNAUTHORIZED` desde `/server` | API key mal copiada o revocada | Verifica la env var; pide una key nueva si fue revocada |
280
+ | CORS bloquea la petición del navegador | Tu dominio no está en la lista de orígenes | Pide el alta de tu dominio al equipo de la plataforma |
281
+ | "token inválido o expirado" al unirse | El token venció (default 1 h) | Emite un token nuevo por sesión; sube `ttlSeconds` si aplica |
282
+ | Cámara/micrófono no arrancan (web) | Contexto no seguro | Sirve tu página por **HTTPS** (`getUserMedia` lo exige) |
283
+ | Cámara/micrófono no arrancan (RN) | Permisos no declarados | Config plugin (Expo) o permisos manuales (bare) + rebuild |
284
+ | `No hay fetch disponible` en `/server` | Node < 18 o runtime sin fetch | Usa Node 18+ o pasa `fetch` en las opciones |
285
+ | Pantalla en blanco en `<VideoCall>` | `embedBaseUrl` en HTTP o mal apuntada | Debe ser HTTPS y responder la página `/room` |
286
+
287
+ ## Licencia
288
+
289
+ [MIT](https://github.com/Payfri/VideoCallsGateway/blob/main/packages/sdk/LICENSE)
package/app.plugin.cjs ADDED
@@ -0,0 +1,8 @@
1
+ // Punto de entrada del config plugin de Expo.
2
+ //
3
+ // El paquete es "type": "module", así que un `app.plugin.js` sería ESM y Expo
4
+ // (que hace require()) fallaría en Node 18/20. Con extensión .cjs forzamos
5
+ // CommonJS, y el mapa de `exports` hace que `payfri-video-sdk/app.plugin.js` resuelva
6
+ // a este archivo. Reexporta el plugin compilado por tsup (que exporta la función
7
+ // directamente en su module.exports).
8
+ module.exports = require("./dist/plugin/withVideoSdk.cjs");
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ var livekitClient = require('livekit-client');
4
+
5
+ // src/client/join-room.ts
6
+
7
+ // src/client/emitter.ts
8
+ var TypedEmitter = class {
9
+ listeners = /* @__PURE__ */ new Map();
10
+ on(event, cb) {
11
+ const set = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
12
+ set.add(cb);
13
+ this.listeners.set(event, set);
14
+ }
15
+ off(event, cb) {
16
+ this.listeners.get(event)?.delete(cb);
17
+ }
18
+ emit(event, payload) {
19
+ for (const cb of this.listeners.get(event) ?? []) {
20
+ cb(payload);
21
+ }
22
+ }
23
+ };
24
+
25
+ // src/client/join-room.ts
26
+ async function joinRoom(options) {
27
+ for (const forbidden of ["apiKey", "apiSecret", "secret"]) {
28
+ if (forbidden in options) {
29
+ throw new Error(`joinRoom no acepta "${forbidden}": jam\xE1s pases credenciales al navegador`);
30
+ }
31
+ }
32
+ const { token, livekitUrl, container } = options;
33
+ const emitter = new TypedEmitter();
34
+ const room = new livekitClient.Room(options.roomOptions);
35
+ const attach = (track) => {
36
+ const el = track.attach();
37
+ el.setAttribute("data-lk-track", track.sid ?? "");
38
+ if (el instanceof HTMLVideoElement) {
39
+ el.style.width = "100%";
40
+ }
41
+ container.appendChild(el);
42
+ };
43
+ room.on(livekitClient.RoomEvent.TrackSubscribed, (track) => attach(track)).on(livekitClient.RoomEvent.TrackUnsubscribed, (track) => {
44
+ track.detach().forEach((el) => el.remove());
45
+ }).on(livekitClient.RoomEvent.LocalTrackPublished, (pub) => {
46
+ if (pub.track && pub.kind === "video") {
47
+ const el = pub.track.attach();
48
+ el.setAttribute("data-lk-local", "true");
49
+ if (el instanceof HTMLMediaElement) {
50
+ el.muted = true;
51
+ }
52
+ if (el instanceof HTMLVideoElement) {
53
+ el.style.width = "100%";
54
+ }
55
+ container.appendChild(el);
56
+ }
57
+ }).on(
58
+ livekitClient.RoomEvent.ParticipantConnected,
59
+ (p) => emitter.emit("participantJoined", { identity: p.identity })
60
+ ).on(
61
+ livekitClient.RoomEvent.ParticipantDisconnected,
62
+ (p) => emitter.emit("participantLeft", { identity: p.identity })
63
+ ).on(livekitClient.RoomEvent.Disconnected, () => emitter.emit("disconnected", void 0)).on(livekitClient.RoomEvent.MediaDevicesError, (err) => emitter.emit("error", err));
64
+ try {
65
+ await room.connect(livekitUrl, token, options.connectOptions);
66
+ } catch (err) {
67
+ emitter.emit("error", err instanceof Error ? err : new Error(String(err)));
68
+ throw err;
69
+ }
70
+ if (options.enableMicrophone !== false) {
71
+ await room.localParticipant.setMicrophoneEnabled(true);
72
+ }
73
+ if (options.enableCamera !== false) {
74
+ await room.localParticipant.setCameraEnabled(true);
75
+ }
76
+ emitter.emit("connected", void 0);
77
+ return {
78
+ on: emitter.on.bind(emitter),
79
+ off: emitter.off.bind(emitter),
80
+ async toggleMic() {
81
+ const next = !room.localParticipant.isMicrophoneEnabled;
82
+ await room.localParticipant.setMicrophoneEnabled(next);
83
+ return next;
84
+ },
85
+ async toggleCamera() {
86
+ const next = !room.localParticipant.isCameraEnabled;
87
+ await room.localParticipant.setCameraEnabled(next);
88
+ return next;
89
+ },
90
+ async leave() {
91
+ await room.disconnect();
92
+ },
93
+ room
94
+ };
95
+ }
96
+
97
+ exports.joinRoom = joinRoom;
98
+ //# sourceMappingURL=index.cjs.map
99
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/emitter.ts","../../src/client/join-room.ts"],"names":["Room","RoomEvent"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { RoomHandleEvent, RoomHandleEventMap } from \"./types\";\n\ntype AnyCb = (payload: never) => void;\n\n/** Emisor de eventos tipado y minimalista para el handle de la sala. */\nexport class TypedEmitter {\n private readonly listeners = new Map<RoomHandleEvent, Set<AnyCb>>();\n\n on<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void {\n const set = this.listeners.get(event) ?? new Set<AnyCb>();\n set.add(cb as AnyCb);\n this.listeners.set(event, set);\n }\n\n off<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void {\n this.listeners.get(event)?.delete(cb as AnyCb);\n }\n\n emit<E extends RoomHandleEvent>(event: E, payload: RoomHandleEventMap[E]): void {\n for (const cb of this.listeners.get(event) ?? []) {\n (cb as (payload: RoomHandleEventMap[E]) => void)(payload);\n }\n }\n}\n","import {\n type LocalTrackPublication,\n type RemoteParticipant,\n type RemoteTrack,\n Room,\n RoomEvent,\n} from \"livekit-client\";\nimport { TypedEmitter } from \"./emitter\";\nimport type { JoinRoomOptions, RoomHandle } from \"./types\";\n\n/**\n * Conecta a una sala de LiveKit desde el navegador y monta los videos en el\n * contenedor. Solo necesita `token` + `livekitUrl`; nunca una API key.\n */\nexport async function joinRoom(options: JoinRoomOptions): Promise<RoomHandle> {\n // Guard defensivo: el tipo ya lo impide, pero rechazamos cualquier intento de\n // colar credenciales de servidor al navegador.\n for (const forbidden of [\"apiKey\", \"apiSecret\", \"secret\"]) {\n if (forbidden in (options as unknown as Record<string, unknown>)) {\n throw new Error(`joinRoom no acepta \"${forbidden}\": jamás pases credenciales al navegador`);\n }\n }\n\n const { token, livekitUrl, container } = options;\n const emitter = new TypedEmitter();\n const room = new Room(options.roomOptions);\n\n const attach = (track: RemoteTrack): void => {\n const el = track.attach();\n el.setAttribute(\"data-lk-track\", track.sid ?? \"\");\n if (el instanceof HTMLVideoElement) {\n el.style.width = \"100%\";\n }\n container.appendChild(el);\n };\n\n room\n .on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => attach(track))\n .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach().forEach((el) => el.remove());\n })\n .on(RoomEvent.LocalTrackPublished, (pub: LocalTrackPublication) => {\n if (pub.track && pub.kind === \"video\") {\n const el = pub.track.attach();\n el.setAttribute(\"data-lk-local\", \"true\");\n if (el instanceof HTMLMediaElement) {\n el.muted = true; // evitar eco del audio propio\n }\n if (el instanceof HTMLVideoElement) {\n el.style.width = \"100%\";\n }\n container.appendChild(el);\n }\n })\n .on(RoomEvent.ParticipantConnected, (p: RemoteParticipant) =>\n emitter.emit(\"participantJoined\", { identity: p.identity }),\n )\n .on(RoomEvent.ParticipantDisconnected, (p: RemoteParticipant) =>\n emitter.emit(\"participantLeft\", { identity: p.identity }),\n )\n .on(RoomEvent.Disconnected, () => emitter.emit(\"disconnected\", undefined))\n .on(RoomEvent.MediaDevicesError, (err: Error) => emitter.emit(\"error\", err));\n\n try {\n await room.connect(livekitUrl, token, options.connectOptions);\n } catch (err) {\n emitter.emit(\"error\", err instanceof Error ? err : new Error(String(err)));\n throw err;\n }\n\n if (options.enableMicrophone !== false) {\n await room.localParticipant.setMicrophoneEnabled(true);\n }\n if (options.enableCamera !== false) {\n await room.localParticipant.setCameraEnabled(true);\n }\n\n emitter.emit(\"connected\", undefined);\n\n return {\n on: emitter.on.bind(emitter),\n off: emitter.off.bind(emitter),\n async toggleMic() {\n const next = !room.localParticipant.isMicrophoneEnabled;\n await room.localParticipant.setMicrophoneEnabled(next);\n return next;\n },\n async toggleCamera() {\n const next = !room.localParticipant.isCameraEnabled;\n await room.localParticipant.setCameraEnabled(next);\n return next;\n },\n async leave() {\n await room.disconnect();\n },\n room,\n };\n}\n"]}
@@ -0,0 +1,53 @@
1
+ import { RoomOptions, RoomConnectOptions, Room } from 'livekit-client';
2
+
3
+ /**
4
+ * Opciones para unirse a una sala desde el navegador. SOLO recibe `token` y
5
+ * `livekitUrl`: NO existe un campo para la API key, por lo que el tipo hace
6
+ * imposible filtrarla al cliente.
7
+ */
8
+ interface JoinRoomOptions {
9
+ /** Token de acceso de LiveKit (corta duración) emitido por tu backend. */
10
+ token: string;
11
+ /** URL ws/wss del servidor LiveKit. */
12
+ livekitUrl: string;
13
+ /** Contenedor donde se montan los videos. */
14
+ container: HTMLElement;
15
+ /** Publicar cámara al conectar (default true). */
16
+ enableCamera?: boolean;
17
+ /** Publicar micrófono al conectar (default true). */
18
+ enableMicrophone?: boolean;
19
+ roomOptions?: RoomOptions;
20
+ connectOptions?: RoomConnectOptions;
21
+ }
22
+ type RoomHandleEvent = "connected" | "disconnected" | "participantJoined" | "participantLeft" | "error";
23
+ interface RoomHandleEventMap {
24
+ connected: void;
25
+ disconnected: void;
26
+ participantJoined: {
27
+ identity: string;
28
+ };
29
+ participantLeft: {
30
+ identity: string;
31
+ };
32
+ error: Error;
33
+ }
34
+ interface RoomHandle {
35
+ on<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void;
36
+ off<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void;
37
+ /** Alterna el micrófono. Devuelve el nuevo estado (true = activo). */
38
+ toggleMic(): Promise<boolean>;
39
+ /** Alterna la cámara. Devuelve el nuevo estado (true = activa). */
40
+ toggleCamera(): Promise<boolean>;
41
+ /** Sale de la sala y libera los recursos. */
42
+ leave(): Promise<void>;
43
+ /** Acceso de bajo nivel al Room de livekit-client. */
44
+ readonly room: Room;
45
+ }
46
+
47
+ /**
48
+ * Conecta a una sala de LiveKit desde el navegador y monta los videos en el
49
+ * contenedor. Solo necesita `token` + `livekitUrl`; nunca una API key.
50
+ */
51
+ declare function joinRoom(options: JoinRoomOptions): Promise<RoomHandle>;
52
+
53
+ export { type JoinRoomOptions, type RoomHandle, type RoomHandleEvent, type RoomHandleEventMap, joinRoom };
@@ -0,0 +1,53 @@
1
+ import { RoomOptions, RoomConnectOptions, Room } from 'livekit-client';
2
+
3
+ /**
4
+ * Opciones para unirse a una sala desde el navegador. SOLO recibe `token` y
5
+ * `livekitUrl`: NO existe un campo para la API key, por lo que el tipo hace
6
+ * imposible filtrarla al cliente.
7
+ */
8
+ interface JoinRoomOptions {
9
+ /** Token de acceso de LiveKit (corta duración) emitido por tu backend. */
10
+ token: string;
11
+ /** URL ws/wss del servidor LiveKit. */
12
+ livekitUrl: string;
13
+ /** Contenedor donde se montan los videos. */
14
+ container: HTMLElement;
15
+ /** Publicar cámara al conectar (default true). */
16
+ enableCamera?: boolean;
17
+ /** Publicar micrófono al conectar (default true). */
18
+ enableMicrophone?: boolean;
19
+ roomOptions?: RoomOptions;
20
+ connectOptions?: RoomConnectOptions;
21
+ }
22
+ type RoomHandleEvent = "connected" | "disconnected" | "participantJoined" | "participantLeft" | "error";
23
+ interface RoomHandleEventMap {
24
+ connected: void;
25
+ disconnected: void;
26
+ participantJoined: {
27
+ identity: string;
28
+ };
29
+ participantLeft: {
30
+ identity: string;
31
+ };
32
+ error: Error;
33
+ }
34
+ interface RoomHandle {
35
+ on<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void;
36
+ off<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void;
37
+ /** Alterna el micrófono. Devuelve el nuevo estado (true = activo). */
38
+ toggleMic(): Promise<boolean>;
39
+ /** Alterna la cámara. Devuelve el nuevo estado (true = activa). */
40
+ toggleCamera(): Promise<boolean>;
41
+ /** Sale de la sala y libera los recursos. */
42
+ leave(): Promise<void>;
43
+ /** Acceso de bajo nivel al Room de livekit-client. */
44
+ readonly room: Room;
45
+ }
46
+
47
+ /**
48
+ * Conecta a una sala de LiveKit desde el navegador y monta los videos en el
49
+ * contenedor. Solo necesita `token` + `livekitUrl`; nunca una API key.
50
+ */
51
+ declare function joinRoom(options: JoinRoomOptions): Promise<RoomHandle>;
52
+
53
+ export { type JoinRoomOptions, type RoomHandle, type RoomHandleEvent, type RoomHandleEventMap, joinRoom };
@@ -0,0 +1,97 @@
1
+ import { Room, RoomEvent } from 'livekit-client';
2
+
3
+ // src/client/join-room.ts
4
+
5
+ // src/client/emitter.ts
6
+ var TypedEmitter = class {
7
+ listeners = /* @__PURE__ */ new Map();
8
+ on(event, cb) {
9
+ const set = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
10
+ set.add(cb);
11
+ this.listeners.set(event, set);
12
+ }
13
+ off(event, cb) {
14
+ this.listeners.get(event)?.delete(cb);
15
+ }
16
+ emit(event, payload) {
17
+ for (const cb of this.listeners.get(event) ?? []) {
18
+ cb(payload);
19
+ }
20
+ }
21
+ };
22
+
23
+ // src/client/join-room.ts
24
+ async function joinRoom(options) {
25
+ for (const forbidden of ["apiKey", "apiSecret", "secret"]) {
26
+ if (forbidden in options) {
27
+ throw new Error(`joinRoom no acepta "${forbidden}": jam\xE1s pases credenciales al navegador`);
28
+ }
29
+ }
30
+ const { token, livekitUrl, container } = options;
31
+ const emitter = new TypedEmitter();
32
+ const room = new Room(options.roomOptions);
33
+ const attach = (track) => {
34
+ const el = track.attach();
35
+ el.setAttribute("data-lk-track", track.sid ?? "");
36
+ if (el instanceof HTMLVideoElement) {
37
+ el.style.width = "100%";
38
+ }
39
+ container.appendChild(el);
40
+ };
41
+ room.on(RoomEvent.TrackSubscribed, (track) => attach(track)).on(RoomEvent.TrackUnsubscribed, (track) => {
42
+ track.detach().forEach((el) => el.remove());
43
+ }).on(RoomEvent.LocalTrackPublished, (pub) => {
44
+ if (pub.track && pub.kind === "video") {
45
+ const el = pub.track.attach();
46
+ el.setAttribute("data-lk-local", "true");
47
+ if (el instanceof HTMLMediaElement) {
48
+ el.muted = true;
49
+ }
50
+ if (el instanceof HTMLVideoElement) {
51
+ el.style.width = "100%";
52
+ }
53
+ container.appendChild(el);
54
+ }
55
+ }).on(
56
+ RoomEvent.ParticipantConnected,
57
+ (p) => emitter.emit("participantJoined", { identity: p.identity })
58
+ ).on(
59
+ RoomEvent.ParticipantDisconnected,
60
+ (p) => emitter.emit("participantLeft", { identity: p.identity })
61
+ ).on(RoomEvent.Disconnected, () => emitter.emit("disconnected", void 0)).on(RoomEvent.MediaDevicesError, (err) => emitter.emit("error", err));
62
+ try {
63
+ await room.connect(livekitUrl, token, options.connectOptions);
64
+ } catch (err) {
65
+ emitter.emit("error", err instanceof Error ? err : new Error(String(err)));
66
+ throw err;
67
+ }
68
+ if (options.enableMicrophone !== false) {
69
+ await room.localParticipant.setMicrophoneEnabled(true);
70
+ }
71
+ if (options.enableCamera !== false) {
72
+ await room.localParticipant.setCameraEnabled(true);
73
+ }
74
+ emitter.emit("connected", void 0);
75
+ return {
76
+ on: emitter.on.bind(emitter),
77
+ off: emitter.off.bind(emitter),
78
+ async toggleMic() {
79
+ const next = !room.localParticipant.isMicrophoneEnabled;
80
+ await room.localParticipant.setMicrophoneEnabled(next);
81
+ return next;
82
+ },
83
+ async toggleCamera() {
84
+ const next = !room.localParticipant.isCameraEnabled;
85
+ await room.localParticipant.setCameraEnabled(next);
86
+ return next;
87
+ },
88
+ async leave() {
89
+ await room.disconnect();
90
+ },
91
+ room
92
+ };
93
+ }
94
+
95
+ export { joinRoom };
96
+ //# sourceMappingURL=index.js.map
97
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/emitter.ts","../../src/client/join-room.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { RoomHandleEvent, RoomHandleEventMap } from \"./types\";\n\ntype AnyCb = (payload: never) => void;\n\n/** Emisor de eventos tipado y minimalista para el handle de la sala. */\nexport class TypedEmitter {\n private readonly listeners = new Map<RoomHandleEvent, Set<AnyCb>>();\n\n on<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void {\n const set = this.listeners.get(event) ?? new Set<AnyCb>();\n set.add(cb as AnyCb);\n this.listeners.set(event, set);\n }\n\n off<E extends RoomHandleEvent>(event: E, cb: (payload: RoomHandleEventMap[E]) => void): void {\n this.listeners.get(event)?.delete(cb as AnyCb);\n }\n\n emit<E extends RoomHandleEvent>(event: E, payload: RoomHandleEventMap[E]): void {\n for (const cb of this.listeners.get(event) ?? []) {\n (cb as (payload: RoomHandleEventMap[E]) => void)(payload);\n }\n }\n}\n","import {\n type LocalTrackPublication,\n type RemoteParticipant,\n type RemoteTrack,\n Room,\n RoomEvent,\n} from \"livekit-client\";\nimport { TypedEmitter } from \"./emitter\";\nimport type { JoinRoomOptions, RoomHandle } from \"./types\";\n\n/**\n * Conecta a una sala de LiveKit desde el navegador y monta los videos en el\n * contenedor. Solo necesita `token` + `livekitUrl`; nunca una API key.\n */\nexport async function joinRoom(options: JoinRoomOptions): Promise<RoomHandle> {\n // Guard defensivo: el tipo ya lo impide, pero rechazamos cualquier intento de\n // colar credenciales de servidor al navegador.\n for (const forbidden of [\"apiKey\", \"apiSecret\", \"secret\"]) {\n if (forbidden in (options as unknown as Record<string, unknown>)) {\n throw new Error(`joinRoom no acepta \"${forbidden}\": jamás pases credenciales al navegador`);\n }\n }\n\n const { token, livekitUrl, container } = options;\n const emitter = new TypedEmitter();\n const room = new Room(options.roomOptions);\n\n const attach = (track: RemoteTrack): void => {\n const el = track.attach();\n el.setAttribute(\"data-lk-track\", track.sid ?? \"\");\n if (el instanceof HTMLVideoElement) {\n el.style.width = \"100%\";\n }\n container.appendChild(el);\n };\n\n room\n .on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => attach(track))\n .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach().forEach((el) => el.remove());\n })\n .on(RoomEvent.LocalTrackPublished, (pub: LocalTrackPublication) => {\n if (pub.track && pub.kind === \"video\") {\n const el = pub.track.attach();\n el.setAttribute(\"data-lk-local\", \"true\");\n if (el instanceof HTMLMediaElement) {\n el.muted = true; // evitar eco del audio propio\n }\n if (el instanceof HTMLVideoElement) {\n el.style.width = \"100%\";\n }\n container.appendChild(el);\n }\n })\n .on(RoomEvent.ParticipantConnected, (p: RemoteParticipant) =>\n emitter.emit(\"participantJoined\", { identity: p.identity }),\n )\n .on(RoomEvent.ParticipantDisconnected, (p: RemoteParticipant) =>\n emitter.emit(\"participantLeft\", { identity: p.identity }),\n )\n .on(RoomEvent.Disconnected, () => emitter.emit(\"disconnected\", undefined))\n .on(RoomEvent.MediaDevicesError, (err: Error) => emitter.emit(\"error\", err));\n\n try {\n await room.connect(livekitUrl, token, options.connectOptions);\n } catch (err) {\n emitter.emit(\"error\", err instanceof Error ? err : new Error(String(err)));\n throw err;\n }\n\n if (options.enableMicrophone !== false) {\n await room.localParticipant.setMicrophoneEnabled(true);\n }\n if (options.enableCamera !== false) {\n await room.localParticipant.setCameraEnabled(true);\n }\n\n emitter.emit(\"connected\", undefined);\n\n return {\n on: emitter.on.bind(emitter),\n off: emitter.off.bind(emitter),\n async toggleMic() {\n const next = !room.localParticipant.isMicrophoneEnabled;\n await room.localParticipant.setMicrophoneEnabled(next);\n return next;\n },\n async toggleCamera() {\n const next = !room.localParticipant.isCameraEnabled;\n await room.localParticipant.setCameraEnabled(next);\n return next;\n },\n async leave() {\n await room.disconnect();\n },\n room,\n };\n}\n"]}