connection-manager.ts 11 KB

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