515 lines
15 KiB
JavaScript
515 lines
15 KiB
JavaScript
// 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: '<xml...>' | undefined,
|
|
audio: { tracks: [], clips: [] },
|
|
seq: number,
|
|
tokensSeen: Set<string> // 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}`);
|
|
});
|