Clean up automation bridge: direct imports, upgrade zod to v4

- Replace ssrLoadModule hacks with direct imports from @open-pencil/core
- Replace isBunRuntime() hack with @hono/node-server (works everywhere)
- Remove ViteServer type parameter from startAutomationBridge
- Upgrade @open-pencil/mcp zod dependency from v3 to v4
- Add @open-pencil/mcp as root devDependency for bridge imports
This commit is contained in:
Danila Poyarkov 2026-03-15 10:03:20 +03:00 committed by Anton A S
parent fea1f4f95d
commit 5641554ab7
5 changed files with 21 additions and 55 deletions

View file

@ -51,6 +51,7 @@
"@hono/node-server": "^1.19.11",
"@iconify-json/lucide": "^1.2.94",
"@modelcontextprotocol/sdk": "^1.27.1",
"@open-pencil/mcp": "workspace:*",
"@playwright/test": "^1.58.2",
"@tauri-apps/cli": "^2",
"@types/culori": "^4.0.1",
@ -135,7 +136,7 @@
"@open-pencil/core": "workspace:*",
"canvaskit-wasm": "^0.40.0",
"hono": "^4.11.4",
"zod": "^3.25.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@types/node": "^22.0.0",
@ -2429,14 +2430,8 @@
"@multiformats/dns/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="],
"@open-pencil/mcp/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@open-pencil/mcp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
"@open-pencil/mcp/hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
"@open-pencil/mcp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/plugin-replace/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],

View file

@ -97,6 +97,7 @@
"vite": "^8.0.0",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0",
"ws": "^8.19.0"
"ws": "^8.19.0",
"@open-pencil/mcp": "workspace:*"
}
}

View file

@ -31,7 +31,7 @@
"@open-pencil/core": "workspace:*",
"canvaskit-wasm": "^0.40.0",
"hono": "^4.11.4",
"zod": "^3.25.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.0.0"

View file

@ -34,8 +34,8 @@ function fail(e: unknown): McpResult {
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true }
}
export function paramToZod(param: ParamDef): z.ZodTypeAny {
const typeMap: Record<ParamType, () => z.ZodTypeAny> = {
export function paramToZod(param: ParamDef): z.ZodType {
const typeMap: Record<ParamType, () => z.ZodType> = {
string: () =>
param.enum
? z.enum(param.enum as [string, ...string[]]).describe(param.description)
@ -94,7 +94,7 @@ export function createServer(version: string, options: CreateServerOptions = {})
}
function registerTool(def: ToolDef) {
const shape: Record<string, z.ZodTypeAny> = {}
const shape: Record<string, z.ZodType> = {}
for (const [key, param] of Object.entries(def.params)) {
shape[key] = paramToZod(param)
}

View file

@ -5,22 +5,14 @@
* ws WebSocket server on :7601 for the browser page.
*
* Flow: CLI HTTP POST /rpc Hono WebSocket browser execute response
*
* Security model: same-machine trust. Both HTTP and WebSocket bind to 127.0.0.1.
* The browser generates a random bearer token and registers it via WebSocket.
* GET /health exposes the token so the CLI can discover it this is intentional:
* any local process can access the bridge, and the token only prevents accidental
* cross-session collisions when multiple instances are running.
*/
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { WebSocketServer, type WebSocket } from 'ws'
import type { ZodTypeAny } from 'zod'
import type { ViteDevServer } from 'vite'
// Can't import from @open-pencil/core here — this file is bundled by esbuild
// as part of the Vite config, and workspace packages are externalized then
// loaded by Node's ESM resolver which can't handle .ts source imports.
const AUTOMATION_HTTP_PORT = 7600
const AUTOMATION_WS_PORT = 7601
const RPC_TIMEOUT = 30_000
@ -31,9 +23,7 @@ interface PendingRequest {
timer: ReturnType<typeof setTimeout>
}
type ViteServer = { ssrLoadModule: (id: string) => Promise<Record<string, unknown>> }
export function startAutomationBridge(server: ViteServer) {
export function startAutomationBridge(server: ViteDevServer) {
const pending = new Map<string, PendingRequest>()
let browserWs: WebSocket | null = null
let authToken: string | null = null
@ -119,12 +109,9 @@ export function startAutomationBridge(server: ViteServer) {
async function jsxToTree(jsx: string): Promise<unknown> {
const core = await server.ssrLoadModule('@open-pencil/core')
const buildComponent = core.buildComponent as (jsx: string) => () => unknown
const resolveToTree = core.resolveToTree as (el: unknown) => unknown
const createElement = core.createElement as (type: unknown, props: unknown) => unknown
const Component = buildComponent(jsx)
const element = createElement(Component, null)
return resolveToTree(element)
const Component = (core.buildComponent as (jsx: string) => () => unknown)(jsx)
const element = (core.createElement as (type: unknown, props: unknown) => unknown)(Component, null)
return (core.resolveToTree as (el: unknown) => unknown)(element)
}
async function preprocessRpc(body: Record<string, unknown>): Promise<Record<string, unknown>> {
@ -178,7 +165,7 @@ export function startAutomationBridge(server: ViteServer) {
}
})
// MCP Streamable HTTP endpoint — proxies tool calls through WebSocket to the live editor
// MCP Streamable HTTP — proxies tool calls through WebSocket to the live editor
type McpTransport = { handleRequest: (r: Request) => Promise<Response> }
const mcpSessions = new Map<string, McpTransport>()
@ -186,15 +173,15 @@ export function startAutomationBridge(server: ViteServer) {
const cached = sessionId ? mcpSessions.get(sessionId) : undefined
if (cached) return cached
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
const { WebStandardStreamableHTTPServerTransport } =
await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js')
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
const { z } = await import('zod')
const core = await server.ssrLoadModule('@open-pencil/core') as {
ALL_TOOLS: Array<{ name: string; description: string; params: Record<string, unknown>; mutates?: boolean }>
ALL_TOOLS: Array<{ name: string; description: string; params: Record<string, unknown> }>
}
const mcp = await server.ssrLoadModule('../../packages/mcp/src/server.ts') as {
paramToZod: (p: unknown) => ZodTypeAny
const mcp = await server.ssrLoadModule('@open-pencil/mcp') as {
paramToZod: (p: unknown) => ReturnType<typeof z.string>
}
const id = sessionId ?? crypto.randomUUID()
@ -202,7 +189,7 @@ export function startAutomationBridge(server: ViteServer) {
const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
for (const def of core.ALL_TOOLS) {
const shape: Record<string, ZodTypeAny> = {}
const shape: Record<string, ReturnType<typeof z.string>> = {}
for (const [key, param] of Object.entries(def.params)) {
shape[key] = mcp.paramToZod(param)
}
@ -239,26 +226,9 @@ export function startAutomationBridge(server: ViteServer) {
return transport.handleRequest(c.req.raw)
})
void startServer(app)
serve({ fetch: app.fetch, port: AUTOMATION_HTTP_PORT, hostname: '127.0.0.1' })
console.log(`[automation] HTTP http://127.0.0.1:${AUTOMATION_HTTP_PORT}`)
console.log(`[automation] WS ws://127.0.0.1:${AUTOMATION_WS_PORT}`)
console.log(`[automation] MCP http://127.0.0.1:${AUTOMATION_HTTP_PORT}/mcp`)
}
function isBunRuntime(): boolean {
return 'Bun' in globalThis
}
async function startServer(app: Hono) {
if (isBunRuntime()) {
;(globalThis as unknown as { Bun: { serve: (opts: object) => void } }).Bun.serve({
fetch: app.fetch,
port: AUTOMATION_HTTP_PORT,
hostname: '127.0.0.1'
})
} else {
const { serve } = await import('@hono/node-server')
serve({ fetch: app.fetch, port: AUTOMATION_HTTP_PORT, hostname: '127.0.0.1' })
}
}