UNPKG

open-collaboration-server

Version:

Open Collaboration Server implementation, part of the Open Collaboration Tools project

260 lines (239 loc) 10.2 kB
// ****************************************************************************** // Copyright 2024 TypeFox GmbH // This program and the accompanying materials are made available under the // terms of the MIT License, which is available in the project root. // ****************************************************************************** import { inject, injectable } from 'inversify'; import { CredentialsManager } from './credentials-manager.js'; import { MessageRelay } from './message-relay.js'; import { Peer, Room, User, isUser } from './types.js'; import { Messages, BroadcastMessage, NotificationMessage, RequestMessage, ResponseMessage, isObject, Info, Event, Disposable, Emitter, JoinRoomResponse, JoinRoomPollResponse, JoinResponse } from 'open-collaboration-protocol'; import { Logger } from './utils/logging.js'; export interface PreparedRoom { id: string; jwt: string; } export interface RoomClaim { room: string; roomClock: number; user: User; host?: boolean; } export function isRoomClaim(obj: unknown): obj is RoomClaim { return isObject<RoomClaim>(obj) && typeof obj.room === 'string' && isUser(obj.user); } export interface RoomJoinInfo extends Info { failure: boolean; } export interface JoinPollResult extends Disposable { update(result: JoinRoomResponse | JoinRoomPollResponse): void; onUpdate: Event<JoinRoomResponse | JoinRoomPollResponse>; result?: JoinRoomResponse | JoinRoomPollResponse; } @injectable() export class RoomManager { protected rooms = new Map<string, Room>(); protected peers = new Map<string, Room>(); protected pollResults = new Map<string, JoinPollResult>(); @inject(MessageRelay) private readonly messageRelay: MessageRelay; @inject(CredentialsManager) protected readonly credentials: CredentialsManager; @inject(Logger) protected logger: Logger; async closeRoom(id: string): Promise<void> { const room = this.getRoomById(id); if (room) { const broadcastMessage = BroadcastMessage.create(Messages.Room.Closed, ''); this.messageRelay.sendBroadcast(room.host, broadcastMessage); for (const peer of room.peers) { this.peers.delete(peer.id); peer.dispose(); } this.rooms.delete(id); this.logger.info(`Deleted room '${room.id}'`); } } async prepareRoom(user: User): Promise<PreparedRoom> { const id = this.credentials.secureId(); const claim: RoomClaim = { room: id, user: { id: user.id, name: user.name, email: user.email }, host: true, roomClock: 0 }; this.logger.info(`Prepared room [id: '${claim.room}'] for user [provider: '${user.authProvider || '<none>'}' | id: '${user.id}' | name: '${user.name}' | email: '${user.email || '<none>'}']`); const jwt = await this.credentials.generateJwt(claim); return { id, jwt }; } async join(peer: Peer, roomId: string): Promise<Room> { let room: Room | undefined; if (peer.host) { room = new Room(roomId, peer, []); this.rooms.set(room.id, room); this.peers.set(peer.id, room); peer.onDispose(() => { this.closeRoom(room!.id); }); this.logger.info(`Host [id: '${peer.id}' | client: '${peer.client}' | userId: '${peer.user.id}' | name: '${peer.user.name}' | email: '${peer.user.email || '<none>'}'] created room [id: '${room.id}']`); } else { room = this.rooms.get(roomId); if (!room) { throw this.logger.createErrorAndLog(`Could not find room to join from id: ${roomId}`); } const broadcastMessage = BroadcastMessage.create(Messages.Room.Joined, '', [peer.toProtocol()]); this.peers.set(peer.id, room); room.guests.push(peer); this.logger.info(`Peer [id: '${peer.id}' | client: '${peer.client}' | userId: '${peer.user.id}' | name: '${peer.user.name}' | email: '${peer.user.email || '<none>'}'] joined room [id: '${room.id}']`); try { this.messageRelay.sendBroadcast( peer, broadcastMessage ); } catch (err) { this.logger.error('Failed to send join broadcast', err); } peer.onDispose(() => { this.leaveRoom(peer); }); } // Send the identity info to the user (i.e. what the user needs to know about itself) const infoNotification = NotificationMessage.create( Messages.Peer.Info, '', peer.id, [peer.toProtocol()] ); this.messageRelay.sendNotification( peer, infoNotification ); return room; } async leaveRoom(peer: Peer): Promise<void> { const room = this.getRoomByPeerId(peer.id); if (!room) { return; } if (peer.host) { this.closeRoom(room.id); } else { try { const broadcastMessage = BroadcastMessage.create(Messages.Room.Left, '', [peer.toProtocol()]); this.messageRelay.sendBroadcast( peer, broadcastMessage ); } catch (err) { this.logger.error('Failed to send leave broadcast', err); } // Remove the peer from the room as the last step this.peers.delete(peer.id); room.removeGuest(peer.id); } } getRoomById(id: string): Room | undefined { return this.rooms.get(id); } getRoomByPeerId(id: string): Room | undefined { return this.peers.get(id); } async requestJoin(room: Room, user: User): Promise<string> { this.logger.info(`Request to join room [id: '${room.id}'] by user [id: '${user.id}' | name: '${user.name}' | email: '${user.email ?? '<none>'}']`); const responseId = this.credentials.secureId(); const timeout = setTimeout(() => { pollResult.update({ code: 'JoinTimeout', message: 'Join request has timed out', params: [], failure: true }); pollResult.dispose(); }, 300_000); // 5 minutes of timeout const updateEmitter = new Emitter<JoinRoomResponse | RoomJoinInfo>(); const pollResult: JoinPollResult = { update: result => { pollResult.result = result; updateEmitter.fire(result); }, onUpdate: updateEmitter.event, dispose: () => { updateEmitter.dispose(); this.pollResults.delete(responseId); clearTimeout(timeout); } }; this.pollResults.set(responseId, pollResult); try { const requestMessage = RequestMessage.create(Messages.Peer.Join, this.credentials.secureId(), '', room.host.id, [user]); const responsePromise = this.messageRelay.sendRequest( room.host, requestMessage, 300_000 ); responsePromise.then(async response => { if (ResponseMessage.is(response)) { const joinResponse = response.content.response as JoinResponse | undefined; if (!joinResponse) { pollResult.update({ failure: true, code: 'JoinRejected', params: [], message: 'Join request has been rejected' }); return; } const claim: RoomClaim = { room: room.id, user: { ...user }, // Increment the clock to identify unique sessions // If a user joins a room multiple times, the clock will be incremented // This way, they get a new JWT for each session // If a user reconnects using an old JWT, we can identify a reconnect attempt roomClock: ++room.clock }; const jwt = await this.credentials.generateJwt(claim); const joinRoomResponse: JoinRoomResponse = { roomId: room.id, roomToken: jwt, workspace: joinResponse.workspace, host: room.host.toProtocol() }; pollResult.update(joinRoomResponse); } else { pollResult.update({ failure: true, code: 'JoinRejected', params: [], message: 'Join request has been rejected' }); } }).catch(() => { pollResult.update({ code: 'JoinTimeout', message: 'Join request has timed out', params: [], failure: true }); }); pollResult.update({ failure: false, code: 'WaitingForHost', params: [], message: 'Waiting for host to accept join request', }); return responseId; } catch { throw this.logger.createErrorAndLog('Failed to request join'); } } pollJoin(responseId: string): JoinPollResult | undefined { return this.pollResults.get(responseId); } }