596 lines
19 KiB
TypeScript
596 lines
19 KiB
TypeScript
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;
|
||
});
|
||
}
|