mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
1297 lines
45 KiB
TypeScript
1297 lines
45 KiB
TypeScript
/**
|
|
* Embedded HTTP Server for MCP
|
|
* Serves draw.io embed with state sync and history UI
|
|
*/
|
|
|
|
import { randomBytes, randomUUID } from "node:crypto"
|
|
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"
|
|
import http from "node:http"
|
|
import { dirname, extname, join, posix, resolve, sep } from "node:path"
|
|
import { pipeline } from "node:stream"
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
|
|
|
function readBody(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
cb: (body: string) => void,
|
|
): void {
|
|
// Decode once at the end: a multi-byte UTF-8 character can be split
|
|
// across two chunks.
|
|
const chunks: Buffer[] = []
|
|
let size = 0
|
|
let tooLarge = false
|
|
req.on("data", (chunk: Buffer) => {
|
|
if (tooLarge) return
|
|
size += chunk.length
|
|
if (size > MAX_BODY_BYTES) {
|
|
// Read the rest without keeping it and answer at the end: a
|
|
// connection closed mid-upload reaches the browser as a network
|
|
// error, without this answer
|
|
tooLarge = true
|
|
chunks.length = 0
|
|
return
|
|
}
|
|
chunks.push(chunk)
|
|
})
|
|
req.on("end", () => {
|
|
if (tooLarge) {
|
|
res.writeHead(413, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Payload too large" }))
|
|
return
|
|
}
|
|
cb(Buffer.concat(chunks).toString("utf8"))
|
|
})
|
|
}
|
|
|
|
import { isDrawioTheme, toDrawioLang } from "./drawio-themes.ts"
|
|
import { contentFingerprint } from "./edit-gate.ts"
|
|
import {
|
|
addHistory,
|
|
clearHistory,
|
|
currentEntryId,
|
|
firstCopyIds,
|
|
getHistory,
|
|
getHistoryEntry,
|
|
updateLastHistorySvg,
|
|
} from "./history.ts"
|
|
import { log } from "./logger.ts"
|
|
import { BLANK_MXFILE } from "./pages.ts"
|
|
import { parseSelectionAnswer, type SelectionAnswer } from "./selection.ts"
|
|
|
|
// Configurable draw.io embed URL for private deployments. Set, it replaces
|
|
// the bundled copy (see drawioDir below).
|
|
export const DRAWIO_BASE_URL =
|
|
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
const EXTERNAL_DRAWIO = !!process.env.DRAWIO_BASE_URL
|
|
|
|
// Every /api request must carry this token in the X-Drawio-Token header.
|
|
// The page reads it from its HTML. Another website cannot add a custom
|
|
// header without a CORS preflight, which this server never grants, so its
|
|
// requests stop here whatever Host and Origin they manage to send.
|
|
const API_TOKEN = randomBytes(16).toString("hex")
|
|
|
|
export function getApiToken(): string {
|
|
return API_TOKEN
|
|
}
|
|
|
|
// The trimmed draw.io copy that scripts/fetch-drawio.mjs puts in dist/drawio,
|
|
// next to the compiled server; run from src with tsx, it is one level up
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
let drawioDir: string | null =
|
|
[join(HERE, "drawio"), join(HERE, "../dist/drawio")].find((dir) =>
|
|
existsSync(join(dir, "index.html")),
|
|
) ?? null
|
|
let drawioVersion = readDrawioVersion(drawioDir)
|
|
|
|
/** For tests: serve draw.io from this directory (null: no bundled copy) */
|
|
export function setDrawioDir(dir: string | null): void {
|
|
drawioDir = dir
|
|
drawioVersion = readDrawioVersion(dir)
|
|
}
|
|
|
|
// The canvas shell (the web app's canvas without the chat), built by
|
|
// scripts/build-shell.mjs into dist/shell
|
|
let shellDir: string | null =
|
|
[join(HERE, "shell"), join(HERE, "../dist/shell")].find((dir) =>
|
|
existsSync(join(dir, "index.html")),
|
|
) ?? null
|
|
|
|
/** For tests: serve the shell from this directory (null: not built) */
|
|
export function setShellDir(dir: string | null): void {
|
|
shellDir = dir
|
|
}
|
|
|
|
// This package's version, stamped into the shell files' ETags (see
|
|
// readDrawioVersion for why)
|
|
const PACKAGE_VERSION: string = JSON.parse(
|
|
readFileSync(join(HERE, "../package.json"), "utf8"),
|
|
).version
|
|
|
|
/**
|
|
* The preview page start_session opens: the canvas shell (shell/, the web
|
|
* app's canvas), or with DRAWIO_PREVIEW_UI=classic the classic page
|
|
* (src/preview), kept for one more release.
|
|
*/
|
|
export type PreviewUi = "classic" | "shell"
|
|
|
|
export function previewUiFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): PreviewUi {
|
|
return (env.DRAWIO_PREVIEW_UI ?? "").toLowerCase() === "classic"
|
|
? "classic"
|
|
: "shell"
|
|
}
|
|
|
|
export const PREVIEW_UI: PreviewUi = previewUiFromEnv()
|
|
|
|
export function previewUrl(
|
|
port: number,
|
|
sessionId: string,
|
|
ui: PreviewUi = PREVIEW_UI,
|
|
): string {
|
|
// The classic page stands in while the shell is not built (a run from
|
|
// the sources), as embed.diagrams.net does for a missing dist/drawio
|
|
const path = ui === "shell" && shellDir !== null ? "/shell/" : ""
|
|
return `http://localhost:${port}${path}?mcp=${sessionId}`
|
|
}
|
|
|
|
/**
|
|
* The draw.io version the fetch script stamps into the copy. It is part of
|
|
* every file's ETag: an install that keeps the archive's dates (npm does
|
|
* not) would otherwise hand out the old ETag for a changed file of the
|
|
* same size after an upgrade.
|
|
*/
|
|
function readDrawioVersion(dir: string | null): string {
|
|
if (!dir) return ""
|
|
try {
|
|
return readFileSync(join(dir, ".version"), "utf8").trim()
|
|
} catch {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the preview embeds draw.io from its own origin (the bundled copy),
|
|
* which lets the page call the editor directly. False with DRAWIO_BASE_URL,
|
|
* or when the bundled copy is missing.
|
|
*/
|
|
export function isSameOriginDrawio(): boolean {
|
|
return !EXTERNAL_DRAWIO && drawioDir !== null
|
|
}
|
|
|
|
// Extract origin (scheme + host + port) from URL for postMessage security check
|
|
function getOrigin(url: string): string {
|
|
try {
|
|
const parsed = new URL(url)
|
|
return `${parsed.protocol}//${parsed.host}`
|
|
} catch {
|
|
return url // Fallback if parsing fails
|
|
}
|
|
}
|
|
|
|
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
|
|
|
// Normalize URL for iframe src - ensure no double slashes
|
|
function normalizeUrl(url: string): string {
|
|
// Remove trailing slash to avoid double slashes
|
|
return url.replace(/\/$/, "")
|
|
}
|
|
|
|
// Session ids look like "mcp-<base36 time>-<base36 random>" (start_session).
|
|
// Only this charset is accepted, because ids are written into the page's
|
|
// HTML and script and into the redirect Location header.
|
|
function isValidSessionId(sessionId: string): boolean {
|
|
return /^mcp-[a-z0-9-]{1,64}$/.test(sessionId)
|
|
}
|
|
|
|
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
|
function getMostRecentSessionId(): string | null {
|
|
let mostRecent: { id: string; lastUpdated: Date } | null = null
|
|
for (const [sessionId, state] of stateStore) {
|
|
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
|
|
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
|
|
}
|
|
}
|
|
return mostRecent?.id || null
|
|
}
|
|
|
|
/**
|
|
* Give a session whose state is gone (it expired, or the MCP process
|
|
* restarted) its auto-saved diagram back. The MCP tools call this before
|
|
* they read the state, so they never build on an older copy and then
|
|
* overwrite the file. Not a change worth saving again.
|
|
*/
|
|
export function restoreSavedSession(sessionId: string): void {
|
|
if (stateStore.has(sessionId) || !isValidSessionId(sessionId)) return
|
|
const saved = savedStateLoader?.(sessionId)
|
|
if (saved) setState(sessionId, saved, undefined, false, false)
|
|
}
|
|
|
|
function ensureSessionStateInitialized(sessionId: string): void {
|
|
if (!sessionId) return
|
|
if (!isValidSessionId(sessionId)) return
|
|
restoreSavedSession(sessionId)
|
|
if (stateStore.has(sessionId)) return
|
|
|
|
// Not a change worth saving: the browser fills it on its next push
|
|
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
|
|
// forever when no load(xml) is ever sent
|
|
setState(sessionId, BLANK_MXFILE, undefined, false, false)
|
|
// Nothing is known about this session: a tab that still shows it keeps
|
|
// its diagram
|
|
const state = stateStore.get(sessionId)
|
|
if (state) state.blank = true
|
|
}
|
|
|
|
interface SessionState {
|
|
xml: string
|
|
version: number
|
|
// Made when the state is created (first use, or again after it expired
|
|
// or the MCP process restarted) and kept by every write. A tab tells by
|
|
// it that the server lost what it knew, and every push names the state
|
|
// it was based on, so one based on a lost state is refused.
|
|
stateId: string
|
|
// Created blank because nothing was saved; cleared by the first write
|
|
blank?: boolean
|
|
// Version of the last write the browser did not make itself (AI edit,
|
|
// restore). A browser push based on an older version is rejected.
|
|
serverVersion?: number
|
|
// The XML of that write: what a thumbnail taken after loading it shows
|
|
serverXml?: string
|
|
// The browser saved a change of the user's since that write (a sync
|
|
// reply is no change)
|
|
userEdited?: boolean
|
|
lastUpdated: Date
|
|
lastPolled?: number // Last browser poll; an open tab keeps the session alive
|
|
svg?: string // Cached SVG from last browser save
|
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
|
exportFormat?: ExportFormat // Set by MCP tool to request browser export
|
|
exportXml?: string // Single-page projection to load before a page-targeted export
|
|
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
|
|
exportId?: string // Random id of the pending export, echoed with its result
|
|
exportData?: string // Base64/SVG data returned by browser after export
|
|
selectionId?: string // Random id of the pending get_selection request
|
|
selection?: SelectionAnswer // The page's answer to it
|
|
}
|
|
|
|
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
|
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
|
|
|
/**
|
|
* draw.io's PNG export takes these directly: width caps the image size
|
|
* (never upscales), pageId renders a page other than the one on screen.
|
|
*/
|
|
export interface ExportOptions {
|
|
width?: number
|
|
pageId?: string
|
|
}
|
|
|
|
export const stateStore = new Map<string, SessionState>()
|
|
|
|
let server: http.Server | null = null
|
|
let serverPort = 6002
|
|
const MAX_PORT = 6020
|
|
const SESSION_TTL = 60 * 60 * 1000
|
|
|
|
export function getState(sessionId: string): SessionState | undefined {
|
|
return stateStore.get(sessionId)
|
|
}
|
|
|
|
// Called after every state change (AI write, browser push, restore)
|
|
let stateListener: ((sessionId: string, xml: string) => void) | null = null
|
|
|
|
export function onStateChange(
|
|
listener: (sessionId: string, xml: string) => void,
|
|
): void {
|
|
stateListener = listener
|
|
}
|
|
|
|
// Reads a session's saved diagram when its state is created again (it
|
|
// expired, or the MCP process restarted)
|
|
let savedStateLoader: ((sessionId: string) => string | null) | null = null
|
|
|
|
export function onSessionRecreate(
|
|
loader: (sessionId: string) => string | null,
|
|
): void {
|
|
savedStateLoader = loader
|
|
}
|
|
|
|
export function setState(
|
|
sessionId: string,
|
|
xml: string,
|
|
svg?: string,
|
|
fromBrowser = false,
|
|
notify = true,
|
|
): number {
|
|
const existing = stateStore.get(sessionId)
|
|
const newVersion = (existing?.version || 0) + 1
|
|
stateStore.set(sessionId, {
|
|
xml,
|
|
version: newVersion,
|
|
stateId: existing?.stateId ?? randomUUID(),
|
|
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
|
serverXml: fromBrowser ? existing?.serverXml : xml,
|
|
userEdited: fromBrowser ? existing?.userEdited : false,
|
|
lastUpdated: new Date(),
|
|
lastPolled: existing?.lastPolled,
|
|
// The image of this XML, never an older one's: a write without an
|
|
// image (AI write, sync reply) leaves none until the browser sends
|
|
// it, unless it is the same XML
|
|
svg:
|
|
svg ||
|
|
(existing && existing.xml === xml ? existing.svg : undefined),
|
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
|
exportXml: existing?.exportXml, // Preserve pending projection
|
|
exportOptions: existing?.exportOptions,
|
|
exportId: existing?.exportId,
|
|
exportData: existing?.exportData, // Preserve export result
|
|
selectionId: existing?.selectionId, // Preserve pending selection request
|
|
selection: existing?.selection,
|
|
})
|
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
|
if (notify) stateListener?.(sessionId, xml)
|
|
return newVersion
|
|
}
|
|
|
|
/**
|
|
* Keep the session's diagram in History before a write replaces it.
|
|
* Nothing to keep when the browser saved no change of the user's since the
|
|
* last server write and History ends with that write: the state is that
|
|
* write, or draw.io's own copy of it from a sync (other text, same diagram).
|
|
*/
|
|
export function keepInHistory(sessionId: string, xml: string, svg = ""): void {
|
|
const state = stateStore.get(sessionId)
|
|
const last = getHistory(sessionId).at(-1)
|
|
if (state && !state.userEdited && last && last.xml === state.serverXml) {
|
|
return
|
|
}
|
|
addHistory(sessionId, xml, svg, state?.userEdited ? "user" : undefined)
|
|
}
|
|
|
|
/**
|
|
* Ask the browser bridge to export the current diagram as png/svg.
|
|
*
|
|
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
|
* first, waits for draw.io's own load event, exports, then reloads the
|
|
* session's real document — so a page-targeted export never mutates the
|
|
* canonical session state and needs no fixed-delay guessing on the server.
|
|
*
|
|
* Returns false when the session is unknown. Callers should then poll
|
|
* `getState(sessionId)?.exportData` for the result.
|
|
*/
|
|
export function requestExport(
|
|
sessionId: string,
|
|
format: ExportFormat,
|
|
projectionXml?: string,
|
|
options?: ExportOptions,
|
|
): boolean {
|
|
const state = stateStore.get(sessionId)
|
|
if (!state) return false
|
|
state.exportData = undefined
|
|
state.exportXml = projectionXml
|
|
state.exportOptions = options
|
|
state.exportFormat = format
|
|
// The browser sends this back with the result, so a late result of an
|
|
// export that timed out is not taken for this one. Random rather than
|
|
// counted: a counter restarts with the process, and the page retries a
|
|
// result refused with 403 against the process that took over the port
|
|
state.exportId = randomUUID()
|
|
return true
|
|
}
|
|
|
|
export function requestSync(sessionId: string): boolean {
|
|
const state = stateStore.get(sessionId)
|
|
if (state) {
|
|
state.syncRequested = Date.now()
|
|
log.debug(`Sync requested for session=${sessionId}`)
|
|
return true
|
|
}
|
|
log.debug(`Sync requested for non-existent session=${sessionId}`)
|
|
return false
|
|
}
|
|
|
|
export async function waitForSync(
|
|
sessionId: string,
|
|
timeoutMs = 3000,
|
|
): Promise<boolean> {
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeoutMs) {
|
|
const state = stateStore.get(sessionId)
|
|
if (!state?.syncRequested) return true // Sync completed
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
}
|
|
log.warn(`Sync timeout for session=${sessionId}`)
|
|
return false // Timeout
|
|
}
|
|
|
|
/**
|
|
* Ask the preview tab which cells the user has selected (the get_selection
|
|
* tool). Answered through the poll like an export: the tab sees selectionId
|
|
* in GET /api/state and POSTs its reading with that id. Returns false when
|
|
* the session is unknown.
|
|
*/
|
|
export function requestSelection(sessionId: string): boolean {
|
|
const state = stateStore.get(sessionId)
|
|
if (!state) return false
|
|
state.selection = undefined
|
|
// Random, as exportId: a late answer to an earlier request, or one meant
|
|
// for the process that had this port before, is not taken for this one
|
|
state.selectionId = randomUUID()
|
|
return true
|
|
}
|
|
|
|
/** The tab's answer to the pending selection request, or null in time */
|
|
export async function waitForSelection(
|
|
sessionId: string,
|
|
timeoutMs = 10000,
|
|
): Promise<SelectionAnswer | null> {
|
|
const start = Date.now()
|
|
let answer: SelectionAnswer | undefined
|
|
while (Date.now() - start < timeoutMs) {
|
|
// Re-read the store entry each tick: setState replaces it
|
|
answer = stateStore.get(sessionId)?.selection
|
|
if (answer) break
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
}
|
|
const state = stateStore.get(sessionId)
|
|
if (state) {
|
|
state.selection = undefined
|
|
state.selectionId = undefined
|
|
}
|
|
if (!answer) log.warn(`Selection timeout for session=${sessionId}`)
|
|
return answer ?? null
|
|
}
|
|
|
|
/** POST /api/state with a selection: the tab's answer to requestSelection */
|
|
function handleSelectionResult(
|
|
sessionId: string,
|
|
data: { selectionId?: unknown; selection?: unknown },
|
|
): void {
|
|
const state = stateStore.get(sessionId)
|
|
if (!state || data.selectionId !== state.selectionId) {
|
|
log.debug(`Ignored a late selection answer for session=${sessionId}`)
|
|
return
|
|
}
|
|
const answer = parseSelectionAnswer(data.selection)
|
|
if (!answer) return
|
|
state.selection = answer
|
|
state.selectionId = undefined
|
|
log.debug(`Selection received for session=${sessionId}`)
|
|
}
|
|
|
|
export function startHttpServer(port = 6002): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
if (server) {
|
|
resolve(serverPort)
|
|
return
|
|
}
|
|
|
|
serverPort = port
|
|
server = http.createServer(handleRequest)
|
|
|
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
if (err.code === "EADDRINUSE") {
|
|
if (port >= MAX_PORT) {
|
|
reject(
|
|
new Error(
|
|
`No available ports in range 6002-${MAX_PORT}`,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
log.info(`Port ${port} in use, trying ${port + 1}`)
|
|
server = null
|
|
startHttpServer(port + 1)
|
|
.then(resolve)
|
|
.catch(reject)
|
|
} else {
|
|
reject(err)
|
|
}
|
|
})
|
|
|
|
server.listen(port, "127.0.0.1", () => {
|
|
serverPort = port
|
|
log.info(`HTTP server running on http://localhost:${port}`)
|
|
if (!EXTERNAL_DRAWIO && drawioDir === null) {
|
|
log.warn(
|
|
"No bundled draw.io (dist/drawio missing); the preview loads it from embed.diagrams.net",
|
|
)
|
|
}
|
|
if (PREVIEW_UI === "shell" && shellDir === null) {
|
|
log.warn(
|
|
"The canvas shell is not built (dist/shell missing); start_session opens the classic page",
|
|
)
|
|
}
|
|
resolve(port)
|
|
})
|
|
})
|
|
}
|
|
|
|
export function stopHttpServer(): void {
|
|
if (server) {
|
|
server.close()
|
|
server = null
|
|
}
|
|
}
|
|
|
|
function cleanupExpiredSessions(): void {
|
|
const now = Date.now()
|
|
for (const [sessionId, state] of stateStore) {
|
|
const lastActive = Math.max(
|
|
state.lastUpdated.getTime(),
|
|
state.lastPolled ?? 0,
|
|
)
|
|
if (now - lastActive > SESSION_TTL) {
|
|
stateStore.delete(sessionId)
|
|
clearHistory(sessionId)
|
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
|
|
|
export function shutdown(): void {
|
|
clearInterval(cleanupIntervalId)
|
|
stopHttpServer()
|
|
}
|
|
|
|
export function getServerPort(): number {
|
|
return serverPort
|
|
}
|
|
|
|
function handleRequest(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
// A bad request must never take down the MCP process
|
|
try {
|
|
routeRequest(req, res)
|
|
} catch (err) {
|
|
log.error("HTTP request failed:", err)
|
|
if (!res.headersSent) res.writeHead(500)
|
|
res.end()
|
|
}
|
|
}
|
|
|
|
// Serve only requests addressed to localhost, sent by the preview page
|
|
// itself (Origin is the address it was opened at, the Host) or by a
|
|
// non-browser client (no Origin header). This blocks DNS rebinding, other
|
|
// websites, and pages on other localhost ports, whose plain text POSTs need
|
|
// no CORS preflight.
|
|
function isLocalRequest(req: http.IncomingMessage): boolean {
|
|
const host = req.headers.host ?? ""
|
|
const origin = req.headers.origin
|
|
return (
|
|
/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) &&
|
|
(origin === undefined || origin === `http://${host}`)
|
|
)
|
|
}
|
|
|
|
function routeRequest(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
let url: URL
|
|
try {
|
|
url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
|
} catch {
|
|
// e.g. "//" is not a valid URL path
|
|
res.writeHead(400)
|
|
res.end("Bad Request")
|
|
return
|
|
}
|
|
|
|
if (!isLocalRequest(req)) {
|
|
res.writeHead(403)
|
|
res.end("Forbidden")
|
|
return
|
|
}
|
|
|
|
const requestOrigin = req.headers.origin
|
|
if (requestOrigin === `http://localhost:${serverPort}`) {
|
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
res.setHeader(
|
|
"Access-Control-Allow-Headers",
|
|
"Content-Type, X-Drawio-Token",
|
|
)
|
|
}
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (url.pathname.startsWith("/drawio/")) {
|
|
serveStaticFile(
|
|
req,
|
|
res,
|
|
drawioDir,
|
|
url.pathname.slice("/drawio/".length),
|
|
drawioVersion,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (url.pathname === "/shell") {
|
|
res.writeHead(302, { Location: `/shell/${url.search}` })
|
|
res.end()
|
|
return
|
|
}
|
|
if (url.pathname.startsWith("/shell/")) {
|
|
const rest = url.pathname.slice("/shell/".length)
|
|
if (rest === "" || rest === "index.html") {
|
|
servePage(req, res, url, "/shell/", getShellPage)
|
|
} else {
|
|
serveStaticFile(req, res, shellDir, rest, PACKAGE_VERSION)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (
|
|
url.pathname.startsWith("/api/") &&
|
|
req.headers["x-drawio-token"] !== API_TOKEN
|
|
) {
|
|
res.writeHead(403)
|
|
res.end("Forbidden")
|
|
return
|
|
}
|
|
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
servePage(req, res, url, "/", getHtmlPage)
|
|
} else if (url.pathname === "/api/state") {
|
|
handleStateApi(req, res, url)
|
|
} else if (url.pathname === "/api/history") {
|
|
handleHistoryApi(req, res, url)
|
|
} else if (url.pathname === "/api/restore") {
|
|
handleRestoreApi(req, res)
|
|
} else if (url.pathname === "/api/history-svg") {
|
|
handleHistorySvgApi(req, res)
|
|
} else {
|
|
res.writeHead(404)
|
|
res.end("Not Found")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A preview page (the classic one at "/", the shell at "/shell/") for the
|
|
* session in ?mcp=<id>; without one, the most recent session's.
|
|
*/
|
|
function servePage(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
url: URL,
|
|
pagePath: string,
|
|
render: (sessionId: string) => string | null,
|
|
): void {
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
const sessionId = url.searchParams.get("mcp") || ""
|
|
if (sessionId && !isValidSessionId(sessionId)) {
|
|
res.writeHead(400)
|
|
res.end("Invalid session id")
|
|
return
|
|
}
|
|
|
|
// Auto-redirect to most recent session if no sessionId provided
|
|
if (!sessionId) {
|
|
const recentSessionId = getMostRecentSessionId()
|
|
if (recentSessionId) {
|
|
res.writeHead(302, {
|
|
Location: `${pagePath}?mcp=${encodeURIComponent(recentSessionId)}`,
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
}
|
|
|
|
const html = render(sessionId)
|
|
if (html === null) {
|
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
|
|
res.end(
|
|
"The canvas shell is not built (dist/shell is missing); run npm run build in packages/mcp-server",
|
|
)
|
|
return
|
|
}
|
|
ensureSessionStateInitialized(sessionId)
|
|
|
|
// The page holds this process's token: never served from a cache
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Cache-Control": "no-store",
|
|
...HTML_SECURITY_HEADERS,
|
|
})
|
|
res.end(req.method === "HEAD" ? undefined : html)
|
|
}
|
|
|
|
function handleStateApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
url: URL,
|
|
): void {
|
|
if (req.method === "GET") {
|
|
const sessionId = url.searchParams.get("sessionId")
|
|
if (!sessionId) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
return
|
|
}
|
|
ensureSessionStateInitialized(sessionId)
|
|
const state = stateStore.get(sessionId)
|
|
// Polling counts as activity, so a session stays alive while its
|
|
// tab is open
|
|
if (state) state.lastPolled = Date.now()
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({
|
|
xml: state?.xml || null,
|
|
version: state?.version || 0,
|
|
stateId: state?.stateId ?? null,
|
|
blank: !!state?.blank,
|
|
syncRequested: !!state?.syncRequested,
|
|
exportFormat: state?.exportFormat || null,
|
|
exportXml: state?.exportXml || null,
|
|
exportOptions: state?.exportOptions || null,
|
|
exportId: state?.exportId ?? null,
|
|
selectionId: state?.selectionId ?? null,
|
|
}),
|
|
)
|
|
} else if (req.method === "POST") {
|
|
readBody(req, res, (body) => {
|
|
try {
|
|
const data = JSON.parse(body)
|
|
const { sessionId } = data
|
|
if (!sessionId || !isValidSessionId(sessionId)) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({ error: "valid sessionId required" }),
|
|
)
|
|
return
|
|
}
|
|
|
|
// Browser is returning export data (png/svg)
|
|
if (data.exportData !== undefined) {
|
|
const state = stateStore.get(sessionId)
|
|
if (state && data.exportId === state.exportId) {
|
|
state.exportData = data.exportData
|
|
state.exportFormat = undefined
|
|
state.exportXml = undefined
|
|
state.exportOptions = undefined
|
|
state.exportId = undefined
|
|
log.debug(
|
|
`Export data received for session=${sessionId}`,
|
|
)
|
|
} else if (state) {
|
|
log.debug(
|
|
`Ignored a late export result for session=${sessionId}`,
|
|
)
|
|
}
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true }))
|
|
return
|
|
}
|
|
|
|
// The tab is answering a selection request (get_selection)
|
|
if (data.selection !== undefined) {
|
|
handleSelectionResult(sessionId, data)
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true }))
|
|
return
|
|
}
|
|
|
|
// A push can come before the tab's first poll after a
|
|
// restart: recover the saved file first, so it is compared
|
|
// with that and never overwrites it unseen
|
|
ensureSessionStateInitialized(sessionId)
|
|
const current = stateStore.get(sessionId)
|
|
|
|
// A tab of this version names the state its push is based
|
|
// on. Another state (the server lost the one it knew, or
|
|
// the tab has not polled yet): refused, and the tab's next
|
|
// poll decides whose diagram wins.
|
|
if (current && "stateId" in data) {
|
|
if (data.stateId !== current.stateId) {
|
|
res.writeHead(409, {
|
|
"Content-Type": "application/json",
|
|
})
|
|
res.end(
|
|
JSON.stringify({
|
|
error: "Session was recreated",
|
|
stateChanged: true,
|
|
version: current.version,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
// What a recovering tab showed: kept in history only
|
|
if (data.source === "recover") {
|
|
const saved =
|
|
typeof data.xml === "string" &&
|
|
!!data.xml &&
|
|
data.xml !== current.xml
|
|
if (saved) {
|
|
addHistory(
|
|
sessionId,
|
|
data.xml,
|
|
data.svg || "",
|
|
"recover",
|
|
)
|
|
// Saved with the History, as after any change
|
|
stateListener?.(sessionId, current.xml)
|
|
}
|
|
res.writeHead(409, {
|
|
"Content-Type": "application/json",
|
|
})
|
|
res.end(
|
|
JSON.stringify({
|
|
error: "Diagram changed on the server",
|
|
version: current.version,
|
|
savedToHistory: saved,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
// The browser edited a version older than the latest AI write
|
|
// (it has not loaded that write yet). Keep the AI write; the
|
|
// browser loads it on its next poll. A sync reply is also
|
|
// stale after a newer write of the browser's own (a user
|
|
// edit saved while the export ran).
|
|
if (
|
|
typeof data.baseVersion === "number" &&
|
|
(data.baseVersion < (current?.serverVersion ?? 0) ||
|
|
(data.source === "sync" &&
|
|
data.baseVersion < (current?.version ?? 0)))
|
|
) {
|
|
let savedToHistory = false
|
|
if (data.source === "sync") {
|
|
// A stale sync reply: the store already holds the
|
|
// newer AI write, so the sync is done.
|
|
if (current) current.syncRequested = undefined
|
|
} else if (typeof data.xml === "string" && data.xml) {
|
|
// A user edit lost the race with an AI write. Keep
|
|
// it in history so the user can restore it.
|
|
addHistory(sessionId, data.xml, data.svg || "", "user")
|
|
savedToHistory = true
|
|
if (current) stateListener?.(sessionId, current.xml)
|
|
}
|
|
res.writeHead(409, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({
|
|
error: "Diagram changed on the server",
|
|
version: current?.version,
|
|
savedToHistory,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
|
|
if (typeof data.xml !== "string") {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "xml must be a string" }))
|
|
return
|
|
}
|
|
const version = setState(sessionId, data.xml, data.svg, true)
|
|
const saved = stateStore.get(sessionId)
|
|
if (saved && data.source !== "sync") saved.userEdited = true
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true, version }))
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
}
|
|
})
|
|
} else {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
}
|
|
}
|
|
|
|
function handleHistoryApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
url: URL,
|
|
): void {
|
|
if (req.method !== "GET") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
|
|
const sessionId = url.searchParams.get("sessionId")
|
|
if (!sessionId) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
return
|
|
}
|
|
|
|
// The classic page reads index, id and svg; the shell's version cards
|
|
// the rest: the document, who made it, which earlier entry it is a copy
|
|
// of, the state the ids belong to, and the entry the canvas shows
|
|
const history = getHistory(sessionId)
|
|
const firstIds = firstCopyIds(sessionId)
|
|
const state = stateStore.get(sessionId)
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({
|
|
entries: history.map((entry, i) => ({
|
|
index: i,
|
|
id: entry.id,
|
|
svg: entry.svg,
|
|
xml: entry.xml,
|
|
source: entry.source ?? null,
|
|
time: entry.time,
|
|
pages: entry.pages,
|
|
firstId: firstIds.get(entry.id),
|
|
})),
|
|
count: history.length,
|
|
stateId: state?.stateId ?? null,
|
|
currentId: state ? currentEntryId(sessionId, state.xml) : null,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Put a History entry back on the canvas (the preview page's Restore button
|
|
* and the restore_version tool). Returns the new version, or null when the
|
|
* entry is unknown.
|
|
*/
|
|
export function restoreHistoryEntry(
|
|
sessionId: string,
|
|
entryId: number,
|
|
): number | null {
|
|
const entry = getHistoryEntry(sessionId, entryId)
|
|
if (!entry) return null
|
|
|
|
// Edits in the browser since the last entry are not in history
|
|
// yet: keep them, so the restore can be undone
|
|
// (any state besides a blank page; a cleared document with its
|
|
// own pages counts)
|
|
const current = stateStore.get(sessionId)
|
|
if (
|
|
current &&
|
|
contentFingerprint(current.xml) !== contentFingerprint(BLANK_MXFILE)
|
|
) {
|
|
keepInHistory(sessionId, current.xml, current.svg)
|
|
}
|
|
const newVersion = setState(sessionId, entry.xml)
|
|
addHistory(sessionId, entry.xml, entry.svg, "restore")
|
|
|
|
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
|
|
return newVersion
|
|
}
|
|
|
|
function handleRestoreApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
|
|
readBody(req, res, (body) => {
|
|
try {
|
|
const data = JSON.parse(body)
|
|
const { sessionId, id } = data
|
|
if (!sessionId || typeof id !== "number") {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId and id required" }))
|
|
return
|
|
}
|
|
// Picked from the list of a state the server has since lost
|
|
// (a restart reloads History under new ids), or before the tab
|
|
// knew the state. A tab of an older version sends none.
|
|
const current = stateStore.get(sessionId)
|
|
if (
|
|
current &&
|
|
"stateId" in data &&
|
|
data.stateId !== current.stateId
|
|
) {
|
|
res.writeHead(409, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Session was recreated" }))
|
|
return
|
|
}
|
|
|
|
const newVersion = restoreHistoryEntry(sessionId, id)
|
|
if (newVersion === null) {
|
|
res.writeHead(404, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
|
return
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true, newVersion }))
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
}
|
|
})
|
|
}
|
|
|
|
function handleHistorySvgApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
|
|
readBody(req, res, (body) => {
|
|
try {
|
|
const { sessionId, svg, stateId, version } = JSON.parse(body)
|
|
if (!sessionId || !svg) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId and svg required" }))
|
|
return
|
|
}
|
|
|
|
// The browser took it of the server write it loaded, named by
|
|
// the state and version. One that arrives after the next server
|
|
// write, or for a state since lost, is dropped; a browser write
|
|
// since (a sync reply) leaves that write's image valid.
|
|
const state = stateStore.get(sessionId)
|
|
if (
|
|
state &&
|
|
state.stateId === stateId &&
|
|
state.serverVersion === version &&
|
|
state.serverXml !== undefined
|
|
) {
|
|
updateLastHistorySvg(sessionId, svg, state.serverXml)
|
|
if (state.xml === state.serverXml) state.svg = svg
|
|
}
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true }))
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
}
|
|
})
|
|
}
|
|
|
|
// Pages may be framed only by this origin, and nothing is content-sniffed
|
|
const HTML_SECURITY_HEADERS = {
|
|
"Content-Security-Policy": "frame-ancestors 'self'",
|
|
"X-Content-Type-Options": "nosniff",
|
|
}
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".xml": "text/xml; charset=utf-8",
|
|
".json": "application/json",
|
|
".ico": "image/x-icon",
|
|
".wasm": "application/wasm",
|
|
}
|
|
|
|
/**
|
|
* GET /drawio/<path> or /shell/<path>: a file of the bundled draw.io copy or
|
|
* of the built shell. `stamp` names the files' version in their ETags.
|
|
*/
|
|
function serveStaticFile(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
dir: string | null,
|
|
rawPath: string,
|
|
stamp: string,
|
|
): void {
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
let rel: string
|
|
try {
|
|
rel = decodeURIComponent(rawPath)
|
|
} catch {
|
|
res.writeHead(400)
|
|
res.end("Bad Request")
|
|
return
|
|
}
|
|
// One normalized path inside the directory; the war's server-side parts
|
|
// are never served, whatever was extracted
|
|
const normalized = posix.normalize(rel)
|
|
const file = dir ? resolve(dir, normalized) : null
|
|
if (
|
|
!dir ||
|
|
!file ||
|
|
/[\\\0]/.test(rel) ||
|
|
/(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) ||
|
|
!file.startsWith(dir + sep)
|
|
) {
|
|
res.writeHead(404)
|
|
res.end("Not Found")
|
|
return
|
|
}
|
|
let size: number
|
|
let etag: string
|
|
try {
|
|
const stat = statSync(file)
|
|
if (!stat.isFile()) throw new Error("not a file")
|
|
size = stat.size
|
|
etag = `"${stamp}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
|
|
} catch {
|
|
res.writeHead(404)
|
|
res.end("Not Found")
|
|
return
|
|
}
|
|
// The file names do not change between draw.io versions, so the browser
|
|
// may keep a copy but asks before using it (a 304 from localhost is
|
|
// cheap); after a package upgrade the next preview gets the new files
|
|
const cacheHeaders = {
|
|
ETag: etag,
|
|
"Cache-Control": "no-cache",
|
|
"X-Content-Type-Options": "nosniff",
|
|
}
|
|
if (req.headers["if-none-match"] === etag) {
|
|
res.writeHead(304, cacheHeaders)
|
|
res.end()
|
|
return
|
|
}
|
|
const ext = extname(file).toLowerCase()
|
|
res.writeHead(200, {
|
|
...cacheHeaders,
|
|
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
|
|
"Content-Length": size,
|
|
...(ext === ".html" ? HTML_SECURITY_HEADERS : {}),
|
|
})
|
|
if (req.method === "HEAD") {
|
|
res.end()
|
|
return
|
|
}
|
|
// pipeline closes both ends when either fails or goes away (a tab closed
|
|
// mid-download), so no file handle leaks and no error goes unhandled; a
|
|
// client that left early is routine, not worth a log line
|
|
pipeline(createReadStream(file), res, () => {})
|
|
}
|
|
|
|
/** Where the iframe loads the editor from (without its query) */
|
|
function drawioEditorUrl(): string {
|
|
return isSameOriginDrawio()
|
|
? "/drawio/index.html"
|
|
: `${normalizeUrl(DRAWIO_BASE_URL)}/`
|
|
}
|
|
|
|
// The preview page lives in src/preview (the build copies it to dist/preview)
|
|
const PREVIEW_DIR = join(HERE, "preview")
|
|
let previewTemplate: string | null = null
|
|
|
|
function loadPreviewTemplate(): string {
|
|
if (!previewTemplate) {
|
|
const read = (file: string) =>
|
|
readFileSync(join(PREVIEW_DIR, file), "utf-8")
|
|
previewTemplate = read("index.html")
|
|
.replace("{{CSS}}", () => read("preview.css"))
|
|
.replace("{{SCRIPT}}", () => read("preview.js"))
|
|
}
|
|
return previewTemplate
|
|
}
|
|
|
|
/** JSON that is safe inside a <script> element ("<" escaped) */
|
|
const scriptJson = (value: unknown) =>
|
|
JSON.stringify(value).replace(/</g, "\\u003c")
|
|
|
|
/**
|
|
* The editor settings the host config fixes: theme (DRAWIO_UI), language
|
|
* (DRAWIO_LANG) and dark mode (DRAWIO_DARK). ui and lang are "" unless set,
|
|
* because draw.io hides its Extras > Language / Theme submenu once they
|
|
* are given.
|
|
*/
|
|
function hostDrawioSettings(env: NodeJS.ProcessEnv): {
|
|
ui: string
|
|
lang: string
|
|
dark: "dark" | "light" | "auto"
|
|
} {
|
|
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
|
|
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
|
|
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
|
|
return {
|
|
ui: isDrawioTheme(ui) ? ui : "",
|
|
lang: /^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang) ? lang : "",
|
|
dark: ["1", "true", "dark"].includes(dark)
|
|
? "dark"
|
|
: ["0", "false", "light"].includes(dark)
|
|
? "light"
|
|
: // draw.io takes ui=dark as dark mode only when no dark
|
|
// parameter is present, and the pages always send one
|
|
!dark && ui === "dark"
|
|
? "dark"
|
|
: "auto",
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The configurable tail of the classic page's draw.io iframe query: dark
|
|
* mode, and the language and theme when the host config fixes them.
|
|
*/
|
|
export function drawioEmbedParams(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string {
|
|
const settings = hostDrawioSettings(env)
|
|
const params = new URLSearchParams()
|
|
params.set(
|
|
"dark",
|
|
settings.dark === "auto"
|
|
? "auto"
|
|
: settings.dark === "dark"
|
|
? "1"
|
|
: "0",
|
|
)
|
|
if (settings.lang) params.set("lang", settings.lang)
|
|
if (settings.ui) params.set("ui", settings.ui)
|
|
return params.toString()
|
|
}
|
|
|
|
/**
|
|
* What the shell page gets as window.__MCP_CONFIG__ (shell/runtime-config.ts
|
|
* reads it): the session, the API token, where draw.io comes from and the
|
|
* host's editor settings.
|
|
*/
|
|
export function shellConfig(
|
|
sessionId: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Record<string, string> {
|
|
const settings = hostDrawioSettings(env)
|
|
return {
|
|
sessionId,
|
|
token: API_TOKEN,
|
|
apiBase: "/api",
|
|
drawioBaseUrl: drawioEditorUrl(),
|
|
drawioUi: settings.ui,
|
|
drawioLang: settings.lang,
|
|
drawioDark: settings.dark,
|
|
lang: env.DRAWIO_LANG ?? "",
|
|
}
|
|
}
|
|
|
|
/** The shell page, or null when the shell is not built */
|
|
function getShellPage(sessionId: string): string | null {
|
|
if (!shellDir) return null
|
|
const template = readFileSync(join(shellDir, "index.html"), "utf8")
|
|
return template.replace("{{CONFIG_JSON}}", () =>
|
|
scriptJson(shellConfig(sessionId)),
|
|
)
|
|
}
|
|
|
|
function getHtmlPage(sessionId: string): string {
|
|
return (
|
|
loadPreviewTemplate()
|
|
.replace("{{SESSION_BADGE}}", () =>
|
|
sessionId
|
|
? `<span class="session">${sessionId.slice(-8)}</span>`
|
|
: "",
|
|
)
|
|
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
|
|
.replace("{{DRAWIO_URL}}", () => drawioEditorUrl())
|
|
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
|
|
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
|
// Same origin: the page fills in its own origin
|
|
.replace("{{ORIGIN_JSON}}", () =>
|
|
scriptJson(isSameOriginDrawio() ? "" : DRAWIO_ORIGIN),
|
|
)
|
|
.replace("{{TOKEN_JSON}}", () => scriptJson(API_TOKEN))
|
|
)
|
|
}
|