const logger = require('@overleaf/logger') const metrics = require('@overleaf/metrics') const { EventEmitter } = require('node:events') const OError = require('@overleaf/o-error') const IdMap = new Map() // keep track of whether ids are from projects or docs const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events // Manage socket.io rooms for individual projects and docs // // The first time someone joins a project or doc we emit a 'project-active' or // 'doc-active' event. // // When the last person leaves a project or doc, we emit 'project-empty' or // 'doc-empty' event. // // The pubsub side is handled by ChannelManager module.exports = { joinProject(client, projectId, callback) { this.joinEntity(client, 'project', projectId, callback) }, joinDoc(client, docId, callback) { this.joinEntity(client, 'doc', docId, callback) }, leaveDoc(client, docId) { this.leaveEntity(client, 'doc', docId) }, leaveProjectAndDocs(client) { // what rooms is this client in? we need to leave them all. socket.io // will cause us to leave the rooms, so we only need to manage our // channel subscriptions... but it will be safer if we leave them // explicitly, and then socket.io will just regard this as a client that // has not joined any rooms and do a final disconnection. const roomsToLeave = this._roomsClientIsIn(client) logger.debug({ client: client.id, roomsToLeave }, 'client leaving project') for (const id of roomsToLeave) { const entity = IdMap.get(id) this.leaveEntity(client, entity, id) } }, emitOnCompletion(promiseList, eventName) { Promise.all(promiseList) .then(() => RoomEvents.emit(eventName)) .catch(err => RoomEvents.emit(eventName, err)) }, eventSource() { return RoomEvents }, joinEntity(client, entity, id, callback) { const beforeCount = this._clientsInRoom(client, id) // client joins room immediately but joinDoc request does not complete // until room is subscribed client.join(id) // is this a new room? if so, subscribe if (beforeCount === 0) { logger.debug({ entity, id }, 'room is now active') RoomEvents.once(`${entity}-subscribed-${id}`, function (err) { // only allow the client to join when all the relevant channels have subscribed if (err) { OError.tag(err, 'error joining', { entity, id }) return callback(err) } logger.debug( { client: client.id, entity, id, beforeCount }, 'client joined new room and subscribed to channel' ) callback(err) }) RoomEvents.emit(`${entity}-active`, id) IdMap.set(id, entity) // keep track of the number of listeners metrics.gauge('room-listeners', RoomEvents.eventNames().length) } else { logger.debug( { client: client.id, entity, id, beforeCount }, 'client joined existing room' ) callback() } }, leaveEntity(client, entity, id) { // Ignore any requests to leave when the client is not actually in the // room. This can happen if the client sends spurious leaveDoc requests // for old docs after a reconnection. // This can now happen all the time, as we skip the join for clients that // disconnect before joinProject/joinDoc completed. if (!this._clientAlreadyInRoom(client, id)) { logger.debug( { client: client.id, entity, id }, 'ignoring request from client to leave room it is not in' ) return } client.leave(id) const afterCount = this._clientsInRoom(client, id) logger.debug( { client: client.id, entity, id, afterCount }, 'client left room' ) // is the room now empty? if so, unsubscribe if (!entity) { logger.error({ entity: id }, 'unknown entity when leaving with id') return } if (afterCount === 0) { logger.debug({ entity, id }, 'room is now empty') RoomEvents.emit(`${entity}-empty`, id) IdMap.delete(id) metrics.gauge('room-listeners', RoomEvents.eventNames().length) } }, // internal functions below, these access socket.io rooms data directly and // will need updating for socket.io v2 // The below code makes some assumptions that are always true for v0 // - we are using the base namespace '', so room names are '/' // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L62 // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L1018 // - client.namespace is a Namespace // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204 // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L40 // - client.manager is a Manager // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204 // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L41 // - a Manager has // - `.rooms={'NAMESPACE/ENTITY': []}` and // - `.roomClients={'CLIENT_ID': {'...': true}}` // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L287-L288 // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L444-L455 _clientsInRoom(client, room) { const clients = client.manager.rooms['/' + room] || [] return clients.length }, _roomsClientIsIn(client) { const rooms = client.manager.roomClients[client.id] || {} return ( Object.keys(rooms) // drop the namespace .filter(room => room !== '') // room names are composed as '/' and the default // namespace is empty (see comments above), just drop the '/' .map(fullRoomPath => fullRoomPath.slice(1)) ) }, _clientAlreadyInRoom(client, room) { const rooms = client.manager.roomClients[client.id] || {} return !!rooms['/' + room] }, }