From 14b53ecfecd6cfb4579d4eedff839df7696a48d8 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Tue, 19 May 2026 19:37:29 +0200 Subject: [PATCH] :sparkles: Bound MCP memory consumption by limiting parallel exports & response size (#9748) * :sparkles: Bound the size of plugin task responses When using the integrated remote MCP server, bound response size. All responses are passed to LLMs, which themselves impose bounds. This is a measure to bound memory usage in the centrally provided MCP server. GitHub #9493 * :sparkles: Bound parallelism in ExportShapeTool Use an integer semaphore to bound parallel requests to this memory-intensive tool, thus bounding memory usage. GitHub #9493 * :sparkles: Add (manual) integration test script for ExportShapeTool parallelism Add dependency tsx to facilitate executions. GitHub #9493 * :sparkles: Make number of parallel export requests configurable in ExportShapeTool Use env var PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS to configure the maximum number of requests in multi-user mode (default 0, no limit). --- mcp/README.md | 17 +- mcp/packages/plugin/src/TaskHandler.ts | 2 + mcp/packages/plugin/src/main.ts | 39 ++- mcp/packages/plugin/src/plugin.ts | 15 +- mcp/packages/server/package.json | 4 +- ...integration-test-export-image-semaphore.ts | 116 +++++++ .../server/src/tools/ExportShapeTool.ts | 55 +++- mcp/packages/server/src/utils/Semaphore.ts | 69 +++++ mcp/pnpm-lock.yaml | 293 +++++++++++++++++- 9 files changed, 591 insertions(+), 19 deletions(-) create mode 100644 mcp/packages/server/scripts/integration-test-export-image-semaphore.ts create mode 100644 mcp/packages/server/src/utils/Semaphore.ts diff --git a/mcp/README.md b/mcp/README.md index 06eca0c45b..b607db8016 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -264,14 +264,15 @@ The Penpot MCP server can be configured using environment variables. ### Server Configuration -| Environment Variable | Description | Default | -|------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | -| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | -| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | -| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | -| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | -| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | +| Environment Variable | Description | Default | +|--------------------------------------------------|----------------------------------------------------------------------------|----------------| +| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | +| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | +| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | +| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | +| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | +| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | +| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | ### Logging Configuration diff --git a/mcp/packages/plugin/src/TaskHandler.ts b/mcp/packages/plugin/src/TaskHandler.ts index 32218631ff..a8f05a4229 100644 --- a/mcp/packages/plugin/src/TaskHandler.ts +++ b/mcp/packages/plugin/src/TaskHandler.ts @@ -24,8 +24,10 @@ export class Task { return; } + // create response object const response = { type: "task-response", + // NOTE: This inner response schema also constructed in main.ts/sendTaskResponse. response: { id: this.requestId, success: success, diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 40b5bd7ba8..94210da288 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,12 +1,29 @@ import "./style.css"; +/** + * the maximum allowed size for task responses sent back to the MCP server in the integrated remote MCP mode. + * This bounds the JSON response size. + * Note that in the remote MCP case, responses are transferred to LLMs (not the file system) and LLMs have + * size limitations. This serves to bound the size of returned images in particular. + * Too many overly large simultaneous responses can cause OOM issues in the MCP server, so this contributes + * to bounding memory usage in the centrally provided MCP server. + */ +const MAX_TASK_RESPONSE_SIZE_REMOTE_MCP = 15_000_000; + // get the current theme from the URL const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// WebSocket connection management +// WebSocket connection to the MCP server let ws: WebSocket | null = null; +/** + * indicates whether the plugin is running with the Penpot-integrated remote MCP server enabled + * (as opposed to a local server used with the explicitly loaded plugin); + * set via the "mcp-mode" message sent by plugin.ts on initialization + */ +let isIntegratedRemoteMcp = false; + const statusPill = document.getElementById("connection-status") as HTMLElement; const statusText = document.getElementById("status-text") as HTMLElement; const currentTaskEl = document.getElementById("current-task") as HTMLElement; @@ -79,7 +96,22 @@ function updateExecutedCode(code: string | null): void { */ function sendTaskResponse(response: any): void { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(response)); + let responseString = JSON.stringify(response); + if (isIntegratedRemoteMcp && responseString.length > MAX_TASK_RESPONSE_SIZE_REMOTE_MCP) { + const errorMessage = `Serialised response size (${responseString.length}) exceeds maximum of ${MAX_TASK_RESPONSE_SIZE_REMOTE_MCP}.`; + console.warn( + errorMessage + + " [integrated remote MCP mode restriction]; sending error response instead; original response:", + response + ); + response = { + id: response.id, + success: false, + error: errorMessage, + }; + responseString = JSON.stringify(response); + } + ws.send(responseString); console.log("Sent response to MCP server:", response); } else { console.error("WebSocket not connected, cannot send response"); @@ -176,6 +208,9 @@ disconnectBtn?.addEventListener("click", () => { // Listen plugin.ts messages window.addEventListener("message", (event) => { + if (event.data.type === "mcp-mode") { + isIntegratedRemoteMcp = event.data.integratedRemoteMcp; + } if (event.data.type === "start-server") { connectToMcpServer(event.data.url, event.data.token); } diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index 3827db70eb..34e0349e96 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,6 +1,12 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server + * enabled (as opposed to a local server used with the explicitly loaded plugin) + */ +const isIntegratedRemoteMcp = !!mcp; + /** * Extracts the major.minor.patch prefix from a version string. * @@ -23,12 +29,17 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { width: 236, height: 210, - hidden: !!mcp, + hidden: isIntegratedRemoteMcp, } as any); // Register message handlers penpot.ui.onMessage((message) => { if (typeof message === "object" && message.type === "ui-initialized") { + // Inform the UI about the operating mode + penpot.ui.sendMessage({ + type: "mcp-mode", + integratedRemoteMcp: isIntegratedRemoteMcp, + }); // Check Penpot version compatibility const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); @@ -42,7 +53,7 @@ penpot.ui.onMessage N parallel MCP clients that each call the export_shape tool. + * + * Expectations (observed manually from the server's console output): + * - "Semaphore 'ExportShapeTool' saturated; request queued (k waiting)" lines appear + * at INFO level (at least M - N of them). + * - All M tool calls return successfully. + * - Total elapsed wall-clock time is approximately ceil(M / N) * SLEEP_MS. + * + * Invoke from packages/server with: + * pnpm run test:integration:export + */ +import * as net from "node:net"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { PenpotMcpServer } from "../src/PenpotMcpServer"; +import { ExportShapeTool } from "../src/tools/ExportShapeTool"; +import { TextResponse } from "../src/ToolResponse"; +import { Semaphore } from "../src/utils/Semaphore"; + +// === parameters === + +const N = 3; +const M = 6; +const SLEEP_MS = 5_000; + +// === helpers === + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); +} + +async function callExportShape(url: URL, idx: number): Promise { + const client = new Client({ name: `integration-test-client-${idx}`, version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport(url); + await client.connect(transport); + try { + return await client.callTool({ + name: "export_shape", + arguments: { shapeId: "selection" }, + }); + } finally { + await client.close(); + } +} + +// === main === + +async function main(): Promise { + // dynamic ports must be set before PenpotMcpServer is constructed + const httpPort = await findFreePort(); + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(httpPort); + process.env.PENPOT_MCP_WEBSOCKET_PORT = String(await findFreePort()); + process.env.PENPOT_MCP_REPL_PORT = String(await findFreePort()); + + // shrink the gate and stub the worker + (ExportShapeTool as any).parallelismSemaphore = new Semaphore("ExportShapeTool", N); + (ExportShapeTool.prototype as any).exportImage = async (): Promise => { + await new Promise((r) => setTimeout(r, SLEEP_MS)); + return new TextResponse("stubbed export"); + }; + + const server = new PenpotMcpServer(/* isMultiUser */ true); + await server.start(); + + console.log(`\n=== integration test: N=${N} permits, M=${M} clients, sleep=${SLEEP_MS}ms ===\n`); + + // fire M parallel tool calls, each via its own MCP session with a distinct userToken + const start = Date.now(); + const results = await Promise.allSettled( + Array.from({ length: M }, (_, i) => + callExportShape(new URL(`http://127.0.0.1:${httpPort}/mcp?userToken=test-token-${i}`), i) + ) + ); + const elapsed = Date.now() - start; + + await server.stop(); + + // report + const failures = results.map((r, i) => ({ r, i })).filter(({ r }) => r.status === "rejected"); + + console.log(`\n=== results ===`); + console.log(` elapsed: ${elapsed}ms (expected ~${Math.ceil(M / N) * SLEEP_MS}ms)`); + console.log(` succeeded: ${M - failures.length}/${M}`); + if (failures.length > 0) { + console.log(` failures:`); + for (const { r, i } of failures) { + console.log(` [${i}] ${(r as PromiseRejectedResult).reason}`); + } + process.exit(1); + } + console.log(`\nAll ${M} tool calls succeeded. Scroll up to verify saturation log lines.\n`); + process.exit(0); +} + +main().catch((err) => { + console.error("Integration test crashed:", err); + process.exit(1); +}); diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index 7b8ceed4a6..0d84df9380 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { Tool } from "../Tool"; -import { ImageContent, PNGImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; +import { ImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; import "reflect-metadata"; import { PenpotMcpServer } from "../PenpotMcpServer"; import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask"; +import { createLogger } from "../logger"; import { FileUtils } from "../utils/FileUtils"; +import { Semaphore } from "../utils/Semaphore"; import sharp from "sharp"; /** @@ -49,6 +51,38 @@ export class ExportShapeArgs { * Tool for executing JavaScript code in the Penpot plugin context */ export class ExportShapeTool extends Tool { + /** + * Maximum number of image-export operations that may run concurrently in multi-user mode. + * Configurable via the PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS environment variable; + * defaults to 0, meaning no limit. + * + * When set to a positive value (and combined with the plugin-side per-response cap + * MAX_TASK_RESPONSE_SIZE_REMOTE_MCP, ~15 MB JSON), this caps the in-flight memory + * footprint of image exports at roughly N x cap on the centrally hosted MCP server. + */ + private static readonly MAX_PARALLEL_EXPORTS = parseInt( + process.env.PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS ?? "0", + 10 + ); + + /** + * Gates concurrent export operations across all tool instances (one per session in + * multi-user mode). Static because instances are per-session, but the bound has to + * apply across the whole process. Permits beyond the maximum queue in FIFO order. + * Undefined when MAX_PARALLEL_EXPORTS is non-positive, indicating no limit. + */ + private static readonly parallelismSemaphore: Semaphore | undefined = + ExportShapeTool.MAX_PARALLEL_EXPORTS > 0 + ? new Semaphore("ExportShapeTool", ExportShapeTool.MAX_PARALLEL_EXPORTS) + : undefined; + + static { + createLogger("ExportShapeTool").info( + "Max parallel exports (multi-user mode): %d (0 = unbounded)", + ExportShapeTool.MAX_PARALLEL_EXPORTS + ); + } + /** * Creates a new ExecuteCode tool instance. * @@ -79,6 +113,25 @@ export class ExportShapeTool extends Tool { } protected async executeCore(args: ExportShapeArgs): Promise { + // bound concurrent exports in multi-user mode to keep peak server memory under control; + // in single-user mode (or when no limit is configured) the gate is irrelevant + // and the export runs directly + if (this.mcpServer.isMultiUserMode() && ExportShapeTool.parallelismSemaphore) { + return ExportShapeTool.parallelismSemaphore.withPermit(() => this.exportImage(args)); + } else { + return this.exportImage(args); + } + } + + /** + * Performs the actual image export: requests the image via the plugin and either + * returns it as a tool response or saves it to the requested file path. The bulk + * of the memory pressure (parsed plugin response, decoded image buffer, optional + * re-encoding via sharp) lives here, which is why executeCore gates the call. + * + * @param args - the validated tool arguments + */ + private async exportImage(args: ExportShapeArgs): Promise { // check arguments if (args.filePath) { FileUtils.checkPathIsAbsolute(args.filePath); diff --git a/mcp/packages/server/src/utils/Semaphore.ts b/mcp/packages/server/src/utils/Semaphore.ts new file mode 100644 index 0000000000..e88a63db36 --- /dev/null +++ b/mcp/packages/server/src/utils/Semaphore.ts @@ -0,0 +1,69 @@ +import { createLogger } from "../logger"; + +/** + * Counting semaphore for bounding the parallelism of asynchronous operations. + * + * Calls in excess of the configured maximum are queued in FIFO order and + * proceed once an earlier holder has released its permit. + */ +export class Semaphore { + private static readonly logger = createLogger("Semaphore"); + + private available: number; + private readonly waiters: Array<() => void> = []; + + /** + * @param name - identifier used in log messages so the source of contention is recognisable + * @param maxConcurrent - the maximum number of permits that may be held simultaneously + */ + constructor( + private readonly name: string, + maxConcurrent: number + ) { + if (maxConcurrent < 1) { + throw new Error(`maxConcurrent must be at least 1; got ${maxConcurrent}`); + } + this.available = maxConcurrent; + } + + /** + * Acquires a permit, runs the given function, and releases the permit when it + * settles - including on rejection. Queues if no permit is currently available. + * + * @param fn - the function to run while holding the permit + * @returns the value returned by fn + */ + public async withPermit(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.available > 0) { + this.available--; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + Semaphore.logger.info( + "Semaphore '%s' saturated; request queued (%d waiting)", + this.name, + this.waiters.length + ); + }); + } + + private release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + // pass the permit directly to the next waiter without touching `available` + next(); + } else { + this.available++; + } + } +} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index a6a640ec5e..99fa1143d6 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -38,10 +38,10 @@ importers: version: 5.9.3 vite: specifier: ^7.0.8 - version: 7.3.1(@types/node@20.19.30) + version: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) vite-live-preview: specifier: ^0.3.2 - version: 0.3.2(vite@7.3.1(@types/node@20.19.30)) + version: 0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)) packages/server: dependencies: @@ -112,6 +112,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.30)(typescript@5.9.3) + tsx: + specifier: ^4.22.3 + version: 4.22.3 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -142,6 +145,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -154,6 +163,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -166,6 +181,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -178,6 +199,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -190,6 +217,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -202,6 +235,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -214,6 +253,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -226,6 +271,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -238,6 +289,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -250,6 +307,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -262,6 +325,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -274,6 +343,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -286,6 +361,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -298,6 +379,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -310,6 +397,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -322,6 +415,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -334,6 +433,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -346,6 +451,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -358,6 +469,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -370,6 +487,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -382,6 +505,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -394,6 +523,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -406,6 +541,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -418,6 +559,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -430,6 +577,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -442,6 +595,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -1035,6 +1194,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1485,6 +1649,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1627,156 +1796,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -2288,6 +2535,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -2796,6 +3072,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -2814,7 +3096,7 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)): + vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)): dependencies: '@commander-js/extra-typings': 12.1.0(commander@12.1.0) '@types/ansi-html': 0.0.0 @@ -2826,14 +3108,14 @@ snapshots: debug: 4.4.3 escape-goat: 4.0.0 p-defer: 4.0.1 - vite: 7.3.1(@types/node@20.19.30) + vite: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) ws: 8.19.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - vite@7.3.1(@types/node@20.19.30): + vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -2844,6 +3126,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.30 fsevents: 2.3.3 + tsx: 4.22.3 which@2.0.2: dependencies: