@mandujs/mcp 0.37.4 → 0.38.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/package.json +2 -2
- package/src/tools/hydration.ts +143 -103
- package/src/tools/runtime.ts +327 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@mandujs/core": "^0.54.
|
|
37
|
+
"@mandujs/core": "^0.54.6",
|
|
38
38
|
"@mandujs/ate": "^0.26.1",
|
|
39
39
|
"@mandujs/skills": "^0.20.1",
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.25.3"
|
package/src/tools/hydration.ts
CHANGED
|
@@ -18,17 +18,17 @@ export const hydrationToolDefinitions: Tool[] = [
|
|
|
18
18
|
name: "mandu.build",
|
|
19
19
|
description:
|
|
20
20
|
"Build client bundles for hydration. Compiles client slots (.client.ts) into browser-ready JavaScript bundles.",
|
|
21
|
-
annotations: {
|
|
22
|
-
destructiveHint: true,
|
|
23
|
-
readOnlyHint: false,
|
|
24
|
-
idempotentHint: true,
|
|
25
|
-
},
|
|
26
|
-
inputSchema: {
|
|
27
|
-
type: "object",
|
|
28
|
-
properties: {
|
|
29
|
-
minify: {
|
|
30
|
-
type: "boolean",
|
|
31
|
-
description: "Minify the output bundles (default: true in production)",
|
|
21
|
+
annotations: {
|
|
22
|
+
destructiveHint: true,
|
|
23
|
+
readOnlyHint: false,
|
|
24
|
+
idempotentHint: true,
|
|
25
|
+
},
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
minify: {
|
|
30
|
+
type: "boolean",
|
|
31
|
+
description: "Minify the output bundles (default: true in production)",
|
|
32
32
|
},
|
|
33
33
|
sourcemap: {
|
|
34
34
|
type: "boolean",
|
|
@@ -39,50 +39,53 @@ export const hydrationToolDefinitions: Tool[] = [
|
|
|
39
39
|
items: { type: "string" },
|
|
40
40
|
description:
|
|
41
41
|
"Only rebuild specific islands by routeId. Skips Runtime/Router/Vendor rebuild for faster incremental updates. Omit to rebuild everything.",
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
required: [],
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: [],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
47
48
|
{
|
|
48
49
|
name: "mandu.build.status",
|
|
49
50
|
description:
|
|
50
51
|
"Get the current build status, bundle manifest, and statistics for client bundles.",
|
|
51
|
-
annotations: {
|
|
52
|
-
readOnlyHint: true,
|
|
53
|
-
},
|
|
54
|
-
inputSchema: {
|
|
55
|
-
type: "object",
|
|
56
|
-
properties: {},
|
|
57
|
-
required: [],
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
annotations: {
|
|
53
|
+
readOnlyHint: true,
|
|
54
|
+
},
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {},
|
|
58
|
+
required: [],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "mandu.island.list",
|
|
62
64
|
description:
|
|
63
65
|
"List all routes that have client-side hydration (islands). Shows hydration strategy and priority for each.",
|
|
64
|
-
annotations: {
|
|
65
|
-
readOnlyHint: true,
|
|
66
|
-
},
|
|
67
|
-
inputSchema: {
|
|
68
|
-
type: "object",
|
|
69
|
-
properties: {},
|
|
70
|
-
required: [],
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
annotations: {
|
|
67
|
+
readOnlyHint: true,
|
|
68
|
+
},
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {},
|
|
72
|
+
required: [],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "mandu.hydration.set",
|
|
75
78
|
description:
|
|
76
79
|
"Set hydration configuration for a specific route. Updates the route's hydration strategy and priority.",
|
|
77
|
-
annotations: {
|
|
78
|
-
readOnlyHint: false,
|
|
79
|
-
},
|
|
80
|
-
inputSchema: {
|
|
81
|
-
type: "object",
|
|
82
|
-
properties: {
|
|
83
|
-
routeId: {
|
|
84
|
-
type: "string",
|
|
85
|
-
description: "The route ID to configure",
|
|
80
|
+
annotations: {
|
|
81
|
+
readOnlyHint: false,
|
|
82
|
+
},
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
routeId: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "The route ID to configure",
|
|
86
89
|
},
|
|
87
90
|
strategy: {
|
|
88
91
|
type: "string",
|
|
@@ -100,24 +103,25 @@ export const hydrationToolDefinitions: Tool[] = [
|
|
|
100
103
|
type: "boolean",
|
|
101
104
|
description: "Whether to preload the bundle with modulepreload",
|
|
102
105
|
},
|
|
103
|
-
},
|
|
104
|
-
required: ["routeId"],
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
},
|
|
107
|
+
required: ["routeId"],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
108
112
|
name: "mandu.hydration.addClientSlot",
|
|
109
113
|
description:
|
|
110
114
|
"Add a client slot file for a route to enable hydration. Creates the .client.ts file and updates the manifest.",
|
|
111
|
-
annotations: {
|
|
112
|
-
destructiveHint: false,
|
|
113
|
-
readOnlyHint: false,
|
|
114
|
-
},
|
|
115
|
-
inputSchema: {
|
|
116
|
-
type: "object",
|
|
117
|
-
properties: {
|
|
118
|
-
routeId: {
|
|
119
|
-
type: "string",
|
|
120
|
-
description: "The route ID to add client slot for",
|
|
115
|
+
annotations: {
|
|
116
|
+
destructiveHint: false,
|
|
117
|
+
readOnlyHint: false,
|
|
118
|
+
},
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
routeId: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description: "The route ID to add client slot for",
|
|
121
125
|
},
|
|
122
126
|
strategy: {
|
|
123
127
|
type: "string",
|
|
@@ -129,13 +133,14 @@ export const hydrationToolDefinitions: Tool[] = [
|
|
|
129
133
|
enum: ["immediate", "visible", "idle", "interaction"],
|
|
130
134
|
description: "Hydration priority (default: visible)",
|
|
131
135
|
},
|
|
132
|
-
},
|
|
133
|
-
required: ["routeId"],
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
},
|
|
137
|
+
required: ["routeId"],
|
|
138
|
+
additionalProperties: false,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
export function hydrationTools(projectRoot: string) {
|
|
139
144
|
const paths = getProjectPaths(projectRoot);
|
|
140
145
|
|
|
141
146
|
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
@@ -223,20 +228,25 @@ export function hydrationTools(projectRoot: string) {
|
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
const islands = manifestResult.data.routes
|
|
226
|
-
.filter((route) => route.kind === "page")
|
|
227
|
-
.map((route) => {
|
|
228
|
-
const hydration = getRouteHydration(route);
|
|
229
|
-
const isIsland = needsHydration(route);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
231
|
+
.filter((route) => route.kind === "page")
|
|
232
|
+
.map((route) => {
|
|
233
|
+
const hydration = getRouteHydration(route);
|
|
234
|
+
const isIsland = needsHydration(route);
|
|
235
|
+
const warning = isIsland && !route.clientModule
|
|
236
|
+
? `Route has hydration strategy '${hydration.strategy}' but no clientModule; build would emit no route bundle.`
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
routeId: route.id,
|
|
241
|
+
pattern: route.pattern,
|
|
242
|
+
hasClientModule: !!route.clientModule,
|
|
243
|
+
clientModule: route.clientModule || null,
|
|
244
|
+
isIsland,
|
|
245
|
+
status: isIsland ? (route.clientModule ? "ready" : "broken") : "static",
|
|
246
|
+
warning,
|
|
247
|
+
hydration: {
|
|
248
|
+
strategy: hydration.strategy,
|
|
249
|
+
priority: hydration.priority,
|
|
240
250
|
preload: hydration.preload,
|
|
241
251
|
},
|
|
242
252
|
};
|
|
@@ -252,12 +262,19 @@ export function hydrationTools(projectRoot: string) {
|
|
|
252
262
|
islands: islands.filter((i) => i.isIsland),
|
|
253
263
|
staticPages: islands.filter((i) => !i.isIsland),
|
|
254
264
|
};
|
|
255
|
-
},
|
|
256
|
-
|
|
257
|
-
"mandu.hydration.set": async (args: Record<string, unknown>) => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
"mandu.hydration.set": async (args: Record<string, unknown>) => {
|
|
268
|
+
const validationError = validateRouteIdArgs(
|
|
269
|
+
args,
|
|
270
|
+
"mandu.hydration.set",
|
|
271
|
+
["routeId", "strategy", "priority", "preload"],
|
|
272
|
+
);
|
|
273
|
+
if (validationError) return { error: validationError };
|
|
274
|
+
|
|
275
|
+
const { routeId, strategy, priority, preload } = args as {
|
|
276
|
+
routeId: string;
|
|
277
|
+
strategy?: SpecHydrationStrategy;
|
|
261
278
|
priority?: HydrationPriority;
|
|
262
279
|
preload?: boolean;
|
|
263
280
|
};
|
|
@@ -311,12 +328,19 @@ export function hydrationTools(projectRoot: string) {
|
|
|
311
328
|
newHydration,
|
|
312
329
|
message: `Updated hydration config for ${routeId}`,
|
|
313
330
|
};
|
|
314
|
-
},
|
|
315
|
-
|
|
316
|
-
"mandu.hydration.addClientSlot": async (args: Record<string, unknown>) => {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
"mandu.hydration.addClientSlot": async (args: Record<string, unknown>) => {
|
|
334
|
+
const validationError = validateRouteIdArgs(
|
|
335
|
+
args,
|
|
336
|
+
"mandu.hydration.addClientSlot",
|
|
337
|
+
["routeId", "strategy", "priority"],
|
|
338
|
+
);
|
|
339
|
+
if (validationError) return { error: validationError };
|
|
340
|
+
|
|
341
|
+
const { routeId, strategy = "island", priority = "visible" } = args as {
|
|
342
|
+
routeId: string;
|
|
343
|
+
strategy?: SpecHydrationStrategy;
|
|
320
344
|
priority?: HydrationPriority;
|
|
321
345
|
};
|
|
322
346
|
|
|
@@ -396,14 +420,30 @@ export function hydrationTools(projectRoot: string) {
|
|
|
396
420
|
};
|
|
397
421
|
|
|
398
422
|
// Backward-compatible aliases (deprecated)
|
|
399
|
-
handlers["mandu_build"] = handlers["mandu.build"];
|
|
400
|
-
handlers["mandu_build_status"] = handlers["mandu.build.status"];
|
|
401
|
-
handlers["mandu_list_islands"] = handlers["mandu.island.list"];
|
|
402
|
-
handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"];
|
|
403
|
-
handlers["
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
423
|
+
handlers["mandu_build"] = handlers["mandu.build"];
|
|
424
|
+
handlers["mandu_build_status"] = handlers["mandu.build.status"];
|
|
425
|
+
handlers["mandu_list_islands"] = handlers["mandu.island.list"];
|
|
426
|
+
handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"];
|
|
427
|
+
handlers["mandu_hydration_set"] = handlers["mandu.hydration.set"];
|
|
428
|
+
handlers["mandu_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
|
|
429
|
+
handlers["mandu_hydration_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
|
|
430
|
+
|
|
431
|
+
return handlers;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function validateRouteIdArgs(
|
|
435
|
+
args: Record<string, unknown>,
|
|
436
|
+
toolName: string,
|
|
437
|
+
allowedKeys: readonly string[],
|
|
438
|
+
): string | null {
|
|
439
|
+
if (typeof args.routeId !== "string" || args.routeId.trim().length === 0) {
|
|
440
|
+
const allowed = new Set(allowedKeys);
|
|
441
|
+
const unknownKeys = Object.keys(args).filter((key) => !allowed.has(key));
|
|
442
|
+
const got = unknownKeys.length > 0 ? ` (got unknown key${unknownKeys.length === 1 ? "" : "s"} '${unknownKeys.join("', '")}')` : "";
|
|
443
|
+
return `${toolName}: missing required parameter 'routeId'${got}`;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
407
447
|
|
|
408
448
|
/**
|
|
409
449
|
* Generate a client slot template
|
package/src/tools/runtime.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* Query and manage runtime configuration: logger settings and contract normalize options.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
-
import { getProjectPaths } from "../utils/project.js";
|
|
8
|
-
import { loadManifest } from "@mandujs/core";
|
|
9
|
-
import
|
|
6
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { getProjectPaths } from "../utils/project.js";
|
|
8
|
+
import { loadManduConfig, loadManifest, needsHydration } from "@mandujs/core";
|
|
9
|
+
import { getDevServerState } from "./project.js";
|
|
10
|
+
import { readRuntimeControl } from "../utils/runtime-control.js";
|
|
11
|
+
import path from "path";
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
export const runtimeToolDefinitions: Tool[] = [
|
|
@@ -26,8 +28,49 @@ export const runtimeToolDefinitions: Tool[] = [
|
|
|
26
28
|
required: [],
|
|
27
29
|
},
|
|
28
30
|
},
|
|
29
|
-
{
|
|
30
|
-
name: "mandu.runtime.
|
|
31
|
+
{
|
|
32
|
+
name: "mandu.runtime.probe",
|
|
33
|
+
annotations: {
|
|
34
|
+
readOnlyHint: true,
|
|
35
|
+
},
|
|
36
|
+
description:
|
|
37
|
+
"Probe a running Mandu dev/start server by fetching real page HTML and island bundle URLs. " +
|
|
38
|
+
"Catches silent runtime failures that static manifest/guard checks miss, especially empty data-mandu-src island markers.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
baseURL: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Explicit dev server URL. Overrides runtime-control/config discovery.",
|
|
45
|
+
},
|
|
46
|
+
port: {
|
|
47
|
+
type: "number",
|
|
48
|
+
description: "Explicit dev server port. Overrides runtime-control/config discovery.",
|
|
49
|
+
},
|
|
50
|
+
routeIds: {
|
|
51
|
+
type: "array",
|
|
52
|
+
items: { type: "string" },
|
|
53
|
+
description: "Optional route IDs to probe. Omit to probe all page routes that can be sampled.",
|
|
54
|
+
},
|
|
55
|
+
includeDynamic: {
|
|
56
|
+
type: "boolean",
|
|
57
|
+
description: "Probe dynamic routes by substituting __mandu_probe__ for params. Defaults to false.",
|
|
58
|
+
},
|
|
59
|
+
checkBundleUrls: {
|
|
60
|
+
type: "boolean",
|
|
61
|
+
description: "Fetch every non-empty data-mandu-src URL and require a 2xx response. Defaults to true.",
|
|
62
|
+
},
|
|
63
|
+
timeoutMs: {
|
|
64
|
+
type: "number",
|
|
65
|
+
description: "Per-request timeout in milliseconds. Defaults to 3000.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: [],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "mandu.runtime.contractOptions",
|
|
31
74
|
annotations: {
|
|
32
75
|
readOnlyHint: true,
|
|
33
76
|
},
|
|
@@ -150,10 +193,10 @@ async function readFileContent(filePath: string): Promise<string | null> {
|
|
|
150
193
|
}
|
|
151
194
|
}
|
|
152
195
|
|
|
153
|
-
export function runtimeTools(projectRoot: string) {
|
|
154
|
-
const paths = getProjectPaths(projectRoot);
|
|
155
|
-
|
|
156
|
-
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
196
|
+
export function runtimeTools(projectRoot: string) {
|
|
197
|
+
const paths = getProjectPaths(projectRoot);
|
|
198
|
+
|
|
199
|
+
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
157
200
|
"mandu.runtime.config": async () => {
|
|
158
201
|
return {
|
|
159
202
|
defaults: {
|
|
@@ -216,10 +259,78 @@ export default Mandu.contract({
|
|
|
216
259
|
response: { ... },
|
|
217
260
|
});`,
|
|
218
261
|
},
|
|
219
|
-
};
|
|
220
|
-
},
|
|
221
|
-
|
|
222
|
-
"mandu.runtime.
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
"mandu.runtime.probe": async (args: Record<string, unknown>) => {
|
|
266
|
+
const baseUrl = await resolveDevServerBaseUrl(projectRoot, args);
|
|
267
|
+
const timeoutMs = normalizeTimeout(args.timeoutMs);
|
|
268
|
+
const checkBundleUrls = args.checkBundleUrls !== false;
|
|
269
|
+
const includeDynamic = args.includeDynamic === true;
|
|
270
|
+
const routeIdFilter = Array.isArray(args.routeIds)
|
|
271
|
+
? new Set(args.routeIds.filter((id): id is string => typeof id === "string"))
|
|
272
|
+
: null;
|
|
273
|
+
|
|
274
|
+
const result = await loadManifest(paths.manifestPath);
|
|
275
|
+
if (!result.success || !result.data) {
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
baseUrl,
|
|
279
|
+
error: result.errors,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const pageRoutes = result.data.routes
|
|
284
|
+
.filter((route) => route.kind === "page")
|
|
285
|
+
.filter((route) => !routeIdFilter || routeIdFilter.has(route.id))
|
|
286
|
+
.map((route) => ({
|
|
287
|
+
route,
|
|
288
|
+
samplePath: samplePathForPattern(route.pattern, includeDynamic),
|
|
289
|
+
}));
|
|
290
|
+
|
|
291
|
+
const skipped = pageRoutes
|
|
292
|
+
.filter((entry) => entry.samplePath === null)
|
|
293
|
+
.map((entry) => ({
|
|
294
|
+
routeId: entry.route.id,
|
|
295
|
+
pattern: entry.route.pattern,
|
|
296
|
+
reason: "dynamic_route",
|
|
297
|
+
}));
|
|
298
|
+
const probes = await Promise.all(
|
|
299
|
+
pageRoutes
|
|
300
|
+
.filter((entry): entry is typeof entry & { samplePath: string } => entry.samplePath !== null)
|
|
301
|
+
.map((entry) =>
|
|
302
|
+
probeRoute({
|
|
303
|
+
baseUrl,
|
|
304
|
+
route: entry.route,
|
|
305
|
+
samplePath: entry.samplePath,
|
|
306
|
+
timeoutMs,
|
|
307
|
+
checkBundleUrls,
|
|
308
|
+
})
|
|
309
|
+
)
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const failures = probes.flatMap((probe) =>
|
|
313
|
+
probe.failures.map((failure) => ({
|
|
314
|
+
routeId: probe.routeId,
|
|
315
|
+
pattern: probe.pattern,
|
|
316
|
+
path: probe.path,
|
|
317
|
+
...failure,
|
|
318
|
+
}))
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
success: failures.length === 0,
|
|
323
|
+
baseUrl,
|
|
324
|
+
checkedRoutes: probes.length,
|
|
325
|
+
skippedRoutes: skipped.length,
|
|
326
|
+
failureCount: failures.length,
|
|
327
|
+
failures,
|
|
328
|
+
routes: probes,
|
|
329
|
+
skipped,
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
"mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
|
|
223
334
|
const { routeId } = args as { routeId: string };
|
|
224
335
|
|
|
225
336
|
const result = await loadManifest(paths.manifestPath);
|
|
@@ -521,13 +632,205 @@ export const appLogger = logger(${JSON.stringify(config, null, 2)});
|
|
|
521
632
|
// Backward-compatible aliases (deprecated)
|
|
522
633
|
handlers["mandu_get_runtime_config"] = handlers["mandu.runtime.config"];
|
|
523
634
|
handlers["mandu_set_contract_normalize"] = handlers["mandu.runtime.setNormalize"];
|
|
524
|
-
handlers["mandu_get_contract_options"] = handlers["mandu.runtime.contractOptions"];
|
|
525
|
-
handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
|
|
526
|
-
handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
635
|
+
handlers["mandu_get_contract_options"] = handlers["mandu.runtime.contractOptions"];
|
|
636
|
+
handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
|
|
637
|
+
handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
|
|
638
|
+
handlers["mandu_runtime_probe"] = handlers["mandu.runtime.probe"];
|
|
639
|
+
|
|
640
|
+
return handlers;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function insertAfter(content: string, search: string): boolean {
|
|
644
|
+
return content.includes(search);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function resolveDevServerBaseUrl(
|
|
648
|
+
projectRoot: string,
|
|
649
|
+
args: { baseURL?: unknown; port?: unknown } = {},
|
|
650
|
+
): Promise<string> {
|
|
651
|
+
if (typeof args.baseURL === "string" && args.baseURL.trim()) {
|
|
652
|
+
return args.baseURL.trim().replace(/\/+$/, "");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const explicitPort = normalizePort(args.port);
|
|
656
|
+
if (explicitPort) {
|
|
657
|
+
return `http://localhost:${explicitPort}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const control = await readRuntimeControl(projectRoot);
|
|
661
|
+
if (control?.baseUrl) {
|
|
662
|
+
return control.baseUrl.replace(/\/+$/, "");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let port: number | undefined;
|
|
666
|
+
const serverState = getDevServerState();
|
|
667
|
+
if (serverState) {
|
|
668
|
+
for (const line of serverState.output) {
|
|
669
|
+
const portMatch = line.match(/https?:\/\/localhost:(\d+)/);
|
|
670
|
+
if (portMatch) {
|
|
671
|
+
port = Number.parseInt(portMatch[1], 10);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (!port) {
|
|
677
|
+
const config = await loadManduConfig(projectRoot);
|
|
678
|
+
port = config.server?.port ?? 3333;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return `http://localhost:${port}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function normalizePort(value: unknown): number | undefined {
|
|
685
|
+
const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
686
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > 65535) return undefined;
|
|
687
|
+
return raw;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function normalizeTimeout(value: unknown): number {
|
|
691
|
+
const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
692
|
+
if (!Number.isInteger(raw) || raw < 100 || raw > 30_000) return 3000;
|
|
693
|
+
return raw;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function samplePathForPattern(pattern: string, includeDynamic: boolean): string | null {
|
|
697
|
+
if (!includeDynamic && /(^|\/):[^/]+/.test(pattern)) return null;
|
|
698
|
+
const sampled = pattern
|
|
699
|
+
.replace(/:([A-Za-z0-9_]+)/g, "__mandu_probe__")
|
|
700
|
+
.replace(/\*+/g, "__mandu_probe__");
|
|
701
|
+
return sampled.startsWith("/") ? sampled : `/${sampled}`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
interface IslandMarker {
|
|
705
|
+
id: string | null;
|
|
706
|
+
src: string | null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function extractIslandMarkers(html: string): IslandMarker[] {
|
|
710
|
+
const markers: IslandMarker[] = [];
|
|
711
|
+
const tagPattern = /<[^>]*\bdata-mandu-island(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?[^>]*>/gi;
|
|
712
|
+
for (const match of html.matchAll(tagPattern)) {
|
|
713
|
+
const tag = match[0];
|
|
714
|
+
markers.push({
|
|
715
|
+
id: readHtmlAttr(tag, "data-mandu-island"),
|
|
716
|
+
src: readHtmlAttr(tag, "data-mandu-src"),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return markers;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function readHtmlAttr(tag: string, attr: string): string | null {
|
|
723
|
+
const pattern = new RegExp(
|
|
724
|
+
`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
725
|
+
"i",
|
|
726
|
+
);
|
|
727
|
+
const match = tag.match(pattern);
|
|
728
|
+
return match ? (match[1] ?? match[2] ?? match[3] ?? "") : null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function probeRoute({
|
|
732
|
+
baseUrl,
|
|
733
|
+
route,
|
|
734
|
+
samplePath,
|
|
735
|
+
timeoutMs,
|
|
736
|
+
checkBundleUrls,
|
|
737
|
+
}: {
|
|
738
|
+
baseUrl: string;
|
|
739
|
+
route: { id: string; pattern: string; clientModule?: string; hydration?: unknown };
|
|
740
|
+
samplePath: string;
|
|
741
|
+
timeoutMs: number;
|
|
742
|
+
checkBundleUrls: boolean;
|
|
743
|
+
}): Promise<{
|
|
744
|
+
routeId: string;
|
|
745
|
+
pattern: string;
|
|
746
|
+
path: string;
|
|
747
|
+
status: number | null;
|
|
748
|
+
islandCount: number;
|
|
749
|
+
failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }>;
|
|
750
|
+
}> {
|
|
751
|
+
const failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }> = [];
|
|
752
|
+
const url = new URL(samplePath, `${baseUrl}/`);
|
|
753
|
+
let response: Response;
|
|
754
|
+
try {
|
|
755
|
+
response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
756
|
+
} catch (error) {
|
|
757
|
+
return {
|
|
758
|
+
routeId: route.id,
|
|
759
|
+
pattern: route.pattern,
|
|
760
|
+
path: samplePath,
|
|
761
|
+
status: null,
|
|
762
|
+
islandCount: 0,
|
|
763
|
+
failures: [{
|
|
764
|
+
code: "page_fetch_failed",
|
|
765
|
+
message: error instanceof Error ? error.message : String(error),
|
|
766
|
+
status: null,
|
|
767
|
+
}],
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (!response.ok) {
|
|
772
|
+
failures.push({
|
|
773
|
+
code: "page_status",
|
|
774
|
+
message: `Page responded with HTTP ${response.status}`,
|
|
775
|
+
status: response.status,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const html = await response.text();
|
|
780
|
+
const markers = extractIslandMarkers(html);
|
|
781
|
+
const hasRouteIsland = markers.some((marker) => marker.id === route.id);
|
|
782
|
+
if (route.clientModule && needsHydration(route as Parameters<typeof needsHydration>[0]) && !hasRouteIsland) {
|
|
783
|
+
failures.push({
|
|
784
|
+
code: "missing_route_island_marker",
|
|
785
|
+
message: `Route has clientModule but HTML does not contain data-mandu-island="${route.id}"`,
|
|
786
|
+
islandId: route.id,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
for (const marker of markers) {
|
|
791
|
+
if (!marker.src || marker.src.trim().length === 0) {
|
|
792
|
+
failures.push({
|
|
793
|
+
code: "empty_island_src",
|
|
794
|
+
message: "Island marker has empty data-mandu-src",
|
|
795
|
+
islandId: marker.id,
|
|
796
|
+
src: marker.src,
|
|
797
|
+
});
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (!checkBundleUrls) continue;
|
|
801
|
+
|
|
802
|
+
const bundleUrl = new URL(marker.src, `${baseUrl}/`);
|
|
803
|
+
try {
|
|
804
|
+
const bundleResponse = await fetch(bundleUrl, {
|
|
805
|
+
method: "GET",
|
|
806
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
807
|
+
});
|
|
808
|
+
if (!bundleResponse.ok) {
|
|
809
|
+
failures.push({
|
|
810
|
+
code: "bundle_status",
|
|
811
|
+
message: `Island bundle responded with HTTP ${bundleResponse.status}`,
|
|
812
|
+
islandId: marker.id,
|
|
813
|
+
src: marker.src,
|
|
814
|
+
status: bundleResponse.status,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
} catch (error) {
|
|
818
|
+
failures.push({
|
|
819
|
+
code: "bundle_fetch_failed",
|
|
820
|
+
message: error instanceof Error ? error.message : String(error),
|
|
821
|
+
islandId: marker.id,
|
|
822
|
+
src: marker.src,
|
|
823
|
+
status: null,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
routeId: route.id,
|
|
830
|
+
pattern: route.pattern,
|
|
831
|
+
path: samplePath,
|
|
832
|
+
status: response.status,
|
|
833
|
+
islandCount: markers.length,
|
|
834
|
+
failures,
|
|
835
|
+
};
|
|
836
|
+
}
|