| 206 | * @returns A dispose function that tears down the connection. |
| 207 | */ |
| 208 | export function createReconnectingWebSocket<TSocket extends Closable>( |
| 209 | options: ReconnectingWebSocketOptions<TSocket>, |
| 210 | ): () => void { |
| 211 | const { |
| 212 | connect: connectFn, |
| 213 | onOpen, |
| 214 | onDisconnect, |
| 215 | baseMs = RECONNECT_BASE_MS, |
| 216 | maxMs = RECONNECT_MAX_MS, |
| 217 | factor = RECONNECT_FACTOR, |
| 218 | jitter = RECONNECT_JITTER, |
| 219 | random = Math.random, |
| 220 | } = options; |
| 221 | |
| 222 | let disposed = false; |
| 223 | let lastReconnectAttempt = 0; |
| 224 | let reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
| 225 | let activeSocket: TSocket | null = null; |
| 226 | |
| 227 | const scheduleReconnect = (reconnect: ReconnectSchedule) => { |
| 228 | if (disposed) { |
| 229 | return; |
| 230 | } |
| 231 | if (reconnectTimer !== null) { |
| 232 | clearTimeout(reconnectTimer); |
| 233 | } |
| 234 | lastReconnectAttempt = reconnect.attempt; |
| 235 | reconnectTimer = setTimeout(connect, reconnect.delayMs); |
| 236 | }; |
| 237 | |
| 238 | function connect() { |
| 239 | reconnectTimer = null; |
| 240 | if (disposed) { |
| 241 | return; |
| 242 | } |
| 243 | if (activeSocket) { |
| 244 | activeSocket.close(); |
| 245 | } |
| 246 | |
| 247 | const socket = connectFn(); |
| 248 | activeSocket = socket; |
| 249 | |
| 250 | const handleOpen = () => { |
| 251 | // Connection succeeded — reset backoff. |
| 252 | lastReconnectAttempt = 0; |
| 253 | onOpen?.(socket); |
| 254 | }; |
| 255 | |
| 256 | const handleDisconnect = () => { |
| 257 | // Guard against duplicate calls: browsers fire both "error" |
| 258 | // and "close" on a failed WebSocket, so we only process the |
| 259 | // first event per socket instance. |
| 260 | if (activeSocket !== socket || disposed) { |
| 261 | return; |
| 262 | } |
| 263 | activeSocket = null; |
| 264 | const reconnect = getReconnectSchedule({ |
| 265 | attempt: lastReconnectAttempt + 1, |