// server.js - VERSÃO 6 (ACK + Clock Sync + Logs Dinâmicos + Persistência Autoritativa) // ========================= // IMPORTS PRINCIPAIS // ========================= const express = require("express"); const { createServer } = require("http"); const { Server } = require("socket.io"); const fs = require("fs"); const path = require("path"); const pino = require("pino"); // ------------------------- // LOGGER DINÂMICO (GERENCIADOR) // ------------------------- // Vamos manter um mapa de loggers ativos, um para cada sala const loggersByRoom = new Map(); // Função para gerar um timestamp formatado para nome de arquivo function getTimestampForFile() { const d = new Date(); const date = d.toISOString().split("T")[0]; // YYYY-MM-DD // HH-MM-SS (formato 24h, fuso local) const time = d.toTimeString().split(" ")[0].replace(/:/g, "-"); return `${date}_${time}`; // ex: 2025-10-26_12-30-05 } // Função principal: Pega o logger de uma sala ou cria um novo function getActionLoggerForRoom(roomName) { // 1. Se já criamos um logger para esta sala, reutilize-o if (loggersByRoom.has(roomName)) { return loggersByRoom.get(roomName); } // 2. Se for a primeira vez, crie um novo logger const timestamp = getTimestampForFile(); // Limpa o nome da sala para evitar caracteres inválidos no nome do arquivo const safeRoomName = roomName.replace(/[^a-z0-9_-]/gi, "_"); const fileName = `${timestamp}_${safeRoomName}.log`; const filePath = path.join(process.cwd(), "data", fileName); console.log(`[Logger] Novo log de sessão iniciado: ${filePath}`); // Garante que a pasta 'data' exista fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true }); // Cria a instância do pino para este arquivo específico const newLogger = pino(pino.destination(filePath)); // Armazena no mapa para reutilização loggersByRoom.set(roomName, newLogger); return newLogger; } // ------------------------- // Configuração do Servidor // ------------------------- const app = express(); const httpServer = createServer(app); const PORT = process.env.PORT || 33007; // ------------------------- // Persistência por sala // ------------------------- /* Estrutura na memória: roomStates = { [roomName]: { projectXml: '' | undefined, audio: { tracks: [], clips: [] }, seq: number, tokensSeen: Set // idempotência } } */ const roomStates = {}; // compat com V4 (já existia), ampliado para incluir audio/seq function dataFile(roomName) { return path.join(process.cwd(), "data", `${roomName}.json`); } function ensureRoom(roomName) { if (roomStates[roomName]) return roomStates[roomName]; // tenta carregar do disco try { const p = dataFile(roomName); if (fs.existsSync(p)) { const j = JSON.parse(fs.readFileSync(p, "utf8")); roomStates[roomName] = { projectXml: j.projectXml || null, audio: j.audio || { tracks: [], clips: [] }, seq: j.seq || 0, tokensSeen: new Set(), }; return roomStates[roomName]; } } catch (e) { console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e); } roomStates[roomName] = { projectXml: null, audio: { tracks: [], clips: [] }, seq: 0, tokensSeen: new Set(), }; return roomStates[roomName]; } function saveRoom(roomName) { const r = ensureRoom(roomName); try { fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true }); fs.writeFileSync( dataFile(roomName), JSON.stringify({ projectXml: r.projectXml, audio: r.audio, seq: r.seq }) ); } catch (e) { console.warn(`[persist] falha ao salvar estado da sala ${roomName}:`, e); } } // ------------------------- // Server / IO // ------------------------- const io = new Server(httpServer, { cors: { origin: [ "https://alice.ufsj.edu.br", "http://127.0.0.1:33007", "http://localhost:33007", "http://localhost:5173", "http://127.0.0.1:5173", ], methods: ["GET", "POST"], credentials: true, }, }); console.log("Backend V6 (Logs Dinâmicos) iniciado. Aguardando conexões..."); // util de log das salas deste socket function logMyRooms(socket, prefix = "SALAS") { const myRooms = Array.from(socket.rooms || []); console.log(`[${prefix}] socket ${socket.id} está em:`, myRooms); } // ------------------------- // REDUCER autoritativo do editor de áudio // ------------------------- function basenameNoExt(p) { if (!p) return ""; const base = String(p).split(/[\\/]/).pop(); return base.replace(/\.[^/.]+$/, ""); } function applyAuthoritativeAction(roomName, action) { const room = ensureRoom(roomName); const state = room.audio; // idempotência por token (se fornecido) if (action.__token && room.tokensSeen.has(action.__token)) { return null; } if (action.__token) room.tokensSeen.add(action.__token); let mutated = false; switch (action.type) { case "ADD_AUDIO_LANE": { // Usa o trackId enviado pelo cliente, se existir const id = action.trackId || generateUniqueId("track_server"); if (!state.tracks.find((t) => t.id === id)) { state.tracks.push({ id, name: `Pista de Áudio ${state.tracks.length + 1}`, }); mutated = true; } break; } case "ADD_AUDIO_CLIP": { const { clipId, filePath, trackId, startTimeInSeconds, name } = action; // Blindagem do Servidor (Evitar faixas fantasma) if ( !trackId || clipId == null || startTimeInSeconds == null || isNaN(startTimeInSeconds) ) { console.warn( `[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`, action ); return null; // Rejeita a ação, não faz broadcast } if (!state.tracks.find((t) => t.id === trackId)) { state.tracks.push({ id: trackId, name: `Pista de Áudio ${state.tracks.length + 1}`, }); mutated = true; } if (!state.clips.find((c) => String(c.id) === String(clipId))) { state.clips.push({ id: clipId, trackId, name: name || basenameNoExt(filePath) || "sample", sourcePath: filePath || null, // ideal: URL pública startTimeInSeconds, durationInSeconds: action.durationInSeconds || 0, offset: action.offset || 0, pitch: action.pitch || 0, volume: action.volume ?? 1, pan: action.pan ?? 0, originalDuration: action.originalDuration || 0, }); mutated = true; } break; } case "UPDATE_AUDIO_CLIP": { // ================================================================= // 👇 INÍCIO DA CORREÇÃO (Blindagem do Servidor para Bug 4) // ================================================================= if (!action.clipId || !action.props) { console.warn( `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`, action ); return null; } const { trackId, startTimeInSeconds } = action.props; if ( trackId !== undefined && (trackId == null || (typeof trackId === "number" && isNaN(trackId))) ) { console.warn( `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`, action ); return null; } if ( startTimeInSeconds !== undefined && (startTimeInSeconds == null || isNaN(startTimeInSeconds)) ) { console.warn( `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`, action ); return null; } // ================================================================= // 👆 FIM DA CORREÇÃO // ================================================================= const c = state.clips.find((x) => String(x.id) === String(action.clipId)); if (c && action.props && typeof action.props === "object") { Object.assign(c, action.props); mutated = true; } break; } case "REMOVE_AUDIO_CLIP": { const i = state.clips.findIndex( (x) => String(x.id) === String(action.clipId) ); if (i >= 0) { state.clips.splice(i, 1); mutated = true; } break; } default: // outras ações não são da persistência do editor de áudio mutated = false; } if (!mutated) return null; room.seq += 1; // numeração autoritativa saveRoom(roomName); // persistência em disco return { ...action, __seq: room.seq }; // devolve ação com seq para broadcast } io.on("connection", (socket) => { console.log(`Novo usuário conectado: ${socket.id}`); // ======================================= // JOIN ROOM // ======================================= socket.on("join_room", async (data) => { const { roomName, userName } = data || {}; if (!roomName) { console.warn(`join_room inválido de ${socket.id} (sem roomName)`); return; } await socket.join(roomName); console.log( `Usuário ${userName || "(sem nome)"} (${ socket.id }) entrou na sala: ${roomName}` ); logMyRooms(socket, "JOIN"); // envia estado salvo do projeto (XML) — compat V4 const room = ensureRoom(roomName); if (room.projectXml) { socket.emit("load_project_state", room.projectXml); console.log( `Estado XML enviado para ${userName || socket.id} na sala ${roomName}` ); } else { console.log(`Sala ${roomName} sem XML salvo ainda.`); } // envia snapshot autoritativo do editor de áudio socket.emit("action_broadcast", { action: { type: "AUDIO_SNAPSHOT", snapshot: room.audio, __seq: room.seq, __target: socket.id, }, }); socket .to(roomName) .emit("feedback", `Usuário ${userName || socket.id} entrou na sala.`); }); // ======================================= // CLOCK SYNC // ======================================= socket.on("what_time_is_it", (_data, cb) => { cb && cb({ serverNowMs: Date.now() }); }); // ======================================= // BROADCAST COM ACK + PERSISTÊNCIA // ======================================= socket.on("broadcast_action", async (payload, cb) => { const { roomName, action } = payload || {}; if (!roomName || !action) { cb && cb({ ok: false, error: "invalid_payload" }); console.warn( `[broadcast_action] payload inválido de ${socket.id}:`, payload ); return; } // 👇 *** NOSSO NOVO LOG DINÂMICO *** 👇 try { // 1. Pega o logger específico para ESTA sala const roomLogger = getActionLoggerForRoom(roomName); // 2. Loga a ação (não precisamos mais salvar roomName, já está no nome do arq.) roomLogger.info( { timestamp: Date.now(), socketId: socket.id, action: action, }, "action_received" ); } catch (e) { console.warn(`[Logger] Falha ao logar ação para sala ${roomName}:`, e); } // *** FIM DO LOG *** // Confirma recebimento imediatamente cb && cb({ ok: true, token: action.__token }); // Garante que o emissor está na sala (caso o join tenha falhado/atrasado) if (!socket.rooms.has(roomName)) { console.warn( `[broadcast_action] ${socket.id} NÃO estava na sala ${roomName}. Forçando join...` ); await socket.join(roomName); logMyRooms(socket, "FORCED_JOIN"); } // Carrega estado da sala const room = ensureRoom(roomName); // Persiste estado se for LOAD_PROJECT (compat) if (action.type === "LOAD_PROJECT" && action.xml) { room.projectXml = action.xml; saveRoom(roomName); console.log( `[broadcast_action] Estado da sala ${roomName} atualizado (LOAD_PROJECT).` ); } // Aplica ações autoritativas do editor de áudio const maybeApplied = applyAuthoritativeAction(roomName, action); // Debug: quem está na sala neste momento? const socketsInRoom = await io.in(roomName).fetchSockets(); const idsInRoom = socketsInRoom.map((s) => s.id); console.log( `[broadcast_action] ${action.type} (token=${action.__token}) para sala "${roomName}" com ${idsInRoom.length} cliente(s):`, idsInRoom ); // Se foi uma ação do editor de áudio e foi aplicada, rebroadcast com __seq if (maybeApplied) { io.to(roomName).emit("action_broadcast", { action: maybeApplied }); return; } // Caso contrário, apenas repercute a ação como antes (sem __seq) io.to(roomName).emit("action_broadcast", { action }); }); // ======================================= // Retrocompat (daw_action) // ======================================= socket.on("daw_action", async (data) => { const { room, action } = data || {}; if (!room || !action) return; console.warn( "[DEPRECATION] Recebido daw_action. Migre para broadcast_action com ACK." ); // (Opcional, mas recomendado) Adicionando log dinâmico aqui também try { const roomLogger = getActionLoggerForRoom(room); roomLogger.info( { timestamp: Date.now(), socketId: socket.id, action: action, }, "action_received (deprecated daw_action)" ); } catch (e) { console.warn(`[Logger] Falha ao logar daw_action para sala ${room}:`, e); } if (!socket.rooms.has(room)) { console.warn( `[daw_action] ${socket.id} NÃO estava na sala ${room}. Forçando join...` ); await socket.join(room); logMyRooms(socket, "FORCED_JOIN(daw)"); } const r = ensureRoom(room); if (action.type === "LOAD_PROJECT" && action.xml) { r.projectXml = action.xml; saveRoom(room); console.log( `[daw_action] Estado da sala ${room} atualizado (LOAD_PROJECT).` ); } const socketsInRoom = await io.in(room).fetchSockets(); const idsInRoom = socketsInRoom.map((s) => s.id); console.log( `[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`, idsInRoom ); io.in(room).emit("action_broadcast", { action }); }); // ======================================= // Re-sync explícito (quando cliente detectar buraco de seq) // ======================================= socket.on("audio_resync", ({ roomName, lastSeq }) => { const room = ensureRoom(roomName); // caminho simples: envia snapshot atual (estado autoritativo) socket.emit("action_broadcast", { action: { type: "AUDIO_SNAPSHOT", snapshot: room.audio, __seq: room.seq, __target: socket.id, }, }); }); socket.on("disconnect", () => { console.log(`Usuário desconectado: ${socket.id}`); }); }); app.get("/", (req, res) => { res.send( "Servidor Backend V6 (Logs Dinâmicos) da DAW colaborativa está no ar!" ); }); httpServer.listen(PORT, () => { console.log(`Servidor escutando na porta http://localhost:${PORT}`); });