connection-manager.ts 9.4 KB

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