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:
William Valentin
2025-12-05 22:49:22 -08:00
parent b5ee7571c9
commit bb9c8ec1c3
571 changed files with 156739 additions and 1350 deletions
+8 -8
View File
@@ -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,
});
+39
View File
@@ -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" });
})
);
+18
View File
@@ -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);
}),
);
+137
View File
@@ -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;
+10
View File
@@ -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,
+18
View File
@@ -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,