import http, { type IncomingMessage, type ServerResponse } from "node:http"; import { promises as fs } from "node:fs"; import path from "node:path"; import { createReadStream } from "node:fs"; import { BuildManager } from "./builds.ts"; import { fileURLToPath } from "node:url"; import { randomBytes, timingSafeEqual } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { ProjectStore } from "../engine/store.ts"; import { defaultProject } from "../engine/templates.ts"; import { entity, uid, validateProject } from "../engine/schema.ts"; import { projectArchive, gameArchive, decodeData } from "../engine/archive.ts"; import { createMcp, type EngineService } from "./mcp.ts"; import { portableModel, modelComponents, modelDataUri, modelProject, } from "../engine/model-import.ts"; import { imageAsset } from "../engine/image-import.ts"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const mime: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".svg": "image/svg+xml", ".wasm": "application/wasm", ".png": "image/png", ".webp": "image/webp", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".zip": "application/zip", }; export function inside(base: string, requested: string) { const p = path.resolve(base, requested); if (p !== base && !p.startsWith(base + path.sep)) throw Error("Path must stay inside project directory"); return p; } async function safeRead(base: string, file: string) { const candidate = inside(base, file), real = await fs.realpath(candidate); inside(base, path.relative(base, real)); return new Uint8Array(await fs.readFile(real)); } async function atomic(file: string, data: string | Uint8Array) { await fs.mkdir(path.dirname(file), { recursive: true }); const temp = file + ".tmp-" + randomBytes(6).toString("hex"); try { await fs.writeFile(temp, data, { mode: 0o600 }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); } } async function body(req: IncomingMessage) { const chunks: Buffer[] = []; let size = 0; for await (const c of req) { size += c.length; if (size > 50 * 1024 * 1024) throw Error("Request limit is 50 MB"); chunks.push(c); } return JSON.parse(Buffer.concat(chunks).toString() || "{}"); } function json(res: ServerResponse, status: number, data: any) { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", }); res.end(JSON.stringify(data)); } export async function createService( options: { projectDir?: string; port?: number; editorOrigin?: string; token?: string; blank?: boolean; } = {}, ) { const projectDir = path.resolve(options.projectDir || "projects/MyGame"); await fs.mkdir(projectDir, { recursive: true }); const projectFile = path.join(projectDir, "project.forma.json"), tokenFile = path.join(projectDir, ".mcp-token"); let token = options.token; if (!token) { try { token = (await fs.readFile(tokenFile, "utf8")).trim(); } catch (e: any) { if (e.code !== "ENOENT") throw e; token = randomBytes(32).toString("hex"); await fs.writeFile(tokenFile, token, { mode: 0o600, flag: "wx" }); } } if (token.length < 24) throw Error("MCP token must be at least 24 characters"); let project; try { project = JSON.parse(await fs.readFile(projectFile, "utf8")); validateProject(project); } catch (e: any) { if (e.code !== "ENOENT") throw Error("Original project retained; cannot open: " + String(e)); project = defaultProject(options.blank || false); } const store = new ProjectStore(project), subscribers = new Set(), pending = new Map< string, { resolve: (a: any) => void; reject: (e: any) => void; timer: any; client: ServerResponse; } >(); let mcpEnabled = true; let port = options.port ?? 4318, saveQueue = Promise.resolve(), lastSaveError: string | null = null; const auth = (req: IncomingMessage) => { const a = Buffer.from( req.headers.authorization?.replace(/^Bearer /, "") || "", ), b = Buffer.from(token!); return a.length === b.length && timingSafeEqual(a, b); }; const status = () => ({ forma: true, version: "0.3.0", mcpEnabled, mcpUrl: "http://127.0.0.1:" + port + "/mcp", canUndo: store.canUndo, canRedo: store.canRedo, editorConnected: subscribers.size > 0, projectFolder: path.basename(projectDir), lastSaveError, }); const state = () => ({ project: store.project, history: store.history, status: status(), }); const broadcast = (event: string, data: any) => { for (const r of subscribers) r.write("event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n"); }; const persist = () => { const data = JSON.stringify(store.project, null, 2); saveQueue = saveQueue .catch(() => {}) .then(() => atomic(projectFile, data)) .then(() => { lastSaveError = null; }) .catch((e) => { lastSaveError = String(e); throw e; }); return saveQueue; }; store.subscribe(() => { broadcast("project", state()); void persist().catch(() => broadcast("project", state())); }); const readAsset = async (uri: string): Promise => { if (uri.startsWith("data:")) return decodeData(uri); const clean = uri.replace(/^\/+/, "").replace(/^\.\//, ""); if (!/^(assets|engine)\/[a-zA-Z0-9_.-]+$/.test(clean)) throw Error("Invalid asset path"); if (clean.startsWith("assets/")) try { return await safeRead(projectDir, clean); } catch (e: any) { if (e.code !== "ENOENT") throw e; } return safeRead(path.join(root, "public"), clean); }; const builds = new BuildManager(projectDir, readAsset); await builds.init(); const service: EngineService = { builds, store, status, save: async () => { const p = structuredClone(store.project); await persist(); const file = path.join(projectDir, "project.forma"); await atomic(file, await projectArchive(p, readAsset)); return { path: file, revision: p.revision }; }, exportWeb: async () => { const p = structuredClone(store.project), file = path.join( projectDir, "exports", p.name.replace(/[^a-zA-Zа-яА-Я0-9_-]/g, "_") + "-web.zip", ); await atomic(file, await gameArchive(p, readAsset)); return { path: file, revision: p.revision, bytes: (await fs.stat(file)).size, instructions: "Unzip and serve over HTTP. Entry index.html. No editor or MCP required.", }; }, importImage: async (a: any) => { if (Boolean(a.base64) === Boolean(a.path)) throw Error("Supply exactly one of base64 or path"); const bytes = a.base64 ? Buffer.from(a.base64, "base64") : await safeRead(projectDir, a.path); const asset = imageAsset(bytes, a.name), commands: any[] = [{ op: "asset.upsert", args: { asset } }]; if (a.instantiate) commands.push({ op: "node.create", args: { entity: entity(asset.name, { sprite: { assetId: asset.id, frame: 0 }, }), }, }); return store.transaction({ commands, expectedRevision: a.expectedRevision, requestId: a.requestId, label: "Импорт спрайта " + asset.name, source: "mcp", }); }, importModel: async (a: any) => { if (Boolean(a.base64) === Boolean(a.path)) throw Error("Supply exactly one of base64 or path"); const bytes = a.base64 ? Buffer.from(a.base64, "base64") : Buffer.from(await safeRead(projectDir, a.path)); const sourceName = a.path || a.name; const model = await portableModel( bytes, sourceName, a.path ? (file) => safeRead(projectDir, file) : undefined, ); const id = uid("asset"); const asset = { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model), }; const commands: any[] = [{ op: "asset.upsert", args: { asset } }]; if (a.instantiate) commands.push({ op: "node.create", args: { entity: entity( model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata), ), }, }); return store.transaction({ commands: a.asScene ? [{ op: "project.replace", args: { project: modelProject(model) } }] : commands, expectedRevision: a.expectedRevision, requestId: a.requestId, label: "Импорт " + a.name, source: "mcp", }); }, runtime: (action, args = {}) => new Promise((resolve, reject) => { const client = [...subscribers][0]; if (!client) { reject(Error("EDITOR_DISCONNECTED: open local editor in browser")); return; } const id = uid("bridge"), timer = setTimeout(() => { pending.delete(id); reject(Error("EDITOR_TIMEOUT after 15 seconds")); }, 15000); pending.set(id, { resolve, reject, timer, client }); client.write( "event: runtime\ndata: " + JSON.stringify({ id, action, args }) + "\n\n", ); }), }; const server = http.createServer(async (req, res) => { try { const host = req.headers.host || ""; if (!/^((127\.0\.0\.1|localhost)(:\d+)?|\[::1\](:\d+)?)$/.test(host)) { json(res, 403, { error: "Invalid Host" }); return; } const origin = req.headers.origin, origins = new Set([ "http://127.0.0.1:" + port, "http://localhost:" + port, ...(options.editorOrigin ? [options.editorOrigin] : []), ]); if (origin && !origins.has(origin)) { json(res, 403, { error: "Origin not allowed" }); return; } const url = new URL(req.url || "/", "http://127.0.0.1:" + port), pathname = decodeURIComponent(url.pathname); if (pathname === "/mcp") { if (!auth(req)) { res.setHeader("www-authenticate", 'Bearer realm="Forma"'); json(res, 401, { error: "Bearer token required" }); return; } if (!mcpEnabled) { json(res, 403, { error: "MCP_DISABLED_BY_USER" }); return; } if (req.method !== "POST") { res.setHeader("allow", "POST"); json(res, 405, { error: "Use POST for stateless Streamable HTTP" }); return; } const data = await body(req), mcp = createMcp(service), transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.once("close", () => void mcp.close()); await mcp.connect(transport); await transport.handleRequest(req, res, data); return; } if (pathname.startsWith("/api/")) { if (req.method === "POST" && !origin && !auth(req)) { json(res, 403, { error: "Same-origin editor or bearer authentication required", }); return; } if (req.method === "GET" && pathname === "/api/builds/capabilities") { json(res, 200, await builds.capabilities()); return; } if (req.method === "GET" && pathname === "/api/builds") { json(res, 200, { jobs: builds.list() }); return; } const buildFile = pathname.match( /^\/api\/builds\/([a-f0-9-]{36})\/files\/([^/]+)$/, ); if (req.method === "GET" && buildFile) { const file = await builds.artifact(buildFile[1], buildFile[2]); const stat = await fs.stat(file); res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size, "content-disposition": "attachment; filename*=UTF-8''" + encodeURIComponent(buildFile[2]), "x-content-type-options": "nosniff", }); const stream = createReadStream(file); stream.on("error", () => res.destroy()); res.on("close", () => stream.destroy()); stream.pipe(res); return; } const buildId = pathname.match(/^\/api\/builds\/([a-f0-9-]{36})$/); if (req.method === "GET" && buildId) { json(res, 200, builds.get(buildId[1])); return; } if (req.method === "GET" && pathname === "/api/status") { json(res, 200, status()); return; } if (req.method === "GET" && pathname === "/api/project") { json(res, 200, state()); return; } if (req.method === "GET" && pathname === "/api/events") { res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", "x-accel-buffering": "no", }); res.write(": Forma connected\n\n"); subscribers.add(res); res.write( "event: project\ndata: " + JSON.stringify(state()) + "\n\n", ); const heartbeat = setInterval( () => res.write(": heartbeat\n\n"), 20000, ); req.on("close", () => { clearInterval(heartbeat); subscribers.delete(res); for (const [id, p] of pending) if (p.client === res) { clearTimeout(p.timer); p.reject(Error("EDITOR_DISCONNECTED")); pending.delete(id); } }); return; } if (req.method === "POST") { const data = await body(req); if (pathname === "/api/builds") { json( res, 202, await builds.start( store.project, data.options, data.expectedRevision, ), ); return; } if (pathname === "/api/builds/cancel") { json(res, 200, await builds.cancel(data.id)); return; } if (pathname === "/api/transaction") { const result = store.transaction(data); json(res, 200, { ...state(), result }); return; } if (pathname === "/api/history") { if (!["undo", "redo"].includes(data.action)) throw Error("Unknown history action"); if ( data.expectedRevision !== undefined && data.expectedRevision !== store.project.revision ) throw Error("REVISION_CONFLICT"); store[data.action as "undo" | "redo"](); json(res, 200, state()); return; } if (pathname === "/api/mcp") { mcpEnabled = Boolean(data.enabled); if (!mcpEnabled) { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(Error("MCP_DISABLED_BY_USER")); } pending.clear(); broadcast("runtime", { id: uid("cancel"), action: "cancel", args: {}, }); } broadcast("project", state()); json(res, 200, state()); return; } if (pathname === "/api/save") { json(res, 200, await service.save()); return; } if (pathname === "/api/runtime-result") { const p = pending.get(data.id); if (p) { clearTimeout(p.timer); pending.delete(data.id); if (data.error) p.reject(Error(data.error)); else p.resolve(data.result); } json(res, 200, { received: !!p }); return; } if (pathname === "/api/runtime" && auth(req)) { json(res, 200, await service.runtime(data.action, data.args)); return; } } json(res, 404, { error: "Unknown API endpoint" }); return; } if (req.method !== "GET" && req.method !== "HEAD") { json(res, 405, { error: "Method not allowed" }); return; } let clean = pathname === "/" ? "studio/index.html" : pathname.replace(/^\/+/, ""); let bytes: Uint8Array | undefined; if (clean.startsWith("assets/")) try { bytes = await safeRead(projectDir, clean); } catch (e: any) { if (e.code !== "ENOENT") throw e; } if (!bytes) try { bytes = await safeRead(path.join(root, "public"), clean); } catch (e: any) { if (e.code === "ENOENT") { json(res, 404, { error: "File not found. Run npm run local:build.", }); return; } throw e; } res.writeHead(200, { "content-type": mime[path.extname(clean)] || "application/octet-stream", "cache-control": "no-cache", "x-content-type-options": "nosniff", }); res.end(req.method === "HEAD" ? undefined : bytes); } catch (e) { if (!res.headersSent) json(res, String(e).includes("REVISION_CONFLICT") ? 409 : 400, { error: String(e), ...(String(e).includes("REVISION_CONFLICT") ? state() : {}), }); else res.end(); } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", () => { port = (server.address() as any).port; resolve(); }); }); await persist(); return { server, service, projectDir, port, token, close: async () => { await builds.close(); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(Error("Server closing")); } pending.clear(); for (const r of subscribers) r.end(); subscribers.clear(); await saveQueue.catch(() => {}); await new Promise((resolve) => server.close(() => resolve())); }, }; } if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const value = (f: string) => { const i = process.argv.indexOf(f); return i >= 0 ? process.argv[i + 1] : undefined; }; createService({ projectDir: value("--project"), port: Number(value("--port") || 4318), blank: process.argv.includes("--blank"), editorOrigin: process.env.FORMA_EDITOR_ORIGIN, }) .then((s) => { console.log( "Forma Engine · http://127.0.0.1:" + s.port + "\nProject: " + s.projectDir + "\nMCP token file: " + path.join(s.projectDir, ".mcp-token"), ); const stop = () => void s.close().then(() => process.exit(0)); process.once("SIGINT", stop); process.once("SIGTERM", stop); }) .catch((e) => { console.error(String(e)); process.exitCode = 1; }); }