🚑 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: <base64> }, 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.
This commit is contained in:
Dr. Dominik Jain 2026-05-07 23:50:20 +02:00 committed by Andrey Antukh
parent 6a44b19311
commit 362440fead
2 changed files with 41 additions and 8 deletions

View File

@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
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<any> = {
result: result,
@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
};
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: <base64 string> }`
*/
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) };
}
}

View File

@ -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: <base64 string> }` 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[]);
}
}