connection-manager.ts 10 KB

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