@@ -35,6 +35,73 @@ export enum ConnectionState {
3535 DISPOSED = "DISPOSED" ,
3636}
3737
38+ /**
39+ * Actions that trigger state transitions.
40+ */
41+ type StateAction =
42+ | { readonly type : "CONNECT" }
43+ | { readonly type : "OPEN" }
44+ | { readonly type : "SCHEDULE_RETRY" }
45+ | { readonly type : "DISCONNECT" }
46+ | { readonly type : "DISPOSE" } ;
47+
48+ /**
49+ * Pure reducer function for state transitions.
50+ */
51+ function reduceState (
52+ state : ConnectionState ,
53+ action : StateAction ,
54+ ) : ConnectionState {
55+ switch ( action . type ) {
56+ case "CONNECT" :
57+ switch ( state ) {
58+ case ConnectionState . IDLE :
59+ case ConnectionState . CONNECTED :
60+ case ConnectionState . AWAITING_RETRY :
61+ case ConnectionState . DISCONNECTED :
62+ return ConnectionState . CONNECTING ;
63+ default :
64+ return state ;
65+ }
66+
67+ case "OPEN" :
68+ switch ( state ) {
69+ case ConnectionState . CONNECTING :
70+ return ConnectionState . CONNECTED ;
71+ default :
72+ return state ;
73+ }
74+
75+ case "SCHEDULE_RETRY" :
76+ switch ( state ) {
77+ case ConnectionState . CONNECTING :
78+ case ConnectionState . CONNECTED :
79+ return ConnectionState . AWAITING_RETRY ;
80+ default :
81+ return state ;
82+ }
83+
84+ case "DISCONNECT" :
85+ switch ( state ) {
86+ case ConnectionState . IDLE :
87+ case ConnectionState . CONNECTING :
88+ case ConnectionState . CONNECTED :
89+ case ConnectionState . AWAITING_RETRY :
90+ return ConnectionState . DISCONNECTED ;
91+ default :
92+ return state ;
93+ }
94+
95+ case "DISPOSE" :
96+ switch ( state ) {
97+ case ConnectionState . DISPOSED :
98+ return state ;
99+ default :
100+ return ConnectionState . DISPOSED ;
101+ }
102+ }
103+ }
104+
38105export type SocketFactory < TData > = ( ) => Promise < UnidirectionalStream < TData > > ;
39106
40107export interface ReconnectingWebSocketOptions {
@@ -65,10 +132,28 @@ export class ReconnectingWebSocket<
65132 #backoffMs: number ;
66133 #reconnectTimeoutId: NodeJS . Timeout | null = null ;
67134 #state: ConnectionState = ConnectionState . IDLE ;
68- #pendingReconnect = false ; // Queue reconnect during CONNECTING state
69135 #certRefreshAttempted = false ; // Tracks if cert refresh was already attempted this connection cycle
70136 readonly #onDispose?: ( ) => void ;
71137
138+ /**
139+ * Dispatch an action to transition state. Returns true if transition is allowed.
140+ */
141+ #dispatch( action : StateAction ) : boolean {
142+ const newState = reduceState ( this . #state, action ) ;
143+ if ( newState === this . #state) {
144+ // Allow CONNECT from CONNECTING as a "restart" operation
145+ if (
146+ action . type === "CONNECT" &&
147+ this . #state === ConnectionState . CONNECTING
148+ ) {
149+ return true ;
150+ }
151+ return false ;
152+ }
153+ this . #state = newState ;
154+ return true ;
155+ }
156+
72157 private constructor (
73158 socketFactory : SocketFactory < TData > ,
74159 logger : Logger ,
@@ -153,7 +238,6 @@ export class ReconnectingWebSocket<
153238 }
154239
155240 if ( this . #state === ConnectionState . DISCONNECTED ) {
156- this . #state = ConnectionState . IDLE ;
157241 this . #backoffMs = this . #options. initialBackoffMs ;
158242 this . #certRefreshAttempted = false ; // User-initiated reconnect, allow retry
159243 }
@@ -163,11 +247,6 @@ export class ReconnectingWebSocket<
163247 this . #reconnectTimeoutId = null ;
164248 }
165249
166- if ( this . #state === ConnectionState . CONNECTING ) {
167- this . #pendingReconnect = true ;
168- return ;
169- }
170-
171250 // connect() handles all errors internally
172251 void this . connect ( ) ;
173252 }
@@ -176,14 +255,9 @@ export class ReconnectingWebSocket<
176255 * Temporarily disconnect the socket. Can be resumed via reconnect().
177256 */
178257 disconnect ( code ?: number , reason ?: string ) : void {
179- if (
180- this . #state === ConnectionState . DISPOSED ||
181- this . #state === ConnectionState . DISCONNECTED
182- ) {
258+ if ( ! this . #dispatch( { type : "DISCONNECT" } ) ) {
183259 return ;
184260 }
185-
186- this . #state = ConnectionState . DISCONNECTED ;
187261 this . clearCurrentSocket ( code , reason ) ;
188262 }
189263
@@ -205,15 +279,9 @@ export class ReconnectingWebSocket<
205279 }
206280
207281 private async connect ( ) : Promise < void > {
208- if (
209- this . #state !== ConnectionState . IDLE &&
210- this . #state !== ConnectionState . CONNECTED &&
211- this . #state !== ConnectionState . AWAITING_RETRY
212- ) {
282+ if ( ! this . #dispatch( { type : "CONNECT" } ) ) {
213283 return ;
214284 }
215-
216- this . #state = ConnectionState . CONNECTING ;
217285 try {
218286 // Close any existing socket before creating a new one
219287 if ( this . #currentSocket) {
@@ -240,7 +308,9 @@ export class ReconnectingWebSocket<
240308 return ;
241309 }
242310
243- this . #state = ConnectionState . CONNECTED ;
311+ if ( ! this . #dispatch( { type : "OPEN" } ) ) {
312+ return ;
313+ }
244314 // Reset backoff on successful connection
245315 this . #backoffMs = this . #options. initialBackoffMs ;
246316 this . #certRefreshAttempted = false ;
@@ -298,25 +368,14 @@ export class ReconnectingWebSocket<
298368 } ) ;
299369 } catch ( error ) {
300370 await this . handleConnectionError ( error ) ;
301- } finally {
302- if ( this . #pendingReconnect) {
303- this . #pendingReconnect = false ;
304- this . reconnect ( ) ;
305- }
306371 }
307372 }
308373
309374 private scheduleReconnect ( ) : void {
310- if (
311- this . #state === ConnectionState . DISPOSED ||
312- this . #state === ConnectionState . DISCONNECTED ||
313- this . #state === ConnectionState . AWAITING_RETRY
314- ) {
375+ if ( ! this . #dispatch( { type : "SCHEDULE_RETRY" } ) ) {
315376 return ;
316377 }
317378
318- this . #state = ConnectionState . AWAITING_RETRY ;
319-
320379 const jitter =
321380 this . #backoffMs * this . #options. jitterFactor * ( Math . random ( ) * 2 - 1 ) ;
322381 const delayMs = Math . max ( 0 , this . #backoffMs + jitter ) ;
@@ -401,6 +460,10 @@ export class ReconnectingWebSocket<
401460 this . #state === ConnectionState . DISPOSED ||
402461 this . #state === ConnectionState . DISCONNECTED
403462 ) {
463+ this . #logger. debug (
464+ `Ignoring connection error in ${ this . #state} state for ${ this . #route} ` ,
465+ error ,
466+ ) ;
404467 return ;
405468 }
406469
@@ -442,11 +505,9 @@ export class ReconnectingWebSocket<
442505 }
443506
444507 private dispose ( code ?: number , reason ?: string ) : void {
445- if ( this . #state === ConnectionState . DISPOSED ) {
508+ if ( ! this . #dispatch ( { type : "DISPOSE" } ) ) {
446509 return ;
447510 }
448-
449- this . #state = ConnectionState . DISPOSED ;
450511 this . clearCurrentSocket ( code , reason ) ;
451512
452513 for ( const set of Object . values ( this . #eventHandlers) ) {
@@ -457,9 +518,6 @@ export class ReconnectingWebSocket<
457518 }
458519
459520 private clearCurrentSocket ( code ?: number , reason ?: string ) : void {
460- // Clear pending reconnect to prevent resume
461- this . #pendingReconnect = false ;
462-
463521 if ( this . #reconnectTimeoutId !== null ) {
464522 clearTimeout ( this . #reconnectTimeoutId) ;
465523 this . #reconnectTimeoutId = null ;
0 commit comments