@mandujs/core 0.54.10 → 0.54.12

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 (67) hide show
  1. package/package.json +1 -1
  2. package/scripts/postinstall-lock.ts +153 -153
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/agent/__tests__/context.test.ts +17 -65
  5. package/src/agent/context.ts +535 -535
  6. package/src/agent/index.ts +6 -6
  7. package/src/agent/plan.ts +282 -282
  8. package/src/agent/repair.ts +171 -171
  9. package/src/agent/sync.ts +200 -200
  10. package/src/agent/types.ts +10 -18
  11. package/src/agent/verify.ts +17 -115
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +81 -62
  14. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  15. package/src/bundler/__tests__/css.test.ts +20 -20
  16. package/src/bundler/analyzer.ts +15 -15
  17. package/src/bundler/build.test.ts +130 -83
  18. package/src/bundler/build.ts +570 -524
  19. package/src/bundler/css.ts +42 -42
  20. package/src/bundler/manifest-schema.ts +21 -21
  21. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  22. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  23. package/src/bundler/types.ts +31 -31
  24. package/src/client/island.ts +79 -79
  25. package/src/config/validate.ts +1 -1
  26. package/src/deploy/inference/context.ts +82 -82
  27. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  28. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  29. package/src/error/formatter.ts +22 -31
  30. package/src/filling/context.ts +17 -17
  31. package/src/generator/generate.ts +30 -30
  32. package/src/generator/index.ts +3 -3
  33. package/src/generator/templates.test.ts +66 -65
  34. package/src/generator/templates.ts +210 -210
  35. package/src/guard/check.ts +9 -9
  36. package/src/guard/config-guard.ts +13 -13
  37. package/src/guard/fs-routes-policy.ts +51 -51
  38. package/src/guard/index.ts +11 -11
  39. package/src/index.ts +3 -3
  40. package/src/kitchen/api/file-api.ts +11 -11
  41. package/src/report/index.ts +1 -1
  42. package/src/resource/__tests__/generator.test.ts +6 -6
  43. package/src/resource/__tests__/schema.test.ts +14 -14
  44. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  45. package/src/resource/ddl/emit.ts +146 -146
  46. package/src/resource/generator-schema.ts +11 -11
  47. package/src/resource/generators/slot.ts +72 -72
  48. package/src/resource/schema.ts +21 -21
  49. package/src/router/client-entry.test.ts +192 -140
  50. package/src/router/client-entry.ts +516 -486
  51. package/src/router/fs-routes.ts +16 -16
  52. package/src/router/fs-scanner.ts +16 -28
  53. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  54. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  55. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  56. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  57. package/src/runtime/devtools-adapter.ts +68 -68
  58. package/src/runtime/escape.ts +34 -34
  59. package/src/runtime/observability-lifecycle.ts +290 -290
  60. package/src/runtime/page-render-response.ts +106 -106
  61. package/src/runtime/request-middleware.ts +31 -31
  62. package/src/runtime/server.ts +243 -243
  63. package/src/runtime/ssr.ts +59 -59
  64. package/src/runtime/static-files.ts +289 -289
  65. package/src/runtime/streaming-ssr.ts +22 -22
  66. package/src/watcher/__tests__/watcher.test.ts +59 -59
  67. package/src/watcher/watcher.ts +61 -61
@@ -10,23 +10,23 @@ import type {
10
10
  BundleResult,
11
11
  BundleOutput,
12
12
  BundleManifest,
13
- BundleStats,
14
- BundlerOptions,
15
- IslandFileEntry,
16
- PartialFileEntry,
17
- } from "./types";
13
+ BundleStats,
14
+ BundlerOptions,
15
+ IslandFileEntry,
16
+ PartialFileEntry,
17
+ } from "./types";
18
18
  import { HYDRATION } from "../constants";
19
19
  import { safeBuild } from "./safe-build";
20
20
  import { fastRefreshPlugin } from "./fast-refresh-plugin";
21
21
  import { defaultBundlerPlugins } from "./plugins";
22
22
  import type { BunPlugin } from "bun";
23
- import { mark, measure } from "../perf";
24
- import { HMR_PERF } from "../perf/hmr-markers";
25
- import { runOnBundleComplete } from "../plugins/runner";
26
- import {
27
- describeMissingHydrationClientModule,
28
- validateClientModuleForBrowserBundle,
29
- } from "../router/client-entry";
23
+ import { mark, measure } from "../perf";
24
+ import { HMR_PERF } from "../perf/hmr-markers";
25
+ import { runOnBundleComplete } from "../plugins/runner";
26
+ import {
27
+ describeMissingHydrationClientModule,
28
+ validateClientModuleForBrowserBundle,
29
+ } from "../router/client-entry";
30
30
  import {
31
31
  readVendorCache,
32
32
  writeVendorCache,
@@ -100,13 +100,16 @@ function shouldSplitChunks(options: BundlerOptions): boolean {
100
100
  return options.splitting ?? (resolveBundlerMode(options) === "production");
101
101
  }
102
102
 
103
- function nodeEnvDefine(options: BundlerOptions): string {
104
- return JSON.stringify(resolveBundlerMode(options));
105
- }
106
-
107
- function resolveClientOutDir(rootDir: string, outDir?: string): string {
108
- const defaultOutDir = path.join(rootDir, ".mandu/client");
109
- if (!outDir) return defaultOutDir;
103
+ function nodeEnvDefine(options: BundlerOptions): string {
104
+ return JSON.stringify(resolveBundlerMode(options));
105
+ }
106
+
107
+ const FAST_REFRESH_SELF_ALIAS_PATTERN =
108
+ /^var\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*\1;\s*\r?\n(?=\$RefreshReg\$\(\1,\s*["'][^"']*:default["']\);)/gm;
109
+
110
+ function resolveClientOutDir(rootDir: string, outDir?: string): string {
111
+ const defaultOutDir = path.join(rootDir, ".mandu/client");
112
+ if (!outDir) return defaultOutDir;
110
113
 
111
114
  const resolvedOutDir = path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir);
112
115
  if (path.normalize(resolvedOutDir) === path.normalize(path.join(rootDir, ".mandu"))) {
@@ -191,46 +194,46 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
191
194
  *
192
195
  * @internal
193
196
  */
194
- export const _testOnly_scanIslandFiles = scanIslandFiles;
195
-
196
- function normalizeClientEntryName(name: string): string {
197
- return name
198
- .trim()
199
- .replace(/[^A-Za-z0-9_-]/g, "-")
200
- .replace(/^-+|-+$/g, "") || "partial";
201
- }
202
-
203
- async function scanPartialFiles(rootDir: string): Promise<PartialFileEntry[]> {
204
- const entries: PartialFileEntry[] = [];
205
- const seen = new Set<string>();
206
-
207
- try {
208
- const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
209
- for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
210
- if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
211
- if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
212
-
213
- const filePath = path.resolve(rel);
214
- if (seen.has(filePath)) continue;
215
- seen.add(filePath);
216
-
217
- const base = path.basename(filePath).replace(/\.partial\.tsx?$/, "");
218
- entries.push({
219
- name: normalizeClientEntryName(base),
220
- filePath,
221
- priority: HYDRATION.DEFAULT_PRIORITY,
222
- });
223
- }
224
- } catch {
225
- // Bun.Glob unavailable or scan failed — a missing partial bundle should
226
- // not prevent route-level islands from building.
227
- }
228
-
229
- return entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
230
- }
231
-
232
- /** @internal test helper */
233
- export const _testOnly_scanPartialFiles = scanPartialFiles;
197
+ export const _testOnly_scanIslandFiles = scanIslandFiles;
198
+
199
+ function normalizeClientEntryName(name: string): string {
200
+ return name
201
+ .trim()
202
+ .replace(/[^A-Za-z0-9_-]/g, "-")
203
+ .replace(/^-+|-+$/g, "") || "partial";
204
+ }
205
+
206
+ async function scanPartialFiles(rootDir: string): Promise<PartialFileEntry[]> {
207
+ const entries: PartialFileEntry[] = [];
208
+ const seen = new Set<string>();
209
+
210
+ try {
211
+ const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
212
+ for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
213
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
214
+ if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
215
+
216
+ const filePath = path.resolve(rel);
217
+ if (seen.has(filePath)) continue;
218
+ seen.add(filePath);
219
+
220
+ const base = path.basename(filePath).replace(/\.partial\.tsx?$/, "");
221
+ entries.push({
222
+ name: normalizeClientEntryName(base),
223
+ filePath,
224
+ priority: HYDRATION.DEFAULT_PRIORITY,
225
+ });
226
+ }
227
+ } catch {
228
+ // Bun.Glob unavailable or scan failed — a missing partial bundle should
229
+ // not prevent route-level islands from building.
230
+ }
231
+
232
+ return entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
233
+ }
234
+
235
+ /** @internal test helper */
236
+ export const _testOnly_scanPartialFiles = scanPartialFiles;
234
237
 
235
238
  /**
236
239
  * Test-only accessor for the hydrated-routes filter. Mirrors the rationale
@@ -239,9 +242,9 @@ export const _testOnly_scanPartialFiles = scanPartialFiles;
239
242
  *
240
243
  * @internal
241
244
  */
242
- export const _testOnly_getHydratedRoutes = getHydratedRoutes;
243
- export const _testOnly_getHydrationRoutesMissingClientModule =
244
- getHydrationRoutesMissingClientModule;
245
+ export const _testOnly_getHydratedRoutes = getHydratedRoutes;
246
+ export const _testOnly_getHydrationRoutesMissingClientModule =
247
+ getHydrationRoutesMissingClientModule;
245
248
 
246
249
  /**
247
250
  * Issue #240 Phase 2 — collect every file the React Compiler plugin
@@ -260,37 +263,37 @@ export const _testOnly_getHydrationRoutesMissingClientModule =
260
263
  * Paths are absolute and deduplicated. Returns `[]` when the manifest
261
264
  * has no hydrated routes.
262
265
  */
263
- export async function collectCompilerLintTargets(
264
- manifest: RoutesManifest,
265
- rootDir: string,
266
- ): Promise<string[]> {
267
- const hydratedRoutes = getHydratedRoutes(manifest);
268
-
269
- const out = new Set<string>();
270
-
271
- // 1) Islands (reuses the bundler's canonical scanner).
272
- if (hydratedRoutes.length > 0) {
273
- const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
274
- for (const entry of islandFiles) {
275
- out.add(path.resolve(entry.filePath));
276
- }
277
-
278
- // 2) `"use client"` page modules — the hydrated-route filter
279
- // already asserted `clientModule` exists.
280
- for (const route of hydratedRoutes) {
281
- const rel = route.clientModule ?? route.module;
282
- if (rel) out.add(path.resolve(rootDir, rel));
283
- }
284
- }
285
-
286
- // 3) Partials — glob the whole project once. Partials live anywhere
287
- // the user chooses, they're not sibling-scoped like islands.
288
- for (const entry of await scanPartialFiles(rootDir)) {
289
- out.add(path.resolve(entry.filePath));
290
- }
291
-
292
- return Array.from(out).sort();
293
- }
266
+ export async function collectCompilerLintTargets(
267
+ manifest: RoutesManifest,
268
+ rootDir: string,
269
+ ): Promise<string[]> {
270
+ const hydratedRoutes = getHydratedRoutes(manifest);
271
+
272
+ const out = new Set<string>();
273
+
274
+ // 1) Islands (reuses the bundler's canonical scanner).
275
+ if (hydratedRoutes.length > 0) {
276
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
277
+ for (const entry of islandFiles) {
278
+ out.add(path.resolve(entry.filePath));
279
+ }
280
+
281
+ // 2) `"use client"` page modules — the hydrated-route filter
282
+ // already asserted `clientModule` exists.
283
+ for (const route of hydratedRoutes) {
284
+ const rel = route.clientModule ?? route.module;
285
+ if (rel) out.add(path.resolve(rootDir, rel));
286
+ }
287
+ }
288
+
289
+ // 3) Partials — glob the whole project once. Partials live anywhere
290
+ // the user chooses, they're not sibling-scoped like islands.
291
+ for (const entry of await scanPartialFiles(rootDir)) {
292
+ out.add(path.resolve(entry.filePath));
293
+ }
294
+
295
+ return Array.from(out).sort();
296
+ }
294
297
 
295
298
  /** Build a single per-island bundle. */
296
299
  async function buildPerIslandBundle(
@@ -317,73 +320,74 @@ async function buildPerIslandBundle(
317
320
  define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
318
321
  });
319
322
  await fs.unlink(entryPath).catch(() => {});
320
- if (!result.success) {
321
- const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
322
- throw new Error(`Island build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this island file.`);
323
- }
324
- return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
325
- } catch (error) {
326
- await fs.unlink(entryPath).catch(() => {});
327
- throw error;
328
- }
329
- }
330
-
331
- interface PartialBundleBuild {
332
- name: string;
333
- js: string;
334
- priority: PartialFileEntry["priority"];
335
- size: number;
336
- gzipSize: number;
337
- }
338
-
339
- /** Build a single inline partial bundle. */
340
- async function buildPartialBundle(
341
- entry: PartialFileEntry,
342
- outDir: string,
343
- options: BundlerOptions,
344
- ): Promise<PartialBundleBuild> {
345
- const entryPath = path.join(outDir, `_entry_partial_${entry.name}.js`);
346
- const outputName = `${entry.name}.partial.js`;
347
- const isDev = isDevelopmentBuild(options);
348
-
349
- try {
350
- await Bun.write(entryPath, generatePartialEntry(entry.name, entry.filePath));
351
- const result = await safeBuild({
352
- entrypoints: [entryPath],
353
- outdir: outDir,
354
- naming: outputName,
355
- minify: shouldMinify(options),
356
- sourcemap: options.sourcemap ? "external" : "none",
357
- target: "browser",
358
- ...(isDev ? { reactFastRefresh: true } : {}),
359
- plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
360
- external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
361
- define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
362
- });
363
- await fs.unlink(entryPath).catch(() => {});
364
-
365
323
  if (!result.success) {
366
324
  const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
367
- 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 })\`.`);
325
+ throw new Error(`Island build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this island file.`);
368
326
  }
327
+ await sanitizeGeneratedClientBundle(path.join(outDir, outputName), isDev);
328
+ return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
329
+ } catch (error) {
330
+ await fs.unlink(entryPath).catch(() => {});
331
+ throw error;
332
+ }
333
+ }
334
+
335
+ interface PartialBundleBuild {
336
+ name: string;
337
+ js: string;
338
+ priority: PartialFileEntry["priority"];
339
+ size: number;
340
+ gzipSize: number;
341
+ }
342
+
343
+ /** Build a single inline partial bundle. */
344
+ async function buildPartialBundle(
345
+ entry: PartialFileEntry,
346
+ outDir: string,
347
+ options: BundlerOptions,
348
+ ): Promise<PartialBundleBuild> {
349
+ const entryPath = path.join(outDir, `_entry_partial_${entry.name}.js`);
350
+ const outputName = `${entry.name}.partial.js`;
351
+ const isDev = isDevelopmentBuild(options);
352
+
353
+ try {
354
+ await Bun.write(entryPath, generatePartialEntry(entry.name, entry.filePath));
355
+ const result = await safeBuild({
356
+ entrypoints: [entryPath],
357
+ outdir: outDir,
358
+ naming: outputName,
359
+ minify: shouldMinify(options),
360
+ sourcemap: options.sourcemap ? "external" : "none",
361
+ target: "browser",
362
+ ...(isDev ? { reactFastRefresh: true } : {}),
363
+ plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
364
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
365
+ define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
366
+ });
367
+ await fs.unlink(entryPath).catch(() => {});
368
+
369
+ if (!result.success) {
370
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
371
+ 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 })\`.`);
372
+ }
369
373
 
370
374
  const outputPath = path.join(outDir, outputName);
375
+ const sanitizedContent = await sanitizeGeneratedClientBundle(outputPath, isDev);
371
376
  const outputFile = Bun.file(outputPath);
372
- const content = await outputFile.text();
373
- const gzipped = Bun.gzipSync(Buffer.from(content));
374
-
375
- return {
376
- name: entry.name,
377
- js: `/.mandu/client/${outputName}`,
378
- priority: entry.priority,
379
- size: outputFile.size,
380
- gzipSize: gzipped.length,
381
- };
382
- } catch (error) {
383
- await fs.unlink(entryPath).catch(() => {});
384
- throw error;
385
- }
386
- }
377
+ const gzipped = Bun.gzipSync(Buffer.from(sanitizedContent));
378
+
379
+ return {
380
+ name: entry.name,
381
+ js: `/.mandu/client/${outputName}`,
382
+ priority: entry.priority,
383
+ size: outputFile.size,
384
+ gzipSize: gzipped.length,
385
+ };
386
+ } catch (error) {
387
+ await fs.unlink(entryPath).catch(() => {});
388
+ throw error;
389
+ }
390
+ }
387
391
 
388
392
  /**
389
393
  * 빈 매니페스트 생성
@@ -407,23 +411,23 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
407
411
  /**
408
412
  * Hydration이 필요한 라우트 필터링
409
413
  */
410
- function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
411
- return manifest.routes.filter(
412
- (route) =>
413
- route.kind === "page" &&
414
- route.clientModule &&
415
- needsHydration(route)
416
- );
417
- }
418
-
419
- function getHydrationRoutesMissingClientModule(manifest: RoutesManifest): RouteSpec[] {
420
- return manifest.routes.filter(
421
- (route) =>
422
- route.kind === "page" &&
423
- !route.clientModule &&
424
- needsHydration(route)
425
- );
426
- }
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
+ }
422
+
423
+ function getHydrationRoutesMissingClientModule(manifest: RoutesManifest): RouteSpec[] {
424
+ return manifest.routes.filter(
425
+ (route) =>
426
+ route.kind === "page" &&
427
+ !route.clientModule &&
428
+ needsHydration(route)
429
+ );
430
+ }
427
431
 
428
432
  const REACT_SHIM_EXPORTS = [
429
433
  "Activity",
@@ -518,31 +522,31 @@ function generateRuntimeSource(): string {
518
522
  import React, { useState, useEffect, Component } from 'react';
519
523
  import { hydrateRoot, createRoot } from 'react-dom/client';
520
524
 
521
- // Hydrated roots 추적 (unmount용) - 전역 초기화
522
- window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
523
- const hydratedRoots = window.__MANDU_ROOTS__;
524
-
525
- // 서버 데이터
526
- function readManduData() {
527
- if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
528
-
529
- const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
530
- if (!raw) {
531
- window.__MANDU_DATA__ = {};
532
- return window.__MANDU_DATA__;
533
- }
534
-
535
- try {
536
- window.__MANDU_DATA__ = JSON.parse(raw);
537
- } catch (error) {
538
- console.warn('[Mandu] Failed to parse server data:', error);
539
- window.__MANDU_DATA__ = {};
540
- }
541
-
542
- return window.__MANDU_DATA__;
543
- }
544
-
545
- const getServerData = (id) => readManduData()[id]?.serverData || {};
525
+ // Hydrated roots 추적 (unmount용) - 전역 초기화
526
+ window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
527
+ const hydratedRoots = window.__MANDU_ROOTS__;
528
+
529
+ // 서버 데이터
530
+ function readManduData() {
531
+ if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
532
+
533
+ const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
534
+ if (!raw) {
535
+ window.__MANDU_DATA__ = {};
536
+ return window.__MANDU_DATA__;
537
+ }
538
+
539
+ try {
540
+ window.__MANDU_DATA__ = JSON.parse(raw);
541
+ } catch (error) {
542
+ console.warn('[Mandu] Failed to parse server data:', error);
543
+ window.__MANDU_DATA__ = {};
544
+ }
545
+
546
+ return window.__MANDU_DATA__;
547
+ }
548
+
549
+ const getServerData = (id) => readManduData()[id]?.serverData || {};
546
550
 
547
551
  /**
548
552
  * Error Boundary 컴포넌트 (Class Component)
@@ -756,16 +760,16 @@ async function loadAndHydrate(element, src) {
756
760
  const island = module.default;
757
761
  let data = getServerData(id);
758
762
 
759
- // Fallback: read data-props from the island root or a child element if
760
- // __MANDU_DATA__ is empty. Inline partials put their serialized props on
761
- // the root marker itself.
762
- if (!data || Object.keys(data).length === 0) {
763
- const propsEl = element.hasAttribute('data-props')
764
- ? element
765
- : element.querySelector('[data-props]');
766
- if (propsEl) {
767
- try {
768
- data = JSON.parse(propsEl.getAttribute('data-props'));
763
+ // Fallback: read data-props from the island root or a child element if
764
+ // __MANDU_DATA__ is empty. Inline partials put their serialized props on
765
+ // the root marker itself.
766
+ if (!data || Object.keys(data).length === 0) {
767
+ const propsEl = element.hasAttribute('data-props')
768
+ ? element
769
+ : element.querySelector('[data-props]');
770
+ if (propsEl) {
771
+ try {
772
+ data = JSON.parse(propsEl.getAttribute('data-props'));
769
773
  } catch (e) {
770
774
  console.warn('[Mandu] Failed to parse data-props fallback:', e);
771
775
  }
@@ -1439,63 +1443,63 @@ async function buildRouterRuntime(
1439
1443
  * - Runtime이 dynamic import로 로드
1440
1444
  * - 등록/초기화 코드 없음
1441
1445
  */
1442
- function generateIslandEntry(routeId: string, clientModulePath: string): string {
1443
- // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1444
- const normalizedPath = clientModulePath.replace(/\\/g, "/");
1445
- return `
1446
+ function generateIslandEntry(routeId: string, clientModulePath: string): string {
1447
+ // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1448
+ const normalizedPath = clientModulePath.replace(/\\/g, "/");
1449
+ return `
1446
1450
  /**
1447
1451
  * Mandu Island: ${routeId} (Generated)
1448
1452
  * Pure export - no side effects
1449
1453
  */
1450
1454
  import island from "${normalizedPath}";
1451
- export default island;
1452
- `;
1453
- }
1454
-
1455
- function generatePartialEntry(partialId: string, partialModulePath: string): string {
1456
- const normalizedPath = partialModulePath.replace(/\\/g, "/");
1457
- return `
1458
- /**
1459
- * Mandu Partial: ${partialId} (Generated)
1460
- * Exports a runtime-compatible island wrapper around a compiled partial.
1461
- */
1462
- import React from "react";
1463
- import * as partialModule from "${normalizedPath}";
1464
-
1465
- function findPartial(mod) {
1466
- if (mod.default && mod.default.__mandu_partial === true) return mod.default;
1467
- for (const value of Object.values(mod)) {
1468
- if (value && value.__mandu_partial === true) return value;
1469
- }
1470
- throw new Error("[Mandu Partial] ${partialId} must export a value returned by partial({ component })");
1471
- }
1472
-
1473
- const partial = findPartial(partialModule);
1474
- const definition = partial.definition;
1475
- const component = definition.component;
1476
-
1477
- export default {
1478
- __mandu_island: true,
1479
- definition: {
1480
- setup(serverData) {
1481
- if (serverData && typeof serverData === "object" && Object.keys(serverData).length > 0) {
1482
- return serverData;
1483
- }
1484
- return definition.initialProps || {};
1485
- },
1486
- render(props) {
1487
- return React.createElement(component, props);
1488
- },
1489
- errorBoundary: definition.errorBoundary,
1490
- loading: definition.loading,
1491
- },
1492
- };
1493
- `;
1494
- }
1495
-
1496
- /**
1497
- * Runtime 번들 빌드
1498
- */
1455
+ export default island;
1456
+ `;
1457
+ }
1458
+
1459
+ function generatePartialEntry(partialId: string, partialModulePath: string): string {
1460
+ const normalizedPath = partialModulePath.replace(/\\/g, "/");
1461
+ return `
1462
+ /**
1463
+ * Mandu Partial: ${partialId} (Generated)
1464
+ * Exports a runtime-compatible island wrapper around a compiled partial.
1465
+ */
1466
+ import React from "react";
1467
+ import * as partialModule from "${normalizedPath}";
1468
+
1469
+ function findPartial(mod) {
1470
+ if (mod.default && mod.default.__mandu_partial === true) return mod.default;
1471
+ for (const value of Object.values(mod)) {
1472
+ if (value && value.__mandu_partial === true) return value;
1473
+ }
1474
+ throw new Error("[Mandu Partial] ${partialId} must export a value returned by partial({ component })");
1475
+ }
1476
+
1477
+ const partial = findPartial(partialModule);
1478
+ const definition = partial.definition;
1479
+ const component = definition.component;
1480
+
1481
+ export default {
1482
+ __mandu_island: true,
1483
+ definition: {
1484
+ setup(serverData) {
1485
+ if (serverData && typeof serverData === "object" && Object.keys(serverData).length > 0) {
1486
+ return serverData;
1487
+ }
1488
+ return definition.initialProps || {};
1489
+ },
1490
+ render(props) {
1491
+ return React.createElement(component, props);
1492
+ },
1493
+ errorBoundary: definition.errorBoundary,
1494
+ loading: definition.loading,
1495
+ },
1496
+ };
1497
+ `;
1498
+ }
1499
+
1500
+ /**
1501
+ * Runtime 번들 빌드
1502
+ */
1499
1503
  async function buildRuntime(
1500
1504
  outDir: string,
1501
1505
  options: BundlerOptions
@@ -1776,9 +1780,9 @@ async function buildVendorShims(
1776
1780
  }
1777
1781
  }
1778
1782
 
1779
- const buildShim = async (
1780
- shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
1781
- ): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
1783
+ const buildShim = async (
1784
+ shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
1785
+ ): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
1782
1786
  const srcPath = path.join(outDir, `${shim.name}.src.js`);
1783
1787
  const outputName = `${shim.name}.js`;
1784
1788
 
@@ -1814,14 +1818,14 @@ async function buildVendorShims(
1814
1818
 
1815
1819
  await fs.unlink(srcPath).catch(() => {});
1816
1820
 
1817
- if (!result.success) {
1818
- const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1819
- return {
1820
- key: shim.key,
1821
- cacheId: shim.cacheId,
1822
- error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n ${vendorShimFailureHint(shim.name)}`,
1823
- };
1824
- }
1821
+ if (!result.success) {
1822
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1823
+ return {
1824
+ key: shim.key,
1825
+ cacheId: shim.cacheId,
1826
+ error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n ${vendorShimFailureHint(shim.name)}`,
1827
+ };
1828
+ }
1825
1829
 
1826
1830
  return {
1827
1831
  key: shim.key,
@@ -1831,13 +1835,13 @@ async function buildVendorShims(
1831
1835
  };
1832
1836
  } catch (error) {
1833
1837
  await fs.unlink(srcPath).catch(() => {});
1834
- return {
1835
- key: shim.key,
1836
- cacheId: shim.cacheId,
1837
- error: `[${shim.name}] ${String(error)}\n ${vendorShimFailureHint(shim.name)}`,
1838
- };
1839
- }
1840
- };
1838
+ return {
1839
+ key: shim.key,
1840
+ cacheId: shim.cacheId,
1841
+ error: `[${shim.name}] ${String(error)}\n ${vendorShimFailureHint(shim.name)}`,
1842
+ };
1843
+ }
1844
+ };
1841
1845
 
1842
1846
  const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
1843
1847
  const writeEntries: VendorCacheWriteEntry[] = [];
@@ -1880,14 +1884,14 @@ async function buildVendorShims(
1880
1884
  fastRefreshRuntime: results.fastRefreshRuntime,
1881
1885
  errors,
1882
1886
  };
1883
- }
1884
-
1885
- function vendorShimFailureHint(shimName: string): string {
1886
- if (shimName.includes("react-refresh")) {
1887
- return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1888
- }
1889
- return "Hint: check the import paths and ensure the vendor package is installed.";
1890
- }
1887
+ }
1888
+
1889
+ function vendorShimFailureHint(shimName: string): string {
1890
+ if (shimName.includes("react-refresh")) {
1891
+ return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1892
+ }
1893
+ return "Hint: check the import paths and ensure the vendor package is installed.";
1894
+ }
1891
1895
 
1892
1896
  /**
1893
1897
  * 단일 Island 번들 빌드
@@ -1959,9 +1963,9 @@ async function buildIsland(
1959
1963
  actualOutputName = outputName;
1960
1964
  }
1961
1965
 
1962
- const outputFile = Bun.file(actualOutputPath);
1963
- const content = await outputFile.text();
1964
- const gzipped = Bun.gzipSync(Buffer.from(content));
1966
+ const outputFile = Bun.file(actualOutputPath);
1967
+ const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
1968
+ const gzipped = Bun.gzipSync(Buffer.from(content));
1965
1969
 
1966
1970
  return {
1967
1971
  routeId: route.id,
@@ -1973,22 +1977,64 @@ async function buildIsland(
1973
1977
  } catch (error) {
1974
1978
  await fs.unlink(entryPath).catch(() => {});
1975
1979
  throw error;
1976
- }
1977
- }
1978
-
1979
- /**
1980
- * 번들 매니페스트 생성
1980
+ }
1981
+ }
1982
+
1983
+ async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
1984
+ const source = await Bun.file(outputPath).text();
1985
+ if (!isDev) return source;
1986
+
1987
+ const sanitized = removeFastRefreshSelfAliases(source);
1988
+ if (sanitized === source) return source;
1989
+
1990
+ await Bun.write(outputPath, sanitized);
1991
+ await validateGeneratedClientBundle(outputPath);
1992
+ return sanitized;
1993
+ }
1994
+
1995
+ function removeFastRefreshSelfAliases(source: string): string {
1996
+ return source.replace(FAST_REFRESH_SELF_ALIAS_PATTERN, (line, localName: string, offset: number) => {
1997
+ const priorSource = source.slice(0, offset);
1998
+ const declarationPattern = new RegExp(`(?:const|let|class|function)\\s+${escapeRegExp(localName)}\\b`);
1999
+ return declarationPattern.test(priorSource) ? "" : line;
2000
+ });
2001
+ }
2002
+
2003
+ async function validateGeneratedClientBundle(outputPath: string): Promise<void> {
2004
+ const tempOutDir = await fs.mkdtemp(path.join(path.dirname(outputPath), ".validate-"));
2005
+ try {
2006
+ const result = await safeBuild({
2007
+ entrypoints: [outputPath],
2008
+ outdir: tempOutDir,
2009
+ target: "browser",
2010
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
2011
+ });
2012
+ if (result.success) return;
2013
+
2014
+ const grouped = result.logs.map((log) => ` - ${log.message}`).join("\n");
2015
+ throw new Error(`Generated client bundle failed syntax validation (source: ${outputPath}):\n${grouped}`);
2016
+ } finally {
2017
+ await fs.rm(tempOutDir, { recursive: true, force: true }).catch(() => {});
2018
+ }
2019
+ }
2020
+
2021
+ function escapeRegExp(value: string): string {
2022
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2023
+ }
2024
+
2025
+ /**
2026
+ * 번들 매니페스트 생성
1981
2027
  */
1982
- function createBundleManifest(
1983
- outputs: BundleOutput[],
1984
- routes: RouteSpec[],
1985
- runtimePath: string,
1986
- vendorResult: VendorBuildResult,
1987
- routerPath: string,
1988
- env: "development" | "production",
1989
- islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
1990
- partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
1991
- ): BundleManifest {
2028
+ function createBundleManifest(
2029
+ outputs: BundleOutput[],
2030
+ routes: RouteSpec[],
2031
+ runtimePath: string,
2032
+ vendorResult: VendorBuildResult,
2033
+ routerPath: string,
2034
+ env: "development" | "production",
2035
+ islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
2036
+ partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
2037
+ ): BundleManifest {
1992
2038
  const bundles: BundleManifest["bundles"] = {};
1993
2039
 
1994
2040
  for (const output of outputs) {
@@ -2004,27 +2050,27 @@ function createBundleManifest(
2004
2050
 
2005
2051
  // Per-island bundles (code splitting)
2006
2052
  let islands: BundleManifest["islands"];
2007
- if (islandBundles && islandBundles.length > 0) {
2008
- islands = {};
2009
- for (const ib of islandBundles) {
2010
- islands[ib.name] = {
2011
- js: ib.js,
2053
+ if (islandBundles && islandBundles.length > 0) {
2054
+ islands = {};
2055
+ for (const ib of islandBundles) {
2056
+ islands[ib.name] = {
2057
+ js: ib.js,
2012
2058
  route: ib.route,
2013
2059
  priority: ib.priority,
2014
- };
2015
- }
2016
- }
2017
-
2018
- let partials: BundleManifest["partials"];
2019
- if (partialBundles && partialBundles.length > 0) {
2020
- partials = {};
2021
- for (const partial of partialBundles) {
2022
- partials[partial.name] = {
2023
- js: partial.js,
2024
- priority: partial.priority,
2025
- };
2026
- }
2027
- }
2060
+ };
2061
+ }
2062
+ }
2063
+
2064
+ let partials: BundleManifest["partials"];
2065
+ if (partialBundles && partialBundles.length > 0) {
2066
+ partials = {};
2067
+ for (const partial of partialBundles) {
2068
+ partials[partial.name] = {
2069
+ js: partial.js,
2070
+ priority: partial.priority,
2071
+ };
2072
+ }
2073
+ }
2028
2074
 
2029
2075
  // Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
2030
2076
  // preamble can inject a dynamic import pointing at them. Only
@@ -2040,11 +2086,11 @@ function createBundleManifest(
2040
2086
  return {
2041
2087
  version: 1,
2042
2088
  buildTime: new Date().toISOString(),
2043
- env,
2044
- bundles,
2045
- ...(islands ? { islands } : {}),
2046
- ...(partials ? { partials } : {}),
2047
- shared: {
2089
+ env,
2090
+ bundles,
2091
+ ...(islands ? { islands } : {}),
2092
+ ...(partials ? { partials } : {}),
2093
+ shared: {
2048
2094
  runtime: runtimePath,
2049
2095
  vendor: vendorResult.react, // primary vendor for backwards compatibility
2050
2096
  router: routerPath, // Client-side Router
@@ -2065,32 +2111,32 @@ function createBundleManifest(
2065
2111
  /**
2066
2112
  * 번들 통계 계산
2067
2113
  */
2068
- function calculateStats(
2069
- outputs: BundleOutput[],
2070
- startTime: number,
2071
- extraOutputs: Array<{ routeId: string; size: number; gzipSize: number }> = [],
2072
- ): BundleStats {
2073
- let totalSize = 0;
2074
- let totalGzipSize = 0;
2075
- let largestBundle = { routeId: "", size: 0 };
2076
-
2077
- for (const output of [...outputs, ...extraOutputs]) {
2078
- totalSize += output.size;
2079
- totalGzipSize += output.gzipSize;
2080
-
2081
- if (output.size > largestBundle.size) {
2082
- largestBundle = { routeId: output.routeId, size: output.size };
2114
+ function calculateStats(
2115
+ outputs: BundleOutput[],
2116
+ startTime: number,
2117
+ extraOutputs: Array<{ routeId: string; size: number; gzipSize: number }> = [],
2118
+ ): BundleStats {
2119
+ let totalSize = 0;
2120
+ let totalGzipSize = 0;
2121
+ let largestBundle = { routeId: "", size: 0 };
2122
+
2123
+ for (const output of [...outputs, ...extraOutputs]) {
2124
+ totalSize += output.size;
2125
+ totalGzipSize += output.gzipSize;
2126
+
2127
+ if (output.size > largestBundle.size) {
2128
+ largestBundle = { routeId: output.routeId, size: output.size };
2083
2129
  }
2084
2130
  }
2085
2131
 
2086
2132
  return {
2087
2133
  totalSize,
2088
- totalGzipSize,
2089
- largestBundle,
2090
- buildTime: performance.now() - startTime,
2091
- bundleCount: outputs.length + extraOutputs.length,
2092
- };
2093
- }
2134
+ totalGzipSize,
2135
+ largestBundle,
2136
+ buildTime: performance.now() - startTime,
2137
+ bundleCount: outputs.length + extraOutputs.length,
2138
+ };
2139
+ }
2094
2140
 
2095
2141
  /**
2096
2142
  * 클라이언트 번들 빌드
@@ -2134,47 +2180,47 @@ export async function buildClientBundles(
2134
2180
  errors.push(`onBundleComplete[${e.source}]: ${e.error.message}`);
2135
2181
  }
2136
2182
  };
2137
- const env = resolveBundlerMode(options);
2138
-
2139
- // 1. Hydration이 필요한 라우트 필터링
2140
- const invalidClientRouteIds = new Set<string>();
2141
- const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
2142
- const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
2143
- const routesMissingClientModule = getHydrationRoutesMissingClientModule(manifest);
2144
- if (routesMissingClientModule.length > 0) {
2145
- const missingClientErrors = await Promise.all(
2146
- routesMissingClientModule.map((route) =>
2147
- describeMissingHydrationClientModule(route, rootDir, {
2148
- allowPartialOnly: partialFiles.length > 0,
2149
- })
2150
- )
2151
- );
2152
- errors.push(...missingClientErrors.filter((error): error is string => error !== null));
2153
- }
2154
-
2155
- let hydratedRoutes = getHydratedRoutes(manifest);
2156
- if (hydratedRoutes.length > 0) {
2157
- const validRoutes: RouteSpec[] = [];
2158
- for (const route of hydratedRoutes) {
2159
- const validationError = await validateClientModuleForBrowserBundle(route, rootDir);
2160
- if (validationError) {
2161
- invalidClientRouteIds.add(route.id);
2162
- errors.push(validationError);
2163
- continue;
2164
- }
2165
- validRoutes.push(route);
2166
- }
2167
- hydratedRoutes = validRoutes;
2168
- }
2169
- // 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
2183
+ const env = resolveBundlerMode(options);
2184
+
2185
+ // 1. Hydration이 필요한 라우트 필터링
2186
+ const invalidClientRouteIds = new Set<string>();
2187
+ const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
2188
+ const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
2189
+ const routesMissingClientModule = getHydrationRoutesMissingClientModule(manifest);
2190
+ if (routesMissingClientModule.length > 0) {
2191
+ const missingClientErrors = await Promise.all(
2192
+ routesMissingClientModule.map((route) =>
2193
+ describeMissingHydrationClientModule(route, rootDir, {
2194
+ allowPartialOnly: partialFiles.length > 0,
2195
+ })
2196
+ )
2197
+ );
2198
+ errors.push(...missingClientErrors.filter((error): error is string => error !== null));
2199
+ }
2200
+
2201
+ let hydratedRoutes = getHydratedRoutes(manifest);
2202
+ if (hydratedRoutes.length > 0) {
2203
+ const validRoutes: RouteSpec[] = [];
2204
+ for (const route of hydratedRoutes) {
2205
+ const validationError = await validateClientModuleForBrowserBundle(route, rootDir);
2206
+ if (validationError) {
2207
+ invalidClientRouteIds.add(route.id);
2208
+ errors.push(validationError);
2209
+ continue;
2210
+ }
2211
+ validRoutes.push(route);
2212
+ }
2213
+ hydratedRoutes = validRoutes;
2214
+ }
2215
+ // 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
2170
2216
  const outDir = resolveClientOutDir(rootDir, options.outDir);
2171
2217
  await fs.mkdir(outDir, { recursive: true });
2172
2218
 
2173
2219
  // Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
2174
2220
  // (이전 빌드의 stale 매니페스트 참조 방지)
2175
- if (hydratedRoutes.length === 0 && partialFiles.length === 0) {
2221
+ if (hydratedRoutes.length === 0 && partialFiles.length === 0) {
2176
2222
  // #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
2177
- if (options.skipFrameworkBundles && errors.length === 0) {
2223
+ if (options.skipFrameworkBundles && errors.length === 0) {
2178
2224
  const manifestPath = path.join(rootDir, ".mandu/manifest.json");
2179
2225
  try {
2180
2226
  const manifestRaw = await fs.readFile(manifestPath, "utf-8");
@@ -2225,11 +2271,11 @@ export async function buildClientBundles(
2225
2271
  path.join(rootDir, ".mandu/manifest.json"),
2226
2272
  JSON.stringify(emptyManifest, null, 2)
2227
2273
  );
2228
- return {
2229
- success: errors.length === 0,
2230
- outputs: [],
2231
- errors,
2232
- manifest: emptyManifest,
2274
+ return {
2275
+ success: errors.length === 0,
2276
+ outputs: [],
2277
+ errors,
2278
+ manifest: emptyManifest,
2233
2279
  stats: {
2234
2280
  totalSize: 0,
2235
2281
  totalGzipSize: 0,
@@ -2268,14 +2314,14 @@ export async function buildClientBundles(
2268
2314
  return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
2269
2315
  }
2270
2316
 
2271
- // Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
2272
- for (const routeId of invalidClientRouteIds) {
2273
- delete existingManifest.bundles[routeId];
2274
- }
2275
- if (outputs.length > 0 || invalidClientRouteIds.size > 0) {
2276
- for (const output of outputs) {
2277
- if (existingManifest.bundles[output.routeId]) {
2278
- existingManifest.bundles[output.routeId].js = output.outputPath;
2317
+ // Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
2318
+ for (const routeId of invalidClientRouteIds) {
2319
+ delete existingManifest.bundles[routeId];
2320
+ }
2321
+ if (outputs.length > 0 || invalidClientRouteIds.size > 0) {
2322
+ for (const output of outputs) {
2323
+ if (existingManifest.bundles[output.routeId]) {
2324
+ existingManifest.bundles[output.routeId].js = output.outputPath;
2279
2325
  } else {
2280
2326
  const route = targetRoutes.find((r) => r.id === output.routeId);
2281
2327
  const hydration = route ? getRouteHydration(route) : null;
@@ -2287,11 +2333,11 @@ export async function buildClientBundles(
2287
2333
  }
2288
2334
  }
2289
2335
 
2290
- await fs.writeFile(
2291
- path.join(rootDir, ".mandu/manifest.json"),
2292
- JSON.stringify(existingManifest, null, 2)
2293
- );
2294
- }
2336
+ await fs.writeFile(
2337
+ path.join(rootDir, ".mandu/manifest.json"),
2338
+ JSON.stringify(existingManifest, null, 2)
2339
+ );
2340
+ }
2295
2341
  // When all builds failed, do NOT overwrite manifest — keep previous good state
2296
2342
 
2297
2343
  const stats = calculateStats(outputs, startTime);
@@ -2327,13 +2373,13 @@ export async function buildClientBundles(
2327
2373
  "[Mandu] Existing manifest missing required fields (shared/bundles), falling back to full build",
2328
2374
  );
2329
2375
  return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
2330
- }
2331
-
2332
- for (const routeId of invalidClientRouteIds) {
2333
- delete existingManifest.bundles[routeId];
2334
- }
2335
-
2336
- // Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
2376
+ }
2377
+
2378
+ for (const routeId of invalidClientRouteIds) {
2379
+ delete existingManifest.bundles[routeId];
2380
+ }
2381
+
2382
+ // Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
2337
2383
  for (const route of hydratedRoutes) {
2338
2384
  if (!route.clientModule) continue;
2339
2385
  const clientModulePath = path.join(rootDir, route.clientModule);
@@ -2398,62 +2444,62 @@ export async function buildClientBundles(
2398
2444
  };
2399
2445
  }
2400
2446
  }
2401
- if (perIslandBundles.length > 0) {
2402
- existingManifest.islands = existingManifest.islands || {};
2403
- for (const ib of perIslandBundles) {
2404
- existingManifest.islands[ib.name] = {
2405
- js: ib.js,
2447
+ if (perIslandBundles.length > 0) {
2448
+ existingManifest.islands = existingManifest.islands || {};
2449
+ for (const ib of perIslandBundles) {
2450
+ existingManifest.islands[ib.name] = {
2451
+ js: ib.js,
2406
2452
  route: ib.route,
2407
2453
  priority: ib.priority,
2408
- };
2409
- }
2410
- }
2411
-
2412
- const partialBundles: PartialBundleBuild[] = [];
2413
- if (partialFiles.length > 0) {
2414
- const partialResults = await Promise.all(
2415
- partialFiles.map(async (entry) => {
2416
- try {
2417
- return await buildPartialBundle(entry, outDir, options);
2418
- } catch (error) {
2419
- errors.push(`[partial:${entry.name}] ${String(error)}`);
2420
- return null;
2421
- }
2422
- }),
2423
- );
2424
- for (const result of partialResults) {
2425
- if (result) partialBundles.push(result);
2426
- }
2427
- }
2428
- if (partialFiles.length > 0 || existingManifest.partials) {
2429
- existingManifest.partials = {};
2430
- for (const partial of partialBundles) {
2431
- existingManifest.partials[partial.name] = {
2432
- js: partial.js,
2433
- priority: partial.priority,
2434
- };
2435
- }
2436
- if (Object.keys(existingManifest.partials).length === 0) {
2437
- delete existingManifest.partials;
2438
- }
2439
- }
2440
-
2441
- await fs.writeFile(
2442
- path.join(rootDir, ".mandu/manifest.json"),
2443
- JSON.stringify(existingManifest, null, 2),
2444
- );
2445
-
2446
- const stats = calculateStats(
2447
- outputs,
2448
- startTime,
2449
- partialBundles.map((partial) => ({
2450
- routeId: `partial:${partial.name}`,
2451
- size: partial.size,
2452
- gzipSize: partial.gzipSize,
2453
- })),
2454
- );
2455
- return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2456
- }
2454
+ };
2455
+ }
2456
+ }
2457
+
2458
+ const partialBundles: PartialBundleBuild[] = [];
2459
+ if (partialFiles.length > 0) {
2460
+ const partialResults = await Promise.all(
2461
+ partialFiles.map(async (entry) => {
2462
+ try {
2463
+ return await buildPartialBundle(entry, outDir, options);
2464
+ } catch (error) {
2465
+ errors.push(`[partial:${entry.name}] ${String(error)}`);
2466
+ return null;
2467
+ }
2468
+ }),
2469
+ );
2470
+ for (const result of partialResults) {
2471
+ if (result) partialBundles.push(result);
2472
+ }
2473
+ }
2474
+ if (partialFiles.length > 0 || existingManifest.partials) {
2475
+ existingManifest.partials = {};
2476
+ for (const partial of partialBundles) {
2477
+ existingManifest.partials[partial.name] = {
2478
+ js: partial.js,
2479
+ priority: partial.priority,
2480
+ };
2481
+ }
2482
+ if (Object.keys(existingManifest.partials).length === 0) {
2483
+ delete existingManifest.partials;
2484
+ }
2485
+ }
2486
+
2487
+ await fs.writeFile(
2488
+ path.join(rootDir, ".mandu/manifest.json"),
2489
+ JSON.stringify(existingManifest, null, 2),
2490
+ );
2491
+
2492
+ const stats = calculateStats(
2493
+ outputs,
2494
+ startTime,
2495
+ partialBundles.map((partial) => ({
2496
+ routeId: `partial:${partial.name}`,
2497
+ size: partial.size,
2498
+ gzipSize: partial.gzipSize,
2499
+ })),
2500
+ );
2501
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2502
+ }
2457
2503
 
2458
2504
  // 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
2459
2505
  const isDev = env === "development";
@@ -2539,11 +2585,11 @@ export async function buildClientBundles(
2539
2585
  const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
2540
2586
  const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
2541
2587
 
2542
- if (islandFiles.length > 0) {
2543
- const islandResults = await Promise.all(
2544
- islandFiles.map(async (entry) => {
2545
- try {
2546
- return await buildPerIslandBundle(entry, outDir, options);
2588
+ if (islandFiles.length > 0) {
2589
+ const islandResults = await Promise.all(
2590
+ islandFiles.map(async (entry) => {
2591
+ try {
2592
+ return await buildPerIslandBundle(entry, outDir, options);
2547
2593
  } catch (error) {
2548
2594
  errors.push(`[island:${entry.name}] ${String(error)}`);
2549
2595
  return null;
@@ -2551,38 +2597,38 @@ export async function buildClientBundles(
2551
2597
  })
2552
2598
  );
2553
2599
  for (const result of islandResults) {
2554
- if (result) islandBundles.push(result);
2555
- }
2556
- }
2557
-
2558
- const partialBundles: PartialBundleBuild[] = [];
2559
- if (partialFiles.length > 0) {
2560
- const partialResults = await Promise.all(
2561
- partialFiles.map(async (entry) => {
2562
- try {
2563
- return await buildPartialBundle(entry, outDir, options);
2564
- } catch (error) {
2565
- errors.push(`[partial:${entry.name}] ${String(error)}`);
2566
- return null;
2567
- }
2568
- }),
2569
- );
2570
- for (const result of partialResults) {
2571
- if (result) partialBundles.push(result);
2572
- }
2573
- }
2574
-
2575
- // 6. 번들 매니페스트 생성
2576
- const bundleManifest = createBundleManifest(
2577
- outputs,
2578
- hydratedRoutes,
2579
- runtimeResult.outputPath,
2580
- vendorResult,
2581
- routerResult.outputPath,
2582
- env,
2583
- islandBundles,
2584
- partialBundles,
2585
- );
2600
+ if (result) islandBundles.push(result);
2601
+ }
2602
+ }
2603
+
2604
+ const partialBundles: PartialBundleBuild[] = [];
2605
+ if (partialFiles.length > 0) {
2606
+ const partialResults = await Promise.all(
2607
+ partialFiles.map(async (entry) => {
2608
+ try {
2609
+ return await buildPartialBundle(entry, outDir, options);
2610
+ } catch (error) {
2611
+ errors.push(`[partial:${entry.name}] ${String(error)}`);
2612
+ return null;
2613
+ }
2614
+ }),
2615
+ );
2616
+ for (const result of partialResults) {
2617
+ if (result) partialBundles.push(result);
2618
+ }
2619
+ }
2620
+
2621
+ // 6. 번들 매니페스트 생성
2622
+ const bundleManifest = createBundleManifest(
2623
+ outputs,
2624
+ hydratedRoutes,
2625
+ runtimeResult.outputPath,
2626
+ vendorResult,
2627
+ routerResult.outputPath,
2628
+ env,
2629
+ islandBundles,
2630
+ partialBundles,
2631
+ );
2586
2632
 
2587
2633
  await fs.writeFile(
2588
2634
  path.join(rootDir, ".mandu/manifest.json"),
@@ -2590,15 +2636,15 @@ export async function buildClientBundles(
2590
2636
  );
2591
2637
 
2592
2638
  // 7. 통계 계산
2593
- const stats = calculateStats(
2594
- outputs,
2595
- startTime,
2596
- partialBundles.map((partial) => ({
2597
- routeId: `partial:${partial.name}`,
2598
- size: partial.size,
2599
- gzipSize: partial.gzipSize,
2600
- })),
2601
- );
2639
+ const stats = calculateStats(
2640
+ outputs,
2641
+ startTime,
2642
+ partialBundles.map((partial) => ({
2643
+ routeId: `partial:${partial.name}`,
2644
+ size: partial.size,
2645
+ gzipSize: partial.gzipSize,
2646
+ })),
2647
+ );
2602
2648
 
2603
2649
  // Phase 18.τ — fire onBundleComplete(stats) before return.
2604
2650
  await fireOnBundleComplete(stats);
@@ -2626,14 +2672,14 @@ export function formatSize(bytes: number): string {
2626
2672
  * 번들 결과 요약 출력
2627
2673
  */
2628
2674
  export function printBundleStats(result: BundleResult): void {
2629
- console.log("\n📦 Mandu Client Bundles");
2630
- console.log("=".repeat(50));
2631
-
2632
- const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2633
- if (result.outputs.length === 0 && partialCount === 0) {
2634
- console.log("No islands or partials to bundle (hydration: none or no client entry)");
2635
- return;
2636
- }
2675
+ console.log("\n📦 Mandu Client Bundles");
2676
+ console.log("=".repeat(50));
2677
+
2678
+ const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2679
+ if (result.outputs.length === 0 && partialCount === 0) {
2680
+ console.log("No islands or partials to bundle (hydration: none or no client entry)");
2681
+ return;
2682
+ }
2637
2683
 
2638
2684
  console.log(`Environment: ${result.manifest.env}`);
2639
2685
  console.log(`Bundles: ${result.stats.bundleCount}`);
@@ -2642,15 +2688,15 @@ export function printBundleStats(result: BundleResult): void {
2642
2688
  console.log(`Build Time: ${result.stats.buildTime.toFixed(0)}ms`);
2643
2689
  console.log("");
2644
2690
 
2645
- // 각 번들 정보
2646
- for (const output of result.outputs) {
2647
- console.log(
2648
- ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
2649
- );
2650
- }
2651
- if (partialCount > 0) {
2652
- console.log(` Partials: ${partialCount}`);
2653
- }
2691
+ // 각 번들 정보
2692
+ for (const output of result.outputs) {
2693
+ console.log(
2694
+ ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
2695
+ );
2696
+ }
2697
+ if (partialCount > 0) {
2698
+ console.log(` Partials: ${partialCount}`);
2699
+ }
2654
2700
 
2655
2701
  if (result.errors.length > 0) {
2656
2702
  console.log("\n⚠️ Errors:");