feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild

The shell (packages/mcp-server/shell) renders DrawioFrame inside the
shared providers without the chat: its config comes from
window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the
theme is kept under an mcp: localStorage key, and system fonts stand in
for the web fonts. scripts/build-shell.mjs bundles it into dist/shell
with the root's esbuild and Tailwind; check-package caps shell.js at
1.5 MB and shell.css at 300 KB.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 7e2edbf8a1
commit 77c6a2d807
19 changed files with 585 additions and 1 deletions
+2
View File
@@ -16,6 +16,8 @@ packages/*/dist
/coverage
/playwright-report/
/test-results/
packages/*/playwright-report/
packages/*/test-results/
# next.js
/.next/
+1
View File
@@ -11,6 +11,7 @@
"**",
"!public",
"!packages/mcp-server/src/preview",
"!packages/mcp-server/shell/index.html",
"!lib/model-catalog.json"
]
},
+4
View File
@@ -699,5 +699,9 @@
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas",
"openFileHint": "Keep working on a diagram you have"
},
"shell": {
"waiting": "Waiting for the MCP server…",
"toggleTheme": "Switch between light and dark"
}
}
+4
View File
@@ -699,5 +699,9 @@
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める",
"openFileHint": "手元の図の続きを編集"
},
"shell": {
"waiting": "MCP サーバーを待っています…",
"toggleTheme": "ライトとダークを切り替え"
}
}
+4
View File
@@ -699,5 +699,9 @@
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始",
"openFileHint": "接著編輯已有的圖"
},
"shell": {
"waiting": "正在等待 MCP 伺服器…",
"toggleTheme": "切換淺色或深色"
}
}
+4
View File
@@ -699,5 +699,9 @@
"recent": "最近的图纸",
"drawYourself": "从空白画布开始",
"openFileHint": "接着编辑已有的图"
},
"shell": {
"waiting": "正在等待 MCP 服务器…",
"toggleTheme": "切换浅色或深色"
}
}
+2 -1
View File
@@ -8,13 +8,14 @@
"next-ai-drawio-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs",
"build": "tsc && tsc -p shell && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs && node scripts/build-shell.mjs",
"check-drawio": "node scripts/check-drawio-files.mjs",
"check-package": "node scripts/check-package.mjs",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"prepublishOnly": "npm run build"
},
"keywords": [
@@ -0,0 +1,80 @@
// Build the canvas shell (shell/, the web app's canvas without the chat)
// into dist/shell: shell.js with esbuild, shell.css with Tailwind, and the
// page template. Needs the repository checked out with its root
// devDependencies installed (esbuild, Tailwind, React and the shared
// components come from there), like copy-assets.mjs needs ../../docs.
import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { createRequire } from "node:module"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
const root = join(pkg, "../..")
const shell = join(pkg, "shell")
const out = join(pkg, "dist/shell")
// The root's copies: the shell's React must be the one the shared
// components import, and the MCP core's pako and saxes should not be
// bundled twice (packages/mcp-server has its own node_modules)
const rootRequire = createRequire(join(root, "package.json"))
const rootPackage = (name) =>
dirname(rootRequire.resolve(`${name}/package.json`))
const { build } = await import(rootRequire.resolve("esbuild"))
const postcss = (await import(rootRequire.resolve("postcss"))).default
const tailwindcss = (await import(rootRequire.resolve("@tailwindcss/postcss")))
.default
mkdirSync(out, { recursive: true })
const result = await build({
entryPoints: [join(shell, "main.tsx")],
outfile: join(out, "shell.js"),
bundle: true,
minify: true,
format: "esm",
target: "es2022",
platform: "browser",
jsx: "automatic",
legalComments: "none",
metafile: true,
absWorkingDir: root,
alias: {
"@": root,
pako: rootPackage("pako"),
saxes: rootPackage("saxes"),
},
define: {
"process.env.NODE_ENV": '"production"',
// The shell has no base path and takes its draw.io from the config
// the server writes into the page, not from the web app's build
// settings
"process.env.NEXT_PUBLIC_BASE_PATH": '""',
"process.env.NEXT_PUBLIC_DRAWIO_BASE_URL": '""',
},
})
const js = readFileSync(join(out, "shell.js"), "utf8")
// A reference that the defines above missed would throw in the browser
// ("process is not defined")
const leftover = js.match(/process\.env\.[A-Z_]+/)
if (leftover) {
throw new Error(`shell.js still reads ${leftover[0]}; define it above`)
}
const cssFile = join(shell, "shell.css")
const css = await postcss([
// Tailwind scans the shell directory on its own; shell.css names the
// shared component directories with @source
tailwindcss({ base: shell, optimize: { minify: true } }),
]).process(readFileSync(cssFile, "utf8"), {
from: cssFile,
to: join(out, "shell.css"),
})
writeFileSync(join(out, "shell.css"), css.css)
cpSync(join(shell, "index.html"), join(out, "index.html"))
const kb = (n) => `${(n / 1024).toFixed(0)} KB`
const bundled = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
console.log(
`shell: shell.js ${kb(bundled.bytes)}, shell.css ${kb(Buffer.byteLength(css.css))} -> ${out}`,
)
@@ -12,7 +12,16 @@ const REQUIRED = [
"dist/drawio/index.html",
"dist/drawio/js/app.min.js",
"dist/drawio/LICENSE",
"dist/shell/index.html",
"dist/shell/shell.js",
"dist/shell/shell.css",
]
// The canvas shell (708 KB and 50 KB when it was added); a jump means a
// dependency of the shared components grew the bundle
const MAX_FILE_BYTES = {
"dist/shell/shell.js": 1.5 * 1024 * 1024,
"dist/shell/shell.css": 300 * 1024,
}
// The bundled draw.io is most of the package (15.1 MB packed with draw.io
// v32.0.2); a jump means the file list grew more than intended
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
@@ -29,6 +38,16 @@ if (missing.length > 0) {
process.exit(1)
}
const mb = (n) => (n / 1048576).toFixed(1)
const kb = (n) => (n / 1024).toFixed(0)
for (const [path, limit] of Object.entries(MAX_FILE_BYTES)) {
const size = pack.files.find((f) => f.path === path).size
if (size > limit) {
console.error(
`${path} is ${kb(size)} KB, over the ${kb(limit)} KB limit`,
)
process.exit(1)
}
}
if (pack.size > MAX_TARBALL_BYTES) {
console.error(
`npm package is ${mb(pack.size)} MB packed, over the ${mb(MAX_TARBALL_BYTES)} MB limit`,
@@ -0,0 +1,90 @@
import { Moon, Sun } from "lucide-react"
import { useMemo } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage"
import { CompareDialog } from "@/components/canvas/compare-dialog"
import { VersionStrip } from "@/components/canvas/version-strip"
import {
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import type { DrawioSource } from "@/lib/drawio/drawio-config"
import { useSettingsStore } from "@/stores/settings-store"
import type { ShellConfig } from "./runtime-config"
import { setDark } from "./theme"
// No versions until the shell reads the server's History (a later step)
const NO_VERSIONS: VersionsSource = {
versions: [],
onCanvasId: null,
undoneId: null,
isBusy: false,
canUndo: false,
canRedo: false,
restore: () => {},
undo: () => {},
redo: () => {},
}
/**
* The MCP preview: the web app's canvas without the chat. A slim bar names
* the session and the sync state; the versions strip and Compare come from
* the shared canvas components.
*/
export function CanvasShell({ config }: { config: ShellConfig }) {
const dict = useDictionary()
const isDark = useSettingsStore((s) => s.isDark)
const drawioSource = useMemo<DrawioSource>(
() => ({
editorUrl: config.drawioBaseUrl,
ui: config.drawioUi || undefined,
lang: config.drawioLang || undefined,
}),
[config],
)
return (
<VersionsProvider value={NO_VERSIONS}>
<div className="flex h-dvh flex-col bg-background text-foreground">
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
<span className="text-[13px] font-semibold tracking-[-0.01em]">
Next AI Draw.io
</span>
{config.sessionId && (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums"
title={config.sessionId}
data-testid="shell-session"
>
{config.sessionId.slice(-8)}
</span>
)}
<span
className="truncate text-xs text-muted-foreground"
data-testid="shell-status"
>
{dict.shell.waiting}
</span>
<button
type="button"
onClick={() => setDark(!isDark)}
aria-label={dict.shell.toggleTheme}
title={dict.shell.toggleTheme}
className="ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
data-testid="theme-toggle"
>
{isDark ? <Sun /> : <Moon />}
</button>
</header>
<VersionStrip minVersions={1} />
<div className="relative flex-1">
<CanvasStage
className="inset-0"
drawioSource={drawioSource}
/>
</div>
<CompareDialog />
</div>
</VersionsProvider>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { i18n, type Locale } from "@/lib/i18n/config"
import type { Dictionary } from "@/lib/i18n/dictionaries"
import en from "@/lib/i18n/dictionaries/en.json"
import ja from "@/lib/i18n/dictionaries/ja.json"
import zh from "@/lib/i18n/dictionaries/zh.json"
import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json"
// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts
// is server-only); the shell ships all four and picks in the browser
const DICTIONARIES: Record<Locale, Dictionary> = {
en,
zh,
ja,
"zh-Hant": zhHant,
}
/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */
export function matchLocale(tag: string): Locale | null {
const lower = tag.trim().toLowerCase()
if (!lower) return null
if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant"
const base = lower.split("-")[0]
if (base === "zh") return "zh"
if (base === "ja") return "ja"
if (base === "en") return "en"
return null
}
/**
* The shell's language: the server's DRAWIO_LANG when it is one of ours,
* else the browser's first language we have, else English.
*/
export function pickLocale(
configLang: string,
browserLangs: readonly string[],
): Locale {
for (const tag of [configLang, ...browserLangs]) {
const locale = matchLocale(tag)
if (locale) return locale
}
return i18n.defaultLocale
}
export function getShellDictionary(locale: Locale): Dictionary {
return DICTIONARIES[locale]
}
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Next AI Draw.io</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/shell/shell.css">
<script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script>
<script>
// Dark class before the first paint (no white flash). The same rule
// as shell/theme.ts: the choice stored under "mcp:dark-mode", else
// the server's DRAWIO_DARK, else the system
try {
const config = window.__MCP_CONFIG__ || {}
const stored = localStorage.getItem("mcp:dark-mode")
const dark =
stored === "true" ||
(stored === null &&
(config.drawioDark === "dark" ||
(config.drawioDark !== "light" &&
matchMedia("(prefers-color-scheme: dark)").matches)))
if (dark) document.documentElement.classList.add("dark")
} catch {}
</script>
</head>
<body class="antialiased">
<div id="root"></div>
<script type="module" src="/shell/shell.js"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into
// dist/shell/shell.js and served at /shell/ by http-server.ts
import { createRoot } from "react-dom/client"
import { LocaleProvider } from "@/components/canvas/locale-context"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import { CanvasShell } from "./canvas-shell"
import { getShellDictionary, pickLocale } from "./dictionary"
import { pageShellConfig } from "./runtime-config"
import { initTheme } from "./theme"
const config = pageShellConfig()
const locale = pickLocale(config.lang, navigator.languages)
// The stylesheet picks the CJK fonts by the document's language
document.documentElement.lang = locale
initTheme(config.drawioDark)
const root = document.getElementById("root")
if (!root) throw new Error("The shell page has no #root element")
createRoot(root).render(
<DictionaryProvider dictionary={getShellDictionary(locale)}>
<LocaleProvider locale={locale}>
<DiagramProvider>
<CanvasShell config={config} />
</DiagramProvider>
</LocaleProvider>
</DictionaryProvider>,
)
@@ -0,0 +1,64 @@
/**
* What the MCP server writes into the shell page as window.__MCP_CONFIG__
* (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html).
*/
export interface ShellConfig {
sessionId: string
/** Every /api request carries it in the X-Drawio-Token header */
token: string
/** Prefix of the server's API paths */
apiBase: string
/** The editor page: the bundled copy's index.html on this origin, or an
* external draw.io */
drawioBaseUrl: string
/** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */
drawioUi: string
/** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves
* the pick to draw.io */
drawioLang: string
/** DRAWIO_DARK: the theme when the user has not chosen one here */
drawioDark: "dark" | "light" | "auto"
/** DRAWIO_LANG as given, for the shell's own texts */
lang: string
}
const DEFAULTS: ShellConfig = {
sessionId: "",
token: "",
apiBase: "/api",
drawioBaseUrl: "/drawio/index.html",
drawioUi: "",
drawioLang: "",
drawioDark: "auto",
lang: "",
}
/**
* The page's config with every field present: a missing or malformed one
* (another server version) takes its default.
*/
export function readShellConfig(source: unknown): ShellConfig {
const given =
source && typeof source === "object"
? (source as Record<string, unknown>)
: {}
const config = { ...DEFAULTS }
for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) {
const value = given[key]
if (key === "drawioDark") {
if (value === "dark" || value === "light" || value === "auto") {
config.drawioDark = value
}
} else if (typeof value === "string") {
config[key] = value
}
}
return config
}
/** The config the server wrote into this page */
export function pageShellConfig(): ShellConfig {
return readShellConfig(
(window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__,
)
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind,
* with the utility classes of the shared components the shell bundles.
* Built by scripts/build-shell.mjs; Tailwind scans this directory on its
* own, and the shared components through the @source lines.
*/
@import "../../../app/globals.css";
@source "../../../components/canvas";
@source "../../../components/ui";
@source "../../../components/ai-elements";
/* System fonts stand in for the web fonts the Next.js layout loads */
:root {
--font-latin:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
+61
View File
@@ -0,0 +1,61 @@
import { useSettingsStore } from "@/stores/settings-store"
import type { ShellConfig } from "./runtime-config"
/**
* localStorage key of the user's theme choice here: "true" dark, "false"
* light, none: the server's DRAWIO_DARK, else the system. Its own prefix,
* so a web app dev server on the same port keeps its own settings.
* index.html repeats the key in its inline script (run before this bundle).
*/
export const THEME_STORAGE_KEY = "mcp:dark-mode"
/** Whether the shell is dark, from the choice stored here, the server's
* setting and the system */
export function isDarkPreferred(
stored: string | null,
drawioDark: ShellConfig["drawioDark"],
systemDark: boolean,
): boolean {
if (stored === "true" || stored === "false") return stored === "true"
if (drawioDark === "auto") return systemDark
return drawioDark === "dark"
}
function read(): string | null {
try {
return localStorage.getItem(THEME_STORAGE_KEY)
} catch {
return null
}
}
// The canvas components read the settings store's isDark (and the theme
// class, set before the first paint by index.html)
function apply(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark)
useSettingsStore.setState({
hydrated: true,
isDark,
theme: isDark ? "dark" : "light",
})
}
/** Take the theme the inline script chose, and follow the system while
* nothing else decides */
export function initTheme(drawioDark: ShellConfig["drawioDark"]) {
const query = window.matchMedia("(prefers-color-scheme: dark)")
apply(isDarkPreferred(read(), drawioDark, query.matches))
query.addEventListener("change", (event) => {
apply(isDarkPreferred(read(), drawioDark, event.matches))
})
}
/** The user's choice, kept for the next visit */
export function setDark(isDark: boolean) {
try {
localStorage.setItem(THEME_STORAGE_KEY, String(isDark))
} catch {
// Storage can be unavailable (private mode, quota)
}
apply(isDark)
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"incremental": false,
"plugins": []
},
"include": ["./**/*.ts", "./**/*.tsx"]
}
@@ -21,6 +21,8 @@ const SCANNED = [
"lib/diagram-diff.ts",
"stores/canvas-store.ts",
"stores/versions-store.ts",
// The MCP's browser shell, built from these modules by esbuild
"packages/mcp-server/shell",
]
const FORBIDDEN = [
@@ -92,6 +94,7 @@ describe("canvas import boundary", () => {
it("scans the canvas modules", () => {
expect(files).toContain("components/canvas/canvas-stage.tsx")
expect(files).toContain("lib/drawio/editor-bridge.ts")
expect(files).toContain("packages/mcp-server/shell/main.tsx")
})
it.each(files)("%s imports no chat or Next-only module", (file) => {
+115
View File
@@ -0,0 +1,115 @@
/**
* The MCP's canvas shell takes its settings from the config the server
* writes into the page, and picks its language and theme in the browser.
*/
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { describe, expect, it } from "vitest"
import { i18n } from "@/lib/i18n/config"
import {
getShellDictionary,
matchLocale,
pickLocale,
} from "@/packages/mcp-server/shell/dictionary"
import { readShellConfig } from "@/packages/mcp-server/shell/runtime-config"
import {
isDarkPreferred,
THEME_STORAGE_KEY,
} from "@/packages/mcp-server/shell/theme"
const shellDir = join(process.cwd(), "packages/mcp-server/shell")
describe("shell runtime config", () => {
it("takes the server's values", () => {
const config = readShellConfig({
sessionId: "mcp-abc",
token: "0123",
apiBase: "/api",
drawioBaseUrl: "https://embed.diagrams.net/",
drawioUi: "sketch",
drawioLang: "ja",
drawioDark: "dark",
lang: "ja",
})
expect(config.sessionId).toBe("mcp-abc")
expect(config.drawioBaseUrl).toBe("https://embed.diagrams.net/")
expect(config.drawioUi).toBe("sketch")
expect(config.drawioDark).toBe("dark")
})
it("fills in defaults for what is missing or malformed", () => {
expect(readShellConfig(undefined)).toEqual({
sessionId: "",
token: "",
apiBase: "/api",
drawioBaseUrl: "/drawio/index.html",
drawioUi: "",
drawioLang: "",
drawioDark: "auto",
lang: "",
})
const odd = readShellConfig({
sessionId: 42,
drawioDark: "sometimes",
token: "t",
})
expect(odd.sessionId).toBe("")
expect(odd.drawioDark).toBe("auto")
expect(odd.token).toBe("t")
})
})
describe("shell language", () => {
it("maps language tags to the dictionaries we have", () => {
expect(matchLocale("en-US")).toBe("en")
expect(matchLocale("zh")).toBe("zh")
expect(matchLocale("zh-CN")).toBe("zh")
expect(matchLocale("zh-Hans-CN")).toBe("zh")
expect(matchLocale("zh-TW")).toBe("zh-Hant")
expect(matchLocale("zh-Hant")).toBe("zh-Hant")
expect(matchLocale("ZH-HK")).toBe("zh-Hant")
expect(matchLocale("ja-JP")).toBe("ja")
expect(matchLocale("de")).toBeNull()
expect(matchLocale("")).toBeNull()
})
it("prefers the server's language, then the browser's, then English", () => {
expect(pickLocale("zh-tw", ["en-US"])).toBe("zh-Hant")
expect(pickLocale("", ["de-DE", "ja-JP", "en"])).toBe("ja")
expect(pickLocale("de", ["fr"])).toBe("en")
expect(pickLocale("", [])).toBe("en")
})
it("has every dictionary, with the shell's own texts", () => {
for (const locale of i18n.locales) {
const dict = getShellDictionary(locale)
expect(Object.keys(dict.shell).sort(), locale).toEqual([
"toggleTheme",
"waiting",
])
expect(dict.shell.waiting, locale).toBeTruthy()
expect(dict.workspace.canvasLoading, locale).toBeTruthy()
}
})
})
describe("shell theme", () => {
it("follows the stored choice, then the server, then the system", () => {
expect(isDarkPreferred("true", "light", false)).toBe(true)
expect(isDarkPreferred("false", "dark", true)).toBe(false)
expect(isDarkPreferred(null, "dark", false)).toBe(true)
expect(isDarkPreferred(null, "light", true)).toBe(false)
expect(isDarkPreferred(null, "auto", true)).toBe(true)
expect(isDarkPreferred(null, "auto", false)).toBe(false)
expect(isDarkPreferred("maybe", "auto", true)).toBe(true)
})
it("is applied before the first paint by the page's own script", () => {
const html = readFileSync(join(shellDir, "index.html"), "utf8")
expect(THEME_STORAGE_KEY.startsWith("mcp:")).toBe(true)
expect(html).toContain(`localStorage.getItem("${THEME_STORAGE_KEY}")`)
expect(html).toContain("window.__MCP_CONFIG__ = {{CONFIG_JSON}};")
expect(html).toContain('src="/shell/shell.js"')
expect(html).toContain('href="/shell/shell.css"')
})
})