connection-manager.ts 9.5 KB

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