Skip to content

Commit d0104a3

Browse files
committed
Handle streaming OpenAPI responses
1 parent c46730b commit d0104a3

2 files changed

Lines changed: 426 additions & 7 deletions

File tree

packages/plugins/openapi/src/sdk/invoke.ts

Lines changed: 150 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Effect, Layer, Option } from "effect";
1+
import { Effect, Layer, Option, Stream } from "effect";
22
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
33
import type { ToolFileValue } from "@executor-js/sdk/core";
44

@@ -151,6 +151,28 @@ const applyHeaders = (
151151
const 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+
154176
const 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 =>
179201
const 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+
182313
const 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

Comments
 (0)