Files
2026-09-12 04:58:59 +03:00

596 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
".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<ServerResponse>(),
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<Uint8Array> => {
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<void>((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<void>((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;
});
}