RoomManager.js 5.9 KB

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