@@ -53,17 +53,38 @@ interface SocketListeners {
5353 message : ( event : MessageEvent < string | ArrayBuffer > ) => void ;
5454}
5555
56+ type NodeProcessLike = {
57+ on ?: ( event : string , listener : ( ) => void ) => unknown ;
58+ off ?: ( event : string , listener : ( ) => void ) => unknown ;
59+ removeListener ?: ( event : string , listener : ( ) => void ) => unknown ;
60+ } ;
61+
62+ const readInitialOnlineState = ( ) : boolean => {
63+ try {
64+ if ( typeof navigator !== "undefined" ) {
65+ const navOnline = ( navigator as { onLine ?: unknown } ) . onLine ;
66+ if ( typeof navOnline === "boolean" ) {
67+ return navOnline ;
68+ }
69+ }
70+ } catch { }
71+
72+ const globalScope = globalThis as {
73+ navigator ?: { onLine ?: unknown } ;
74+ } ;
75+ const maybe = globalScope . navigator ?. onLine ;
76+ return typeof maybe === "boolean" ? maybe : true ;
77+ } ;
78+
5679/**
5780 * The websocket client's high-level connection status.
5881 * - `Connecting`: initial connect or a manual `connect()` in progress.
5982 * - `Connected`: the websocket is open and usable.
60- * - `Reconnecting`: the connection dropped unexpectedly; the client is retrying with exponential backoff.
61- * - `Disconnected`: the client is not connected and won't auto-reconnect (call `connect()` to resume).
83+ * - `Disconnected`: the client is not connected. Call `connect()` to retry.
6284 */
6385export const ClientStatus = {
6486 Connecting : "connecting" ,
6587 Connected : "connected" ,
66- Reconnecting : "reconnecting" ,
6788 Disconnected : "disconnected" ,
6889} as const ;
6990export type ClientStatusValue =
@@ -93,8 +114,7 @@ export interface LoroWebsocketClientOptions {
93114 *
94115 * Status model:
95116 * - `Connected`: ws open.
96- * - `Reconnecting`: closed unexpectedly; client retries with exponential backoff. Pauses while offline; resumes on `online`.
97- * - `Disconnected`: closed via `close()`; client will not reconnect until `connect()` is called again.
117+ * - `Disconnected`: socket closed. Auto-reconnect retries run unless `close()`/`destroy()` stop them.
98118 * - `Connecting`: initial or manual connect in progress.
99119 *
100120 * Events:
@@ -134,28 +154,91 @@ export class LoroWebsocketClient {
134154 private shouldReconnect = true ;
135155 private reconnectAttempts = 0 ;
136156 private reconnectTimer ?: ReturnType < typeof setTimeout > ;
137- private isOnline = true ;
157+ private isOnline = readInitialOnlineState ( ) ;
158+ private removeNetworkListeners ?: ( ) => void ;
138159
139160 constructor ( private ops : LoroWebsocketClientOptions ) {
140- // Wire browser network events if available
161+ this . attachNetworkListeners ( ) ;
162+
163+ // Start initial connection
164+ this . ensureConnectedPromise ( ) ;
165+ void this . connect ( ) ;
166+ }
167+
168+ private ensureConnectedPromise ( ) : void {
169+ if ( this . resolveConnected ) return ;
170+ this . connectedPromise = new Promise < void > ( ( resolve , reject ) => {
171+ this . resolveConnected = ( ) => {
172+ this . resolveConnected = undefined ;
173+ this . rejectConnected = undefined ;
174+ resolve ( ) ;
175+ } ;
176+ this . rejectConnected = ( err : Error ) => {
177+ this . resolveConnected = undefined ;
178+ this . rejectConnected = undefined ;
179+ reject ( err ) ;
180+ } ;
181+ } ) ;
182+ }
183+
184+ private attachNetworkListeners ( ) : void {
185+ this . removeNetworkListeners ?.( ) ;
186+ this . removeNetworkListeners = undefined ;
187+
141188 if (
142189 typeof window !== "undefined" &&
143190 typeof window . addEventListener === "function"
144191 ) {
145192 window . addEventListener ( "online" , this . handleOnline ) ;
146193 window . addEventListener ( "offline" , this . handleOffline ) ;
194+ this . removeNetworkListeners = ( ) => {
195+ window . removeEventListener ( "online" , this . handleOnline ) ;
196+ window . removeEventListener ( "offline" , this . handleOffline ) ;
197+ } ;
198+ return ;
147199 }
148200
149- // Start initial connection
150- this . connectedPromise = this . createConnectedPromise ( ) ;
151- void this . connect ( ) ;
152- }
201+ const globalScope = globalThis as typeof globalThis & {
202+ addEventListener ?: (
203+ type : string ,
204+ listener : EventListenerOrEventListenerObject
205+ ) => void ;
206+ removeEventListener ?: (
207+ type : string ,
208+ listener : EventListenerOrEventListenerObject
209+ ) => void ;
210+ process ?: NodeProcessLike ;
211+ } ;
153212
154- private createConnectedPromise ( ) {
155- return new Promise < void > ( ( resolve , reject ) => {
156- this . resolveConnected = resolve ;
157- this . rejectConnected = reject ;
158- } ) ;
213+ if ( typeof globalScope . addEventListener === "function" ) {
214+ const online = this . handleOnline as EventListener ;
215+ const offline = this . handleOffline as EventListener ;
216+ globalScope . addEventListener ( "online" , online ) ;
217+ globalScope . addEventListener ( "offline" , offline ) ;
218+ this . removeNetworkListeners = ( ) => {
219+ globalScope . removeEventListener ?.( "online" , online ) ;
220+ globalScope . removeEventListener ?.( "offline" , offline ) ;
221+ } ;
222+ return ;
223+ }
224+
225+ const maybeProcess = globalScope . process ;
226+ if ( maybeProcess && typeof maybeProcess . on === "function" ) {
227+ // Node environments may surface online/offline via the global process emitter.
228+ const online = ( ) => this . handleOnline ( ) ;
229+ const offline = ( ) => this . handleOffline ( ) ;
230+ maybeProcess . on ( "online" , online ) ;
231+ maybeProcess . on ( "offline" , offline ) ;
232+ this . removeNetworkListeners = ( ) => {
233+ if ( typeof maybeProcess . off === "function" ) {
234+ maybeProcess . off ( "online" , online ) ;
235+ maybeProcess . off ( "offline" , offline ) ;
236+ } else if ( typeof maybeProcess . removeListener === "function" ) {
237+ maybeProcess . removeListener ( "online" , online ) ;
238+ maybeProcess . removeListener ( "offline" , offline ) ;
239+ }
240+ } ;
241+ }
159242 }
160243
161244 /** Current client status. */
@@ -212,14 +295,15 @@ export class LoroWebsocketClient {
212295 }
213296 }
214297 this . clearReconnectTimer ( ) ;
215- this . setStatus (
216- this . reconnectAttempts > 0
217- ? ClientStatus . Reconnecting
218- : ClientStatus . Connecting
219- ) ;
298+ // Ensure there's a pending promise for this attempt
299+ this . ensureConnectedPromise ( ) ;
300+
301+ if ( ! this . isOnline ) {
302+ this . setStatus ( ClientStatus . Disconnected ) ;
303+ return this . connectedPromise ;
304+ }
220305
221- // Reset the connected promise for this attempt
222- this . connectedPromise = this . createConnectedPromise ( ) ;
306+ this . setStatus ( ClientStatus . Connecting ) ;
223307
224308 const ws = new WebSocket ( this . ops . url ) ;
225309 this . ws = ws ;
@@ -316,7 +400,7 @@ export class LoroWebsocketClient {
316400 return ;
317401 }
318402 // Start (or continue) exponential backoff retries
319- this . setStatus ( ClientStatus . Reconnecting ) ;
403+ this . setStatus ( ClientStatus . Disconnected ) ;
320404 this . scheduleReconnect ( ) ;
321405 }
322406
@@ -345,13 +429,13 @@ export class LoroWebsocketClient {
345429 if ( msg != null ) await this . handleMessage ( msg ) ;
346430 }
347431
348- private scheduleReconnect ( ) {
432+ private scheduleReconnect ( immediate = false ) {
349433 if ( this . reconnectTimer ) return ;
350434 if ( ! this . isOnline ) return ; // pause while offline
351435 const attempt = ++ this . reconnectAttempts ;
352436 const base = 500 ; // ms
353437 const max = 15_000 ; // ms
354- const delay = Math . min ( max , base * 2 ** ( attempt - 1 ) ) ;
438+ const delay = immediate ? 0 : Math . min ( max , base * 2 ** ( attempt - 1 ) ) ;
355439 this . reconnectTimer = setTimeout ( ( ) => {
356440 this . reconnectTimer = undefined ;
357441 void this . connect ( ) ;
@@ -365,23 +449,21 @@ export class LoroWebsocketClient {
365449
366450 private handleOnline = ( ) => {
367451 this . isOnline = true ;
368- if (
369- this . shouldReconnect &&
370- ( this . status === ClientStatus . Reconnecting ||
371- this . status === ClientStatus . Connecting )
372- ) {
373- this . clearReconnectTimer ( ) ;
374- // Try immediately when back online
375- void this . connect ( ) ;
376- }
452+ if ( ! this . shouldReconnect ) return ;
453+ if ( this . status === ClientStatus . Connected ) return ;
454+ this . clearReconnectTimer ( ) ;
455+ this . scheduleReconnect ( true ) ;
377456 } ;
378457
379458 private handleOffline = ( ) => {
380459 this . isOnline = false ;
381460 // Pause scheduled retries until online
382461 this . clearReconnectTimer ( ) ;
383462 if ( this . shouldReconnect ) {
384- this . setStatus ( ClientStatus . Reconnecting ) ;
463+ this . setStatus ( ClientStatus . Disconnected ) ;
464+ try {
465+ this . ws ?. close ( 1001 , "Offline" ) ;
466+ } catch { }
385467 }
386468 } ;
387469
@@ -824,6 +906,7 @@ export class LoroWebsocketClient {
824906 this . shouldReconnect = false ;
825907 this . clearReconnectTimer ( ) ;
826908 this . clearPingTimer ( ) ;
909+ this . reconnectAttempts = 0 ;
827910 this . rejectConnected ?.( new Error ( "Disconnected" ) ) ;
828911 this . rejectConnected = undefined ;
829912 this . resolveConnected = undefined ;
@@ -855,6 +938,10 @@ export class LoroWebsocketClient {
855938 roomId : string ,
856939 update : Uint8Array
857940 ) : void {
941+ const ws = this . ws ;
942+ if ( ! ws || ws . readyState !== WebSocket . OPEN ) {
943+ return ;
944+ }
858945 // Leave headroom for protocol overhead to stay under MAX_MESSAGE_SIZE
859946 const FRAG_LIMIT = Math . max (
860947 1 ,
@@ -863,7 +950,7 @@ export class LoroWebsocketClient {
863950
864951 if ( update . length <= FRAG_LIMIT ) {
865952 // Send as a single DocUpdate with one update entry
866- this . ws . send (
953+ ws . send (
867954 encode ( {
868955 type : MessageType . DocUpdate ,
869956 crdt,
@@ -888,7 +975,7 @@ export class LoroWebsocketClient {
888975 fragmentCount,
889976 totalSizeBytes : update . length ,
890977 } ;
891- this . ws . send ( encode ( header ) ) ;
978+ ws . send ( encode ( header ) ) ;
892979
893980 for ( let i = 0 ; i < fragmentCount ; i ++ ) {
894981 const start = i * FRAG_LIMIT ;
@@ -902,7 +989,7 @@ export class LoroWebsocketClient {
902989 index : i ,
903990 fragment,
904991 } ;
905- this . ws . send ( encode ( msg ) ) ;
992+ ws . send ( encode ( msg ) ) ;
906993 }
907994 }
908995
@@ -926,6 +1013,7 @@ export class LoroWebsocketClient {
9261013 this . shouldReconnect = false ;
9271014 this . clearReconnectTimer ( ) ;
9281015 this . clearPingTimer ( ) ;
1016+ this . reconnectAttempts = 0 ;
9291017 this . rejectConnected ?.( new Error ( "Destroyed" ) ) ;
9301018 this . rejectConnected = undefined ;
9311019 this . resolveConnected = undefined ;
@@ -944,16 +1032,10 @@ export class LoroWebsocketClient {
9441032 this . ops . onWsClose ?.( ) ;
9451033 }
9461034 this . detachSocketListeners ( ws ) ;
947- // Remove window event listeners if present
9481035 try {
949- if (
950- typeof window !== "undefined" &&
951- typeof window . removeEventListener === "function"
952- ) {
953- window . removeEventListener ( "online" , this . handleOnline ) ;
954- window . removeEventListener ( "offline" , this . handleOffline ) ;
955- }
1036+ this . removeNetworkListeners ?.( ) ;
9561037 } catch { }
1038+ this . removeNetworkListeners = undefined ;
9571039 // Close websocket after flushing pending frames
9581040 try {
9591041 this . flushAndCloseWebSocket ( ws , {
@@ -997,7 +1079,11 @@ export class LoroWebsocketClient {
9971079 }
9981080
9991081 const buffered = readBufferedAmount ( ) ;
1000- if ( buffered == null || buffered <= 0 || Date . now ( ) - start >= timeoutMs ) {
1082+ if (
1083+ buffered == null ||
1084+ buffered <= 0 ||
1085+ Date . now ( ) - start >= timeoutMs
1086+ ) {
10011087 requested = true ;
10021088 try {
10031089 ws . close ( code , reason ) ;
0 commit comments