@@ -10,8 +10,7 @@ export type S2RealtimeStreamsOptions = {
1010 streamPrefix ?: string ; // defaults to ""
1111
1212 // Read behavior
13- s2WaitSeconds ?: number ; // long poll wait for reads (default 60)
14- sseHeartbeatMs ?: number ; // ping interval to keep h2 alive (default 25000)
13+ s2WaitSeconds ?: number ;
1514
1615 flushIntervalMs ?: number ; // how often to flush buffered chunks (default 200ms)
1716 maxRetries ?: number ; // max number of retries for failed flushes (default 10)
@@ -37,7 +36,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
3736 private readonly streamPrefix : string ;
3837
3938 private readonly s2WaitSeconds : number ;
40- private readonly sseHeartbeatMs : number ;
4139
4240 private readonly flushIntervalMs : number ;
4341 private readonly maxRetries : number ;
@@ -52,7 +50,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
5250 this . streamPrefix = opts . streamPrefix ?? "" ;
5351
5452 this . s2WaitSeconds = opts . s2WaitSeconds ?? 60 ;
55- this . sseHeartbeatMs = opts . sseHeartbeatMs ?? 25_000 ;
5653
5754 this . flushIntervalMs = opts . flushIntervalMs ?? 200 ;
5855 this . maxRetries = opts . maxRetries ?? 10 ;
@@ -111,68 +108,18 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
111108 lastEventId ?: string
112109 ) : Promise < Response > {
113110 const s2Stream = this . toStreamName ( runId , streamId ) ;
114- const encoder = new TextEncoder ( ) ;
115-
116- const startSeq = this . parseLastEventId ( lastEventId ) ; // if undefined => from beginning
117- const readable = new ReadableStream < Uint8Array > ( {
118- start : async ( controller ) => {
119- let aborted = false ;
120- const onAbort = ( ) => ( aborted = true ) ;
121- signal . addEventListener ( "abort" , onAbort ) ;
122-
123- const hb = setInterval ( ( ) => {
124- controller . enqueue ( encoder . encode ( `: ping\n\n` ) ) ;
125- } , this . sseHeartbeatMs ) ;
126-
127- try {
128- let nextSeq = startSeq ?? 0 ;
129-
130- // Live follow via long-poll read (wait=)
131- // clamp=true ensures starting past-tail doesn't 416; it clamps to tail and waits.
132- while ( ! aborted ) {
133- const resp = await this . s2ReadOnce ( s2Stream , {
134- seq_num : nextSeq ,
135- clamp : true ,
136- count : 1000 ,
137- wait : this . s2WaitSeconds , // long polling for new data. :contentReference[oaicite:6]{index=6}
138- } ) ;
139-
140- if ( resp . records ?. length ) {
141- for ( const rec of resp . records ) {
142- const seq = rec . seq_num ! ;
143- controller . enqueue ( encoder . encode ( `id: ${ seq } \n` ) ) ;
144- const body = rec . body ?? "" ;
145- const lines = body . split ( "\n" ) . filter ( ( l ) => l . length > 0 ) ;
146- for ( const line of lines ) {
147- controller . enqueue ( encoder . encode ( `data: ${ line } \n` ) ) ;
148- }
149- controller . enqueue ( encoder . encode ( `\n` ) ) ;
150- nextSeq = seq + 1 ;
151- }
152- }
153- // If no records within wait, loop; heartbeat keeps connection alive.
154- }
155- } catch ( error ) {
156- this . logger . error ( "[S2RealtimeStreams][streamResponse] fatal" , {
157- error,
158- runId,
159- streamId,
160- } ) ;
161- controller . error ( error ) ;
162- } finally {
163- signal . removeEventListener ( "abort" , onAbort ) ;
164- clearInterval ( hb ) ;
165- }
166- } ,
111+ const startSeq = this . parseLastEventId ( lastEventId ) ;
112+
113+ // Request SSE stream from S2 and return it directly
114+ const s2Response = await this . s2StreamRecords ( s2Stream , {
115+ seq_num : startSeq ?? 0 ,
116+ clamp : true ,
117+ wait : this . s2WaitSeconds , // S2 will keep the connection open and stream new records
118+ signal, // Pass abort signal so S2 connection is cleaned up when client disconnects
167119 } ) ;
168120
169- return new Response ( readable , {
170- headers : {
171- "Content-Type" : "text/event-stream" ,
172- "Cache-Control" : "no-cache" ,
173- Connection : "keep-alive" ,
174- } ,
175- } ) ;
121+ // Return S2's SSE response directly to the client
122+ return s2Response ;
176123 }
177124
178125 // ---------- Internals: S2 REST ----------
@@ -209,6 +156,47 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
209156 return data . access_token ;
210157 }
211158
159+ private async s2StreamRecords (
160+ stream : string ,
161+ opts : {
162+ seq_num ?: number ;
163+ clamp ?: boolean ;
164+ wait ?: number ;
165+ signal ?: AbortSignal ;
166+ }
167+ ) : Promise < Response > {
168+ // GET /v1/streams/{stream}/records with Accept: text/event-stream for SSE streaming
169+ const qs = new URLSearchParams ( ) ;
170+ if ( opts . seq_num != null ) qs . set ( "seq_num" , String ( opts . seq_num ) ) ;
171+ if ( opts . clamp != null ) qs . set ( "clamp" , String ( opts . clamp ) ) ;
172+ if ( opts . wait != null ) qs . set ( "wait" , String ( opts . wait ) ) ;
173+
174+ const res = await fetch ( `${ this . baseUrl } /streams/${ encodeURIComponent ( stream ) } /records?${ qs } ` , {
175+ method : "GET" ,
176+ headers : {
177+ Authorization : `Bearer ${ this . token } ` ,
178+ Accept : "text/event-stream" ,
179+ "S2-Format" : "raw" ,
180+ } ,
181+ signal : opts . signal ,
182+ } ) ;
183+
184+ if ( ! res . ok ) {
185+ const text = await res . text ( ) . catch ( ( ) => "" ) ;
186+ throw new Error ( `S2 stream failed: ${ res . status } ${ res . statusText } ${ text } ` ) ;
187+ }
188+
189+ const headers = new Headers ( res . headers ) ;
190+ headers . set ( "X-Stream-Version" , "v2" ) ;
191+ headers . set ( "Access-Control-Expose-Headers" , "*" ) ;
192+
193+ return new Response ( res . body , {
194+ headers,
195+ status : res . status ,
196+ statusText : res . statusText ,
197+ } ) ;
198+ }
199+
212200 private async s2ReadOnce (
213201 stream : string ,
214202 opts : {
0 commit comments