mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 20:34:05 +02:00
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"keywords": [
|
||||
"api-client",
|
||||
"bruno-alternative",
|
||||
|
||||
+42
-1
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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user