| const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024; |
| const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024; |
|
|
| export type CapturedOutput = { |
| text: string; |
| truncated: boolean; |
| totalBytes: number; |
| }; |
|
|
| function normalizeByteLimit(maxBytes: number) { |
| return Math.max(1, Math.trunc(maxBytes)); |
| } |
|
|
| export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) { |
| const limit = normalizeByteLimit(maxBytes); |
| const chunks: Buffer[] = []; |
| let bufferedBytes = 0; |
| let totalBytes = 0; |
| let truncated = false; |
|
|
| return { |
| append(chunk: Buffer | string | null | undefined) { |
| if (chunk === null || chunk === undefined) return; |
| const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); |
| if (buffer.length === 0) return; |
|
|
| chunks.push(buffer); |
| bufferedBytes += buffer.length; |
| totalBytes += buffer.length; |
|
|
| while (bufferedBytes > limit && chunks.length > 0) { |
| const overflow = bufferedBytes - limit; |
| const head = chunks[0]!; |
| if (head.length <= overflow) { |
| chunks.shift(); |
| bufferedBytes -= head.length; |
| truncated = true; |
| continue; |
| } |
|
|
| chunks[0] = head.subarray(overflow); |
| bufferedBytes -= overflow; |
| truncated = true; |
| } |
| }, |
|
|
| finish(): CapturedOutput { |
| const body = Buffer.concat(chunks).toString("utf8"); |
| if (!truncated) { |
| return { |
| text: body, |
| truncated, |
| totalBytes, |
| }; |
| } |
|
|
| return { |
| text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`, |
| truncated, |
| totalBytes, |
| }; |
| }, |
| }; |
| } |
|
|
| export async function parseJsonResponseWithLimit<T>( |
| response: Response, |
| maxBytes = DEFAULT_JSON_RESPONSE_BYTES, |
| ): Promise<T> { |
| const limit = normalizeByteLimit(maxBytes); |
| const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10); |
| if (Number.isFinite(contentLength) && contentLength > limit) { |
| throw new Error(`Response exceeds ${limit} bytes`); |
| } |
|
|
| if (!response.body) { |
| throw new Error("Response has no body"); |
| } |
|
|
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let text = ""; |
| let totalBytes = 0; |
|
|
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| totalBytes += value.byteLength; |
| if (totalBytes > limit) { |
| await reader.cancel("response too large"); |
| throw new Error(`Response exceeds ${limit} bytes`); |
| } |
| text += decoder.decode(value, { stream: true }); |
| } |
| text += decoder.decode(); |
| } finally { |
| reader.releaseLock(); |
| } |
|
|
| return JSON.parse(text) as T; |
| } |
|
|