SocketIoShim.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. /* global io */
  2. import { debugConsole } from '@/utils/debugging'
  3. import EventEmitter from '@/utils/EventEmitter'
  4. class SocketShimBase {
  5. // unused vars kept to document the interface
  6. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  7. static connect(url, options) {
  8. return new SocketShimBase()
  9. }
  10. constructor(socket) {
  11. this._socket = socket
  12. }
  13. forceDisconnectWithoutEvent() {}
  14. }
  15. const transparentMethods = [
  16. 'connect',
  17. 'disconnect',
  18. 'emit',
  19. 'on',
  20. 'removeListener',
  21. ]
  22. for (const method of transparentMethods) {
  23. SocketShimBase.prototype[method] = function () {
  24. this._socket[method].apply(this._socket, arguments)
  25. }
  26. }
  27. class SocketShimNoop extends SocketShimBase {
  28. static connect() {
  29. return new SocketShimNoop()
  30. }
  31. constructor(socket) {
  32. super(socket)
  33. this.socket = {
  34. get connected() {
  35. return false
  36. },
  37. get sessionid() {
  38. return undefined
  39. },
  40. get transport() {
  41. return {}
  42. },
  43. connect() {},
  44. // unused vars kept to document the interface
  45. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  46. disconnect(reason) {},
  47. }
  48. }
  49. connect() {}
  50. // unused vars kept to document the interface
  51. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  52. disconnect(reason) {}
  53. emit() {}
  54. on() {}
  55. removeListener() {}
  56. }
  57. class SocketShimV0 extends SocketShimBase {
  58. static connect(url, options) {
  59. return new SocketShimV0(io.connect(url, options))
  60. }
  61. constructor(socket) {
  62. super(socket)
  63. this.socket = this._socket.socket
  64. const self = this
  65. Object.defineProperty(this.socket, 'transport', {
  66. get() {
  67. return self._transport
  68. },
  69. set(v) {
  70. self.forceDisconnectWithoutEvent()
  71. self._transport = v
  72. },
  73. })
  74. }
  75. forceDisconnectWithoutEvent() {
  76. clearTimeout(this.socket.heartbeatTimeoutTimer)
  77. if (this._transport) this.forceCloseTransport(this._transport)
  78. }
  79. forceCloseTransport(transport) {
  80. transport.clearTimeouts()
  81. if (transport instanceof io.Transport.websocket) {
  82. // retry closing
  83. transport.websocket.onopen = transport.websocket.onmessage = () =>
  84. transport.websocket.close()
  85. // mute close/error handler
  86. transport.websocket.onclose = transport.websocket.onerror = () => {}
  87. // disconnect
  88. try {
  89. transport.websocket.close()
  90. } catch {}
  91. } else if (transport instanceof io.Transport['xhr-polling']) {
  92. // mute data/close handler and block new polling GET requests
  93. transport.onData = transport.onClose = transport.get = () => {}
  94. // abort pending long-polling/POST request
  95. for (const xhr of [transport.xhr, transport.sendXHR]) {
  96. if (!xhr) continue // not pending
  97. // mute xhr callbacks
  98. xhr.onreadystatechange = xhr.onload = xhr.onerror = () => {}
  99. try {
  100. xhr.abort()
  101. } catch {}
  102. }
  103. transport.xhr = transport.sendXHR = null
  104. // Mark long-polling client as disconnected to avoid "ghost" connected client.
  105. fetch(transport.prepareUrl() + '/?disconnect=1', {
  106. // Let the request continue after navigating away from or reloading the page.
  107. keepalive: true,
  108. })
  109. // Avoid leaving a dangling response on the wire.
  110. .then(res => res.text())
  111. .catch(() => {})
  112. } else {
  113. try {
  114. transport.close()
  115. } catch {}
  116. debugConsole.warn('unexpected socket.io transport', transport)
  117. }
  118. }
  119. }
  120. class SocketShimV2 extends SocketShimBase {
  121. static connect(url, options) {
  122. options.forceNew = options['force new connection']
  123. // .resource has no leading slash, path wants to see one.
  124. options.path = '/' + options.resource
  125. options.reconnection = options.reconnect
  126. options.timeout = options['connect timeout']
  127. return new SocketShimV2(url, options)
  128. }
  129. static get EVENT_MAP() {
  130. // Use the v2 event names transparently to the frontend.
  131. const connectionFailureEvents = [
  132. 'connect_error',
  133. 'connect_timeout',
  134. 'error',
  135. ]
  136. return new Map([
  137. ['connect_failed', connectionFailureEvents],
  138. ['error', connectionFailureEvents],
  139. ])
  140. }
  141. _on(event, handler) {
  142. // Keep track of our event listeners.
  143. // We move them to a new socket in ._replaceSocketWithNewInstance()
  144. if (!this._events.has(event)) {
  145. this._events.set(event, [handler])
  146. } else {
  147. this._events.get(event).push(handler)
  148. }
  149. this._socket.on(event, handler)
  150. }
  151. on(event, handler) {
  152. if (SocketShimV2.EVENT_MAP.has(event)) {
  153. for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) {
  154. this._on(v2Event, handler)
  155. }
  156. } else {
  157. this._on(event, handler)
  158. }
  159. }
  160. _removeListener(event, handler) {
  161. // Keep track of our event listeners.
  162. // We move them to a new socket in ._replaceSocketWithNewInstance()
  163. if (this._events.has(event)) {
  164. const listeners = this._events.get(event)
  165. const pos = listeners.indexOf(handler)
  166. if (pos !== -1) {
  167. listeners.splice(pos, 1)
  168. }
  169. }
  170. this._socket.removeListener(event, handler)
  171. }
  172. removeListener(event, handler) {
  173. if (SocketShimV2.EVENT_MAP.has(event)) {
  174. for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) {
  175. this._removeListener(v2Event, handler)
  176. }
  177. } else {
  178. this._removeListener(event, handler)
  179. }
  180. }
  181. static createNewSocket(url, options) {
  182. // open a brand new connection for the default namespace '/'
  183. // The old socket can still leak 'disconnect' events from the teardown
  184. // of the old transport. The leaking 'disconnect' events interfere with
  185. // the _new_ connection and cancel the new connect attempt.
  186. // Also skip the caching in these locations:
  187. // - `io.connect()` caches `io.Manager`s in `io.managers`
  188. // - `io.Manager().socket()` caches `io.Socket`s in its `this.nsps`
  189. return io.Manager(url, options).socket('/', options)
  190. }
  191. _replaceSocketWithNewInstance() {
  192. const oldSocket = this._socket
  193. const newSocket = SocketShimV2.createNewSocket(this._url, this._options)
  194. // move our existing event handlers to the new socket
  195. this._events.forEach((listeners, event) => {
  196. for (const listener of listeners) {
  197. oldSocket.removeListener(event, listener)
  198. newSocket.on(event, listener)
  199. }
  200. })
  201. if (oldSocket.connected) {
  202. // We overwrite the reference to oldSocket soon.
  203. // Make sure we are disconnected.
  204. oldSocket.disconnect()
  205. }
  206. this._socket = newSocket
  207. }
  208. connect() {
  209. // have the same logic behind socket.connect and socket.socket.connect
  210. this._replaceSocketWithNewInstance()
  211. }
  212. constructor(url, options) {
  213. super(SocketShimV2.createNewSocket(url, options))
  214. this._url = url
  215. this._options = options
  216. this._events = new Map()
  217. const self = this
  218. function _getEngine() {
  219. return (self._socket.io && self._socket.io.engine) || {}
  220. }
  221. this.socket = {
  222. get connected() {
  223. return self._socket.connected
  224. },
  225. get sessionid() {
  226. if (self._socket.id) {
  227. return self._socket.id
  228. }
  229. // socket.id is discarded upon disconnect
  230. // the id is still available in the internal state
  231. return _getEngine().id
  232. },
  233. get transport() {
  234. return _getEngine().transport
  235. },
  236. connect() {
  237. self._replaceSocketWithNewInstance()
  238. },
  239. disconnect(reason) {
  240. return self._socket.disconnect(reason)
  241. },
  242. }
  243. }
  244. }
  245. let current
  246. if (typeof io === 'undefined' || !io) {
  247. debugConsole.log('[socket.io] Shim: socket.io is not loaded, returning noop')
  248. current = SocketShimNoop
  249. } else if (typeof io.version === 'string' && io.version.slice(0, 1) === '0') {
  250. debugConsole.log('[socket.io] Shim: detected v0')
  251. current = SocketShimV0
  252. } else {
  253. // socket.io v2 does not have a global io.version attribute.
  254. debugConsole.log('[socket.io] Shim: detected v2')
  255. current = SocketShimV2
  256. }
  257. export class SocketIOMock extends SocketShimBase {
  258. constructor() {
  259. super(new EventEmitter())
  260. this.socket = {
  261. get connected() {
  262. return false
  263. },
  264. get sessionid() {
  265. return undefined
  266. },
  267. get transport() {
  268. return {}
  269. },
  270. get transports() {
  271. return []
  272. },
  273. connect() {},
  274. // unused vars kept to document the interface
  275. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  276. disconnect(reason) {},
  277. }
  278. }
  279. addListener(event, listener) {
  280. this._socket.on(event, listener)
  281. }
  282. removeListener(event, listener) {
  283. this._socket.off(event, listener)
  284. }
  285. disconnect() {
  286. this.emitToClient('disconnect')
  287. }
  288. emitToClient(...args) {
  289. // Round-trip through JSON.parse/stringify to simulate (de-)serializing on network layer.
  290. this.emit(...JSON.parse(JSON.stringify(args)))
  291. }
  292. countEventListeners(event) {
  293. return this._socket.events[event].length
  294. }
  295. }
  296. export default {
  297. SocketShimNoop,
  298. SocketShimV0,
  299. SocketShimV2,
  300. current,
  301. connect: current.connect,
  302. stub: () => new SocketShimNoop(),
  303. }