Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+558
View File
@@ -0,0 +1,558 @@
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 { inspectModel } from "../engine/model.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",
".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.",
};
},
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 metadata = inspectModel(bytes, a.name),
gltf = a.name.toLowerCase().endsWith(".gltf");
const id = uid("asset"),
asset = {
id,
name: a.name,
kind: "model",
metadata,
uri:
"data:" +
(gltf ? "model/gltf+json" : "model/gltf-binary") +
";base64," +
bytes.toString("base64"),
},
commands: any[] = [{ op: "asset.upsert", args: { asset } }];
if (a.instantiate)
commands.push({
op: "node.create",
args: {
entity: entity(a.name.replace(/\.(glb|gltf)$/i, ""), {
mesh: { type: "model", assetId: id },
}),
},
});
return store.transaction({
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;
});
}