@@ -296,31 +296,58 @@ class ReplaneRemoteStorage implements ReplaneStorage {
296296 private async * startReplicationStreamImpl (
297297 options : StartReplicationStreamReplaneStorageOptions
298298 ) : AsyncIterable < ReplicationStreamRecord > {
299- const rawEvents = fetchSse ( {
300- fetchFn : options . fetchFn ,
301- headers : {
302- Authorization : this . getAuthHeader ( options ) ,
303- "Content-Type" : "application/json" ,
304- } ,
305- body : JSON . stringify ( options . getBody ( ) ) ,
306- timeoutMs : options . requestTimeoutMs ,
307- method : "POST" ,
308- signal : options . signal ,
309- url : this . getApiEndpoint ( `/sdk/v1/replication/stream` , options ) ,
310- onConnect : options . onConnect ,
311- } ) ;
299+ // Create an abort controller for inactivity timeout
300+ const inactivityAbortController = new AbortController ( ) ;
301+ const { signal : combinedSignal , cleanUpSignals } = options . signal
302+ ? combineAbortSignals ( [ options . signal , inactivityAbortController . signal ] )
303+ : { signal : inactivityAbortController . signal , cleanUpSignals : ( ) => { } } ;
304+
305+ let inactivityTimer : ReturnType < typeof setTimeout > | null = null ;
306+
307+ const resetInactivityTimer = ( ) => {
308+ if ( inactivityTimer ) clearTimeout ( inactivityTimer ) ;
309+ inactivityTimer = setTimeout ( ( ) => {
310+ inactivityAbortController . abort ( ) ;
311+ } , options . inactivityTimeoutMs ) ;
312+ } ;
313+
314+ try {
315+ const rawEvents = fetchSse ( {
316+ fetchFn : options . fetchFn ,
317+ headers : {
318+ Authorization : this . getAuthHeader ( options ) ,
319+ "Content-Type" : "application/json" ,
320+ } ,
321+ body : JSON . stringify ( options . getBody ( ) ) ,
322+ timeoutMs : options . requestTimeoutMs ,
323+ method : "POST" ,
324+ signal : combinedSignal ,
325+ url : this . getApiEndpoint ( `/sdk/v1/replication/stream` , options ) ,
326+ onConnect : ( ) => {
327+ resetInactivityTimer ( ) ;
328+ options . onConnect ?.( ) ;
329+ } ,
330+ } ) ;
331+
332+ for await ( const sseEvent of rawEvents ) {
333+ resetInactivityTimer ( ) ;
334+
335+ if ( sseEvent . type === "ping" ) continue ;
312336
313- for await ( const rawEvent of rawEvents ) {
314- const event = JSON . parse ( rawEvent ) ;
315- if (
316- typeof event === "object" &&
317- event !== null &&
318- "type" in event &&
319- typeof event . type === "string" &&
320- ( SUPPORTED_REPLICATION_STREAM_RECORD_TYPES as unknown as string [ ] ) . includes ( event . type )
321- ) {
322- yield event as ReplicationStreamRecord ;
337+ const event = JSON . parse ( sseEvent . payload ) ;
338+ if (
339+ typeof event === "object" &&
340+ event !== null &&
341+ "type" in event &&
342+ typeof event . type === "string" &&
343+ ( SUPPORTED_REPLICATION_STREAM_RECORD_TYPES as unknown as string [ ] ) . includes ( event . type )
344+ ) {
345+ yield event as ReplicationStreamRecord ;
346+ }
323347 }
348+ } finally {
349+ if ( inactivityTimer ) clearTimeout ( inactivityTimer ) ;
350+ cleanUpSignals ( ) ;
324351 }
325352 }
326353
@@ -374,6 +401,12 @@ export interface ReplaneClientOptions<T extends Configs> {
374401 * @default 200
375402 */
376403 retryDelayMs ?: number ;
404+ /**
405+ * Timeout in ms for SSE connection inactivity.
406+ * If no events (including pings) are received within this time, the connection will be re-established.
407+ * @default 60000
408+ */
409+ inactivityTimeoutMs ?: number ;
377410 /**
378411 * Optional logger (defaults to console).
379412 */
@@ -428,6 +461,7 @@ interface ReplaneFinalOptions {
428461 fetchFn : typeof fetch ;
429462 requestTimeoutMs : number ;
430463 initializationTimeoutMs : number ;
464+ inactivityTimeoutMs : number ;
431465 sdkKey : string ;
432466 logger : ReplaneLogger ;
433467 retryDelayMs : number ;
@@ -725,6 +759,7 @@ function toFinalOptions<T extends Configs>(defaults: ReplaneClientOptions<T>): R
725759 globalThis . fetch . bind ( globalThis ) ,
726760 requestTimeoutMs : defaults . requestTimeoutMs ?? 2000 ,
727761 initializationTimeoutMs : defaults . initializationTimeoutMs ?? 5000 ,
762+ inactivityTimeoutMs : defaults . inactivityTimeoutMs ?? 60_000 ,
728763 logger : defaults . logger ?? console ,
729764 retryDelayMs : defaults . retryDelayMs ?? 200 ,
730765 context : {
@@ -775,6 +810,8 @@ async function fetchWithTimeout(
775810
776811const SSE_DATA_PREFIX = "data:" ;
777812
813+ type SseEvent = { type : "ping" } | { type : "data" ; payload : string } ;
814+
778815async function * fetchSse ( params : {
779816 fetchFn : typeof fetch ;
780817 url : string ;
@@ -784,7 +821,7 @@ async function* fetchSse(params: {
784821 method ?: string ;
785822 signal ?: AbortSignal ;
786823 onConnect ?: ( ) => void ;
787- } ) {
824+ } ) : AsyncGenerator < SseEvent > {
788825 const abortController = new AbortController ( ) ;
789826 const { signal, cleanUpSignals } = params . signal
790827 ? combineAbortSignals ( [ params . signal , abortController . signal ] )
@@ -841,10 +878,15 @@ async function* fetchSse(params: {
841878 for ( const frame of frames ) {
842879 // Parse lines inside a single SSE event frame
843880 const dataLines : string [ ] = [ ] ;
881+ let isPing = false ;
844882
845883 for ( const rawLine of frame . split ( / \r ? \n / ) ) {
846884 if ( ! rawLine ) continue ;
847- if ( rawLine . startsWith ( ":" ) ) continue ; // comment/keepalive
885+ if ( rawLine . startsWith ( ":" ) ) {
886+ // comment/keepalive - treat as ping
887+ isPing = true ;
888+ continue ;
889+ }
848890
849891 if ( rawLine . startsWith ( SSE_DATA_PREFIX ) ) {
850892 // Keep leading space after "data:" if present per spec
@@ -855,7 +897,9 @@ async function* fetchSse(params: {
855897
856898 if ( dataLines . length ) {
857899 const payload = dataLines . join ( "\n" ) ;
858- yield payload ;
900+ yield { type : "data" , payload } ;
901+ } else if ( isPing ) {
902+ yield { type : "ping" } ;
859903 }
860904 }
861905 }
0 commit comments