RoomManager.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /* eslint-disable
  2. camelcase,
  3. */
  4. const logger = require('logger-sharelatex')
  5. const metrics = require('metrics-sharelatex')
  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.log({ 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.log({ 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.log(
  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.log(
  76. { client: client.id, entity, id, beforeCount },
  77. 'client joined existing room'
  78. )
  79. client.join(id)
  80. callback()
  81. }
  82. },
  83. leaveEntity(client, entity, id) {
  84. // Ignore any requests to leave when the client is not actually in the
  85. // room. This can happen if the client sends spurious leaveDoc requests
  86. // for old docs after a reconnection.
  87. // This can now happen all the time, as we skip the join for clients that
  88. // disconnect before joinProject/joinDoc completed.
  89. if (!this._clientAlreadyInRoom(client, id)) {
  90. logger.log(
  91. { client: client.id, entity, id },
  92. 'ignoring request from client to leave room it is not in'
  93. )
  94. return
  95. }
  96. client.leave(id)
  97. const afterCount = this._clientsInRoom(client, id)
  98. logger.log(
  99. { client: client.id, entity, id, afterCount },
  100. 'client left room'
  101. )
  102. // is the room now empty? if so, unsubscribe
  103. if (!entity) {
  104. logger.error({ entity: id }, 'unknown entity when leaving with id')
  105. return
  106. }
  107. if (afterCount === 0) {
  108. logger.log({ entity, id }, 'room is now empty')
  109. RoomEvents.emit(`${entity}-empty`, id)
  110. IdMap.delete(id)
  111. metrics.gauge('room-listeners', RoomEvents.eventNames().length)
  112. }
  113. },
  114. // internal functions below, these access socket.io rooms data directly and
  115. // will need updating for socket.io v2
  116. // The below code makes some assumptions that are always true for v0
  117. // - we are using the base namespace '', so room names are '/<ENTITY>'
  118. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L62
  119. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L1018
  120. // - client.namespace is a Namespace
  121. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204
  122. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L40
  123. // - client.manager is a Manager
  124. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204
  125. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L41
  126. // - a Manager has
  127. // - `.rooms={'NAMESPACE/ENTITY': []}` and
  128. // - `.roomClients={'CLIENT_ID': {'...': true}}`
  129. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L287-L288
  130. // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L444-L455
  131. _clientsInRoom(client, room) {
  132. const clients = client.manager.rooms['/' + room] || []
  133. return clients.length
  134. },
  135. _roomsClientIsIn(client) {
  136. const rooms = client.manager.roomClients[client.id] || {}
  137. return (
  138. Object.keys(rooms)
  139. // drop the namespace
  140. .filter((room) => room !== '')
  141. // room names are composed as '<NAMESPACE>/<ROOM>' and the default
  142. // namespace is empty (see comments above), just drop the '/'
  143. .map((fullRoomPath) => fullRoomPath.slice(1))
  144. )
  145. },
  146. _clientAlreadyInRoom(client, room) {
  147. const rooms = client.manager.roomClients[client.id] || {}
  148. return !!rooms['/' + room]
  149. }
  150. }