diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 579a9c3e..8375c4b2 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -1268,11 +1268,10 @@ Fetch a raw repo record by `(repo, collection, rkey)`. ### PluginResponse -Response returned from [fetch](#fetch). The host always buffers and -base64-encodes the raw response bytes for transport (so binary bodies -survive intact), and this class decodes that on demand depending on -which accessor is called. `status`, `ok`, and `headers` (a `Map`) mirror -the underlying HTTP response. +Response returned from [fetch](#fetch). The host buffers the raw response +bytes and sends them as an `ArrayBuffer`, and this class decodes them on +demand depending on which accessor is called. `status`, `ok`, and `headers` +(a `Map`) mirror the underlying HTTP response. #### Properties diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index 6cc0f6bb..406cb84b 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -299,11 +299,10 @@ export class App { showMoreLikeThis(postUri: string, feedUri: string): Promise; } /** - * Response returned from {@link fetch}. The host always buffers and - * base64-encodes the raw response bytes for transport (so binary bodies - * survive intact), and this class decodes that on demand depending on - * which accessor is called. `status`, `ok`, and `headers` (a `Map`) mirror - * the underlying HTTP response. + * Response returned from {@link fetch}. The host buffers the raw response + * bytes and sends them as an `ArrayBuffer`, and this class decodes them on + * demand depending on which accessor is called. `status`, `ok`, and `headers` + * (a `Map`) mirror the underlying HTTP response. */ export class PluginResponse { /** @@ -1433,13 +1432,13 @@ export type HostEventMessage = { export type HostMessage = HostCallMessage | HostResultMessage | HostEventMessage; /** * {@internal} The host's reply to a proxied {@link fetch}. `body` is - * always the raw response bytes, base64-encoded (see {@link PluginResponse}). + * always the raw response bytes (see {@link PluginResponse}). */ export type SerializedFetchResponse = { status: number; ok: boolean; headers: Record; - body: string; + body: ArrayBuffer; }; /** * {@internal} A {@link PluginFetchInit} with its headers flattened for transfer. diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 2e643415..b6ff4877 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -553,44 +553,14 @@ function serializeFetchInit(init) { } /** - * @param {Uint8Array} bytes - * @returns {string} - */ -function bytesToBase64(bytes) { - // Encoded in fixed-size chunks rather than one - // String.fromCharCode(...bytes) call, which risks "too many - // arguments"/stack errors once bytes gets into the megabytes. - const CHUNK_SIZE = 0x8000; - let binary = ""; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)); - } - return btoa(binary); -} - -/** - * @param {string} base64 - * @returns {ArrayBuffer} - */ -function base64ToArrayBuffer(base64) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} - -/** - * Response returned from {@link fetch}. The host always buffers and - * base64-encodes the raw response bytes for transport (so binary bodies - * survive intact), and this class decodes that on demand depending on - * which accessor is called. `status`, `ok`, and `headers` (a `Map`) mirror - * the underlying HTTP response. + * Response returned from {@link fetch}. The host buffers the raw response + * bytes and sends them as an `ArrayBuffer`, and this class decodes them on + * demand depending on which accessor is called. `status`, `ok`, and `headers` + * (a `Map`) mirror the underlying HTTP response. */ export class PluginResponse { - /** @type {string} base64-encoded raw response bytes */ - #bodyBase64; + /** @type {ArrayBuffer} raw response bytes */ + #body; /** * @internal * @param {SerializedFetchResponse} response @@ -602,14 +572,14 @@ export class PluginResponse { this.ok = ok; /** @type {Map} */ this.headers = new Map(Object.entries(headers ?? {})); - this.#bodyBase64 = body; + this.#body = body; } /** * Resolves with the raw response bytes. * @returns {Promise} */ async arrayBuffer() { - return base64ToArrayBuffer(this.#bodyBase64); + return this.#body; } /** * Resolves with the response body decoded as UTF-8 text. @@ -2056,9 +2026,9 @@ export class VirtualEl { * {@internal} An out-of-band notification from the host. * @typedef {HostCallMessage | HostResultMessage | HostEventMessage} HostMessage * {@internal} Anything the host may post to this worker. - * @typedef {{ status: number, ok: boolean, headers: Record, body: string }} SerializedFetchResponse + * @typedef {{ status: number, ok: boolean, headers: Record, body: ArrayBuffer }} SerializedFetchResponse * {@internal} The host's reply to a proxied {@link fetch}. `body` is - * always the raw response bytes, base64-encoded (see {@link PluginResponse}). + * always the raw response bytes (see {@link PluginResponse}). * @typedef {{ method?: string, headers?: Record, body?: string }} SerializedFetchInit * {@internal} A {@link PluginFetchInit} with its headers flattened for transfer. * @typedef {Record} SerializedRichTextToken diff --git a/src/js/plugins/pluginRequests.js b/src/js/plugins/pluginRequests.js index c7873fff..208d7d17 100644 --- a/src/js/plugins/pluginRequests.js +++ b/src/js/plugins/pluginRequests.js @@ -4,33 +4,8 @@ const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]; const FORBIDDEN_HEADERS = ["authorization", "cookie"]; const MAX_BODY_CHARS = 1_000_000; -// Response bytes are buffered into memory whole (no streaming to the -// worker), and base64-encoded for transport - generous enough for a -// legitimately large response, but still bounded so a plugin can't be -// handed an unbounded download. export const MAX_RESPONSE_BYTES = 100_000_000; -// Encodes in fixed-size chunks rather than String.fromCharCode(...bytes) in -// one call, which risks "too many arguments"/stack errors once bytes gets -// into the tens of millions of entries this is sized for. -export function bytesToBase64(bytes) { - const CHUNK_SIZE = 0x8000; - let binary = ""; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)); - } - return btoa(binary); -} - -export function base64ToArrayBuffer(base64) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} - export async function pluginFetch( permissions, url, @@ -53,18 +28,11 @@ export async function pluginFetch( `fetch response too large (${bodyBuffer.byteLength} bytes, max ${MAX_RESPONSE_BYTES})`, ); } - // Always base64, regardless of content type: this is the one encoding - // that survives the postMessage/structured-clone hop to the plugin - // worker byte-for-byte, whether the response is JSON, HTML, or a binary - // asset. PluginResponse (impro-plugin/main.js) decodes it back into - // text/JSON/raw bytes on the plugin side depending on which accessor is - // called. - const bodyBase64 = bytesToBase64(new Uint8Array(bodyBuffer)); return { status: response.status, ok: response.ok, headers: filterResponseHeaders(response.headers, ["content-type"]), - body: bodyBase64, + body: bodyBuffer, }; } diff --git a/tests/unit/specs/plugins/pluginRequests.test.js b/tests/unit/specs/plugins/pluginRequests.test.js index 4663987b..82244187 100644 --- a/tests/unit/specs/plugins/pluginRequests.test.js +++ b/tests/unit/specs/plugins/pluginRequests.test.js @@ -22,12 +22,11 @@ function makeFakeFetch({ status = 200, body = "", headers = {} } = {}) { return { fakeFetch, calls }; } -// pluginFetch now always base64-encodes the response body for transport -// (see pluginRequests.js) so it can carry binary payloads, not just text - -// tests that care about the actual body content decode it back rather than -// comparing against raw text. -function decodeBody(base64Body) { - return Buffer.from(base64Body, "base64").toString("utf8"); +// pluginFetch relays the response body as raw bytes so it can carry binary +// payloads, not just text - tests that care about the actual body content +// decode it back rather than comparing against a string. +function decodeBody(bodyBuffer) { + return new TextDecoder().decode(bodyBuffer); } async function expectRejection(fn, includes) { @@ -333,6 +332,24 @@ describe("response shape", () => { assert.deepEqual(result.ok, false); assert.deepEqual(decodeBody(result.body), "nope"); }); + + it("relays binary bytes intact", async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe]); + const fakeFetch = async () => ({ + status: 200, + ok: true, + headers: { get: () => null }, + arrayBuffer: async () => bytes.buffer, + }); + const result = await pluginFetch( + makePermissions(["https://api.example.com/*"]), + "https://api.example.com/x", + {}, + fakeFetch, + ); + assert(result.body instanceof ArrayBuffer); + assert.deepEqual([...new Uint8Array(result.body)], [...bytes]); + }); }); describe("response size", () => { diff --git a/tests/unit/specs/plugins/pluginWorker.test.js b/tests/unit/specs/plugins/pluginWorker.test.js index 8dd5da05..88a99348 100644 --- a/tests/unit/specs/plugins/pluginWorker.test.js +++ b/tests/unit/specs/plugins/pluginWorker.test.js @@ -1268,6 +1268,42 @@ describe("fetch — header serialization", () => { }); }); +describe("fetch — response body", () => { + function resolveFetchWith(bytes) { + clearMessages(); + const responsePromise = pluginFetch("https://example.com/", {}); + const sent = postedMessages.find( + (message) => message.type === "hostCall" && message.method === "fetch", + ); + dispatch({ + type: "hostResult", + hostCallId: sent.hostCallId, + value: { + status: 200, + ok: true, + headers: {}, + body: bytes.buffer, + }, + }); + return responsePromise; + } + + it("decodes the raw bytes as text and JSON", async () => { + const response = await resolveFetchWith( + new TextEncoder().encode('{"a":1}'), + ); + assert.deepEqual(await response.text(), '{"a":1}'); + assert.deepEqual(await response.json(), { a: 1 }); + }); + + it("exposes the raw bytes unchanged for a binary body", async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe]); + const response = await resolveFetchWith(bytes); + const buffer = await response.arrayBuffer(); + assert.deepEqual([...new Uint8Array(buffer)], [...bytes]); + }); +}); + describe("registerRichTextTransform", () => { it("posts a register message", () => { clearMessages();