RoomManager.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import logger from '@overleaf/logger'
  2. import metrics from '@overleaf/metrics'
  3. import { EventEmitter } from 'node:events'
  4. import OError from '@overleaf/o-error'
  5. const IdMap = new Map() // keep track of whether ids are from projects or docs
  6. const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events
  7. // Manage socket.io rooms for individual projects and docs
  8. //
  9. // The first time someone joins a project or doc we emit a 'project-active' or
  10. // 'doc-active' event.
  11. //
  12. // When the last person leaves a project or doc, we emit 'project-empty' or
  13. // 'doc-empty' event.
  14. //
  15. // The pubsub side is handled by ChannelManager
  16. export default {
  17. joinProject(client, projectId, callback) {
  18. this.joinEntity(client, 'project', projectId, callback)
  19. },
  20. joinDoc(client, docId, callback) {
  21. this.joinEntity(client, 'doc', docId, callback)
  22. },
  23. leaveDoc(client, docId) {
  24. this.leaveEntity(client, 'doc', docId)
  25. },
  26. leaveProjectAndDocs(client) {
  27. // what rooms is this client in? we need to leave them all. socket.io
  28. // will cause us to leave the rooms, so we only need to manage our
  29. // channel subscriptions... but it will be safer if we leave them
  30. // explicitly, and then socket.io will just regard this as a client that
  31. // has not joined any rooms and do a final disconnection.
  32. const roomsToLeave = this._roomsClientIsIn(client)
  33. logger.debug({ client: client.id, roomsToLeave }, 'client leaving project')
  34. for (const id of roomsToLeave) {
  35. const entity = IdMap.get(id)
  36. this.leaveEntity(client, entity, id)
  37. }
  38. },
  39. emitOnCompletion(promiseList, eventName) {
  40. Promise.all(promiseList)
  41. .then(() => RoomEvents.emit(eventName))
  42. .catch(err => RoomEvents.emit(eventName, err))
  43. },
  44. eventSource() {
  45. return RoomEvents
  46. },
  47. joinEntity(client, entity, id, callback) {
  48. const beforeCount = this._clientsInRoom(client, id)
  49. // client joins room immediately but joinDoc request does not complete
  50. // until room is subscribed
  51. client.join(id)
  52. // is this a new room? if so, subscribe
  53. if (beforeCount === 0) {
  54. logger.debug({ entity, id }, 'room is now active')
  55. RoomEvents.once(`${entity}-subscribed-${id}`, function (err) {
  56. // only allow the client to join when all the relevant channels have subscribed
  57. if (err) {
  58. OError.tag(err, 'error joining', { entity, id })
  59. return callback(err)
  60. }
  61. logger.debug(
  62. { client: client.id, entity, id, beforeCount },
  63. 'client joined new room and subscribed to channel'
  64. )
  65. callback(err)
  66. })
  67. RoomEvents.emit(`${entity}-active`, id)
  68. IdMap.set(id, entity)
  69. // keep track of the number of listeners
  70. metrics.gauge('room-listeners', RoomEvents.eventNames().length)
  71. } else {
  72. logger.debug(
  73. { client: client.id, entity, id, beforeCount },
  74. 'client joined existing room'
  75. )
  76. callback()
  77. }
  78. },
  79. leaveEntity(client, entity, id) {
  80. // Ignore any requests to leave when the client is not actually in the
  81. // room. This can happen if the client sends spurious leaveDoc requests
  82. // for old docs after a reconnection.
  83. // This can now happen all the time, as we skip the join for clients that
  84. // disconnect before joinProject/joinDoc completed.
  85. if (!this._clientAlreadyInRoom(client, id)) {
  86. logger.debug(
  87. { client: client.id, entity, id },
  88. 'ignoring request from client to leave room it is not in'
  89. )
  90. return
  91. }
  92. client.leave(id)
  93. const afterCount = this._clientsInRoom(client, id)
  94. logger.debug(
  95. { client: client.id, entity, id, afterCount },
  96. 'client left room'
  97. )
  98. // is the room now empty? if so, unsubscribe
  99. if (!entity) {
  100. logger.error({ entity: id }, 'unknown entity when leaving with id')
  101. return
  102. }
  103. if (afterCount === 0) {
  104. logger.debug({ entity, id }, 'room is now empty')
  105. RoomEvents.emit(`${entity}-empty`, id)
  106. IdMap.delete(id)
  107. metrics.gauge('room-listeners', RoomEvents.eventNames().length)
  108. }
  109. },
  110. // internal functions below, these access socket.io rooms data directly and
  111. // will need updating for socket.io v2
  112. // The below code makes some assumptions that are always true for v0
  113. // - we are using the base namespace '', so room names are '/<ENTITY>'
  114. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L62
  115. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L1018
  116. // - client.namespace is a Namespace
  117. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204
  118. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L40
  119. // - client.manager is a Manager
  120. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204
  121. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L41
  122. // - a Manager has
  123. // - `.rooms={'NAMESPACE/ENTITY': []}` and
  124. // - `.roomClients={'CLIENT_ID': {'...': true}}`
  125. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L287-L288
  126. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L444-L455
  127. _clientsInRoom(client, room) {
  128. const clients = client.manager.rooms['/' + room] || []
  129. return clients.length
  130. },
  131. _roomsClientIsIn(client) {
  132. const rooms = client.manager.roomClients[client.id] || {}
  133. return (
  134. Object.keys(rooms)
  135. // drop the namespace
  136. .filter(room => room !== '')
  137. // room names are composed as '<NAMESPACE>/<ROOM>' and the default
  138. // namespace is empty (see comments above), just drop the '/'
  139. .map(fullRoomPath => fullRoomPath.slice(1))
  140. )
  141. },
  142. _clientAlreadyInRoom(client, room) {
  143. const rooms = client.manager.roomClients[client.id] || {}
  144. return !!rooms['/' + room]
  145. },
  146. }