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
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import type {
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoRequest,
JsonPrimitive,
ListCookieNamesResponse,
ListFoldersRequest,
@@ -65,6 +66,56 @@ type DynamicPromptFormRequest = Omit<PromptFormRequest, "inputs"> & {
export type WorkspaceHandle = Pick<WorkspaceInfo, "id" | "name">;
export interface ReadHttpResponseBodyOptions {
/**
* Refuse to buffer a body larger than this, in bytes. Defaults to 32 MiB.
* Pass `Infinity` to read whatever is there.
*/
maxBytes?: number;
/** Bytes to pull from the host at a time. Defaults to 1 MiB. */
chunkSize?: number;
}
/**
* A response body, read back from wherever the host stored it.
*
* The accessors are named after `fetch`'s, but unlike `fetch` the body is not
* used up by reading it: these bytes are in durable storage, so every accessor
* can be called as many times as you like, in any order.
*/
export interface HttpResponseBody {
/** The response these bytes belong to. */
readonly responseId: string;
/**
* How many bytes are stored, which is not necessarily what the
* `Content-Length` header claimed. Zero when the response has no body.
*/
readonly contentLength: number;
/** The response's `Content-Type` header, verbatim, or null if it had none. */
readonly contentType: string | null;
/**
* The body decoded to a string, using the charset from `contentType` and
* falling back to UTF-8. Throws if the body is over `maxBytes`.
*/
text(options?: ReadHttpResponseBodyOptions): Promise<string>;
/** `text()`, parsed as JSON. */
json<T = unknown>(options?: ReadHttpResponseBodyOptions): Promise<T>;
/** The raw bytes. Throws if the body is over `maxBytes`. */
arrayBuffer(options?: ReadHttpResponseBodyOptions): Promise<ArrayBuffer>;
/**
* The raw bytes, a chunk at a time, so a body of any size can be read
* without holding all of it at once. Not subject to `maxBytes`.
*/
chunks(options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">): AsyncIterable<Uint8Array>;
}
export interface Context {
clipboard: {
copyText(text: string): Promise<void>;
@@ -129,6 +180,12 @@ export interface Context {
};
httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
/**
* Read a response's body by id. Where the host keeps the bytes — files on
* a desktop, rows in a database, somewhere else later — is not something a
* plugin sees or should depend on.
*/
body(args: GetHttpResponseBodyInfoRequest): Promise<HttpResponseBody>;
};
templates: {
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
@@ -13,7 +13,12 @@ import type { WorkspaceActionPlugin } from "./WorkspaceActionPlugin";
export type { Context };
export type { DynamicAuthenticationArg } from "./AuthenticationPlugin";
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from "./Context";
export type {
CallPromptFormDynamicArgs,
DynamicPromptFormArg,
HttpResponseBody,
ReadHttpResponseBodyOptions,
} from "./Context";
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
export type { TemplateFunctionPlugin };
export type { FolderActionPlugin } from "./FolderActionPlugin";