mmpSearch/assets/js/creations/server/server.js

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}`);
});