open-collaboration-server
Version:
Open Collaboration Server implementation, part of the Open Collaboration Tools project
260 lines (239 loc) • 10.2 kB
text/typescript
// ******************************************************************************
// 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;
}
export class RoomManager {
protected rooms = new Map<string, Room>();
protected peers = new Map<string, Room>();
protected pollResults = new Map<string, JoinPollResult>();
private readonly messageRelay: MessageRelay;
protected readonly credentials: CredentialsManager;
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);
}
}