1- import { Effect , Layer , Option } from "effect" ;
1+ import { Effect , Layer , Option , Stream } from "effect" ;
22import { HttpClient , HttpClientRequest } from "effect/unstable/http" ;
33import type { ToolFileValue } from "@executor-js/sdk/core" ;
44
@@ -151,6 +151,28 @@ const applyHeaders = (
151151const normalizeContentType = ( ct : string | null | undefined ) : string =>
152152 ct ?. split ( ";" ) [ 0 ] ?. trim ( ) . toLowerCase ( ) ?? "" ;
153153
154+ export const STREAM_MAX_BYTES = 1_000_000 ;
155+ export const STREAM_MAX_MS = 10_000 ;
156+
157+ class StreamCapReached extends Error {
158+ readonly _tag = "StreamCapReached" ;
159+ }
160+
161+ export interface StreamingResponseCaps {
162+ readonly maxBytes ?: number ;
163+ readonly maxMs ?: number ;
164+ }
165+
166+ const STREAMING_RESPONSE_CONTENT_TYPES = new Set ( [
167+ "application/stream+json" ,
168+ "application/x-ndjson" ,
169+ "application/jsonl" ,
170+ "text/event-stream" ,
171+ ] ) ;
172+
173+ const isStreamingResponseContentType = ( ct : string | null | undefined ) : boolean =>
174+ STREAMING_RESPONSE_CONTENT_TYPES . has ( normalizeContentType ( ct ) ) ;
175+
154176const isJsonContentType = ( ct : string | null | undefined ) : boolean => {
155177 const normalized = normalizeContentType ( ct ) ;
156178 if ( ! normalized ) return false ;
@@ -179,6 +201,115 @@ const isTextContentType = (ct: string | null | undefined): boolean =>
179201const isOctetStream = ( ct : string | null | undefined ) : boolean =>
180202 normalizeContentType ( ct ) === "application/octet-stream" ;
181203
204+ const parseStreamingJsonLines = ( text : string , truncated : boolean ) : unknown => {
205+ const lines = text . split ( / \r ? \n / ) ;
206+ let lastNonEmptyIndex = - 1 ;
207+ for ( let index = lines . length - 1 ; index >= 0 ; index -- ) {
208+ const line = lines [ index ] ;
209+ if ( line !== undefined && line . trim ( ) !== "" ) {
210+ lastNonEmptyIndex = index ;
211+ break ;
212+ }
213+ }
214+ const rows : unknown [ ] = [ ] ;
215+
216+ for ( let index = 0 ; index < lines . length ; index ++ ) {
217+ const line = lines [ index ] ;
218+ if ( line === undefined || line . trim ( ) === "" ) continue ;
219+ try {
220+ rows . push ( JSON . parse ( line ) ) ;
221+ } catch {
222+ if ( truncated && index === lastNonEmptyIndex ) continue ;
223+ return text ;
224+ }
225+ }
226+
227+ return rows ;
228+ } ;
229+
230+ const decodeStreamingResponseBody = (
231+ contentType : string | null ,
232+ text : string ,
233+ truncated : boolean ,
234+ ) : unknown => {
235+ switch ( normalizeContentType ( contentType ) ) {
236+ case "application/stream+json" :
237+ case "application/x-ndjson" :
238+ case "application/jsonl" :
239+ return parseStreamingJsonLines ( text , truncated ) ;
240+ case "text/event-stream" :
241+ default :
242+ return text ;
243+ }
244+ } ;
245+
246+ export const collectStreamingBody = (
247+ stream : Stream . Stream < Uint8Array , unknown , never > ,
248+ contentType : string | null ,
249+ caps : StreamingResponseCaps = { } ,
250+ ) =>
251+ Effect . gen ( function * ( ) {
252+ const maxBytes = caps . maxBytes ?? STREAM_MAX_BYTES ;
253+ const maxMs = caps . maxMs ?? STREAM_MAX_MS ;
254+ const chunks : Uint8Array [ ] = [ ] ;
255+ let bytes = 0 ;
256+ let truncated = false ;
257+ const startedAt = Date . now ( ) ;
258+
259+ const completed = yield * stream . pipe (
260+ Stream . timeout ( maxMs ) ,
261+ Stream . runForEach ( ( chunk ) =>
262+ Effect . gen ( function * ( ) {
263+ const remaining = maxBytes - bytes ;
264+ if ( remaining <= 0 ) {
265+ truncated = true ;
266+ return yield * Effect . fail ( new StreamCapReached ( ) ) ;
267+ }
268+
269+ if ( chunk . byteLength > remaining ) {
270+ chunks . push ( chunk . subarray ( 0 , remaining ) ) ;
271+ bytes += remaining ;
272+ truncated = true ;
273+ return yield * Effect . fail ( new StreamCapReached ( ) ) ;
274+ }
275+
276+ chunks . push ( chunk ) ;
277+ bytes += chunk . byteLength ;
278+ if ( bytes >= maxBytes ) {
279+ truncated = true ;
280+ return yield * Effect . fail ( new StreamCapReached ( ) ) ;
281+ }
282+ } ) ,
283+ ) ,
284+ Effect . timeoutOption ( maxMs + 1_000 ) ,
285+ Effect . catch ( ( error : unknown ) =>
286+ error instanceof StreamCapReached
287+ ? Effect . succeed ( Option . some ( undefined ) )
288+ : Effect . fail ( error ) ,
289+ ) ,
290+ ) ;
291+ if ( Option . isNone ( completed ) ) truncated = true ;
292+
293+ const durationMs = Date . now ( ) - startedAt ;
294+ if ( durationMs >= maxMs ) truncated = true ;
295+ const collected = new Uint8Array ( bytes ) ;
296+ let offset = 0 ;
297+ for ( const chunk of chunks ) {
298+ collected . set ( chunk , offset ) ;
299+ offset += chunk . byteLength ;
300+ }
301+ const text = new TextDecoder ( "utf-8" ) . decode ( collected ) ;
302+
303+ return {
304+ data : decodeStreamingResponseBody ( contentType , text , truncated ) ,
305+ headers : {
306+ "x-executor-stream" : truncated ? "truncated" : "complete" ,
307+ "x-executor-stream-bytes" : String ( bytes ) ,
308+ "x-executor-stream-duration-ms" : String ( durationMs ) ,
309+ } ,
310+ } ;
311+ } ) ;
312+
182313const bytesToBase64 = ( bytes : Uint8Array ) : string => {
183314 let binary = "" ;
184315 const chunkSize = 0x8000 ;
@@ -861,6 +992,16 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* (
861992 ? Option . getOrUndefined ( responseBodyBinding . fileHint )
862993 : undefined ;
863994 const ok = status >= 200 && status < 300 ;
995+ const streamingBody =
996+ ok &&
997+ status !== 204 &&
998+ fileHint ?. kind !== "binaryResponse" &&
999+ isStreamingResponseContentType ( contentType )
1000+ ? yield * collectStreamingBody ( response . stream , contentType ) . pipe ( mapBodyError )
1001+ : null ;
1002+ if ( streamingBody ) {
1003+ Object . assign ( responseHeaders , streamingBody . headers ) ;
1004+ }
8641005 const responseBody : unknown =
8651006 status === 204
8661007 ? null
@@ -870,12 +1011,14 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* (
8701011 fileHint ,
8711012 contentType ,
8721013 )
873- : isJsonContentType ( contentType )
874- ? yield * response . json . pipe (
875- Effect . catch ( ( ) => response . text ) ,
876- mapBodyError ,
877- )
878- : yield * response . text . pipe ( mapBodyError ) ;
1014+ : streamingBody
1015+ ? streamingBody . data
1016+ : isJsonContentType ( contentType )
1017+ ? yield * response . json . pipe (
1018+ Effect . catch ( ( ) => response . text ) ,
1019+ mapBodyError ,
1020+ )
1021+ : yield * response . text . pipe ( mapBodyError ) ;
8791022
8801023 const dataBody =
8811024 ok && fileHint ?. kind === "byteField"
0 commit comments