evOo-local/packages/core/src/http-response.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

98 lines
2.8 KiB
TypeScript

export class ResponseBodyTooLargeError extends Error {
constructor(maxBytes: number) {
super(`Response body exceeds ${maxBytes} bytes`);
this.name = "ResponseBodyTooLargeError";
}
}
export async function readBoundedJsonResponse<T>(
response: Response,
maxBytes: number,
signal?: AbortSignal,
): Promise<T> {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
throw new Error("Response body limit must be a positive integer");
}
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
if (Number.isFinite(declared) && declared > maxBytes) {
cancelResponseBody(response);
throw new ResponseBodyTooLargeError(maxBytes);
}
if (!response.body) return JSON.parse("") as T;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await readWithAbort(reader, signal);
if (done) break;
if (!value?.byteLength) continue;
total += value.byteLength;
if (total > maxBytes) {
cancelReader(reader);
throw new ResponseBodyTooLargeError(maxBytes);
}
chunks.push(value);
}
} catch (error) {
cancelReader(reader);
throw error;
} finally {
try {
reader.releaseLock();
} catch {
// The reader may already be cancelled or released.
}
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
function readWithAbort(
reader: ReadableStreamDefaultReader<Uint8Array>,
signal?: AbortSignal,
): ReturnType<ReadableStreamDefaultReader<Uint8Array>["read"]> {
if (!signal) return reader.read();
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Request aborted"));
return new Promise((resolve, reject) => {
const onAbort = () => {
cancelReader(reader);
reject(signal.reason ?? new Error("Request aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
reader.read().then(
(result) => {
signal.removeEventListener("abort", onAbort);
resolve(result);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
export function cancelResponseBody(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Cancellation is best-effort and must not delay the bounded failure.
}
}
function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
try {
void Promise.resolve(reader.cancel()).catch(() => undefined);
} catch {
// Cancellation is best-effort and must not delay the bounded failure.
}
}