2025-12-17 14:50:07 +09:00
|
|
|
/**
|
|
|
|
|
* Embedded HTTP Server for MCP
|
2025-12-21 16:11:49 +09:00
|
|
|
* Serves draw.io embed with state sync and history UI
|
2025-12-17 14:50:07 +09:00
|
|
|
*/
|
|
|
|
|
|
2026-10-05 17:33:36 +09:00
|
|
|
import { randomUUID } from "node:crypto"
|
2026-10-04 06:46:09 +09:00
|
|
|
import { readFileSync } from "node:fs"
|
2025-12-17 14:50:07 +09:00
|
|
|
import http from "node:http"
|
2026-10-04 06:46:09 +09:00
|
|
|
import { dirname, join } from "node:path"
|
|
|
|
|
import { fileURLToPath } from "node:url"
|
2026-04-06 09:14:20 +09:00
|
|
|
|
|
|
|
|
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
|
|
|
|
|
|
|
|
|
function readBody(
|
|
|
|
|
req: http.IncomingMessage,
|
|
|
|
|
res: http.ServerResponse,
|
|
|
|
|
cb: (body: string) => void,
|
|
|
|
|
): void {
|
2026-10-03 17:45:33 +09:00
|
|
|
// Decode once at the end: a multi-byte UTF-8 character can be split
|
|
|
|
|
// across two chunks.
|
|
|
|
|
const chunks: Buffer[] = []
|
2026-04-06 09:14:20 +09:00
|
|
|
let size = 0
|
|
|
|
|
req.on("data", (chunk: Buffer) => {
|
|
|
|
|
size += chunk.length
|
|
|
|
|
if (size > MAX_BODY_BYTES) {
|
|
|
|
|
res.writeHead(413, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "Payload too large" }))
|
|
|
|
|
req.destroy()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-03 17:45:33 +09:00
|
|
|
chunks.push(chunk)
|
2026-04-06 09:14:20 +09:00
|
|
|
})
|
2026-10-03 17:45:33 +09:00
|
|
|
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
|
2026-04-06 09:14:20 +09:00
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:04:32 +09:00
|
|
|
import { contentFingerprint } from "./edit-gate.ts"
|
2025-12-21 16:09:14 +09:00
|
|
|
import {
|
2025-12-21 16:11:49 +09:00
|
|
|
addHistory,
|
2025-12-21 16:09:14 +09:00
|
|
|
clearHistory,
|
|
|
|
|
getHistory,
|
2025-12-21 16:11:49 +09:00
|
|
|
getHistoryEntry,
|
2025-12-21 16:58:41 +09:00
|
|
|
updateLastHistorySvg,
|
2026-10-04 12:25:53 +09:00
|
|
|
} from "./history.ts"
|
|
|
|
|
import { log } from "./logger.ts"
|
2026-10-05 13:04:32 +09:00
|
|
|
import { BLANK_MXFILE } from "./pages.ts"
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2026-01-01 13:47:39 +08:00
|
|
|
// Configurable draw.io embed URL for private deployments
|
|
|
|
|
const DRAWIO_BASE_URL =
|
|
|
|
|
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-01-01 15:20:00 +09:00
|
|
|
return url.replace(/\/$/, "")
|
2026-01-01 13:47:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:45:33 +09:00
|
|
|
// 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)
|
2026-01-03 11:05:38 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:03:58 +09:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:04:32 +09:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 11:05:38 +08:00
|
|
|
function ensureSessionStateInitialized(sessionId: string): void {
|
|
|
|
|
if (!sessionId) return
|
2026-10-03 17:45:33 +09:00
|
|
|
if (!isValidSessionId(sessionId)) return
|
2026-10-05 13:04:32 +09:00
|
|
|
restoreSavedSession(sessionId)
|
2026-01-03 11:05:38 +08:00
|
|
|
if (stateStore.has(sessionId)) return
|
|
|
|
|
|
2026-10-04 06:42:44 +09:00
|
|
|
// Not a change worth saving: the browser fills it on its next push
|
2026-10-04 12:51:20 +09:00
|
|
|
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
|
|
|
|
|
// forever when no load(xml) is ever sent
|
2026-10-05 13:04:32 +09:00
|
|
|
setState(sessionId, BLANK_MXFILE, undefined, false, false)
|
2026-10-05 17:33:36 +09:00
|
|
|
// 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
|
2026-01-03 11:05:38 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
interface SessionState {
|
|
|
|
|
xml: string
|
|
|
|
|
version: number
|
2026-10-05 17:33:36 +09:00
|
|
|
// 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
|
2026-10-03 17:45:33 +09:00
|
|
|
// 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
|
2025-12-17 14:50:07 +09:00
|
|
|
lastUpdated: Date
|
2026-10-03 17:45:33 +09:00
|
|
|
lastPolled?: number // Last browser poll; an open tab keeps the session alive
|
2025-12-21 16:58:41 +09:00
|
|
|
svg?: string // Cached SVG from last browser save
|
2025-12-21 19:38:35 +09:00
|
|
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
2026-10-04 07:10:01 +09:00
|
|
|
exportFormat?: ExportFormat // Set by MCP tool to request browser export
|
2026-06-16 05:45:50 +05:30
|
|
|
exportXml?: string // Single-page projection to load before a page-targeted export
|
2026-10-04 06:29:33 +09:00
|
|
|
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
|
2026-10-04 22:52:41 +09:00
|
|
|
exportId?: number // Number of the pending export, echoed with its result
|
2026-02-07 12:55:09 +09:00
|
|
|
exportData?: string // Base64/SVG data returned by browser after export
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 07:10:01 +09:00
|
|
|
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
|
|
|
|
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
|
|
|
|
|
2026-10-04 06:29:33 +09:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
export const stateStore = new Map<string, SessionState>()
|
|
|
|
|
|
|
|
|
|
let server: http.Server | null = null
|
2025-12-21 16:11:49 +09:00
|
|
|
let serverPort = 6002
|
|
|
|
|
const MAX_PORT = 6020
|
|
|
|
|
const SESSION_TTL = 60 * 60 * 1000
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
export function getState(sessionId: string): SessionState | undefined {
|
|
|
|
|
return stateStore.get(sessionId)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 06:42:44 +09:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 10:52:20 +09:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:45:33 +09:00
|
|
|
export function setState(
|
|
|
|
|
sessionId: string,
|
|
|
|
|
xml: string,
|
|
|
|
|
svg?: string,
|
|
|
|
|
fromBrowser = false,
|
2026-10-04 06:42:44 +09:00
|
|
|
notify = true,
|
2026-10-03 17:45:33 +09:00
|
|
|
): number {
|
2025-12-17 14:50:07 +09:00
|
|
|
const existing = stateStore.get(sessionId)
|
|
|
|
|
const newVersion = (existing?.version || 0) + 1
|
|
|
|
|
stateStore.set(sessionId, {
|
|
|
|
|
xml,
|
|
|
|
|
version: newVersion,
|
2026-10-05 17:33:36 +09:00
|
|
|
stateId: existing?.stateId ?? randomUUID(),
|
2026-10-03 17:45:33 +09:00
|
|
|
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
2025-12-17 14:50:07 +09:00
|
|
|
lastUpdated: new Date(),
|
2026-10-03 17:45:33 +09:00
|
|
|
lastPolled: existing?.lastPolled,
|
2026-10-05 13:04:32 +09:00
|
|
|
// 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
|
|
|
|
|
svg: svg || undefined,
|
2025-12-21 19:38:35 +09:00
|
|
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
2026-02-07 12:55:09 +09:00
|
|
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
2026-06-16 05:45:50 +05:30
|
|
|
exportXml: existing?.exportXml, // Preserve pending projection
|
2026-10-04 06:29:33 +09:00
|
|
|
exportOptions: existing?.exportOptions,
|
2026-10-04 22:52:41 +09:00
|
|
|
exportId: existing?.exportId,
|
2026-02-07 12:55:09 +09:00
|
|
|
exportData: existing?.exportData, // Preserve export result
|
2025-12-17 14:50:07 +09:00
|
|
|
})
|
|
|
|
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
2026-10-04 06:42:44 +09:00
|
|
|
if (notify) stateListener?.(sessionId, xml)
|
2025-12-17 14:50:07 +09:00
|
|
|
return newVersion
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 05:45:50 +05:30
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-10-04 07:10:01 +09:00
|
|
|
format: ExportFormat,
|
2026-06-16 05:45:50 +05:30
|
|
|
projectionXml?: string,
|
2026-10-04 06:29:33 +09:00
|
|
|
options?: ExportOptions,
|
2026-06-16 05:45:50 +05:30
|
|
|
): boolean {
|
|
|
|
|
const state = stateStore.get(sessionId)
|
|
|
|
|
if (!state) return false
|
|
|
|
|
state.exportData = undefined
|
|
|
|
|
state.exportXml = projectionXml
|
2026-10-04 06:29:33 +09:00
|
|
|
state.exportOptions = options
|
2026-06-16 05:45:50 +05:30
|
|
|
state.exportFormat = format
|
2026-10-04 22:52:41 +09:00
|
|
|
// The browser sends this back with the result, so a late result of an
|
|
|
|
|
// export that timed out is not taken for this one
|
|
|
|
|
state.exportId = ++lastExportId
|
2026-06-16 05:45:50 +05:30
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 22:52:41 +09:00
|
|
|
let lastExportId = 0
|
|
|
|
|
|
2025-12-21 19:38:35 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
export function startHttpServer(port = 6002): Promise<number> {
|
2025-12-17 14:50:07 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-06 09:04:38 +09:00
|
|
|
server.listen(port, "127.0.0.1", () => {
|
2025-12-17 14:50:07 +09:00
|
|
|
serverPort = port
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info(`HTTP server running on http://localhost:${port}`)
|
2025-12-17 14:50:07 +09:00
|
|
|
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) {
|
2026-10-03 17:45:33 +09:00
|
|
|
const lastActive = Math.max(
|
|
|
|
|
state.lastUpdated.getTime(),
|
|
|
|
|
state.lastPolled ?? 0,
|
|
|
|
|
)
|
|
|
|
|
if (now - lastActive > SESSION_TTL) {
|
2025-12-17 14:50:07 +09:00
|
|
|
stateStore.delete(sessionId)
|
2025-12-21 16:11:49 +09:00
|
|
|
clearHistory(sessionId)
|
2025-12-17 14:50:07 +09:00
|
|
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 18:38:20 +09:00
|
|
|
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
|
|
|
|
|
|
|
|
|
export function shutdown(): void {
|
|
|
|
|
clearInterval(cleanupIntervalId)
|
|
|
|
|
stopHttpServer()
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2026-10-04 06:29:33 +09:00
|
|
|
export function getServerPort(): number {
|
|
|
|
|
return serverPort
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
function handleRequest(
|
|
|
|
|
req: http.IncomingMessage,
|
|
|
|
|
res: http.ServerResponse,
|
|
|
|
|
): void {
|
2026-10-03 17:45:33 +09:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 17:02:06 +09:00
|
|
|
// 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.
|
2026-10-03 17:45:33 +09:00
|
|
|
function isLocalRequest(req: http.IncomingMessage): boolean {
|
2026-10-05 17:02:06 +09:00
|
|
|
const host = req.headers.host ?? ""
|
2026-10-03 17:45:33 +09:00
|
|
|
const origin = req.headers.origin
|
|
|
|
|
return (
|
2026-10-05 17:02:06 +09:00
|
|
|
/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) &&
|
|
|
|
|
(origin === undefined || origin === `http://${host}`)
|
2026-10-03 17:45:33 +09:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2026-03-24 11:50:03 +09:00
|
|
|
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")
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
if (req.method === "OPTIONS") {
|
|
|
|
|
res.writeHead(204)
|
|
|
|
|
res.end()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
2026-01-03 11:05:38 +08:00
|
|
|
const sessionId = url.searchParams.get("mcp") || ""
|
2026-10-03 17:45:33 +09:00
|
|
|
if (sessionId && !isValidSessionId(sessionId)) {
|
|
|
|
|
res.writeHead(400)
|
|
|
|
|
res.end("Invalid session id")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-24 11:03:58 +09:00
|
|
|
|
|
|
|
|
// Auto-redirect to most recent session if no sessionId provided
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
const recentSessionId = getMostRecentSessionId()
|
|
|
|
|
if (recentSessionId) {
|
2026-10-03 17:45:33 +09:00
|
|
|
res.writeHead(302, {
|
|
|
|
|
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
|
|
|
|
|
})
|
2026-01-24 11:03:58 +09:00
|
|
|
res.end()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 11:05:38 +08:00
|
|
|
ensureSessionStateInitialized(sessionId)
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
res.writeHead(200, { "Content-Type": "text/html" })
|
2026-01-03 11:05:38 +08:00
|
|
|
res.end(getHtmlPage(sessionId))
|
2025-12-21 16:11:49 +09:00
|
|
|
} else if (url.pathname === "/api/state") {
|
2025-12-17 14:50:07 +09:00
|
|
|
handleStateApi(req, res, url)
|
2025-12-21 16:11:49 +09:00
|
|
|
} else if (url.pathname === "/api/history") {
|
2025-12-21 16:09:14 +09:00
|
|
|
handleHistoryApi(req, res, url)
|
2025-12-21 16:11:49 +09:00
|
|
|
} else if (url.pathname === "/api/restore") {
|
|
|
|
|
handleRestoreApi(req, res)
|
2025-12-21 16:58:41 +09:00
|
|
|
} else if (url.pathname === "/api/history-svg") {
|
|
|
|
|
handleHistorySvgApi(req, res)
|
2025-12-17 14:50:07 +09:00
|
|
|
} else {
|
|
|
|
|
res.writeHead(404)
|
|
|
|
|
res.end("Not Found")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-01-03 11:05:38 +08:00
|
|
|
ensureSessionStateInitialized(sessionId)
|
2025-12-17 14:50:07 +09:00
|
|
|
const state = stateStore.get(sessionId)
|
2026-10-03 17:45:33 +09:00
|
|
|
// Polling counts as activity, so a session stays alive while its
|
|
|
|
|
// tab is open
|
|
|
|
|
if (state) state.lastPolled = Date.now()
|
2025-12-17 14:50:07 +09:00
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
xml: state?.xml || null,
|
|
|
|
|
version: state?.version || 0,
|
2026-10-05 17:33:36 +09:00
|
|
|
stateId: state?.stateId ?? null,
|
|
|
|
|
blank: !!state?.blank,
|
2025-12-21 19:38:35 +09:00
|
|
|
syncRequested: !!state?.syncRequested,
|
2026-02-07 12:55:09 +09:00
|
|
|
exportFormat: state?.exportFormat || null,
|
2026-06-16 05:45:50 +05:30
|
|
|
exportXml: state?.exportXml || null,
|
2026-10-04 06:29:33 +09:00
|
|
|
exportOptions: state?.exportOptions || null,
|
2026-10-04 22:52:41 +09:00
|
|
|
exportId: state?.exportId ?? null,
|
2025-12-17 14:50:07 +09:00
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
} else if (req.method === "POST") {
|
2026-04-06 09:14:20 +09:00
|
|
|
readBody(req, res, (body) => {
|
2025-12-17 14:50:07 +09:00
|
|
|
try {
|
2026-02-07 12:55:09 +09:00
|
|
|
const data = JSON.parse(body)
|
|
|
|
|
const { sessionId } = data
|
2026-10-03 17:45:33 +09:00
|
|
|
if (!sessionId || !isValidSessionId(sessionId)) {
|
2025-12-17 14:50:07 +09:00
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
2026-10-03 17:45:33 +09:00
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({ error: "valid sessionId required" }),
|
|
|
|
|
)
|
2025-12-17 14:50:07 +09:00
|
|
|
return
|
|
|
|
|
}
|
2026-02-07 12:55:09 +09:00
|
|
|
|
|
|
|
|
// Browser is returning export data (png/svg)
|
|
|
|
|
if (data.exportData !== undefined) {
|
|
|
|
|
const state = stateStore.get(sessionId)
|
2026-10-04 22:52:41 +09:00
|
|
|
if (state && data.exportId === state.exportId) {
|
2026-02-07 12:55:09 +09:00
|
|
|
state.exportData = data.exportData
|
|
|
|
|
state.exportFormat = undefined
|
2026-06-16 05:45:50 +05:30
|
|
|
state.exportXml = undefined
|
2026-10-04 06:29:33 +09:00
|
|
|
state.exportOptions = undefined
|
2026-10-04 22:52:41 +09:00
|
|
|
state.exportId = undefined
|
2026-02-07 12:55:09 +09:00
|
|
|
log.debug(
|
|
|
|
|
`Export data received for session=${sessionId}`,
|
|
|
|
|
)
|
2026-10-04 22:52:41 +09:00
|
|
|
} else if (state) {
|
|
|
|
|
log.debug(
|
|
|
|
|
`Ignored a late export result for session=${sessionId}`,
|
|
|
|
|
)
|
2026-02-07 12:55:09 +09:00
|
|
|
}
|
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ success: true }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 17:33:36 +09:00
|
|
|
// 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 || "")
|
|
|
|
|
}
|
|
|
|
|
res.writeHead(409, {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
})
|
|
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
error: "Diagram changed on the server",
|
|
|
|
|
version: current.version,
|
|
|
|
|
savedToHistory: saved,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:45:33 +09:00
|
|
|
// The browser edited a version older than the latest AI write
|
|
|
|
|
// (it has not loaded that write yet). Keep the AI write; the
|
2026-10-05 10:52:20 +09:00
|
|
|
// 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).
|
2026-10-03 17:45:33 +09:00
|
|
|
if (
|
|
|
|
|
typeof data.baseVersion === "number" &&
|
2026-10-05 10:52:20 +09:00
|
|
|
(data.baseVersion < (current?.serverVersion ?? 0) ||
|
|
|
|
|
(data.source === "sync" &&
|
|
|
|
|
data.baseVersion < (current?.version ?? 0)))
|
2026-10-03 17:45:33 +09:00
|
|
|
) {
|
2026-10-03 22:03:44 +09:00
|
|
|
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 || "")
|
|
|
|
|
savedToHistory = true
|
|
|
|
|
}
|
2026-10-03 17:45:33 +09:00
|
|
|
res.writeHead(409, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
error: "Diagram changed on the server",
|
|
|
|
|
version: current?.version,
|
2026-10-03 22:03:44 +09:00
|
|
|
savedToHistory,
|
2026-10-03 17:45:33 +09:00
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 07:38:17 +09:00
|
|
|
if (typeof data.xml !== "string") {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "xml must be a string" }))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-03 17:45:33 +09:00
|
|
|
const version = setState(sessionId, data.xml, data.svg, true)
|
2025-12-17 14:50:07 +09:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:09:14 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
const history = getHistory(sessionId)
|
2025-12-21 16:09:14 +09:00
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({
|
2026-10-03 17:45:33 +09:00
|
|
|
entries: history.map((entry, i) => ({
|
|
|
|
|
index: i,
|
|
|
|
|
id: entry.id,
|
|
|
|
|
svg: entry.svg,
|
|
|
|
|
})),
|
2025-12-21 16:09:14 +09:00
|
|
|
count: history.length,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleRestoreApi(
|
|
|
|
|
req: http.IncomingMessage,
|
|
|
|
|
res: http.ServerResponse,
|
|
|
|
|
): void {
|
|
|
|
|
if (req.method !== "POST") {
|
|
|
|
|
res.writeHead(405)
|
|
|
|
|
res.end("Method Not Allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:14:20 +09:00
|
|
|
readBody(req, res, (body) => {
|
2025-12-21 16:09:14 +09:00
|
|
|
try {
|
2026-10-03 17:45:33 +09:00
|
|
|
const { sessionId, id } = JSON.parse(body)
|
|
|
|
|
if (!sessionId || typeof id !== "number") {
|
2025-12-21 16:09:14 +09:00
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
2026-10-03 17:45:33 +09:00
|
|
|
res.end(JSON.stringify({ error: "sessionId and id required" }))
|
2025-12-21 16:09:14 +09:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:45:33 +09:00
|
|
|
const entry = getHistoryEntry(sessionId, id)
|
2025-12-21 16:09:14 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
res.writeHead(404, { "Content-Type": "application/json" })
|
2025-12-21 16:11:49 +09:00
|
|
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
2025-12-21 16:09:14 +09:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 10:52:20 +09:00
|
|
|
// Edits in the browser since the last entry are not in history
|
|
|
|
|
// yet: keep them, so the restore can be undone
|
2026-10-05 13:04:32 +09:00
|
|
|
// (any state besides a blank page; a cleared document with its
|
|
|
|
|
// own pages counts)
|
2026-10-05 10:52:20 +09:00
|
|
|
const current = stateStore.get(sessionId)
|
2026-10-05 13:04:32 +09:00
|
|
|
if (
|
|
|
|
|
current &&
|
|
|
|
|
contentFingerprint(current.xml) !==
|
|
|
|
|
contentFingerprint(BLANK_MXFILE)
|
|
|
|
|
) {
|
2026-10-05 10:52:20 +09:00
|
|
|
addHistory(sessionId, current.xml, current.svg)
|
|
|
|
|
}
|
2025-12-21 16:09:14 +09:00
|
|
|
const newVersion = setState(sessionId, entry.xml)
|
2025-12-21 16:11:49 +09:00
|
|
|
addHistory(sessionId, entry.xml, entry.svg)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2026-10-03 17:45:33 +09:00
|
|
|
log.info(`Restored session ${sessionId} to history entry ${id}`)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
2025-12-21 16:11:49 +09:00
|
|
|
res.end(JSON.stringify({ success: true, newVersion }))
|
2025-12-21 16:09:14 +09:00
|
|
|
} catch {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:58:41 +09:00
|
|
|
function handleHistorySvgApi(
|
|
|
|
|
req: http.IncomingMessage,
|
|
|
|
|
res: http.ServerResponse,
|
|
|
|
|
): void {
|
|
|
|
|
if (req.method !== "POST") {
|
|
|
|
|
res.writeHead(405)
|
|
|
|
|
res.end("Method Not Allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:14:20 +09:00
|
|
|
readBody(req, res, (body) => {
|
2025-12-21 16:58:41 +09:00
|
|
|
try {
|
|
|
|
|
const { sessionId, svg } = JSON.parse(body)
|
|
|
|
|
if (!sessionId || !svg) {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "sessionId and svg required" }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:04:32 +09:00
|
|
|
// The browser took it of the diagram it just loaded: the state
|
|
|
|
|
const state = stateStore.get(sessionId)
|
|
|
|
|
if (state) {
|
|
|
|
|
updateLastHistorySvg(sessionId, svg, state.xml)
|
|
|
|
|
state.svg = svg
|
|
|
|
|
}
|
2025-12-21 16:58:41 +09:00
|
|
|
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" }))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 06:46:09 +09:00
|
|
|
// The preview page lives in src/preview (the build copies it to dist/preview)
|
|
|
|
|
const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview")
|
|
|
|
|
let previewTemplate: string | null = null
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2026-10-04 06:46:09 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A JSON string literal that is safe inside a <script> element */
|
|
|
|
|
const scriptJson = (value: string) =>
|
|
|
|
|
JSON.stringify(value).replace(/</g, "\\u003c")
|
|
|
|
|
|
|
|
|
|
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}}", () => normalizeUrl(DRAWIO_BASE_URL))
|
|
|
|
|
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
|
|
|
|
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|