feat: Migrate from Socket.IO to Server-Sent Events (SSE)
- Replace Socket.IO with SSE for real-time server-to-client communication - Add SSE service with client management and topic-based subscriptions - Implement SSE authentication middleware and streaming endpoints - Update all backend routes to emit SSE events instead of Socket.IO - Create SSE context provider for frontend with EventSource API - Update all frontend components to use SSE instead of Socket.IO - Add comprehensive SSE tests for both backend and frontend - Remove Socket.IO dependencies and legacy files - Update documentation to reflect SSE architecture Benefits: - Simpler architecture using native browser EventSource API - Lower bundle size (removed socket.io-client dependency) - Better compatibility with reverse proxies and load balancers - Reduced resource usage for Raspberry Pi deployment - Standard HTTP-based real-time communication 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
@@ -61,10 +61,10 @@ router.post(
|
||||
content,
|
||||
});
|
||||
|
||||
// Emit Socket.IO event for new comment
|
||||
const io = req.app.get("io");
|
||||
if (io) {
|
||||
io.to(`post_${postId}`).emit("newComment", {
|
||||
// Emit SSE event for new comment
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic(`post_${postId}`, "newComment", {
|
||||
postId,
|
||||
comment,
|
||||
});
|
||||
@@ -111,10 +111,10 @@ router.delete(
|
||||
// Delete comment
|
||||
await Comment.deleteComment(commentId);
|
||||
|
||||
// Emit Socket.IO event for deleted comment
|
||||
const io = req.app.get("io");
|
||||
if (io) {
|
||||
io.to(`post_${postId}`).emit("commentDeleted", {
|
||||
// Emit SSE event for deleted comment
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic(`post_${postId}`, "commentDeleted", {
|
||||
postId,
|
||||
commentId,
|
||||
});
|
||||
|
||||
@@ -55,6 +55,15 @@ router.post(
|
||||
// Invalidate events cache
|
||||
invalidateCacheByPattern('/api/events');
|
||||
|
||||
// Emit SSE event for new event
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic("events", "eventUpdate", {
|
||||
type: "new_event",
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(event);
|
||||
}),
|
||||
);
|
||||
@@ -120,6 +129,16 @@ router.put(
|
||||
// Check and award badges
|
||||
await couchdbService.checkAndAwardBadges(userId, updatedUser.points);
|
||||
|
||||
// Emit SSE event for RSVP
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic(`event_${eventId}`, "eventUpdate", {
|
||||
type: "participants_updated",
|
||||
eventId,
|
||||
participants: updatedEvent.participants,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
participants: updatedEvent.participants,
|
||||
pointsAwarded: 15,
|
||||
@@ -172,6 +191,16 @@ router.put(
|
||||
}
|
||||
|
||||
const updatedEvent = await Event.update(req.params.id, updateData);
|
||||
|
||||
// Emit SSE event for event update
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic(`event_${req.params.id}`, "eventUpdate", {
|
||||
type: "event_updated",
|
||||
event: updatedEvent,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(updatedEvent);
|
||||
})
|
||||
);
|
||||
@@ -244,6 +273,16 @@ router.delete(
|
||||
}
|
||||
|
||||
await Event.delete(req.params.id);
|
||||
|
||||
// Emit SSE event for event deletion
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic(`event_${req.params.id}`, "eventUpdate", {
|
||||
type: "event_deleted",
|
||||
eventId: req.params.id,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ msg: "Event deleted successfully" });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -63,6 +63,14 @@ router.post(
|
||||
// Invalidate posts cache
|
||||
invalidateCacheByPattern('/api/posts');
|
||||
|
||||
// Emit SSE event for new post
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic("posts", "newPost", {
|
||||
post,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
post,
|
||||
pointsAwarded: 5, // Standard post creation points
|
||||
@@ -132,6 +140,16 @@ router.put(
|
||||
|
||||
const updatedPost = await Post.addLike(req.params.id, req.user.id);
|
||||
|
||||
// Emit SSE event for post like
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic("posts", "postUpdate", {
|
||||
type: "post_liked",
|
||||
postId: req.params.id,
|
||||
likes: updatedPost.likes,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(updatedPost.likes);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const sseAuth = require("../middleware/sseAuth");
|
||||
const sseService = require("../services/sseService");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
/**
|
||||
* @route GET /api/sse/stream
|
||||
* @desc SSE stream endpoint
|
||||
* @access Private
|
||||
*/
|
||||
router.get("/stream", sseAuth, (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
||||
|
||||
// Send initial connection success message
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ userId, timestamp: new Date().toISOString() })}\n\n`);
|
||||
|
||||
// Register client
|
||||
sseService.addClient(userId, res);
|
||||
|
||||
// Send heartbeat every 30 seconds to keep connection alive
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
res.write(`:heartbeat\n\n`);
|
||||
} catch (error) {
|
||||
logger.error(`Heartbeat failed for user`, { userId, error: error.message });
|
||||
clearInterval(heartbeatInterval);
|
||||
sseService.removeClient(userId);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Handle client disconnect
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
sseService.removeClient(userId);
|
||||
logger.info(`SSE stream closed`, { userId });
|
||||
});
|
||||
|
||||
// Handle connection errors
|
||||
req.on("error", (error) => {
|
||||
logger.error(`SSE stream error`, { userId, error: error.message });
|
||||
clearInterval(heartbeatInterval);
|
||||
sseService.removeClient(userId);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /api/sse/subscribe
|
||||
* @desc Subscribe to SSE topics
|
||||
* @access Private
|
||||
*/
|
||||
router.post("/subscribe", sseAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { topics } = req.body;
|
||||
|
||||
// Validate topics
|
||||
if (!topics || !Array.isArray(topics) || topics.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
msg: "Topics must be a non-empty array"
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to topics
|
||||
const success = sseService.subscribe(userId, topics);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
msg: "User not connected to SSE stream"
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
msg: "Subscribed to topics",
|
||||
topics
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Subscribe error`, { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /api/sse/unsubscribe
|
||||
* @desc Unsubscribe from SSE topics
|
||||
* @access Private
|
||||
*/
|
||||
router.post("/unsubscribe", sseAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { topics } = req.body;
|
||||
|
||||
// Validate topics
|
||||
if (!topics || !Array.isArray(topics) || topics.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
msg: "Topics must be a non-empty array"
|
||||
});
|
||||
}
|
||||
|
||||
// Unsubscribe from topics
|
||||
const success = sseService.unsubscribe(userId, topics);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
msg: "User not connected to SSE stream"
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
msg: "Unsubscribed from topics",
|
||||
topics
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Unsubscribe error`, { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -177,6 +177,16 @@ router.put(
|
||||
}
|
||||
);
|
||||
|
||||
// Emit SSE event for street adoption
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic("streets", "streetUpdate", {
|
||||
type: "street_adopted",
|
||||
streetId: street._id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
street,
|
||||
pointsAwarded: 50,
|
||||
|
||||
@@ -71,6 +71,15 @@ router.post(
|
||||
description,
|
||||
});
|
||||
|
||||
// Emit SSE event for new task
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic("tasks", "taskUpdate", {
|
||||
type: "new_task",
|
||||
task,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}),
|
||||
);
|
||||
@@ -120,6 +129,15 @@ router.put(
|
||||
}
|
||||
);
|
||||
|
||||
// Emit SSE event for task completion
|
||||
const sse = req.app.get("sse");
|
||||
if (sse) {
|
||||
sse.broadcastToTopic("tasks", "taskUpdate", {
|
||||
type: "task_completed",
|
||||
task,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
task,
|
||||
pointsAwarded: task.pointsAwarded || 10,
|
||||
|
||||
Reference in New Issue
Block a user