Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
+558
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user