@@ -17,6 +17,24 @@ import type {
1717 UnidirectionalStream ,
1818} from "./eventStreamConnection" ;
1919
20+ /**
21+ * Connection states for the ReconnectingWebSocket state machine.
22+ */
23+ export enum ConnectionState {
24+ /** Initial state, ready to connect */
25+ IDLE = "IDLE" ,
26+ /** Actively running connect() - WS factory in progress */
27+ CONNECTING = "CONNECTING" ,
28+ /** Socket is open and working */
29+ CONNECTED = "CONNECTED" ,
30+ /** Waiting for backoff timer before attempting reconnection */
31+ AWAITING_RETRY = "AWAITING_RETRY" ,
32+ /** Temporarily paused - user must call reconnect() to resume */
33+ DISCONNECTED = "DISCONNECTED" ,
34+ /** Permanently closed - cannot be reused */
35+ DISPOSED = "DISPOSED" ,
36+ }
37+
2038export type SocketFactory < TData > = ( ) => Promise < UnidirectionalStream < TData > > ;
2139
2240export interface ReconnectingWebSocketOptions {
@@ -49,10 +67,8 @@ export class ReconnectingWebSocket<
4967 #lastRoute = "unknown" ; // Cached route for logging when socket is closed
5068 #backoffMs: number ;
5169 #reconnectTimeoutId: NodeJS . Timeout | null = null ;
52- #isDisconnected = false ; // Temporary pause, can be resumed via reconnect()
53- #isDisposed = false ; // Permanent disposal, cannot be resumed
54- #isConnecting = false ;
55- #pendingReconnect = false ;
70+ #state: ConnectionState = ConnectionState . IDLE ;
71+ #pendingReconnect = false ; // Queue reconnect during CONNECTING state
5672 #certRefreshAttempted = false ; // Tracks if cert refresh was already attempted this connection cycle
5773 readonly #onDispose?: ( ) => void ;
5874
@@ -97,11 +113,10 @@ export class ReconnectingWebSocket<
97113 }
98114
99115 /**
100- * Returns true if the socket is temporarily disconnected and not attempting to reconnect.
101- * Use reconnect() to resume.
116+ * Returns the current connection state.
102117 */
103- get isDisconnected ( ) : boolean {
104- return this . #isDisconnected ;
118+ get state ( ) : string {
119+ return this . #state ;
105120 }
106121
107122 /**
@@ -136,22 +151,22 @@ export class ReconnectingWebSocket<
136151 * Resumes the socket if previously disconnected via disconnect().
137152 */
138153 reconnect ( ) : void {
139- if ( this . #isDisconnected) {
140- this . #isDisconnected = false ;
141- this . #backoffMs = this . #options. initialBackoffMs ;
142- this . #certRefreshAttempted = false ; // User-initiated reconnect, allow retry
154+ if ( this . #state === ConnectionState . DISPOSED ) {
155+ return ;
143156 }
144157
145- if ( this . #isDisposed) {
146- return ;
158+ if ( this . #state === ConnectionState . DISCONNECTED ) {
159+ this . #state = ConnectionState . IDLE ;
160+ this . #backoffMs = this . #options. initialBackoffMs ;
161+ this . #certRefreshAttempted = false ; // User-initiated reconnect, allow retry
147162 }
148163
149164 if ( this . #reconnectTimeoutId !== null ) {
150165 clearTimeout ( this . #reconnectTimeoutId) ;
151166 this . #reconnectTimeoutId = null ;
152167 }
153168
154- if ( this . #isConnecting ) {
169+ if ( this . #state === ConnectionState . CONNECTING ) {
155170 this . #pendingReconnect = true ;
156171 return ;
157172 }
@@ -164,16 +179,19 @@ export class ReconnectingWebSocket<
164179 * Temporarily disconnect the socket. Can be resumed via reconnect().
165180 */
166181 disconnect ( code ?: number , reason ?: string ) : void {
167- if ( this . #isDisposed || this . #isDisconnected) {
182+ if (
183+ this . #state === ConnectionState . DISPOSED ||
184+ this . #state === ConnectionState . DISCONNECTED
185+ ) {
168186 return ;
169187 }
170188
171- this . #isDisconnected = true ;
189+ this . #state = ConnectionState . DISCONNECTED ;
172190 this . clearCurrentSocket ( code , reason ) ;
173191 }
174192
175193 close ( code ?: number , reason ?: string ) : void {
176- if ( this . #isDisposed ) {
194+ if ( this . #state === ConnectionState . DISPOSED ) {
177195 return ;
178196 }
179197
@@ -190,11 +208,16 @@ export class ReconnectingWebSocket<
190208 }
191209
192210 private async connect ( ) : Promise < void > {
193- if ( this . #isDisposed || this . #isDisconnected || this . #isConnecting) {
211+ // Only allow connecting from IDLE, CONNECTED (reconnect), or AWAITING_RETRY states
212+ if (
213+ this . #state === ConnectionState . DISPOSED ||
214+ this . #state === ConnectionState . DISCONNECTED ||
215+ this . #state === ConnectionState . CONNECTING
216+ ) {
194217 return ;
195218 }
196219
197- this . #isConnecting = true ;
220+ this . #state = ConnectionState . CONNECTING ;
198221 try {
199222 // Close any existing socket before creating a new one
200223 if ( this . #currentSocket) {
@@ -207,18 +230,20 @@ export class ReconnectingWebSocket<
207230
208231 const socket = await this . #socketFactory( ) ;
209232
210- // Check if disconnected/disposed while waiting for factory
211- if ( this . #isDisposed || this . #isDisconnected ) {
233+ // Check if state changed while waiting for factory (e.g., disconnect/dispose called)
234+ if ( this . #state !== ConnectionState . CONNECTING ) {
212235 socket . close ( WebSocketCloseCode . NORMAL , "Cancelled during connection" ) ;
213236 return ;
214237 }
215238
216239 this . #currentSocket = socket ;
217240 this . #lastRoute = this . #route;
241+ this . #state = ConnectionState . CONNECTED ;
218242
219243 socket . addEventListener ( "open" , ( event ) => {
244+ // Reset backoff on successful connection
220245 this . #backoffMs = this . #options. initialBackoffMs ;
221- this . #certRefreshAttempted = false ; // Reset on successful connection
246+ this . #certRefreshAttempted = false ;
222247 this . executeHandlers ( "open" , event ) ;
223248 } ) ;
224249
@@ -236,7 +261,10 @@ export class ReconnectingWebSocket<
236261 } ) ;
237262
238263 socket . addEventListener ( "close" , ( event ) => {
239- if ( this . #isDisposed || this . #isDisconnected) {
264+ if (
265+ this . #state === ConnectionState . DISPOSED ||
266+ this . #state === ConnectionState . DISCONNECTED
267+ ) {
240268 return ;
241269 }
242270
@@ -259,8 +287,6 @@ export class ReconnectingWebSocket<
259287 } catch ( error ) {
260288 await this . handleConnectionError ( error ) ;
261289 } finally {
262- this . #isConnecting = false ;
263-
264290 if ( this . #pendingReconnect) {
265291 this . #pendingReconnect = false ;
266292 this . reconnect ( ) ;
@@ -270,13 +296,15 @@ export class ReconnectingWebSocket<
270296
271297 private scheduleReconnect ( ) : void {
272298 if (
273- this . #isDisposed ||
274- this . #isDisconnected ||
275- this . #reconnectTimeoutId !== null
299+ this . #state === ConnectionState . DISPOSED ||
300+ this . #state === ConnectionState . DISCONNECTED ||
301+ this . #state === ConnectionState . AWAITING_RETRY
276302 ) {
277303 return ;
278304 }
279305
306+ this . #state = ConnectionState . AWAITING_RETRY ;
307+
280308 const jitter =
281309 this . #backoffMs * this . #options. jitterFactor * ( Math . random ( ) * 2 - 1 ) ;
282310 const delayMs = Math . max ( 0 , this . #backoffMs + jitter ) ;
@@ -361,7 +389,10 @@ export class ReconnectingWebSocket<
361389 * otherwise schedules a reconnect.
362390 */
363391 private async handleConnectionError ( error : unknown ) : Promise < void > {
364- if ( this . #isDisposed || this . #isDisconnected) {
392+ if (
393+ this . #state === ConnectionState . DISPOSED ||
394+ this . #state === ConnectionState . DISCONNECTED
395+ ) {
365396 return ;
366397 }
367398
@@ -403,11 +434,11 @@ export class ReconnectingWebSocket<
403434 }
404435
405436 private dispose ( code ?: number , reason ?: string ) : void {
406- if ( this . #isDisposed ) {
437+ if ( this . #state === ConnectionState . DISPOSED ) {
407438 return ;
408439 }
409440
410- this . #isDisposed = true ;
441+ this . #state = ConnectionState . DISPOSED ;
411442 this . clearCurrentSocket ( code , reason ) ;
412443
413444 for ( const set of Object . values ( this . #eventHandlers) ) {
0 commit comments