@mandujs/core 0.54.2 → 0.54.3

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.
@@ -10,10 +10,11 @@ import type {
10
10
  BundleResult,
11
11
  BundleOutput,
12
12
  BundleManifest,
13
- BundleStats,
14
- BundlerOptions,
15
- IslandFileEntry,
16
- } from "./types";
13
+ BundleStats,
14
+ BundlerOptions,
15
+ IslandFileEntry,
16
+ PartialFileEntry,
17
+ } from "./types";
17
18
  import { HYDRATION } from "../constants";
18
19
  import { safeBuild } from "./safe-build";
19
20
  import { fastRefreshPlugin } from "./fast-refresh-plugin";
@@ -186,7 +187,46 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
186
187
  *
187
188
  * @internal
188
189
  */
189
- export const _testOnly_scanIslandFiles = scanIslandFiles;
190
+ export const _testOnly_scanIslandFiles = scanIslandFiles;
191
+
192
+ function normalizeClientEntryName(name: string): string {
193
+ return name
194
+ .trim()
195
+ .replace(/[^A-Za-z0-9_-]/g, "-")
196
+ .replace(/^-+|-+$/g, "") || "partial";
197
+ }
198
+
199
+ async function scanPartialFiles(rootDir: string): Promise<PartialFileEntry[]> {
200
+ const entries: PartialFileEntry[] = [];
201
+ const seen = new Set<string>();
202
+
203
+ try {
204
+ const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
205
+ for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
206
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
207
+ if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
208
+
209
+ const filePath = path.resolve(rel);
210
+ if (seen.has(filePath)) continue;
211
+ seen.add(filePath);
212
+
213
+ const base = path.basename(filePath).replace(/\.partial\.tsx?$/, "");
214
+ entries.push({
215
+ name: normalizeClientEntryName(base),
216
+ filePath,
217
+ priority: HYDRATION.DEFAULT_PRIORITY,
218
+ });
219
+ }
220
+ } catch {
221
+ // Bun.Glob unavailable or scan failed — a missing partial bundle should
222
+ // not prevent route-level islands from building.
223
+ }
224
+
225
+ return entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
226
+ }
227
+
228
+ /** @internal test helper */
229
+ export const _testOnly_scanPartialFiles = scanPartialFiles;
190
230
 
191
231
  /**
192
232
  * Test-only accessor for the hydrated-routes filter. Mirrors the rationale
@@ -214,49 +254,42 @@ export const _testOnly_getHydratedRoutes = getHydratedRoutes;
214
254
  * Paths are absolute and deduplicated. Returns `[]` when the manifest
215
255
  * has no hydrated routes.
216
256
  */
217
- export async function collectCompilerLintTargets(
218
- manifest: RoutesManifest,
219
- rootDir: string,
220
- ): Promise<string[]> {
221
- const hydratedRoutes = getHydratedRoutes(manifest);
222
- if (hydratedRoutes.length === 0) return [];
223
-
224
- const out = new Set<string>();
225
-
226
- // 1) Islands (reuses the bundler's canonical scanner).
227
- const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
228
- for (const entry of islandFiles) {
229
- out.add(path.resolve(entry.filePath));
230
- }
231
-
232
- // 2) `"use client"` page modules — the hydrated-route filter
233
- // already asserted `clientModule` exists.
234
- for (const route of hydratedRoutes) {
235
- const rel = route.clientModule ?? route.module;
236
- if (rel) out.add(path.resolve(rootDir, rel));
237
- }
238
-
239
- // 3) Partials — glob the whole project once. Partials live anywhere
240
- // the user chooses, they're not sibling-scoped like islands.
241
- try {
242
- const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
243
- for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
244
- if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
245
- if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
246
- out.add(path.resolve(rel));
247
- }
248
- } catch {
249
- // Bun.Glob unavailable or scan failed — skip partials rather than
250
- // aborting the whole diagnostic run.
251
- }
252
-
253
- return Array.from(out).sort();
254
- }
257
+ export async function collectCompilerLintTargets(
258
+ manifest: RoutesManifest,
259
+ rootDir: string,
260
+ ): Promise<string[]> {
261
+ const hydratedRoutes = getHydratedRoutes(manifest);
262
+
263
+ const out = new Set<string>();
264
+
265
+ // 1) Islands (reuses the bundler's canonical scanner).
266
+ if (hydratedRoutes.length > 0) {
267
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
268
+ for (const entry of islandFiles) {
269
+ out.add(path.resolve(entry.filePath));
270
+ }
271
+
272
+ // 2) `"use client"` page modules — the hydrated-route filter
273
+ // already asserted `clientModule` exists.
274
+ for (const route of hydratedRoutes) {
275
+ const rel = route.clientModule ?? route.module;
276
+ if (rel) out.add(path.resolve(rootDir, rel));
277
+ }
278
+ }
279
+
280
+ // 3) Partials glob the whole project once. Partials live anywhere
281
+ // the user chooses, they're not sibling-scoped like islands.
282
+ for (const entry of await scanPartialFiles(rootDir)) {
283
+ out.add(path.resolve(entry.filePath));
284
+ }
285
+
286
+ return Array.from(out).sort();
287
+ }
255
288
 
256
289
  /** Build a single per-island bundle. */
257
- async function buildPerIslandBundle(
258
- entry: IslandFileEntry, outDir: string, options: BundlerOptions
259
- ): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
290
+ async function buildPerIslandBundle(
291
+ entry: IslandFileEntry, outDir: string, options: BundlerOptions
292
+ ): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
260
293
  const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
261
294
  const outputName = `${entry.name}.island.js`;
262
295
  // Phase 7.1 B-1/B-4: wire Bun's native React Fast Refresh transform +
@@ -285,9 +318,66 @@ async function buildPerIslandBundle(
285
318
  return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
286
319
  } catch (error) {
287
320
  await fs.unlink(entryPath).catch(() => {});
288
- throw error;
289
- }
290
- }
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ interface PartialBundleBuild {
326
+ name: string;
327
+ js: string;
328
+ priority: PartialFileEntry["priority"];
329
+ size: number;
330
+ gzipSize: number;
331
+ }
332
+
333
+ /** Build a single inline partial bundle. */
334
+ async function buildPartialBundle(
335
+ entry: PartialFileEntry,
336
+ outDir: string,
337
+ options: BundlerOptions,
338
+ ): Promise<PartialBundleBuild> {
339
+ const entryPath = path.join(outDir, `_entry_partial_${entry.name}.js`);
340
+ const outputName = `${entry.name}.partial.js`;
341
+ const isDev = isDevelopmentBuild(options);
342
+
343
+ try {
344
+ await Bun.write(entryPath, generatePartialEntry(entry.name, entry.filePath));
345
+ const result = await safeBuild({
346
+ entrypoints: [entryPath],
347
+ outdir: outDir,
348
+ naming: outputName,
349
+ minify: shouldMinify(options),
350
+ sourcemap: options.sourcemap ? "external" : "none",
351
+ target: "browser",
352
+ ...(isDev ? { reactFastRefresh: true } : {}),
353
+ plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
354
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
355
+ define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
356
+ });
357
+ await fs.unlink(entryPath).catch(() => {});
358
+
359
+ if (!result.success) {
360
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
361
+ throw new Error(`Partial build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Export a Mandu partial from this file with \`partial({ component })\`.`);
362
+ }
363
+
364
+ const outputPath = path.join(outDir, outputName);
365
+ const outputFile = Bun.file(outputPath);
366
+ const content = await outputFile.text();
367
+ const gzipped = Bun.gzipSync(Buffer.from(content));
368
+
369
+ return {
370
+ name: entry.name,
371
+ js: `/.mandu/client/${outputName}`,
372
+ priority: entry.priority,
373
+ size: outputFile.size,
374
+ gzipSize: gzipped.length,
375
+ };
376
+ } catch (error) {
377
+ await fs.unlink(entryPath).catch(() => {});
378
+ throw error;
379
+ }
380
+ }
291
381
 
292
382
  /**
293
383
  * 빈 매니페스트 생성
@@ -413,12 +503,31 @@ function generateRuntimeSource(): string {
413
503
  import React, { useState, useEffect, Component } from 'react';
414
504
  import { hydrateRoot, createRoot } from 'react-dom/client';
415
505
 
416
- // Hydrated roots 추적 (unmount용) - 전역 초기화
417
- window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
418
- const hydratedRoots = window.__MANDU_ROOTS__;
419
-
420
- // 서버 데이터
421
- const getServerData = (id) => (window.__MANDU_DATA__ || {})[id]?.serverData || {};
506
+ // Hydrated roots 추적 (unmount용) - 전역 초기화
507
+ window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
508
+ const hydratedRoots = window.__MANDU_ROOTS__;
509
+
510
+ // 서버 데이터
511
+ function readManduData() {
512
+ if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
513
+
514
+ const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
515
+ if (!raw) {
516
+ window.__MANDU_DATA__ = {};
517
+ return window.__MANDU_DATA__;
518
+ }
519
+
520
+ try {
521
+ window.__MANDU_DATA__ = JSON.parse(raw);
522
+ } catch (error) {
523
+ console.warn('[Mandu] Failed to parse server data:', error);
524
+ window.__MANDU_DATA__ = {};
525
+ }
526
+
527
+ return window.__MANDU_DATA__;
528
+ }
529
+
530
+ const getServerData = (id) => readManduData()[id]?.serverData || {};
422
531
 
423
532
  /**
424
533
  * Error Boundary 컴포넌트 (Class Component)
@@ -632,12 +741,16 @@ async function loadAndHydrate(element, src) {
632
741
  const island = module.default;
633
742
  let data = getServerData(id);
634
743
 
635
- // Fallback: read data-props from child element if __MANDU_DATA__ is empty
636
- if (!data || Object.keys(data).length === 0) {
637
- const propsEl = element.querySelector('[data-props]');
638
- if (propsEl) {
639
- try {
640
- data = JSON.parse(propsEl.getAttribute('data-props'));
744
+ // Fallback: read data-props from the island root or a child element if
745
+ // __MANDU_DATA__ is empty. Inline partials put their serialized props on
746
+ // the root marker itself.
747
+ if (!data || Object.keys(data).length === 0) {
748
+ const propsEl = element.hasAttribute('data-props')
749
+ ? element
750
+ : element.querySelector('[data-props]');
751
+ if (propsEl) {
752
+ try {
753
+ data = JSON.parse(propsEl.getAttribute('data-props'));
641
754
  } catch (e) {
642
755
  console.warn('[Mandu] Failed to parse data-props fallback:', e);
643
756
  }
@@ -1311,22 +1424,63 @@ async function buildRouterRuntime(
1311
1424
  * - Runtime이 dynamic import로 로드
1312
1425
  * - 등록/초기화 코드 없음
1313
1426
  */
1314
- function generateIslandEntry(routeId: string, clientModulePath: string): string {
1315
- // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1316
- const normalizedPath = clientModulePath.replace(/\\/g, "/");
1317
- return `
1427
+ function generateIslandEntry(routeId: string, clientModulePath: string): string {
1428
+ // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1429
+ const normalizedPath = clientModulePath.replace(/\\/g, "/");
1430
+ return `
1318
1431
  /**
1319
1432
  * Mandu Island: ${routeId} (Generated)
1320
1433
  * Pure export - no side effects
1321
1434
  */
1322
1435
  import island from "${normalizedPath}";
1323
- export default island;
1324
- `;
1325
- }
1326
-
1327
- /**
1328
- * Runtime 번들 빌드
1329
- */
1436
+ export default island;
1437
+ `;
1438
+ }
1439
+
1440
+ function generatePartialEntry(partialId: string, partialModulePath: string): string {
1441
+ const normalizedPath = partialModulePath.replace(/\\/g, "/");
1442
+ return `
1443
+ /**
1444
+ * Mandu Partial: ${partialId} (Generated)
1445
+ * Exports a runtime-compatible island wrapper around a compiled partial.
1446
+ */
1447
+ import React from "react";
1448
+ import * as partialModule from "${normalizedPath}";
1449
+
1450
+ function findPartial(mod) {
1451
+ if (mod.default && mod.default.__mandu_partial === true) return mod.default;
1452
+ for (const value of Object.values(mod)) {
1453
+ if (value && value.__mandu_partial === true) return value;
1454
+ }
1455
+ throw new Error("[Mandu Partial] ${partialId} must export a value returned by partial({ component })");
1456
+ }
1457
+
1458
+ const partial = findPartial(partialModule);
1459
+ const definition = partial.definition;
1460
+ const component = definition.component;
1461
+
1462
+ export default {
1463
+ __mandu_island: true,
1464
+ definition: {
1465
+ setup(serverData) {
1466
+ if (serverData && typeof serverData === "object" && Object.keys(serverData).length > 0) {
1467
+ return serverData;
1468
+ }
1469
+ return definition.initialProps || {};
1470
+ },
1471
+ render(props) {
1472
+ return React.createElement(component, props);
1473
+ },
1474
+ errorBoundary: definition.errorBoundary,
1475
+ loading: definition.loading,
1476
+ },
1477
+ };
1478
+ `;
1479
+ }
1480
+
1481
+ /**
1482
+ * Runtime 번들 빌드
1483
+ */
1330
1484
  async function buildRuntime(
1331
1485
  outDir: string,
1332
1486
  options: BundlerOptions
@@ -1810,15 +1964,16 @@ async function buildIsland(
1810
1964
  /**
1811
1965
  * 번들 매니페스트 생성
1812
1966
  */
1813
- function createBundleManifest(
1814
- outputs: BundleOutput[],
1815
- routes: RouteSpec[],
1816
- runtimePath: string,
1817
- vendorResult: VendorBuildResult,
1818
- routerPath: string,
1819
- env: "development" | "production",
1820
- islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>
1821
- ): BundleManifest {
1967
+ function createBundleManifest(
1968
+ outputs: BundleOutput[],
1969
+ routes: RouteSpec[],
1970
+ runtimePath: string,
1971
+ vendorResult: VendorBuildResult,
1972
+ routerPath: string,
1973
+ env: "development" | "production",
1974
+ islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
1975
+ partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
1976
+ ): BundleManifest {
1822
1977
  const bundles: BundleManifest["bundles"] = {};
1823
1978
 
1824
1979
  for (const output of outputs) {
@@ -1834,16 +1989,27 @@ function createBundleManifest(
1834
1989
 
1835
1990
  // Per-island bundles (code splitting)
1836
1991
  let islands: BundleManifest["islands"];
1837
- if (islandBundles && islandBundles.length > 0) {
1838
- islands = {};
1839
- for (const ib of islandBundles) {
1840
- islands[ib.name] = {
1841
- js: ib.js,
1992
+ if (islandBundles && islandBundles.length > 0) {
1993
+ islands = {};
1994
+ for (const ib of islandBundles) {
1995
+ islands[ib.name] = {
1996
+ js: ib.js,
1842
1997
  route: ib.route,
1843
1998
  priority: ib.priority,
1844
- };
1845
- }
1846
- }
1999
+ };
2000
+ }
2001
+ }
2002
+
2003
+ let partials: BundleManifest["partials"];
2004
+ if (partialBundles && partialBundles.length > 0) {
2005
+ partials = {};
2006
+ for (const partial of partialBundles) {
2007
+ partials[partial.name] = {
2008
+ js: partial.js,
2009
+ priority: partial.priority,
2010
+ };
2011
+ }
2012
+ }
1847
2013
 
1848
2014
  // Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
1849
2015
  // preamble can inject a dynamic import pointing at them. Only
@@ -1859,10 +2025,11 @@ function createBundleManifest(
1859
2025
  return {
1860
2026
  version: 1,
1861
2027
  buildTime: new Date().toISOString(),
1862
- env,
1863
- bundles,
1864
- ...(islands ? { islands } : {}),
1865
- shared: {
2028
+ env,
2029
+ bundles,
2030
+ ...(islands ? { islands } : {}),
2031
+ ...(partials ? { partials } : {}),
2032
+ shared: {
1866
2033
  runtime: runtimePath,
1867
2034
  vendor: vendorResult.react, // primary vendor for backwards compatibility
1868
2035
  router: routerPath, // Client-side Router
@@ -1883,28 +2050,32 @@ function createBundleManifest(
1883
2050
  /**
1884
2051
  * 번들 통계 계산
1885
2052
  */
1886
- function calculateStats(outputs: BundleOutput[], startTime: number): BundleStats {
1887
- let totalSize = 0;
1888
- let totalGzipSize = 0;
1889
- let largestBundle = { routeId: "", size: 0 };
1890
-
1891
- for (const output of outputs) {
1892
- totalSize += output.size;
1893
- totalGzipSize += output.gzipSize;
1894
-
1895
- if (output.size > largestBundle.size) {
1896
- largestBundle = { routeId: output.routeId, size: output.size };
2053
+ function calculateStats(
2054
+ outputs: BundleOutput[],
2055
+ startTime: number,
2056
+ extraOutputs: Array<{ routeId: string; size: number; gzipSize: number }> = [],
2057
+ ): BundleStats {
2058
+ let totalSize = 0;
2059
+ let totalGzipSize = 0;
2060
+ let largestBundle = { routeId: "", size: 0 };
2061
+
2062
+ for (const output of [...outputs, ...extraOutputs]) {
2063
+ totalSize += output.size;
2064
+ totalGzipSize += output.gzipSize;
2065
+
2066
+ if (output.size > largestBundle.size) {
2067
+ largestBundle = { routeId: output.routeId, size: output.size };
1897
2068
  }
1898
2069
  }
1899
2070
 
1900
2071
  return {
1901
2072
  totalSize,
1902
- totalGzipSize,
1903
- largestBundle,
1904
- buildTime: performance.now() - startTime,
1905
- bundleCount: outputs.length,
1906
- };
1907
- }
2073
+ totalGzipSize,
2074
+ largestBundle,
2075
+ buildTime: performance.now() - startTime,
2076
+ bundleCount: outputs.length + extraOutputs.length,
2077
+ };
2078
+ }
1908
2079
 
1909
2080
  /**
1910
2081
  * 클라이언트 번들 빌드
@@ -1950,8 +2121,10 @@ export async function buildClientBundles(
1950
2121
  };
1951
2122
  const env = resolveBundlerMode(options);
1952
2123
 
1953
- // 1. Hydration이 필요한 라우트 필터링
1954
- const hydratedRoutes = getHydratedRoutes(manifest);
2124
+ // 1. Hydration이 필요한 라우트 필터링
2125
+ const hydratedRoutes = getHydratedRoutes(manifest);
2126
+ const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
2127
+ const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
1955
2128
 
1956
2129
  // 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
1957
2130
  const outDir = resolveClientOutDir(rootDir, options.outDir);
@@ -1959,7 +2132,7 @@ export async function buildClientBundles(
1959
2132
 
1960
2133
  // Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
1961
2134
  // (이전 빌드의 stale 매니페스트 참조 방지)
1962
- if (hydratedRoutes.length === 0) {
2135
+ if (hydratedRoutes.length === 0 && partialFiles.length === 0) {
1963
2136
  // #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
1964
2137
  if (options.skipFrameworkBundles) {
1965
2138
  const manifestPath = path.join(rootDir, ".mandu/manifest.json");
@@ -2178,25 +2351,62 @@ export async function buildClientBundles(
2178
2351
  };
2179
2352
  }
2180
2353
  }
2181
- if (perIslandBundles.length > 0) {
2182
- existingManifest.islands = existingManifest.islands || {};
2183
- for (const ib of perIslandBundles) {
2184
- existingManifest.islands[ib.name] = {
2185
- js: ib.js,
2354
+ if (perIslandBundles.length > 0) {
2355
+ existingManifest.islands = existingManifest.islands || {};
2356
+ for (const ib of perIslandBundles) {
2357
+ existingManifest.islands[ib.name] = {
2358
+ js: ib.js,
2186
2359
  route: ib.route,
2187
2360
  priority: ib.priority,
2188
- };
2189
- }
2190
- }
2191
-
2192
- await fs.writeFile(
2193
- path.join(rootDir, ".mandu/manifest.json"),
2194
- JSON.stringify(existingManifest, null, 2),
2195
- );
2196
-
2197
- const stats = calculateStats(outputs, startTime);
2198
- return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2199
- }
2361
+ };
2362
+ }
2363
+ }
2364
+
2365
+ const partialBundles: PartialBundleBuild[] = [];
2366
+ if (partialFiles.length > 0) {
2367
+ const partialResults = await Promise.all(
2368
+ partialFiles.map(async (entry) => {
2369
+ try {
2370
+ return await buildPartialBundle(entry, outDir, options);
2371
+ } catch (error) {
2372
+ errors.push(`[partial:${entry.name}] ${String(error)}`);
2373
+ return null;
2374
+ }
2375
+ }),
2376
+ );
2377
+ for (const result of partialResults) {
2378
+ if (result) partialBundles.push(result);
2379
+ }
2380
+ }
2381
+ if (partialFiles.length > 0 || existingManifest.partials) {
2382
+ existingManifest.partials = {};
2383
+ for (const partial of partialBundles) {
2384
+ existingManifest.partials[partial.name] = {
2385
+ js: partial.js,
2386
+ priority: partial.priority,
2387
+ };
2388
+ }
2389
+ if (Object.keys(existingManifest.partials).length === 0) {
2390
+ delete existingManifest.partials;
2391
+ }
2392
+ }
2393
+
2394
+ await fs.writeFile(
2395
+ path.join(rootDir, ".mandu/manifest.json"),
2396
+ JSON.stringify(existingManifest, null, 2),
2397
+ );
2398
+
2399
+ const stats = calculateStats(
2400
+ outputs,
2401
+ startTime,
2402
+ partialBundles.map((partial) => ({
2403
+ routeId: `partial:${partial.name}`,
2404
+ size: partial.size,
2405
+ gzipSize: partial.gzipSize,
2406
+ })),
2407
+ );
2408
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2409
+ }
2200
2410
 
2201
2411
  // 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
2202
2412
  const isDev = env === "development";
@@ -2282,11 +2492,11 @@ export async function buildClientBundles(
2282
2492
  const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
2283
2493
  const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
2284
2494
 
2285
- if (islandFiles.length > 0) {
2286
- const islandResults = await Promise.all(
2287
- islandFiles.map(async (entry) => {
2288
- try {
2289
- return await buildPerIslandBundle(entry, outDir, options);
2495
+ if (islandFiles.length > 0) {
2496
+ const islandResults = await Promise.all(
2497
+ islandFiles.map(async (entry) => {
2498
+ try {
2499
+ return await buildPerIslandBundle(entry, outDir, options);
2290
2500
  } catch (error) {
2291
2501
  errors.push(`[island:${entry.name}] ${String(error)}`);
2292
2502
  return null;
@@ -2294,20 +2504,38 @@ export async function buildClientBundles(
2294
2504
  })
2295
2505
  );
2296
2506
  for (const result of islandResults) {
2297
- if (result) islandBundles.push(result);
2298
- }
2299
- }
2300
-
2301
- // 6. 번들 매니페스트 생성
2302
- const bundleManifest = createBundleManifest(
2303
- outputs,
2304
- hydratedRoutes,
2305
- runtimeResult.outputPath,
2306
- vendorResult,
2307
- routerResult.outputPath,
2308
- env,
2309
- islandBundles
2310
- );
2507
+ if (result) islandBundles.push(result);
2508
+ }
2509
+ }
2510
+
2511
+ const partialBundles: PartialBundleBuild[] = [];
2512
+ if (partialFiles.length > 0) {
2513
+ const partialResults = await Promise.all(
2514
+ partialFiles.map(async (entry) => {
2515
+ try {
2516
+ return await buildPartialBundle(entry, outDir, options);
2517
+ } catch (error) {
2518
+ errors.push(`[partial:${entry.name}] ${String(error)}`);
2519
+ return null;
2520
+ }
2521
+ }),
2522
+ );
2523
+ for (const result of partialResults) {
2524
+ if (result) partialBundles.push(result);
2525
+ }
2526
+ }
2527
+
2528
+ // 6. 번들 매니페스트 생성
2529
+ const bundleManifest = createBundleManifest(
2530
+ outputs,
2531
+ hydratedRoutes,
2532
+ runtimeResult.outputPath,
2533
+ vendorResult,
2534
+ routerResult.outputPath,
2535
+ env,
2536
+ islandBundles,
2537
+ partialBundles,
2538
+ );
2311
2539
 
2312
2540
  await fs.writeFile(
2313
2541
  path.join(rootDir, ".mandu/manifest.json"),
@@ -2315,7 +2543,15 @@ export async function buildClientBundles(
2315
2543
  );
2316
2544
 
2317
2545
  // 7. 통계 계산
2318
- const stats = calculateStats(outputs, startTime);
2546
+ const stats = calculateStats(
2547
+ outputs,
2548
+ startTime,
2549
+ partialBundles.map((partial) => ({
2550
+ routeId: `partial:${partial.name}`,
2551
+ size: partial.size,
2552
+ gzipSize: partial.gzipSize,
2553
+ })),
2554
+ );
2319
2555
 
2320
2556
  // Phase 18.τ — fire onBundleComplete(stats) before return.
2321
2557
  await fireOnBundleComplete(stats);
@@ -2343,13 +2579,14 @@ export function formatSize(bytes: number): string {
2343
2579
  * 번들 결과 요약 출력
2344
2580
  */
2345
2581
  export function printBundleStats(result: BundleResult): void {
2346
- console.log("\n📦 Mandu Client Bundles");
2347
- console.log("=".repeat(50));
2348
-
2349
- if (result.outputs.length === 0) {
2350
- console.log("No islands to bundle (hydration: none or no clientModule)");
2351
- return;
2352
- }
2582
+ console.log("\n📦 Mandu Client Bundles");
2583
+ console.log("=".repeat(50));
2584
+
2585
+ const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2586
+ if (result.outputs.length === 0 && partialCount === 0) {
2587
+ console.log("No islands or partials to bundle (hydration: none or no client entry)");
2588
+ return;
2589
+ }
2353
2590
 
2354
2591
  console.log(`Environment: ${result.manifest.env}`);
2355
2592
  console.log(`Bundles: ${result.stats.bundleCount}`);
@@ -2358,12 +2595,15 @@ export function printBundleStats(result: BundleResult): void {
2358
2595
  console.log(`Build Time: ${result.stats.buildTime.toFixed(0)}ms`);
2359
2596
  console.log("");
2360
2597
 
2361
- // 각 번들 정보
2362
- for (const output of result.outputs) {
2363
- console.log(
2364
- ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
2365
- );
2366
- }
2598
+ // 각 번들 정보
2599
+ for (const output of result.outputs) {
2600
+ console.log(
2601
+ ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
2602
+ );
2603
+ }
2604
+ if (partialCount > 0) {
2605
+ console.log(` Partials: ${partialCount}`);
2606
+ }
2367
2607
 
2368
2608
  if (result.errors.length > 0) {
2369
2609
  console.log("\n⚠️ Errors:");