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 = {
|
const clientInfo = {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
adminName: null,
|
||||||
|
currentPage: null,
|
||||||
subscribedSessions: new Set(),
|
subscribedSessions: new Set(),
|
||||||
lastPing: Date.now()
|
lastPing: Date.now()
|
||||||
};
|
};
|
||||||
@@ -96,6 +98,15 @@ class WebSocketManager {
|
|||||||
clientInfo.lastPing = Date.now();
|
clientInfo.lastPing = Date.now();
|
||||||
this.send(ws, { type: 'pong' });
|
this.send(ws, { type: 'pong' });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'page_focus':
|
||||||
|
if (!clientInfo.authenticated) {
|
||||||
|
this.sendError(ws, 'Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clientInfo.currentPage = message.page || null;
|
||||||
|
this.broadcastPresence();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||||
@@ -117,7 +128,13 @@ class WebSocketManager {
|
|||||||
|
|
||||||
if (clientInfo) {
|
if (clientInfo) {
|
||||||
clientInfo.authenticated = true;
|
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, {
|
this.send(ws, {
|
||||||
type: 'auth_success',
|
type: 'auth_success',
|
||||||
@@ -283,9 +300,31 @@ class WebSocketManager {
|
|||||||
|
|
||||||
this.clients.delete(ws);
|
this.clients.delete(ws);
|
||||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
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
|
* Start heartbeat to detect dead connections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -125,3 +125,103 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
expect(res.status).toBe(401);
|
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