@mandujs/core 0.54.23 → 0.54.25

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.
@@ -4,7 +4,7 @@ import type * as __ManduPluginsReactCompilerTypes0 from "./plugins/react-compile
4
4
  * Bun.build 기반 클라이언트 번들 빌드
5
5
  */
6
6
 
7
- import type { RouteClientBoundary, 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,19 +36,19 @@ import {
36
36
  type VendorCacheWriteEntry,
37
37
  } from "./vendor-cache";
38
38
  import path from "path";
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
- }
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
+ }
52
52
 
53
53
  /**
54
54
  * Resolve Mandu's default bundler plugin set from a `BundlerOptions`
@@ -423,24 +423,24 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
423
423
  /**
424
424
  * Hydration이 필요한 라우트 필터링
425
425
  */
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
- }
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
+ }
434
434
 
435
435
  function getHydrationRoutesMissingClientModule(manifest: RoutesManifest): RouteSpec[] {
436
436
  return manifest.routes.filter(
437
- (route) =>
438
- route.kind === "page" &&
439
- !route.clientModule &&
440
- !route.boundaries?.length &&
441
- needsHydration(route)
442
- );
443
- }
437
+ (route) =>
438
+ route.kind === "page" &&
439
+ !route.clientModule &&
440
+ !route.boundaries?.length &&
441
+ needsHydration(route)
442
+ );
443
+ }
444
444
 
445
445
  const REACT_SHIM_EXPORTS = [
446
446
  "Activity",
@@ -514,9 +514,9 @@ function formatShimBindings(names: readonly string[], indent = " "): string {
514
514
  return names.map((name) => `${indent}${name},`).join("\n");
515
515
  }
516
516
 
517
- /**
518
- * Runtime bundle entry point.
519
- *
517
+ /**
518
+ * Runtime bundle entry point.
519
+ *
520
520
  * The browser runtime lives in client/runtime-entry.ts so it is typechecked
521
521
  * with the rest of core and imports the shared props deserializer directly.
522
522
  */
@@ -633,9 +633,11 @@ function generateJsxRuntimeShimSource(): string {
633
633
  /**
634
634
  * Mandu JSX Runtime Shim (Generated)
635
635
  * Production JSX 변환용
636
- * 순환 참조 방지: 'react'에서 import (import map이 _react.js로 매핑)
636
+ * jsx/jsxs/Fragment는 'react/jsx-runtime' 원본에서 직접 가져온다.
637
+ * import map의 'react/jsx-runtime'이 다시 이 셰임을 가리키므로,
638
+ * 셰임 내부는 실제 원본 경로를 직접 참조해야 순환/누락이 생기지 않는다.
637
639
  */
638
- import { jsx, jsxs, Fragment } from 'react';
640
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
639
641
 
640
642
  // Named exports
641
643
  export { jsx, jsxs, Fragment };
@@ -654,9 +656,11 @@ function generateJsxDevRuntimeShimSource(): string {
654
656
  /**
655
657
  * Mandu JSX Dev Runtime Shim (Generated)
656
658
  * Development JSX 변환용
657
- * 순환 참조 방지: 'react'에서 import (import map이 _react.js로 매핑)
659
+ * jsxDEV/Fragment는 'react'가 아니라 'react/jsx-dev-runtime'에서만 export된다.
660
+ * import map의 'react/jsx-dev-runtime'이 다시 이 셰임을 가리키므로,
661
+ * 셰임 내부는 실제 원본 경로를 직접 참조해야 순환/누락(jsxDEV undefined)이 생기지 않는다.
658
662
  */
659
- import { jsxDEV, Fragment } from 'react';
663
+ import { jsxDEV, Fragment } from 'react/jsx-dev-runtime';
660
664
 
661
665
  // Named exports
662
666
  export { jsxDEV, Fragment };
@@ -666,6 +670,18 @@ export default { jsxDEV, Fragment };
666
670
  `;
667
671
  }
668
672
 
673
+ /**
674
+ * Test-only access to the JSX runtime shim generators so tests can assert
675
+ * that jsx/jsxDEV/Fragment are sourced from the correct React subpaths
676
+ * (regression guard for #322: jsxDEV must come from 'react/jsx-dev-runtime',
677
+ * never from bare 'react' which does not export it).
678
+ *
679
+ * @internal
680
+ */
681
+ export const _testOnly_generateJsxRuntimeShimSource = generateJsxRuntimeShimSource;
682
+ /** @internal */
683
+ export const _testOnly_generateJsxDevRuntimeShimSource = generateJsxDevRuntimeShimSource;
684
+
669
685
  /**
670
686
  * Client-side Router 런타임 소스 생성
671
687
  */
@@ -730,44 +746,44 @@ function patternCacheSet(key, value) {
730
746
  patternCache.set(key, value);
731
747
  }
732
748
 
733
- function compilePattern(pattern) {
734
- var cached = patternCacheGet(pattern);
735
- if (cached) return cached;
736
-
737
- const paramNames = [];
738
- const normalized = pattern === '/' ? '/' : pattern.replace(/\\/+$/, '') || '/';
739
- const segments = normalized.split('/').filter(Boolean);
740
- const regexStr = segments.length === 0
741
- ? '/'
742
- : segments.map((segment) => {
743
- if (segment === '*') return '/.+';
744
- const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\\*(\\?)?$/);
745
- if (wildcardMatch) {
746
- paramNames.push(wildcardMatch[1]);
747
- return wildcardMatch[2] === '?' ? '(?:/(.*))?' : '/(.+)';
748
- }
749
- const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
750
- if (paramMatch) {
751
- paramNames.push(paramMatch[1]);
752
- return '/([^/]+)';
753
- }
754
- return '/' + segment.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
755
- }).join('');
756
-
757
- const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
758
- patternCacheSet(pattern, compiled);
759
- return compiled;
760
- }
749
+ function compilePattern(pattern) {
750
+ var cached = patternCacheGet(pattern);
751
+ if (cached) return cached;
752
+
753
+ const paramNames = [];
754
+ const normalized = pattern === '/' ? '/' : pattern.replace(/\\/+$/, '') || '/';
755
+ const segments = normalized.split('/').filter(Boolean);
756
+ const regexStr = segments.length === 0
757
+ ? '/'
758
+ : segments.map((segment) => {
759
+ if (segment === '*') return '/.+';
760
+ const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\\*(\\?)?$/);
761
+ if (wildcardMatch) {
762
+ paramNames.push(wildcardMatch[1]);
763
+ return wildcardMatch[2] === '?' ? '(?:/(.*))?' : '/(.+)';
764
+ }
765
+ const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
766
+ if (paramMatch) {
767
+ paramNames.push(paramMatch[1]);
768
+ return '/([^/]+)';
769
+ }
770
+ return '/' + segment.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
771
+ }).join('');
772
+
773
+ const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
774
+ patternCacheSet(pattern, compiled);
775
+ return compiled;
776
+ }
761
777
 
762
778
  function extractParams(pattern, pathname) {
763
779
  const compiled = compilePattern(pattern);
764
780
  const match = pathname.match(compiled.regex);
765
781
  if (!match) return {};
766
-
767
- const params = {};
768
- compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1] || ''; });
769
- return params;
770
- }
782
+
783
+ const params = {};
784
+ compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1] || ''; });
785
+ return params;
786
+ }
771
787
 
772
788
  function notifyListeners() {
773
789
  const state = getGlobalState();
@@ -1010,74 +1026,74 @@ async function buildRouterRuntime(
1010
1026
  * - Runtime이 dynamic import로 로드
1011
1027
  * - 등록/초기화 코드 없음
1012
1028
  */
1013
- function generateIslandEntry(routeId: string, clientModulePath: string, exportName?: string): string {
1014
- // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1015
- const normalizedPath = clientModulePath.replace(/\\/g, "/");
1016
- const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
1017
- const namedExport = normalizedExportName && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalizedExportName)
1018
- ? `export const ${normalizedExportName} = exportedIsland;`
1019
- : "";
1020
- const candidates = [
1021
- normalizedExportName,
1022
- inferClientExportNameFromPath(clientModulePath),
1023
- inferClientExportNameFromRouteId(routeId),
1024
- ].filter((candidate, index, values): candidate is string =>
1025
- !!candidate && values.indexOf(candidate) === index
1026
- );
1027
- const importSpecifier = JSON.stringify(normalizedPath);
1028
- const routeLabel = JSON.stringify(routeId);
1029
- const commentRouteId = routeId.replace(/\*\//g, "* /");
1030
- return `
1031
- /**
1032
- * Mandu Island: ${commentRouteId} (Generated)
1033
- * Pure export - no side effects
1034
- */
1035
- import React from "react";
1036
- import * as islandModule from ${importSpecifier};
1037
-
1038
- const candidateExportNames = ${JSON.stringify(candidates)};
1039
- const explicitExportName = ${JSON.stringify(normalizedExportName ?? null)};
1040
-
1041
- function resolveIslandExport(mod) {
1042
- if (explicitExportName && mod[explicitExportName]) return mod[explicitExportName];
1043
- if (mod.default) return mod.default;
1044
- for (const name of candidateExportNames) {
1045
- if (mod[name]) return mod[name];
1046
- }
1047
- const runtimeExports = Object.keys(mod).filter((name) => name !== "__esModule");
1048
- if (runtimeExports.length === 1) return mod[runtimeExports[0]];
1049
- throw new Error(
1050
- "[Mandu Island] " + ${routeLabel} + " must export a default component" +
1051
- (candidateExportNames.length > 0 ? " or one of: " + candidateExportNames.join(", ") : "")
1052
- );
1053
- }
1054
-
1055
- const island = resolveIslandExport(islandModule);
1056
- const exportedIsland = island && island.__mandu_island === true
1057
- ? island
1058
- : function ManduGeneratedIsland(props) {
1059
- return React.createElement(island, props || {});
1060
- };
1061
-
1062
- export default exportedIsland;
1063
- ${namedExport}
1064
- `;
1065
- }
1066
-
1067
- function inferClientExportNameFromPath(clientModulePath: string): string | null {
1068
- const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
1069
- const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
1070
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix) ? withoutClientSuffix : null;
1071
- }
1072
-
1073
- function inferClientExportNameFromRouteId(routeId: string): string | null {
1074
- const pascal = routeId
1075
- .split(/[^A-Za-z0-9]+/)
1076
- .filter(Boolean)
1077
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1078
- .join("");
1079
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : null;
1080
- }
1029
+ function generateIslandEntry(routeId: string, clientModulePath: string, exportName?: string): string {
1030
+ // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1031
+ const normalizedPath = clientModulePath.replace(/\\/g, "/");
1032
+ const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
1033
+ const namedExport = normalizedExportName && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalizedExportName)
1034
+ ? `export const ${normalizedExportName} = exportedIsland;`
1035
+ : "";
1036
+ const candidates = [
1037
+ normalizedExportName,
1038
+ inferClientExportNameFromPath(clientModulePath),
1039
+ inferClientExportNameFromRouteId(routeId),
1040
+ ].filter((candidate, index, values): candidate is string =>
1041
+ !!candidate && values.indexOf(candidate) === index
1042
+ );
1043
+ const importSpecifier = JSON.stringify(normalizedPath);
1044
+ const routeLabel = JSON.stringify(routeId);
1045
+ const commentRouteId = routeId.replace(/\*\//g, "* /");
1046
+ return `
1047
+ /**
1048
+ * Mandu Island: ${commentRouteId} (Generated)
1049
+ * Pure export - no side effects
1050
+ */
1051
+ import React from "react";
1052
+ import * as islandModule from ${importSpecifier};
1053
+
1054
+ const candidateExportNames = ${JSON.stringify(candidates)};
1055
+ const explicitExportName = ${JSON.stringify(normalizedExportName ?? null)};
1056
+
1057
+ function resolveIslandExport(mod) {
1058
+ if (explicitExportName && mod[explicitExportName]) return mod[explicitExportName];
1059
+ if (mod.default) return mod.default;
1060
+ for (const name of candidateExportNames) {
1061
+ if (mod[name]) return mod[name];
1062
+ }
1063
+ const runtimeExports = Object.keys(mod).filter((name) => name !== "__esModule");
1064
+ if (runtimeExports.length === 1) return mod[runtimeExports[0]];
1065
+ throw new Error(
1066
+ "[Mandu Island] " + ${routeLabel} + " must export a default component" +
1067
+ (candidateExportNames.length > 0 ? " or one of: " + candidateExportNames.join(", ") : "")
1068
+ );
1069
+ }
1070
+
1071
+ const island = resolveIslandExport(islandModule);
1072
+ const exportedIsland = island && island.__mandu_island === true
1073
+ ? island
1074
+ : function ManduGeneratedIsland(props) {
1075
+ return React.createElement(island, props || {});
1076
+ };
1077
+
1078
+ export default exportedIsland;
1079
+ ${namedExport}
1080
+ `;
1081
+ }
1082
+
1083
+ function inferClientExportNameFromPath(clientModulePath: string): string | null {
1084
+ const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
1085
+ const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
1086
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix) ? withoutClientSuffix : null;
1087
+ }
1088
+
1089
+ function inferClientExportNameFromRouteId(routeId: string): string | null {
1090
+ const pascal = routeId
1091
+ .split(/[^A-Za-z0-9]+/)
1092
+ .filter(Boolean)
1093
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1094
+ .join("");
1095
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : null;
1096
+ }
1081
1097
 
1082
1098
  function generatePartialEntry(partialId: string, partialModulePath: string): string {
1083
1099
  const normalizedPath = partialModulePath.replace(/\\/g, "/");
@@ -1123,17 +1139,17 @@ export default {
1123
1139
  /**
1124
1140
  * Runtime 번들 빌드
1125
1141
  */
1126
- async function buildRuntime(
1127
- outDir: string,
1128
- options: BundlerOptions
1129
- ): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
1130
- const runtimePath = getRuntimeEntryPath();
1131
- const outputName = "_runtime.js";
1132
-
1133
- try {
1134
- const result = await safeBuild({
1135
- entrypoints: [runtimePath],
1136
- outdir: outDir,
1142
+ async function buildRuntime(
1143
+ outDir: string,
1144
+ options: BundlerOptions
1145
+ ): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
1146
+ const runtimePath = getRuntimeEntryPath();
1147
+ const outputName = "_runtime.js";
1148
+
1149
+ try {
1150
+ const result = await safeBuild({
1151
+ entrypoints: [runtimePath],
1152
+ outdir: outDir,
1137
1153
  naming: outputName,
1138
1154
  minify: shouldMinify(options),
1139
1155
  sourcemap: options.sourcemap ? "external" : "none",
@@ -1144,24 +1160,24 @@ async function buildRuntime(
1144
1160
  "process.env.NODE_ENV": nodeEnvDefine(options),
1145
1161
  ...options.define,
1146
1162
  },
1147
- });
1148
-
1149
- if (!result.success) {
1150
- const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1151
- return {
1152
- success: false,
1153
- outputPath: "",
1154
- errors: [`Runtime bundle build failed (source: ${runtimePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types.`],
1155
- };
1156
- }
1157
-
1158
- return {
1159
- success: true,
1160
- outputPath: `/.mandu/client/${outputName}`,
1161
- errors: [],
1162
- };
1163
- } catch (error: unknown) {
1164
- const extra: string[] = [];
1163
+ });
1164
+
1165
+ if (!result.success) {
1166
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1167
+ return {
1168
+ success: false,
1169
+ outputPath: "",
1170
+ errors: [`Runtime bundle build failed (source: ${runtimePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types.`],
1171
+ };
1172
+ }
1173
+
1174
+ return {
1175
+ success: true,
1176
+ outputPath: `/.mandu/client/${outputName}`,
1177
+ errors: [],
1178
+ };
1179
+ } catch (error: unknown) {
1180
+ const extra: string[] = [];
1165
1181
  const errObj = error as Record<string, unknown> | null;
1166
1182
  if (errObj && Array.isArray(errObj.errors)) {
1167
1183
  extra.push(...errObj.errors.map((e: unknown) => String((e as Record<string, unknown>)?.message || e)));
@@ -1500,41 +1516,41 @@ async function buildVendorShims(
1500
1516
  };
1501
1517
  }
1502
1518
 
1503
- function vendorShimFailureHint(shimName: string): string {
1504
- if (shimName.includes("react-refresh")) {
1505
- return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1506
- }
1507
- return "Hint: check the import paths and ensure the vendor package is installed.";
1508
- }
1509
-
1510
- function routeIdToAssetStem(routeId: string): string {
1511
- const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
1512
- `_${ch.codePointAt(0)!.toString(16)}_`
1513
- );
1514
- return safe.replace(/[. ]+$/g, "") || "route";
1515
- }
1516
-
1517
- /**
1518
- * 단일 Island 번들 빌드
1519
- */
1520
- async function buildIsland(
1519
+ function vendorShimFailureHint(shimName: string): string {
1520
+ if (shimName.includes("react-refresh")) {
1521
+ return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1522
+ }
1523
+ return "Hint: check the import paths and ensure the vendor package is installed.";
1524
+ }
1525
+
1526
+ function routeIdToAssetStem(routeId: string): string {
1527
+ const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
1528
+ `_${ch.codePointAt(0)!.toString(16)}_`
1529
+ );
1530
+ return safe.replace(/[. ]+$/g, "") || "route";
1531
+ }
1532
+
1533
+ /**
1534
+ * 단일 Island 번들 빌드
1535
+ */
1536
+ async function buildIsland(
1521
1537
  route: RouteSpec,
1522
1538
  rootDir: string,
1523
1539
  outDir: string,
1524
1540
  options: BundlerOptions
1525
- ): Promise<BundleOutput> {
1526
- const clientModulePath = path.join(rootDir, route.clientModule!);
1527
- const assetStem = routeIdToAssetStem(route.id);
1528
- const entryStem = `_entry_${assetStem}`;
1529
- const entryPath = path.join(outDir, `${entryStem}.js`);
1530
- const outputName = `${assetStem}.island.js`;
1541
+ ): Promise<BundleOutput> {
1542
+ const clientModulePath = path.join(rootDir, route.clientModule!);
1543
+ const assetStem = routeIdToAssetStem(route.id);
1544
+ const entryStem = `_entry_${assetStem}`;
1545
+ const entryPath = path.join(outDir, `${entryStem}.js`);
1546
+ const outputName = `${assetStem}.island.js`;
1531
1547
 
1532
1548
  // Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
1533
1549
  // boundary injection plugin. Dev-only; prod bundles remain clean.
1534
1550
  const isDev = isDevelopmentBuild(options);
1535
1551
  try {
1536
1552
  // 엔트리 래퍼 생성
1537
- await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath, route.clientExportName));
1553
+ await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath, route.clientExportName));
1538
1554
 
1539
1555
  // 빌드
1540
1556
  // splitting 옵션: true면 공통 코드를 별도 청크로 추출
@@ -1568,11 +1584,11 @@ async function buildIsland(
1568
1584
  let actualOutputPath: string;
1569
1585
  let actualOutputName: string;
1570
1586
 
1571
- if (options.splitting && result.outputs.length > 0) {
1572
- // splitting 모드: 결과에서 엔트리 파일 찾기
1573
- const entryOutput = result.outputs.find(
1574
- (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
1575
- );
1587
+ if (options.splitting && result.outputs.length > 0) {
1588
+ // splitting 모드: 결과에서 엔트리 파일 찾기
1589
+ const entryOutput = result.outputs.find(
1590
+ (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
1591
+ );
1576
1592
  if (entryOutput) {
1577
1593
  actualOutputPath = entryOutput.path;
1578
1594
  actualOutputName = path.basename(entryOutput.path);
@@ -1600,171 +1616,171 @@ async function buildIsland(
1600
1616
  } catch (error) {
1601
1617
  await fs.unlink(entryPath).catch(() => {});
1602
1618
  throw error;
1603
- }
1604
- }
1605
-
1606
- async function buildBoundaryBundle(
1607
- boundary: RouteClientBoundary,
1608
- rootDir: string,
1609
- outDir: string,
1610
- options: BundlerOptions,
1611
- ): Promise<BoundaryBundleBuild> {
1612
- const clientModulePath = path.join(rootDir, boundary.module);
1613
- const assetStem = routeIdToAssetStem(boundary.id);
1614
- const entryStem = `_entry_boundary_${assetStem}`;
1615
- const entryPath = path.join(outDir, `${entryStem}.js`);
1616
- const outputName = `${assetStem}.boundary.js`;
1617
- const isDev = isDevelopmentBuild(options);
1618
-
1619
- try {
1620
- await Bun.write(entryPath, generateIslandEntry(boundary.id, clientModulePath, boundary.exportName));
1621
-
1622
- const result = await safeBuild({
1623
- entrypoints: [entryPath],
1624
- outdir: outDir,
1625
- naming: options.splitting ? "[name]-[hash].js" : outputName,
1626
- minify: shouldMinify(options),
1627
- sourcemap: options.sourcemap ? "external" : "none",
1628
- target: "browser",
1629
- splitting: shouldSplitChunks(options),
1630
- ...(isDev ? { reactFastRefresh: true } : {}),
1631
- plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
1632
- external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1633
- define: {
1634
- "process.env.NODE_ENV": nodeEnvDefine(options),
1635
- ...options.define,
1636
- },
1637
- });
1638
-
1639
- await fs.unlink(entryPath).catch(() => {});
1640
-
1641
- if (!result.success) {
1642
- const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1643
- 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.`);
1644
- }
1645
-
1646
- let actualOutputPath: string;
1647
- let actualOutputName: string;
1648
- if (options.splitting && result.outputs.length > 0) {
1649
- const entryOutput = result.outputs.find(
1650
- (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem),
1651
- );
1652
- actualOutputPath = entryOutput?.path ?? result.outputs[0].path;
1653
- actualOutputName = path.basename(actualOutputPath);
1654
- } else {
1655
- actualOutputPath = path.join(outDir, outputName);
1656
- actualOutputName = outputName;
1657
- }
1658
-
1659
- const outputFile = Bun.file(actualOutputPath);
1660
- const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
1661
- const gzipped = Bun.gzipSync(Buffer.from(content));
1662
- const priority = boundaryPriorityToLegacyPriority(boundary.hydrate);
1663
-
1664
- return {
1665
- id: boundary.id,
1666
- route: boundary.routeId,
1667
- js: `/.mandu/client/${actualOutputName}`,
1668
- module: boundary.module,
1669
- exportName: boundary.exportName,
1670
- priority,
1671
- hydrate: boundary.hydrate,
1672
- size: outputFile.size,
1673
- gzipSize: gzipped.length,
1674
- };
1675
- } finally {
1676
- await fs.unlink(entryPath).catch(() => {});
1677
- }
1678
- }
1679
-
1680
- async function buildBoundaryBundlesForRecords(
1681
- boundaries: RouteClientBoundary[],
1682
- rootDir: string,
1683
- outDir: string,
1684
- options: BundlerOptions,
1685
- errors: string[],
1686
- ): Promise<BoundaryBundleBuild[]> {
1687
- if (boundaries.length === 0) return [];
1688
- if (pushDuplicateBoundaryIdErrors(boundaries, errors)) return [];
1689
-
1690
- const results = await Promise.all(
1691
- boundaries.map(async (boundary) => {
1692
- try {
1693
- return await buildBoundaryBundle(boundary, rootDir, outDir, options);
1694
- } catch (error) {
1695
- errors.push(`[boundary:${boundary.id}] ${String(error)}`);
1696
- return null;
1697
- }
1698
- }),
1699
- );
1700
-
1701
- return results.filter((result): result is BoundaryBundleBuild => result !== null);
1702
- }
1703
-
1704
- function pushDuplicateBoundaryIdErrors(boundaries: RouteClientBoundary[], errors: string[]): boolean {
1705
- const firstById = new Map<string, RouteClientBoundary>();
1706
- let hasDuplicate = false;
1707
-
1708
- for (const boundary of boundaries) {
1709
- const first = firstById.get(boundary.id);
1710
- if (!first) {
1711
- firstById.set(boundary.id, boundary);
1712
- continue;
1713
- }
1714
-
1715
- hasDuplicate = true;
1716
- errors.push(
1717
- `[boundary:${boundary.id}] MANDU_BOUNDARY_DUPLICATE_ID Duplicate client boundary id. ` +
1718
- `First route="${first.routeId}" source="${first.source.file}", duplicate route="${boundary.routeId}" source="${boundary.source.file}". ` +
1719
- "Boundary ids must be unique before bundle manifest generation.",
1720
- );
1721
- }
1722
-
1723
- return hasDuplicate;
1724
- }
1725
-
1726
- function mergeBoundaryBundlesIntoManifest(
1727
- manifest: BundleManifest,
1728
- routeIds: Iterable<string>,
1729
- boundaryBundles: BoundaryBundleBuild[],
1730
- ): void {
1731
- const rebuiltRouteIds = new Set(routeIds);
1732
- if (rebuiltRouteIds.size === 0 && boundaryBundles.length === 0) return;
1733
-
1734
- if (manifest.boundaries) {
1735
- for (const [id, boundary] of Object.entries(manifest.boundaries)) {
1736
- if (rebuiltRouteIds.has(boundary.route)) {
1737
- delete manifest.boundaries[id];
1738
- }
1739
- }
1740
- }
1741
-
1742
- if (boundaryBundles.length > 0) {
1743
- manifest.boundaries = manifest.boundaries || {};
1744
- for (const boundary of boundaryBundles) {
1745
- manifest.boundaries[boundary.id] = {
1746
- route: boundary.route,
1747
- js: boundary.js,
1748
- module: boundary.module,
1749
- exportName: boundary.exportName,
1750
- priority: boundary.priority,
1751
- hydrate: boundary.hydrate,
1752
- };
1753
- }
1754
- }
1755
-
1756
- if (manifest.boundaries && Object.keys(manifest.boundaries).length === 0) {
1757
- delete manifest.boundaries;
1758
- }
1759
- }
1760
-
1761
- function boundaryPriorityToLegacyPriority(value: string): BoundaryBundleBuild["priority"] {
1762
- if (value === "load") return "immediate";
1763
- if (value === "immediate" || value === "visible" || value === "idle" || value === "interaction") {
1764
- return value;
1765
- }
1766
- return "visible";
1767
- }
1619
+ }
1620
+ }
1621
+
1622
+ async function buildBoundaryBundle(
1623
+ boundary: RouteClientBoundary,
1624
+ rootDir: string,
1625
+ outDir: string,
1626
+ options: BundlerOptions,
1627
+ ): Promise<BoundaryBundleBuild> {
1628
+ const clientModulePath = path.join(rootDir, boundary.module);
1629
+ const assetStem = routeIdToAssetStem(boundary.id);
1630
+ const entryStem = `_entry_boundary_${assetStem}`;
1631
+ const entryPath = path.join(outDir, `${entryStem}.js`);
1632
+ const outputName = `${assetStem}.boundary.js`;
1633
+ const isDev = isDevelopmentBuild(options);
1634
+
1635
+ try {
1636
+ await Bun.write(entryPath, generateIslandEntry(boundary.id, clientModulePath, boundary.exportName));
1637
+
1638
+ const result = await safeBuild({
1639
+ entrypoints: [entryPath],
1640
+ outdir: outDir,
1641
+ naming: options.splitting ? "[name]-[hash].js" : outputName,
1642
+ minify: shouldMinify(options),
1643
+ sourcemap: options.sourcemap ? "external" : "none",
1644
+ target: "browser",
1645
+ splitting: shouldSplitChunks(options),
1646
+ ...(isDev ? { reactFastRefresh: true } : {}),
1647
+ plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
1648
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1649
+ define: {
1650
+ "process.env.NODE_ENV": nodeEnvDefine(options),
1651
+ ...options.define,
1652
+ },
1653
+ });
1654
+
1655
+ await fs.unlink(entryPath).catch(() => {});
1656
+
1657
+ if (!result.success) {
1658
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1659
+ 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.`);
1660
+ }
1661
+
1662
+ let actualOutputPath: string;
1663
+ let actualOutputName: string;
1664
+ if (options.splitting && result.outputs.length > 0) {
1665
+ const entryOutput = result.outputs.find(
1666
+ (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem),
1667
+ );
1668
+ actualOutputPath = entryOutput?.path ?? result.outputs[0].path;
1669
+ actualOutputName = path.basename(actualOutputPath);
1670
+ } else {
1671
+ actualOutputPath = path.join(outDir, outputName);
1672
+ actualOutputName = outputName;
1673
+ }
1674
+
1675
+ const outputFile = Bun.file(actualOutputPath);
1676
+ const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
1677
+ const gzipped = Bun.gzipSync(Buffer.from(content));
1678
+ const priority = boundaryPriorityToLegacyPriority(boundary.hydrate);
1679
+
1680
+ return {
1681
+ id: boundary.id,
1682
+ route: boundary.routeId,
1683
+ js: `/.mandu/client/${actualOutputName}`,
1684
+ module: boundary.module,
1685
+ exportName: boundary.exportName,
1686
+ priority,
1687
+ hydrate: boundary.hydrate,
1688
+ size: outputFile.size,
1689
+ gzipSize: gzipped.length,
1690
+ };
1691
+ } finally {
1692
+ await fs.unlink(entryPath).catch(() => {});
1693
+ }
1694
+ }
1695
+
1696
+ async function buildBoundaryBundlesForRecords(
1697
+ boundaries: RouteClientBoundary[],
1698
+ rootDir: string,
1699
+ outDir: string,
1700
+ options: BundlerOptions,
1701
+ errors: string[],
1702
+ ): Promise<BoundaryBundleBuild[]> {
1703
+ if (boundaries.length === 0) return [];
1704
+ if (pushDuplicateBoundaryIdErrors(boundaries, errors)) return [];
1705
+
1706
+ const results = await Promise.all(
1707
+ boundaries.map(async (boundary) => {
1708
+ try {
1709
+ return await buildBoundaryBundle(boundary, rootDir, outDir, options);
1710
+ } catch (error) {
1711
+ errors.push(`[boundary:${boundary.id}] ${String(error)}`);
1712
+ return null;
1713
+ }
1714
+ }),
1715
+ );
1716
+
1717
+ return results.filter((result): result is BoundaryBundleBuild => result !== null);
1718
+ }
1719
+
1720
+ function pushDuplicateBoundaryIdErrors(boundaries: RouteClientBoundary[], errors: string[]): boolean {
1721
+ const firstById = new Map<string, RouteClientBoundary>();
1722
+ let hasDuplicate = false;
1723
+
1724
+ for (const boundary of boundaries) {
1725
+ const first = firstById.get(boundary.id);
1726
+ if (!first) {
1727
+ firstById.set(boundary.id, boundary);
1728
+ continue;
1729
+ }
1730
+
1731
+ hasDuplicate = true;
1732
+ errors.push(
1733
+ `[boundary:${boundary.id}] MANDU_BOUNDARY_DUPLICATE_ID Duplicate client boundary id. ` +
1734
+ `First route="${first.routeId}" source="${first.source.file}", duplicate route="${boundary.routeId}" source="${boundary.source.file}". ` +
1735
+ "Boundary ids must be unique before bundle manifest generation.",
1736
+ );
1737
+ }
1738
+
1739
+ return hasDuplicate;
1740
+ }
1741
+
1742
+ function mergeBoundaryBundlesIntoManifest(
1743
+ manifest: BundleManifest,
1744
+ routeIds: Iterable<string>,
1745
+ boundaryBundles: BoundaryBundleBuild[],
1746
+ ): void {
1747
+ const rebuiltRouteIds = new Set(routeIds);
1748
+ if (rebuiltRouteIds.size === 0 && boundaryBundles.length === 0) return;
1749
+
1750
+ if (manifest.boundaries) {
1751
+ for (const [id, boundary] of Object.entries(manifest.boundaries)) {
1752
+ if (rebuiltRouteIds.has(boundary.route)) {
1753
+ delete manifest.boundaries[id];
1754
+ }
1755
+ }
1756
+ }
1757
+
1758
+ if (boundaryBundles.length > 0) {
1759
+ manifest.boundaries = manifest.boundaries || {};
1760
+ for (const boundary of boundaryBundles) {
1761
+ manifest.boundaries[boundary.id] = {
1762
+ route: boundary.route,
1763
+ js: boundary.js,
1764
+ module: boundary.module,
1765
+ exportName: boundary.exportName,
1766
+ priority: boundary.priority,
1767
+ hydrate: boundary.hydrate,
1768
+ };
1769
+ }
1770
+ }
1771
+
1772
+ if (manifest.boundaries && Object.keys(manifest.boundaries).length === 0) {
1773
+ delete manifest.boundaries;
1774
+ }
1775
+ }
1776
+
1777
+ function boundaryPriorityToLegacyPriority(value: string): BoundaryBundleBuild["priority"] {
1778
+ if (value === "load") return "immediate";
1779
+ if (value === "immediate" || value === "visible" || value === "idle" || value === "interaction") {
1780
+ return value;
1781
+ }
1782
+ return "visible";
1783
+ }
1768
1784
 
1769
1785
  async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
1770
1786
  const source = await Bun.file(outputPath).text();
@@ -1817,11 +1833,11 @@ function createBundleManifest(
1817
1833
  runtimePath: string,
1818
1834
  vendorResult: VendorBuildResult,
1819
1835
  routerPath: string,
1820
- env: "development" | "production",
1821
- islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
1822
- partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
1823
- boundaryBundles?: BoundaryBundleBuild[],
1824
- ): BundleManifest {
1836
+ env: "development" | "production",
1837
+ islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
1838
+ partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
1839
+ boundaryBundles?: BoundaryBundleBuild[],
1840
+ ): BundleManifest {
1825
1841
  const bundles: BundleManifest["bundles"] = {};
1826
1842
 
1827
1843
  for (const output of outputs) {
@@ -1848,7 +1864,7 @@ function createBundleManifest(
1848
1864
  }
1849
1865
  }
1850
1866
 
1851
- let partials: BundleManifest["partials"];
1867
+ let partials: BundleManifest["partials"];
1852
1868
  if (partialBundles && partialBundles.length > 0) {
1853
1869
  partials = {};
1854
1870
  for (const partial of partialBundles) {
@@ -1857,22 +1873,22 @@ function createBundleManifest(
1857
1873
  priority: partial.priority,
1858
1874
  };
1859
1875
  }
1860
- }
1861
-
1862
- let boundaries: BundleManifest["boundaries"];
1863
- if (boundaryBundles && boundaryBundles.length > 0) {
1864
- boundaries = {};
1865
- for (const boundary of boundaryBundles) {
1866
- boundaries[boundary.id] = {
1867
- route: boundary.route,
1868
- js: boundary.js,
1869
- module: boundary.module,
1870
- exportName: boundary.exportName,
1871
- priority: boundary.priority,
1872
- hydrate: boundary.hydrate,
1873
- };
1874
- }
1875
- }
1876
+ }
1877
+
1878
+ let boundaries: BundleManifest["boundaries"];
1879
+ if (boundaryBundles && boundaryBundles.length > 0) {
1880
+ boundaries = {};
1881
+ for (const boundary of boundaryBundles) {
1882
+ boundaries[boundary.id] = {
1883
+ route: boundary.route,
1884
+ js: boundary.js,
1885
+ module: boundary.module,
1886
+ exportName: boundary.exportName,
1887
+ priority: boundary.priority,
1888
+ hydrate: boundary.hydrate,
1889
+ };
1890
+ }
1891
+ }
1876
1892
 
1877
1893
  // Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
1878
1894
  // preamble can inject a dynamic import pointing at them. Only
@@ -1889,11 +1905,11 @@ function createBundleManifest(
1889
1905
  version: 1,
1890
1906
  buildTime: new Date().toISOString(),
1891
1907
  env,
1892
- bundles,
1893
- ...(islands ? { islands } : {}),
1894
- ...(partials ? { partials } : {}),
1895
- ...(boundaries ? { boundaries } : {}),
1896
- shared: {
1908
+ bundles,
1909
+ ...(islands ? { islands } : {}),
1910
+ ...(partials ? { partials } : {}),
1911
+ ...(boundaries ? { boundaries } : {}),
1912
+ shared: {
1897
1913
  runtime: runtimePath,
1898
1914
  vendor: vendorResult.react, // primary vendor for backwards compatibility
1899
1915
  router: routerPath, // Client-side Router
@@ -2089,37 +2105,37 @@ export async function buildClientBundles(
2089
2105
  };
2090
2106
  }
2091
2107
 
2092
- // 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
2093
- if (options.targetRouteIds && options.targetRouteIds.length > 0) {
2094
- const targetRouteIds = new Set(options.targetRouteIds);
2095
- const targetRoutes = hydratedRoutes.filter((r) => targetRouteIds.has(r.id));
2096
- const targetIslandRoutes = targetRoutes.filter((route) => !!route.clientModule);
2097
-
2098
- const targetResults = await Promise.all(
2099
- targetIslandRoutes.map(async (route) => {
2100
- try {
2101
- return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2102
- } catch (error) {
2108
+ // 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
2109
+ if (options.targetRouteIds && options.targetRouteIds.length > 0) {
2110
+ const targetRouteIds = new Set(options.targetRouteIds);
2111
+ const targetRoutes = hydratedRoutes.filter((r) => targetRouteIds.has(r.id));
2112
+ const targetIslandRoutes = targetRoutes.filter((route) => !!route.clientModule);
2113
+
2114
+ const targetResults = await Promise.all(
2115
+ targetIslandRoutes.map(async (route) => {
2116
+ try {
2117
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2118
+ } catch (error) {
2103
2119
  return { ok: false as const, routeId: route.id, error: String(error) };
2104
2120
  }
2105
2121
  }),
2106
2122
  );
2107
2123
  for (const r of targetResults) {
2108
2124
  if (r.ok) outputs.push(r.result);
2109
- else errors.push(`[${r.routeId}] ${r.error}`);
2110
- }
2111
-
2112
- const boundaryRecords = targetRoutes.flatMap((route) => route.boundaries ?? []);
2113
- const boundaryBundles = await buildBoundaryBundlesForRecords(
2114
- boundaryRecords,
2115
- rootDir,
2116
- outDir,
2117
- options,
2118
- errors,
2119
- );
2120
-
2121
- // 기존 매니페스트를 읽어 변경된 Island만 갱신
2122
- let existingManifest: BundleManifest;
2125
+ else errors.push(`[${r.routeId}] ${r.error}`);
2126
+ }
2127
+
2128
+ const boundaryRecords = targetRoutes.flatMap((route) => route.boundaries ?? []);
2129
+ const boundaryBundles = await buildBoundaryBundlesForRecords(
2130
+ boundaryRecords,
2131
+ rootDir,
2132
+ outDir,
2133
+ options,
2134
+ errors,
2135
+ );
2136
+
2137
+ // 기존 매니페스트를 읽어 변경된 Island만 갱신
2138
+ let existingManifest: BundleManifest;
2123
2139
  try {
2124
2140
  const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
2125
2141
  existingManifest = JSON.parse(manifestData) as BundleManifest;
@@ -2132,45 +2148,45 @@ export async function buildClientBundles(
2132
2148
  for (const routeId of invalidClientRouteIds) {
2133
2149
  delete existingManifest.bundles[routeId];
2134
2150
  }
2135
- if (outputs.length > 0 || invalidClientRouteIds.size > 0 || boundaryRecords.length > 0) {
2136
- for (const output of outputs) {
2137
- if (existingManifest.bundles[output.routeId]) {
2138
- existingManifest.bundles[output.routeId].js = output.outputPath;
2139
- } else {
2140
- const route = targetIslandRoutes.find((r) => r.id === output.routeId);
2141
- const hydration = route ? getRouteHydration(route) : null;
2142
- existingManifest.bundles[output.routeId] = {
2143
- js: output.outputPath,
2151
+ if (outputs.length > 0 || invalidClientRouteIds.size > 0 || boundaryRecords.length > 0) {
2152
+ for (const output of outputs) {
2153
+ if (existingManifest.bundles[output.routeId]) {
2154
+ existingManifest.bundles[output.routeId].js = output.outputPath;
2155
+ } else {
2156
+ const route = targetIslandRoutes.find((r) => r.id === output.routeId);
2157
+ const hydration = route ? getRouteHydration(route) : null;
2158
+ existingManifest.bundles[output.routeId] = {
2159
+ js: output.outputPath,
2144
2160
  dependencies: ["_runtime", "_react"],
2145
2161
  priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
2146
- };
2147
- }
2148
- }
2149
-
2150
- mergeBoundaryBundlesIntoManifest(
2151
- existingManifest,
2152
- targetRoutes.map((route) => route.id),
2153
- boundaryBundles,
2154
- );
2155
-
2156
- await fs.writeFile(
2157
- path.join(rootDir, ".mandu/manifest.json"),
2162
+ };
2163
+ }
2164
+ }
2165
+
2166
+ mergeBoundaryBundlesIntoManifest(
2167
+ existingManifest,
2168
+ targetRoutes.map((route) => route.id),
2169
+ boundaryBundles,
2170
+ );
2171
+
2172
+ await fs.writeFile(
2173
+ path.join(rootDir, ".mandu/manifest.json"),
2158
2174
  JSON.stringify(existingManifest, null, 2)
2159
2175
  );
2160
2176
  }
2161
- // When all builds failed, do NOT overwrite manifest — keep previous good state
2162
-
2163
- const stats = calculateStats(
2164
- outputs,
2165
- startTime,
2166
- boundaryBundles.map((boundary) => ({
2167
- routeId: `boundary:${boundary.id}`,
2168
- size: boundary.size,
2169
- gzipSize: boundary.gzipSize,
2170
- })),
2171
- );
2172
- return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2173
- }
2177
+ // When all builds failed, do NOT overwrite manifest — keep previous good state
2178
+
2179
+ const stats = calculateStats(
2180
+ outputs,
2181
+ startTime,
2182
+ boundaryBundles.map((boundary) => ({
2183
+ routeId: `boundary:${boundary.id}`,
2184
+ size: boundary.size,
2185
+ gzipSize: boundary.gzipSize,
2186
+ })),
2187
+ );
2188
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2189
+ }
2174
2190
 
2175
2191
  // #185: Framework-internal 번들 스킵 모드
2176
2192
  // 사용자 코드(src/shared 등) 변경 시 runtime/router/vendor/devtools 재빌드는 낭비.
@@ -2226,7 +2242,7 @@ export async function buildClientBundles(
2226
2242
  }
2227
2243
 
2228
2244
  const islandResults = await Promise.all(
2229
- hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
2245
+ hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
2230
2246
  try {
2231
2247
  return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2232
2248
  } catch (error) {
@@ -2272,32 +2288,32 @@ export async function buildClientBundles(
2272
2288
  };
2273
2289
  }
2274
2290
  }
2275
- if (perIslandBundles.length > 0) {
2276
- existingManifest.islands = existingManifest.islands || {};
2277
- for (const ib of perIslandBundles) {
2278
- existingManifest.islands[ib.name] = {
2291
+ if (perIslandBundles.length > 0) {
2292
+ existingManifest.islands = existingManifest.islands || {};
2293
+ for (const ib of perIslandBundles) {
2294
+ existingManifest.islands[ib.name] = {
2279
2295
  js: ib.js,
2280
2296
  route: ib.route,
2281
2297
  priority: ib.priority,
2282
- };
2283
- }
2284
- }
2285
-
2286
- const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
2287
- const boundaryBundles = await buildBoundaryBundlesForRecords(
2288
- boundaryRecords,
2289
- rootDir,
2290
- outDir,
2291
- options,
2292
- errors,
2293
- );
2294
- mergeBoundaryBundlesIntoManifest(
2295
- existingManifest,
2296
- hydratedRoutes.map((route) => route.id),
2297
- boundaryBundles,
2298
- );
2299
-
2300
- const partialBundles: PartialBundleBuild[] = [];
2298
+ };
2299
+ }
2300
+ }
2301
+
2302
+ const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
2303
+ const boundaryBundles = await buildBoundaryBundlesForRecords(
2304
+ boundaryRecords,
2305
+ rootDir,
2306
+ outDir,
2307
+ options,
2308
+ errors,
2309
+ );
2310
+ mergeBoundaryBundlesIntoManifest(
2311
+ existingManifest,
2312
+ hydratedRoutes.map((route) => route.id),
2313
+ boundaryBundles,
2314
+ );
2315
+
2316
+ const partialBundles: PartialBundleBuild[] = [];
2301
2317
  if (partialFiles.length > 0) {
2302
2318
  const partialResults = await Promise.all(
2303
2319
  partialFiles.map(async (entry) => {
@@ -2331,22 +2347,22 @@ export async function buildClientBundles(
2331
2347
  JSON.stringify(existingManifest, null, 2),
2332
2348
  );
2333
2349
 
2334
- const stats = calculateStats(
2335
- outputs,
2336
- startTime,
2337
- [
2338
- ...partialBundles.map((partial) => ({
2339
- routeId: `partial:${partial.name}`,
2340
- size: partial.size,
2341
- gzipSize: partial.gzipSize,
2342
- })),
2343
- ...boundaryBundles.map((boundary) => ({
2344
- routeId: `boundary:${boundary.id}`,
2345
- size: boundary.size,
2346
- gzipSize: boundary.gzipSize,
2347
- })),
2348
- ],
2349
- );
2350
+ const stats = calculateStats(
2351
+ outputs,
2352
+ startTime,
2353
+ [
2354
+ ...partialBundles.map((partial) => ({
2355
+ routeId: `partial:${partial.name}`,
2356
+ size: partial.size,
2357
+ gzipSize: partial.gzipSize,
2358
+ })),
2359
+ ...boundaryBundles.map((boundary) => ({
2360
+ routeId: `boundary:${boundary.id}`,
2361
+ size: boundary.size,
2362
+ gzipSize: boundary.gzipSize,
2363
+ })),
2364
+ ],
2365
+ );
2350
2366
  return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2351
2367
  }
2352
2368
 
@@ -2404,7 +2420,7 @@ export async function buildClientBundles(
2404
2420
 
2405
2421
  // 5. 각 Island 번들 병렬 빌드 (#185: L1631의 per-island와 일관성 확보)
2406
2422
  const fullIslandResults = await Promise.all(
2407
- hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
2423
+ hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
2408
2424
  try {
2409
2425
  return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2410
2426
  } catch (error) {
@@ -2413,27 +2429,27 @@ export async function buildClientBundles(
2413
2429
  }),
2414
2430
  );
2415
2431
  for (const r of fullIslandResults) {
2416
- if (r.ok) {
2417
- outputs.push(r.result);
2418
- } else {
2419
- const errorStr = r.error;
2420
- if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
2421
- const clientModule = r.route.clientModule || "";
2422
- errors.push(
2423
- `[${r.route.id}] ${errorStr}\n` +
2424
- ` Hint: Check import paths and browser-compatible exports for this island. File: ${clientModule}`,
2425
- );
2426
- } else {
2427
- errors.push(`[${r.route.id}] ${errorStr}`);
2428
- }
2432
+ if (r.ok) {
2433
+ outputs.push(r.result);
2434
+ } else {
2435
+ const errorStr = r.error;
2436
+ if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
2437
+ const clientModule = r.route.clientModule || "";
2438
+ errors.push(
2439
+ `[${r.route.id}] ${errorStr}\n` +
2440
+ ` Hint: Check import paths and browser-compatible exports for this island. File: ${clientModule}`,
2441
+ );
2442
+ } else {
2443
+ errors.push(`[${r.route.id}] ${errorStr}`);
2444
+ }
2429
2445
  }
2430
2446
  }
2431
2447
 
2432
2448
  // 5.5. Per-island code splitting: scan and build individual island bundles
2433
2449
  const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
2434
- const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
2450
+ const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
2435
2451
 
2436
- if (islandFiles.length > 0) {
2452
+ if (islandFiles.length > 0) {
2437
2453
  const islandResults = await Promise.all(
2438
2454
  islandFiles.map(async (entry) => {
2439
2455
  try {
@@ -2447,27 +2463,27 @@ export async function buildClientBundles(
2447
2463
  for (const result of islandResults) {
2448
2464
  if (result) islandBundles.push(result);
2449
2465
  }
2450
- }
2451
-
2452
- const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
2453
- const boundaryBundles: BoundaryBundleBuild[] = [];
2454
- if (boundaryRecords.length > 0 && !pushDuplicateBoundaryIdErrors(boundaryRecords, errors)) {
2455
- const boundaryResults = await Promise.all(
2456
- boundaryRecords.map(async (boundary) => {
2457
- try {
2458
- return await buildBoundaryBundle(boundary, rootDir, outDir, options);
2459
- } catch (error) {
2460
- errors.push(`[boundary:${boundary.id}] ${String(error)}`);
2461
- return null;
2462
- }
2463
- }),
2464
- );
2465
- for (const result of boundaryResults) {
2466
- if (result) boundaryBundles.push(result);
2467
- }
2468
- }
2469
-
2470
- const partialBundles: PartialBundleBuild[] = [];
2466
+ }
2467
+
2468
+ const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
2469
+ const boundaryBundles: BoundaryBundleBuild[] = [];
2470
+ if (boundaryRecords.length > 0 && !pushDuplicateBoundaryIdErrors(boundaryRecords, errors)) {
2471
+ const boundaryResults = await Promise.all(
2472
+ boundaryRecords.map(async (boundary) => {
2473
+ try {
2474
+ return await buildBoundaryBundle(boundary, rootDir, outDir, options);
2475
+ } catch (error) {
2476
+ errors.push(`[boundary:${boundary.id}] ${String(error)}`);
2477
+ return null;
2478
+ }
2479
+ }),
2480
+ );
2481
+ for (const result of boundaryResults) {
2482
+ if (result) boundaryBundles.push(result);
2483
+ }
2484
+ }
2485
+
2486
+ const partialBundles: PartialBundleBuild[] = [];
2471
2487
  if (partialFiles.length > 0) {
2472
2488
  const partialResults = await Promise.all(
2473
2489
  partialFiles.map(async (entry) => {
@@ -2491,11 +2507,11 @@ export async function buildClientBundles(
2491
2507
  runtimeResult.outputPath,
2492
2508
  vendorResult,
2493
2509
  routerResult.outputPath,
2494
- env,
2495
- islandBundles,
2496
- partialBundles,
2497
- boundaryBundles,
2498
- );
2510
+ env,
2511
+ islandBundles,
2512
+ partialBundles,
2513
+ boundaryBundles,
2514
+ );
2499
2515
 
2500
2516
  await fs.writeFile(
2501
2517
  path.join(rootDir, ".mandu/manifest.json"),
@@ -2503,22 +2519,22 @@ export async function buildClientBundles(
2503
2519
  );
2504
2520
 
2505
2521
  // 7. 통계 계산
2506
- const stats = calculateStats(
2507
- outputs,
2508
- startTime,
2509
- [
2510
- ...partialBundles.map((partial) => ({
2511
- routeId: `partial:${partial.name}`,
2512
- size: partial.size,
2513
- gzipSize: partial.gzipSize,
2514
- })),
2515
- ...boundaryBundles.map((boundary) => ({
2516
- routeId: `boundary:${boundary.id}`,
2517
- size: boundary.size,
2518
- gzipSize: boundary.gzipSize,
2519
- })),
2520
- ],
2521
- );
2522
+ const stats = calculateStats(
2523
+ outputs,
2524
+ startTime,
2525
+ [
2526
+ ...partialBundles.map((partial) => ({
2527
+ routeId: `partial:${partial.name}`,
2528
+ size: partial.size,
2529
+ gzipSize: partial.gzipSize,
2530
+ })),
2531
+ ...boundaryBundles.map((boundary) => ({
2532
+ routeId: `boundary:${boundary.id}`,
2533
+ size: boundary.size,
2534
+ gzipSize: boundary.gzipSize,
2535
+ })),
2536
+ ],
2537
+ );
2522
2538
 
2523
2539
  // Phase 18.τ — fire onBundleComplete(stats) before return.
2524
2540
  await fireOnBundleComplete(stats);
@@ -2547,15 +2563,15 @@ export function formatSize(bytes: number): string {
2547
2563
  */
2548
2564
  export function printBundleStats(result: BundleResult): void {
2549
2565
  console.log("\n📦 Mandu Client Bundles");
2550
- console.log("=".repeat(50));
2551
-
2552
- const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2553
- const boundaryCount = Object.keys(result.manifest.boundaries ?? {}).length;
2554
- if (result.outputs.length === 0 && partialCount === 0 && boundaryCount === 0) {
2555
- console.log("No islands, partials, or boundaries to bundle (hydration: none or no client entry)");
2556
- if (result.errors.length > 0) {
2557
- console.log("\n⚠️ Errors:");
2558
- for (const error of result.errors) {
2566
+ console.log("=".repeat(50));
2567
+
2568
+ const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2569
+ const boundaryCount = Object.keys(result.manifest.boundaries ?? {}).length;
2570
+ if (result.outputs.length === 0 && partialCount === 0 && boundaryCount === 0) {
2571
+ console.log("No islands, partials, or boundaries to bundle (hydration: none or no client entry)");
2572
+ if (result.errors.length > 0) {
2573
+ console.log("\n⚠️ Errors:");
2574
+ for (const error of result.errors) {
2559
2575
  console.log(` ${error}`);
2560
2576
  }
2561
2577
  }
@@ -2575,14 +2591,14 @@ export function printBundleStats(result: BundleResult): void {
2575
2591
  ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
2576
2592
  );
2577
2593
  }
2578
- if (partialCount > 0) {
2579
- console.log(` Partials: ${partialCount}`);
2580
- }
2581
- if (boundaryCount > 0) {
2582
- console.log(` Boundaries: ${boundaryCount}`);
2583
- }
2584
-
2585
- if (result.errors.length > 0) {
2594
+ if (partialCount > 0) {
2595
+ console.log(` Partials: ${partialCount}`);
2596
+ }
2597
+ if (boundaryCount > 0) {
2598
+ console.log(` Boundaries: ${boundaryCount}`);
2599
+ }
2600
+
2601
+ if (result.errors.length > 0) {
2586
2602
  console.log("\n⚠️ Errors:");
2587
2603
  for (const error of result.errors) {
2588
2604
  console.log(` ${error}`);