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
+226
View File
@@ -0,0 +1,226 @@
const request = require("supertest");
const { app, server } = require("../../server");
const User = require("../../models/User");
const sseService = require("../../services/sseService");
const jwt = require("jsonwebtoken");
const couchdbService = require("../../services/couchdbService");
describe("SSE Routes", () => {
let token;
let userId;
beforeAll(async () => {
await couchdbService.initialize();
});
beforeEach(async () => {
// Create a test user with unique email to avoid conflicts
const timestamp = Date.now();
const user = await User.create({
name: "SSE Test User",
username: `sseuser${timestamp}`,
email: `sse${timestamp}@test.com`,
password: "Password123!",
});
userId = user._id;
// Generate token
const payload = { user: { id: user._id } };
token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "1h" });
// Clear SSE service state
sseService.clients.clear();
sseService.topics.clear();
});
afterAll(async () => {
await couchdbService.shutdown();
server.close();
});
describe("POST /api/sse/subscribe", () => {
test("should require authentication", async () => {
const res = await request(app)
.post("/api/sse/subscribe")
.send({ topics: ["test"] });
expect(res.statusCode).toBe(401);
expect(res.body.success).toBe(false);
});
test("should subscribe to topics with valid token", async () => {
// First, add client to SSE service
const mockRes = { write: jest.fn() };
sseService.addClient(userId, mockRes);
const res = await request(app)
.post("/api/sse/subscribe")
.set("x-auth-token", token)
.send({ topics: ["events", "posts"] });
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.topics).toEqual(["events", "posts"]);
// Verify subscription in service
const stats = sseService.getStats();
expect(stats.topics).toHaveProperty("events");
expect(stats.topics).toHaveProperty("posts");
});
test("should fail if user not connected to SSE stream", async () => {
const res = await request(app)
.post("/api/sse/subscribe")
.set("x-auth-token", token)
.send({ topics: ["events"] });
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
test("should validate topics array", async () => {
const res = await request(app)
.post("/api/sse/subscribe")
.set("x-auth-token", token)
.send({ topics: "not-an-array" });
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
});
describe("POST /api/sse/unsubscribe", () => {
test("should require authentication", async () => {
const res = await request(app)
.post("/api/sse/unsubscribe")
.send({ topics: ["test"] });
expect(res.statusCode).toBe(401);
expect(res.body.success).toBe(false);
});
test("should unsubscribe from topics", async () => {
// Setup: Add client and subscribe
const mockRes = { write: jest.fn() };
sseService.addClient(userId, mockRes);
sseService.subscribe(userId, ["events", "posts"]);
// Unsubscribe from one topic
const res = await request(app)
.post("/api/sse/unsubscribe")
.set("x-auth-token", token)
.send({ topics: ["events"] });
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
// Verify unsubscription
const stats = sseService.getStats();
expect(stats.topics).not.toHaveProperty("events");
expect(stats.topics).toHaveProperty("posts");
});
test("should validate topics array", async () => {
const res = await request(app)
.post("/api/sse/unsubscribe")
.set("x-auth-token", token)
.send({ topics: null });
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
});
describe("SSE Service", () => {
test("should add and remove clients", () => {
const mockRes = { write: jest.fn() };
sseService.addClient(userId, mockRes);
let stats = sseService.getStats();
expect(stats.totalClients).toBe(1);
sseService.removeClient(userId);
stats = sseService.getStats();
expect(stats.totalClients).toBe(0);
});
test("should broadcast to all clients", () => {
const mockRes1 = { write: jest.fn() };
const mockRes2 = { write: jest.fn() };
sseService.addClient("user1", mockRes1);
sseService.addClient("user2", mockRes2);
sseService.broadcast("testEvent", { message: "Hello" });
expect(mockRes1.write).toHaveBeenCalledWith(
'event: testEvent\ndata: {"message":"Hello"}\n\n'
);
expect(mockRes2.write).toHaveBeenCalledWith(
'event: testEvent\ndata: {"message":"Hello"}\n\n'
);
});
test("should broadcast to topic subscribers only", () => {
const mockRes1 = { write: jest.fn() };
const mockRes2 = { write: jest.fn() };
sseService.addClient("user1", mockRes1);
sseService.addClient("user2", mockRes2);
sseService.subscribe("user1", ["events"]);
sseService.broadcastToTopic("events", "eventUpdate", { id: 1 });
expect(mockRes1.write).toHaveBeenCalled();
expect(mockRes2.write).not.toHaveBeenCalled();
});
test("should send to specific user", () => {
const mockRes = { write: jest.fn() };
sseService.addClient(userId, mockRes);
const success = sseService.sendToUser(userId, "notification", { text: "Test" });
expect(success).toBe(true);
expect(mockRes.write).toHaveBeenCalledWith(
'event: notification\ndata: {"text":"Test"}\n\n'
);
});
test("should return false when sending to non-existent user", () => {
const success = sseService.sendToUser("nonexistent", "test", {});
expect(success).toBe(false);
});
test("should get accurate stats", () => {
const mockRes1 = { write: jest.fn() };
const mockRes2 = { write: jest.fn() };
sseService.addClient("user1", mockRes1);
sseService.addClient("user2", mockRes2);
sseService.subscribe("user1", ["events", "posts"]);
sseService.subscribe("user2", ["events"]);
const stats = sseService.getStats();
expect(stats.totalClients).toBe(2);
expect(stats.totalTopics).toBe(2);
expect(stats.topics.events).toBe(2);
expect(stats.topics.posts).toBe(1);
});
test("should clean up topics when last subscriber leaves", () => {
const mockRes = { write: jest.fn() };
sseService.addClient(userId, mockRes);
sseService.subscribe(userId, ["events"]);
let stats = sseService.getStats();
expect(stats.totalTopics).toBe(1);
sseService.removeClient(userId);
stats = sseService.getStats();
expect(stats.totalTopics).toBe(0);
});
});
});
-395
View File
@@ -1,395 +0,0 @@
const request = require("supertest");
const socketIoClient = require("socket.io-client");
const jwt = require("jsonwebtoken");
const { createServer } = require("http");
const { Server } = require("socket.io");
// Create test server with Socket.IO
const createTestServer = () => {
const app = require("express")();
const server = createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
// Socket.IO authentication middleware
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error("Authentication error"));
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || "test_secret");
socket.userId = decoded.user.id;
next();
} catch (err) {
next(new Error("Authentication error"));
}
});
io.on("connection", (socket) => {
console.log("User connected:", socket.userId);
// Join event rooms
socket.on("joinEvent", (eventId) => {
socket.join(`event_${eventId}`);
socket.emit("joinedEvent", { eventId });
});
// Leave event rooms
socket.on("leaveEvent", (eventId) => {
socket.leave(`event_${eventId}`);
socket.emit("leftEvent", { eventId });
});
// Handle event updates
socket.on("eventUpdate", (data) => {
socket.to(`event_${data.eventId}`).emit("eventUpdate", data);
});
// Handle new posts
socket.on("newPost", (data) => {
socket.broadcast.emit("newPost", data);
});
// Handle task updates
socket.on("taskUpdate", (data) => {
socket.broadcast.emit("taskUpdate", data);
});
socket.on("disconnect", () => {
console.log("User disconnected:", socket.userId);
});
});
return { server, io };
};
describe("Socket.IO Real-time Features", () => {
let server;
let io;
let clientSocket;
let testUser;
let authToken;
beforeAll(async () => {
// Create test server
const testServer = createTestServer();
server = testServer.server;
io = testServer.io;
// Start server on random port
await new Promise((resolve) => {
server.listen(0, resolve);
});
// Create mock test user
testUser = {
_id: "test_user_123",
name: "Test User",
email: "test@example.com"
};
// Generate auth token
authToken = jwt.sign(
{ user: { id: testUser._id } },
process.env.JWT_SECRET || "test_secret"
);
});
afterAll(async () => {
if (clientSocket) {
clientSocket.disconnect();
}
io.close();
server.close();
});
beforeEach((done) => {
// Connect client socket with authentication
clientSocket = socketIoClient(`http://localhost:${server.address().port}`, {
auth: { token: authToken },
});
clientSocket.on("connect", () => {
done();
});
clientSocket.on("connect_error", (err) => {
done(err);
});
});
afterEach(() => {
if (clientSocket && clientSocket.connected) {
clientSocket.disconnect();
}
});
describe("Socket Authentication", () => {
test("should connect with valid token", (done) => {
expect(clientSocket.connected).toBe(true);
done();
});
test("should reject connection with invalid token", (done) => {
const invalidSocket = socketIoClient(
`http://localhost:${server.address().port}`,
{
auth: { token: "invalid_token" },
}
);
invalidSocket.on("connect_error", (err) => {
expect(err.message).toBe("Authentication error");
invalidSocket.disconnect();
done();
});
});
test("should reject connection without token", (done) => {
const noTokenSocket = socketIoClient(
`http://localhost:${server.address().port}`
);
noTokenSocket.on("connect_error", (err) => {
expect(err.message).toBe("Authentication error");
noTokenSocket.disconnect();
done();
});
});
});
describe("Event Participation", () => {
let testEvent;
beforeEach(() => {
testEvent = {
_id: "test_event_123",
title: "Test Event",
description: "Test Description",
date: new Date(Date.now() + 86400000), // Tomorrow
location: "Test Location",
participants: [],
};
});
test("should join event room", (done) => {
clientSocket.emit("joinEvent", testEvent._id);
clientSocket.on("joinedEvent", (data) => {
expect(data.eventId).toBe(testEvent._id);
done();
});
});
test("should receive event updates in room", (done) => {
clientSocket.emit("joinEvent", testEvent._id);
// Create another client to send updates to the room
const anotherClient = socketIoClient(`http://localhost:${server.address().port}`, {
auth: { token: authToken },
});
anotherClient.on("connect", () => {
// Listen for updates from first client
clientSocket.on("eventUpdate", (data) => {
expect(data.message).toBe("Event status updated to ongoing");
anotherClient.disconnect();
done();
});
// Join the same event room
anotherClient.emit("joinEvent", testEvent._id);
// Send update from second client (will be broadcast to room)
setTimeout(() => {
anotherClient.emit("eventUpdate", {
eventId: testEvent._id,
message: "Event status updated to ongoing",
});
}, 100);
});
});
test("should not receive updates for events not joined", (done) => {
const anotherEventId = "another_event_456";
// Listen for updates (should not receive any)
let updateReceived = false;
clientSocket.on("eventUpdate", () => {
updateReceived = true;
});
// Send update for event not joined
setTimeout(() => {
clientSocket.emit("eventUpdate", {
eventId: anotherEventId,
message: "This should not be received",
});
// Check after delay that no update was received
setTimeout(() => {
expect(updateReceived).toBe(false);
done();
}, 100);
}, 100);
});
});
describe("Post Interactions", () => {
let testPost;
let testEvent;
beforeEach(() => {
testPost = {
_id: "test_post_123",
user: {
userId: testUser._id,
name: testUser.name,
},
content: "Test post content",
likes: [],
commentsCount: 0,
};
testEvent = {
_id: "test_event_123",
title: "Test Event",
description: "Test Description",
date: new Date(Date.now() + 86400000),
location: "Test Location",
participants: [],
};
});
test("should broadcast new posts", (done) => {
// Create another client to receive broadcasts
const anotherClient = socketIoClient(`http://localhost:${server.address().port}`, {
auth: { token: authToken },
});
anotherClient.on("connect", () => {
// Listen for new posts
anotherClient.on("newPost", (data) => {
expect(data.content).toBe("Test broadcast post");
anotherClient.disconnect();
done();
});
// Send new post from first client
clientSocket.emit("newPost", {
content: "Test broadcast post",
user: testUser
});
});
});
test("should handle multiple event joins", (done) => {
const testEvent2 = {
_id: "test_event_456",
title: "Another Event",
description: "Another Description",
date: new Date(Date.now() + 86400000),
location: "Another Location",
participants: [],
};
let joinCount = 0;
const checkJoins = () => {
joinCount++;
if (joinCount === 2) {
done();
}
};
clientSocket.on("joinedEvent", (data) => {
checkJoins();
});
clientSocket.emit("joinEvent", testEvent._id);
clientSocket.emit("joinEvent", testEvent2._id);
});
});
describe("Connection Stability", () => {
test("should handle disconnection gracefully", (done) => {
// Simple test that disconnection doesn't throw errors
expect(() => {
clientSocket.disconnect();
}).not.toThrow();
setTimeout(() => {
expect(clientSocket.connected).toBe(false);
done();
}, 100);
});
test("should maintain connection under load", async () => {
const startTime = Date.now();
const messageCount = 50; // Reduced for test stability
for (let i = 0; i < messageCount; i++) {
await new Promise((resolve) => {
clientSocket.emit("eventUpdate", {
eventId: `test_event_${i}`,
message: `Test message ${i}`,
});
setTimeout(resolve, 10);
});
}
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete within reasonable time (less than 5 seconds)
expect(duration).toBeLessThan(5000);
expect(clientSocket.connected).toBe(true);
});
});
describe("Concurrent Connections", () => {
test("should handle multiple simultaneous connections", async () => {
const clients = [];
const connectionPromises = [];
// Create 10 concurrent connections
for (let i = 0; i < 10; i++) {
const promise = new Promise((resolve) => {
const client = socketIoClient(
`http://localhost:${server.address().port}`,
{
auth: { token: authToken },
}
);
client.on("connect", () => {
clients.push(client);
resolve();
});
client.on("connect_error", (err) => {
resolve(err);
});
});
connectionPromises.push(promise);
}
await Promise.all(connectionPromises);
// All connections should succeed
expect(clients.length).toBe(10);
clients.forEach((client) => {
expect(client.connected).toBe(true);
});
// Clean up
clients.forEach((client) => client.disconnect());
});
});
});