@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.
Files changed (35) 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__/page-render-response.test.ts +212 -0
  31. package/src/runtime/handlers.ts +50 -26
  32. package/src/runtime/page-render-response.ts +1 -0
  33. package/src/runtime/ssr.ts +16 -5
  34. package/src/runtime/streaming-ssr.ts +119 -76
  35. package/src/spec/schema.ts +31 -5
@@ -4,7 +4,7 @@ import type * as __ManduPluginsReactCompilerTypes0 from "./plugins/react-compile
4
4
  * Bun.build 기반 클라이언트 번들 빌드
5
5
  */
6
6
 
7
- import type { RoutesManifest, RouteSpec } from "../spec/schema";
7
+ import type { RouteClientBoundary, RoutesManifest, RouteSpec } from "../spec/schema";
8
8
  import { needsHydration, getRouteHydration } from "../spec/schema";
9
9
  import type {
10
10
  BundleResult,
@@ -36,7 +36,19 @@ import {
36
36
  type VendorCacheWriteEntry,
37
37
  } from "./vendor-cache";
38
38
  import path from "path";
39
- import fs from "fs/promises";
39
+ import fs from "fs/promises";
40
+
41
+ interface BoundaryBundleBuild {
42
+ id: string;
43
+ route: string;
44
+ js: string;
45
+ module: string;
46
+ exportName: string;
47
+ priority: "immediate" | "visible" | "idle" | "interaction";
48
+ hydrate: string;
49
+ size: number;
50
+ gzipSize: number;
51
+ }
40
52
 
41
53
  /**
42
54
  * Resolve Mandu's default bundler plugin set from a `BundlerOptions`
@@ -411,23 +423,24 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
411
423
  /**
412
424
  * Hydration이 필요한 라우트 필터링
413
425
  */
414
- function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
415
- return manifest.routes.filter(
416
- (route) =>
417
- route.kind === "page" &&
418
- route.clientModule &&
419
- needsHydration(route)
420
- );
421
- }
426
+ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
427
+ return manifest.routes.filter(
428
+ (route) =>
429
+ route.kind === "page" &&
430
+ (!!route.clientModule || !!route.boundaries?.length) &&
431
+ needsHydration(route)
432
+ );
433
+ }
422
434
 
423
435
  function getHydrationRoutesMissingClientModule(manifest: RoutesManifest): RouteSpec[] {
424
436
  return manifest.routes.filter(
425
- (route) =>
426
- route.kind === "page" &&
427
- !route.clientModule &&
428
- needsHydration(route)
429
- );
430
- }
437
+ (route) =>
438
+ route.kind === "page" &&
439
+ !route.clientModule &&
440
+ !route.boundaries?.length &&
441
+ needsHydration(route)
442
+ );
443
+ }
431
444
 
432
445
  const REACT_SHIM_EXPORTS = [
433
446
  "Activity",
@@ -525,6 +538,7 @@ import { hydrateRoot, createRoot } from 'react-dom/client';
525
538
  // Hydrated roots 추적 (unmount용) - 전역 초기화
526
539
  window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
527
540
  const hydratedRoots = window.__MANDU_ROOTS__;
541
+ const warnedBoundaryPropFallbacks = new Set();
528
542
 
529
543
  const TYPE_MARKERS = {
530
544
  UNDEFINED: "\\u0000_",
@@ -624,7 +638,21 @@ function readManduData() {
624
638
  return window.__MANDU_DATA__;
625
639
  }
626
640
 
627
- const getServerData = (id) => readManduData()[id]?.serverData || {};
641
+ const getServerData = (id, element) => {
642
+ const data = readManduData();
643
+ if (data[id] && Object.prototype.hasOwnProperty.call(data[id], 'serverData')) {
644
+ return data[id].serverData;
645
+ }
646
+ const routeId = element?.getAttribute?.('data-mandu-route-id');
647
+ if (
648
+ routeId &&
649
+ data[routeId] &&
650
+ Object.prototype.hasOwnProperty.call(data[routeId], 'serverData')
651
+ ) {
652
+ return data[routeId].serverData;
653
+ }
654
+ return {};
655
+ };
628
656
 
629
657
  function findPropsScript(id) {
630
658
  const scripts = document.querySelectorAll('script[data-mandu-props]');
@@ -661,7 +689,33 @@ function readDataProps(element) {
661
689
  }
662
690
 
663
691
  function getIslandProps(id, element) {
664
- return parsePropsScript(id) || readDataProps(element) || getServerData(id);
692
+ const inlineProps = parsePropsScript(id);
693
+ if (inlineProps) return inlineProps;
694
+
695
+ const dataProps = readDataProps(element);
696
+ if (dataProps) return dataProps;
697
+
698
+ const boundaryId = element?.getAttribute?.('data-mandu-boundary-id');
699
+ if (boundaryId && !warnedBoundaryPropFallbacks.has(id)) {
700
+ warnedBoundaryPropFallbacks.add(id);
701
+ console.warn(
702
+ '[Mandu] Missing boundary-local props for transformed client boundary ' +
703
+ boundaryId +
704
+ '; falling back to route server data.'
705
+ );
706
+ }
707
+
708
+ return getServerData(id, element);
709
+ }
710
+
711
+ function resolveIslandExport(module, element) {
712
+ const exportName = element.getAttribute('data-mandu-client-export');
713
+ if (exportName) {
714
+ if (exportName === 'default' && module.default) return module.default;
715
+ if (exportName !== 'default' && module[exportName]) return module[exportName];
716
+ console.warn('[Mandu] Client boundary export "' + exportName + '" was not found; falling back to default export.');
717
+ }
718
+ return module.default;
665
719
  }
666
720
 
667
721
  /**
@@ -908,8 +962,8 @@ async function loadAndHydrate(element, src) {
908
962
 
909
963
  try {
910
964
  // Dynamic import - 이 시점에 Island 모듈 로드
911
- const module = await import(src);
912
- const island = module.default;
965
+ const module = await import(src);
966
+ const island = resolveIslandExport(module, element);
913
967
  const data = getIslandProps(id, element);
914
968
 
915
969
  // Mandu Island (preferred)
@@ -995,20 +1049,27 @@ async function loadAndHydrate(element, src) {
995
1049
 
996
1050
  console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
997
1051
  }
998
- // Plain React component fallback (e.g. "use client" pages)
999
- else if (typeof island === 'function' || React.isValidElement(island)) {
1000
- console.warn('[Mandu] Plain component hydration:', id);
1001
- const renderMode = 'hydrate';
1002
-
1003
- const root = typeof island === 'function'
1004
- ? hydrateRoot(
1005
- element,
1006
- React.createElement(island, data),
1007
- createHydrationOptions(element, id, renderMode)
1008
- )
1009
- : hydrateRoot(element, island, createHydrationOptions(element, id, renderMode));
1010
-
1011
- hydratedRoots.set(id, root);
1052
+ // Plain React component fallback (e.g. "use client" pages)
1053
+ else if (typeof island === 'function' || React.isValidElement(island)) {
1054
+ console.warn('[Mandu] Plain component hydration:', id);
1055
+ const shouldHydrate = hasHydratableMarkup(element);
1056
+ const renderMode = shouldHydrate ? 'hydrate' : 'mount';
1057
+
1058
+ const root = shouldHydrate
1059
+ ? (typeof island === 'function'
1060
+ ? hydrateRoot(
1061
+ element,
1062
+ React.createElement(island, data),
1063
+ createHydrationOptions(element, id, renderMode)
1064
+ )
1065
+ : hydrateRoot(element, island, createHydrationOptions(element, id, renderMode)))
1066
+ : createRoot(element);
1067
+
1068
+ if (!shouldHydrate) {
1069
+ root.render(typeof island === 'function' ? React.createElement(island, data) : island);
1070
+ }
1071
+
1072
+ hydratedRoots.set(id, root);
1012
1073
 
1013
1074
  // 완료 표시
1014
1075
  element.setAttribute('data-mandu-render-mode', renderMode);
@@ -1589,6 +1650,9 @@ function generateIslandEntry(routeId: string, clientModulePath: string, exportNa
1589
1650
  // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1590
1651
  const normalizedPath = clientModulePath.replace(/\\/g, "/");
1591
1652
  const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
1653
+ const namedExport = normalizedExportName && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalizedExportName)
1654
+ ? `export const ${normalizedExportName} = exportedIsland;`
1655
+ : "";
1592
1656
  const candidates = [
1593
1657
  normalizedExportName,
1594
1658
  inferClientExportNameFromPath(clientModulePath),
@@ -1608,8 +1672,10 @@ import React from "react";
1608
1672
  import * as islandModule from ${importSpecifier};
1609
1673
 
1610
1674
  const candidateExportNames = ${JSON.stringify(candidates)};
1675
+ const explicitExportName = ${JSON.stringify(normalizedExportName ?? null)};
1611
1676
 
1612
1677
  function resolveIslandExport(mod) {
1678
+ if (explicitExportName && mod[explicitExportName]) return mod[explicitExportName];
1613
1679
  if (mod.default) return mod.default;
1614
1680
  for (const name of candidateExportNames) {
1615
1681
  if (mod[name]) return mod[name];
@@ -1630,6 +1696,7 @@ const exportedIsland = island && island.__mandu_island === true
1630
1696
  };
1631
1697
 
1632
1698
  export default exportedIsland;
1699
+ ${namedExport}
1633
1700
  `;
1634
1701
  }
1635
1702
 
@@ -2095,7 +2162,7 @@ function routeIdToAssetStem(routeId: string): string {
2095
2162
  /**
2096
2163
  * 단일 Island 번들 빌드
2097
2164
  */
2098
- async function buildIsland(
2165
+ async function buildIsland(
2099
2166
  route: RouteSpec,
2100
2167
  rootDir: string,
2101
2168
  outDir: string,
@@ -2178,8 +2245,171 @@ async function buildIsland(
2178
2245
  } catch (error) {
2179
2246
  await fs.unlink(entryPath).catch(() => {});
2180
2247
  throw error;
2181
- }
2182
- }
2248
+ }
2249
+ }
2250
+
2251
+ async function buildBoundaryBundle(
2252
+ boundary: RouteClientBoundary,
2253
+ rootDir: string,
2254
+ outDir: string,
2255
+ options: BundlerOptions,
2256
+ ): Promise<BoundaryBundleBuild> {
2257
+ const clientModulePath = path.join(rootDir, boundary.module);
2258
+ const assetStem = routeIdToAssetStem(boundary.id);
2259
+ const entryStem = `_entry_boundary_${assetStem}`;
2260
+ const entryPath = path.join(outDir, `${entryStem}.js`);
2261
+ const outputName = `${assetStem}.boundary.js`;
2262
+ const isDev = isDevelopmentBuild(options);
2263
+
2264
+ try {
2265
+ await Bun.write(entryPath, generateIslandEntry(boundary.id, clientModulePath, boundary.exportName));
2266
+
2267
+ const result = await safeBuild({
2268
+ entrypoints: [entryPath],
2269
+ outdir: outDir,
2270
+ naming: options.splitting ? "[name]-[hash].js" : outputName,
2271
+ minify: shouldMinify(options),
2272
+ sourcemap: options.sourcemap ? "external" : "none",
2273
+ target: "browser",
2274
+ splitting: shouldSplitChunks(options),
2275
+ ...(isDev ? { reactFastRefresh: true } : {}),
2276
+ plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
2277
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
2278
+ define: {
2279
+ "process.env.NODE_ENV": nodeEnvDefine(options),
2280
+ ...options.define,
2281
+ },
2282
+ });
2283
+
2284
+ await fs.unlink(entryPath).catch(() => {});
2285
+
2286
+ if (!result.success) {
2287
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
2288
+ throw new Error(`Boundary build failed for '${boundary.id}' (source: ${clientModulePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this client boundary file.`);
2289
+ }
2290
+
2291
+ let actualOutputPath: string;
2292
+ let actualOutputName: string;
2293
+ if (options.splitting && result.outputs.length > 0) {
2294
+ const entryOutput = result.outputs.find(
2295
+ (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem),
2296
+ );
2297
+ actualOutputPath = entryOutput?.path ?? result.outputs[0].path;
2298
+ actualOutputName = path.basename(actualOutputPath);
2299
+ } else {
2300
+ actualOutputPath = path.join(outDir, outputName);
2301
+ actualOutputName = outputName;
2302
+ }
2303
+
2304
+ const outputFile = Bun.file(actualOutputPath);
2305
+ const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
2306
+ const gzipped = Bun.gzipSync(Buffer.from(content));
2307
+ const priority = boundaryPriorityToLegacyPriority(boundary.hydrate);
2308
+
2309
+ return {
2310
+ id: boundary.id,
2311
+ route: boundary.routeId,
2312
+ js: `/.mandu/client/${actualOutputName}`,
2313
+ module: boundary.module,
2314
+ exportName: boundary.exportName,
2315
+ priority,
2316
+ hydrate: boundary.hydrate,
2317
+ size: outputFile.size,
2318
+ gzipSize: gzipped.length,
2319
+ };
2320
+ } finally {
2321
+ await fs.unlink(entryPath).catch(() => {});
2322
+ }
2323
+ }
2324
+
2325
+ async function buildBoundaryBundlesForRecords(
2326
+ boundaries: RouteClientBoundary[],
2327
+ rootDir: string,
2328
+ outDir: string,
2329
+ options: BundlerOptions,
2330
+ errors: string[],
2331
+ ): Promise<BoundaryBundleBuild[]> {
2332
+ if (boundaries.length === 0) return [];
2333
+ if (pushDuplicateBoundaryIdErrors(boundaries, errors)) return [];
2334
+
2335
+ const results = await Promise.all(
2336
+ boundaries.map(async (boundary) => {
2337
+ try {
2338
+ return await buildBoundaryBundle(boundary, rootDir, outDir, options);
2339
+ } catch (error) {
2340
+ errors.push(`[boundary:${boundary.id}] ${String(error)}`);
2341
+ return null;
2342
+ }
2343
+ }),
2344
+ );
2345
+
2346
+ return results.filter((result): result is BoundaryBundleBuild => result !== null);
2347
+ }
2348
+
2349
+ function pushDuplicateBoundaryIdErrors(boundaries: RouteClientBoundary[], errors: string[]): boolean {
2350
+ const firstById = new Map<string, RouteClientBoundary>();
2351
+ let hasDuplicate = false;
2352
+
2353
+ for (const boundary of boundaries) {
2354
+ const first = firstById.get(boundary.id);
2355
+ if (!first) {
2356
+ firstById.set(boundary.id, boundary);
2357
+ continue;
2358
+ }
2359
+
2360
+ hasDuplicate = true;
2361
+ errors.push(
2362
+ `[boundary:${boundary.id}] MANDU_BOUNDARY_DUPLICATE_ID Duplicate client boundary id. ` +
2363
+ `First route="${first.routeId}" source="${first.source.file}", duplicate route="${boundary.routeId}" source="${boundary.source.file}". ` +
2364
+ "Boundary ids must be unique before bundle manifest generation.",
2365
+ );
2366
+ }
2367
+
2368
+ return hasDuplicate;
2369
+ }
2370
+
2371
+ function mergeBoundaryBundlesIntoManifest(
2372
+ manifest: BundleManifest,
2373
+ routeIds: Iterable<string>,
2374
+ boundaryBundles: BoundaryBundleBuild[],
2375
+ ): void {
2376
+ const rebuiltRouteIds = new Set(routeIds);
2377
+ if (rebuiltRouteIds.size === 0 && boundaryBundles.length === 0) return;
2378
+
2379
+ if (manifest.boundaries) {
2380
+ for (const [id, boundary] of Object.entries(manifest.boundaries)) {
2381
+ if (rebuiltRouteIds.has(boundary.route)) {
2382
+ delete manifest.boundaries[id];
2383
+ }
2384
+ }
2385
+ }
2386
+
2387
+ if (boundaryBundles.length > 0) {
2388
+ manifest.boundaries = manifest.boundaries || {};
2389
+ for (const boundary of boundaryBundles) {
2390
+ manifest.boundaries[boundary.id] = {
2391
+ route: boundary.route,
2392
+ js: boundary.js,
2393
+ module: boundary.module,
2394
+ exportName: boundary.exportName,
2395
+ priority: boundary.priority,
2396
+ hydrate: boundary.hydrate,
2397
+ };
2398
+ }
2399
+ }
2400
+
2401
+ if (manifest.boundaries && Object.keys(manifest.boundaries).length === 0) {
2402
+ delete manifest.boundaries;
2403
+ }
2404
+ }
2405
+
2406
+ function boundaryPriorityToLegacyPriority(value: string): BoundaryBundleBuild["priority"] {
2407
+ if (value === "load") return "immediate";
2408
+ if (value === "immediate" || value === "visible" || value === "idle" || value === "interaction") {
2409
+ return value;
2410
+ }
2411
+ return "visible";
2412
+ }
2183
2413
 
2184
2414
  async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
2185
2415
  const source = await Bun.file(outputPath).text();
@@ -2232,10 +2462,11 @@ function createBundleManifest(
2232
2462
  runtimePath: string,
2233
2463
  vendorResult: VendorBuildResult,
2234
2464
  routerPath: string,
2235
- env: "development" | "production",
2236
- islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
2237
- partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
2238
- ): BundleManifest {
2465
+ env: "development" | "production",
2466
+ islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
2467
+ partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
2468
+ boundaryBundles?: BoundaryBundleBuild[],
2469
+ ): BundleManifest {
2239
2470
  const bundles: BundleManifest["bundles"] = {};
2240
2471
 
2241
2472
  for (const output of outputs) {
@@ -2262,7 +2493,7 @@ function createBundleManifest(
2262
2493
  }
2263
2494
  }
2264
2495
 
2265
- let partials: BundleManifest["partials"];
2496
+ let partials: BundleManifest["partials"];
2266
2497
  if (partialBundles && partialBundles.length > 0) {
2267
2498
  partials = {};
2268
2499
  for (const partial of partialBundles) {
@@ -2271,7 +2502,22 @@ function createBundleManifest(
2271
2502
  priority: partial.priority,
2272
2503
  };
2273
2504
  }
2274
- }
2505
+ }
2506
+
2507
+ let boundaries: BundleManifest["boundaries"];
2508
+ if (boundaryBundles && boundaryBundles.length > 0) {
2509
+ boundaries = {};
2510
+ for (const boundary of boundaryBundles) {
2511
+ boundaries[boundary.id] = {
2512
+ route: boundary.route,
2513
+ js: boundary.js,
2514
+ module: boundary.module,
2515
+ exportName: boundary.exportName,
2516
+ priority: boundary.priority,
2517
+ hydrate: boundary.hydrate,
2518
+ };
2519
+ }
2520
+ }
2275
2521
 
2276
2522
  // Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
2277
2523
  // preamble can inject a dynamic import pointing at them. Only
@@ -2288,10 +2534,11 @@ function createBundleManifest(
2288
2534
  version: 1,
2289
2535
  buildTime: new Date().toISOString(),
2290
2536
  env,
2291
- bundles,
2292
- ...(islands ? { islands } : {}),
2293
- ...(partials ? { partials } : {}),
2294
- shared: {
2537
+ bundles,
2538
+ ...(islands ? { islands } : {}),
2539
+ ...(partials ? { partials } : {}),
2540
+ ...(boundaries ? { boundaries } : {}),
2541
+ shared: {
2295
2542
  runtime: runtimePath,
2296
2543
  vendor: vendorResult.react, // primary vendor for backwards compatibility
2297
2544
  router: routerPath, // Client-side Router
@@ -2487,26 +2734,37 @@ export async function buildClientBundles(
2487
2734
  };
2488
2735
  }
2489
2736
 
2490
- // 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
2491
- if (options.targetRouteIds && options.targetRouteIds.length > 0) {
2492
- const targetRoutes = hydratedRoutes.filter((r) => options.targetRouteIds!.includes(r.id));
2493
-
2494
- const targetResults = await Promise.all(
2495
- targetRoutes.map(async (route) => {
2496
- try {
2497
- return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2498
- } catch (error) {
2737
+ // 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
2738
+ if (options.targetRouteIds && options.targetRouteIds.length > 0) {
2739
+ const targetRouteIds = new Set(options.targetRouteIds);
2740
+ const targetRoutes = hydratedRoutes.filter((r) => targetRouteIds.has(r.id));
2741
+ const targetIslandRoutes = targetRoutes.filter((route) => !!route.clientModule);
2742
+
2743
+ const targetResults = await Promise.all(
2744
+ targetIslandRoutes.map(async (route) => {
2745
+ try {
2746
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2747
+ } catch (error) {
2499
2748
  return { ok: false as const, routeId: route.id, error: String(error) };
2500
2749
  }
2501
2750
  }),
2502
2751
  );
2503
2752
  for (const r of targetResults) {
2504
2753
  if (r.ok) outputs.push(r.result);
2505
- else errors.push(`[${r.routeId}] ${r.error}`);
2506
- }
2507
-
2508
- // 기존 매니페스트를 읽어 변경된 Island만 갱신
2509
- let existingManifest: BundleManifest;
2754
+ else errors.push(`[${r.routeId}] ${r.error}`);
2755
+ }
2756
+
2757
+ const boundaryRecords = targetRoutes.flatMap((route) => route.boundaries ?? []);
2758
+ const boundaryBundles = await buildBoundaryBundlesForRecords(
2759
+ boundaryRecords,
2760
+ rootDir,
2761
+ outDir,
2762
+ options,
2763
+ errors,
2764
+ );
2765
+
2766
+ // 기존 매니페스트를 읽어 변경된 Island만 갱신
2767
+ let existingManifest: BundleManifest;
2510
2768
  try {
2511
2769
  const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
2512
2770
  existingManifest = JSON.parse(manifestData) as BundleManifest;
@@ -2519,31 +2777,45 @@ export async function buildClientBundles(
2519
2777
  for (const routeId of invalidClientRouteIds) {
2520
2778
  delete existingManifest.bundles[routeId];
2521
2779
  }
2522
- if (outputs.length > 0 || invalidClientRouteIds.size > 0) {
2523
- for (const output of outputs) {
2524
- if (existingManifest.bundles[output.routeId]) {
2525
- existingManifest.bundles[output.routeId].js = output.outputPath;
2526
- } else {
2527
- const route = targetRoutes.find((r) => r.id === output.routeId);
2528
- const hydration = route ? getRouteHydration(route) : null;
2529
- existingManifest.bundles[output.routeId] = {
2530
- js: output.outputPath,
2780
+ if (outputs.length > 0 || invalidClientRouteIds.size > 0 || boundaryRecords.length > 0) {
2781
+ for (const output of outputs) {
2782
+ if (existingManifest.bundles[output.routeId]) {
2783
+ existingManifest.bundles[output.routeId].js = output.outputPath;
2784
+ } else {
2785
+ const route = targetIslandRoutes.find((r) => r.id === output.routeId);
2786
+ const hydration = route ? getRouteHydration(route) : null;
2787
+ existingManifest.bundles[output.routeId] = {
2788
+ js: output.outputPath,
2531
2789
  dependencies: ["_runtime", "_react"],
2532
2790
  priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
2533
- };
2534
- }
2535
- }
2536
-
2537
- await fs.writeFile(
2538
- path.join(rootDir, ".mandu/manifest.json"),
2791
+ };
2792
+ }
2793
+ }
2794
+
2795
+ mergeBoundaryBundlesIntoManifest(
2796
+ existingManifest,
2797
+ targetRoutes.map((route) => route.id),
2798
+ boundaryBundles,
2799
+ );
2800
+
2801
+ await fs.writeFile(
2802
+ path.join(rootDir, ".mandu/manifest.json"),
2539
2803
  JSON.stringify(existingManifest, null, 2)
2540
2804
  );
2541
2805
  }
2542
- // When all builds failed, do NOT overwrite manifest — keep previous good state
2543
-
2544
- const stats = calculateStats(outputs, startTime);
2545
- return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2546
- }
2806
+ // When all builds failed, do NOT overwrite manifest — keep previous good state
2807
+
2808
+ const stats = calculateStats(
2809
+ outputs,
2810
+ startTime,
2811
+ boundaryBundles.map((boundary) => ({
2812
+ routeId: `boundary:${boundary.id}`,
2813
+ size: boundary.size,
2814
+ gzipSize: boundary.gzipSize,
2815
+ })),
2816
+ );
2817
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2818
+ }
2547
2819
 
2548
2820
  // #185: Framework-internal 번들 스킵 모드
2549
2821
  // 사용자 코드(src/shared 등) 변경 시 runtime/router/vendor/devtools 재빌드는 낭비.
@@ -2599,7 +2871,7 @@ export async function buildClientBundles(
2599
2871
  }
2600
2872
 
2601
2873
  const islandResults = await Promise.all(
2602
- hydratedRoutes.map(async (route) => {
2874
+ hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
2603
2875
  try {
2604
2876
  return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2605
2877
  } catch (error) {
@@ -2645,18 +2917,32 @@ export async function buildClientBundles(
2645
2917
  };
2646
2918
  }
2647
2919
  }
2648
- if (perIslandBundles.length > 0) {
2649
- existingManifest.islands = existingManifest.islands || {};
2650
- for (const ib of perIslandBundles) {
2651
- existingManifest.islands[ib.name] = {
2920
+ if (perIslandBundles.length > 0) {
2921
+ existingManifest.islands = existingManifest.islands || {};
2922
+ for (const ib of perIslandBundles) {
2923
+ existingManifest.islands[ib.name] = {
2652
2924
  js: ib.js,
2653
2925
  route: ib.route,
2654
2926
  priority: ib.priority,
2655
- };
2656
- }
2657
- }
2658
-
2659
- const partialBundles: PartialBundleBuild[] = [];
2927
+ };
2928
+ }
2929
+ }
2930
+
2931
+ const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
2932
+ const boundaryBundles = await buildBoundaryBundlesForRecords(
2933
+ boundaryRecords,
2934
+ rootDir,
2935
+ outDir,
2936
+ options,
2937
+ errors,
2938
+ );
2939
+ mergeBoundaryBundlesIntoManifest(
2940
+ existingManifest,
2941
+ hydratedRoutes.map((route) => route.id),
2942
+ boundaryBundles,
2943
+ );
2944
+
2945
+ const partialBundles: PartialBundleBuild[] = [];
2660
2946
  if (partialFiles.length > 0) {
2661
2947
  const partialResults = await Promise.all(
2662
2948
  partialFiles.map(async (entry) => {
@@ -2690,15 +2976,22 @@ export async function buildClientBundles(
2690
2976
  JSON.stringify(existingManifest, null, 2),
2691
2977
  );
2692
2978
 
2693
- const stats = calculateStats(
2694
- outputs,
2695
- startTime,
2696
- partialBundles.map((partial) => ({
2697
- routeId: `partial:${partial.name}`,
2698
- size: partial.size,
2699
- gzipSize: partial.gzipSize,
2700
- })),
2701
- );
2979
+ const stats = calculateStats(
2980
+ outputs,
2981
+ startTime,
2982
+ [
2983
+ ...partialBundles.map((partial) => ({
2984
+ routeId: `partial:${partial.name}`,
2985
+ size: partial.size,
2986
+ gzipSize: partial.gzipSize,
2987
+ })),
2988
+ ...boundaryBundles.map((boundary) => ({
2989
+ routeId: `boundary:${boundary.id}`,
2990
+ size: boundary.size,
2991
+ gzipSize: boundary.gzipSize,
2992
+ })),
2993
+ ],
2994
+ );
2702
2995
  return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2703
2996
  }
2704
2997
 
@@ -2756,7 +3049,7 @@ export async function buildClientBundles(
2756
3049
 
2757
3050
  // 5. 각 Island 번들 병렬 빌드 (#185: L1631의 per-island와 일관성 확보)
2758
3051
  const fullIslandResults = await Promise.all(
2759
- hydratedRoutes.map(async (route) => {
3052
+ hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
2760
3053
  try {
2761
3054
  return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2762
3055
  } catch (error) {
@@ -2783,9 +3076,9 @@ export async function buildClientBundles(
2783
3076
 
2784
3077
  // 5.5. Per-island code splitting: scan and build individual island bundles
2785
3078
  const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
2786
- const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
3079
+ const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
2787
3080
 
2788
- if (islandFiles.length > 0) {
3081
+ if (islandFiles.length > 0) {
2789
3082
  const islandResults = await Promise.all(
2790
3083
  islandFiles.map(async (entry) => {
2791
3084
  try {
@@ -2799,9 +3092,27 @@ export async function buildClientBundles(
2799
3092
  for (const result of islandResults) {
2800
3093
  if (result) islandBundles.push(result);
2801
3094
  }
2802
- }
2803
-
2804
- const partialBundles: PartialBundleBuild[] = [];
3095
+ }
3096
+
3097
+ const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
3098
+ const boundaryBundles: BoundaryBundleBuild[] = [];
3099
+ if (boundaryRecords.length > 0 && !pushDuplicateBoundaryIdErrors(boundaryRecords, errors)) {
3100
+ const boundaryResults = await Promise.all(
3101
+ boundaryRecords.map(async (boundary) => {
3102
+ try {
3103
+ return await buildBoundaryBundle(boundary, rootDir, outDir, options);
3104
+ } catch (error) {
3105
+ errors.push(`[boundary:${boundary.id}] ${String(error)}`);
3106
+ return null;
3107
+ }
3108
+ }),
3109
+ );
3110
+ for (const result of boundaryResults) {
3111
+ if (result) boundaryBundles.push(result);
3112
+ }
3113
+ }
3114
+
3115
+ const partialBundles: PartialBundleBuild[] = [];
2805
3116
  if (partialFiles.length > 0) {
2806
3117
  const partialResults = await Promise.all(
2807
3118
  partialFiles.map(async (entry) => {
@@ -2825,10 +3136,11 @@ export async function buildClientBundles(
2825
3136
  runtimeResult.outputPath,
2826
3137
  vendorResult,
2827
3138
  routerResult.outputPath,
2828
- env,
2829
- islandBundles,
2830
- partialBundles,
2831
- );
3139
+ env,
3140
+ islandBundles,
3141
+ partialBundles,
3142
+ boundaryBundles,
3143
+ );
2832
3144
 
2833
3145
  await fs.writeFile(
2834
3146
  path.join(rootDir, ".mandu/manifest.json"),
@@ -2836,15 +3148,22 @@ export async function buildClientBundles(
2836
3148
  );
2837
3149
 
2838
3150
  // 7. 통계 계산
2839
- const stats = calculateStats(
2840
- outputs,
2841
- startTime,
2842
- partialBundles.map((partial) => ({
2843
- routeId: `partial:${partial.name}`,
2844
- size: partial.size,
2845
- gzipSize: partial.gzipSize,
2846
- })),
2847
- );
3151
+ const stats = calculateStats(
3152
+ outputs,
3153
+ startTime,
3154
+ [
3155
+ ...partialBundles.map((partial) => ({
3156
+ routeId: `partial:${partial.name}`,
3157
+ size: partial.size,
3158
+ gzipSize: partial.gzipSize,
3159
+ })),
3160
+ ...boundaryBundles.map((boundary) => ({
3161
+ routeId: `boundary:${boundary.id}`,
3162
+ size: boundary.size,
3163
+ gzipSize: boundary.gzipSize,
3164
+ })),
3165
+ ],
3166
+ );
2848
3167
 
2849
3168
  // Phase 18.τ — fire onBundleComplete(stats) before return.
2850
3169
  await fireOnBundleComplete(stats);
@@ -2873,14 +3192,15 @@ export function formatSize(bytes: number): string {
2873
3192
  */
2874
3193
  export function printBundleStats(result: BundleResult): void {
2875
3194
  console.log("\n📦 Mandu Client Bundles");
2876
- console.log("=".repeat(50));
2877
-
2878
- const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2879
- if (result.outputs.length === 0 && partialCount === 0) {
2880
- console.log("No islands or partials to bundle (hydration: none or no client entry)");
2881
- if (result.errors.length > 0) {
2882
- console.log("\n⚠️ Errors:");
2883
- for (const error of result.errors) {
3195
+ console.log("=".repeat(50));
3196
+
3197
+ const partialCount = Object.keys(result.manifest.partials ?? {}).length;
3198
+ const boundaryCount = Object.keys(result.manifest.boundaries ?? {}).length;
3199
+ if (result.outputs.length === 0 && partialCount === 0 && boundaryCount === 0) {
3200
+ console.log("No islands, partials, or boundaries to bundle (hydration: none or no client entry)");
3201
+ if (result.errors.length > 0) {
3202
+ console.log("\n⚠️ Errors:");
3203
+ for (const error of result.errors) {
2884
3204
  console.log(` ${error}`);
2885
3205
  }
2886
3206
  }
@@ -2900,11 +3220,14 @@ export function printBundleStats(result: BundleResult): void {
2900
3220
  ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
2901
3221
  );
2902
3222
  }
2903
- if (partialCount > 0) {
2904
- console.log(` Partials: ${partialCount}`);
2905
- }
2906
-
2907
- if (result.errors.length > 0) {
3223
+ if (partialCount > 0) {
3224
+ console.log(` Partials: ${partialCount}`);
3225
+ }
3226
+ if (boundaryCount > 0) {
3227
+ console.log(` Boundaries: ${boundaryCount}`);
3228
+ }
3229
+
3230
+ if (result.errors.length > 0) {
2908
3231
  console.log("\n⚠️ Errors:");
2909
3232
  for (const error of result.errors) {
2910
3233
  console.log(` ${error}`);