From 362440feadab542b2236cae4a710b82b7727a0d3 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Thu, 7 May 2026 23:50:20 +0200 Subject: [PATCH] :ambulance: Use base64 envelope for Uint8Array task results to avoid JSON expansion (#9431) Resolves #9420 (critical memory usage issue in PROD deployment) When the plugin's ExecuteCodeTaskHandler returns a Uint8Array (e.g. from penpotUtils.exportImage), JSON.stringify previously serialized it as an object with numeric string keys, causing ~10x payload expansion and large peak heap usage on the server side. The plugin now wraps a top-level Uint8Array result in a tagged envelope { __type: "base64", data: }, and ImageContent.byteData decodes this envelope on the server. The legacy numeric-keyed-object path is retained as a fallback for compatibility with older plugin builds. --- .../task-handlers/ExecuteCodeTaskHandler.ts | 26 +++++++++++++++++++ mcp/packages/server/src/ToolResponse.ts | 23 ++++++++++------ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 85ed5a32d1..b464c599ef 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler { console.log("Code execution result:", result); + // transform a top-level Uint8Array result into a compact base64 envelope to avoid the + // ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects + // with numeric string keys (see penpot/penpot#9420) + if (result instanceof Uint8Array) { + result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result); + } + // return result and captured log let resultData: ExecuteCodeTaskResultData = { result: result, @@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler { }; task.sendSuccess(resultData); } + + /** + * Base64-encodes the given bytes and wraps the result in a tagged envelope that the + * server side recognizes (see `ImageContent.byteData`). + * + * @param bytes - the raw binary data to encode + * @returns an envelope of the form `{ __type: "base64", data: }` + */ + private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } { + // build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly + // would overflow the call stack for large arrays + const chunkSize = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + return { __type: "base64", data: btoa(binary) }; + } } diff --git a/mcp/packages/server/src/ToolResponse.ts b/mcp/packages/server/src/ToolResponse.ts index 6055f24ccf..db5babe4db 100644 --- a/mcp/packages/server/src/ToolResponse.ts +++ b/mcp/packages/server/src/ToolResponse.ts @@ -37,19 +37,26 @@ export class ImageContent implements ImageItem { /** * Utility function for ensuring a consistent Uint8Array representation of byte data. - * Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array - * from the plugin). + * Input can be one of: + * - a `Uint8Array` (already in the desired form); + * - a base64 envelope `{ __type: "base64", data: }` produced by the plugin + * to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420); + * - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback). * - * @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array) - * @return data as Uint8Array + * @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object + * @return data as `Uint8Array` */ public static byteData(data: Uint8Array | object): Uint8Array { - if (typeof data === "object") { - // convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array - return new Uint8Array(Object.values(data) as number[]); - } else { + if (data instanceof Uint8Array) { return data; } + // recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler + const envelope = data as { __type?: unknown; data?: unknown }; + if (envelope.__type === "base64" && typeof envelope.data === "string") { + return new Uint8Array(Buffer.from(envelope.data, "base64")); + } + // legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array + return new Uint8Array(Object.values(data) as number[]); } }