1+ import {
2+ WebSocketCloseCode ,
3+ NORMAL_CLOSURE_CODES ,
4+ UNRECOVERABLE_WS_CLOSE_CODES ,
5+ UNRECOVERABLE_HTTP_CODES ,
6+ } from "./codes" ;
7+
18import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket" ;
2- import type { CloseEvent } from "ws" ;
39
410import type { Logger } from "../logging/logger" ;
511
@@ -16,17 +22,16 @@ export type ReconnectingWebSocketOptions = {
1622 jitterFactor ?: number ;
1723} ;
1824
19- // 403 Forbidden, 410 Gone, 426 Upgrade Required, 1002/1003 Protocol errors
20- const UNRECOVERABLE_CLOSE_CODES = new Set ( [ 403 , 410 , 426 , 1002 , 1003 ] ) ;
21-
2225export class ReconnectingWebSocket < TData = unknown >
2326 implements UnidirectionalStream < TData >
2427{
2528 readonly #socketFactory: SocketFactory < TData > ;
2629 readonly #logger: Logger ;
2730 readonly #apiRoute: string ;
2831 readonly #options: Required < ReconnectingWebSocketOptions > ;
29- readonly #eventHandlers = {
32+ readonly #eventHandlers: {
33+ [ K in WebSocketEventType ] : Set < EventHandler < TData , K > > ;
34+ } = {
3035 open : new Set < EventHandler < TData , "open" > > ( ) ,
3136 close : new Set < EventHandler < TData , "close" > > ( ) ,
3237 error : new Set < EventHandler < TData , "error" > > ( ) ,
@@ -86,18 +91,14 @@ export class ReconnectingWebSocket<TData = unknown>
8691 event : TEvent ,
8792 callback : EventHandler < TData , TEvent > ,
8893 ) : void {
89- ( this . #eventHandlers[ event ] as Set < EventHandler < TData , TEvent > > ) . add (
90- callback ,
91- ) ;
94+ this . #eventHandlers[ event ] . add ( callback ) ;
9295 }
9396
9497 removeEventListener < TEvent extends WebSocketEventType > (
9598 event : TEvent ,
9699 callback : EventHandler < TData , TEvent > ,
97100 ) : void {
98- ( this . #eventHandlers[ event ] as Set < EventHandler < TData , TEvent > > ) . delete (
99- callback ,
100- ) ;
101+ this . #eventHandlers[ event ] . delete ( callback ) ;
101102 }
102103
103104 reconnect ( ) : void {
@@ -117,14 +118,7 @@ export class ReconnectingWebSocket<TData = unknown>
117118 }
118119
119120 // connect() will close any existing socket
120- this . connect ( ) . catch ( ( error ) => {
121- if ( ! this . #isDisposed) {
122- this . #logger. warn (
123- `Manual reconnection failed for ${ this . #apiRoute} : ${ error instanceof Error ? error . message : String ( error ) } ` ,
124- ) ;
125- this . scheduleReconnect ( ) ;
126- }
127- } ) ;
121+ this . connect ( ) . catch ( ( error ) => this . handleConnectionError ( error ) ) ;
128122 }
129123
130124 close ( code ?: number , reason ?: string ) : void {
@@ -135,12 +129,10 @@ export class ReconnectingWebSocket<TData = unknown>
135129 // Fire close handlers synchronously before disposing
136130 if ( this . #currentSocket) {
137131 this . executeHandlers ( "close" , {
138- code : code ?? 1000 ,
139- reason : reason ?? "" ,
132+ code : code ?? WebSocketCloseCode . NORMAL ,
133+ reason : reason ?? "Normal closure " ,
140134 wasClean : true ,
141- type : "close" ,
142- target : this . #currentSocket,
143- } as CloseEvent ) ;
135+ } ) ;
144136 }
145137
146138 this . dispose ( code , reason ) ;
@@ -155,7 +147,10 @@ export class ReconnectingWebSocket<TData = unknown>
155147 try {
156148 // Close any existing socket before creating a new one
157149 if ( this . #currentSocket) {
158- this . #currentSocket. close ( 1000 , "Replacing connection" ) ;
150+ this . #currentSocket. close (
151+ WebSocketCloseCode . NORMAL ,
152+ "Replacing connection" ,
153+ ) ;
159154 this . #currentSocket = null ;
160155 }
161156
@@ -182,7 +177,7 @@ export class ReconnectingWebSocket<TData = unknown>
182177
183178 this . executeHandlers ( "close" , event ) ;
184179
185- if ( UNRECOVERABLE_CLOSE_CODES . has ( event . code ) ) {
180+ if ( UNRECOVERABLE_WS_CLOSE_CODES . has ( event . code ) ) {
186181 this . #logger. error (
187182 `WebSocket connection closed with unrecoverable error code ${ event . code } ` ,
188183 ) ;
@@ -191,7 +186,7 @@ export class ReconnectingWebSocket<TData = unknown>
191186 }
192187
193188 // Don't reconnect on normal closure
194- if ( event . code === 1000 || event . code === 1001 ) {
189+ if ( NORMAL_CLOSURE_CODES . has ( event . code ) ) {
195190 return ;
196191 }
197192
@@ -223,14 +218,7 @@ export class ReconnectingWebSocket<TData = unknown>
223218
224219 this . #reconnectTimeoutId = setTimeout ( ( ) => {
225220 this . #reconnectTimeoutId = null ;
226- this . connect ( ) . catch ( ( error ) => {
227- if ( ! this . #isDisposed) {
228- this . #logger. warn (
229- `WebSocket connection failed for ${ this . #apiRoute} : ${ error instanceof Error ? error . message : String ( error ) } ` ,
230- ) ;
231- this . scheduleReconnect ( ) ;
232- }
233- } ) ;
221+ this . connect ( ) . catch ( ( error ) => this . handleConnectionError ( error ) ) ;
234222 } , delayMs ) ;
235223
236224 this . #backoffMs = Math . min ( this . #backoffMs * 2 , this . #options. maxBackoffMs ) ;
@@ -240,20 +228,56 @@ export class ReconnectingWebSocket<TData = unknown>
240228 event : TEvent ,
241229 eventData : Parameters < EventHandler < TData , TEvent > > [ 0 ] ,
242230 ) : void {
243- const handlers = this . #eventHandlers[ event ] as Set <
244- EventHandler < TData , TEvent >
245- > ;
246- for ( const handler of handlers ) {
231+ for ( const handler of this . #eventHandlers[ event ] ) {
247232 try {
248233 handler ( eventData ) ;
249234 } catch ( error ) {
250235 this . #logger. error (
251- `Error in ${ event } handler for ${ this . #apiRoute} : ${ error instanceof Error ? error . message : String ( error ) } ` ,
236+ `Error in ${ event } handler for ${ this . #apiRoute} ` ,
237+ error ,
252238 ) ;
253239 }
254240 }
255241 }
256242
243+ /**
244+ * Checks if the error is unrecoverable and disposes the connection,
245+ * otherwise schedules a reconnect.
246+ */
247+ private handleConnectionError ( error : unknown ) : void {
248+ if ( this . #isDisposed) {
249+ return ;
250+ }
251+
252+ if ( this . isUnrecoverableHttpError ( error ) ) {
253+ this . #logger. error (
254+ `Unrecoverable HTTP error during connection for ${ this . #apiRoute} ` ,
255+ error ,
256+ ) ;
257+ this . dispose ( ) ;
258+ return ;
259+ }
260+
261+ this . #logger. warn (
262+ `WebSocket connection failed for ${ this . #apiRoute} ` ,
263+ error ,
264+ ) ;
265+ this . scheduleReconnect ( ) ;
266+ }
267+
268+ /**
269+ * Check if an error contains an unrecoverable HTTP status code.
270+ */
271+ private isUnrecoverableHttpError ( error : unknown ) : boolean {
272+ const errorMessage = error instanceof Error ? error . message : String ( error ) ;
273+ for ( const code of UNRECOVERABLE_HTTP_CODES ) {
274+ if ( errorMessage . includes ( String ( code ) ) ) {
275+ return true ;
276+ }
277+ }
278+ return false ;
279+ }
280+
257281 private dispose ( code ?: number , reason ?: string ) : void {
258282 if ( this . #isDisposed) {
259283 return ;
0 commit comments