From 40f960e8395475ddcffdb3253e51e9f9b5e71622 Mon Sep 17 00:00:00 2001 From: Gaudenz Alder Date: Tue, 14 Jul 2026 19:14:38 +0200 Subject: [PATCH] feat(libavoid): adopt the pure-JS two-file js/libavoid-js CDN layout The next draw.io release replaces the three-part WASM bundle (glue + base64 wasm payload + loader) with a single self-contained pure-JS libavoid.min.js that publishes globalThis.Avoid and parks window.__libavoidReady itself, next to the shared routing core. - mcp-app-server: load the bundle + routing core from the CDN as two ETag-versioned URLs and drop the loader plumbing the new bundle makes obsolete (processLibavoidBundle, libavoidWasmB64, wasm-unsafe-eval notes). No pre-release fallback: deploy once the release is on viewer.diagrams.net - against the old CDN layout routing degrades gracefully to unrouted edges. - mcp-tool-server: refresh the vendored cold-cache/offline fallback copy of libavoid-routing.js verbatim from drawio-dev (dangling-end free-point routing, lazily-requested jetty checkpoints, cappedJetty, snapToPoint candidate pins). Co-Authored-By: Claude Fable 5 --- mcp-app-server/CLAUDE.md | 2 +- mcp-app-server/src/build-html.js | 8 +- mcp-app-server/src/index.js | 8 +- mcp-app-server/src/libavoid-versions.js | 17 +-- mcp-app-server/src/shared.js | 122 +++++------------- mcp-app-server/vendor/libavoid/README.md | 44 +++---- mcp-tool-server/vendor/libavoid/README.md | 12 +- .../vendor/libavoid/libavoid-routing.js | 65 +++++++--- 8 files changed, 126 insertions(+), 152 deletions(-) diff --git a/mcp-app-server/CLAUDE.md b/mcp-app-server/CLAUDE.md index d96ce4e..bfbb73f 100644 --- a/mcp-app-server/CLAUDE.md +++ b/mcp-app-server/CLAUDE.md @@ -19,7 +19,7 @@ At startup (Node.js) or build time (Workers), the HTML is assembled. The draw.io - **`app-with-deps.js`** (~319 KB, from `node_modules/@modelcontextprotocol/ext-apps`) — MCP Apps SDK browser bundle. The bundle is ESM (ends with `export { ... as App }`), so `processAppBundle()` strips the export statement and creates a local `var App = ` alias. This makes it safe to inline in a plain `\n' + - ' ' - : '\n' + + var libavoidBlock = libavoidJs + ? '\n' + + ' ' + : '\n' + libavoidSrcs.map(function(u) { return ' '; @@ -1900,7 +1869,7 @@ function routeWithLibavoid(graph, Avoid) } /** - * Async wrapper around routeWithLibavoid: await wasm readiness, then route. + * Async wrapper around routeWithLibavoid: await router readiness, then route. * Degrades gracefully — calls onDone(false) (diagram stays unrouted) when * libavoid isn't present, failed to load, or routing throws. * @@ -4451,7 +4420,7 @@ function finalizeStreamingView(xml, opts) // Routing without ELK: the author's (LLM's) vertex positions are kept; // libavoid only computes obstacle-avoiding paths for the edges. The // normal fit above already ran (no postLayout), and routing doesn't move - // vertices, so no extra camera move is needed. applyRouting awaits wasm + // vertices, so no extra camera move is needed. applyRouting awaits router // readiness internally, so no rAF gate here. runRoutingPass(streamGraph); } @@ -6401,39 +6370,6 @@ export function processElkBundle(raw) "drawio-elk.min.js"); } -/** - * Process the libavoid-js browser bundle (obstacle-avoiding orthogonal edge - * routing — the `routing: "libavoid"` pass). Ships as ESM ending in - * `export{X as AvoidLib}` and uses `import.meta.url` (illegal in a classic - * - - ``` -(glue → base64 wasm payload → loader → shared routing core; see -`buildHtml`'s `libavoidBlock` in `src/shared.js`). The wasm rides as base64 -inside `libavoid-wasm.js` and is decoded by the loader — still **no -`fetch`**, so the sandboxed iframe (no `allow-same-origin`, no `data:` in -`connect-src`) is satisfied by plain `script-src`. The loader parks -`window.__libavoidReady` (resolves to the `Avoid` namespace, or `null` on -failure); the routing core defines `globalThis.AvoidRouting` — the same -canonical artifact the draw.io editor bundles into `extensions.min.js` and -the mcp-tool-server vendors (`mcp-tool-server/vendor/libavoid/`, which stays -vendored: it runs server-side in Node and ships in the npm package). +(pure-JS router bundle → shared routing core; see `buildHtml`'s +`libavoidBlock` in `src/shared.js`, ETag-versioned URLs from +`src/libavoid-versions.js`). `libavoid.min.js` is the pure-JS Emscripten +build — a self-contained classic script, **no WASM and no `fetch`**, so the +sandboxed iframe (no `allow-same-origin`, no `data:` in `connect-src`) is +satisfied by plain `script-src` — no `'wasm-unsafe-eval'` needed. On +execution it publishes `globalThis.Avoid` and synchronously parks +`window.__libavoidReady` (an already-resolved promise: the `Avoid` +namespace, or `null` on an init failure); the routing core defines +`globalThis.AvoidRouting` — the same canonical `drawio-dev js/libavoid-js/` +artifact the draw.io editor bundles and the mcp-tool-server vendors +(`mcp-tool-server/vendor/libavoid/`, which stays vendored: it runs +server-side in Node and ships in the npm package). `buildHtml` still supports inlining a local build instead (pass -`options.libavoidJs` — a `processLibavoidBundle`-processed glue with -`libavoid-routing.js` appended — plus `options.libavoidWasmB64`), e.g. for -testing unreleased libavoid changes. +`options.libavoidJs` — the drawio-dev `libavoid.min.js` artifact as-is, with +`libavoid-routing.js` appended), e.g. for testing unreleased libavoid +changes. Remaining files: @@ -37,7 +39,5 @@ Remaining files: - `LICENSE` — libavoid-js is LGPL-2.1-or-later (kept for reference; the binaries are served by the CDN, not shipped from this repo). -> ⚠️ WebAssembly must instantiate inside the Claude.ai MCP-app iframe, -> which requires the host CSP to allow wasm compilation -> (`'wasm-unsafe-eval'`) and `viewer.diagrams.net` in `script-src` (the -> same allowance drawio-elk/drawio-mermaid already rely on). +> ⚠️ The host CSP must allow `viewer.diagrams.net` in `script-src` — the +> same allowance drawio-elk/drawio-mermaid already rely on. diff --git a/mcp-tool-server/vendor/libavoid/README.md b/mcp-tool-server/vendor/libavoid/README.md index 94fac6a..f7d5f3d 100644 --- a/mcp-tool-server/vendor/libavoid/README.md +++ b/mcp-tool-server/vendor/libavoid/README.md @@ -3,9 +3,10 @@ Obstacle-avoiding orthogonal **edge routing** for the `routing: "libavoid"` option on `open_drawio_xml`. Vendored from [`libavoid-js`](https://github.com/Aksem/libavoid-js). -Unlike the app server (which inlines the *browser* build + base64 wasm into a -sandboxed iframe), the tool server runs in plain Node, so we vendor the **node -build** and let it read the wasm from disk — no patching, no base64. +Unlike the app server (which loads the pure-JS *browser* bundle from the +viewer.diagrams.net CDN into a sandboxed iframe), the tool server runs in +plain Node, so we vendor the **node build** and let it read the wasm from +disk — no patching, no base64. Artifacts: @@ -15,8 +16,9 @@ Artifacts: - `libavoid.wasm` — the Emscripten binary (~492 KB). Loaded by path: `await AvoidLib.load(join(__dirname, "vendor/libavoid/libavoid.wasm"))`. - `libavoid-routing.js` — the shared routing core (`globalThis.AvoidRouting`: - `computeRoutes` incl. fixed-connection-point pins and jettySize stub - checkpoints, plus the pure geometry helpers). **Verbatim copy** — the + `computeRoutes` incl. fixed-connection-point pins, jetty checkpoints, and + dangling-end free-point routing, plus the pure geometry helpers). + **Verbatim copy** — the canonical source is `drawio-dev src/main/webapp/js/libavoid-js/ libavoid-routing.js` (the same artifact the draw.io editor bundles and the app server loads from the CDN); copy it over when it changes there. At diff --git a/mcp-tool-server/vendor/libavoid/libavoid-routing.js b/mcp-tool-server/vendor/libavoid/libavoid-routing.js index e02e4b2..fb7dfe2 100644 --- a/mcp-tool-server/vendor/libavoid/libavoid-routing.js +++ b/mcp-tool-server/vendor/libavoid/libavoid-routing.js @@ -1,7 +1,8 @@ /* - * libavoid-routing.js — the draw.io routing core for the vendored libavoid-js - * WASM router. Hand-authored (NOT regenerated by ant vendor-libavoid, like - * libavoid-loader.js); it never changes with the upstream version. + * libavoid-routing.js — the draw.io routing core over the libavoid solver. + * Hand-authored; engine-agnostic (takes the `Avoid` namespace as a parameter, + * so it works whether the host loads libavoid as WASM or pure JS) and never + * changes with the libavoid build. * * CANONICAL SOURCE: this file (drawio-dev js/libavoid-js/). drawio-mcp vendors * verbatim copies (mcp-app-server/vendor/libavoid/ and @@ -216,6 +217,12 @@ */ AvoidRouting.cappedJetty = function(jetty, constraint, from, to) { + // A dangling (free-point) end has no bounds to measure the gap against. + if (from == null || to == null) + { + return jetty; + } + var DIR = AvoidRouting.DIR; var d = (constraint != null) ? constraint.dir : 0; var dgap = (d == DIR.down) ? to.y - (from.y + from.h) : @@ -323,11 +330,17 @@ * @param {Array<{id:string,x:number,y:number,w:number,h:number}>} vertices * Obstacles, in ABSOLUTE coordinates. Shapes enclosing a terminal * of a routed edge are dropped (filterEnclosing). - * @param {Array<{id,source,target,sourceConstraint?,targetConstraint?, + * @param {Array<{id,source,target,sourcePoint?,targetPoint?, + * sourceConstraint?,targetConstraint?, * sourcePoints?,targetPoints?,sourceSides?,targetSides?, * sourceJetty?,targetJetty?}>} edges - * Edges referencing vertex ids (endpoints not both known vertices - * are skipped). *Constraint = {x,y,dir} (constraintForPoint): a + * Edges referencing vertex ids. An end may instead be a DANGLING free + * point — sourcePoint/targetPoint {x,y} in absolute coords, used when + * that terminal has no vertex (an unconnected endpoint): it routes to + * a plain Point ConnEnd (no shape, no direction), matching the + * drag-preview, and takes no constraint/pin/mask/jetty. An edge is + * skipped only when an end is neither a vertex nor a free point. + * *Constraint = {x,y,dir} (constraintForPoint): a * fixed connection point routed via a directed ShapeConnectionPin; * absent => the endpoint floats at the shape centre. *Points = an * array of {x,y,dir} candidate anchors (draw.io's snapToPoint — @@ -423,8 +436,20 @@ // constraint (pinned exit/entry) wins over both, and a snap-point set // wins over a mask (the snapToPoint branch of // updateFloatingTerminalPoint runs before any mask enforcement). - function makeEnd(vid, b, constraint, sides, points) + function makeEnd(vid, b, constraint, sides, points, freePoint) { + // Dangling end: no vertex bounds, just a free point (the edge's + // unconnected endpoint). Route to a plain Point ConnEnd — no shape, + // no direction — exactly as the warm-session drag preview does, so a + // committed edge with an unconnected end matches its live preview. + if (b == null) + { + var dp = new Avoid.Point(freePoint.x, freePoint.y); + var dce = new Avoid.ConnEnd(dp); + dp.delete(); + return dce; + } + if (constraint != null && constraint.dir != null && shapeRefs[vid] != null) { pinClass++; @@ -481,10 +506,16 @@ return ce; } - // Endpoint anchor: the fixed connection point, else the shape centre — - // the same point the ConnEnd routes from. - function anchor(b, constraint) + // Endpoint anchor: the free point for a dangling end, else the fixed + // connection point, else the shape centre — the point the ConnEnd routes + // from. + function anchor(b, constraint, freePoint) { + if (b == null) + { + return freePoint; + } + return (constraint != null) ? {x: b.x + constraint.x * b.w, y: b.y + constraint.y * b.h} : {x: b.x + b.w / 2, y: b.y + b.h / 2}; @@ -518,15 +549,19 @@ var sb = bounds[e.source]; var tb = bounds[e.target]; - if (sb == null || tb == null) + // An end is either a known vertex (sb/tb) or a dangling free point + // (e.sourcePoint / e.targetPoint, absolute coords) for an unconnected + // endpoint. Skip only when an end is neither. + if ((sb == null && e.sourcePoint == null) || + (tb == null && e.targetPoint == null)) { continue; } // ConnRef copies the ConnEnds, so free them after construction // (they are not owned by the router). - var se = makeEnd(e.source, sb, e.sourceConstraint, e.sourceSides, e.sourcePoints); - var de = makeEnd(e.target, tb, e.targetConstraint, e.targetSides, e.targetPoints); + var se = makeEnd(e.source, sb, e.sourceConstraint, e.sourceSides, e.sourcePoints, e.sourcePoint); + var de = makeEnd(e.target, tb, e.targetConstraint, e.targetSides, e.targetPoints, e.targetPoint); var conn = new Avoid.ConnRef(router, se, de); se.delete(); de.delete(); @@ -543,8 +578,8 @@ // after the first solve (see below). var sourceJetty = AvoidRouting.cappedJetty(e.sourceJetty, e.sourceConstraint, sb, tb); var targetJetty = AvoidRouting.cappedJetty(e.targetJetty, e.targetConstraint, tb, sb); - var sa = anchor(sb, e.sourceConstraint); - var ta = anchor(tb, e.targetConstraint); + var sa = anchor(sb, e.sourceConstraint, e.sourcePoint); + var ta = anchor(tb, e.targetConstraint, e.targetPoint); // A pinned anchor buried inside ANOTHER obstacle (a shape dragged // over the terminal) puts the whole route into libavoid's escape /