@mandujs/core 0.54.16 → 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.
Files changed (37) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
  31. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  32. package/src/runtime/handlers.ts +50 -26
  33. package/src/runtime/page-render-response.ts +43 -3
  34. package/src/runtime/server.ts +42 -5
  35. package/src/runtime/ssr.ts +16 -5
  36. package/src/runtime/streaming-ssr.ts +119 -76
  37. package/src/spec/schema.ts +31 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.16",
3
+ "version": "0.54.18",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -65,6 +65,7 @@
65
65
  "./i18n": "./src/i18n/index.ts",
66
66
  "./id": "./src/id/index.ts",
67
67
  "./internal": "./src/internal/index.ts",
68
+ "./internal/client-boundary": "./src/internal/client-boundary.ts",
68
69
  "./observability": "./src/observability/index.ts",
69
70
  "./openapi/generator": "./src/openapi/generator.ts",
70
71
  "./perf": "./src/perf/index.ts",
@@ -203,6 +204,7 @@
203
204
  "fast-glob": "^3.3.2",
204
205
  "glob": "^13.0.0",
205
206
  "minimatch": "^10.1.1",
207
+ "typescript": ">=6.0.0",
206
208
  "zod": "^3.23.8"
207
209
  }
208
210
  }
@@ -64,14 +64,35 @@ describe("agent context", () => {
64
64
  await writeFile(root, ".mandu/routes.manifest.json", JSON.stringify({
65
65
  version: 1,
66
66
  routes: [
67
- {
68
- id: "home",
69
- pattern: "/",
70
- kind: "page",
71
- module: "app/page.tsx",
72
- componentModule: "app/page.tsx",
73
- layoutChain: [],
74
- },
67
+ {
68
+ id: "home",
69
+ pattern: "/",
70
+ kind: "page",
71
+ module: "app/page.tsx",
72
+ componentModule: "app/page.tsx",
73
+ layoutChain: [],
74
+ hydration: { strategy: "island", priority: "visible", preload: false },
75
+ boundaries: [
76
+ {
77
+ id: "home--0",
78
+ routeId: "home",
79
+ module: "src/client/HomeWidget.client.tsx",
80
+ importSpecifier: "@/client/HomeWidget.client",
81
+ exportName: "HomeWidget",
82
+ localName: "HomeWidget",
83
+ hydrate: "visible",
84
+ ordinal: 0,
85
+ propsSource: "inline",
86
+ propsKeys: ["user"],
87
+ hasSpreadProps: false,
88
+ source: {
89
+ file: "app/page.tsx",
90
+ line: 5,
91
+ column: 12,
92
+ },
93
+ },
94
+ ],
95
+ },
75
96
  {
76
97
  id: "api-ping",
77
98
  pattern: "/api/ping",
@@ -102,10 +123,26 @@ describe("agent context", () => {
102
123
  expect(context.framework).toBe("mandu");
103
124
  expect(context.project.name).toBe("agent-app");
104
125
  expect(context.project.packageManager).toBe("bun@1.3.14");
105
- expect(context.routeSource).toBe("manifest");
106
- expect(context.routes).toHaveLength(2);
107
- expect(context.pages.map((r) => r.id)).toEqual(["home"]);
108
- expect(context.apis.map((r) => r.id)).toEqual(["api-ping"]);
126
+ expect(context.routeSource).toBe("manifest");
127
+ expect(context.routes).toHaveLength(2);
128
+ expect(context.pages.map((r) => r.id)).toEqual(["home"]);
129
+ expect(context.pages[0]?.boundaryCount).toBe(1);
130
+ expect(context.pages[0]?.boundaries?.[0]).toMatchObject({
131
+ id: "home--0",
132
+ module: "src/client/HomeWidget.client.tsx",
133
+ importSpecifier: "@/client/HomeWidget.client",
134
+ exportName: "HomeWidget",
135
+ localName: "HomeWidget",
136
+ ordinal: 0,
137
+ hydrate: "visible",
138
+ propsKeys: ["user"],
139
+ source: {
140
+ file: "app/page.tsx",
141
+ line: 5,
142
+ column: 12,
143
+ },
144
+ });
145
+ expect(context.apis.map((r) => r.id)).toEqual(["api-ping"]);
109
146
  expect(context.apis[0]?.hasContractModule).toBe(true);
110
147
  expect(context.partials.map((p) => p.path)).toEqual(["app/dashboard/counter.partial.tsx"]);
111
148
  expect(context.islands.map((p) => p.path)).toEqual(["app/dashboard/chart.island.tsx"]);
@@ -125,10 +162,11 @@ describe("agent context", () => {
125
162
 
126
163
  expect(result.path).toBe(agentManifestPath(root));
127
164
  const manifest = await readAgentManifest(root);
128
- expect(manifest?.schemaVersion).toBe(1);
129
- expect(manifest?.project.name).toBe("agent-app");
130
- expect(manifest?.agentWorkflow.canonical).toEqual(["context", "plan", "apply", "verify", "repair"]);
131
- });
165
+ expect(manifest?.schemaVersion).toBe(1);
166
+ expect(manifest?.project.name).toBe("agent-app");
167
+ expect(manifest?.routes[0]?.boundaries?.[0]?.id).toBe("home--0");
168
+ expect(manifest?.agentWorkflow.canonical).toEqual(["context", "plan", "apply", "verify", "repair"]);
169
+ });
132
170
 
133
171
  it("builds a deterministic agent plan from intent", () => {
134
172
  const plan = buildAgentPlan({
@@ -119,6 +119,7 @@ function summarizeRoute(route: RouteSpec | {
119
119
  methods?: string[];
120
120
  hydration?: { strategy?: string; priority?: string };
121
121
  clientModule?: string;
122
+ boundaries?: RouteSpec["boundaries"];
122
123
  contractModule?: string;
123
124
  layoutChain?: string[];
124
125
  metadataKind?: string;
@@ -127,6 +128,21 @@ function summarizeRoute(route: RouteSpec | {
127
128
  "metadataKind" in route && typeof route.metadataKind === "string"
128
129
  ? route.metadataKind
129
130
  : undefined;
131
+ const boundaries = Array.isArray(route.boundaries)
132
+ ? route.boundaries.map((boundary) => ({
133
+ id: boundary.id,
134
+ module: boundary.module,
135
+ ...(boundary.importSpecifier ? { importSpecifier: boundary.importSpecifier } : {}),
136
+ exportName: boundary.exportName,
137
+ localName: boundary.localName,
138
+ ordinal: boundary.ordinal,
139
+ hydrate: boundary.hydrate,
140
+ propsSource: boundary.propsSource,
141
+ propsKeys: boundary.propsKeys ?? [],
142
+ hasSpreadProps: !!boundary.hasSpreadProps,
143
+ source: boundary.source,
144
+ }))
145
+ : [];
130
146
  return {
131
147
  id: route.id,
132
148
  pattern: route.pattern,
@@ -144,6 +160,7 @@ function summarizeRoute(route: RouteSpec | {
144
160
  hasClientModule: typeof route.clientModule === "string" && route.clientModule.length > 0,
145
161
  hasContractModule:
146
162
  typeof route.contractModule === "string" && route.contractModule.length > 0,
163
+ ...(boundaries.length > 0 ? { boundaryCount: boundaries.length, boundaries } : {}),
147
164
  layoutDepth: Array.isArray(route.layoutChain) ? route.layoutChain.length : 0,
148
165
  ...(metadataKind ? { metadataKind } : {}),
149
166
  };
@@ -32,23 +32,43 @@ export interface AgentProjectSummary {
32
32
  configFile: string | null;
33
33
  }
34
34
 
35
- export interface AgentRouteSummary {
36
- id: string;
37
- pattern: string;
38
- kind: "page" | "api" | "metadata" | string;
35
+ export interface AgentRouteSummary {
36
+ id: string;
37
+ pattern: string;
38
+ kind: "page" | "api" | "metadata" | string;
39
39
  module: string;
40
40
  methods?: string[];
41
41
  hydration?: {
42
42
  strategy?: string;
43
43
  priority?: string;
44
- };
45
- hasClientModule: boolean;
46
- hasContractModule: boolean;
47
- layoutDepth: number;
48
- metadataKind?: string;
49
- }
50
-
51
- export interface AgentArtifactSummary {
44
+ };
45
+ hasClientModule: boolean;
46
+ hasContractModule: boolean;
47
+ boundaryCount?: number;
48
+ boundaries?: AgentRouteBoundarySummary[];
49
+ layoutDepth: number;
50
+ metadataKind?: string;
51
+ }
52
+
53
+ export interface AgentRouteBoundarySummary {
54
+ id: string;
55
+ module: string;
56
+ importSpecifier?: string;
57
+ exportName: string;
58
+ localName: string;
59
+ ordinal: number;
60
+ hydrate: string;
61
+ propsSource: string;
62
+ propsKeys: string[];
63
+ hasSpreadProps: boolean;
64
+ source: {
65
+ file: string;
66
+ line: number;
67
+ column: number;
68
+ };
69
+ }
70
+
71
+ export interface AgentArtifactSummary {
52
72
  path: string;
53
73
  kind: "partial" | "island" | "slot" | "contract";
54
74
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "snapshots": {
3
+ "client boundary manifest metadata": "{\n \"bundleBoundaries\": {\n \"boundary-demo--0\": {\n \"exportName\": \"BoundaryWidget\",\n \"hydrate\": \"visible\",\n \"js\": \"/.mandu/client/boundary-demo--0.boundary.js\",\n \"module\": \"src/client/BoundaryWidget.client.tsx\",\n \"priority\": \"visible\",\n \"route\": \"boundary-demo\"\n }\n },\n \"routes\": [\n {\n \"boundaries\": [\n {\n \"exportName\": \"BoundaryWidget\",\n \"hasSpreadProps\": false,\n \"hydrate\": \"visible\",\n \"id\": \"boundary-demo--0\",\n \"importSpecifier\": \"@/client/BoundaryWidget.client\",\n \"localName\": \"BoundaryWidget\",\n \"module\": \"src/client/BoundaryWidget.client.tsx\",\n \"ordinal\": 0,\n \"propsKeys\": [\n \"label\"\n ],\n \"propsSource\": \"inline\",\n \"routeId\": \"boundary-demo\",\n \"source\": {\n \"column\": 12,\n \"file\": \"app/boundary/page.tsx\",\n \"line\": 5\n }\n }\n ],\n \"componentModule\": \"app/boundary/page.tsx\",\n \"id\": \"boundary-demo\",\n \"module\": \"app/boundary/page.tsx\"\n }\n ]\n}\n"
4
+ }
5
+ }
@@ -51,8 +51,11 @@
51
51
  * clean tmpdir with no prior `.mandu/vendor-cache/`.
52
52
  */
53
53
 
54
- import { buildClientBundles } from "../build";
55
- import type { RoutesManifest } from "../../spec/schema";
54
+ import { buildClientBundles } from "../build";
55
+ import type { RoutesManifest } from "../../spec/schema";
56
+ import { mkdir } from "fs/promises";
57
+ import path from "path";
58
+ import { generateManifest } from "../../router/fs-routes";
56
59
 
57
60
  const rootDir = process.argv[2];
58
61
  if (!rootDir) {
@@ -61,7 +64,9 @@ if (!rootDir) {
61
64
  }
62
65
  const mode = process.argv[3] ?? "default";
63
66
 
64
- const manifest: RoutesManifest = mode === "server-page-client-module"
67
+ const manifest: RoutesManifest = mode === "client-boundary-generated-transitive"
68
+ ? (await generateManifest(rootDir)).manifest
69
+ : mode === "server-page-client-module"
65
70
  ? {
66
71
  version: 1,
67
72
  routes: [
@@ -118,6 +123,83 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
118
123
  },
119
124
  ],
120
125
  }
126
+ : mode === "client-boundary"
127
+ || mode === "client-boundary-target"
128
+ || mode === "client-boundary-skip-framework"
129
+ || mode === "client-boundary-duplicate-id"
130
+ ? {
131
+ version: 1,
132
+ routes: [
133
+ {
134
+ id: "boundary-demo",
135
+ kind: "page",
136
+ pattern: "/boundary",
137
+ module: "app/boundary/page.tsx",
138
+ componentModule: "app/boundary/page.tsx",
139
+ hydration: {
140
+ strategy: "island",
141
+ priority: "visible",
142
+ preload: false,
143
+ },
144
+ boundaries: [
145
+ {
146
+ id: "boundary-demo--0",
147
+ routeId: "boundary-demo",
148
+ module: "src/client/BoundaryWidget.client.tsx",
149
+ importSpecifier: "@/client/BoundaryWidget.client",
150
+ exportName: "BoundaryWidget",
151
+ localName: "BoundaryWidget",
152
+ hydrate: "visible",
153
+ ordinal: 0,
154
+ propsSource: "inline",
155
+ propsKeys: ["label"],
156
+ hasSpreadProps: false,
157
+ source: {
158
+ file: "app/boundary/page.tsx",
159
+ line: 5,
160
+ column: 12,
161
+ },
162
+ },
163
+ ],
164
+ },
165
+ ...(mode === "client-boundary-duplicate-id"
166
+ ? [
167
+ {
168
+ id: "boundary-copy",
169
+ kind: "page" as const,
170
+ pattern: "/boundary-copy",
171
+ module: "app/boundary/page.tsx",
172
+ componentModule: "app/boundary/page.tsx",
173
+ hydration: {
174
+ strategy: "island" as const,
175
+ priority: "visible" as const,
176
+ preload: false,
177
+ },
178
+ boundaries: [
179
+ {
180
+ id: "boundary-demo--0",
181
+ routeId: "boundary-copy",
182
+ module: "src/client/BoundaryWidget.client.tsx",
183
+ importSpecifier: "@/client/BoundaryWidget.client",
184
+ exportName: "BoundaryWidget",
185
+ localName: "BoundaryWidget",
186
+ hydrate: "visible" as const,
187
+ ordinal: 0,
188
+ propsSource: "inline" as const,
189
+ propsKeys: ["label"],
190
+ hasSpreadProps: false,
191
+ source: {
192
+ file: "app/boundary/page.tsx",
193
+ line: 8,
194
+ column: 12,
195
+ },
196
+ },
197
+ ],
198
+ },
199
+ ]
200
+ : []),
201
+ ],
202
+ }
121
203
  : mode === "i18n-locale-route-id"
122
204
  ? {
123
205
  version: 1,
@@ -156,27 +238,58 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
156
238
  ],
157
239
  };
158
240
 
159
- try {
160
- const result = await buildClientBundles(manifest, rootDir, {
161
- minify: false,
162
- sourcemap: false,
163
- splitting: false,
164
- });
241
+ try {
242
+ if (mode === "client-boundary-target" || mode === "client-boundary-skip-framework") {
243
+ await mkdir(path.join(rootDir, ".mandu"), { recursive: true });
244
+ await Bun.write(
245
+ path.join(rootDir, ".mandu", "manifest.json"),
246
+ JSON.stringify(
247
+ {
248
+ version: 1,
249
+ buildTime: "2026-05-23T00:00:00.000Z",
250
+ env: "development",
251
+ bundles: {},
252
+ shared: {
253
+ runtime: "/.mandu/client/_runtime.js",
254
+ vendor: "/.mandu/client/_react.js",
255
+ router: "/.mandu/client/_router.js",
256
+ },
257
+ },
258
+ null,
259
+ 2,
260
+ ),
261
+ );
262
+ }
263
+
264
+ const result = await buildClientBundles(manifest, rootDir, {
265
+ minify: false,
266
+ sourcemap: false,
267
+ splitting: false,
268
+ ...(mode === "client-boundary-target" ? { targetRouteIds: ["boundary-demo"] } : {}),
269
+ ...(mode === "client-boundary-skip-framework" ? { skipFrameworkBundles: true } : {}),
270
+ });
165
271
  process.stdout.write(
166
272
  JSON.stringify({
167
- success: result.success,
168
- errors: result.errors,
169
- manifest: {
170
- shared: {
273
+ success: result.success,
274
+ errors: result.errors,
275
+ manifest: {
276
+ routes: manifest.routes.map((route) => ({
277
+ id: route.id,
278
+ module: route.module,
279
+ componentModule: route.componentModule,
280
+ boundaries: route.kind === "page" ? route.boundaries ?? [] : [],
281
+ })),
282
+ shared: {
171
283
  runtime: result.manifest.shared?.runtime ?? null,
172
284
  vendor: result.manifest.shared?.vendor ?? null,
173
285
  router: result.manifest.shared?.router ?? null,
174
286
  fastRefresh: result.manifest.shared?.fastRefresh ?? null,
175
287
  },
176
- bundles: result.manifest.bundles ?? {},
177
- },
178
- }) + "\n",
179
- );
288
+ bundles: result.manifest.bundles ?? {},
289
+ boundaries: result.manifest.boundaries ?? {},
290
+ },
291
+ }) + "\n",
292
+ );
180
293
  process.exit(result.success ? 0 : 1);
181
294
  } catch (err) {
182
295
  process.stdout.write(