Add Server-Sent Events (SSE) support for real-time synchronization
Despliegue Automático / desplegar (push) Successful in 1m38s

This commit is contained in:
2026-09-09 13:39:20 +02:00
parent 1c8b0642b0
commit 4ed5685f1a
4 changed files with 81 additions and 2 deletions
+33 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const syncService = require('../services/syncService'); const syncService = require('../services/syncService');
const sseManager = require('../services/sseManager');
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
@@ -36,4 +37,35 @@ const sync = async (req, res) => {
} }
}; };
module.exports = { sync }; const streamEvents = async (req, res) => {
try {
// Validar token (usa tu lógica existente o un middleware)
const token = req.headers.authorization.slice(7).trim();
const payload = jwt.verify(token, JWT_SECRET);
const userId = payload.id;
// Configurar cabeceras obligatorias para SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // Enviar cabeceras inmediatamente
// Registrar este dispositivo en el manager
sseManager.addClient(userId, res);
// Latido (Heartbeat) para mantener la conexión viva a través de firewalls
const interval = setInterval(() => res.write(':keepalive\n\n'), 30000);
// Cuando el cliente (móvil/PC) cierre la app, limpiar la memoria
req.on('close', () => {
clearInterval(interval);
sseManager.removeClient(userId, res);
res.end();
});
} catch (error) {
res.status(401).json({ error: 'No autorizado' });
}
};
module.exports = { sync, streamEvents };
+2
View File
@@ -4,4 +4,6 @@ const syncController = require('../controllers/syncController');
router.post('/sync', syncController.sync); router.post('/sync', syncController.sync);
router.get('/stream', syncController.streamEvents);
module.exports = router; module.exports = router;
+39
View File
@@ -0,0 +1,39 @@
class SseManager {
constructor() {
// Mapa para guardar los clientes conectados: { userId: [res1, res2, ...] }
this.clients = new Map();
}
addClient(userId, res) {
if (!this.clients.has(userId)) {
this.clients.set(userId, []);
}
this.clients.get(userId).push(res);
}
removeClient(userId, res) {
if (this.clients.has(userId)) {
const userClients = this.clients.get(userId);
const index = userClients.indexOf(res);
if (index !== -1) userClients.splice(index, 1);
// Limpiar el mapa si el usuario ya no tiene dispositivos conectados
if (userClients.length === 0) {
this.clients.delete(userId);
}
}
}
// El "timbre": Avisar a todos los dispositivos de un usuario (excepto al que originó el cambio)
notifyUser(userId) {
if (this.clients.has(userId)) {
const userClients = this.clients.get(userId);
userClients.forEach(res => {
// Formato obligatorio de SSE: "data: {JSON}\n\n"
res.write(`data: ${JSON.stringify({ event: 'sync_needed' })}\n\n`);
});
}
}
}
module.exports = new SseManager();
+7 -1
View File
@@ -2,7 +2,7 @@ const { Op } = require('sequelize');
const sequelize = require('../config/database'); const sequelize = require('../config/database');
const Note = require('../models/Note'); const Note = require('../models/Note');
const Category = require('../models/Category'); const Category = require('../models/Category');
const { randomUUID } = require('crypto'); const sseManager = require('./sseManager');
const toIso = (value) => (value ? new Date(value).toISOString() : null); const toIso = (value) => (value ? new Date(value).toISOString() : null);
@@ -217,6 +217,12 @@ class SyncService {
}) })
]); ]);
if(incomingNotes && incomingNotes.length > 0 ||
incomingCategories && incomingCategories.length > 0
) {
sseManager.notifyUser(userId);
}
return { return {
serverTimestamp: new Date().toISOString(), serverTimestamp: new Date().toISOString(),
synced: true, synced: true,