@imenam/mcp-feature-analyzer 1.0.0
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/LICENSE +15 -0
- package/README.md +295 -0
- package/dist/shared/diagram-geometry.d.ts +45 -0
- package/dist/shared/diagram-geometry.js +120 -0
- package/dist/shared/diagram-layout.d.ts +98 -0
- package/dist/shared/diagram-layout.js +1156 -0
- package/dist/shared/diagram-quality.d.ts +30 -0
- package/dist/shared/diagram-quality.js +26 -0
- package/dist/shared/labels.d.ts +31 -0
- package/dist/shared/labels.js +53 -0
- package/dist/shared/mindmap-tree.d.ts +28 -0
- package/dist/shared/mindmap-tree.js +62 -0
- package/dist/shared/prompt.d.ts +17 -0
- package/dist/shared/prompt.js +97 -0
- package/dist/shared/review-state.d.ts +10 -0
- package/dist/shared/review-state.js +17 -0
- package/dist/shared/schemas/analysis.schema.d.ts +372 -0
- package/dist/shared/schemas/analysis.schema.js +152 -0
- package/dist/shared/schemas/api.schema.d.ts +92 -0
- package/dist/shared/schemas/api.schema.js +46 -0
- package/dist/shared/schemas/diff.schema.d.ts +91 -0
- package/dist/shared/schemas/diff.schema.js +37 -0
- package/dist/shared/schemas/ipc.schema.d.ts +29 -0
- package/dist/shared/schemas/ipc.schema.js +30 -0
- package/dist/shared/severity.d.ts +11 -0
- package/dist/shared/severity.js +17 -0
- package/dist/shared/text.d.ts +12 -0
- package/dist/shared/text.js +20 -0
- package/dist/src/cli/setup-mcp.d.ts +24 -0
- package/dist/src/cli/setup-mcp.js +145 -0
- package/dist/src/core/analysis-store.d.ts +95 -0
- package/dist/src/core/analysis-store.js +387 -0
- package/dist/src/core/data-dir.d.ts +5 -0
- package/dist/src/core/data-dir.js +11 -0
- package/dist/src/core/diff-parser.d.ts +22 -0
- package/dist/src/core/diff-parser.js +225 -0
- package/dist/src/core/errors.d.ts +16 -0
- package/dist/src/core/errors.js +28 -0
- package/dist/src/core/git.d.ts +35 -0
- package/dist/src/core/git.js +257 -0
- package/dist/src/core/ids.d.ts +16 -0
- package/dist/src/core/ids.js +41 -0
- package/dist/src/core/invariants.d.ts +24 -0
- package/dist/src/core/invariants.js +157 -0
- package/dist/src/core/locations.d.ts +30 -0
- package/dist/src/core/locations.js +66 -0
- package/dist/src/core/log-dir.d.ts +6 -0
- package/dist/src/core/log-dir.js +13 -0
- package/dist/src/core/migrate.d.ts +24 -0
- package/dist/src/core/migrate.js +53 -0
- package/dist/src/core/refresh.d.ts +30 -0
- package/dist/src/core/refresh.js +113 -0
- package/dist/src/core/request-guard.d.ts +32 -0
- package/dist/src/core/request-guard.js +72 -0
- package/dist/src/core/review-actions.d.ts +27 -0
- package/dist/src/core/review-actions.js +99 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +401 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +658 -0
- package/dist/src/tools/add-findings.d.ts +9 -0
- package/dist/src/tools/add-findings.js +106 -0
- package/dist/src/tools/create-analysis.d.ts +4 -0
- package/dist/src/tools/create-analysis.js +36 -0
- package/dist/src/tools/delete-analysis.d.ts +4 -0
- package/dist/src/tools/delete-analysis.js +11 -0
- package/dist/src/tools/delete-diagram.d.ts +4 -0
- package/dist/src/tools/delete-diagram.js +22 -0
- package/dist/src/tools/delete-findings.d.ts +7 -0
- package/dist/src/tools/delete-findings.js +27 -0
- package/dist/src/tools/format.d.ts +27 -0
- package/dist/src/tools/format.js +92 -0
- package/dist/src/tools/get-analysis.d.ts +4 -0
- package/dist/src/tools/get-analysis.js +87 -0
- package/dist/src/tools/get-diff.d.ts +4 -0
- package/dist/src/tools/get-diff.js +67 -0
- package/dist/src/tools/get-review-feedback.d.ts +4 -0
- package/dist/src/tools/get-review-feedback.js +71 -0
- package/dist/src/tools/inputs.d.ts +14 -0
- package/dist/src/tools/inputs.js +40 -0
- package/dist/src/tools/list-analyses.d.ts +8 -0
- package/dist/src/tools/list-analyses.js +71 -0
- package/dist/src/tools/refresh-analysis.d.ts +7 -0
- package/dist/src/tools/refresh-analysis.js +39 -0
- package/dist/src/tools/set-diagram.d.ts +16 -0
- package/dist/src/tools/set-diagram.js +207 -0
- package/dist/src/tools/types.d.ts +52 -0
- package/dist/src/tools/types.js +133 -0
- package/dist/src/tools/update-analysis.d.ts +4 -0
- package/dist/src/tools/update-analysis.js +121 -0
- package/dist/src/tools/update-finding.d.ts +8 -0
- package/dist/src/tools/update-finding.js +91 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/gui/dist/assets/index-CU9pNYDp.js +72 -0
- package/gui/dist/assets/index-Du5eL78F.css +1 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +56 -0
- package/scripts/generate-version.js +20 -0
|
@@ -0,0 +1,401 @@
|
|
|
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, PUSHED_EVENT_TYPES } from "../shared/schemas/ipc.schema.js";
|
|
10
|
+
import { AddNoteBodySchema, DeleteNoteBodySchema, SetFileReviewedBodySchema, SetFindingStatusBodySchema, SubmitReviewBodySchema, } from "../shared/schemas/api.schema.js";
|
|
11
|
+
import { resolveLogDir } from "./core/log-dir.js";
|
|
12
|
+
import { LISTEN_HOST, allowedHostnames, checkRequest } from "./core/request-guard.js";
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
function findProjectRoot(startDir) {
|
|
15
|
+
let current = startDir;
|
|
16
|
+
while (current !== path.dirname(current)) {
|
|
17
|
+
if (fs.existsSync(path.join(current, "gui")) || fs.existsSync(path.join(current, "package.json")))
|
|
18
|
+
return current;
|
|
19
|
+
current = path.dirname(current);
|
|
20
|
+
}
|
|
21
|
+
return startDir;
|
|
22
|
+
}
|
|
23
|
+
const rootDir = findProjectRoot(__dirname);
|
|
24
|
+
const absoluteStaticRoot = path.resolve(path.join(rootDir, "gui/dist"));
|
|
25
|
+
const configuredAppPath = process.env.APP_PATH || "/feature-analyzer";
|
|
26
|
+
/**
|
|
27
|
+
* Chemin réellement monté par le proxy. Il diffère de `configuredAppPath` quand
|
|
28
|
+
* `APP_PATH_PREFIX` est défini : le client partagé préfixe alors le chemin et
|
|
29
|
+
* renvoie le chemin final dans `result.path`. Le `<base href>` suit ce chemin.
|
|
30
|
+
*/
|
|
31
|
+
let effectiveAppPath = configuredAppPath;
|
|
32
|
+
function resolveGuiMode() {
|
|
33
|
+
const proxyUrl = process.env.PROXY_URL?.trim();
|
|
34
|
+
if (proxyUrl)
|
|
35
|
+
return { kind: "proxy", proxyUrl };
|
|
36
|
+
const rawPort = process.env.APP_PORT?.trim();
|
|
37
|
+
if (!rawPort) {
|
|
38
|
+
return { kind: "disabled", reason: "PROXY_URL et APP_PORT non définis" };
|
|
39
|
+
}
|
|
40
|
+
const port = Number(rawPort);
|
|
41
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
42
|
+
return { kind: "disabled", reason: `APP_PORT invalide (${rawPort}) — attendu : entier entre 1 et 65535` };
|
|
43
|
+
}
|
|
44
|
+
return { kind: "standalone", port };
|
|
45
|
+
}
|
|
46
|
+
const guiMode = resolveGuiMode();
|
|
47
|
+
const isStandalone = guiMode.kind === "standalone";
|
|
48
|
+
setupLogging({ processLabel: "mcp-feature-analyzer:GUI", logDir: resolveLogDir(rootDir) });
|
|
49
|
+
const logger = createLogger("mcp-feature-analyzer:GUI");
|
|
50
|
+
process.on("uncaughtException", (err) => {
|
|
51
|
+
logger.error(`Uncaught Exception: ${err.message}`);
|
|
52
|
+
logger.error(err.stack || "No stack trace");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
process.on("unhandledRejection", (reason) => {
|
|
56
|
+
logger.error(`Unhandled Rejection: ${reason}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
59
|
+
// Le master peut disparaître sans que l'évènement `disconnect` ne parvienne au
|
|
60
|
+
// worker (cas Windows). Deux garde-fous : le canal IPC, fermé dès que le parent
|
|
61
|
+
// meurt, et le PID parent, moins fiable car les PID sont recyclés sous Windows.
|
|
62
|
+
const parentPid = process.ppid;
|
|
63
|
+
const parentCheckInterval = setInterval(() => {
|
|
64
|
+
if (!process.connected) {
|
|
65
|
+
logger.info("IPC channel to the MCP master is closed. Exiting.");
|
|
66
|
+
clearInterval(parentCheckInterval);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
process.kill(parentPid, 0);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
logger.info(`Parent process (PID ${parentPid}) is gone. Exiting.`);
|
|
75
|
+
clearInterval(parentCheckInterval);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
}, 2000);
|
|
79
|
+
const app = new Hono();
|
|
80
|
+
// Contrôle de l'hôte appelé et des requêtes de modification, avant toute route.
|
|
81
|
+
const allowedHosts = allowedHostnames(guiMode.kind === "proxy" ? guiMode.proxyUrl : undefined);
|
|
82
|
+
app.use("*", async (c, next) => {
|
|
83
|
+
const rejection = checkRequest({
|
|
84
|
+
method: c.req.method,
|
|
85
|
+
host: c.req.header("host"),
|
|
86
|
+
origin: c.req.header("origin"),
|
|
87
|
+
contentType: c.req.header("content-type"),
|
|
88
|
+
}, allowedHosts);
|
|
89
|
+
if (rejection !== null) {
|
|
90
|
+
logger.warn(`Request refused (${rejection.status}) ${c.req.method} ${c.req.path}: ${rejection.error}`);
|
|
91
|
+
return c.json({ error: rejection.error }, rejection.status);
|
|
92
|
+
}
|
|
93
|
+
await next();
|
|
94
|
+
});
|
|
95
|
+
let proxyClient = null;
|
|
96
|
+
const sseStreams = new Set();
|
|
97
|
+
const pendingRequests = new Map();
|
|
98
|
+
function safeSend(message) {
|
|
99
|
+
if (process.send && process.connected) {
|
|
100
|
+
try {
|
|
101
|
+
return process.send(message);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
logger.error(`[IPC] Failed to send message: ${err.message}`);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
/** Requête IPC vers le master. Ne rejette jamais : un timeout devient une réponse d'erreur. */
|
|
111
|
+
function ipcRequest(type, data, timeout = 5000) {
|
|
112
|
+
const correlationId = Math.random().toString(36).substring(2, 12);
|
|
113
|
+
const message = { type, correlationId, data, timestamp: new Date().toISOString() };
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
if (pendingRequests.has(correlationId)) {
|
|
117
|
+
pendingRequests.delete(correlationId);
|
|
118
|
+
resolve({ error: `IPC request ${type} timed out after ${timeout}ms` });
|
|
119
|
+
}
|
|
120
|
+
}, timeout);
|
|
121
|
+
pendingRequests.set(correlationId, (msg) => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
resolve(msg);
|
|
124
|
+
});
|
|
125
|
+
if (!safeSend(message)) {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
pendingRequests.delete(correlationId);
|
|
128
|
+
resolve({ error: "IPC channel to the MCP master is not available" });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const PUSHED_EVENTS = new Set(PUSHED_EVENT_TYPES);
|
|
133
|
+
function setupIpcHandlers() {
|
|
134
|
+
process.on("error", (err) => {
|
|
135
|
+
if (err.code === "ERR_IPC_CHANNEL_CLOSED")
|
|
136
|
+
return;
|
|
137
|
+
logger.error(`Process error: ${err.message}`);
|
|
138
|
+
});
|
|
139
|
+
process.on("disconnect", () => {
|
|
140
|
+
logger.warn("IPC disconnected from MCP master. GUI worker exits; EventSource clients show Disconnected.");
|
|
141
|
+
process.exit(0);
|
|
142
|
+
});
|
|
143
|
+
process.on("message", (raw) => {
|
|
144
|
+
const parsed = IPCMessageSchema.safeParse(raw);
|
|
145
|
+
if (!parsed.success) {
|
|
146
|
+
logger.warn(`Invalid IPC message ignored: ${parsed.error.message}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const msg = parsed.data;
|
|
150
|
+
if (msg.correlationId && pendingRequests.has(msg.correlationId)) {
|
|
151
|
+
const resolve = pendingRequests.get(msg.correlationId);
|
|
152
|
+
pendingRequests.delete(msg.correlationId);
|
|
153
|
+
resolve(msg);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (PUSHED_EVENTS.has(msg.type)) {
|
|
157
|
+
for (const stream of sseStreams) {
|
|
158
|
+
stream.writeSSE({ data: JSON.stringify(msg), event: "message" }).catch((err) => {
|
|
159
|
+
logger.warn(`SSE push ${msg.type} failed, stream dropped: ${err?.message ?? err}`);
|
|
160
|
+
sseStreams.delete(stream);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
logger.debug(`Unhandled IPC message type: ${msg.type}`);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// --- Routes HTTP ------------------------------------------------------------
|
|
169
|
+
app.get("/proxy/health", (c) => {
|
|
170
|
+
const memory = process.memoryUsage();
|
|
171
|
+
return c.json({
|
|
172
|
+
status: "ok",
|
|
173
|
+
proxy: proxyClient ? proxyClient.getStatus() : isStandalone ? "standalone" : "error",
|
|
174
|
+
uptime: process.uptime(),
|
|
175
|
+
memory: { rss: memory.rss, heapUsed: memory.heapUsed },
|
|
176
|
+
ipc: process.connected ? "connected" : "disconnected",
|
|
177
|
+
timestamp: new Date().toISOString(),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
181
|
+
const clientId = Math.random().toString(36).slice(2, 11);
|
|
182
|
+
sseStreams.add(stream);
|
|
183
|
+
logger.info(`SSE /api/events: client connected (id=${clientId})`);
|
|
184
|
+
stream.onAbort(() => {
|
|
185
|
+
sseStreams.delete(stream);
|
|
186
|
+
logger.info(`SSE /api/events: stream closed (id=${clientId}).`);
|
|
187
|
+
});
|
|
188
|
+
if (!safeSend({ type: "GET_INITIAL_STATE", timestamp: new Date().toISOString() })) {
|
|
189
|
+
logger.warn(`SSE /api/events: GET_INITIAL_STATE not sent (id=${clientId}) — IPC unavailable.`);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
await stream.writeSSE({ data: "connected", event: "ping" });
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
logger.warn(`SSE /api/events: initial writeSSE failed (id=${clientId}): ${err?.message ?? err}`);
|
|
196
|
+
sseStreams.delete(stream);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
while (!stream.aborted) {
|
|
200
|
+
await stream.sleep(30000);
|
|
201
|
+
try {
|
|
202
|
+
await stream.writeSSE({ data: "ping", event: "ping" });
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
logger.warn(`SSE /api/events: keepalive ping failed (id=${clientId}): ${err?.message ?? err}`);
|
|
206
|
+
sseStreams.delete(stream);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}));
|
|
211
|
+
app.post("/api/client-diagnostics", async (c) => {
|
|
212
|
+
try {
|
|
213
|
+
logger.warn(`Client diagnostics (SSE/EventSource): ${JSON.stringify(await c.req.json())}`);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
logger.warn(`Client diagnostics: invalid JSON body (${e?.message ?? e})`);
|
|
217
|
+
}
|
|
218
|
+
return c.json({ ok: true });
|
|
219
|
+
});
|
|
220
|
+
/** Code HTTP d'une erreur renvoyée par le master ; une erreur locale (timeout, IPC absent) est une erreur serveur. */
|
|
221
|
+
const HTTP_STATUS_BY_ERROR_KIND = {
|
|
222
|
+
invalid: 400,
|
|
223
|
+
not_found: 404,
|
|
224
|
+
conflict: 409,
|
|
225
|
+
internal: 500,
|
|
226
|
+
};
|
|
227
|
+
/** Transmet une requête au master et renvoie sa réponse, ou `{ error }` avec le code HTTP correspondant. */
|
|
228
|
+
async function forward(c, type, data) {
|
|
229
|
+
const result = await ipcRequest(type, data);
|
|
230
|
+
if (result.error !== undefined) {
|
|
231
|
+
return c.json({ error: result.error }, HTTP_STATUS_BY_ERROR_KIND[result.errorKind ?? "internal"]);
|
|
232
|
+
}
|
|
233
|
+
return c.json(result.data);
|
|
234
|
+
}
|
|
235
|
+
/** Lit et valide le corps JSON d'une requête ; renvoie une réponse 400 explicite s'il est invalide. */
|
|
236
|
+
async function readBody(c, schema) {
|
|
237
|
+
let raw;
|
|
238
|
+
try {
|
|
239
|
+
raw = await c.req.json();
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
return { response: c.json({ error: `The request body is not valid JSON: ${err?.message ?? err}` }, 400) };
|
|
243
|
+
}
|
|
244
|
+
const parsed = schema.safeParse(raw);
|
|
245
|
+
if (!parsed.success) {
|
|
246
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.map(String).join(".") || "(root)"} ${issue.message}`);
|
|
247
|
+
return { response: c.json({ error: `Invalid request body: ${issues.join("; ")}.` }, 400) };
|
|
248
|
+
}
|
|
249
|
+
return { body: parsed.data };
|
|
250
|
+
}
|
|
251
|
+
app.get("/api/config", (c) => forward(c, "GET_CONFIG"));
|
|
252
|
+
app.get("/api/analyses", (c) => forward(c, "LIST_ANALYSES"));
|
|
253
|
+
app.get("/api/analyses/:id", (c) => forward(c, "GET_ANALYSIS", { analysisId: c.req.param("id") }));
|
|
254
|
+
app.get("/api/analyses/:id/diff", (c) => forward(c, "GET_DIFF", { analysisId: c.req.param("id") }));
|
|
255
|
+
app.post("/api/analyses/:id/files/reviewed", async (c) => {
|
|
256
|
+
const read = await readBody(c, SetFileReviewedBodySchema);
|
|
257
|
+
if ("response" in read)
|
|
258
|
+
return read.response;
|
|
259
|
+
return forward(c, "SET_FILE_REVIEWED", { analysisId: c.req.param("id"), body: read.body });
|
|
260
|
+
});
|
|
261
|
+
app.post("/api/analyses/:id/findings/:findingId/status", async (c) => {
|
|
262
|
+
const read = await readBody(c, SetFindingStatusBodySchema);
|
|
263
|
+
if ("response" in read)
|
|
264
|
+
return read.response;
|
|
265
|
+
return forward(c, "SET_FINDING_STATUS", { analysisId: c.req.param("id"), findingId: c.req.param("findingId"), body: read.body });
|
|
266
|
+
});
|
|
267
|
+
app.post("/api/analyses/:id/notes", async (c) => {
|
|
268
|
+
const read = await readBody(c, AddNoteBodySchema);
|
|
269
|
+
if ("response" in read)
|
|
270
|
+
return read.response;
|
|
271
|
+
return forward(c, "ADD_NOTE", { analysisId: c.req.param("id"), body: read.body });
|
|
272
|
+
});
|
|
273
|
+
app.delete("/api/analyses/:id/notes/:noteId", async (c) => {
|
|
274
|
+
const read = await readBody(c, DeleteNoteBodySchema);
|
|
275
|
+
if ("response" in read)
|
|
276
|
+
return read.response;
|
|
277
|
+
return forward(c, "DELETE_NOTE", { analysisId: c.req.param("id"), noteId: c.req.param("noteId"), body: read.body });
|
|
278
|
+
});
|
|
279
|
+
app.post("/api/analyses/:id/review", async (c) => {
|
|
280
|
+
const read = await readBody(c, SubmitReviewBodySchema);
|
|
281
|
+
if ("response" in read)
|
|
282
|
+
return read.response;
|
|
283
|
+
return forward(c, "SUBMIT_REVIEW", { analysisId: c.req.param("id"), body: read.body });
|
|
284
|
+
});
|
|
285
|
+
/** Route d'API inconnue : 404 explicite plutôt que la page de la SPA. */
|
|
286
|
+
app.all("/api/*", (c) => c.json({ error: `Unknown API route: ${c.req.method} ${c.req.path}` }, 404));
|
|
287
|
+
// Fichiers statiques de la SPA
|
|
288
|
+
app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
|
|
289
|
+
function getHtmlBaseHref(appPath) {
|
|
290
|
+
const normalized = appPath.startsWith("/") ? appPath : `/${appPath}`;
|
|
291
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
292
|
+
}
|
|
293
|
+
// Toute autre route renvoie la page de la SPA, avec la balise <base> du chemin de montage.
|
|
294
|
+
app.get("/*", (c, next) => {
|
|
295
|
+
const urlPath = c.req.path;
|
|
296
|
+
if (urlPath.startsWith("/api") || urlPath.startsWith("/proxy") || urlPath.startsWith("/assets"))
|
|
297
|
+
return next();
|
|
298
|
+
const indexPath = path.join(absoluteStaticRoot, "index.html");
|
|
299
|
+
if (!fs.existsSync(indexPath)) {
|
|
300
|
+
return c.text(`GUI build not found at ${absoluteStaticRoot}. Run "npm run build:gui" in the package.`, 404);
|
|
301
|
+
}
|
|
302
|
+
// ?__direct=1 : accès au worker sur son port local, sans le proxy — le
|
|
303
|
+
// préfixe de montage ne préfixe alors pas les assets. En mode standalone la
|
|
304
|
+
// GUI est servie à la racine : même règle.
|
|
305
|
+
const baseHref = isStandalone || c.req.query("__direct") === "1" ? "/" : getHtmlBaseHref(effectiveAppPath);
|
|
306
|
+
const html = fs.readFileSync(indexPath, "utf8").replace("<head>", `<head>\n <base href="${baseHref}" />`);
|
|
307
|
+
return c.html(html);
|
|
308
|
+
});
|
|
309
|
+
/**
|
|
310
|
+
* Lance Hono sur `port` et prévient le master. `publicUrl` est l'URL que
|
|
311
|
+
* l'utilisateur ouvre : via le proxy, ou directement en localhost.
|
|
312
|
+
*/
|
|
313
|
+
function listen(port, publicUrl, isProxied) {
|
|
314
|
+
const serverInstance = serve({ fetch: app.fetch, port, hostname: LISTEN_HOST }, (info) => {
|
|
315
|
+
logger.info(`Feature Analyzer GUI listening on port ${info.port} (${LISTEN_HOST})`);
|
|
316
|
+
safeSend(IPCMessageSchema.parse({
|
|
317
|
+
type: "READY",
|
|
318
|
+
data: { port: info.port, isProxied, url: publicUrl },
|
|
319
|
+
timestamp: new Date().toISOString(),
|
|
320
|
+
}));
|
|
321
|
+
});
|
|
322
|
+
serverInstance.on("error", (err) => {
|
|
323
|
+
if (err.code === "EADDRINUSE") {
|
|
324
|
+
// Sortie avec le code 0 : le master ne relance pas de worker supplémentaire.
|
|
325
|
+
logger.error(`Port ${port} already in use — another GUI instance is running.`);
|
|
326
|
+
safeSend(IPCMessageSchema.parse({
|
|
327
|
+
type: "ALREADY_RUNNING",
|
|
328
|
+
data: { url: publicUrl },
|
|
329
|
+
timestamp: new Date().toISOString(),
|
|
330
|
+
}));
|
|
331
|
+
process.exit(0);
|
|
332
|
+
}
|
|
333
|
+
logger.error(`Server error: ${err.message}`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** Sans proxy : la GUI écoute sur APP_PORT et est servie à la racine. */
|
|
338
|
+
function startStandalone(port) {
|
|
339
|
+
const publicUrl = `http://localhost:${port}`;
|
|
340
|
+
logger.info(`Standalone mode (no PROXY_URL): serving GUI on ${publicUrl}`);
|
|
341
|
+
setupIpcHandlers();
|
|
342
|
+
listen(port, publicUrl, false);
|
|
343
|
+
}
|
|
344
|
+
async function startServer() {
|
|
345
|
+
if (guiMode.kind === "disabled") {
|
|
346
|
+
process.stderr.write(`[GUI] ${guiMode.reason} — arrêt.\n`);
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
if (guiMode.kind === "standalone") {
|
|
350
|
+
startStandalone(guiMode.port);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const proxyUrl = guiMode.proxyUrl;
|
|
354
|
+
const appPath = configuredAppPath;
|
|
355
|
+
const appName = process.env.APP_NAME || "Feature Analyzer";
|
|
356
|
+
logger.info(`Starting server with proxyUrl=${proxyUrl}`);
|
|
357
|
+
proxyClient = new ProxyClient(proxyUrl);
|
|
358
|
+
let result;
|
|
359
|
+
try {
|
|
360
|
+
// APP_GROUP place la GUI dans une section repliable du proxy (facultatif).
|
|
361
|
+
result = await proxyClient.register({ path: appPath, name: appName, group: process.env.APP_GROUP });
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
process.stderr.write("[GUI] Enregistrement proxy échoué — arrêt.\n");
|
|
365
|
+
logger.error(`Proxy registration error: ${e.message}`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
if (result.error === "HTTP 409") {
|
|
369
|
+
logger.info("Another GUI instance already registered.");
|
|
370
|
+
safeSend(IPCMessageSchema.parse({
|
|
371
|
+
type: "ALREADY_RUNNING",
|
|
372
|
+
data: { url: `${proxyUrl}${appPath}` },
|
|
373
|
+
timestamp: new Date().toISOString(),
|
|
374
|
+
}));
|
|
375
|
+
process.exit(0);
|
|
376
|
+
}
|
|
377
|
+
if (!result.success) {
|
|
378
|
+
process.stderr.write("[GUI] Enregistrement proxy échoué — arrêt.\n");
|
|
379
|
+
logger.error(`Proxy registration failed: ${result.error}`);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
const finalPort = result.port;
|
|
383
|
+
effectiveAppPath = result.path ?? appPath;
|
|
384
|
+
const publicUrl = result.url || `${proxyUrl}${effectiveAppPath}`;
|
|
385
|
+
logger.info(`Registered with proxy. Public URL: ${publicUrl}, Local Port: ${finalPort}`);
|
|
386
|
+
const cleanupProxy = async () => {
|
|
387
|
+
if (proxyClient)
|
|
388
|
+
await proxyClient.unregister();
|
|
389
|
+
process.exit(0);
|
|
390
|
+
};
|
|
391
|
+
process.on("SIGTERM", cleanupProxy);
|
|
392
|
+
process.on("SIGINT", cleanupProxy);
|
|
393
|
+
setupIpcHandlers();
|
|
394
|
+
listen(finalPort, publicUrl, true);
|
|
395
|
+
}
|
|
396
|
+
startServer().catch((err) => {
|
|
397
|
+
logger.error(`Failed to start GUI Worker: ${err.message}`);
|
|
398
|
+
if (err.stack)
|
|
399
|
+
logger.error(err.stack);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
});
|