RoomManager.js 6.1 KB

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