@imenam/mcp-design 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -0
- package/dist/shared/node-defaults.d.ts +30 -0
- package/dist/shared/node-defaults.js +67 -0
- package/dist/shared/node-fields.d.ts +33 -0
- package/dist/shared/node-fields.js +40 -0
- package/dist/shared/render.d.ts +105 -0
- package/dist/shared/render.js +215 -0
- package/dist/shared/schemas/design.schema.d.ts +231 -0
- package/dist/shared/schemas/design.schema.js +98 -0
- package/dist/shared/tree.d.ts +93 -0
- package/dist/shared/tree.js +223 -0
- package/dist/src/cli/setup-mcp.d.ts +5 -0
- package/dist/src/cli/setup-mcp.js +87 -0
- package/dist/src/core/design-store.d.ts +61 -0
- package/dist/src/core/design-store.js +294 -0
- package/dist/src/core/diff.d.ts +22 -0
- package/dist/src/core/diff.js +97 -0
- package/dist/src/core/export.d.ts +6 -0
- package/dist/src/core/export.js +251 -0
- package/dist/src/core/hierarchy.d.ts +18 -0
- package/dist/src/core/hierarchy.js +68 -0
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +21 -0
- package/dist/src/core/log-dir.d.ts +6 -0
- package/dist/src/core/log-dir.js +12 -0
- package/dist/src/core/migrate.d.ts +18 -0
- package/dist/src/core/migrate.js +54 -0
- package/dist/src/core/node-factory.d.ts +19 -0
- package/dist/src/core/node-factory.js +103 -0
- package/dist/src/core/screenshot.d.ts +31 -0
- package/dist/src/core/screenshot.js +53 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +334 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +673 -0
- package/dist/src/tools/add-nodes.d.ts +7 -0
- package/dist/src/tools/add-nodes.js +26 -0
- package/dist/src/tools/create-design.d.ts +7 -0
- package/dist/src/tools/create-design.js +15 -0
- package/dist/src/tools/delete-design.d.ts +3 -0
- package/dist/src/tools/delete-design.js +7 -0
- package/dist/src/tools/delete-nodes.d.ts +3 -0
- package/dist/src/tools/delete-nodes.js +29 -0
- package/dist/src/tools/export-design.d.ts +3 -0
- package/dist/src/tools/export-design.js +21 -0
- package/dist/src/tools/get-changes.d.ts +11 -0
- package/dist/src/tools/get-changes.js +27 -0
- package/dist/src/tools/get-design.d.ts +7 -0
- package/dist/src/tools/get-design.js +29 -0
- package/dist/src/tools/group-nodes.d.ts +10 -0
- package/dist/src/tools/group-nodes.js +83 -0
- package/dist/src/tools/list-designs.d.ts +3 -0
- package/dist/src/tools/list-designs.js +13 -0
- package/dist/src/tools/mark-synced.d.ts +7 -0
- package/dist/src/tools/mark-synced.js +19 -0
- package/dist/src/tools/reorder-nodes.d.ts +7 -0
- package/dist/src/tools/reorder-nodes.js +60 -0
- package/dist/src/tools/screenshot-design.d.ts +3 -0
- package/dist/src/tools/screenshot-design.js +36 -0
- package/dist/src/tools/types.d.ts +25 -0
- package/dist/src/tools/types.js +13 -0
- package/dist/src/tools/ungroup-nodes.d.ts +7 -0
- package/dist/src/tools/ungroup-nodes.js +64 -0
- package/dist/src/tools/update-design.d.ts +4 -0
- package/dist/src/tools/update-design.js +33 -0
- package/dist/src/tools/update-nodes.d.ts +3 -0
- package/dist/src/tools/update-nodes.js +35 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/package.json +58 -0
- package/scripts/generate-version.js +20 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
|
|
3
|
+
import { shortId } from "./ids.js";
|
|
4
|
+
export class DesignError extends Error {
|
|
5
|
+
}
|
|
6
|
+
const GEOMETRY = NODE_GEOMETRY_FIELDS;
|
|
7
|
+
const ALLOWED = NODE_STYLE_FIELDS;
|
|
8
|
+
const DERIVED = GROUP_DERIVED_FIELDS;
|
|
9
|
+
/** Champs geometriques réellement modifiables pour un type donné. */
|
|
10
|
+
function geometryFieldsFor(type) {
|
|
11
|
+
return type === "group" ? GEOMETRY.filter((field) => !DERIVED.includes(field)) : GEOMETRY;
|
|
12
|
+
}
|
|
13
|
+
function readParentId(value) {
|
|
14
|
+
if (value === null)
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
17
|
+
return value.trim();
|
|
18
|
+
throw new DesignError(`"parentId" must be the id of an existing group, or null for the root level.`);
|
|
19
|
+
}
|
|
20
|
+
function requireFiniteNumber(value, field) {
|
|
21
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
22
|
+
if (!Number.isFinite(num)) {
|
|
23
|
+
throw new DesignError(`"${field}" must be a finite number (received: ${JSON.stringify(value)})`);
|
|
24
|
+
}
|
|
25
|
+
return num;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Construit un noeud complet et valide à partir d'une entrée partielle d'agent :
|
|
29
|
+
* seul `type` est obligatoire, tout le reste vient des valeurs par défaut, ce qui
|
|
30
|
+
* évite les noeuds invisibles (taille nulle, pas de remplissage).
|
|
31
|
+
*
|
|
32
|
+
* `existingIds` empêche une collision d'identifiants fournis explicitement.
|
|
33
|
+
*/
|
|
34
|
+
export function buildNode(input, existingIds, index) {
|
|
35
|
+
const type = input.type;
|
|
36
|
+
if (!type || !(type in NODE_DEFAULTS)) {
|
|
37
|
+
throw new DesignError(`Unknown node type "${String(input.type)}". Expected one of: rect, ellipse, line, text.`);
|
|
38
|
+
}
|
|
39
|
+
const id = typeof input.id === "string" && input.id.trim() !== "" ? input.id.trim() : shortId("n");
|
|
40
|
+
if (existingIds.has(id))
|
|
41
|
+
throw new DesignError(`Node id "${id}" already exists in this design.`);
|
|
42
|
+
const draft = {
|
|
43
|
+
id,
|
|
44
|
+
type,
|
|
45
|
+
name: typeof input.name === "string" && input.name.trim() !== "" ? input.name.trim() : defaultNodeName(type, index),
|
|
46
|
+
parentId: input.parentId === undefined ? null : readParentId(input.parentId),
|
|
47
|
+
x: 0,
|
|
48
|
+
y: 0,
|
|
49
|
+
rotation: 0,
|
|
50
|
+
opacity: 1,
|
|
51
|
+
...NODE_DEFAULTS[type],
|
|
52
|
+
};
|
|
53
|
+
for (const field of geometryFieldsFor(type)) {
|
|
54
|
+
if (input[field] !== undefined)
|
|
55
|
+
draft[field] = requireFiniteNumber(input[field], field);
|
|
56
|
+
}
|
|
57
|
+
for (const field of ALLOWED[type]) {
|
|
58
|
+
if (input[field] !== undefined)
|
|
59
|
+
draft[field] = input[field];
|
|
60
|
+
}
|
|
61
|
+
const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
|
|
62
|
+
draft.width = size.width;
|
|
63
|
+
draft.height = size.height;
|
|
64
|
+
const parsed = DesignNodeSchema.safeParse(draft);
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
throw new DesignError(`Invalid node: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
|
|
67
|
+
}
|
|
68
|
+
return parsed.data;
|
|
69
|
+
}
|
|
70
|
+
/** Applique un patch partiel en ne conservant que les champs valides pour le type du noeud. */
|
|
71
|
+
export function applyPatch(node, patch) {
|
|
72
|
+
const next = { ...node };
|
|
73
|
+
if (patch.name !== undefined) {
|
|
74
|
+
if (typeof patch.name !== "string" || patch.name.trim() === "") {
|
|
75
|
+
throw new DesignError(`"name" must be a non-empty string.`);
|
|
76
|
+
}
|
|
77
|
+
next.name = patch.name.trim();
|
|
78
|
+
}
|
|
79
|
+
if (patch.parentId !== undefined)
|
|
80
|
+
next.parentId = readParentId(patch.parentId);
|
|
81
|
+
for (const field of geometryFieldsFor(node.type)) {
|
|
82
|
+
if (patch[field] !== undefined)
|
|
83
|
+
next[field] = requireFiniteNumber(patch[field], field);
|
|
84
|
+
}
|
|
85
|
+
for (const field of ALLOWED[node.type]) {
|
|
86
|
+
if (patch[field] !== undefined)
|
|
87
|
+
next[field] = patch[field];
|
|
88
|
+
}
|
|
89
|
+
const editable = ["name", "parentId", ...geometryFieldsFor(node.type), ...ALLOWED[node.type]];
|
|
90
|
+
const unknownFields = Object.keys(patch).filter((key) => key !== "id" && !editable.includes(key));
|
|
91
|
+
if (unknownFields.length > 0) {
|
|
92
|
+
const derived = unknownFields.filter((key) => node.type === "group" && DERIVED.includes(key));
|
|
93
|
+
const reason = derived.length > 0 ? ` The size of a group is derived from its content and cannot be set.` : "";
|
|
94
|
+
throw new DesignError(`Field(s) [${unknownFields.join(", ")}] cannot be set on a "${node.type}" node.${reason} ` +
|
|
95
|
+
`Allowed: ${editable.join(", ")}.`);
|
|
96
|
+
}
|
|
97
|
+
const parsed = DesignNodeSchema.safeParse(next);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new DesignError(`Invalid patch: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
|
|
100
|
+
}
|
|
101
|
+
return parsed.data;
|
|
102
|
+
}
|
|
103
|
+
export { NODE_DEFAULTS };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Design } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Capture d'écran d'un design.
|
|
4
|
+
*
|
|
5
|
+
* Le rendu passe par le même SVG que `export_design` — donc par la même
|
|
6
|
+
* géométrie que le canvas de la GUI — puis est rasterisé par resvg. Pas de
|
|
7
|
+
* navigateur headless : l'outil doit rester utilisable depuis un serveur MCP
|
|
8
|
+
* lancé en stdio, sans dépendance système.
|
|
9
|
+
*/
|
|
10
|
+
/** Côté maximal du PNG produit : au-delà, l'image coûte du contexte sans rien apporter. */
|
|
11
|
+
export declare const DEFAULT_MAX_DIMENSION = 1568;
|
|
12
|
+
export interface ScreenshotOptions {
|
|
13
|
+
/** Cadre la capture sur ce noeud (typiquement un groupe) au lieu de tout l'artboard. */
|
|
14
|
+
nodeId?: string;
|
|
15
|
+
/** Facteur de zoom appliqué avant l'écrêtage par maxDimension (défaut 1). */
|
|
16
|
+
scale?: number;
|
|
17
|
+
/** Marge, en pixels du design, ajoutée autour du noeud capturé. */
|
|
18
|
+
padding?: number;
|
|
19
|
+
maxDimension?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Screenshot {
|
|
22
|
+
png: Buffer;
|
|
23
|
+
/** Dimensions du PNG produit. */
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
/** Dimensions de la zone capturée, en pixels du design. */
|
|
27
|
+
sourceWidth: number;
|
|
28
|
+
sourceHeight: number;
|
|
29
|
+
scale: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function screenshotDesign(design: Design, options?: ScreenshotOptions): Screenshot;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
2
|
+
import { documentToSvg, subtreeToSvg } from "../../shared/render.js";
|
|
3
|
+
/**
|
|
4
|
+
* Capture d'écran d'un design.
|
|
5
|
+
*
|
|
6
|
+
* Le rendu passe par le même SVG que `export_design` — donc par la même
|
|
7
|
+
* géométrie que le canvas de la GUI — puis est rasterisé par resvg. Pas de
|
|
8
|
+
* navigateur headless : l'outil doit rester utilisable depuis un serveur MCP
|
|
9
|
+
* lancé en stdio, sans dépendance système.
|
|
10
|
+
*/
|
|
11
|
+
/** Côté maximal du PNG produit : au-delà, l'image coûte du contexte sans rien apporter. */
|
|
12
|
+
export const DEFAULT_MAX_DIMENSION = 1568;
|
|
13
|
+
function toDocument(design) {
|
|
14
|
+
return {
|
|
15
|
+
name: design.name,
|
|
16
|
+
width: design.width,
|
|
17
|
+
height: design.height,
|
|
18
|
+
background: design.background,
|
|
19
|
+
nodes: design.nodes,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Lit width/height du SVG produit, seule source fiable de la taille de la zone capturée. */
|
|
23
|
+
function svgSize(svg) {
|
|
24
|
+
const width = Number(/\bwidth="([\d.]+)"/.exec(svg)?.[1] ?? 0);
|
|
25
|
+
const height = Number(/\bheight="([\d.]+)"/.exec(svg)?.[1] ?? 0);
|
|
26
|
+
return { width, height };
|
|
27
|
+
}
|
|
28
|
+
export function screenshotDesign(design, options = {}) {
|
|
29
|
+
const doc = toDocument(design);
|
|
30
|
+
const padding = Math.max(0, options.padding ?? 0);
|
|
31
|
+
const svg = options.nodeId ? subtreeToSvg(doc, options.nodeId, padding) : documentToSvg(doc);
|
|
32
|
+
const source = svgSize(svg);
|
|
33
|
+
if (!(source.width > 0) || !(source.height > 0)) {
|
|
34
|
+
throw new Error("Nothing to capture: the requested area has no size.");
|
|
35
|
+
}
|
|
36
|
+
const maxDimension = Math.max(1, options.maxDimension ?? DEFAULT_MAX_DIMENSION);
|
|
37
|
+
const requested = Math.max(0.05, Math.min(8, options.scale ?? 1));
|
|
38
|
+
// L'écrêtage ne peut que réduire : un zoom demandé plus petit reste respecté.
|
|
39
|
+
const scale = Math.min(requested, maxDimension / Math.max(source.width, source.height));
|
|
40
|
+
const resvg = new Resvg(svg, {
|
|
41
|
+
fitTo: { mode: "zoom", value: scale },
|
|
42
|
+
font: { loadSystemFonts: true, defaultFontFamily: "Segoe UI", sansSerifFamily: "Segoe UI" },
|
|
43
|
+
});
|
|
44
|
+
const rendered = resvg.render();
|
|
45
|
+
return {
|
|
46
|
+
png: Buffer.from(rendered.asPng()),
|
|
47
|
+
width: rendered.width,
|
|
48
|
+
height: rendered.height,
|
|
49
|
+
sourceWidth: source.width,
|
|
50
|
+
sourceHeight: source.height,
|
|
51
|
+
scale,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { streamSSE } from "hono/streaming";
|
|
5
|
+
import { ProxyClient, createLogger, setupLogging } from "@imenam/mcp-gui-interface";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { IPCMessageSchema } from "../shared/schemas/design.schema.js";
|
|
10
|
+
import { resolveLogDir } from "./core/log-dir.js";
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
function findProjectRoot(startDir) {
|
|
13
|
+
let current = startDir;
|
|
14
|
+
while (current !== path.dirname(current)) {
|
|
15
|
+
if (fs.existsSync(path.join(current, "gui")) || fs.existsSync(path.join(current, "package.json")))
|
|
16
|
+
return current;
|
|
17
|
+
current = path.dirname(current);
|
|
18
|
+
}
|
|
19
|
+
return startDir;
|
|
20
|
+
}
|
|
21
|
+
const rootDir = findProjectRoot(__dirname);
|
|
22
|
+
const absoluteStaticRoot = path.resolve(path.join(rootDir, "gui/dist"));
|
|
23
|
+
const configuredAppPath = process.env.APP_PATH || "/design-gui";
|
|
24
|
+
setupLogging({ processLabel: "mcp-design:GUI", logDir: resolveLogDir(rootDir) });
|
|
25
|
+
const logger = createLogger("mcp-design:GUI");
|
|
26
|
+
process.on("uncaughtException", (err) => {
|
|
27
|
+
logger.error(`Uncaught Exception: ${err.message}`);
|
|
28
|
+
logger.error(err.stack || "No stack trace");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
process.on("unhandledRejection", (reason) => {
|
|
32
|
+
logger.error(`Unhandled Rejection: ${reason}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
});
|
|
35
|
+
// Le master peut disparaître sans que l'évènement `disconnect` ne parvienne au
|
|
36
|
+
// worker (cas Windows). Deux garde-fous complémentaires : le canal IPC, qui se
|
|
37
|
+
// ferme dès que le parent meurt, et le PID parent en dernier recours — les PID
|
|
38
|
+
// étant recyclés sous Windows, le premier est le plus fiable des deux.
|
|
39
|
+
const parentPid = process.ppid;
|
|
40
|
+
const parentCheckInterval = setInterval(() => {
|
|
41
|
+
if (!process.connected) {
|
|
42
|
+
logger.info("IPC channel to the MCP master is closed. Exiting.");
|
|
43
|
+
clearInterval(parentCheckInterval);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
process.kill(parentPid, 0);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
logger.info(`Parent process (PID ${parentPid}) is gone. Exiting.`);
|
|
52
|
+
clearInterval(parentCheckInterval);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
}, 2000);
|
|
56
|
+
const app = new Hono();
|
|
57
|
+
let proxyClient = null;
|
|
58
|
+
const sseControllers = new Set();
|
|
59
|
+
const pendingRequests = new Map();
|
|
60
|
+
function safeSend(message) {
|
|
61
|
+
if (process.send && process.connected) {
|
|
62
|
+
try {
|
|
63
|
+
return process.send(message);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
logger.error(`[IPC] Failed to send message: ${err.message}`);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/** Requête IPC vers le master. Ne rejette jamais : un timeout devient une réponse d'erreur. */
|
|
73
|
+
function ipcRequest(type, data, timeout = 5000) {
|
|
74
|
+
const correlationId = Math.random().toString(36).substring(7);
|
|
75
|
+
const message = { type, correlationId, data, timestamp: new Date().toISOString() };
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
if (pendingRequests.has(correlationId)) {
|
|
79
|
+
pendingRequests.delete(correlationId);
|
|
80
|
+
resolve({ error: "Request timed out" });
|
|
81
|
+
}
|
|
82
|
+
}, timeout);
|
|
83
|
+
pendingRequests.set(correlationId, (msg) => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
resolve(msg);
|
|
86
|
+
});
|
|
87
|
+
if (!safeSend(message)) {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
pendingRequests.delete(correlationId);
|
|
90
|
+
resolve({ error: "IPC not available" });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const PUSHED_EVENTS = new Set(["INITIAL_STATE", "DESIGNS_UPDATED", "DESIGN_UPDATED"]);
|
|
95
|
+
function setupIpcErrorHandlers() {
|
|
96
|
+
process.on("error", (err) => {
|
|
97
|
+
if (err.code === "ERR_IPC_CHANNEL_CLOSED")
|
|
98
|
+
return;
|
|
99
|
+
logger.error(`Process error: ${err.message}`);
|
|
100
|
+
});
|
|
101
|
+
process.on("disconnect", () => {
|
|
102
|
+
logger.warn("IPC disconnected from MCP master. GUI worker will exit; EventSource clients will show Disconnected.");
|
|
103
|
+
process.exit(0);
|
|
104
|
+
});
|
|
105
|
+
process.on("message", (msg) => {
|
|
106
|
+
if (msg && msg.correlationId && pendingRequests.has(msg.correlationId)) {
|
|
107
|
+
const resolve = pendingRequests.get(msg.correlationId);
|
|
108
|
+
pendingRequests.delete(msg.correlationId);
|
|
109
|
+
resolve(msg);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (msg && msg.type === "GET_HEALTH") {
|
|
113
|
+
const memory = process.memoryUsage();
|
|
114
|
+
safeSend({
|
|
115
|
+
type: "HEALTH_RESPONSE",
|
|
116
|
+
correlationId: msg.correlationId,
|
|
117
|
+
data: { status: "ok", uptime: process.uptime(), memory: { rss: memory.rss, heapUsed: memory.heapUsed } },
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (msg && PUSHED_EVENTS.has(msg.type)) {
|
|
123
|
+
for (const controller of sseControllers) {
|
|
124
|
+
try {
|
|
125
|
+
controller.writeSSE({ data: JSON.stringify(msg), event: "message" });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
sseControllers.delete(controller);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// --- Routes HTTP ------------------------------------------------------------
|
|
135
|
+
app.get("/proxy/health", (c) => {
|
|
136
|
+
const memory = process.memoryUsage();
|
|
137
|
+
return c.json({
|
|
138
|
+
status: "ok",
|
|
139
|
+
proxy: proxyClient ? proxyClient.getStatus() : process.env.PROXY_URL ? "error" : "standalone",
|
|
140
|
+
uptime: process.uptime(),
|
|
141
|
+
memory: { rss: memory.rss, heapUsed: memory.heapUsed },
|
|
142
|
+
ipc: process.connected ? "connected" : "disconnected",
|
|
143
|
+
timestamp: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
147
|
+
const clientId = Math.random().toString(36).slice(2, 11);
|
|
148
|
+
sseControllers.add(stream);
|
|
149
|
+
logger.info(`SSE /api/events: client connected (id=${clientId})`);
|
|
150
|
+
stream.onAbort(() => {
|
|
151
|
+
sseControllers.delete(stream);
|
|
152
|
+
logger.warn(`SSE /api/events: stream aborted (id=${clientId}).`);
|
|
153
|
+
});
|
|
154
|
+
if (!safeSend({ type: "GET_INITIAL_STATE", timestamp: new Date().toISOString() })) {
|
|
155
|
+
logger.warn(`SSE /api/events: GET_INITIAL_STATE not sent (id=${clientId}) — IPC unavailable.`);
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await stream.writeSSE({ data: "connected", event: "ping" });
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
logger.warn(`SSE /api/events: initial writeSSE failed (id=${clientId}): ${err?.message ?? err}`);
|
|
162
|
+
sseControllers.delete(stream);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
while (true) {
|
|
166
|
+
await stream.sleep(30000);
|
|
167
|
+
try {
|
|
168
|
+
await stream.writeSSE({ data: "ping", event: "ping" });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
logger.warn(`SSE /api/events: keepalive ping failed (id=${clientId}): ${err?.message ?? err}`);
|
|
172
|
+
sseControllers.delete(stream);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
app.post("/api/client-diagnostics", async (c) => {
|
|
178
|
+
try {
|
|
179
|
+
logger.warn(`Client diagnostics (SSE/EventSource): ${JSON.stringify(await c.req.json())}`);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
logger.warn(`Client diagnostics: invalid JSON body (${e?.message ?? e})`);
|
|
183
|
+
}
|
|
184
|
+
return c.json({ ok: true });
|
|
185
|
+
});
|
|
186
|
+
app.get("/api/config", async (c) => {
|
|
187
|
+
const result = await ipcRequest("GET_CONFIG");
|
|
188
|
+
if (result.error)
|
|
189
|
+
return c.json({ error: result.error }, 500);
|
|
190
|
+
return c.json(result.data);
|
|
191
|
+
});
|
|
192
|
+
app.get("/api/designs", async (c) => {
|
|
193
|
+
const result = await ipcRequest("LIST_DESIGNS");
|
|
194
|
+
if (result.error)
|
|
195
|
+
return c.json({ error: result.error }, 500);
|
|
196
|
+
return c.json(result.data.designs);
|
|
197
|
+
});
|
|
198
|
+
app.post("/api/designs", async (c) => {
|
|
199
|
+
let body = {};
|
|
200
|
+
try {
|
|
201
|
+
body = await c.req.json();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Corps facultatif : la GUI peut créer un design avec les valeurs par défaut.
|
|
205
|
+
}
|
|
206
|
+
const result = await ipcRequest("CREATE_DESIGN", body, 10000);
|
|
207
|
+
if (result.error)
|
|
208
|
+
return c.json({ error: result.error }, 400);
|
|
209
|
+
return c.json(result.data);
|
|
210
|
+
});
|
|
211
|
+
app.get("/api/designs/:id", async (c) => {
|
|
212
|
+
const result = await ipcRequest("GET_DESIGN", { id: c.req.param("id") });
|
|
213
|
+
if (result.error)
|
|
214
|
+
return c.json({ error: result.error }, 404);
|
|
215
|
+
return c.json(result.data);
|
|
216
|
+
});
|
|
217
|
+
app.put("/api/designs/:id", async (c) => {
|
|
218
|
+
let body;
|
|
219
|
+
try {
|
|
220
|
+
body = await c.req.json();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
224
|
+
}
|
|
225
|
+
const result = await ipcRequest("SAVE_DESIGN", { ...body, id: c.req.param("id") }, 10000);
|
|
226
|
+
if (result.error)
|
|
227
|
+
return c.json({ error: result.error }, 400);
|
|
228
|
+
return c.json(result.data);
|
|
229
|
+
});
|
|
230
|
+
app.delete("/api/designs/:id", async (c) => {
|
|
231
|
+
const result = await ipcRequest("DELETE_DESIGN", { id: c.req.param("id") }, 10000);
|
|
232
|
+
if (result.error)
|
|
233
|
+
return c.json({ error: result.error }, 404);
|
|
234
|
+
return c.json(result.data);
|
|
235
|
+
});
|
|
236
|
+
// Fichiers statiques de la SPA
|
|
237
|
+
app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
|
|
238
|
+
function getHtmlBaseHref(appPath) {
|
|
239
|
+
const normalized = appPath.startsWith("/") ? appPath : `/${appPath}`;
|
|
240
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
241
|
+
}
|
|
242
|
+
// Fallback SPA
|
|
243
|
+
app.get("/*", (c, next) => {
|
|
244
|
+
const urlPath = c.req.path;
|
|
245
|
+
if (urlPath.startsWith("/api") || urlPath.startsWith("/proxy") || urlPath.startsWith("/assets"))
|
|
246
|
+
return next();
|
|
247
|
+
try {
|
|
248
|
+
const indexPath = path.join(absoluteStaticRoot, "index.html");
|
|
249
|
+
if (fs.existsSync(indexPath)) {
|
|
250
|
+
// ?__direct=1 : accès au worker sur son port local, sans passer par le
|
|
251
|
+
// proxy — le préfixe de montage ne doit alors pas préfixer les assets.
|
|
252
|
+
const baseHref = c.req.query("__direct") === "1" ? "/" : getHtmlBaseHref(configuredAppPath);
|
|
253
|
+
const html = fs.readFileSync(indexPath, "utf8").replace("<head>", `<head>\n <base href="${baseHref}" />`);
|
|
254
|
+
return c.html(html);
|
|
255
|
+
}
|
|
256
|
+
return c.text("GUI build not found at " + absoluteStaticRoot, 404);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return next();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
async function startServer() {
|
|
263
|
+
if (!process.env.PROXY_URL) {
|
|
264
|
+
process.stderr.write("[GUI] PROXY_URL non défini — arrêt.\n");
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
const proxyUrl = process.env.PROXY_URL;
|
|
268
|
+
const appPath = configuredAppPath;
|
|
269
|
+
const appName = process.env.APP_NAME || "MCP Design";
|
|
270
|
+
logger.info(`Starting server with proxyUrl=${proxyUrl}`);
|
|
271
|
+
proxyClient = new ProxyClient(proxyUrl);
|
|
272
|
+
let result;
|
|
273
|
+
try {
|
|
274
|
+
// APP_GROUP place la GUI dans une section repliable du proxy (facultatif).
|
|
275
|
+
const registerOptions = { path: appPath, name: appName, group: process.env.APP_GROUP };
|
|
276
|
+
result = await proxyClient.register(registerOptions);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
process.stderr.write("[GUI] Enregistrement proxy échoué — arrêt.\n");
|
|
280
|
+
logger.error(`Proxy registration error: ${e.message}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (result.error === "HTTP 409") {
|
|
285
|
+
logger.info("Another GUI instance already registered.");
|
|
286
|
+
safeSend(IPCMessageSchema.parse({
|
|
287
|
+
type: "ALREADY_RUNNING",
|
|
288
|
+
data: { url: `${proxyUrl}${appPath}` },
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
}));
|
|
291
|
+
process.exit(0);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!result.success) {
|
|
295
|
+
process.stderr.write("[GUI] Enregistrement proxy échoué — arrêt.\n");
|
|
296
|
+
logger.error(`Proxy registration failed: ${result.error}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const finalPort = result.port;
|
|
301
|
+
const publicUrl = result.url || `${proxyUrl}${appPath}`;
|
|
302
|
+
logger.info(`Registered with proxy. Public URL: ${publicUrl}, Local Port: ${finalPort}`);
|
|
303
|
+
const cleanupProxy = async () => {
|
|
304
|
+
if (proxyClient)
|
|
305
|
+
await proxyClient.unregister();
|
|
306
|
+
process.exit(0);
|
|
307
|
+
};
|
|
308
|
+
process.on("SIGTERM", cleanupProxy);
|
|
309
|
+
process.on("SIGINT", cleanupProxy);
|
|
310
|
+
setupIpcErrorHandlers();
|
|
311
|
+
const serverInstance = serve({ fetch: app.fetch, port: finalPort, hostname: "0.0.0.0" }, (info) => {
|
|
312
|
+
logger.info(`Design canvas server listening on port ${info.port}`);
|
|
313
|
+
safeSend(IPCMessageSchema.parse({
|
|
314
|
+
type: "READY",
|
|
315
|
+
data: { port: info.port, isProxied: true, url: publicUrl },
|
|
316
|
+
timestamp: new Date().toISOString(),
|
|
317
|
+
}));
|
|
318
|
+
});
|
|
319
|
+
serverInstance.on("error", (err) => {
|
|
320
|
+
if (err.code === "EADDRINUSE") {
|
|
321
|
+
// Sortie propre (code 0) : le master ne doit pas relancer un worker de plus.
|
|
322
|
+
logger.error(`Port ${finalPort} already in use — another GUI instance is running.`);
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
325
|
+
logger.error(`Server error: ${err.message}`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
startServer().catch((err) => {
|
|
330
|
+
logger.error(`Failed to start GUI Worker: ${err.message}`);
|
|
331
|
+
if (err.stack)
|
|
332
|
+
logger.error(err.stack);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
});
|