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:
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+325
-246
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-85
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
@@ -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 ===');
|
||||
Reference in New Issue
Block a user