@mandujs/mcp 0.38.4 → 0.38.6
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 +3 -3
- package/package.json +2 -2
- package/src/executor/error-handler.ts +2 -76
- package/src/index.ts +5 -5
- package/src/prompts.ts +4 -4
- package/src/resources/skills/guides.ts +49 -49
- package/src/resources/skills/loader.ts +8 -8
- package/src/resources/skills/mandu-agent-workflow/SKILL.md +124 -124
- package/src/resources/skills/mandu-agent-workflow/metadata.json +7 -7
- package/src/resources/skills/mandu-composition/SKILL.md +47 -47
- package/src/resources/skills/mandu-deployment/SKILL.md +53 -53
- package/src/resources/skills/mandu-deployment/rules/db-provider-supabase.md +3 -3
- package/src/resources/skills/mandu-fs-routes/SKILL.md +47 -47
- package/src/resources/skills/mandu-guard/SKILL.md +58 -58
- package/src/resources/skills/mandu-hydration/SKILL.md +67 -67
- package/src/resources/skills/mandu-hydration/rules/hydration-island-setup.md +54 -54
- package/src/resources/skills/mandu-hydration/rules/hydration-priority-visible.md +60 -60
- package/src/resources/skills/mandu-performance/SKILL.md +47 -47
- package/src/resources/skills/mandu-security/SKILL.md +47 -47
- package/src/resources/skills/mandu-slot/SKILL.md +48 -48
- package/src/resources/skills/mandu-styling/SKILL.md +52 -52
- package/src/resources/skills/mandu-testing/SKILL.md +55 -55
- package/src/resources/skills/mandu-ui/SKILL.md +52 -52
- package/src/resources/skills/recipes.ts +28 -28
- package/src/server.ts +3 -3
- package/src/tools/agent.ts +443 -443
- package/src/tools/ate.ts +37 -37
- package/src/tools/composite.ts +13 -13
- package/src/tools/guard.ts +0 -35
- package/src/tools/hydration.ts +197 -197
- package/src/tools/index.ts +4 -18
- package/src/tools/kitchen.ts +72 -72
- package/src/tools/runtime.ts +579 -579
- package/src/tools/slot-validation.ts +19 -19
- package/src/tools/transaction.ts +4 -18
package/src/tools/runtime.ts
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
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 {
|
|
9
|
-
getRouteHydration,
|
|
10
|
-
loadManduConfig,
|
|
11
|
-
loadManifest,
|
|
12
|
-
needsHydration,
|
|
13
|
-
type BundleManifest,
|
|
14
|
-
} from "@mandujs/core";
|
|
15
|
-
import { getDevServerState } from "./project.js";
|
|
16
|
-
import { readRuntimeControl } from "../utils/runtime-control.js";
|
|
17
|
-
import path from "path";
|
|
6
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { getProjectPaths } from "../utils/project.js";
|
|
8
|
+
import {
|
|
9
|
+
getRouteHydration,
|
|
10
|
+
loadManduConfig,
|
|
11
|
+
loadManifest,
|
|
12
|
+
needsHydration,
|
|
13
|
+
type BundleManifest,
|
|
14
|
+
} from "@mandujs/core";
|
|
15
|
+
import { getDevServerState } from "./project.js";
|
|
16
|
+
import { readRuntimeControl } from "../utils/runtime-control.js";
|
|
17
|
+
import path from "path";
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
export const runtimeToolDefinitions: Tool[] = [
|
|
@@ -34,63 +34,63 @@ export const runtimeToolDefinitions: Tool[] = [
|
|
|
34
34
|
required: [],
|
|
35
35
|
},
|
|
36
36
|
},
|
|
37
|
-
{
|
|
38
|
-
name: "mandu.runtime.probe",
|
|
39
|
-
annotations: {
|
|
40
|
-
readOnlyHint: true,
|
|
41
|
-
},
|
|
42
|
-
description:
|
|
43
|
-
"Probe a running Mandu dev/start server by fetching real page HTML and island bundle URLs. " +
|
|
44
|
-
"Catches silent runtime failures that static manifest/guard checks miss, especially empty data-mandu-src island markers.",
|
|
45
|
-
inputSchema: {
|
|
46
|
-
type: "object",
|
|
47
|
-
properties: {
|
|
48
|
-
baseURL: {
|
|
49
|
-
type: "string",
|
|
50
|
-
description: "Explicit dev server URL. Overrides runtime-control/config discovery.",
|
|
51
|
-
},
|
|
52
|
-
port: {
|
|
53
|
-
type: "number",
|
|
54
|
-
description: "Explicit dev server port. Overrides runtime-control/config discovery.",
|
|
55
|
-
},
|
|
56
|
-
routeIds: {
|
|
57
|
-
type: "array",
|
|
58
|
-
items: { type: "string" },
|
|
59
|
-
description: "Optional route IDs to probe. Omit to probe all page routes that can be sampled.",
|
|
60
|
-
},
|
|
61
|
-
includeDynamic: {
|
|
62
|
-
type: "boolean",
|
|
63
|
-
description: "Probe dynamic routes by substituting __mandu_probe__ for params. Defaults to false.",
|
|
64
|
-
},
|
|
65
|
-
checkBundleUrls: {
|
|
66
|
-
type: "boolean",
|
|
67
|
-
description: "Fetch every non-empty data-mandu-src URL and require a 2xx response. Defaults to true.",
|
|
68
|
-
},
|
|
69
|
-
timeoutMs: {
|
|
70
|
-
type: "number",
|
|
71
|
-
description: "Per-request timeout in milliseconds. Defaults to 3000.",
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
required: [],
|
|
75
|
-
additionalProperties: false,
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
name: "mandu.runtime.status",
|
|
80
|
-
annotations: {
|
|
81
|
-
readOnlyHint: true,
|
|
82
|
-
},
|
|
83
|
-
description:
|
|
84
|
-
"Single source of truth for Mandu client runtime state. Separates page client mounts from nested islands and compares routes manifest, bundle manifest, and generated route artifacts.",
|
|
85
|
-
inputSchema: {
|
|
86
|
-
type: "object",
|
|
87
|
-
properties: {},
|
|
88
|
-
required: [],
|
|
89
|
-
additionalProperties: false,
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
name: "mandu.runtime.contractOptions",
|
|
37
|
+
{
|
|
38
|
+
name: "mandu.runtime.probe",
|
|
39
|
+
annotations: {
|
|
40
|
+
readOnlyHint: true,
|
|
41
|
+
},
|
|
42
|
+
description:
|
|
43
|
+
"Probe a running Mandu dev/start server by fetching real page HTML and island bundle URLs. " +
|
|
44
|
+
"Catches silent runtime failures that static manifest/guard checks miss, especially empty data-mandu-src island markers.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
baseURL: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Explicit dev server URL. Overrides runtime-control/config discovery.",
|
|
51
|
+
},
|
|
52
|
+
port: {
|
|
53
|
+
type: "number",
|
|
54
|
+
description: "Explicit dev server port. Overrides runtime-control/config discovery.",
|
|
55
|
+
},
|
|
56
|
+
routeIds: {
|
|
57
|
+
type: "array",
|
|
58
|
+
items: { type: "string" },
|
|
59
|
+
description: "Optional route IDs to probe. Omit to probe all page routes that can be sampled.",
|
|
60
|
+
},
|
|
61
|
+
includeDynamic: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
description: "Probe dynamic routes by substituting __mandu_probe__ for params. Defaults to false.",
|
|
64
|
+
},
|
|
65
|
+
checkBundleUrls: {
|
|
66
|
+
type: "boolean",
|
|
67
|
+
description: "Fetch every non-empty data-mandu-src URL and require a 2xx response. Defaults to true.",
|
|
68
|
+
},
|
|
69
|
+
timeoutMs: {
|
|
70
|
+
type: "number",
|
|
71
|
+
description: "Per-request timeout in milliseconds. Defaults to 3000.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
required: [],
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "mandu.runtime.status",
|
|
80
|
+
annotations: {
|
|
81
|
+
readOnlyHint: true,
|
|
82
|
+
},
|
|
83
|
+
description:
|
|
84
|
+
"Single source of truth for Mandu client runtime state. Separates page client mounts from nested islands and compares routes manifest, bundle manifest, and generated route artifacts.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {},
|
|
88
|
+
required: [],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "mandu.runtime.contractOptions",
|
|
94
94
|
annotations: {
|
|
95
95
|
readOnlyHint: true,
|
|
96
96
|
},
|
|
@@ -213,10 +213,10 @@ async function readFileContent(filePath: string): Promise<string | null> {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
export function runtimeTools(projectRoot: string) {
|
|
217
|
-
const paths = getProjectPaths(projectRoot);
|
|
218
|
-
|
|
219
|
-
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
216
|
+
export function runtimeTools(projectRoot: string) {
|
|
217
|
+
const paths = getProjectPaths(projectRoot);
|
|
218
|
+
|
|
219
|
+
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
220
220
|
"mandu.runtime.config": async () => {
|
|
221
221
|
return {
|
|
222
222
|
defaults: {
|
|
@@ -279,139 +279,139 @@ export default Mandu.contract({
|
|
|
279
279
|
response: { ... },
|
|
280
280
|
});`,
|
|
281
281
|
},
|
|
282
|
-
};
|
|
283
|
-
},
|
|
284
|
-
|
|
285
|
-
"mandu.runtime.probe": async (args: Record<string, unknown>) => {
|
|
286
|
-
const baseUrl = await resolveDevServerBaseUrl(projectRoot, args);
|
|
287
|
-
const timeoutMs = normalizeTimeout(args.timeoutMs);
|
|
288
|
-
const checkBundleUrls = args.checkBundleUrls !== false;
|
|
289
|
-
const includeDynamic = args.includeDynamic === true;
|
|
290
|
-
const routeIdFilter = Array.isArray(args.routeIds)
|
|
291
|
-
? new Set(args.routeIds.filter((id): id is string => typeof id === "string"))
|
|
292
|
-
: null;
|
|
293
|
-
|
|
294
|
-
const result = await loadManifest(paths.manifestPath);
|
|
295
|
-
if (!result.success || !result.data) {
|
|
296
|
-
return {
|
|
297
|
-
success: false,
|
|
298
|
-
baseUrl,
|
|
299
|
-
error: result.errors,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const pageRoutes = result.data.routes
|
|
304
|
-
.filter((route) => route.kind === "page")
|
|
305
|
-
.filter((route) => !routeIdFilter || routeIdFilter.has(route.id))
|
|
306
|
-
.map((route) => ({
|
|
307
|
-
route,
|
|
308
|
-
samplePath: samplePathForPattern(route.pattern, includeDynamic),
|
|
309
|
-
}));
|
|
310
|
-
|
|
311
|
-
const skipped = pageRoutes
|
|
312
|
-
.filter((entry) => entry.samplePath === null)
|
|
313
|
-
.map((entry) => ({
|
|
314
|
-
routeId: entry.route.id,
|
|
315
|
-
pattern: entry.route.pattern,
|
|
316
|
-
reason: "dynamic_route",
|
|
317
|
-
}));
|
|
318
|
-
const probes = await Promise.all(
|
|
319
|
-
pageRoutes
|
|
320
|
-
.filter((entry): entry is typeof entry & { samplePath: string } => entry.samplePath !== null)
|
|
321
|
-
.map((entry) =>
|
|
322
|
-
probeRoute({
|
|
323
|
-
baseUrl,
|
|
324
|
-
route: entry.route,
|
|
325
|
-
samplePath: entry.samplePath,
|
|
326
|
-
timeoutMs,
|
|
327
|
-
checkBundleUrls,
|
|
328
|
-
})
|
|
329
|
-
)
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
const failures = probes.flatMap((probe) =>
|
|
333
|
-
probe.failures.map((failure) => ({
|
|
334
|
-
routeId: probe.routeId,
|
|
335
|
-
pattern: probe.pattern,
|
|
336
|
-
path: probe.path,
|
|
337
|
-
...failure,
|
|
338
|
-
}))
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
success: failures.length === 0,
|
|
343
|
-
baseUrl,
|
|
344
|
-
checkedRoutes: probes.length,
|
|
345
|
-
skippedRoutes: skipped.length,
|
|
346
|
-
failureCount: failures.length,
|
|
347
|
-
failures,
|
|
348
|
-
routes: probes,
|
|
349
|
-
skipped,
|
|
350
|
-
};
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
"mandu.runtime.status": async () => {
|
|
354
|
-
const result = await loadManifest(paths.manifestPath);
|
|
355
|
-
if (!result.success || !result.data) {
|
|
356
|
-
return {
|
|
357
|
-
success: false,
|
|
358
|
-
error: result.errors,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const bundleManifest = await readBundleManifest(projectRoot);
|
|
363
|
-
const pageRoutes = result.data.routes.filter((route) => route.kind === "page");
|
|
364
|
-
const pageClientMounts = await Promise.all(
|
|
365
|
-
pageRoutes.map(async (route) =>
|
|
366
|
-
describePageClientMount(projectRoot, route, bundleManifest),
|
|
367
|
-
),
|
|
368
|
-
);
|
|
369
|
-
const nestedIslands = Object.entries(bundleManifest?.islands ?? {}).map(([id, island]) => ({
|
|
370
|
-
islandId: id,
|
|
371
|
-
routeId: island.route,
|
|
372
|
-
bundleUrl: island.js,
|
|
373
|
-
priority: island.priority,
|
|
374
|
-
}));
|
|
375
|
-
const partials = Object.entries(bundleManifest?.partials ?? {}).map(([id, partial]) => ({
|
|
376
|
-
partialId: id,
|
|
377
|
-
bundleUrl: partial.js,
|
|
378
|
-
priority: partial.priority,
|
|
379
|
-
}));
|
|
380
|
-
const consistencyChecks = buildRuntimeConsistencyChecks(pageClientMounts, bundleManifest);
|
|
381
|
-
const failedChecks = consistencyChecks.filter((check) => check.status === "fail");
|
|
382
|
-
const brokenMounts = pageClientMounts.filter((mount) => mount.status === "broken");
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
success: failedChecks.length === 0 && brokenMounts.length === 0,
|
|
386
|
-
terminology: {
|
|
387
|
-
pageClientMount:
|
|
388
|
-
"A page route whose whole page is hydrated from a route-level clientModule and route bundle.",
|
|
389
|
-
island:
|
|
390
|
-
"A nested or route-local island bundle listed in .mandu/manifest.json islands, distinct from page client mounts.",
|
|
391
|
-
partial:
|
|
392
|
-
"An inline partial hydration boundary listed in .mandu/manifest.json partials.",
|
|
393
|
-
},
|
|
394
|
-
sources: {
|
|
395
|
-
routesManifest: ".mandu/routes.manifest.json",
|
|
396
|
-
bundleManifest: bundleManifest ? ".mandu/manifest.json" : null,
|
|
397
|
-
generatedRoutes: ".mandu/generated/web/routes/*.route.tsx",
|
|
398
|
-
},
|
|
399
|
-
summary: {
|
|
400
|
-
totalPages: pageRoutes.length,
|
|
401
|
-
pageClientMountCount: pageClientMounts.filter((mount) => mount.needsClientMount).length,
|
|
402
|
-
brokenPageClientMountCount: brokenMounts.length,
|
|
403
|
-
nestedIslandCount: nestedIslands.length,
|
|
404
|
-
partialCount: partials.length,
|
|
405
|
-
failedConsistencyCheckCount: failedChecks.length,
|
|
406
|
-
},
|
|
407
|
-
pageClientMounts,
|
|
408
|
-
islands: nestedIslands,
|
|
409
|
-
partials,
|
|
410
|
-
consistencyChecks,
|
|
411
|
-
};
|
|
412
|
-
},
|
|
413
|
-
|
|
414
|
-
"mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
"mandu.runtime.probe": async (args: Record<string, unknown>) => {
|
|
286
|
+
const baseUrl = await resolveDevServerBaseUrl(projectRoot, args);
|
|
287
|
+
const timeoutMs = normalizeTimeout(args.timeoutMs);
|
|
288
|
+
const checkBundleUrls = args.checkBundleUrls !== false;
|
|
289
|
+
const includeDynamic = args.includeDynamic === true;
|
|
290
|
+
const routeIdFilter = Array.isArray(args.routeIds)
|
|
291
|
+
? new Set(args.routeIds.filter((id): id is string => typeof id === "string"))
|
|
292
|
+
: null;
|
|
293
|
+
|
|
294
|
+
const result = await loadManifest(paths.manifestPath);
|
|
295
|
+
if (!result.success || !result.data) {
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
baseUrl,
|
|
299
|
+
error: result.errors,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const pageRoutes = result.data.routes
|
|
304
|
+
.filter((route) => route.kind === "page")
|
|
305
|
+
.filter((route) => !routeIdFilter || routeIdFilter.has(route.id))
|
|
306
|
+
.map((route) => ({
|
|
307
|
+
route,
|
|
308
|
+
samplePath: samplePathForPattern(route.pattern, includeDynamic),
|
|
309
|
+
}));
|
|
310
|
+
|
|
311
|
+
const skipped = pageRoutes
|
|
312
|
+
.filter((entry) => entry.samplePath === null)
|
|
313
|
+
.map((entry) => ({
|
|
314
|
+
routeId: entry.route.id,
|
|
315
|
+
pattern: entry.route.pattern,
|
|
316
|
+
reason: "dynamic_route",
|
|
317
|
+
}));
|
|
318
|
+
const probes = await Promise.all(
|
|
319
|
+
pageRoutes
|
|
320
|
+
.filter((entry): entry is typeof entry & { samplePath: string } => entry.samplePath !== null)
|
|
321
|
+
.map((entry) =>
|
|
322
|
+
probeRoute({
|
|
323
|
+
baseUrl,
|
|
324
|
+
route: entry.route,
|
|
325
|
+
samplePath: entry.samplePath,
|
|
326
|
+
timeoutMs,
|
|
327
|
+
checkBundleUrls,
|
|
328
|
+
})
|
|
329
|
+
)
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const failures = probes.flatMap((probe) =>
|
|
333
|
+
probe.failures.map((failure) => ({
|
|
334
|
+
routeId: probe.routeId,
|
|
335
|
+
pattern: probe.pattern,
|
|
336
|
+
path: probe.path,
|
|
337
|
+
...failure,
|
|
338
|
+
}))
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
success: failures.length === 0,
|
|
343
|
+
baseUrl,
|
|
344
|
+
checkedRoutes: probes.length,
|
|
345
|
+
skippedRoutes: skipped.length,
|
|
346
|
+
failureCount: failures.length,
|
|
347
|
+
failures,
|
|
348
|
+
routes: probes,
|
|
349
|
+
skipped,
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
"mandu.runtime.status": async () => {
|
|
354
|
+
const result = await loadManifest(paths.manifestPath);
|
|
355
|
+
if (!result.success || !result.data) {
|
|
356
|
+
return {
|
|
357
|
+
success: false,
|
|
358
|
+
error: result.errors,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const bundleManifest = await readBundleManifest(projectRoot);
|
|
363
|
+
const pageRoutes = result.data.routes.filter((route) => route.kind === "page");
|
|
364
|
+
const pageClientMounts = await Promise.all(
|
|
365
|
+
pageRoutes.map(async (route) =>
|
|
366
|
+
describePageClientMount(projectRoot, route, bundleManifest),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
const nestedIslands = Object.entries(bundleManifest?.islands ?? {}).map(([id, island]) => ({
|
|
370
|
+
islandId: id,
|
|
371
|
+
routeId: island.route,
|
|
372
|
+
bundleUrl: island.js,
|
|
373
|
+
priority: island.priority,
|
|
374
|
+
}));
|
|
375
|
+
const partials = Object.entries(bundleManifest?.partials ?? {}).map(([id, partial]) => ({
|
|
376
|
+
partialId: id,
|
|
377
|
+
bundleUrl: partial.js,
|
|
378
|
+
priority: partial.priority,
|
|
379
|
+
}));
|
|
380
|
+
const consistencyChecks = buildRuntimeConsistencyChecks(pageClientMounts, bundleManifest);
|
|
381
|
+
const failedChecks = consistencyChecks.filter((check) => check.status === "fail");
|
|
382
|
+
const brokenMounts = pageClientMounts.filter((mount) => mount.status === "broken");
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
success: failedChecks.length === 0 && brokenMounts.length === 0,
|
|
386
|
+
terminology: {
|
|
387
|
+
pageClientMount:
|
|
388
|
+
"A page route whose whole page is hydrated from a route-level clientModule and route bundle.",
|
|
389
|
+
island:
|
|
390
|
+
"A nested or route-local island bundle listed in .mandu/manifest.json islands, distinct from page client mounts.",
|
|
391
|
+
partial:
|
|
392
|
+
"An inline partial hydration boundary listed in .mandu/manifest.json partials.",
|
|
393
|
+
},
|
|
394
|
+
sources: {
|
|
395
|
+
routesManifest: ".mandu/routes.manifest.json",
|
|
396
|
+
bundleManifest: bundleManifest ? ".mandu/manifest.json" : null,
|
|
397
|
+
generatedRoutes: ".mandu/generated/web/routes/*.route.tsx",
|
|
398
|
+
},
|
|
399
|
+
summary: {
|
|
400
|
+
totalPages: pageRoutes.length,
|
|
401
|
+
pageClientMountCount: pageClientMounts.filter((mount) => mount.needsClientMount).length,
|
|
402
|
+
brokenPageClientMountCount: brokenMounts.length,
|
|
403
|
+
nestedIslandCount: nestedIslands.length,
|
|
404
|
+
partialCount: partials.length,
|
|
405
|
+
failedConsistencyCheckCount: failedChecks.length,
|
|
406
|
+
},
|
|
407
|
+
pageClientMounts,
|
|
408
|
+
islands: nestedIslands,
|
|
409
|
+
partials,
|
|
410
|
+
consistencyChecks,
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
"mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
|
|
415
415
|
const { routeId } = args as { routeId: string };
|
|
416
416
|
|
|
417
417
|
const result = await loadManifest(paths.manifestPath);
|
|
@@ -713,376 +713,376 @@ export const appLogger = logger(${JSON.stringify(config, null, 2)});
|
|
|
713
713
|
// Backward-compatible aliases (deprecated)
|
|
714
714
|
handlers["mandu_get_runtime_config"] = handlers["mandu.runtime.config"];
|
|
715
715
|
handlers["mandu_set_contract_normalize"] = handlers["mandu.runtime.setNormalize"];
|
|
716
|
-
handlers["mandu_get_contract_options"] = handlers["mandu.runtime.contractOptions"];
|
|
717
|
-
handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
|
|
718
|
-
handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
|
|
719
|
-
handlers["mandu_runtime_probe"] = handlers["mandu.runtime.probe"];
|
|
720
|
-
handlers["mandu_runtime_status"] = handlers["mandu.runtime.status"];
|
|
721
|
-
|
|
722
|
-
return handlers;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
type PageRouteForStatus = Parameters<typeof needsHydration>[0] & {
|
|
726
|
-
id: string;
|
|
727
|
-
pattern: string;
|
|
728
|
-
clientModule?: string;
|
|
729
|
-
};
|
|
730
|
-
|
|
731
|
-
type PageClientMountStatus = {
|
|
732
|
-
routeId: string;
|
|
733
|
-
pattern: string;
|
|
734
|
-
needsClientMount: boolean;
|
|
735
|
-
hasClientModule: boolean;
|
|
736
|
-
clientModule: string | null;
|
|
737
|
-
hydration: ReturnType<typeof getRouteHydration>;
|
|
738
|
-
bundleUrl: string | null;
|
|
739
|
-
status: "static" | "pending" | "healthy" | "broken";
|
|
740
|
-
reasons: string[];
|
|
741
|
-
generatedRoute: GeneratedRouteInspection;
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
type GeneratedRouteInspection = {
|
|
745
|
-
path: string;
|
|
746
|
-
exists: boolean;
|
|
747
|
-
kind: "missing" | "client_mount" | "placeholder" | "custom";
|
|
748
|
-
referencesClientModule: boolean;
|
|
749
|
-
callsIslandRender: boolean;
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
type RuntimeConsistencyCheck = {
|
|
753
|
-
check: string;
|
|
754
|
-
status: "pass" | "fail" | "skip";
|
|
755
|
-
failingRoutes?: string[];
|
|
756
|
-
reason?: string;
|
|
757
|
-
};
|
|
758
|
-
|
|
759
|
-
async function readBundleManifest(projectRoot: string): Promise<BundleManifest | null> {
|
|
760
|
-
const filePath = path.join(projectRoot, ".mandu/manifest.json");
|
|
761
|
-
try {
|
|
762
|
-
const raw = await Bun.file(filePath).text();
|
|
763
|
-
return JSON.parse(raw) as BundleManifest;
|
|
764
|
-
} catch {
|
|
765
|
-
return null;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
async function describePageClientMount(
|
|
770
|
-
projectRoot: string,
|
|
771
|
-
route: PageRouteForStatus,
|
|
772
|
-
bundleManifest: BundleManifest | null,
|
|
773
|
-
): Promise<PageClientMountStatus> {
|
|
774
|
-
const hydration = getRouteHydration(route);
|
|
775
|
-
const needsClientMount = needsHydration(route);
|
|
776
|
-
const bundle = bundleManifest?.bundles?.[route.id] ?? null;
|
|
777
|
-
const generatedRoute = await inspectGeneratedRoute(projectRoot, route);
|
|
778
|
-
const reasons: string[] = [];
|
|
779
|
-
|
|
780
|
-
if (needsClientMount && !route.clientModule) {
|
|
781
|
-
reasons.push("missing_client_module");
|
|
782
|
-
}
|
|
783
|
-
if (needsClientMount && route.clientModule && bundleManifest && !bundle) {
|
|
784
|
-
reasons.push("missing_bundle");
|
|
785
|
-
}
|
|
786
|
-
if (needsClientMount && route.clientModule && generatedRoute.exists && !generatedRoute.referencesClientModule) {
|
|
787
|
-
reasons.push("generated_route_not_using_client_module");
|
|
788
|
-
}
|
|
789
|
-
if (needsClientMount && !route.clientModule && generatedRoute.kind === "placeholder") {
|
|
790
|
-
reasons.push("generated_placeholder_for_hydrating_route");
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const status = !needsClientMount
|
|
794
|
-
? "static"
|
|
795
|
-
: reasons.length > 0
|
|
796
|
-
? "broken"
|
|
797
|
-
: bundleManifest
|
|
798
|
-
? "healthy"
|
|
799
|
-
: "pending";
|
|
800
|
-
|
|
801
|
-
return {
|
|
802
|
-
routeId: route.id,
|
|
803
|
-
pattern: route.pattern,
|
|
804
|
-
needsClientMount,
|
|
805
|
-
hasClientModule: !!route.clientModule,
|
|
806
|
-
clientModule: route.clientModule ?? null,
|
|
807
|
-
hydration,
|
|
808
|
-
bundleUrl: bundle?.js ?? null,
|
|
809
|
-
status,
|
|
810
|
-
reasons,
|
|
811
|
-
generatedRoute,
|
|
812
|
-
};
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
async function inspectGeneratedRoute(
|
|
816
|
-
projectRoot: string,
|
|
817
|
-
route: PageRouteForStatus,
|
|
818
|
-
): Promise<GeneratedRouteInspection> {
|
|
819
|
-
const relPath = `.mandu/generated/web/routes/${route.id}.route.tsx`;
|
|
820
|
-
const filePath = path.join(projectRoot, relPath);
|
|
821
|
-
let source: string;
|
|
822
|
-
try {
|
|
823
|
-
source = await Bun.file(filePath).text();
|
|
824
|
-
} catch {
|
|
825
|
-
return {
|
|
826
|
-
path: relPath,
|
|
827
|
-
exists: false,
|
|
828
|
-
kind: "missing",
|
|
829
|
-
referencesClientModule: false,
|
|
830
|
-
callsIslandRender: false,
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
const normalized = source.replace(/\\/g, "/");
|
|
835
|
-
const clientModule = route.clientModule?.replace(/\\/g, "/") ?? null;
|
|
836
|
-
const referencesClientModule = clientModule ? normalized.includes(`Client Module: ${clientModule}`) : false;
|
|
837
|
-
const callsIslandRender = normalized.includes("islandModule.definition.render");
|
|
838
|
-
const placeholder =
|
|
839
|
-
normalized.includes('React.createElement("h1", null') &&
|
|
840
|
-
normalized.includes(`Route ID: ${route.id}`);
|
|
841
|
-
|
|
842
|
-
return {
|
|
843
|
-
path: relPath,
|
|
844
|
-
exists: true,
|
|
845
|
-
kind: referencesClientModule && callsIslandRender
|
|
846
|
-
? "client_mount"
|
|
847
|
-
: placeholder
|
|
848
|
-
? "placeholder"
|
|
849
|
-
: "custom",
|
|
850
|
-
referencesClientModule,
|
|
851
|
-
callsIslandRender,
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function buildRuntimeConsistencyChecks(
|
|
856
|
-
pageClientMounts: PageClientMountStatus[],
|
|
857
|
-
bundleManifest: BundleManifest | null,
|
|
858
|
-
): RuntimeConsistencyCheck[] {
|
|
859
|
-
const activeMounts = pageClientMounts.filter((mount) => mount.needsClientMount);
|
|
860
|
-
const missingClientModule = activeMounts
|
|
861
|
-
.filter((mount) => !mount.hasClientModule)
|
|
862
|
-
.map((mount) => mount.routeId);
|
|
863
|
-
const missingBundle = activeMounts
|
|
864
|
-
.filter((mount) => mount.hasClientModule && bundleManifest && !mount.bundleUrl)
|
|
865
|
-
.map((mount) => mount.routeId);
|
|
866
|
-
const generatedMismatch = activeMounts
|
|
867
|
-
.filter((mount) => mount.generatedRoute.exists && mount.generatedRoute.kind !== "client_mount")
|
|
868
|
-
.map((mount) => mount.routeId);
|
|
869
|
-
|
|
870
|
-
return [
|
|
871
|
-
{
|
|
872
|
-
check: "hydrating-routes-have-client-module",
|
|
873
|
-
status: missingClientModule.length > 0 ? "fail" : "pass",
|
|
874
|
-
failingRoutes: missingClientModule,
|
|
875
|
-
},
|
|
876
|
-
{
|
|
877
|
-
check: "client-modules-have-route-bundles",
|
|
878
|
-
status: !bundleManifest ? "skip" : missingBundle.length > 0 ? "fail" : "pass",
|
|
879
|
-
failingRoutes: missingBundle,
|
|
880
|
-
reason: !bundleManifest ? "No .mandu/manifest.json found. Run mandu.build first." : undefined,
|
|
881
|
-
},
|
|
882
|
-
{
|
|
883
|
-
check: "generated-routes-match-client-modules",
|
|
884
|
-
status: generatedMismatch.length > 0 ? "fail" : "pass",
|
|
885
|
-
failingRoutes: generatedMismatch,
|
|
886
|
-
},
|
|
887
|
-
{
|
|
888
|
-
check: "terminology-separated",
|
|
889
|
-
status: "pass",
|
|
890
|
-
reason: "pageClientMounts, islands, and partials are reported as separate collections.",
|
|
891
|
-
},
|
|
892
|
-
];
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function insertAfter(content: string, search: string): boolean {
|
|
896
|
-
return content.includes(search);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
async function resolveDevServerBaseUrl(
|
|
900
|
-
projectRoot: string,
|
|
901
|
-
args: { baseURL?: unknown; port?: unknown } = {},
|
|
902
|
-
): Promise<string> {
|
|
903
|
-
if (typeof args.baseURL === "string" && args.baseURL.trim()) {
|
|
904
|
-
return args.baseURL.trim().replace(/\/+$/, "");
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
const explicitPort = normalizePort(args.port);
|
|
908
|
-
if (explicitPort) {
|
|
909
|
-
return `http://localhost:${explicitPort}`;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const control = await readRuntimeControl(projectRoot);
|
|
913
|
-
if (control?.baseUrl) {
|
|
914
|
-
return control.baseUrl.replace(/\/+$/, "");
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
let port: number | undefined;
|
|
918
|
-
const serverState = getDevServerState();
|
|
919
|
-
if (serverState) {
|
|
920
|
-
for (const line of serverState.output) {
|
|
921
|
-
const portMatch = line.match(/https?:\/\/localhost:(\d+)/);
|
|
922
|
-
if (portMatch) {
|
|
923
|
-
port = Number.parseInt(portMatch[1], 10);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
if (!port) {
|
|
929
|
-
const config = await loadManduConfig(projectRoot);
|
|
930
|
-
port = config.server?.port ?? 3333;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
return `http://localhost:${port}`;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
function normalizePort(value: unknown): number | undefined {
|
|
937
|
-
const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
938
|
-
if (!Number.isInteger(raw) || raw < 1 || raw > 65535) return undefined;
|
|
939
|
-
return raw;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
function normalizeTimeout(value: unknown): number {
|
|
943
|
-
const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
944
|
-
if (!Number.isInteger(raw) || raw < 100 || raw > 30_000) return 3000;
|
|
945
|
-
return raw;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
function samplePathForPattern(pattern: string, includeDynamic: boolean): string | null {
|
|
949
|
-
if (!includeDynamic && /(^|\/):[^/]+/.test(pattern)) return null;
|
|
950
|
-
const sampled = pattern
|
|
951
|
-
.replace(/:([A-Za-z0-9_]+)/g, "__mandu_probe__")
|
|
952
|
-
.replace(/\*+/g, "__mandu_probe__");
|
|
953
|
-
return sampled.startsWith("/") ? sampled : `/${sampled}`;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
interface IslandMarker {
|
|
957
|
-
id: string | null;
|
|
958
|
-
src: string | null;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function extractIslandMarkers(html: string): IslandMarker[] {
|
|
962
|
-
const markers: IslandMarker[] = [];
|
|
963
|
-
const tagPattern = /<[^>]*\bdata-mandu-island(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?[^>]*>/gi;
|
|
964
|
-
for (const match of html.matchAll(tagPattern)) {
|
|
965
|
-
const tag = match[0];
|
|
966
|
-
markers.push({
|
|
967
|
-
id: readHtmlAttr(tag, "data-mandu-island"),
|
|
968
|
-
src: readHtmlAttr(tag, "data-mandu-src"),
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
return markers;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function readHtmlAttr(tag: string, attr: string): string | null {
|
|
975
|
-
const pattern = new RegExp(
|
|
976
|
-
`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
977
|
-
"i",
|
|
978
|
-
);
|
|
979
|
-
const match = tag.match(pattern);
|
|
980
|
-
return match ? (match[1] ?? match[2] ?? match[3] ?? "") : null;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
async function probeRoute({
|
|
984
|
-
baseUrl,
|
|
985
|
-
route,
|
|
986
|
-
samplePath,
|
|
987
|
-
timeoutMs,
|
|
988
|
-
checkBundleUrls,
|
|
989
|
-
}: {
|
|
990
|
-
baseUrl: string;
|
|
991
|
-
route: { id: string; pattern: string; clientModule?: string; hydration?: unknown };
|
|
992
|
-
samplePath: string;
|
|
993
|
-
timeoutMs: number;
|
|
994
|
-
checkBundleUrls: boolean;
|
|
995
|
-
}): Promise<{
|
|
996
|
-
routeId: string;
|
|
997
|
-
pattern: string;
|
|
998
|
-
path: string;
|
|
999
|
-
status: number | null;
|
|
1000
|
-
islandCount: number;
|
|
1001
|
-
failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }>;
|
|
1002
|
-
}> {
|
|
1003
|
-
const failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }> = [];
|
|
1004
|
-
const url = new URL(samplePath, `${baseUrl}/`);
|
|
1005
|
-
let response: Response;
|
|
1006
|
-
try {
|
|
1007
|
-
response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1008
|
-
} catch (error) {
|
|
1009
|
-
return {
|
|
1010
|
-
routeId: route.id,
|
|
1011
|
-
pattern: route.pattern,
|
|
1012
|
-
path: samplePath,
|
|
1013
|
-
status: null,
|
|
1014
|
-
islandCount: 0,
|
|
1015
|
-
failures: [{
|
|
1016
|
-
code: "page_fetch_failed",
|
|
1017
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1018
|
-
status: null,
|
|
1019
|
-
}],
|
|
1020
|
-
};
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
if (!response.ok) {
|
|
1024
|
-
failures.push({
|
|
1025
|
-
code: "page_status",
|
|
1026
|
-
message: `Page responded with HTTP ${response.status}`,
|
|
1027
|
-
status: response.status,
|
|
1028
|
-
});
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
const html = await response.text();
|
|
1032
|
-
const markers = extractIslandMarkers(html);
|
|
1033
|
-
const hasRouteIsland = markers.some((marker) => marker.id === route.id);
|
|
1034
|
-
if (route.clientModule && needsHydration(route as Parameters<typeof needsHydration>[0]) && !hasRouteIsland) {
|
|
1035
|
-
failures.push({
|
|
1036
|
-
code: "missing_route_island_marker",
|
|
1037
|
-
message: `Route has clientModule but HTML does not contain data-mandu-island="${route.id}"`,
|
|
1038
|
-
islandId: route.id,
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
for (const marker of markers) {
|
|
1043
|
-
if (!marker.src || marker.src.trim().length === 0) {
|
|
1044
|
-
failures.push({
|
|
1045
|
-
code: "empty_island_src",
|
|
1046
|
-
message: "Island marker has empty data-mandu-src",
|
|
1047
|
-
islandId: marker.id,
|
|
1048
|
-
src: marker.src,
|
|
1049
|
-
});
|
|
1050
|
-
continue;
|
|
1051
|
-
}
|
|
1052
|
-
if (!checkBundleUrls) continue;
|
|
1053
|
-
|
|
1054
|
-
const bundleUrl = new URL(marker.src, `${baseUrl}/`);
|
|
1055
|
-
try {
|
|
1056
|
-
const bundleResponse = await fetch(bundleUrl, {
|
|
1057
|
-
method: "GET",
|
|
1058
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
1059
|
-
});
|
|
1060
|
-
if (!bundleResponse.ok) {
|
|
1061
|
-
failures.push({
|
|
1062
|
-
code: "bundle_status",
|
|
1063
|
-
message: `Island bundle responded with HTTP ${bundleResponse.status}`,
|
|
1064
|
-
islandId: marker.id,
|
|
1065
|
-
src: marker.src,
|
|
1066
|
-
status: bundleResponse.status,
|
|
1067
|
-
});
|
|
1068
|
-
}
|
|
1069
|
-
} catch (error) {
|
|
1070
|
-
failures.push({
|
|
1071
|
-
code: "bundle_fetch_failed",
|
|
1072
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1073
|
-
islandId: marker.id,
|
|
1074
|
-
src: marker.src,
|
|
1075
|
-
status: null,
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
return {
|
|
1081
|
-
routeId: route.id,
|
|
1082
|
-
pattern: route.pattern,
|
|
1083
|
-
path: samplePath,
|
|
1084
|
-
status: response.status,
|
|
1085
|
-
islandCount: markers.length,
|
|
1086
|
-
failures,
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
716
|
+
handlers["mandu_get_contract_options"] = handlers["mandu.runtime.contractOptions"];
|
|
717
|
+
handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
|
|
718
|
+
handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
|
|
719
|
+
handlers["mandu_runtime_probe"] = handlers["mandu.runtime.probe"];
|
|
720
|
+
handlers["mandu_runtime_status"] = handlers["mandu.runtime.status"];
|
|
721
|
+
|
|
722
|
+
return handlers;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
type PageRouteForStatus = Parameters<typeof needsHydration>[0] & {
|
|
726
|
+
id: string;
|
|
727
|
+
pattern: string;
|
|
728
|
+
clientModule?: string;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
type PageClientMountStatus = {
|
|
732
|
+
routeId: string;
|
|
733
|
+
pattern: string;
|
|
734
|
+
needsClientMount: boolean;
|
|
735
|
+
hasClientModule: boolean;
|
|
736
|
+
clientModule: string | null;
|
|
737
|
+
hydration: ReturnType<typeof getRouteHydration>;
|
|
738
|
+
bundleUrl: string | null;
|
|
739
|
+
status: "static" | "pending" | "healthy" | "broken";
|
|
740
|
+
reasons: string[];
|
|
741
|
+
generatedRoute: GeneratedRouteInspection;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
type GeneratedRouteInspection = {
|
|
745
|
+
path: string;
|
|
746
|
+
exists: boolean;
|
|
747
|
+
kind: "missing" | "client_mount" | "placeholder" | "custom";
|
|
748
|
+
referencesClientModule: boolean;
|
|
749
|
+
callsIslandRender: boolean;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
type RuntimeConsistencyCheck = {
|
|
753
|
+
check: string;
|
|
754
|
+
status: "pass" | "fail" | "skip";
|
|
755
|
+
failingRoutes?: string[];
|
|
756
|
+
reason?: string;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
async function readBundleManifest(projectRoot: string): Promise<BundleManifest | null> {
|
|
760
|
+
const filePath = path.join(projectRoot, ".mandu/manifest.json");
|
|
761
|
+
try {
|
|
762
|
+
const raw = await Bun.file(filePath).text();
|
|
763
|
+
return JSON.parse(raw) as BundleManifest;
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function describePageClientMount(
|
|
770
|
+
projectRoot: string,
|
|
771
|
+
route: PageRouteForStatus,
|
|
772
|
+
bundleManifest: BundleManifest | null,
|
|
773
|
+
): Promise<PageClientMountStatus> {
|
|
774
|
+
const hydration = getRouteHydration(route);
|
|
775
|
+
const needsClientMount = needsHydration(route);
|
|
776
|
+
const bundle = bundleManifest?.bundles?.[route.id] ?? null;
|
|
777
|
+
const generatedRoute = await inspectGeneratedRoute(projectRoot, route);
|
|
778
|
+
const reasons: string[] = [];
|
|
779
|
+
|
|
780
|
+
if (needsClientMount && !route.clientModule) {
|
|
781
|
+
reasons.push("missing_client_module");
|
|
782
|
+
}
|
|
783
|
+
if (needsClientMount && route.clientModule && bundleManifest && !bundle) {
|
|
784
|
+
reasons.push("missing_bundle");
|
|
785
|
+
}
|
|
786
|
+
if (needsClientMount && route.clientModule && generatedRoute.exists && !generatedRoute.referencesClientModule) {
|
|
787
|
+
reasons.push("generated_route_not_using_client_module");
|
|
788
|
+
}
|
|
789
|
+
if (needsClientMount && !route.clientModule && generatedRoute.kind === "placeholder") {
|
|
790
|
+
reasons.push("generated_placeholder_for_hydrating_route");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const status = !needsClientMount
|
|
794
|
+
? "static"
|
|
795
|
+
: reasons.length > 0
|
|
796
|
+
? "broken"
|
|
797
|
+
: bundleManifest
|
|
798
|
+
? "healthy"
|
|
799
|
+
: "pending";
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
routeId: route.id,
|
|
803
|
+
pattern: route.pattern,
|
|
804
|
+
needsClientMount,
|
|
805
|
+
hasClientModule: !!route.clientModule,
|
|
806
|
+
clientModule: route.clientModule ?? null,
|
|
807
|
+
hydration,
|
|
808
|
+
bundleUrl: bundle?.js ?? null,
|
|
809
|
+
status,
|
|
810
|
+
reasons,
|
|
811
|
+
generatedRoute,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function inspectGeneratedRoute(
|
|
816
|
+
projectRoot: string,
|
|
817
|
+
route: PageRouteForStatus,
|
|
818
|
+
): Promise<GeneratedRouteInspection> {
|
|
819
|
+
const relPath = `.mandu/generated/web/routes/${route.id}.route.tsx`;
|
|
820
|
+
const filePath = path.join(projectRoot, relPath);
|
|
821
|
+
let source: string;
|
|
822
|
+
try {
|
|
823
|
+
source = await Bun.file(filePath).text();
|
|
824
|
+
} catch {
|
|
825
|
+
return {
|
|
826
|
+
path: relPath,
|
|
827
|
+
exists: false,
|
|
828
|
+
kind: "missing",
|
|
829
|
+
referencesClientModule: false,
|
|
830
|
+
callsIslandRender: false,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const normalized = source.replace(/\\/g, "/");
|
|
835
|
+
const clientModule = route.clientModule?.replace(/\\/g, "/") ?? null;
|
|
836
|
+
const referencesClientModule = clientModule ? normalized.includes(`Client Module: ${clientModule}`) : false;
|
|
837
|
+
const callsIslandRender = normalized.includes("islandModule.definition.render");
|
|
838
|
+
const placeholder =
|
|
839
|
+
normalized.includes('React.createElement("h1", null') &&
|
|
840
|
+
normalized.includes(`Route ID: ${route.id}`);
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
path: relPath,
|
|
844
|
+
exists: true,
|
|
845
|
+
kind: referencesClientModule && callsIslandRender
|
|
846
|
+
? "client_mount"
|
|
847
|
+
: placeholder
|
|
848
|
+
? "placeholder"
|
|
849
|
+
: "custom",
|
|
850
|
+
referencesClientModule,
|
|
851
|
+
callsIslandRender,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function buildRuntimeConsistencyChecks(
|
|
856
|
+
pageClientMounts: PageClientMountStatus[],
|
|
857
|
+
bundleManifest: BundleManifest | null,
|
|
858
|
+
): RuntimeConsistencyCheck[] {
|
|
859
|
+
const activeMounts = pageClientMounts.filter((mount) => mount.needsClientMount);
|
|
860
|
+
const missingClientModule = activeMounts
|
|
861
|
+
.filter((mount) => !mount.hasClientModule)
|
|
862
|
+
.map((mount) => mount.routeId);
|
|
863
|
+
const missingBundle = activeMounts
|
|
864
|
+
.filter((mount) => mount.hasClientModule && bundleManifest && !mount.bundleUrl)
|
|
865
|
+
.map((mount) => mount.routeId);
|
|
866
|
+
const generatedMismatch = activeMounts
|
|
867
|
+
.filter((mount) => mount.generatedRoute.exists && mount.generatedRoute.kind !== "client_mount")
|
|
868
|
+
.map((mount) => mount.routeId);
|
|
869
|
+
|
|
870
|
+
return [
|
|
871
|
+
{
|
|
872
|
+
check: "hydrating-routes-have-client-module",
|
|
873
|
+
status: missingClientModule.length > 0 ? "fail" : "pass",
|
|
874
|
+
failingRoutes: missingClientModule,
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
check: "client-modules-have-route-bundles",
|
|
878
|
+
status: !bundleManifest ? "skip" : missingBundle.length > 0 ? "fail" : "pass",
|
|
879
|
+
failingRoutes: missingBundle,
|
|
880
|
+
reason: !bundleManifest ? "No .mandu/manifest.json found. Run mandu.build first." : undefined,
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
check: "generated-routes-match-client-modules",
|
|
884
|
+
status: generatedMismatch.length > 0 ? "fail" : "pass",
|
|
885
|
+
failingRoutes: generatedMismatch,
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
check: "terminology-separated",
|
|
889
|
+
status: "pass",
|
|
890
|
+
reason: "pageClientMounts, islands, and partials are reported as separate collections.",
|
|
891
|
+
},
|
|
892
|
+
];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function insertAfter(content: string, search: string): boolean {
|
|
896
|
+
return content.includes(search);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function resolveDevServerBaseUrl(
|
|
900
|
+
projectRoot: string,
|
|
901
|
+
args: { baseURL?: unknown; port?: unknown } = {},
|
|
902
|
+
): Promise<string> {
|
|
903
|
+
if (typeof args.baseURL === "string" && args.baseURL.trim()) {
|
|
904
|
+
return args.baseURL.trim().replace(/\/+$/, "");
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const explicitPort = normalizePort(args.port);
|
|
908
|
+
if (explicitPort) {
|
|
909
|
+
return `http://localhost:${explicitPort}`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const control = await readRuntimeControl(projectRoot);
|
|
913
|
+
if (control?.baseUrl) {
|
|
914
|
+
return control.baseUrl.replace(/\/+$/, "");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
let port: number | undefined;
|
|
918
|
+
const serverState = getDevServerState();
|
|
919
|
+
if (serverState) {
|
|
920
|
+
for (const line of serverState.output) {
|
|
921
|
+
const portMatch = line.match(/https?:\/\/localhost:(\d+)/);
|
|
922
|
+
if (portMatch) {
|
|
923
|
+
port = Number.parseInt(portMatch[1], 10);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!port) {
|
|
929
|
+
const config = await loadManduConfig(projectRoot);
|
|
930
|
+
port = config.server?.port ?? 3333;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return `http://localhost:${port}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function normalizePort(value: unknown): number | undefined {
|
|
937
|
+
const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
938
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > 65535) return undefined;
|
|
939
|
+
return raw;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function normalizeTimeout(value: unknown): number {
|
|
943
|
+
const raw = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
944
|
+
if (!Number.isInteger(raw) || raw < 100 || raw > 30_000) return 3000;
|
|
945
|
+
return raw;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function samplePathForPattern(pattern: string, includeDynamic: boolean): string | null {
|
|
949
|
+
if (!includeDynamic && /(^|\/):[^/]+/.test(pattern)) return null;
|
|
950
|
+
const sampled = pattern
|
|
951
|
+
.replace(/:([A-Za-z0-9_]+)/g, "__mandu_probe__")
|
|
952
|
+
.replace(/\*+/g, "__mandu_probe__");
|
|
953
|
+
return sampled.startsWith("/") ? sampled : `/${sampled}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
interface IslandMarker {
|
|
957
|
+
id: string | null;
|
|
958
|
+
src: string | null;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function extractIslandMarkers(html: string): IslandMarker[] {
|
|
962
|
+
const markers: IslandMarker[] = [];
|
|
963
|
+
const tagPattern = /<[^>]*\bdata-mandu-island(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?[^>]*>/gi;
|
|
964
|
+
for (const match of html.matchAll(tagPattern)) {
|
|
965
|
+
const tag = match[0];
|
|
966
|
+
markers.push({
|
|
967
|
+
id: readHtmlAttr(tag, "data-mandu-island"),
|
|
968
|
+
src: readHtmlAttr(tag, "data-mandu-src"),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
return markers;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function readHtmlAttr(tag: string, attr: string): string | null {
|
|
975
|
+
const pattern = new RegExp(
|
|
976
|
+
`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
977
|
+
"i",
|
|
978
|
+
);
|
|
979
|
+
const match = tag.match(pattern);
|
|
980
|
+
return match ? (match[1] ?? match[2] ?? match[3] ?? "") : null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function probeRoute({
|
|
984
|
+
baseUrl,
|
|
985
|
+
route,
|
|
986
|
+
samplePath,
|
|
987
|
+
timeoutMs,
|
|
988
|
+
checkBundleUrls,
|
|
989
|
+
}: {
|
|
990
|
+
baseUrl: string;
|
|
991
|
+
route: { id: string; pattern: string; clientModule?: string; hydration?: unknown };
|
|
992
|
+
samplePath: string;
|
|
993
|
+
timeoutMs: number;
|
|
994
|
+
checkBundleUrls: boolean;
|
|
995
|
+
}): Promise<{
|
|
996
|
+
routeId: string;
|
|
997
|
+
pattern: string;
|
|
998
|
+
path: string;
|
|
999
|
+
status: number | null;
|
|
1000
|
+
islandCount: number;
|
|
1001
|
+
failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }>;
|
|
1002
|
+
}> {
|
|
1003
|
+
const failures: Array<{ code: string; message: string; islandId?: string | null; src?: string | null; status?: number | null }> = [];
|
|
1004
|
+
const url = new URL(samplePath, `${baseUrl}/`);
|
|
1005
|
+
let response: Response;
|
|
1006
|
+
try {
|
|
1007
|
+
response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
return {
|
|
1010
|
+
routeId: route.id,
|
|
1011
|
+
pattern: route.pattern,
|
|
1012
|
+
path: samplePath,
|
|
1013
|
+
status: null,
|
|
1014
|
+
islandCount: 0,
|
|
1015
|
+
failures: [{
|
|
1016
|
+
code: "page_fetch_failed",
|
|
1017
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1018
|
+
status: null,
|
|
1019
|
+
}],
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (!response.ok) {
|
|
1024
|
+
failures.push({
|
|
1025
|
+
code: "page_status",
|
|
1026
|
+
message: `Page responded with HTTP ${response.status}`,
|
|
1027
|
+
status: response.status,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const html = await response.text();
|
|
1032
|
+
const markers = extractIslandMarkers(html);
|
|
1033
|
+
const hasRouteIsland = markers.some((marker) => marker.id === route.id);
|
|
1034
|
+
if (route.clientModule && needsHydration(route as Parameters<typeof needsHydration>[0]) && !hasRouteIsland) {
|
|
1035
|
+
failures.push({
|
|
1036
|
+
code: "missing_route_island_marker",
|
|
1037
|
+
message: `Route has clientModule but HTML does not contain data-mandu-island="${route.id}"`,
|
|
1038
|
+
islandId: route.id,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
for (const marker of markers) {
|
|
1043
|
+
if (!marker.src || marker.src.trim().length === 0) {
|
|
1044
|
+
failures.push({
|
|
1045
|
+
code: "empty_island_src",
|
|
1046
|
+
message: "Island marker has empty data-mandu-src",
|
|
1047
|
+
islandId: marker.id,
|
|
1048
|
+
src: marker.src,
|
|
1049
|
+
});
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (!checkBundleUrls) continue;
|
|
1053
|
+
|
|
1054
|
+
const bundleUrl = new URL(marker.src, `${baseUrl}/`);
|
|
1055
|
+
try {
|
|
1056
|
+
const bundleResponse = await fetch(bundleUrl, {
|
|
1057
|
+
method: "GET",
|
|
1058
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1059
|
+
});
|
|
1060
|
+
if (!bundleResponse.ok) {
|
|
1061
|
+
failures.push({
|
|
1062
|
+
code: "bundle_status",
|
|
1063
|
+
message: `Island bundle responded with HTTP ${bundleResponse.status}`,
|
|
1064
|
+
islandId: marker.id,
|
|
1065
|
+
src: marker.src,
|
|
1066
|
+
status: bundleResponse.status,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
failures.push({
|
|
1071
|
+
code: "bundle_fetch_failed",
|
|
1072
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1073
|
+
islandId: marker.id,
|
|
1074
|
+
src: marker.src,
|
|
1075
|
+
status: null,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
routeId: route.id,
|
|
1082
|
+
pattern: route.pattern,
|
|
1083
|
+
path: samplePath,
|
|
1084
|
+
status: response.status,
|
|
1085
|
+
islandCount: markers.length,
|
|
1086
|
+
failures,
|
|
1087
|
+
};
|
|
1088
|
+
}
|