wip provider server command endpoints

This commit is contained in:
Thomas Ramé
2026-03-05 12:11:26 +01:00
parent 3e45193a7c
commit da4d323144
5 changed files with 134 additions and 50 deletions
@@ -15,6 +15,7 @@ console.error = vi.fn();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
import { handleRelayServerConnection, getRelayRoom } from '@/servers/relayServer';
const apiEndpoint = '/collaboration/api/get-connections/';
@@ -205,6 +206,42 @@ describe('Server Tests', () => {
});
});
test('GET /collaboration/api/get-connections?room=[ROOM_ID] returns connection info for encrypted relay room', async () => {
const roomId = 'relay-test-room';
// Create a mock WebSocket that the relay server can register
const mockWs = {
binaryType: 'nodebuffer',
readyState: 1, // OPEN
on: vi.fn(),
off: vi.fn(),
send: vi.fn(),
close: vi.fn(),
ping: vi.fn(),
removeAllListeners: vi.fn(),
};
// Register the mock connection in the relay server
await handleRelayServerConnection(mockWs as any, roomId);
const room = getRelayRoom(roomId);
expect(room).toBeDefined();
expect(room!.size).toBe(1);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=${roomId}&sessionKey=any-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 1,
exists: false,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { closeRelayConnections } from '@/servers/relayServer';
import { logger } from '@/utils';
type ResetConnectionsRequestQuery = {
@@ -18,46 +19,42 @@ export const collaborationResetConnectionsHandler = (
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
res.status(500).json({ error: 'not implemented yet' });
hocuspocusServer;
/**
* If no user ID is provided, close all connections in the room
*
* Below we avoid database call to check if the room if for encrypted server or not (could be switching to the other), so looking in both lists
*/
if (!userId) {
// hocuspocusServer.hocuspocus.closeConnections(room);
// const doc = getYDoc(room);
// if (doc) {
// doc.conns.forEach((_, conn) => closeConn(doc, conn));
// }
hocuspocusServer.hocuspocus.closeConnections(room);
closeRelayConnections(room);
} else {
const targetUserId = Array.isArray(userId) ? userId[0] : userId;
/**
* Close connections for the user in the room
* Close connections for the user in the room (hocuspocus)
*/
// hocuspocusServer.hocuspocus.documents.forEach((doc) => {
// if (doc.name !== room) {
// return;
// }
// doc.getConnections().forEach((connection) => {
// // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
// if (connection.context.userId === userId) {
// connection.close();
// }
// });
// });
// const doc = getYDoc(room);
// if (doc) {
// doc.conns.forEach((clientIds, conn) => {
// // TODO: with this current implementation there is no logic about user ID but only also "clientID"
// // ... it should be adapted first as for hocuspocus before having this metadata
// // closeConn(doc, conn)
// });
// }
hocuspocusServer.hocuspocus.documents.forEach((doc) => {
if (doc.name !== room) {
return;
}
doc.getConnections().forEach((connection) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (connection.context.userId === targetUserId) {
connection.close();
}
});
});
/**
* Close connections for the user in the room (relay)
*/
closeRelayConnections(room, targetUserId);
}
// res.status(200).json({ message: 'Connections reset' });
res.status(200).json({ message: 'Connections reset' });
};
@@ -87,7 +87,7 @@ export const collaborationWSHandler = async (
// mimick the Hocuspocus protocol to properly hide the frontend loader
ws.send('system:authenticated');
await handleRelayServerConnection(ws, roomId);
await handleRelayServerConnection(ws, roomId, userId);
} else {
hocuspocusServer.hocuspocus.handleConnection(ws, req, {
roomId: roomId,
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { getRelayRoom } from '@/servers/relayServer';
import { logger } from '@/utils';
type getDocumentConnectionInfoRequestQuery = {
@@ -17,37 +18,52 @@ export const getDocumentConnectionInfoHandler = (
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
if (!req.query.sessionKey) {
res.status(400).json({ error: 'Session key not provided' });
return;
}
logger('Getting document connection info for room:', room);
res.status(500).json({ error: 'not implemented yet' });
// we avoid database call since a document could be being encrypted or not, so looking in both lists
// so check hocuspocus first (non-encrypted documents)
const hocuspocusRoom = hocuspocusServer.hocuspocus.documents.get(room);
hocuspocusServer;
sessionKey;
if (hocuspocusRoom) {
const connections = hocuspocusRoom
.getConnections()
.filter((connection) => connection.readOnly === false);
// const roomInfo = hocuspocusServer.hocuspocus.documents.get(room);
res.status(200).json({
count: connections.length,
exists: connections.some(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(connection) => connection.context.sessionKey === sessionKey,
),
});
// if (!roomInfo) {
// logger('Room not found:', room);
// res.status(404).json({ error: 'Room not found' });
// return;
// }
// const connections = roomInfo
// .getConnections()
// .filter((connection) => connection.readOnly === false);
return;
}
// res.status(200).json({
// count: connections.length,
// exists: connections.some(
// // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
// (connection) => connection.context.sessionKey === sessionKey,
// ),
// });
const relayRoom = getRelayRoom(room);
if (relayRoom) {
// the relay server is a blind passthrough, there is no readOnly distinction
// or session key tracking, so we report all connections and cannot confirm session existence
res.status(200).json({
count: relayRoom.size,
exists: false,
});
return;
}
logger('Room not found:', room);
res.status(404).json({ error: 'Room not found' });
};
@@ -3,6 +3,7 @@ import * as ws from 'ws';
const rooms = new Map<string, Set<ws.WebSocket>>();
const roomsMutex = new Mutex();
const connectionMeta = new Map<ws.WebSocket, { userId: string | null }>();
function sendMessage(ws: ws.WebSocket, data: ws.RawData) {
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
@@ -16,9 +17,39 @@ function sendMessage(ws: ws.WebSocket, data: ws.RawData) {
}
}
export function getRelayRoom(roomId: string): Set<ws.WebSocket> | undefined {
return rooms.get(roomId);
}
export function closeRelayConnections(
roomId: string,
userId?: string,
): void {
const room = rooms.get(roomId);
if (!room) {
return;
}
if (!userId) {
for (const peer of Array.from(room)) {
peer.close();
}
} else {
for (const peer of Array.from(room)) {
const meta = connectionMeta.get(peer);
if (meta?.userId === userId) {
peer.close();
}
}
}
}
export async function handleRelayServerConnection(
ws: ws.WebSocket,
roomId: string,
userId?: string | null,
) {
ws.binaryType = 'arraybuffer'; // Same configuration in the client provider
@@ -38,6 +69,8 @@ export async function handleRelayServerConnection(
roomsMutexRelease();
}
connectionMeta.set(ws, { userId: userId ?? null });
ws.on('error', () => {
ws.close();
});
@@ -80,6 +113,7 @@ export async function handleRelayServerConnection(
ws.on('close', async () => {
clearInterval(pingInterval);
ws.removeAllListeners();
connectionMeta.delete(ws);
const roomsMutexRelease = await roomsMutex.acquire();