connection-manager.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import { ConnectionError, ConnectionState } from './types/connection-state'
  2. import SocketIoShim from '../../../ide/connection/SocketIoShim'
  3. import getMeta from '../../../utils/meta'
  4. import { Socket } from '@/features/ide-react/connection/types/socket'
  5. import { debugConsole } from '@/utils/debugging'
  6. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  7. const TWO_MINUTES_IN_MS = 2 * 60 * 1000
  8. const DISCONNECT_AFTER_MS = ONE_HOUR_IN_MS * 24
  9. const CONNECTION_ERROR_RECONNECT_DELAY = 1000
  10. const USER_ACTIVITY_RECONNECT_NOW_DELAY = 1000
  11. const USER_ACTIVITY_RECONNECT_DELAY = 5000
  12. const JOIN_PROJECT_RATE_LIMITED_DELAY = 15 * 1000
  13. const RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS = 5000
  14. const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = 45 * 1000
  15. const MAX_RETRY_CONNECT = 5
  16. const initialState: ConnectionState = {
  17. readyState: WebSocket.CLOSED,
  18. forceDisconnected: false,
  19. inactiveDisconnect: false,
  20. lastConnectionAttempt: 0,
  21. reconnectAt: null,
  22. forcedDisconnectDelay: 0,
  23. error: '',
  24. }
  25. export class StateChangeEvent extends CustomEvent<{
  26. state: ConnectionState
  27. previousState: ConnectionState
  28. }> {}
  29. export class ConnectionManager extends EventTarget {
  30. state: ConnectionState = initialState
  31. private connectionAttempt: number | null = null
  32. private gracefullyReconnectUntil = 0
  33. private lastUserActivity: number
  34. private protocolVersion = -1
  35. private readonly idleDisconnectInterval: number
  36. private reconnectCountdownInterval = 0
  37. readonly socket: Socket
  38. private userIsLeavingPage = false
  39. constructor() {
  40. super()
  41. this.lastUserActivity = performance.now()
  42. this.idleDisconnectInterval = window.setInterval(() => {
  43. this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)
  44. }, ONE_HOUR_IN_MS)
  45. window.addEventListener('online', () => this.onOnline())
  46. window.addEventListener('beforeunload', () => {
  47. this.userIsLeavingPage = true
  48. })
  49. const parsedURL = new URL(
  50. getMeta('ol-wsUrl') || '/socket.io',
  51. window.origin
  52. )
  53. const socket = SocketIoShim.connect(parsedURL.origin, {
  54. resource: parsedURL.pathname.slice(1),
  55. 'auto connect': false,
  56. 'connect timeout': 30 * 1000,
  57. 'force new connection': true,
  58. query: new URLSearchParams({
  59. projectId: getMeta('ol-project_id'),
  60. }).toString(),
  61. reconnect: false,
  62. }) as unknown as Socket
  63. this.socket = socket
  64. // bail out if socket.io failed to load (e.g. the real-time server is down)
  65. if (typeof window.io !== 'object') {
  66. this.switchToWsFallbackIfPossible()
  67. debugConsole.error(
  68. 'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
  69. )
  70. this.changeState({
  71. ...this.state,
  72. error: 'io-not-loaded',
  73. })
  74. return
  75. }
  76. socket.on('disconnect', () => this.onDisconnect())
  77. socket.on('error', () => this.onConnectError())
  78. socket.on('connect_failed', () => this.onConnectError())
  79. socket.on('joinProjectResponse', body => this.onJoinProjectResponse(body))
  80. socket.on('connectionRejected', err => this.onConnectionRejected(err))
  81. socket.on('reconnectGracefully', () => this.onReconnectGracefully())
  82. socket.on('forceDisconnect', (_, delay) => this.onForceDisconnect(delay))
  83. this.tryReconnect()
  84. }
  85. close(error: ConnectionError) {
  86. this.onForceDisconnect(0, error)
  87. }
  88. tryReconnectNow() {
  89. this.tryReconnectWithBackoff(USER_ACTIVITY_RECONNECT_NOW_DELAY)
  90. }
  91. // Called when document is clicked or the editor cursor changes
  92. registerUserActivity() {
  93. this.lastUserActivity = performance.now()
  94. this.userIsLeavingPage = false
  95. this.ensureIsConnected()
  96. }
  97. private changeState(state: ConnectionState) {
  98. const previousState = this.state
  99. this.state = state
  100. debugConsole.log('[ConnectionManager] changed state', {
  101. previousState,
  102. state,
  103. })
  104. this.dispatchEvent(
  105. new StateChangeEvent('statechange', { detail: { state, previousState } })
  106. )
  107. }
  108. private switchToWsFallbackIfPossible() {
  109. const search = new URLSearchParams(window.location.search)
  110. if (getMeta('ol-wsUrl') && search.get('ws') !== 'fallback') {
  111. // if we tried to boot from a custom real-time backend and failed,
  112. // try reloading and falling back to the siteUrl
  113. search.set('ws', 'fallback')
  114. window.location.search = search.toString()
  115. return true
  116. }
  117. return false
  118. }
  119. private onOnline() {
  120. if (!this.state.inactiveDisconnect) this.ensureIsConnected()
  121. }
  122. private onConnectionRejected(err: any) {
  123. switch (err?.message) {
  124. case 'retry': // pending real-time shutdown
  125. this.startAutoReconnectCountdown(0)
  126. break
  127. case 'rate-limit hit when joining project': // rate-limited
  128. this.changeState({
  129. ...this.state,
  130. error: 'rate-limited',
  131. })
  132. break
  133. case 'not authorized': // not logged in
  134. case 'invalid session': // expired session
  135. this.changeState({
  136. ...this.state,
  137. error: 'not-logged-in',
  138. forceDisconnected: true,
  139. })
  140. break
  141. case 'project not found': // project has been deleted
  142. this.changeState({
  143. ...this.state,
  144. error: 'project-deleted',
  145. forceDisconnected: true,
  146. })
  147. break
  148. default:
  149. this.changeState({
  150. ...this.state,
  151. error: 'unable-to-join',
  152. })
  153. break
  154. }
  155. }
  156. private onConnectError() {
  157. if (this.connectionAttempt === null) return // ignore errors once connected.
  158. if (this.connectionAttempt++ < MAX_RETRY_CONNECT) {
  159. setTimeout(
  160. () => {
  161. if (this.canReconnect()) this.socket.socket.connect()
  162. },
  163. // add jitter to spread reconnects
  164. this.connectionAttempt *
  165. (1 + Math.random()) *
  166. CONNECTION_ERROR_RECONNECT_DELAY
  167. )
  168. } else {
  169. if (!this.switchToWsFallbackIfPossible()) {
  170. this.disconnect()
  171. this.changeState({
  172. ...this.state,
  173. error: 'unable-to-connect',
  174. })
  175. }
  176. }
  177. }
  178. private onDisconnect() {
  179. this.connectionAttempt = null
  180. this.changeState({
  181. ...this.state,
  182. readyState: WebSocket.CLOSED,
  183. })
  184. if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)) return
  185. if (this.state.error === 'rate-limited') {
  186. this.tryReconnectWithBackoff(JOIN_PROJECT_RATE_LIMITED_DELAY)
  187. } else {
  188. this.startAutoReconnectCountdown(0)
  189. }
  190. }
  191. private onForceDisconnect(
  192. delay: number,
  193. error: ConnectionError = 'maintenance'
  194. ) {
  195. clearInterval(this.idleDisconnectInterval)
  196. clearTimeout(this.reconnectCountdownInterval)
  197. window.removeEventListener('online', this.onOnline)
  198. window.setTimeout(() => this.disconnect(), 1000 * delay)
  199. this.changeState({
  200. ...this.state,
  201. forceDisconnected: true,
  202. forcedDisconnectDelay: delay,
  203. error,
  204. })
  205. }
  206. private onJoinProjectResponse({
  207. protocolVersion,
  208. publicId,
  209. }: {
  210. protocolVersion: number
  211. publicId: string
  212. }) {
  213. if (
  214. this.protocolVersion !== -1 &&
  215. this.protocolVersion !== protocolVersion
  216. ) {
  217. this.onForceDisconnect(0, 'protocol-changed')
  218. return
  219. }
  220. this.protocolVersion = protocolVersion
  221. this.socket.publicId = publicId
  222. this.connectionAttempt = null
  223. this.changeState({
  224. ...this.state,
  225. readyState: WebSocket.OPEN,
  226. error: '',
  227. reconnectAt: null,
  228. })
  229. }
  230. private onReconnectGracefully() {
  231. // Disconnect idle users a little earlier than the 24h limit.
  232. if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS * 0.75)) return
  233. if (this.gracefullyReconnectUntil) return
  234. this.gracefullyReconnectUntil =
  235. performance.now() + MAX_RECONNECT_GRACEFULLY_INTERVAL_MS
  236. this.tryReconnectGracefully()
  237. }
  238. private canReconnect(): boolean {
  239. if (this.state.readyState === WebSocket.OPEN) return false // no need to reconnect
  240. if (this.state.forceDisconnected) return false // reconnecting blocked
  241. return true
  242. }
  243. private isReconnectingSoon(ms: number): boolean {
  244. if (!this.state.reconnectAt) return false
  245. return this.state.reconnectAt - performance.now() <= ms
  246. }
  247. private hasReconnectedRecently(ms: number): boolean {
  248. return performance.now() - this.state.lastConnectionAttempt < ms
  249. }
  250. private isUserInactiveSince(since: number): boolean {
  251. return performance.now() - this.lastUserActivity > since
  252. }
  253. private disconnectIfIdleSince(threshold: number): boolean {
  254. if (!this.isUserInactiveSince(threshold)) return false
  255. const previouslyClosed = this.state.readyState === WebSocket.CLOSED
  256. this.changeState({
  257. ...this.state,
  258. readyState: WebSocket.CLOSED,
  259. inactiveDisconnect: true,
  260. })
  261. if (!previouslyClosed) {
  262. this.socket.disconnect()
  263. }
  264. return true
  265. }
  266. disconnect() {
  267. this.changeState({
  268. ...this.state,
  269. readyState: WebSocket.CLOSED,
  270. })
  271. this.socket.disconnect()
  272. }
  273. private ensureIsConnected() {
  274. if (this.state.readyState === WebSocket.OPEN) return
  275. this.tryReconnectWithBackoff(
  276. this.state.error === 'rate-limited'
  277. ? JOIN_PROJECT_RATE_LIMITED_DELAY
  278. : USER_ACTIVITY_RECONNECT_DELAY
  279. )
  280. }
  281. private startAutoReconnectCountdown(backoff: number) {
  282. if (this.userIsLeavingPage) return
  283. if (!this.canReconnect()) return
  284. let countdown
  285. if (this.isUserInactiveSince(TWO_MINUTES_IN_MS)) {
  286. countdown = 60 + Math.floor(Math.random() * 2 * 60)
  287. } else {
  288. countdown = 3 + Math.floor(Math.random() * 7)
  289. }
  290. const ms = backoff + countdown * 1000
  291. if (this.isReconnectingSoon(ms)) return
  292. this.changeState({
  293. ...this.state,
  294. reconnectAt: performance.now() + ms,
  295. })
  296. clearTimeout(this.reconnectCountdownInterval)
  297. this.reconnectCountdownInterval = window.setTimeout(() => {
  298. if (this.isReconnectingSoon(0)) {
  299. this.tryReconnect()
  300. }
  301. }, ms)
  302. }
  303. private tryReconnect() {
  304. this.gracefullyReconnectUntil = 0
  305. this.changeState({
  306. ...this.state,
  307. reconnectAt: null,
  308. })
  309. if (!this.canReconnect()) return
  310. this.connectionAttempt = 0
  311. this.changeState({
  312. ...this.state,
  313. readyState: WebSocket.CONNECTING,
  314. error: '',
  315. inactiveDisconnect: false,
  316. lastConnectionAttempt: performance.now(),
  317. })
  318. this.addReconnectListeners()
  319. this.socket.socket.connect()
  320. }
  321. private addReconnectListeners() {
  322. const handleFailure = () => {
  323. removeSocketListeners()
  324. this.startAutoReconnectCountdown(0)
  325. }
  326. const handleSuccess = () => {
  327. removeSocketListeners()
  328. }
  329. const removeSocketListeners = () => {
  330. this.socket.removeListener('error', handleFailure)
  331. this.socket.removeListener('connect', handleSuccess)
  332. }
  333. this.socket.on('error', handleFailure)
  334. this.socket.on('connect', handleSuccess)
  335. }
  336. private tryReconnectGracefully() {
  337. if (
  338. this.state.readyState === WebSocket.CLOSED ||
  339. !this.gracefullyReconnectUntil
  340. )
  341. return
  342. if (
  343. this.gracefullyReconnectUntil < performance.now() ||
  344. this.isUserInactiveSince(RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
  345. ) {
  346. this.disconnect()
  347. this.tryReconnect()
  348. } else {
  349. setTimeout(() => {
  350. this.tryReconnectGracefully()
  351. }, RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
  352. }
  353. }
  354. private tryReconnectWithBackoff(backoff: number) {
  355. if (this.hasReconnectedRecently(backoff)) {
  356. this.startAutoReconnectCountdown(backoff)
  357. } else {
  358. this.tryReconnect()
  359. }
  360. }
  361. }