Add a plugin API for reading HTTP response bodies

Plugins read bodies by response id through ctx.httpResponse.body()
instead of opening HttpResponse.bodyPath themselves. The accessors are
named after fetch's, minus the single-use semantics, since the bytes are
durable and re-reading should work.

Underneath is a chunked pull over the existing plugin protocol, so the
host can move bodies off the filesystem without plugins noticing.
text() now decodes with the response's charset rather than assuming
UTF-8, and the buffering accessors refuse past 32 MiB and point at
chunks().
This commit is contained in:
Gregory Schier
2026-08-16 09:20:29 -07:00
parent 6a02cbe525
commit 96c8a95094
18 changed files with 915 additions and 46 deletions
+35 -1
View File
@@ -21,6 +21,7 @@ import type {
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoResponse,
GetKeyValueResponse,
GrpcRequestAction,
HttpAuthenticationAction,
@@ -37,6 +38,7 @@ import type {
PluginContext,
PromptFormResponse,
PromptTextResponse,
ReadHttpResponseBodyChunkResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
@@ -49,6 +51,7 @@ import type {
import { applyDynamicFormInput } from "./common";
import { EventChannel } from "./EventChannel";
import { migrateTemplateFunctionSelectOptions } from "./migrations";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
export interface PluginWorkerData {
bootRequest: BootRequest;
@@ -555,16 +558,24 @@ export class PluginInstance {
#sendForReply<T extends Omit<InternalEventPayload, "type">>(
context: PluginContext,
payload: InternalEventPayload,
// Off by default because a reply-shaped object with none of the expected
// fields is what every existing caller already copes with; turning it on
// for a new call is how that stops spreading.
{ throwOnError = false }: { throwOnError?: boolean } = {},
): Promise<T> {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(context, payload, null);
// 2. Spawn listener in background
const promise = new Promise<T>((resolve) => {
const promise = new Promise<T>((resolve, reject) => {
const cb = (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
if (throwOnError && event.payload.type === "error_response") {
reject(new Error(String((payload as { error?: string }).error ?? "Unknown error")));
return;
}
resolve(payload as T);
}
};
@@ -751,6 +762,29 @@ export class PluginInstance {
);
return httpResponses;
},
body: async ({ responseId }) => {
const info = await this.#sendForReply<GetHttpResponseBodyInfoResponse>(
context,
{ type: "get_http_response_body_info_request", responseId },
{ throwOnError: true },
);
return createResponseBody(
{
responseId,
contentLength: info.contentLength,
contentType: info.contentType ?? null,
},
async (offset, length) => {
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
context,
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
{ throwOnError: true },
);
return decodeBase64Chunk(chunk.data);
},
);
},
},
grpcRequest: {
render: async (args) => {
+151
View File
@@ -0,0 +1,151 @@
import type { HttpResponseBody, ReadHttpResponseBodyOptions } from "@yaakapp/api";
/** Bytes pulled from the host per round trip, when the caller doesn't say. */
const DEFAULT_CHUNK_SIZE = 1024 * 1024;
/**
* The most a plugin buffers by default.
*
* Reading a body used to be unbounded, so any ceiling is an improvement; this
* one is set well above what an API returns and well below what makes the
* plugin runtime fall over. `chunks()` has no ceiling, and any caller that
* really wants the whole thing can raise `maxBytes`.
*/
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
/** Fetch one window of body bytes from the host. */
export type ReadResponseBodyChunk = (offset: number, length: number) => Promise<Uint8Array>;
export interface ResponseBodyInfo {
responseId: string;
contentLength: number;
contentType: string | null;
}
export function createResponseBody(
info: ResponseBodyInfo,
readChunk: ReadResponseBodyChunk,
): HttpResponseBody {
const { responseId, contentLength, contentType } = info;
async function* chunks(
options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">,
): AsyncIterable<Uint8Array> {
const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));
let offset = 0;
// Bounded by the length the host reported, but a short read still ends it:
// the body may have been rewritten between the two calls.
while (offset < contentLength) {
const chunk = await readChunk(offset, Math.min(chunkSize, contentLength - offset));
if (chunk.byteLength === 0) return;
yield chunk;
offset += chunk.byteLength;
}
}
async function readAll(accessor: string, options?: ReadHttpResponseBodyOptions) {
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
refuseIfTooBig(accessor, contentLength, maxBytes);
const parts: Uint8Array[] = [];
let total = 0;
for await (const chunk of chunks(options)) {
total += chunk.byteLength;
// The size the host reported is a claim about a moment ago, so check the
// bytes actually arriving too.
refuseIfTooBig(accessor, total, maxBytes);
parts.push(chunk);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.byteLength;
}
return bytes;
}
return {
responseId,
contentLength,
contentType,
chunks,
async arrayBuffer(options) {
const bytes = await readAll("arrayBuffer", options);
return bytes.buffer as ArrayBuffer;
},
async text(options) {
return decodeBody(await readAll("text", options), contentType);
},
async json<T>(options?: ReadHttpResponseBodyOptions) {
return JSON.parse(decodeBody(await readAll("json", options), contentType)) as T;
},
};
}
function refuseIfTooBig(accessor: string, bytes: number, maxBytes: number) {
if (bytes <= maxBytes) return;
throw new Error(
`Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ` +
`${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`,
);
}
/**
* Decode using the charset the response declared.
*
* Assuming UTF-8 mangles every response that isn't, and the header is right
* there. An unknown label is the one case worth guessing on, since the
* alternative is refusing to read a body we can very likely still read.
*/
function decodeBody(bytes: Uint8Array, contentType: string | null): string {
const charset = parseCharset(contentType);
if (charset != null) {
try {
return new TextDecoder(charset).decode(bytes);
} catch {
// Not a label this runtime knows.
}
}
// TextDecoder drops a leading BOM on its own.
return new TextDecoder("utf-8").decode(bytes);
}
function parseCharset(contentType: string | null): string | null {
const match = contentType?.match(/;\s*charset\s*=\s*"?([^";]+)"?/i);
return match?.[1]?.trim() || null;
}
function formatBytes(bytes: number): string {
if (bytes === Infinity) return "unlimited";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
/**
* Decode a chunk that arrived as base64.
*
* The desktop transport is a WebSocket carrying JSON text frames, so bytes
* have to be spelled out. A host that can pass an ArrayBuffer along skips this.
*/
export function decodeBase64Chunk(data: string): Uint8Array {
if (typeof Buffer !== "undefined") {
const buf = Buffer.from(data, "base64");
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vite-plus/test";
import { createResponseBody, decodeBase64Chunk } from "../src/responseBody";
/** A store of bytes that records every window it was asked for. */
function fakeBody(bytes: Uint8Array, contentType: string | null) {
const reads: Array<[number, number]> = [];
const body = createResponseBody(
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType },
async (offset, length) => {
reads.push([offset, length]);
return bytes.slice(offset, offset + length);
},
);
return { body, reads };
}
function utf8(text: string) {
return new TextEncoder().encode(text);
}
describe("response body", () => {
test("pulls a body in chunks and reassembles it", async () => {
const { body, reads } = fakeBody(utf8("abcdefghij"), "text/plain");
expect(await body.text({ chunkSize: 4 })).toEqual("abcdefghij");
expect(reads).toEqual([
[0, 4],
[4, 4],
[8, 2],
]);
});
test("can be read more than once, unlike fetch", async () => {
const { body } = fakeBody(utf8('{"a":1}'), "application/json");
expect(await body.text()).toEqual('{"a":1}');
expect(await body.json()).toEqual({ a: 1 });
expect(new Uint8Array(await body.arrayBuffer())).toEqual(utf8('{"a":1}'));
});
test("decodes using the charset the response declared", async () => {
// "café naïve" as Latin-1, which is mojibake if read as UTF-8.
const latin1 = new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x20, 0x6e, 0x61, 0xef, 0x76, 0x65]);
const declared = fakeBody(latin1, "text/plain; charset=iso-8859-1");
expect(await declared.body.text()).toEqual("café naïve");
const undeclared = fakeBody(latin1, "text/plain");
expect(await undeclared.body.text()).not.toEqual("café naïve");
});
test("falls back to UTF-8 for a charset the runtime doesn't know", async () => {
const { body } = fakeBody(utf8("hello"), "text/plain; charset=not-a-real-charset");
expect(await body.text()).toEqual("hello");
});
test("drops a UTF-8 BOM", async () => {
const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('{"a":1}')]);
const { body } = fakeBody(withBom, "application/json");
expect(await body.text()).toEqual('{"a":1}');
expect(await body.json()).toEqual({ a: 1 });
});
test("refuses to buffer past maxBytes, and says what to do instead", async () => {
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
await expect(body.text({ maxBytes: 50 })).rejects.toThrow(/chunks\(\)/);
await expect(body.json({ maxBytes: 50 })).rejects.toThrow(/over the/);
await expect(body.arrayBuffer({ maxBytes: 50 })).rejects.toThrow(/arrayBuffer\(\)/);
// The ceiling is the caller's to raise.
expect(await body.text({ maxBytes: 100 })).toHaveLength(100);
});
test("streams past maxBytes through chunks()", async () => {
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
let total = 0;
for await (const chunk of body.chunks({ chunkSize: 10 })) {
total += chunk.byteLength;
}
expect(total).toEqual(100);
});
test("stops early when the host runs out of bytes sooner than it claimed", async () => {
// contentLength says 100; the store only ever hands back 10.
const body = createResponseBody(
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain" },
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
);
expect(await body.text()).toEqual("0123456789");
});
test("a response with no body reads as empty", async () => {
const { body, reads } = fakeBody(new Uint8Array(), "application/json");
expect(body.contentLength).toEqual(0);
expect(await body.text()).toEqual("");
expect(reads).toEqual([]);
});
test("keeps binary bytes intact", async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]);
const { body } = fakeBody(png, "image/png");
expect(new Uint8Array(await body.arrayBuffer({ chunkSize: 3 }))).toEqual(png);
});
});
describe("decodeBase64Chunk", () => {
test("round-trips arbitrary bytes", () => {
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
const base64 = Buffer.from(bytes).toString("base64");
expect(decodeBase64Chunk(base64)).toEqual(bytes);
});
test("decodes an empty chunk", () => {
expect(decodeBase64Chunk("")).toEqual(new Uint8Array());
});
});