overleaf/services/web/frontend/js/ide/connection/ConnectionManager.js
Jakob Ackermann 82cde31619 Merge pull request #16808 from overleaf/jpa-wsUrl
[web] move wsUrl into meta tag

GitOrigin-RevId: c94638759b79b8090b8b3cfa81baa718a69839e1
2024-02-02 09:03:35 +00:00

630 lines
22 KiB
JavaScript

// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import SocketIoShim from './SocketIoShim'
import getMeta from '../../utils/meta'
import { debugConsole, debugging } from '@/utils/debugging'
let ConnectionManager
const ONEHOUR = 1000 * 60 * 60
export default ConnectionManager = (function () {
ConnectionManager = class ConnectionManager {
static initClass() {
this.prototype.disconnectAfterMs = ONEHOUR * 24
this.prototype.lastUserAction = new Date()
this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now"
this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000
this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms
this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000
}
constructor(ide, $scope) {
this.ide = ide
this.$scope = $scope
if (typeof window.io !== 'object') {
this.switchToWsFallbackIfPossible()
debugConsole.error(
'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
)
this.ide.socket = SocketIoShim.stub()
this.$scope.$apply(() => {
return (this.$scope.state.error =
'Could not connect to websocket server :(')
})
return
}
setInterval(() => {
return this.disconnectIfInactive()
}, ONEHOUR)
// trigger a reconnect immediately if network comes back online
window.addEventListener('online', () => {
debugConsole.log('[online] browser notified online')
if (!this.connected) {
return this.tryReconnectWithRateLimit({ force: true })
}
})
this.userIsLeavingPage = false
window.addEventListener('beforeunload', () => {
this.userIsLeavingPage = true
}) // Don't return true or it will show a pop up
this.connected = false
this.userIsInactive = false
this.gracefullyReconnecting = false
this.shuttingDown = false
this.$scope.connection = {
debug: debugging,
reconnecting: false,
stillReconnecting: false,
// If we need to force everyone to reload the editor
forced_disconnect: false,
inactive_disconnect: false,
jobId: 0,
}
this.$scope.tryReconnectNow = () => {
// user manually requested reconnection via "Try now" button
return this.tryReconnectWithRateLimit({ force: true })
}
this.$scope.$on('cursor:editor:update', () => {
this.lastUserAction = new Date() // time of last edit
if (!this.connected) {
// user is editing, try to reconnect
return this.tryReconnectWithRateLimit()
}
})
document.querySelector('body').addEventListener('click', e => {
if (
!this.shuttingDown &&
!this.connected &&
e.target.id !== 'try-reconnect-now-button'
) {
// user is editing, try to reconnect
return this.tryReconnectWithRateLimit()
}
})
// initial connection attempt
this.updateConnectionManagerState('connecting')
const parsedURL = new URL(
getMeta('ol-wsUrl') || '/socket.io',
window.origin
)
const query = new URLSearchParams({
projectId: getMeta('ol-project_id'),
}).toString()
this.ide.socket = SocketIoShim.connect(parsedURL.origin, {
resource: parsedURL.pathname.slice(1),
reconnect: false,
'connect timeout': 30 * 1000,
'force new connection': true,
query,
})
// handle network-level websocket errors (e.g. failed dns lookups)
let connectionAttempt = 1
const connectionErrorHandler = err => {
if (
window.wsRetryHandshake &&
connectionAttempt++ < window.wsRetryHandshake
) {
return setTimeout(
() => this.ide.socket.socket.connect(),
// add jitter to spread reconnects
connectionAttempt * (1 + Math.random()) * 1000
)
}
this.updateConnectionManagerState('error')
debugConsole.log('socket.io error', err)
if (!this.switchToWsFallbackIfPossible()) {
this.connected = false
return this.$scope.$apply(() => {
return (this.$scope.state.error =
"Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
})
}
}
this.ide.socket.on('error', connectionErrorHandler)
// The "connect" event is the first event we get back. It only
// indicates that the websocket is connected, we still need to
// pass authentication to join a project.
this.ide.socket.on('connect', () => {
// state should be 'connecting'...
// remove connection error handler when connected, avoid unwanted fallbacks
this.ide.socket.removeListener('error', connectionErrorHandler)
debugConsole.log('[socket.io connect] Connected')
this.updateConnectionManagerState('authenticating')
})
// The next event we should get is an authentication response
// from the server, either "joinProjectResponse" or "connectionRejected".
this.ide.socket.on(
'joinProjectResponse',
({ publicId, project, permissionsLevel, protocolVersion }) => {
this.ide.socket.publicId = publicId
debugConsole.log('[socket.io bootstrap] ready for joinDoc')
this.connected = true
this.gracefullyReconnecting = false
this.ide.pushEvent('connected')
this.ide.pushEvent('joinProjectResponse')
this.updateConnectionManagerState('joining')
this.$scope.$apply(() => {
if (this.$scope.state.loading) {
this.$scope.state.load_progress = 70
}
})
this.handleJoinProjectResponse({
project,
permissionsLevel,
protocolVersion,
})
}
)
this.ide.socket.on('connectionRejected', err => {
// state should be 'authenticating'...
debugConsole.log(
'[socket.io connectionRejected] session not valid or other connection error'
)
// real-time sends a 'retry' message if the process was shutting down
// real-time sends TooManyRequests if joinProject was rate-limited.
if (err?.message === 'retry' || err?.code === 'TooManyRequests') {
return this.tryReconnectWithRateLimit()
}
if (err?.code === 'ProjectNotFound') {
// A stale browser tab tried to join a deleted project.
// Reloading the page will render a 404.
this.ide
.showGenericMessageModal(
'Project has been deleted',
'This project has been deleted by the owner.'
)
.result.then(() => location.reload(true))
return
}
// we have failed authentication, usually due to an invalid session cookie
return this.reportConnectionError(err)
})
// Alternatively the attempt to connect can fail completely, so
// we never get into the "connect" state.
this.ide.socket.on('connect_failed', () => {
this.updateConnectionManagerState('error')
this.connected = false
return this.$scope.$apply(() => {
return (this.$scope.state.error =
"Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
})
})
// We can get a "disconnect" event at any point after the
// "connect" event.
this.ide.socket.on('disconnect', () => {
debugConsole.log('[socket.io disconnect] Disconnected')
this.connected = false
this.ide.pushEvent('disconnected')
if (!this.$scope.connection.state.match(/^waiting/)) {
if (
!this.$scope.connection.forced_disconnect &&
!this.userIsInactive &&
!this.shuttingDown
) {
this.startAutoReconnectCountdown()
} else {
this.updateConnectionManagerState('inactive')
}
}
})
// Site administrators can send the forceDisconnect event to all users
this.ide.socket.on('forceDisconnect', (message, delay = 10) => {
this.updateConnectionManagerState('inactive')
this.shuttingDown = true // prevent reconnection attempts
this.$scope.$apply(() => {
this.$scope.permissions.write = false
return (this.$scope.connection.forced_disconnect = true)
})
// flush changes before disconnecting
this.ide.$scope.$broadcast('flush-changes')
setTimeout(() => this.ide.socket.disconnect(), 1000)
this.ide.showLockEditorMessageModal(
'Please wait',
`\
We're performing maintenance on Overleaf and you need to wait a moment.
Sorry for any inconvenience.
The editor will refresh automatically in ${delay} seconds.\
`
)
return setTimeout(() => location.reload(), delay * 1000)
})
this.ide.socket.on('reconnectGracefully', () => {
debugConsole.log('Reconnect gracefully')
this.reconnectGracefully()
})
}
switchToWsFallbackIfPossible() {
const search = new URLSearchParams(window.location.search)
if (getMeta('ol-wsUrl') && search.get('ws') !== 'fallback') {
// if we tried to boot from a custom real-time backend and failed,
// try reloading and falling back to the siteUrl
search.set('ws', 'fallback')
window.location.search = search.toString()
return true
}
return false
}
updateConnectionManagerState(state) {
this.$scope.$apply(() => {
this.$scope.connection.jobId += 1
const jobId = this.$scope.connection.jobId
debugConsole.log(
`[updateConnectionManagerState ${jobId}] from ${this.$scope.connection.state} to ${state}`
)
this.$scope.connection.state = state
this.$scope.connection.reconnecting = false
this.$scope.connection.stillReconnecting = false
this.$scope.connection.inactive_disconnect = false
this.$scope.connection.joining = false
this.$scope.connection.reconnection_countdown = null
if (state === 'connecting') {
// initial connection
} else if (state === 'reconnecting') {
// reconnection after a connection has failed
this.stopReconnectCountdownTimer()
this.$scope.connection.reconnecting = true
// if reconnecting takes more than 1s (it doesn't, usually) show the
// 'reconnecting...' warning
setTimeout(() => {
if (
this.$scope.connection.reconnecting &&
this.$scope.connection.jobId === jobId
) {
this.$scope.connection.stillReconnecting = true
}
}, 1000)
} else if (state === 'reconnectFailed') {
// reconnect attempt failed
} else if (state === 'authenticating') {
// socket connection has been established, trying to authenticate
} else if (state === 'joining') {
// authenticated, joining project
this.$scope.connection.joining = true
} else if (state === 'ready') {
// project has been joined
} else if (state === 'waitingCountdown') {
// disconnected and waiting to reconnect via the countdown timer
this.stopReconnectCountdownTimer()
} else if (state === 'waitingGracefully') {
// disconnected and waiting to reconnect gracefully
this.stopReconnectCountdownTimer()
} else if (state === 'inactive') {
// disconnected and not trying to reconnect (inactive)
} else if (state === 'error') {
// something is wrong
} else {
debugConsole.log(
`[WARN] [updateConnectionManagerState ${jobId}] got unrecognised state ${state}`
)
}
})
}
expectConnectionManagerState(state, jobId) {
if (
this.$scope.connection.state === state &&
(!jobId || jobId === this.$scope.connection.jobId)
) {
return true
}
debugConsole.log(
`[WARN] [state mismatch] expected state ${state}${
jobId ? '/' + jobId : ''
} when in ${this.$scope.connection.state}/${
this.$scope.connection.jobId
}`
)
return false
}
// Error reporting, which can reload the page if appropriate
reportConnectionError(err) {
debugConsole.log('[socket.io] reporting connection error')
this.updateConnectionManagerState('error')
if (
(err != null ? err.message : undefined) === 'not authorized' ||
(err != null ? err.message : undefined) === 'invalid session'
) {
return (window.location = `/login?redir=${encodeURI(
window.location.pathname
)}`)
} else {
this.ide.socket.disconnect()
return this.ide.showGenericMessageModal(
'Something went wrong connecting',
`\
Something went wrong connecting to your project. Please refresh if this continues to happen.\
`
)
}
}
handleJoinProjectResponse({ project, permissionsLevel, protocolVersion }) {
if (
this.$scope.protocolVersion != null &&
this.$scope.protocolVersion !== protocolVersion
) {
location.reload(true)
}
this.$scope.$apply(() => {
this.updateConnectionManagerState('ready')
this.$scope.protocolVersion = protocolVersion
const defaultProjectAttributes = { rootDoc_id: null }
this.$scope.project = { ...defaultProjectAttributes, ...project }
this.$scope.permissionsLevel = permissionsLevel
this.ide.loadingManager.socketLoaded()
window.dispatchEvent(
new CustomEvent('project:joined', { detail: this.$scope.project })
)
this.$scope.$broadcast('project:joined')
})
}
reconnectImmediately() {
this.disconnect()
return this.tryReconnect()
}
disconnect(options) {
if (options && options.permanent) {
debugConsole.log('[disconnect] shutting down ConnectionManager')
this.updateConnectionManagerState('inactive')
this.shuttingDown = true // prevent reconnection attempts
} else if (this.ide.socket.socket && !this.ide.socket.socket.connected) {
debugConsole.log(
'[socket.io] skipping disconnect because socket.io has not connected'
)
return
}
debugConsole.log('[socket.io] disconnecting client')
return this.ide.socket.disconnect()
}
startAutoReconnectCountdown() {
this.updateConnectionManagerState('waitingCountdown')
const connectionId = this.$scope.connection.jobId
let countdown
debugConsole.log('[ConnectionManager] starting autoreconnect countdown')
const twoMinutes = 2 * 60 * 1000
if (
this.lastUserAction != null &&
new Date() - this.lastUserAction > twoMinutes
) {
// between 1 minute and 3 minutes
countdown = 60 + Math.floor(Math.random() * 120)
} else {
countdown = 3 + Math.floor(Math.random() * 7)
}
if (this.userIsLeavingPage) {
// user will have pressed refresh or back etc
return
}
this.$scope.$apply(() => {
this.$scope.connection.reconnecting = false
this.$scope.connection.stillReconnecting = false
this.$scope.connection.joining = false
this.$scope.connection.reconnection_countdown = countdown
})
setTimeout(() => {
if (!this.connected && !this.countdownTimeoutId) {
this.countdownTimeoutId = setTimeout(
() => this.decreaseCountdown(connectionId),
1000
)
}
}, 200)
}
stopReconnectCountdownTimer() {
// clear timeout and set to null so we know there is no countdown running
if (this.countdownTimeoutId != null) {
debugConsole.log(
'[ConnectionManager] cancelling existing reconnect timer'
)
clearTimeout(this.countdownTimeoutId)
this.countdownTimeoutId = null
}
}
decreaseCountdown(connectionId) {
this.countdownTimeoutId = null
if (this.$scope.connection.reconnection_countdown == null) {
return
}
if (
!this.expectConnectionManagerState('waitingCountdown', connectionId)
) {
debugConsole.log(
`[ConnectionManager] Aborting stale countdown ${connectionId}`
)
return
}
debugConsole.log(
'[ConnectionManager] decreasing countdown',
this.$scope.connection.reconnection_countdown
)
this.$scope.$apply(() => {
this.$scope.connection.reconnection_countdown--
})
if (this.$scope.connection.reconnection_countdown <= 0) {
this.$scope.connection.reconnecting = false
this.$scope.$apply(() => {
this.tryReconnect()
})
} else {
this.countdownTimeoutId = setTimeout(
() => this.decreaseCountdown(connectionId),
1000
)
}
}
tryReconnect() {
debugConsole.log('[ConnectionManager] tryReconnect')
if (
this.connected ||
this.shuttingDown ||
this.$scope.connection.reconnecting
) {
return
}
this.updateConnectionManagerState('reconnecting')
debugConsole.log('[ConnectionManager] Starting new connection')
const removeHandler = () => {
this.ide.socket.removeListener('error', handleFailure)
this.ide.socket.removeListener('connect', handleSuccess)
}
const handleFailure = () => {
debugConsole.log('[ConnectionManager] tryReconnect: failed')
removeHandler()
this.updateConnectionManagerState('reconnectFailed')
this.tryReconnectWithRateLimit({ force: true })
}
const handleSuccess = () => {
debugConsole.log('[ConnectionManager] tryReconnect: success')
removeHandler()
}
this.ide.socket.on('error', handleFailure)
this.ide.socket.on('connect', handleSuccess)
// use socket.io connect() here to make a single attempt, the
// reconnect() method makes multiple attempts
this.ide.socket.socket.connect()
// record the time of the last attempt to connect
this.lastConnectionAttempt = new Date()
}
tryReconnectWithRateLimit(options) {
// bail out if the reconnect is already in progress
if (this.$scope.connection.reconnecting || this.connected) {
return
}
// bail out if we are going to reconnect soon anyway
const reconnectingSoon =
this.$scope.connection.reconnection_countdown != null &&
this.$scope.connection.reconnection_countdown <= 5
const clickedTryNow = options != null ? options.force : undefined // user requested reconnection
if (reconnectingSoon && !clickedTryNow) {
return
}
// bail out if we tried reconnecting recently
const allowedInterval = clickedTryNow
? this.MIN_RETRY_INTERVAL
: this.BACKGROUND_RETRY_INTERVAL
if (
this.lastConnectionAttempt != null &&
new Date() - this.lastConnectionAttempt < allowedInterval
) {
if (this.$scope.connection.state !== 'waitingCountdown') {
this.startAutoReconnectCountdown()
}
return
}
this.tryReconnect()
}
disconnectIfInactive() {
this.userIsInactive =
new Date() - this.lastUserAction > this.disconnectAfterMs
if (this.userIsInactive && this.connected) {
this.disconnect()
return this.$scope.$apply(() => {
return (this.$scope.connection.inactive_disconnect = true)
}) // 5 minutes
}
}
reconnectGracefully(force) {
if (this.reconnectGracefullyStarted == null) {
this.reconnectGracefullyStarted = new Date()
} else {
if (!force) {
debugConsole.log(
'[reconnectGracefully] reconnection is already in process, so skipping'
)
return
}
}
const userIsInactive =
new Date() - this.lastUserAction >
this.RECONNECT_GRACEFULLY_RETRY_INTERVAL
const maxIntervalReached =
new Date() - this.reconnectGracefullyStarted >
this.MAX_RECONNECT_GRACEFULLY_INTERVAL
if (userIsInactive || maxIntervalReached) {
debugConsole.log(
"[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
)
this._reconnectGracefullyNow()
} else {
debugConsole.log(
'[reconnectGracefully] User is working, will try again in 5 seconds'
)
this.updateConnectionManagerState('waitingGracefully')
setTimeout(() => {
this.reconnectGracefully(true)
}, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL)
}
}
_reconnectGracefullyNow() {
this.gracefullyReconnecting = true
this.reconnectGracefullyStarted = null
// Clear cookie so we don't go to the same backend server
$.cookie('SERVERID', '', { expires: -1, path: '/' })
return this.reconnectImmediately()
}
}
ConnectionManager.initClass()
return ConnectionManager
})()