ConnectionManager.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Fix any style issues and re-enable lint.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS102: Remove unnecessary code created because of implicit returns
  6. * DS206: Consider reworking classes to avoid initClass
  7. * DS207: Consider shorter variations of null checks
  8. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  9. */
  10. /* global io */
  11. define([], function() {
  12. let ConnectionManager
  13. const ONEHOUR = 1000 * 60 * 60
  14. return (ConnectionManager = (function() {
  15. ConnectionManager = class ConnectionManager {
  16. static initClass() {
  17. this.prototype.disconnectAfterMs = ONEHOUR * 24
  18. this.prototype.lastUserAction = new Date()
  19. this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now"
  20. this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000
  21. this.prototype.JOIN_PROJECT_RETRY_INTERVAL = 5000
  22. this.prototype.JOIN_PROJECT_MAX_RETRY_INTERVAL = 60000
  23. this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms
  24. this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000
  25. }
  26. constructor(ide, $scope) {
  27. this.ide = ide
  28. this.$scope = $scope
  29. this.wsUrl = ide.wsUrl || null // websocket url (if defined)
  30. if (typeof io === 'undefined' || io === null) {
  31. console.error(
  32. 'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
  33. )
  34. this.ide.socket = { on() {} }
  35. this.$scope.$apply(() => {
  36. return (this.$scope.state.error =
  37. 'Could not connect to websocket server :(')
  38. })
  39. return
  40. }
  41. setInterval(() => {
  42. return this.disconnectIfInactive()
  43. }, ONEHOUR)
  44. // trigger a reconnect immediately if network comes back online
  45. window.addEventListener('online', () => {
  46. sl_console.log('[online] browser notified online')
  47. if (!this.connected) {
  48. return this.tryReconnectWithRateLimit({ force: true })
  49. }
  50. })
  51. this.userIsLeavingPage = false
  52. window.addEventListener('beforeunload', () => {
  53. this.userIsLeavingPage = true
  54. }) // Don't return true or it will show a pop up
  55. this.connected = false
  56. this.userIsInactive = false
  57. this.gracefullyReconnecting = false
  58. this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
  59. this.$scope.connection = {
  60. debug: sl_debugging,
  61. reconnecting: false,
  62. stillReconnecting: false,
  63. // If we need to force everyone to reload the editor
  64. forced_disconnect: false,
  65. inactive_disconnect: false,
  66. jobId: 0
  67. }
  68. this.$scope.tryReconnectNow = () => {
  69. // user manually requested reconnection via "Try now" button
  70. return this.tryReconnectWithRateLimit({ force: true })
  71. }
  72. this.$scope.$on('cursor:editor:update', () => {
  73. this.lastUserAction = new Date() // time of last edit
  74. if (!this.connected) {
  75. // user is editing, try to reconnect
  76. return this.tryReconnectWithRateLimit()
  77. }
  78. })
  79. document.querySelector('body').addEventListener('click', e => {
  80. if (!this.connected && e.target.id !== 'try-reconnect-now-button') {
  81. // user is editing, try to reconnect
  82. return this.tryReconnectWithRateLimit()
  83. }
  84. })
  85. // initial connection attempt
  86. this.updateConnectionManagerState('connecting')
  87. let parsedURL
  88. try {
  89. parsedURL = new URL(this.wsUrl || '/socket.io', window.location)
  90. } catch (e) {
  91. // hello IE11
  92. parsedURL = {
  93. origin: null,
  94. pathname: '/socket.io'
  95. }
  96. }
  97. this.ide.socket = io.connect(
  98. parsedURL.origin,
  99. {
  100. resource: parsedURL.pathname.slice(1),
  101. reconnect: false,
  102. 'connect timeout': 30 * 1000,
  103. 'force new connection': true
  104. }
  105. )
  106. // handle network-level websocket errors (e.g. failed dns lookups)
  107. let connectionErrorHandler = err => {
  108. this.updateConnectionManagerState('error')
  109. sl_console.log('socket.io error', err)
  110. if (this.wsUrl && !window.location.href.match(/ws=fallback/)) {
  111. // if we tried to load a custom websocket location and failed
  112. // try reloading and falling back to the siteUrl
  113. window.location = window.location.href + '?ws=fallback'
  114. } else {
  115. this.connected = false
  116. return this.$scope.$apply(() => {
  117. return (this.$scope.state.error =
  118. "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
  119. })
  120. }
  121. }
  122. this.ide.socket.on('error', connectionErrorHandler)
  123. // The "connect" event is the first event we get back. It only
  124. // indicates that the websocket is connected, we still need to
  125. // pass authentication to join a project.
  126. this.ide.socket.on('connect', () => {
  127. // state should be 'connecting'...
  128. // remove connection error handler when connected, avoid unwanted fallbacks
  129. this.ide.socket.removeListener('error', connectionErrorHandler)
  130. sl_console.log('[socket.io connect] Connected')
  131. this.updateConnectionManagerState('authenticating')
  132. })
  133. // The next event we should get is an authentication response
  134. // from the server, either "connectionAccepted" or
  135. // "connectionRejected".
  136. this.ide.socket.on('connectionAccepted', message => {
  137. // state should be 'authenticating'...
  138. sl_console.log('[socket.io connectionAccepted] allowed to connect')
  139. this.connected = true
  140. this.gracefullyReconnecting = false
  141. this.ide.pushEvent('connected')
  142. this.updateConnectionManagerState('joining')
  143. this.$scope.$apply(() => {
  144. if (this.$scope.state.loading) {
  145. return (this.$scope.state.load_progress = 70)
  146. }
  147. })
  148. // we have passed authentication so we can now join the project
  149. let connectionJobId = this.$scope.connection.jobId
  150. setTimeout(() => {
  151. this.joinProject(connectionJobId)
  152. }, 100)
  153. })
  154. this.ide.socket.on('connectionRejected', err => {
  155. // state should be 'authenticating'...
  156. sl_console.log(
  157. '[socket.io connectionRejected] session not valid or other connection error'
  158. )
  159. // real time sends a 'retry' message if the process was shutting down
  160. if (err && err.message === 'retry') {
  161. return this.tryReconnectWithRateLimit()
  162. }
  163. // we have failed authentication, usually due to an invalid session cookie
  164. return this.reportConnectionError(err)
  165. })
  166. // Alternatively the attempt to connect can fail completely, so
  167. // we never get into the "connect" state.
  168. this.ide.socket.on('connect_failed', () => {
  169. this.updateConnectionManagerState('error')
  170. this.connected = false
  171. return this.$scope.$apply(() => {
  172. return (this.$scope.state.error =
  173. "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
  174. })
  175. })
  176. // We can get a "disconnect" event at any point after the
  177. // "connect" event.
  178. this.ide.socket.on('disconnect', () => {
  179. sl_console.log('[socket.io disconnect] Disconnected')
  180. this.connected = false
  181. this.ide.pushEvent('disconnected')
  182. if (!this.$scope.connection.state.match(/^waiting/)) {
  183. if (
  184. !this.$scope.connection.forced_disconnect &&
  185. !this.userIsInactive
  186. ) {
  187. this.startAutoReconnectCountdown()
  188. } else {
  189. this.updateConnectionManagerState('inactive')
  190. }
  191. }
  192. })
  193. // Site administrators can send the forceDisconnect event to all users
  194. this.ide.socket.on('forceDisconnect', message => {
  195. this.updateConnectionManagerState('inactive')
  196. this.$scope.$apply(() => {
  197. this.$scope.permissions.write = false
  198. return (this.$scope.connection.forced_disconnect = true)
  199. })
  200. this.ide.socket.disconnect()
  201. this.ide.showGenericMessageModal(
  202. 'Please Refresh',
  203. `\
  204. We're performing maintenance on Overleaf and you need to refresh the editor.
  205. Sorry for any inconvenience.
  206. The editor will refresh in automatically in 10 seconds.\
  207. `
  208. )
  209. return setTimeout(() => location.reload(), 10 * 1000)
  210. })
  211. this.ide.socket.on('reconnectGracefully', () => {
  212. sl_console.log('Reconnect gracefully')
  213. this.reconnectGracefully()
  214. })
  215. }
  216. updateConnectionManagerState(state) {
  217. this.$scope.$apply(() => {
  218. this.$scope.connection.jobId += 1
  219. let jobId = this.$scope.connection.jobId
  220. sl_console.log(
  221. `[updateConnectionManagerState ${jobId}] from ${
  222. this.$scope.connection.state
  223. } to ${state}`
  224. )
  225. this.$scope.connection.state = state
  226. this.$scope.connection.reconnecting = false
  227. this.$scope.connection.stillReconnecting = false
  228. this.$scope.connection.inactive_disconnect = false
  229. this.$scope.connection.joining = false
  230. this.$scope.connection.reconnection_countdown = null
  231. if (state === 'connecting') {
  232. // initial connection
  233. } else if (state === 'reconnecting') {
  234. // reconnection after a connection has failed
  235. this.stopReconnectCountdownTimer()
  236. this.$scope.connection.reconnecting = true
  237. // if reconnecting takes more than 1s (it doesn't, usually) show the
  238. // 'reconnecting...' warning
  239. setTimeout(() => {
  240. if (
  241. this.$scope.connection.reconnecting &&
  242. this.$scope.connection.jobId === jobId
  243. ) {
  244. this.$scope.connection.stillReconnecting = true
  245. }
  246. }, 1000)
  247. } else if (state === 'reconnectFailed') {
  248. // reconnect attempt failed
  249. } else if (state === 'authenticating') {
  250. // socket connection has been established, trying to authenticate
  251. } else if (state === 'joining') {
  252. // authenticated, joining project
  253. this.$scope.connection.joining = true
  254. } else if (state === 'ready') {
  255. // project has been joined
  256. } else if (state === 'waitingCountdown') {
  257. // disconnected and waiting to reconnect via the countdown timer
  258. this.stopReconnectCountdownTimer()
  259. } else if (state === 'waitingGracefully') {
  260. // disconnected and waiting to reconnect gracefully
  261. this.stopReconnectCountdownTimer()
  262. } else if (state === 'inactive') {
  263. // disconnected and not trying to reconnect (inactive)
  264. } else if (state === 'error') {
  265. // something is wrong
  266. } else {
  267. sl_console.log(
  268. `[WARN] [updateConnectionManagerState ${jobId}] got unrecognised state ${state}`
  269. )
  270. }
  271. })
  272. }
  273. expectConnectionManagerState(state, jobId) {
  274. if (
  275. this.$scope.connection.state === state &&
  276. (!jobId || jobId === this.$scope.connection.jobId)
  277. ) {
  278. return true
  279. }
  280. sl_console.log(
  281. `[WARN] [state mismatch] expected state ${state}${
  282. jobId ? '/' + jobId : ''
  283. } when in ${this.$scope.connection.state}/${
  284. this.$scope.connection.jobId
  285. }`
  286. )
  287. return false
  288. }
  289. // Error reporting, which can reload the page if appropriate
  290. reportConnectionError(err) {
  291. sl_console.log('[socket.io] reporting connection error')
  292. this.updateConnectionManagerState('error')
  293. if (
  294. (err != null ? err.message : undefined) === 'not authorized' ||
  295. (err != null ? err.message : undefined) === 'invalid session'
  296. ) {
  297. return (window.location = `/login?redir=${encodeURI(
  298. window.location.pathname
  299. )}`)
  300. } else {
  301. this.ide.socket.disconnect()
  302. return this.ide.showGenericMessageModal(
  303. 'Something went wrong connecting',
  304. `\
  305. Something went wrong connecting to your project. Please refresh if this continues to happen.\
  306. `
  307. )
  308. }
  309. }
  310. joinProject(connectionId) {
  311. sl_console.log(`[joinProject ${connectionId}] joining...`)
  312. // Note: if the "joinProject" message doesn't reach the server
  313. // (e.g. if we are in a disconnected state at this point) the
  314. // callback will never be executed
  315. if (!this.expectConnectionManagerState('joining', connectionId)) {
  316. sl_console.log(
  317. `[joinProject ${connectionId}] aborting with stale connection`
  318. )
  319. return
  320. }
  321. const data = {
  322. project_id: this.ide.project_id
  323. }
  324. if (window.anonymousAccessToken) {
  325. data.anonymousAccessToken = window.anonymousAccessToken
  326. }
  327. this.ide.socket.emit(
  328. 'joinProject',
  329. data,
  330. (err, project, permissionsLevel, protocolVersion) => {
  331. if (err != null || project == null) {
  332. if (err.code === 'TooManyRequests') {
  333. sl_console.log(
  334. `[joinProject ${connectionId}] retrying: ${err.message}`
  335. )
  336. setTimeout(
  337. () => this.joinProject(connectionId),
  338. this.joinProjectRetryInterval
  339. )
  340. if (
  341. this.joinProjectRetryInterval <
  342. this.JOIN_PROJECT_MAX_RETRY_INTERVAL
  343. ) {
  344. this.joinProjectRetryInterval += this.JOIN_PROJECT_RETRY_INTERVAL
  345. }
  346. return
  347. } else {
  348. return this.reportConnectionError(err)
  349. }
  350. }
  351. this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
  352. if (
  353. this.$scope.protocolVersion != null &&
  354. this.$scope.protocolVersion !== protocolVersion
  355. ) {
  356. location.reload(true)
  357. }
  358. this.$scope.$apply(() => {
  359. this.updateConnectionManagerState('ready')
  360. this.$scope.protocolVersion = protocolVersion
  361. this.$scope.project = project
  362. this.$scope.permissionsLevel = permissionsLevel
  363. this.$scope.state.load_progress = 100
  364. this.$scope.state.loading = false
  365. this.$scope.$broadcast('project:joined')
  366. })
  367. }
  368. )
  369. }
  370. reconnectImmediately() {
  371. this.disconnect()
  372. return this.tryReconnect()
  373. }
  374. disconnect() {
  375. if (this.ide.socket.socket && !this.ide.socket.socket.connected) {
  376. sl_console.log(
  377. '[socket.io] skipping disconnect because socket.io has not connected'
  378. )
  379. return
  380. }
  381. sl_console.log('[socket.io] disconnecting client')
  382. return this.ide.socket.disconnect()
  383. }
  384. startAutoReconnectCountdown() {
  385. this.updateConnectionManagerState('waitingCountdown')
  386. let connectionId = this.$scope.connection.jobId
  387. let countdown
  388. sl_console.log('[ConnectionManager] starting autoreconnect countdown')
  389. const twoMinutes = 2 * 60 * 1000
  390. if (
  391. this.lastUserAction != null &&
  392. new Date() - this.lastUserAction > twoMinutes
  393. ) {
  394. // between 1 minute and 3 minutes
  395. countdown = 60 + Math.floor(Math.random() * 120)
  396. } else {
  397. countdown = 3 + Math.floor(Math.random() * 7)
  398. }
  399. if (this.userIsLeavingPage) {
  400. // user will have pressed refresh or back etc
  401. return
  402. }
  403. this.$scope.$apply(() => {
  404. this.$scope.connection.reconnecting = false
  405. this.$scope.connection.stillReconnecting = false
  406. this.$scope.connection.joining = false
  407. this.$scope.connection.reconnection_countdown = countdown
  408. })
  409. setTimeout(() => {
  410. if (!this.connected && !this.countdownTimeoutId) {
  411. this.countdownTimeoutId = setTimeout(
  412. () => this.decreaseCountdown(connectionId),
  413. 1000
  414. )
  415. }
  416. }, 200)
  417. }
  418. stopReconnectCountdownTimer() {
  419. // clear timeout and set to null so we know there is no countdown running
  420. if (this.countdownTimeoutId != null) {
  421. sl_console.log(
  422. '[ConnectionManager] cancelling existing reconnect timer'
  423. )
  424. clearTimeout(this.countdownTimeoutId)
  425. this.countdownTimeoutId = null
  426. }
  427. }
  428. decreaseCountdown(connectionId) {
  429. this.countdownTimeoutId = null
  430. if (this.$scope.connection.reconnection_countdown == null) {
  431. return
  432. }
  433. if (
  434. !this.expectConnectionManagerState('waitingCountdown', connectionId)
  435. ) {
  436. sl_console.log(
  437. `[ConnectionManager] Aborting stale countdown ${connectionId}`
  438. )
  439. return
  440. }
  441. sl_console.log(
  442. '[ConnectionManager] decreasing countdown',
  443. this.$scope.connection.reconnection_countdown
  444. )
  445. this.$scope.$apply(() => {
  446. this.$scope.connection.reconnection_countdown--
  447. })
  448. if (this.$scope.connection.reconnection_countdown <= 0) {
  449. this.$scope.connection.reconnecting = false
  450. this.$scope.$apply(() => {
  451. this.tryReconnect()
  452. })
  453. } else {
  454. this.countdownTimeoutId = setTimeout(
  455. () => this.decreaseCountdown(connectionId),
  456. 1000
  457. )
  458. }
  459. }
  460. tryReconnect() {
  461. sl_console.log('[ConnectionManager] tryReconnect')
  462. if (this.connected || this.$scope.connection.reconnecting) {
  463. return
  464. }
  465. this.updateConnectionManagerState('reconnecting')
  466. sl_console.log('[ConnectionManager] Starting new connection')
  467. const removeHandler = () => {
  468. this.ide.socket.removeListener('error', handleFailure)
  469. this.ide.socket.removeListener('connect', handleSuccess)
  470. }
  471. const handleFailure = () => {
  472. sl_console.log('[ConnectionManager] tryReconnect: failed')
  473. removeHandler()
  474. this.updateConnectionManagerState('reconnectFailed')
  475. this.tryReconnectWithRateLimit({ force: true })
  476. }
  477. const handleSuccess = () => {
  478. sl_console.log('[ConnectionManager] tryReconnect: success')
  479. removeHandler()
  480. }
  481. this.ide.socket.on('error', handleFailure)
  482. this.ide.socket.on('connect', handleSuccess)
  483. // use socket.io connect() here to make a single attempt, the
  484. // reconnect() method makes multiple attempts
  485. this.ide.socket.socket.connect()
  486. // record the time of the last attempt to connect
  487. this.lastConnectionAttempt = new Date()
  488. }
  489. tryReconnectWithRateLimit(options) {
  490. // bail out if the reconnect is already in progress
  491. if (this.$scope.connection.reconnecting || this.connected) {
  492. return
  493. }
  494. // bail out if we are going to reconnect soon anyway
  495. const reconnectingSoon =
  496. this.$scope.connection.reconnection_countdown != null &&
  497. this.$scope.connection.reconnection_countdown <= 5
  498. const clickedTryNow = options != null ? options.force : undefined // user requested reconnection
  499. if (reconnectingSoon && !clickedTryNow) {
  500. return
  501. }
  502. // bail out if we tried reconnecting recently
  503. const allowedInterval = clickedTryNow
  504. ? this.MIN_RETRY_INTERVAL
  505. : this.BACKGROUND_RETRY_INTERVAL
  506. if (
  507. this.lastConnectionAttempt != null &&
  508. new Date() - this.lastConnectionAttempt < allowedInterval
  509. ) {
  510. if (this.$scope.connection.state !== 'waitingCountdown') {
  511. this.startAutoReconnectCountdown()
  512. }
  513. return
  514. }
  515. this.tryReconnect()
  516. }
  517. disconnectIfInactive() {
  518. this.userIsInactive =
  519. new Date() - this.lastUserAction > this.disconnectAfterMs
  520. if (this.userIsInactive && this.connected) {
  521. this.disconnect()
  522. return this.$scope.$apply(() => {
  523. return (this.$scope.connection.inactive_disconnect = true)
  524. }) // 5 minutes
  525. }
  526. }
  527. reconnectGracefully(force) {
  528. if (this.reconnectGracefullyStarted == null) {
  529. this.reconnectGracefullyStarted = new Date()
  530. } else {
  531. if (!force) {
  532. sl_console.log(
  533. '[reconnectGracefully] reconnection is already in process, so skipping'
  534. )
  535. return
  536. }
  537. }
  538. const userIsInactive =
  539. new Date() - this.lastUserAction >
  540. this.RECONNECT_GRACEFULLY_RETRY_INTERVAL
  541. const maxIntervalReached =
  542. new Date() - this.reconnectGracefullyStarted >
  543. this.MAX_RECONNECT_GRACEFULLY_INTERVAL
  544. if (userIsInactive || maxIntervalReached) {
  545. sl_console.log(
  546. "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
  547. )
  548. this._reconnectGracefullyNow()
  549. } else {
  550. sl_console.log(
  551. '[reconnectGracefully] User is working, will try again in 5 seconds'
  552. )
  553. this.updateConnectionManagerState('waitingGracefully')
  554. setTimeout(() => {
  555. this.reconnectGracefully(true)
  556. }, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL)
  557. }
  558. }
  559. _reconnectGracefullyNow() {
  560. this.gracefullyReconnecting = true
  561. this.reconnectGracefullyStarted = null
  562. // Clear cookie so we don't go to the same backend server
  563. $.cookie('SERVERID', '', { expires: -1, path: '/' })
  564. return this.reconnectImmediately()
  565. }
  566. }
  567. ConnectionManager.initClass()
  568. return ConnectionManager
  569. })())
  570. })