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
+2 -2
View File
@@ -1,5 +1,5 @@
# Multi-stage build for multi-architecture support (AMD64, ARM64)
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
FROM --platform=$BUILDPLATFORM node:20-alpine3.20 AS builder
WORKDIR /app
@@ -13,7 +13,7 @@ RUN npm ci --production
COPY . .
# --- Production stage ---
FROM --platform=$TARGETPLATFORM node:20-alpine
FROM --platform=$TARGETPLATFORM node:20-alpine3.20
# Install curl for health checks and other utilities
RUN apk add --no-cache curl wget
+377
View File
@@ -0,0 +1,377 @@
# Server-Sent Events (SSE) Infrastructure
This document describes the SSE implementation for real-time server-to-client communication in the Adopt-a-Street backend.
## Overview
Server-Sent Events (SSE) provides a unidirectional real-time communication channel from server to client over HTTP. Unlike WebSockets, SSE is built on standard HTTP and automatically handles reconnection.
## Architecture
### Components
1. **SSE Service** (`services/sseService.js`)
- Manages client connections and topic subscriptions
- Provides methods for broadcasting and targeted messaging
- Handles automatic cleanup on disconnect
2. **SSE Auth Middleware** (`middleware/sseAuth.js`)
- JWT authentication for SSE connections
- Supports token from query string, Authorization header, or x-auth-token header
3. **SSE Routes** (`routes/sse.js`)
- `/api/sse/stream` - SSE stream endpoint
- `/api/sse/subscribe` - Subscribe to topics
- `/api/sse/unsubscribe` - Unsubscribe from topics
## API Endpoints
### GET /api/sse/stream
Opens an SSE connection for the authenticated user.
**Authentication:** Required (JWT via query parameter `?token=xxx` or Authorization header)
**Response:** SSE stream with events
**Example:**
```javascript
const token = localStorage.getItem('token');
const eventSource = new EventSource(`/api/sse/stream?token=${token}`);
eventSource.addEventListener('connected', (e) => {
console.log('Connected:', JSON.parse(e.data));
});
eventSource.addEventListener('notification', (e) => {
const data = JSON.parse(e.data);
console.log('Notification:', data);
});
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
};
```
### POST /api/sse/subscribe
Subscribe to one or more topics.
**Authentication:** Required
**Body:**
```json
{
"topics": ["events", "posts", "notifications"]
}
```
**Response:**
```json
{
"success": true,
"msg": "Subscribed to topics",
"topics": ["events", "posts", "notifications"]
}
```
### POST /api/sse/unsubscribe
Unsubscribe from topics.
**Authentication:** Required
**Body:**
```json
{
"topics": ["events"]
}
```
**Response:**
```json
{
"success": true,
"msg": "Unsubscribed from topics",
"topics": ["events"]
}
```
## SSE Service Methods
### Client Management
```javascript
const sseService = require('./services/sseService');
// Add a client (called automatically by /stream endpoint)
sseService.addClient(userId, res);
// Remove a client (called automatically on disconnect)
sseService.removeClient(userId);
```
### Topic Subscriptions
```javascript
// Subscribe to topics
sseService.subscribe(userId, ['events', 'posts']);
// Unsubscribe from topics
sseService.unsubscribe(userId, ['events']);
```
### Broadcasting Messages
```javascript
// Broadcast to all connected clients
sseService.broadcast('announcement', {
message: 'System maintenance in 10 minutes'
});
// Broadcast to topic subscribers
sseService.broadcastToTopic('events', 'eventUpdate', {
eventId: 123,
status: 'started'
});
// Send to specific user
sseService.sendToUser(userId, 'notification', {
text: 'You have a new message',
priority: 'high'
});
```
### Statistics
```javascript
// Get service statistics
const stats = sseService.getStats();
// Returns: { totalClients: 5, totalTopics: 3, topics: { events: 3, posts: 2, notifications: 5 } }
```
## Usage in Routes
You can access the SSE service from any route handler:
```javascript
router.post('/events', auth, async (req, res) => {
try {
// Create event...
const event = await Event.create(req.body);
// Notify subscribers via SSE
const sse = req.app.get('sse');
sse.broadcastToTopic('events', 'newEvent', {
eventId: event._id,
title: event.title,
date: event.date
});
res.json({ success: true, event });
} catch (error) {
res.status(500).json({ success: false, msg: 'Server error' });
}
});
```
## Event Types
SSE messages are sent with specific event types. Clients can listen for specific event types:
```javascript
eventSource.addEventListener('newEvent', (e) => {
// Handle new event
});
eventSource.addEventListener('eventUpdate', (e) => {
// Handle event update
});
eventSource.addEventListener('notification', (e) => {
// Handle notification
});
```
## Common Topics
Recommended topic names for consistency:
- `events` - Event creation, updates, deletions
- `posts` - Social feed posts
- `tasks` - Task updates
- `notifications` - User notifications
- `rewards` - Badge and reward notifications
- `leaderboard` - Leaderboard changes
## Message Format
SSE messages follow this format:
```
event: eventType
data: {"key": "value"}
```
The SSE service automatically formats messages correctly.
## Connection Management
- **Heartbeat:** The server sends a heartbeat comment (`:heartbeat`) every 30 seconds to keep the connection alive
- **Auto-reconnect:** Browsers automatically reconnect if the connection is lost
- **Cleanup:** Clients are automatically removed when the connection closes
## Health Monitoring
SSE statistics are included in the `/api/health` endpoint:
```json
{
"status": "healthy",
"services": {
"sse": {
"totalClients": 5,
"totalTopics": 3
}
}
}
```
## Testing
Run SSE tests:
```bash
npm test -- __tests__/routes/sse.test.js
```
Demo script:
```bash
node test-sse-demo.js
```
## Security
- All SSE connections require JWT authentication
- Tokens can be passed via query string (for EventSource compatibility) or headers
- Connections are validated on connect; invalid tokens receive 401 Unauthorized
- Each user can only have one active SSE connection (new connections replace old ones)
## Performance Considerations
- SSE uses HTTP/1.1 long-polling, so each connection uses one HTTP connection
- Browser limit: ~6 connections per domain (HTTP/1.1)
- For high-concurrency scenarios, consider using HTTP/2 (multiplexing) or WebSockets
- The service is designed for low-memory usage with Map-based storage
## Comparison with Socket.IO
The backend supports both SSE and Socket.IO:
| Feature | SSE | Socket.IO |
|---------|-----|-----------|
| Direction | Server → Client | Bidirectional |
| Protocol | HTTP | WebSocket + HTTP fallback |
| Browser Support | All modern browsers | All browsers |
| Auto-reconnect | Built-in | Built-in |
| Use Case | Server push notifications | Real-time chat, collaboration |
**When to use SSE:**
- Server needs to push updates to clients
- Unidirectional communication is sufficient
- Simpler setup and infrastructure
**When to use Socket.IO:**
- Bidirectional real-time communication needed
- Complex event patterns
- Existing Socket.IO infrastructure
## Example: Complete Client Implementation
```javascript
class SSEClient {
constructor(token) {
this.token = token;
this.eventSource = null;
this.connected = false;
}
connect() {
this.eventSource = new EventSource(`/api/sse/stream?token=${this.token}`);
this.eventSource.addEventListener('connected', (e) => {
console.log('SSE Connected:', JSON.parse(e.data));
this.connected = true;
this.subscribeToTopics(['events', 'notifications']);
});
this.eventSource.addEventListener('newEvent', (e) => {
const event = JSON.parse(e.data);
this.handleNewEvent(event);
});
this.eventSource.addEventListener('notification', (e) => {
const notification = JSON.parse(e.data);
this.handleNotification(notification);
});
this.eventSource.onerror = (error) => {
console.error('SSE Error:', error);
this.connected = false;
};
}
async subscribeToTopics(topics) {
const response = await fetch('/api/sse/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': this.token
},
body: JSON.stringify({ topics })
});
const data = await response.json();
console.log('Subscribed:', data);
}
handleNewEvent(event) {
console.log('New event:', event);
// Update UI with new event
}
handleNotification(notification) {
console.log('Notification:', notification);
// Show notification to user
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.connected = false;
}
}
}
// Usage
const sseClient = new SSEClient(localStorage.getItem('token'));
sseClient.connect();
```
## Troubleshooting
### Connection not established
- Check that JWT token is valid and not expired
- Verify token is passed correctly (query param or header)
- Check browser console for CORS errors
### Not receiving messages
- Verify client is subscribed to the correct topics
- Check that server is broadcasting to the correct topic
- Ensure client is still connected (check `eventSource.readyState`)
### High memory usage
- Review the number of connected clients
- Check for memory leaks in client connection handlers
- Monitor SSE stats via `/api/health` endpoint
+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());
});
});
});
-30
View File
@@ -1,30 +0,0 @@
const jwt = require("jsonwebtoken");
/**
* Socket.IO Authentication Middleware
* Verifies JWT token before allowing socket connections
*/
const socketAuth = (socket, next) => {
try {
// Get token from handshake auth or query
const token =
socket.handshake.auth.token || socket.handshake.query.token;
if (!token) {
return next(new Error("Authentication error: No token provided"));
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach user data to socket
socket.user = decoded.user;
next();
} catch (err) {
console.error("Socket authentication error:", err.message);
return next(new Error("Authentication error: Invalid token"));
}
};
module.exports = socketAuth;
+42
View File
@@ -0,0 +1,42 @@
const jwt = require("jsonwebtoken");
/**
* SSE Authentication Middleware
* Supports token from query string (for SSE connections) or Authorization header
*/
module.exports = function (req, res, next) {
let token;
// Try to get token from query string (for SSE EventSource connections)
if (req.query.token) {
token = req.query.token;
}
// Try to get token from Authorization header (Bearer token)
else if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ")) {
token = req.headers.authorization.substring(7);
}
// Try to get token from x-auth-token header (legacy support)
else if (req.header("x-auth-token")) {
token = req.header("x-auth-token");
}
// Check if no token found
if (!token) {
return res.status(401).json({
success: false,
msg: "No token, authorization denied"
});
}
// Verify token
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded.user;
next();
} catch (err) {
return res.status(401).json({
success: false,
msg: "Token is not valid"
});
}
};
+325 -246
View File
@@ -24,7 +24,6 @@
"multer": "^1.4.5-lts.1",
"nano": "^10.1.4",
"node-cache": "^5.1.2",
"socket.io": "^4.8.1",
"stripe": "^17.7.0",
"xss-clean": "^0.1.4"
},
@@ -34,7 +33,6 @@
"eslint": "^9.38.0",
"jest": "^30.2.0",
"jest-environment-node": "^30.2.0",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4"
}
},
@@ -63,6 +61,7 @@
"version": "7.28.5",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -530,6 +529,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
"integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"dev": true,
@@ -1320,6 +1353,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"dev": true,
@@ -1380,9 +1426,16 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"license": "MIT"
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -1421,13 +1474,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
@@ -1498,6 +1544,188 @@
"dev": true,
"license": "ISC"
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
"integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.11.1",
"cpu": [
@@ -1522,6 +1750,65 @@
"linux"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.11"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/accepts": {
"version": "1.3.8",
"license": "MIT",
@@ -1537,6 +1824,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1729,13 +2017,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.23",
"dev": true,
@@ -1822,6 +2103,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2368,85 +2650,6 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/debug/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/error-ex": {
"version": "1.3.4",
"dev": true,
@@ -2519,6 +2722,7 @@
"version": "9.39.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3005,6 +3209,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -5096,128 +5315,6 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/debug/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/debug/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/debug/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"dev": true,
@@ -5580,6 +5677,14 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -5859,32 +5964,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xss-clean": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/xss-clean/-/xss-clean-0.1.4.tgz",
-2
View File
@@ -32,7 +32,6 @@
"multer": "^1.4.5-lts.1",
"nano": "^10.1.4",
"node-cache": "^5.1.2",
"socket.io": "^4.8.1",
"stripe": "^17.7.0",
"xss-clean": "^0.1.4"
},
@@ -42,7 +41,6 @@
"eslint": "^9.38.0",
"jest": "^30.2.0",
"jest-environment-node": "^30.2.0",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4"
}
}
+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,
+13 -85
View File
@@ -1,15 +1,14 @@
require("dotenv").config();
const express = require("express");
const couchdbService = require("./services/couchdbService");
const sseService = require("./services/sseService");
const cors = require("cors");
const http = require("http");
const socketio = require("socket.io");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const mongoSanitize = require("express-mongo-sanitize");
const xss = require("xss-clean");
const { errorHandler } = require("./middleware/errorHandler");
const socketAuth = require("./middleware/socketAuth");
const requestLogger = require("./middleware/requestLogger");
const logger = require("./utils/logger");
const { validateEnv, logEnvConfig } = require("./utils/validateEnv");
@@ -26,13 +25,6 @@ try {
const app = express();
const server = http.createServer(app);
const io = socketio(server, {
cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000",
methods: ["GET", "POST"],
credentials: true,
},
});
const port = process.env.PORT || 5000;
// Trust proxy - required when behind ingress/reverse proxy
@@ -101,34 +93,8 @@ if (process.env.NODE_ENV !== 'test') {
});
}
// Socket.IO Authentication Middleware
io.use(socketAuth);
// Socket.IO Setup with Authentication
io.on("connection", (socket) => {
logger.info(`Socket.IO client connected`, { userId: socket.user.id });
socket.on("joinEvent", (eventId) => {
socket.join(`event_${eventId}`);
logger.debug(`User joined event`, { userId: socket.user.id, eventId });
});
socket.on("joinPost", (postId) => {
socket.join(`post_${postId}`);
logger.debug(`User joined post`, { userId: socket.user.id, postId });
});
socket.on("eventUpdate", (data) => {
io.to(`event_${data.eventId}`).emit("update", data.message);
});
socket.on("disconnect", () => {
logger.info(`Socket.IO client disconnected`, { userId: socket.user.id });
});
});
// Make io available to routes
app.set("io", io);
// Make sse available to routes
app.set("sse", sseService);
// Routes
const authRoutes = require("./routes/auth");
@@ -147,6 +113,7 @@ const cacheRoutes = require("./routes/cache");
const profileRoutes = require("./routes/profile");
const analyticsRoutes = require("./routes/analytics");
const leaderboardRoutes = require("./routes/leaderboard");
const sseRoutes = require("./routes/sse");
// Apply rate limiters
app.use("/api/auth/register", authLimiter);
@@ -158,15 +125,10 @@ app.get("/api/health", async (req, res) => {
try {
const couchdbStatus = await couchdbService.checkConnection();
// Check Socket.IO status
const socketIOStatus = {
engine: io.engine ? "running" : "stopped",
connectedClients: io.engine ? io.engine.clientsCount : 0,
// Get number of connected sockets
sockets: io.sockets ? io.sockets.sockets.size : 0
};
// Get SSE stats
const sseStats = sseService.getStats();
const isHealthy = couchdbStatus && io.engine;
const isHealthy = couchdbStatus;
res.status(isHealthy ? 200 : 503).json({
status: isHealthy ? "healthy" : "degraded",
@@ -174,10 +136,9 @@ app.get("/api/health", async (req, res) => {
uptime: process.uptime(),
services: {
couchdb: couchdbStatus ? "connected" : "disconnected",
socketIO: {
status: socketIOStatus.engine,
connectedClients: socketIOStatus.connectedClients,
activeSockets: socketIOStatus.sockets
sse: {
totalClients: sseStats.totalClients,
totalTopics: sseStats.totalTopics
}
},
memory: {
@@ -193,47 +154,13 @@ app.get("/api/health", async (req, res) => {
uptime: process.uptime(),
services: {
couchdb: "disconnected",
socketIO: "unknown"
sse: "unknown"
},
error: error.message,
});
}
});
// Detailed Socket.IO health check endpoint
app.get("/api/health/socketio", (req, res) => {
try {
const socketIOInfo = {
status: io.engine ? "running" : "stopped",
connectedClients: io.engine ? io.engine.clientsCount : 0,
activeSockets: io.sockets ? io.sockets.sockets.size : 0,
rooms: [],
timestamp: new Date().toISOString()
};
// Get list of active rooms (excluding auto-generated socket ID rooms)
if (io.sockets && io.sockets.adapter && io.sockets.adapter.rooms) {
const rooms = Array.from(io.sockets.adapter.rooms.keys()).filter(room => {
// Filter out socket ID rooms (they start with socket ID pattern)
return room.startsWith('event_') || room.startsWith('post_');
});
socketIOInfo.rooms = rooms.map(room => {
const roomSize = io.sockets.adapter.rooms.get(room)?.size || 0;
return { name: room, members: roomSize };
});
}
res.status(200).json(socketIOInfo);
} catch (error) {
res.status(500).json({
status: "error",
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// Routes
app.use("/api/auth", authRoutes);
app.use("/api/streets", streetRoutes);
@@ -251,6 +178,7 @@ app.use("/api/cache", cacheRoutes);
app.use("/api/profile", profileRoutes);
app.use("/api/analytics", analyticsRoutes);
app.use("/api/leaderboard", leaderboardRoutes);
app.use("/api/sse", sseRoutes);
app.get("/", (req, res) => {
res.send("Street Adoption App Backend");
@@ -267,7 +195,7 @@ if (require.main === module) {
}
// Export app and server for testing
module.exports = { app, server, io };
module.exports = { app, server };
// Graceful shutdown
process.on("SIGTERM", async () => {
+216
View File
@@ -0,0 +1,216 @@
const logger = require("../utils/logger");
/**
* SSE Service for Server-Sent Events
* Manages SSE connections, topic subscriptions, and broadcasting
*/
class SSEService {
constructor() {
// Map of userId -> {res: Response, topics: Set<string>}
this.clients = new Map();
// Map of topicName -> Set<userId>
this.topics = new Map();
}
/**
* Add a client connection
* @param {string} userId - User ID
* @param {Response} res - Express response object
*/
addClient(userId, res) {
// Remove existing client if any
this.removeClient(userId);
this.clients.set(userId, {
res,
topics: new Set(),
});
logger.info(`SSE client added`, { userId, totalClients: this.clients.size });
}
/**
* Remove a client connection
* @param {string} userId - User ID
*/
removeClient(userId) {
const client = this.clients.get(userId);
if (!client) {
return;
}
// Unsubscribe from all topics
client.topics.forEach((topic) => {
const topicSubscribers = this.topics.get(topic);
if (topicSubscribers) {
topicSubscribers.delete(userId);
if (topicSubscribers.size === 0) {
this.topics.delete(topic);
}
}
});
this.clients.delete(userId);
logger.info(`SSE client removed`, { userId, totalClients: this.clients.size });
}
/**
* Subscribe a user to topics
* @param {string} userId - User ID
* @param {string[]} topicList - Array of topic names
*/
subscribe(userId, topicList) {
const client = this.clients.get(userId);
if (!client) {
logger.warn(`Cannot subscribe: client not found`, { userId });
return false;
}
topicList.forEach((topic) => {
// Add to client's topics
client.topics.add(topic);
// Add to topic's subscribers
if (!this.topics.has(topic)) {
this.topics.set(topic, new Set());
}
this.topics.get(topic).add(userId);
});
logger.info(`User subscribed to topics`, { userId, topics: topicList });
return true;
}
/**
* Unsubscribe a user from topics
* @param {string} userId - User ID
* @param {string[]} topicList - Array of topic names
*/
unsubscribe(userId, topicList) {
const client = this.clients.get(userId);
if (!client) {
logger.warn(`Cannot unsubscribe: client not found`, { userId });
return false;
}
topicList.forEach((topic) => {
// Remove from client's topics
client.topics.delete(topic);
// Remove from topic's subscribers
const topicSubscribers = this.topics.get(topic);
if (topicSubscribers) {
topicSubscribers.delete(userId);
if (topicSubscribers.size === 0) {
this.topics.delete(topic);
}
}
});
logger.info(`User unsubscribed from topics`, { userId, topics: topicList });
return true;
}
/**
* Broadcast an event to all connected clients
* @param {string} eventType - Event type
* @param {object} data - Event data
*/
broadcast(eventType, data) {
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
let sentCount = 0;
this.clients.forEach((client, userId) => {
try {
client.res.write(message);
sentCount++;
} catch (error) {
logger.error(`Failed to send SSE to user`, { userId, error: error.message });
this.removeClient(userId);
}
});
logger.debug(`Broadcast event`, { eventType, recipients: sentCount });
}
/**
* Broadcast an event to all subscribers of a topic
* @param {string} topic - Topic name
* @param {string} eventType - Event type
* @param {object} data - Event data
*/
broadcastToTopic(topic, eventType, data) {
const subscribers = this.topics.get(topic);
if (!subscribers || subscribers.size === 0) {
logger.debug(`No subscribers for topic`, { topic });
return;
}
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
let sentCount = 0;
subscribers.forEach((userId) => {
const client = this.clients.get(userId);
if (!client) {
return;
}
try {
client.res.write(message);
sentCount++;
} catch (error) {
logger.error(`Failed to send SSE to user`, { userId, error: error.message });
this.removeClient(userId);
}
});
logger.debug(`Broadcast to topic`, { topic, eventType, recipients: sentCount });
}
/**
* Send an event to a specific user
* @param {string} userId - User ID
* @param {string} eventType - Event type
* @param {object} data - Event data
*/
sendToUser(userId, eventType, data) {
const client = this.clients.get(userId);
if (!client) {
logger.debug(`User not connected`, { userId });
return false;
}
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
try {
client.res.write(message);
logger.debug(`Sent event to user`, { userId, eventType });
return true;
} catch (error) {
logger.error(`Failed to send SSE to user`, { userId, error: error.message });
this.removeClient(userId);
return false;
}
}
/**
* Get service statistics
* @returns {object} Stats object
*/
getStats() {
const topicStats = {};
this.topics.forEach((subscribers, topic) => {
topicStats[topic] = subscribers.size;
});
return {
totalClients: this.clients.size,
totalTopics: this.topics.size,
topics: topicStats,
};
}
}
// Export singleton instance
module.exports = new SSEService();
+76
View File
@@ -0,0 +1,76 @@
/**
* SSE Demo Script
* Demonstrates the SSE service functionality
*/
const sseService = require('./services/sseService');
console.log('=== SSE Service Demo ===\n');
// Mock response objects
const createMockRes = (userId) => ({
write: (data) => console.log(`[${userId}] Received:`, data.trim()),
});
// 1. Add clients
console.log('1. Adding clients...');
const res1 = createMockRes('user1');
const res2 = createMockRes('user2');
const res3 = createMockRes('user3');
sseService.addClient('user1', res1);
sseService.addClient('user2', res2);
sseService.addClient('user3', res3);
let stats = sseService.getStats();
console.log('Stats after adding clients:', stats);
console.log('');
// 2. Subscribe to topics
console.log('2. Subscribing to topics...');
sseService.subscribe('user1', ['events', 'posts']);
sseService.subscribe('user2', ['events']);
sseService.subscribe('user3', ['posts']);
stats = sseService.getStats();
console.log('Stats after subscriptions:', stats);
console.log('');
// 3. Broadcast to all
console.log('3. Broadcasting to all clients...');
sseService.broadcast('announcement', { message: 'Hello everyone!' });
console.log('');
// 4. Broadcast to topic
console.log('4. Broadcasting to "events" topic (user1, user2)...');
sseService.broadcastToTopic('events', 'eventUpdate', { eventId: 123, status: 'started' });
console.log('');
console.log('5. Broadcasting to "posts" topic (user1, user3)...');
sseService.broadcastToTopic('posts', 'newPost', { postId: 456, author: 'Alice' });
console.log('');
// 6. Send to specific user
console.log('6. Sending to specific user (user2)...');
sseService.sendToUser('user2', 'notification', { text: 'You have a new message' });
console.log('');
// 7. Unsubscribe
console.log('7. Unsubscribing user1 from "events"...');
sseService.unsubscribe('user1', ['events']);
stats = sseService.getStats();
console.log('Stats after unsubscribe:', stats);
console.log('');
// 8. Remove client
console.log('8. Removing user3...');
sseService.removeClient('user3');
stats = sseService.getStats();
console.log('Stats after removing user3:', stats);
console.log('');
// Clean up
sseService.removeClient('user1');
sseService.removeClient('user2');
console.log('=== Demo Complete ===');