@mandujs/core 0.54.17 → 0.54.18
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 +3 -1
- package/src/agent/__tests__/context.test.ts +54 -16
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +440 -8
- package/src/bundler/build.ts +455 -132
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/diagnose/__tests__/checks.test.ts +117 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +43 -6
- package/src/router/client-entry.ts +33 -12
- package/src/router/fs-routes.test.ts +388 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +166 -18
- package/src/router/fs-types.ts +4 -1
- package/src/runtime/__tests__/page-render-response.test.ts +212 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +1 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/bundler/dev.ts
CHANGED
|
@@ -21,12 +21,13 @@ import {
|
|
|
21
21
|
scanFileImports,
|
|
22
22
|
DEFAULT_MAX_CLOSURE_DEPTH,
|
|
23
23
|
} from "./reverse-import-graph";
|
|
24
|
-
import path from "path";
|
|
25
|
-
import fs from "fs";
|
|
26
|
-
import { LRUCache } from "../utils/lru-cache";
|
|
27
|
-
import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
import path from "path";
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import { LRUCache } from "../utils/lru-cache";
|
|
27
|
+
import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
|
|
28
|
+
export { generateFastRefreshPreamble } from "./fast-refresh-preamble";
|
|
29
|
+
|
|
30
|
+
/**
|
|
30
31
|
* #184: 공통 디렉토리 변경 시 사용하는 sentinel.
|
|
31
32
|
* `onSSRChange`에 특정 파일 경로 대신 이 상수를 전달하면 "전체 SSR 레지스트리 invalidate" 의미.
|
|
32
33
|
*/
|
|
@@ -135,12 +136,14 @@ export interface RebuildResult {
|
|
|
135
136
|
error?: string;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
export interface DevBundler {
|
|
139
|
-
/** 초기 빌드 결과 */
|
|
140
|
-
initialBuild: BundleResult;
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
export interface DevBundler {
|
|
140
|
+
/** 초기 빌드 결과 */
|
|
141
|
+
initialBuild: BundleResult;
|
|
142
|
+
/** Reverse import graph initial seed completion for deterministic tests. */
|
|
143
|
+
reverseGraphReady: Promise<void>;
|
|
144
|
+
/** 파일 감시 중지 */
|
|
145
|
+
close: () => void;
|
|
146
|
+
}
|
|
144
147
|
|
|
145
148
|
/**
|
|
146
149
|
* #180: 파일 경로 비교를 위한 정규화.
|
|
@@ -647,7 +650,8 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
647
650
|
// surface typed-only stubs that have no source yet); misses simply
|
|
648
651
|
// keep the legacy "silent drop" behavior so no project is made
|
|
649
652
|
// worse by turning this on.
|
|
650
|
-
const reverseGraph = new ReverseImportGraph();
|
|
653
|
+
const reverseGraph = new ReverseImportGraph();
|
|
654
|
+
let reverseGraphSeedPromise: Promise<void> | null = null;
|
|
651
655
|
|
|
652
656
|
/**
|
|
653
657
|
* Re-scan a single file's imports and update its row in the
|
|
@@ -1239,11 +1243,15 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1239
1243
|
* stay silent. Dispatch is idempotent — each root is touched at
|
|
1240
1244
|
* most once per change via the `dispatched` set.
|
|
1241
1245
|
*/
|
|
1242
|
-
const dispatchByTransitiveImporters = async (
|
|
1243
|
-
changedFile: string,
|
|
1244
|
-
): Promise<number> => {
|
|
1245
|
-
const absChanged = path.resolve(rootDir, changedFile);
|
|
1246
|
-
|
|
1246
|
+
const dispatchByTransitiveImporters = async (
|
|
1247
|
+
changedFile: string,
|
|
1248
|
+
): Promise<number> => {
|
|
1249
|
+
const absChanged = path.resolve(rootDir, changedFile);
|
|
1250
|
+
if (reverseGraphSeedPromise) {
|
|
1251
|
+
await reverseGraphSeedPromise;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Refresh the changed file's OWN imports first. A leaf edit can
|
|
1247
1255
|
// legitimately add or remove imports (e.g. switching a barrel
|
|
1248
1256
|
// from `./en` to `./ko`); the reverse graph must track that so
|
|
1249
1257
|
// the NEXT unknown-file change uses the correct set.
|
|
@@ -1655,16 +1663,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1655
1663
|
// and the first user edit can't race the seed (first event goes
|
|
1656
1664
|
// through the 100 ms debounce, by which point the scan has finished
|
|
1657
1665
|
// for any realistic project size).
|
|
1658
|
-
seedReverseGraph().catch((err) => {
|
|
1659
|
-
console.warn(
|
|
1660
|
-
"[Mandu HMR] reverse import-graph seed skipped:",
|
|
1661
|
-
err instanceof Error ? err.message : String(err),
|
|
1662
|
-
);
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1665
|
-
return {
|
|
1666
|
-
initialBuild,
|
|
1667
|
-
|
|
1666
|
+
reverseGraphSeedPromise = seedReverseGraph().catch((err) => {
|
|
1667
|
+
console.warn(
|
|
1668
|
+
"[Mandu HMR] reverse import-graph seed skipped:",
|
|
1669
|
+
err instanceof Error ? err.message : String(err),
|
|
1670
|
+
);
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
return {
|
|
1674
|
+
initialBuild,
|
|
1675
|
+
reverseGraphReady: reverseGraphSeedPromise,
|
|
1676
|
+
close: () => {
|
|
1668
1677
|
// B6: clear all per-file timers to release event-loop refs.
|
|
1669
1678
|
// Phase 17 — `LRUCache.clear()` fires the registered `onEvict`
|
|
1670
1679
|
// (`clearTimeout(timer)`) for every entry before dropping them,
|
|
@@ -2208,90 +2217,8 @@ export function createHMRServer(
|
|
|
2208
2217
|
};
|
|
2209
2218
|
}
|
|
2210
2219
|
|
|
2211
|
-
/**
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
* Emitted by the SSR renderer in dev mode, **before** any island JS
|
|
2215
|
-
* evaluates. Two concerns, one <script>:
|
|
2216
|
-
*
|
|
2217
|
-
* 1. Install inert stubs for `$RefreshReg$` / `$RefreshSig$` on
|
|
2218
|
-
* `window`. Bun's `reactFastRefresh: true` transform inserts calls
|
|
2219
|
-
* to these at the top of every transformed module; if they are
|
|
2220
|
-
* undefined when the module body runs, we get a runtime error and
|
|
2221
|
-
* the island never hydrates. Vite's preamble does the same inline
|
|
2222
|
-
* stub install for the same reason.
|
|
2223
|
-
*
|
|
2224
|
-
* 2. Fire a dynamic `import()` of the bundled glue (`_fast-refresh-
|
|
2225
|
-
* runtime.js`), which in turn `await`s the real `react-refresh/
|
|
2226
|
-
* runtime`, installs `window.__MANDU_HMR__`, and upgrades the
|
|
2227
|
-
* stubs to live wrappers that forward to the refresh runtime. The
|
|
2228
|
-
* race between "module evaluates and calls `$RefreshReg$`" and
|
|
2229
|
-
* "glue has upgraded the stubs" is benign — registrations that
|
|
2230
|
-
* land on the stub are simply no-ops, which at worst means the
|
|
2231
|
-
* very first mount isn't tracked. Subsequent hot swaps land on
|
|
2232
|
-
* the live wrappers and work normally.
|
|
2233
|
-
*
|
|
2234
|
-
* The emitted script is **inline** (no `type="module"`, no external
|
|
2235
|
-
* src). This is deliberate: the stubs must exist before *any* module
|
|
2236
|
-
* script runs, and inline execution blocks the parser. The dynamic
|
|
2237
|
-
* import inside the inline script is non-blocking so we don't stall
|
|
2238
|
-
* First Contentful Paint.
|
|
2239
|
-
*
|
|
2240
|
-
* CSP note: the inline <script> uses no `eval` or `new Function`; it
|
|
2241
|
-
* only calls `Object.assign`, defines functions, and initiates an
|
|
2242
|
-
* `import()`. All of these are permitted under `script-src 'self'
|
|
2243
|
-
* 'unsafe-inline'` which is Mandu's default dev CSP (production CSP
|
|
2244
|
-
* forbids `unsafe-inline`, but this preamble is dev-only).
|
|
2245
|
-
*
|
|
2246
|
-
* `glueUrl` and `runtimeUrl` come from the build manifest's
|
|
2247
|
-
* `shared.fastRefresh` block (populated only in dev). Both must be
|
|
2248
|
-
* absolute URLs served from the same origin as the HTML, which our
|
|
2249
|
-
* bundler always guarantees (`/.mandu/client/...`).
|
|
2250
|
-
*/
|
|
2251
|
-
export function generateFastRefreshPreamble(
|
|
2252
|
-
glueUrl: string,
|
|
2253
|
-
runtimeUrl: string,
|
|
2254
|
-
): string {
|
|
2255
|
-
// Both URLs must be non-empty. If either is missing (e.g. vendor
|
|
2256
|
-
// shim build failed), the caller (ssr.ts) should skip this preamble
|
|
2257
|
-
// entirely — defensive guard here keeps the output valid regardless.
|
|
2258
|
-
if (!glueUrl || !runtimeUrl) {
|
|
2259
|
-
return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
|
|
2260
|
-
}
|
|
2261
|
-
// JSON.stringify escapes the URLs safely for inline `<script>`:
|
|
2262
|
-
// - quotes produce a valid JS string literal
|
|
2263
|
-
// - forward-slashes / backslashes are handled
|
|
2264
|
-
// We also `split('</')` to avoid a stray `</script>` sequence in the
|
|
2265
|
-
// URL bytes breaking the enclosing tag. This is the same defense
|
|
2266
|
-
// Vite uses in its own preamble emitter.
|
|
2267
|
-
const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
|
|
2268
|
-
const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
|
|
2269
|
-
return `<script>
|
|
2270
|
-
// Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
|
|
2271
|
-
(function () {
|
|
2272
|
-
if (typeof window === "undefined") return;
|
|
2273
|
-
// Install inert stubs so transformed modules that run BEFORE the
|
|
2274
|
-
// async runtime upgrade don't hit ReferenceError on $RefreshReg$.
|
|
2275
|
-
if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
|
|
2276
|
-
if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
|
|
2277
|
-
// Async-load the glue; failures are reported but never throw out of
|
|
2278
|
-
// the preamble — a missing runtime degrades to full-reload HMR.
|
|
2279
|
-
import(${glueLit})
|
|
2280
|
-
.then(function (mod) {
|
|
2281
|
-
var runtimeImport = function () { return import(${runtimeLit}); };
|
|
2282
|
-
if (mod && typeof mod.installGlobal === "function") {
|
|
2283
|
-
return mod.installGlobal({ runtimeImport: runtimeImport });
|
|
2284
|
-
}
|
|
2285
|
-
})
|
|
2286
|
-
.catch(function (err) {
|
|
2287
|
-
console.error("[Mandu Fast Refresh] preamble failed:", err);
|
|
2288
|
-
});
|
|
2289
|
-
})();
|
|
2290
|
-
</script>`;
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
/**
|
|
2294
|
-
* HMR 클라이언트 스크립트 생성
|
|
2220
|
+
/**
|
|
2221
|
+
* HMR 클라이언트 스크립트 생성
|
|
2295
2222
|
* 브라우저에서 실행되어 HMR 서버와 연결.
|
|
2296
2223
|
*
|
|
2297
2224
|
* Phase 7.0 R1 Agent C additions:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small runtime-safe Fast Refresh preamble helper.
|
|
3
|
+
*
|
|
4
|
+
* Keep this separate from `bundler/dev.ts`: SSR runtimes import the helper
|
|
5
|
+
* while edge bundles must not pull the dev bundler, build graph, or TypeScript
|
|
6
|
+
* compiler into production worker output.
|
|
7
|
+
*/
|
|
8
|
+
export function generateFastRefreshPreamble(
|
|
9
|
+
glueUrl: string,
|
|
10
|
+
runtimeUrl: string,
|
|
11
|
+
): string {
|
|
12
|
+
// Both URLs must be non-empty. If either is missing (e.g. vendor
|
|
13
|
+
// shim build failed), the caller should skip this preamble entirely.
|
|
14
|
+
if (!glueUrl || !runtimeUrl) {
|
|
15
|
+
return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
|
|
16
|
+
}
|
|
17
|
+
// JSON.stringify escapes the URLs safely for inline `<script>`:
|
|
18
|
+
// - quotes produce a valid JS string literal
|
|
19
|
+
// - forward-slashes / backslashes are handled
|
|
20
|
+
// We also `split('</')` to avoid a stray `</script>` sequence in the
|
|
21
|
+
// URL bytes breaking the enclosing tag. This is the same defense
|
|
22
|
+
// Vite uses in its own preamble emitter.
|
|
23
|
+
const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
|
|
24
|
+
const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
|
|
25
|
+
return `<script>
|
|
26
|
+
// Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
|
|
27
|
+
(function () {
|
|
28
|
+
if (typeof window === "undefined") return;
|
|
29
|
+
// Install inert stubs so transformed modules that run BEFORE the
|
|
30
|
+
// async runtime upgrade don't hit ReferenceError on $RefreshReg$.
|
|
31
|
+
if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
|
|
32
|
+
if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
|
|
33
|
+
// Async-load the glue; failures are reported but never throw out of
|
|
34
|
+
// the preamble — a missing runtime degrades to full-reload HMR.
|
|
35
|
+
import(${glueLit})
|
|
36
|
+
.then(function (mod) {
|
|
37
|
+
var runtimeImport = function () { return import(${runtimeLit}); };
|
|
38
|
+
if (mod && typeof mod.installGlobal === "function") {
|
|
39
|
+
return mod.installGlobal({ runtimeImport: runtimeImport });
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
.catch(function (err) {
|
|
43
|
+
console.error("[Mandu Fast Refresh] preamble failed:", err);
|
|
44
|
+
});
|
|
45
|
+
})();
|
|
46
|
+
</script>`;
|
|
47
|
+
}
|
package/src/bundler/index.ts
CHANGED
|
@@ -173,6 +173,15 @@ const PartialEntrySchema = z.object({
|
|
|
173
173
|
priority: PrioritySchema,
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
const BoundaryEntrySchema = z.object({
|
|
177
|
+
route: z.string().min(1),
|
|
178
|
+
js: safeManduUrl("boundaries[].js"),
|
|
179
|
+
module: z.string().min(1),
|
|
180
|
+
exportName: z.string().min(1),
|
|
181
|
+
priority: PrioritySchema,
|
|
182
|
+
hydrate: z.string().min(1),
|
|
183
|
+
});
|
|
184
|
+
|
|
176
185
|
const FastRefreshSchema = z.object({
|
|
177
186
|
runtime: safeManduUrl("shared.fastRefresh.runtime"),
|
|
178
187
|
glue: safeManduUrl("shared.fastRefresh.glue"),
|
|
@@ -229,6 +238,7 @@ export const BundleManifestSchema = z
|
|
|
229
238
|
bundles: z.record(z.string(), BundleEntrySchema),
|
|
230
239
|
islands: z.record(z.string(), IslandEntrySchema).optional(),
|
|
231
240
|
partials: z.record(z.string(), PartialEntrySchema).optional(),
|
|
241
|
+
boundaries: z.record(z.string(), BoundaryEntrySchema).optional(),
|
|
232
242
|
shared: SharedSchema,
|
|
233
243
|
importMap: ImportMapSchema.optional(),
|
|
234
244
|
})
|
package/src/bundler/types.ts
CHANGED
|
@@ -77,8 +77,26 @@ export interface BundleManifest {
|
|
|
77
77
|
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
78
78
|
}
|
|
79
79
|
>;
|
|
80
|
-
/**
|
|
81
|
-
|
|
80
|
+
/** Compiler-owned client boundary bundles keyed by boundary id. */
|
|
81
|
+
boundaries?: Record<
|
|
82
|
+
string,
|
|
83
|
+
{
|
|
84
|
+
/** Owning route id */
|
|
85
|
+
route: string;
|
|
86
|
+
/** JavaScript bundle path */
|
|
87
|
+
js: string;
|
|
88
|
+
/** Source client module */
|
|
89
|
+
module: string;
|
|
90
|
+
/** Client export name */
|
|
91
|
+
exportName: string;
|
|
92
|
+
/** Hydration priority */
|
|
93
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
94
|
+
/** Hydration mode emitted into data-hydrate */
|
|
95
|
+
hydrate: string;
|
|
96
|
+
}
|
|
97
|
+
>;
|
|
98
|
+
/** 공유 청크 */
|
|
99
|
+
shared: {
|
|
82
100
|
/** Hydration 런타임 */
|
|
83
101
|
runtime: string;
|
|
84
102
|
/** React 번들 경로 */
|
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
checkPrerenderPollution,
|
|
17
17
|
checkCloneElementWarnings,
|
|
18
18
|
checkDevArtifactsInProd,
|
|
19
|
-
checkPackageExportGaps,
|
|
20
|
-
checkNestedInternalCore,
|
|
21
|
-
|
|
19
|
+
checkPackageExportGaps,
|
|
20
|
+
checkNestedInternalCore,
|
|
21
|
+
checkClientBoundaryManifests,
|
|
22
|
+
} from "../checks";
|
|
22
23
|
import { runExtendedDiagnose, buildReport } from "../run";
|
|
23
24
|
|
|
24
25
|
async function mkTmpRoot(): Promise<string> {
|
|
@@ -324,7 +325,7 @@ describe("checkPackageExportGaps", () => {
|
|
|
324
325
|
// nested_internal_core (#261)
|
|
325
326
|
// ──────────────────────────────────────────────────────────────────
|
|
326
327
|
|
|
327
|
-
describe("checkNestedInternalCore", () => {
|
|
328
|
+
describe("checkNestedInternalCore", () => {
|
|
328
329
|
let rootDir: string;
|
|
329
330
|
beforeEach(async () => { rootDir = await mkTmpRoot(); });
|
|
330
331
|
afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
|
|
@@ -387,21 +388,119 @@ describe("checkNestedInternalCore", () => {
|
|
|
387
388
|
expect(result.ok).toBe(false);
|
|
388
389
|
expect(result.details?.mismatchCount).toBe(2);
|
|
389
390
|
});
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
// ──────────────────────────────────────────────────────────────────
|
|
393
|
-
//
|
|
394
|
-
// ──────────────────────────────────────────────────────────────────
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ──────────────────────────────────────────────────────────────────
|
|
394
|
+
// client_boundary_manifests
|
|
395
|
+
// ──────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
describe("checkClientBoundaryManifests", () => {
|
|
398
|
+
let rootDir: string;
|
|
399
|
+
beforeEach(async () => { rootDir = await mkTmpRoot(); });
|
|
400
|
+
afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
|
|
401
|
+
|
|
402
|
+
it("skips gracefully when routes manifest is missing", async () => {
|
|
403
|
+
const result = await checkClientBoundaryManifests(rootDir);
|
|
404
|
+
expect(result.ok).toBe(true);
|
|
405
|
+
expect(result.details?.skipped).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("passes when the routes manifest has no compiler-owned boundaries", async () => {
|
|
409
|
+
await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
|
|
410
|
+
routes: [{ id: "home", kind: "page" }],
|
|
411
|
+
}));
|
|
412
|
+
const result = await checkClientBoundaryManifests(rootDir);
|
|
413
|
+
expect(result.ok).toBe(true);
|
|
414
|
+
expect(result.details?.boundaryCount).toBe(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("flags duplicate client boundary ids in the routes manifest", async () => {
|
|
418
|
+
await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
|
|
419
|
+
routes: [
|
|
420
|
+
{
|
|
421
|
+
id: "a",
|
|
422
|
+
boundaries: [{ id: "dup--0", routeId: "a", module: "src/client/A.client.tsx", exportName: "A" }],
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
id: "b",
|
|
426
|
+
boundaries: [{ id: "dup--0", routeId: "b", module: "src/client/B.client.tsx", exportName: "B" }],
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
}));
|
|
430
|
+
const result = await checkClientBoundaryManifests(rootDir);
|
|
431
|
+
expect(result.ok).toBe(false);
|
|
432
|
+
expect(result.severity).toBe("error");
|
|
433
|
+
expect(result.message).toMatch(/duplicate/);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("flags missing boundary bundle manifest entries", async () => {
|
|
437
|
+
await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
|
|
438
|
+
routes: [
|
|
439
|
+
{
|
|
440
|
+
id: "home",
|
|
441
|
+
boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
}));
|
|
445
|
+
await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
|
|
446
|
+
version: 1,
|
|
447
|
+
buildTime: "x",
|
|
448
|
+
env: "production",
|
|
449
|
+
bundles: {},
|
|
450
|
+
boundaries: {},
|
|
451
|
+
shared: { runtime: "/.mandu/client/_runtime.js", vendor: "/.mandu/client/_react.js" },
|
|
452
|
+
}));
|
|
453
|
+
const result = await checkClientBoundaryManifests(rootDir);
|
|
454
|
+
expect(result.ok).toBe(false);
|
|
455
|
+
expect(result.severity).toBe("error");
|
|
456
|
+
expect(result.message).toMatch(/incomplete/);
|
|
457
|
+
expect(result.details?.missingEntries).toEqual(["home--0"]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("passes when route and bundle boundary manifests line up", async () => {
|
|
461
|
+
await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
|
|
462
|
+
routes: [
|
|
463
|
+
{
|
|
464
|
+
id: "home",
|
|
465
|
+
boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
}));
|
|
469
|
+
await writeFile(rootDir, ".mandu/client/home--0.boundary.js", "export default function Home() {}\n");
|
|
470
|
+
await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
|
|
471
|
+
version: 1,
|
|
472
|
+
buildTime: "x",
|
|
473
|
+
env: "production",
|
|
474
|
+
bundles: {},
|
|
475
|
+
boundaries: {
|
|
476
|
+
"home--0": {
|
|
477
|
+
route: "home",
|
|
478
|
+
js: "/.mandu/client/home--0.boundary.js",
|
|
479
|
+
module: "src/client/Home.client.tsx",
|
|
480
|
+
exportName: "Home",
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
shared: { runtime: "/.mandu/client/_runtime.js", vendor: "/.mandu/client/_react.js" },
|
|
484
|
+
}));
|
|
485
|
+
const result = await checkClientBoundaryManifests(rootDir);
|
|
486
|
+
expect(result.ok).toBe(true);
|
|
487
|
+
expect(result.details?.boundaryCount).toBe(1);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ──────────────────────────────────────────────────────────────────
|
|
492
|
+
// aggregator
|
|
493
|
+
// ──────────────────────────────────────────────────────────────────
|
|
395
494
|
|
|
396
495
|
describe("runExtendedDiagnose", () => {
|
|
397
496
|
let rootDir: string;
|
|
398
497
|
beforeEach(async () => { rootDir = await mkTmpRoot(); });
|
|
399
498
|
afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
|
|
400
499
|
|
|
401
|
-
it("runs all
|
|
402
|
-
const report = await runExtendedDiagnose(rootDir);
|
|
403
|
-
//
|
|
404
|
-
expect(report.summary.total).toBe(
|
|
500
|
+
it("runs all 8 extended checks and returns a structured report", async () => {
|
|
501
|
+
const report = await runExtendedDiagnose(rootDir);
|
|
502
|
+
// F42/F45 added `client_boundary_manifests` — total is now 8.
|
|
503
|
+
expect(report.summary.total).toBe(8);
|
|
405
504
|
// manifest is missing → at least one error
|
|
406
505
|
expect(report.healthy).toBe(false);
|
|
407
506
|
expect(report.errorCount).toBeGreaterThanOrEqual(1);
|
|
@@ -410,10 +509,11 @@ describe("runExtendedDiagnose", () => {
|
|
|
410
509
|
expect(rules).toContain("prerender_pollution");
|
|
411
510
|
expect(rules).toContain("cloneelement_warnings");
|
|
412
511
|
expect(rules).toContain("dev_artifacts_in_prod");
|
|
413
|
-
expect(rules).toContain("package_export_gaps");
|
|
414
|
-
expect(rules).toContain("nested_internal_core");
|
|
415
|
-
expect(rules).toContain("
|
|
416
|
-
|
|
512
|
+
expect(rules).toContain("package_export_gaps");
|
|
513
|
+
expect(rules).toContain("nested_internal_core");
|
|
514
|
+
expect(rules).toContain("client_boundary_manifests");
|
|
515
|
+
expect(rules).toContain("a11y_hints");
|
|
516
|
+
});
|
|
417
517
|
|
|
418
518
|
it("returns healthy=true when all checks pass (production manifest, no gaps)", async () => {
|
|
419
519
|
await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
|
package/src/diagnose/checks.ts
CHANGED
|
@@ -767,7 +767,7 @@ async function listMandujsSiblings(rootDir: string): Promise<string[]> {
|
|
|
767
767
|
* version to the hoisted core. A mismatch is reported as `error`
|
|
768
768
|
* (boot-breaking on the user's machine) with a copy-pastable fix.
|
|
769
769
|
*/
|
|
770
|
-
export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
770
|
+
export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
771
771
|
const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
|
|
772
772
|
const hoistedVersion = await readPackageVersion(hoistedPath);
|
|
773
773
|
|
|
@@ -828,5 +828,186 @@ export async function checkNestedInternalCore(rootDir: string): Promise<Diagnose
|
|
|
828
828
|
mismatchCount: mismatches.length,
|
|
829
829
|
mismatches,
|
|
830
830
|
},
|
|
831
|
-
};
|
|
832
|
-
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
835
|
+
// 8. client_boundary_manifests (F42/F45)
|
|
836
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
interface DiagnoseRouteBoundary {
|
|
839
|
+
id?: unknown;
|
|
840
|
+
routeId?: unknown;
|
|
841
|
+
module?: unknown;
|
|
842
|
+
exportName?: unknown;
|
|
843
|
+
source?: unknown;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
interface DiagnoseRouteRecord {
|
|
847
|
+
id?: unknown;
|
|
848
|
+
boundaries?: unknown;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function readJsonObject(filePath: string): Promise<Record<string, unknown> | null> {
|
|
852
|
+
try {
|
|
853
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
854
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
855
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
856
|
+
? parsed as Record<string, unknown>
|
|
857
|
+
: null;
|
|
858
|
+
} catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function toRouteRecords(value: unknown): DiagnoseRouteRecord[] {
|
|
864
|
+
if (!Array.isArray(value)) return [];
|
|
865
|
+
return value.filter((entry): entry is DiagnoseRouteRecord =>
|
|
866
|
+
!!entry && typeof entry === "object" && !Array.isArray(entry)
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function toRouteBoundaries(route: DiagnoseRouteRecord): DiagnoseRouteBoundary[] {
|
|
871
|
+
if (!Array.isArray(route.boundaries)) return [];
|
|
872
|
+
return route.boundaries.filter((entry): entry is DiagnoseRouteBoundary =>
|
|
873
|
+
!!entry && typeof entry === "object" && !Array.isArray(entry)
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function bundleAssetExists(rootDir: string, jsPath: unknown): Promise<boolean> {
|
|
878
|
+
if (typeof jsPath !== "string" || jsPath.length === 0) return Promise.resolve(false);
|
|
879
|
+
const relativePath = jsPath.startsWith("/")
|
|
880
|
+
? jsPath.slice(1)
|
|
881
|
+
: jsPath;
|
|
882
|
+
return fs.access(path.join(rootDir, relativePath)).then(
|
|
883
|
+
() => true,
|
|
884
|
+
() => false,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* F42/F45 check: validate compiler-owned client boundary records.
|
|
890
|
+
*
|
|
891
|
+
* This does not rebuild the app. It inspects generated route and bundle
|
|
892
|
+
* manifests and reports:
|
|
893
|
+
* - duplicate boundary ids in `.mandu/routes.manifest.json`
|
|
894
|
+
* - malformed boundary records missing id/module/exportName
|
|
895
|
+
* - missing boundary bundle entries in `.mandu/manifest.json`
|
|
896
|
+
* - boundary bundle entries whose JS asset is absent on disk
|
|
897
|
+
*/
|
|
898
|
+
export async function checkClientBoundaryManifests(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
899
|
+
const routesManifestPath = path.join(rootDir, ".mandu", "routes.manifest.json");
|
|
900
|
+
const routesManifest = await readJsonObject(routesManifestPath);
|
|
901
|
+
|
|
902
|
+
if (!routesManifest) {
|
|
903
|
+
return {
|
|
904
|
+
ok: true,
|
|
905
|
+
rule: "client_boundary_manifests",
|
|
906
|
+
message: "Routes manifest is missing or unreadable — boundary manifest check skipped.",
|
|
907
|
+
details: { skipped: true, routesManifestPath: path.relative(rootDir, routesManifestPath) },
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const boundaries: Array<{ routeId: string; id: string; module: string; exportName: string }> = [];
|
|
912
|
+
const malformed: Array<{ routeId: string; reason: string }> = [];
|
|
913
|
+
const seen = new Map<string, string>();
|
|
914
|
+
const duplicates: Array<{ id: string; firstRouteId: string; duplicateRouteId: string }> = [];
|
|
915
|
+
|
|
916
|
+
for (const route of toRouteRecords(routesManifest.routes)) {
|
|
917
|
+
const routeId = typeof route.id === "string" ? route.id : "(unknown-route)";
|
|
918
|
+
for (const boundary of toRouteBoundaries(route)) {
|
|
919
|
+
const id = typeof boundary.id === "string" ? boundary.id : "";
|
|
920
|
+
const module = typeof boundary.module === "string" ? boundary.module : "";
|
|
921
|
+
const exportName = typeof boundary.exportName === "string" ? boundary.exportName : "";
|
|
922
|
+
if (!id || !module || !exportName) {
|
|
923
|
+
malformed.push({ routeId, reason: "Boundary record must include id, module, and exportName." });
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const firstRouteId = seen.get(id);
|
|
928
|
+
if (firstRouteId) {
|
|
929
|
+
duplicates.push({ id, firstRouteId, duplicateRouteId: routeId });
|
|
930
|
+
} else {
|
|
931
|
+
seen.set(id, routeId);
|
|
932
|
+
}
|
|
933
|
+
boundaries.push({ routeId, id, module, exportName });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (malformed.length > 0 || duplicates.length > 0) {
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
rule: "client_boundary_manifests",
|
|
941
|
+
severity: "error",
|
|
942
|
+
message: `Client boundary route manifest has ${malformed.length} malformed record(s) and ${duplicates.length} duplicate id(s).`,
|
|
943
|
+
suggestion: "Regenerate routes with `mandu generate` or rerun the build; boundary ids must be unique and include id/module/exportName.",
|
|
944
|
+
details: {
|
|
945
|
+
boundaryCount: boundaries.length,
|
|
946
|
+
malformed: malformed.slice(0, 10),
|
|
947
|
+
duplicates: duplicates.slice(0, 10),
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (boundaries.length === 0) {
|
|
953
|
+
return {
|
|
954
|
+
ok: true,
|
|
955
|
+
rule: "client_boundary_manifests",
|
|
956
|
+
message: "No compiler-owned client boundaries recorded in the routes manifest.",
|
|
957
|
+
details: { boundaryCount: 0 },
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const bundleManifestPath = path.join(rootDir, ".mandu", "manifest.json");
|
|
962
|
+
const bundleManifest = await readJsonObject(bundleManifestPath);
|
|
963
|
+
if (!bundleManifest) {
|
|
964
|
+
return {
|
|
965
|
+
ok: false,
|
|
966
|
+
rule: "client_boundary_manifests",
|
|
967
|
+
severity: "warning",
|
|
968
|
+
message: `Routes manifest declares ${boundaries.length} client boundary record(s), but bundle manifest is missing.`,
|
|
969
|
+
suggestion: "Run `mandu build` before deploy so boundary bundles are emitted and can be checked.",
|
|
970
|
+
details: { boundaryCount: boundaries.length, bundleManifestPath: path.relative(rootDir, bundleManifestPath) },
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const bundleBoundaries = bundleManifest.boundaries && typeof bundleManifest.boundaries === "object" && !Array.isArray(bundleManifest.boundaries)
|
|
975
|
+
? bundleManifest.boundaries as Record<string, unknown>
|
|
976
|
+
: {};
|
|
977
|
+
const missingEntries: string[] = [];
|
|
978
|
+
const missingAssets: string[] = [];
|
|
979
|
+
|
|
980
|
+
for (const boundary of boundaries) {
|
|
981
|
+
const entry = bundleBoundaries[boundary.id];
|
|
982
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
983
|
+
missingEntries.push(boundary.id);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const jsPath = (entry as { js?: unknown }).js;
|
|
987
|
+
if (!(await bundleAssetExists(rootDir, jsPath))) {
|
|
988
|
+
missingAssets.push(boundary.id);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (missingEntries.length > 0 || missingAssets.length > 0) {
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
rule: "client_boundary_manifests",
|
|
996
|
+
severity: "error",
|
|
997
|
+
message: `Client boundary bundle manifest is incomplete: ${missingEntries.length} missing entr${missingEntries.length === 1 ? "y" : "ies"}, ${missingAssets.length} missing asset(s).`,
|
|
998
|
+
suggestion: "Run `mandu clean && mandu build`; if the issue persists, inspect `mandu.route.boundaries` with `includeBundle: true`.",
|
|
999
|
+
details: {
|
|
1000
|
+
boundaryCount: boundaries.length,
|
|
1001
|
+
missingEntries: missingEntries.slice(0, 10),
|
|
1002
|
+
missingAssets: missingAssets.slice(0, 10),
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return {
|
|
1008
|
+
ok: true,
|
|
1009
|
+
rule: "client_boundary_manifests",
|
|
1010
|
+
message: `Client boundary manifests are consistent for ${boundaries.length} boundary record(s).`,
|
|
1011
|
+
details: { boundaryCount: boundaries.length },
|
|
1012
|
+
};
|
|
1013
|
+
}
|