feat: WebSocket presence tracking with page_focus and presence_update
Made-with: Cursor
This commit is contained in:
@@ -32,6 +32,8 @@ class WebSocketManager {
|
||||
const clientInfo = {
|
||||
authenticated: false,
|
||||
userId: null,
|
||||
adminName: null,
|
||||
currentPage: null,
|
||||
subscribedSessions: new Set(),
|
||||
lastPing: Date.now()
|
||||
};
|
||||
@@ -96,6 +98,15 @@ class WebSocketManager {
|
||||
clientInfo.lastPing = Date.now();
|
||||
this.send(ws, { type: 'pong' });
|
||||
break;
|
||||
|
||||
case 'page_focus':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
clientInfo.currentPage = message.page || null;
|
||||
this.broadcastPresence();
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||
@@ -117,7 +128,13 @@ class WebSocketManager {
|
||||
|
||||
if (clientInfo) {
|
||||
clientInfo.authenticated = true;
|
||||
clientInfo.userId = decoded.role; // 'admin' for now
|
||||
clientInfo.userId = decoded.role;
|
||||
clientInfo.adminName = decoded.name || null;
|
||||
|
||||
if (!decoded.name) {
|
||||
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.send(ws, {
|
||||
type: 'auth_success',
|
||||
@@ -283,9 +300,31 @@ class WebSocketManager {
|
||||
|
||||
this.clients.delete(ws);
|
||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
||||
this.broadcastPresence();
|
||||
}
|
||||
}
|
||||
|
||||
broadcastPresence() {
|
||||
const admins = [];
|
||||
this.clients.forEach((info) => {
|
||||
if (info.authenticated && info.adminName && info.currentPage) {
|
||||
admins.push({ name: info.adminName, page: info.currentPage });
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
type: 'presence_update',
|
||||
timestamp: new Date().toISOString(),
|
||||
admins
|
||||
};
|
||||
|
||||
this.clients.forEach((info, ws) => {
|
||||
if (info.authenticated && ws.readyState === ws.OPEN) {
|
||||
this.send(ws, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat to detect dead connections
|
||||
*/
|
||||
|
||||
@@ -125,3 +125,103 @@ describe('POST /api/auth/login — named admins', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const http = require('http');
|
||||
|
||||
describe('WebSocket presence', () => {
|
||||
let server, wsUrl;
|
||||
|
||||
beforeAll((done) => {
|
||||
process.env.ADMIN_KEY = 'test-admin-key';
|
||||
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
|
||||
jest.resetModules();
|
||||
const { app } = require('../../backend/server');
|
||||
const { WebSocketManager, setWebSocketManager } = require('../../backend/utils/websocket-manager');
|
||||
|
||||
server = http.createServer(app);
|
||||
const wsManager = new WebSocketManager(server);
|
||||
setWebSocketManager(wsManager);
|
||||
|
||||
server.listen(0, () => {
|
||||
const port = server.address().port;
|
||||
wsUrl = `ws://localhost:${port}/api/sessions/live`;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
function makeToken(name) {
|
||||
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
function connectAndAuth(name) {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'auth_success') {
|
||||
resolve(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMessage(ws, type) {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === type) {
|
||||
ws.off('message', handler);
|
||||
resolve(msg);
|
||||
}
|
||||
};
|
||||
ws.on('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
test('page_focus triggers presence_update with admin name and page', async () => {
|
||||
const ws1 = await connectAndAuth('Alice');
|
||||
const ws2 = await connectAndAuth('Bob');
|
||||
|
||||
const presencePromise = waitForMessage(ws2, 'presence_update');
|
||||
|
||||
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||
|
||||
const msg = await presencePromise;
|
||||
expect(msg.admins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Alice', page: '/history' })
|
||||
])
|
||||
);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
|
||||
test('disconnect removes admin from presence', async () => {
|
||||
const ws1 = await connectAndAuth('Alice');
|
||||
const ws2 = await connectAndAuth('Bob');
|
||||
|
||||
ws1.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||
ws2.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const presencePromise = waitForMessage(ws2, 'presence_update');
|
||||
ws1.close();
|
||||
|
||||
const msg = await presencePromise;
|
||||
const names = msg.admins.map(a => a.name);
|
||||
expect(names).not.toContain('Alice');
|
||||
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user