added public watch parties with cloudflared
This commit is contained in:
@@ -1,20 +1,17 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import { openTunnel } from "./tunnel.manager";
|
||||
|
||||
interface CreateRoomBody {
|
||||
name: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface JoinRoomBody {
|
||||
password?: string;
|
||||
guestName?: string;
|
||||
expose?: boolean;
|
||||
}
|
||||
|
||||
export async function createRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { name, password } = req.body as CreateRoomBody;
|
||||
const { name, password, expose } = req.body as CreateRoomBody;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
@@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) {
|
||||
userId
|
||||
};
|
||||
|
||||
const room = roomService.createRoom(name, host, password);
|
||||
let publicUrl: string | undefined;
|
||||
|
||||
if (expose) {
|
||||
publicUrl = await openTunnel();
|
||||
}
|
||||
|
||||
const room = roomService.createRoom(
|
||||
name,
|
||||
host,
|
||||
password,
|
||||
!!expose,
|
||||
publicUrl
|
||||
);
|
||||
|
||||
if (expose && publicUrl) {
|
||||
room.publicUrl = `${publicUrl}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
hasPassword: !!room.password,
|
||||
userCount: room.users.size
|
||||
userCount: room.users.size,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
hasPassword: !!room.password,
|
||||
currentVideo: room.currentVideo
|
||||
currentVideo: room.currentVideo,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { closeTunnelIfUnused } from "./tunnel.manager";
|
||||
|
||||
interface RoomUser {
|
||||
id: string;
|
||||
@@ -33,6 +34,8 @@ interface RoomData {
|
||||
} | null;
|
||||
password?: string;
|
||||
metadata?: RoomMetadata | null;
|
||||
exposed: boolean;
|
||||
publicUrl?: string;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, RoomData>();
|
||||
@@ -41,7 +44,7 @@ export function generateRoomId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
export function createRoom(name: string, host: RoomUser, password?: string): RoomData {
|
||||
export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData {
|
||||
const roomId = generateRoomId();
|
||||
|
||||
const room: RoomData = {
|
||||
@@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo
|
||||
currentVideo: null,
|
||||
password: password || undefined,
|
||||
metadata: null,
|
||||
exposed,
|
||||
publicUrl
|
||||
};
|
||||
|
||||
rooms.set(roomId, room);
|
||||
@@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean {
|
||||
|
||||
room.users.delete(userId);
|
||||
|
||||
// Si no quedan usuarios, eliminar la sala
|
||||
if (room.users.size === 0) {
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si era el host, asignar nuevo host
|
||||
if (room.host.id === userId && room.users.size > 0) {
|
||||
const newHost = Array.from(room.users.values())[0];
|
||||
newHost.isHost = true;
|
||||
@@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
|
||||
}
|
||||
|
||||
export function deleteRoom(roomId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
|
||||
return rooms.delete(roomId);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] User ${userId} requested sync from host`);
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId,
|
||||
username: room.users.get(userId)?.username
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[Sync Request] Host not available for user ${userId}`);
|
||||
|
||||
if (room.currentVideo) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] Sending cached video state to ${userId}`);
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'sync',
|
||||
currentTime: room.currentVideo.currentTime || 0,
|
||||
isPlaying: room.currentVideo.isPlaying || false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
@@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'play',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
// Solo el host puede controlar la reproducción
|
||||
if (room.host.id !== userId) {
|
||||
console.warn('Non-host attempted pause:', userId);
|
||||
return;
|
||||
@@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'pause',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
// Solo el host puede controlar la reproducción
|
||||
if (room.host.id !== userId) {
|
||||
console.warn('Non-host attempted seek:', userId);
|
||||
return;
|
||||
@@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'seek',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId
|
||||
}));
|
||||
}
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_state':
|
||||
// Enviar estado actual de la sala al usuario que lo solicita
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
|
||||
111
docker/src/api/rooms/tunnel.manager.ts
Normal file
111
docker/src/api/rooms/tunnel.manager.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
const { values } = loadConfig();
|
||||
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
|
||||
|
||||
let tunnelProcess: ChildProcess | null = null;
|
||||
let exposedRooms = 0;
|
||||
let publicUrl: string | null = null;
|
||||
let tunnelPromise: Promise<string> | null = null;
|
||||
|
||||
export function openTunnel(): Promise<string> {
|
||||
if (tunnelProcess && publicUrl) {
|
||||
exposedRooms++;
|
||||
return Promise.resolve(publicUrl);
|
||||
}
|
||||
|
||||
if (tunnelPromise) {
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
tunnelPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timeout esperando URL del túnel (30s)"));
|
||||
}, 30000);
|
||||
|
||||
tunnelProcess = spawn(CLOUDFLARED_PATH, [
|
||||
"tunnel",
|
||||
"--url",
|
||||
"http://localhost:54322",
|
||||
"--no-autoupdate"
|
||||
]);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
tunnelPromise = null;
|
||||
};
|
||||
|
||||
let outputBuffer = "";
|
||||
|
||||
const processOutput = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuffer += text;
|
||||
|
||||
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match && !publicUrl) {
|
||||
publicUrl = match[0];
|
||||
exposedRooms = 1;
|
||||
cleanup();
|
||||
resolve(publicUrl);
|
||||
}
|
||||
};
|
||||
|
||||
tunnelProcess.stdout?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.stderr?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.on("error", (error) => {
|
||||
console.error("[Cloudflared Process Error]", error);
|
||||
cleanup();
|
||||
tunnelProcess = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
tunnelProcess.on("exit", (code, signal) => {
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
|
||||
if (!publicUrl) {
|
||||
cleanup();
|
||||
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
export function closeTunnelIfUnused() {
|
||||
exposedRooms--;
|
||||
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
|
||||
|
||||
if (exposedRooms <= 0 && tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Cerrando túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTunnelUrl(): string | null {
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
export function forceTunnelClose() {
|
||||
if (tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Forzando cierre del túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user