@@ -130,9 +130,9 @@ const processLogEvent = (event: string, format: LogFormat) => {
130130type TimeoutRaceResult < T > = { kind : 'value' ; value : T } | { kind : 'timeout' } ;
131131
132132// Races a promise against a timer so a hung `reader.read()` doesn't block the
133- // reconnect loop . Without this, the connection TTL check below the read() call
134- // never runs when the API proxy half-closes the socket — bytes stop arriving
135- // but no FIN/error surfaces, so the read promise stays pending forever.
133+ // read pump . Without this, the connection TTL check never runs when the API
134+ // proxy half-closes the socket — bytes stop arriving but no FIN/error
135+ // surfaces, so the read promise stays pending forever.
136136const raceWithTimeout = async < T > (
137137 promise : Promise < T > ,
138138 timeoutMs : number ,
@@ -187,6 +187,67 @@ const openLogStream = async (
187187 return reader ;
188188} ;
189189
190+ // Reasons the read pump stops on its own (vs. throwing). The outer reconnect
191+ // loop maps each to a user-facing message — or silence — and opens a fresh
192+ // stream.
193+ type Rotation = 'ttl' | 'idle-timeout' | 'stream-done' ;
194+
195+ const pumpUntilRotation = async (
196+ reader : ReadableStreamDefaultReader < Uint8Array > ,
197+ format : LogFormat ,
198+ connectionTtlMs : number ,
199+ ) : Promise < Rotation > => {
200+ const decoder = new TextDecoder ( ) ;
201+ const connectTime = Date . now ( ) ;
202+ let buffer = '' ;
203+ let receivedData = false ;
204+
205+ // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
206+ while ( true ) {
207+ const remainingTtlMs = connectionTtlMs - ( Date . now ( ) - connectTime ) ;
208+
209+ if ( remainingTtlMs <= 0 ) {
210+ void reader . cancel ( ) ;
211+
212+ return 'ttl' ;
213+ }
214+
215+ // eslint-disable-next-line no-await-in-loop
216+ const readResult = await raceWithTimeout ( reader . read ( ) , remainingTtlMs ) ;
217+
218+ if ( readResult . kind === 'timeout' ) {
219+ void reader . cancel ( ) ;
220+
221+ // No data for the whole window: proxy likely half-closed the socket.
222+ // If data flowed earlier, treat it as a normal TTL boundary instead.
223+ return receivedData ? 'ttl' : 'idle-timeout' ;
224+ }
225+
226+ const { value, done : streamDone } = readResult . value ;
227+
228+ if ( value ) {
229+ receivedData = true ;
230+ buffer += decoder . decode ( value , { stream : true } ) ;
231+
232+ const parts = buffer . split ( '\n\n' ) ;
233+
234+ // Last element is either empty (complete event) or a partial chunk to carry over
235+ buffer = parts . pop ( ) ?? '' ;
236+
237+ parts
238+ . map ( ( raw ) => parseSSEEvent ( raw ) )
239+ . filter ( ( event ) : event is string => event !== null )
240+ . forEach ( ( event ) => processLogEvent ( event , format ) ) ;
241+ }
242+
243+ if ( streamDone ) {
244+ void reader . cancel ( ) ;
245+
246+ return 'stream-done' ;
247+ }
248+ }
249+ } ;
250+
190251export const tailLogs = async (
191252 projectUuid : string ,
192253 storeHash : string ,
@@ -204,58 +265,16 @@ export const tailLogs = async (
204265 try {
205266 // eslint-disable-next-line no-await-in-loop
206267 const reader = await openLogStream ( projectUuid , storeHash , accessToken , apiHost ) ;
207- const decoder = new TextDecoder ( ) ;
208- const connectTime = Date . now ( ) ;
209- let buffer = '' ;
210- let receivedData = false ;
211268
212269 retries = 0 ;
213270
214- // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
215- while ( true ) {
216- const remainingTtlMs = connectionTtlMs - ( Date . now ( ) - connectTime ) ;
217-
218- if ( remainingTtlMs <= 0 ) {
219- void reader . cancel ( ) ;
220- break ;
221- }
222-
223- // eslint-disable-next-line no-await-in-loop
224- const readResult = await raceWithTimeout ( reader . read ( ) , remainingTtlMs ) ;
225-
226- if ( readResult . kind === 'timeout' ) {
227- // No bytes for the whole TTL window — proxy likely half-closed the
228- // socket. Warn the user so they see the reconnect happen.
229- if ( ! receivedData ) {
230- consola . warn ( 'Log stream idle, reconnecting...' ) ;
231- }
232-
233- void reader . cancel ( ) ;
234- break ;
235- }
236-
237- const { value, done : streamDone } = readResult . value ;
238-
239- if ( value ) {
240- receivedData = true ;
241- buffer += decoder . decode ( value , { stream : true } ) ;
242-
243- const parts = buffer . split ( '\n\n' ) ;
244-
245- // Last element is either empty (complete event) or a partial chunk to carry over
246- buffer = parts . pop ( ) ?? '' ;
247-
248- parts
249- . map ( ( raw ) => parseSSEEvent ( raw ) )
250- . filter ( ( event ) : event is string => event !== null )
251- . forEach ( ( event ) => processLogEvent ( event , format ) ) ;
252- }
271+ // eslint-disable-next-line no-await-in-loop
272+ const rotation = await pumpUntilRotation ( reader , format , connectionTtlMs ) ;
253273
254- if ( streamDone ) {
255- void reader . cancel ( ) ;
256- break ;
257- }
274+ if ( rotation === 'idle-timeout' ) {
275+ consola . warn ( 'Log stream idle, reconnecting...' ) ;
258276 }
277+ // 'ttl' and 'stream-done' are healthy rotations — reconnect silently.
259278 } catch ( error ) {
260279 if ( error instanceof StreamError && error . fatal ) {
261280 throw error ;
0 commit comments