@mandujs/core 0.54.11 → 0.54.13

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 (76) hide show
  1. package/package.json +208 -200
  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 +49 -1
  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 +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +5 -4
  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 +73 -7
  18. package/src/bundler/build.ts +139 -31
  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/contract/schema.ts +7 -0
  27. package/src/deploy/inference/context.ts +82 -82
  28. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  29. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  30. package/src/error/formatter.ts +10 -1
  31. package/src/experimental/index.ts +10 -0
  32. package/src/filling/context.ts +17 -17
  33. package/src/filling/filling.ts +22 -1
  34. package/src/filling/index.ts +15 -1
  35. package/src/generator/generate.ts +30 -30
  36. package/src/generator/index.ts +3 -3
  37. package/src/generator/templates.ts +210 -210
  38. package/src/guard/check.ts +9 -9
  39. package/src/guard/config-guard.ts +13 -13
  40. package/src/guard/fs-routes-policy.ts +51 -51
  41. package/src/guard/index.ts +11 -11
  42. package/src/index.ts +0 -10
  43. package/src/internal/index.ts +25 -0
  44. package/src/kitchen/api/file-api.ts +11 -11
  45. package/src/report/index.ts +1 -1
  46. package/src/resource/__tests__/generator.test.ts +6 -6
  47. package/src/resource/__tests__/schema.test.ts +14 -14
  48. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  49. package/src/resource/ddl/emit.ts +146 -146
  50. package/src/resource/generator-schema.ts +11 -11
  51. package/src/resource/generators/slot.ts +72 -72
  52. package/src/resource/schema.ts +21 -21
  53. package/src/router/client-entry.test.ts +69 -33
  54. package/src/router/client-entry.ts +134 -74
  55. package/src/router/fs-routes.ts +24 -22
  56. package/src/router/fs-scanner.ts +21 -17
  57. package/src/router/fs-types.ts +8 -5
  58. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  59. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  60. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  61. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  62. package/src/runtime/devtools-adapter.ts +68 -68
  63. package/src/runtime/escape.ts +34 -34
  64. package/src/runtime/image-feature.ts +15 -0
  65. package/src/runtime/observability-lifecycle.ts +290 -290
  66. package/src/runtime/page-render-response.ts +106 -106
  67. package/src/runtime/rate-limit.ts +231 -0
  68. package/src/runtime/request-middleware.ts +31 -31
  69. package/src/runtime/scheduler-lifecycle.ts +64 -0
  70. package/src/runtime/server.ts +27 -295
  71. package/src/runtime/ssr.ts +59 -59
  72. package/src/runtime/static-files.ts +289 -289
  73. package/src/runtime/streaming-ssr.ts +22 -22
  74. package/src/spec/schema.ts +4 -3
  75. package/src/watcher/__tests__/watcher.test.ts +59 -59
  76. package/src/watcher/watcher.ts +61 -61
@@ -28,13 +28,13 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
28
28
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
29
29
  import { tmpdir } from "os";
30
30
  import path from "path";
31
- import type { RouteSpec, RoutesManifest } from "../../spec/schema";
32
- import {
33
- _testOnly_scanIslandFiles,
34
- _testOnly_scanPartialFiles,
35
- _testOnly_getHydratedRoutes,
36
- _testOnly_getHydrationRoutesMissingClientModule,
37
- } from "../build";
31
+ import type { RouteSpec, RoutesManifest } from "../../spec/schema";
32
+ import {
33
+ _testOnly_scanIslandFiles,
34
+ _testOnly_scanPartialFiles,
35
+ _testOnly_getHydratedRoutes,
36
+ _testOnly_getHydrationRoutesMissingClientModule,
37
+ } from "../build";
38
38
  import { HMR_PERF } from "../../perf/hmr-markers";
39
39
  import {
40
40
  _resetCacheForTesting as _resetPerfCache,
@@ -374,7 +374,7 @@ describe("Phase 7.1 R1 Agent C — getHydratedRoutes filter", () => {
374
374
  });
375
375
  });
376
376
 
377
- describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
377
+ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
378
378
  let project: ReturnType<typeof createProject>;
379
379
 
380
380
  beforeEach(() => {
@@ -502,55 +502,55 @@ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", (
502
502
  ];
503
503
  const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
504
504
  expect(result).toEqual([]);
505
- });
506
- });
507
-
508
- describe("hydration route validation", () => {
509
- it("finds pages that request hydration without a client module", () => {
510
- const manifest = {
511
- version: 1,
512
- routes: [
513
- {
514
- id: "login",
515
- pattern: "/login",
516
- kind: "page",
517
- module: "app/login/page.tsx",
518
- componentModule: "app/login/page.tsx",
519
- hydration: { strategy: "full", priority: "immediate", preload: false },
520
- },
521
- pureSsrRoute("about", "/about", "app/about"),
522
- pageRoute("dashboard", "/dashboard", "app/dashboard"),
523
- ],
524
- } as RoutesManifest;
525
-
526
- const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
527
- expect(missing.map((route) => route.id)).toEqual(["login"]);
528
- });
529
- });
530
-
531
- describe("partial bundle scan", () => {
532
- let project: ReturnType<typeof createProject>;
533
-
534
- beforeEach(() => {
535
- project = createProject();
536
- });
537
-
538
- afterEach(() => {
539
- try {
540
- rmSync(project.rootDir, { recursive: true, force: true });
541
- } catch {
542
- /* Windows lock tolerance */
543
- }
544
- });
545
-
546
- it("discovers *.partial.tsx files outside .mandu and node_modules", async () => {
547
- project.writeIslandFile("app", "Home.partial.tsx");
548
- project.writeIslandFile(".mandu/client", "Stale.partial.tsx");
549
- project.writeIslandFile("node_modules/pkg", "Ignored.partial.tsx");
550
-
551
- const result = await _testOnly_scanPartialFiles(project.rootDir);
552
-
553
- expect(result.map((entry) => entry.name)).toEqual(["Home"]);
554
- expect(result[0].priority).toBe("visible");
555
- });
556
- });
505
+ });
506
+ });
507
+
508
+ describe("hydration route validation", () => {
509
+ it("finds pages that request hydration without a client module", () => {
510
+ const manifest = {
511
+ version: 1,
512
+ routes: [
513
+ {
514
+ id: "login",
515
+ pattern: "/login",
516
+ kind: "page",
517
+ module: "app/login/page.tsx",
518
+ componentModule: "app/login/page.tsx",
519
+ hydration: { strategy: "full", priority: "immediate", preload: false },
520
+ },
521
+ pureSsrRoute("about", "/about", "app/about"),
522
+ pageRoute("dashboard", "/dashboard", "app/dashboard"),
523
+ ],
524
+ } as RoutesManifest;
525
+
526
+ const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
527
+ expect(missing.map((route) => route.id)).toEqual(["login"]);
528
+ });
529
+ });
530
+
531
+ describe("partial bundle scan", () => {
532
+ let project: ReturnType<typeof createProject>;
533
+
534
+ beforeEach(() => {
535
+ project = createProject();
536
+ });
537
+
538
+ afterEach(() => {
539
+ try {
540
+ rmSync(project.rootDir, { recursive: true, force: true });
541
+ } catch {
542
+ /* Windows lock tolerance */
543
+ }
544
+ });
545
+
546
+ it("discovers *.partial.tsx files outside .mandu and node_modules", async () => {
547
+ project.writeIslandFile("app", "Home.partial.tsx");
548
+ project.writeIslandFile(".mandu/client", "Stale.partial.tsx");
549
+ project.writeIslandFile("node_modules/pkg", "Ignored.partial.tsx");
550
+
551
+ const result = await _testOnly_scanPartialFiles(project.rootDir);
552
+
553
+ expect(result.map((entry) => entry.name)).toEqual(["Home"]);
554
+ expect(result[0].priority).toBe("visible");
555
+ });
556
+ });
@@ -1,20 +1,20 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { __private } from "../css";
3
-
4
- describe("CSS Tailwind command resolution", () => {
5
- test("uses process.execPath when it is Bun", () => {
6
- expect(
7
- __private.getTailwindCommand(["@tailwindcss/cli"], "C:\\tools\\bun.exe"),
8
- ).toEqual(["C:\\tools\\bun.exe", "x", "@tailwindcss/cli"]);
9
- });
10
-
11
- test("does not treat standalone mandu.exe as Bun", () => {
12
- expect(
13
- __private.getTailwindCommand(
14
- ["@tailwindcss/cli"],
15
- "C:\\Users\\User\\AppData\\Local\\Mandu\\bin\\mandu.exe",
16
- () => "C:\\Users\\User\\.bun\\bin\\bun.exe",
17
- ),
18
- ).toEqual(["C:\\Users\\User\\.bun\\bin\\bun.exe", "x", "@tailwindcss/cli"]);
19
- });
20
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import { __private } from "../css";
3
+
4
+ describe("CSS Tailwind command resolution", () => {
5
+ test("uses process.execPath when it is Bun", () => {
6
+ expect(
7
+ __private.getTailwindCommand(["@tailwindcss/cli"], "C:\\tools\\bun.exe"),
8
+ ).toEqual(["C:\\tools\\bun.exe", "x", "@tailwindcss/cli"]);
9
+ });
10
+
11
+ test("does not treat standalone mandu.exe as Bun", () => {
12
+ expect(
13
+ __private.getTailwindCommand(
14
+ ["@tailwindcss/cli"],
15
+ "C:\\Users\\User\\AppData\\Local\\Mandu\\bin\\mandu.exe",
16
+ () => "C:\\Users\\User\\.bun\\bin\\bun.exe",
17
+ ),
18
+ ).toEqual(["C:\\Users\\User\\.bun\\bin\\bun.exe", "x", "@tailwindcss/cli"]);
19
+ });
20
+ });
@@ -341,26 +341,26 @@ export async function analyzeBundle(
341
341
  deps: entry.dependencies ?? [],
342
342
  });
343
343
  }
344
- for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
345
- // Avoid double-counting: if a per-island chunk shares its route id with
346
- // a route-level bundle, we prefer the island entry (finer granularity).
347
- const existing = islandSources.findIndex((s) => s.name === islandName);
344
+ for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
345
+ // Avoid double-counting: if a per-island chunk shares its route id with
346
+ // a route-level bundle, we prefer the island entry (finer granularity).
347
+ const existing = islandSources.findIndex((s) => s.name === islandName);
348
348
  if (existing !== -1) islandSources.splice(existing, 1);
349
349
  islandSources.push({
350
350
  name: islandName,
351
351
  url: entry.js,
352
352
  priority: entry.priority,
353
- deps: [],
354
- });
355
- }
356
- for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) {
357
- islandSources.push({
358
- name: `partial:${partialName}`,
359
- url: entry.js,
360
- priority: entry.priority,
361
- deps: [],
362
- });
363
- }
353
+ deps: [],
354
+ });
355
+ }
356
+ for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) {
357
+ islandSources.push({
358
+ name: `partial:${partialName}`,
359
+ url: entry.js,
360
+ priority: entry.priority,
361
+ deps: [],
362
+ });
363
+ }
364
364
 
365
365
  const islands: AnalyzeIsland[] = [];
366
366
  for (const src of islandSources) {
@@ -219,7 +219,7 @@ describe("buildClientBundles vendor shims", () => {
219
219
  }
220
220
  });
221
221
 
222
- test("rewrites route-component clientModule to the real client import before bundling", async () => {
222
+ test("rewrites route-component clientModule to the real client import before bundling", async () => {
223
223
  const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-client-import-"));
224
224
  try {
225
225
  await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
@@ -239,19 +239,85 @@ describe("buildClientBundles vendor shims", () => {
239
239
  );
240
240
  await writeFile(
241
241
  path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
242
- '"use client";\nexport default function LoginPage() { return <form />; }\n',
242
+ '"use client";\n' +
243
+ 'import { useState } from "react";\n' +
244
+ "export default function LoginPage() {\n" +
245
+ ' const [email, setEmail] = useState("");\n' +
246
+ ' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
247
+ "}\n",
243
248
  "utf-8",
244
249
  );
245
250
 
246
251
  const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-client-import");
247
252
  expect(routeClientResult.success).toBe(true);
248
- expect(await Bun.file(path.join(routeClientRoot, ".mandu", "client", "login.island.js")).exists()).toBe(true);
253
+ const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
254
+ expect(await Bun.file(bundlePath).exists()).toBe(true);
255
+
256
+ const bundleSource = await readFile(bundlePath, "utf-8");
257
+ expect(bundleSource).not.toContain("var LoginPage = LoginPage;");
258
+ const parseResult = await Bun.build({
259
+ entrypoints: [bundlePath],
260
+ target: "browser",
261
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
262
+ });
263
+ expect(parseResult.success).toBe(true);
249
264
  } finally {
250
265
  await rm(routeClientRoot, { recursive: true, force: true });
251
- }
252
- });
253
-
254
- test("fails when hydration is enabled but no clientModule can be resolved", async () => {
266
+ }
267
+ });
268
+
269
+ test("bundles route-level named client exports without requiring a default export", async () => {
270
+ const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-named-client-import-"));
271
+ try {
272
+ await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
273
+ await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
274
+ await writeFile(
275
+ path.join(routeClientRoot, "package.json"),
276
+ JSON.stringify({ name: "mandu-route-named-client-import-test", type: "module" }, null, 2),
277
+ "utf-8",
278
+ );
279
+ await writeFile(
280
+ path.join(routeClientRoot, "app", "login", "page.tsx"),
281
+ 'import { LoginPage } from "@/client/pages/login/LoginPage.client";\n' +
282
+ "export default function Page() {\n" +
283
+ " return <LoginPage />;\n" +
284
+ "}\n",
285
+ "utf-8",
286
+ );
287
+ await writeFile(
288
+ path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
289
+ '"use client";\n' +
290
+ 'import { useState } from "react";\n' +
291
+ "export function LoginPage() {\n" +
292
+ ' const [email, setEmail] = useState("");\n' +
293
+ ' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
294
+ "}\n",
295
+ "utf-8",
296
+ );
297
+
298
+ const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-named-client-import");
299
+ if (!routeClientResult.success) {
300
+ console.error("[named route client] errors:", routeClientResult.errors);
301
+ }
302
+ expect(routeClientResult.success).toBe(true);
303
+ const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
304
+ expect(await Bun.file(bundlePath).exists()).toBe(true);
305
+
306
+ const bundleSource = await readFile(bundlePath, "utf-8");
307
+ expect(bundleSource).toContain("LoginPage");
308
+ expect(bundleSource).not.toContain("Client islands cannot use server-side modules");
309
+ const parseResult = await Bun.build({
310
+ entrypoints: [bundlePath],
311
+ target: "browser",
312
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
313
+ });
314
+ expect(parseResult.success).toBe(true);
315
+ } finally {
316
+ await rm(routeClientRoot, { recursive: true, force: true });
317
+ }
318
+ });
319
+
320
+ test("fails when hydration is enabled but no clientModule can be resolved", async () => {
255
321
  const missingRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-hydration-no-client-"));
256
322
  try {
257
323
  await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
@@ -104,6 +104,9 @@ function nodeEnvDefine(options: BundlerOptions): string {
104
104
  return JSON.stringify(resolveBundlerMode(options));
105
105
  }
106
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
+
107
110
  function resolveClientOutDir(rootDir: string, outDir?: string): string {
108
111
  const defaultOutDir = path.join(rootDir, ".mandu/client");
109
112
  if (!outDir) return defaultOutDir;
@@ -321,6 +324,7 @@ async function buildPerIslandBundle(
321
324
  const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
322
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.`);
323
326
  }
327
+ await sanitizeGeneratedClientBundle(path.join(outDir, outputName), isDev);
324
328
  return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
325
329
  } catch (error) {
326
330
  await fs.unlink(entryPath).catch(() => {});
@@ -368,9 +372,9 @@ async function buildPartialBundle(
368
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));
377
+ const gzipped = Bun.gzipSync(Buffer.from(sanitizedContent));
374
378
 
375
379
  return {
376
380
  name: entry.name,
@@ -1439,18 +1443,61 @@ 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
- /**
1447
- * Mandu Island: ${routeId} (Generated)
1448
- * Pure export - no side effects
1449
- */
1450
- import island from "${normalizedPath}";
1451
- export default island;
1452
- `;
1453
- }
1446
+ function generateIslandEntry(routeId: string, clientModulePath: string, exportName?: string): string {
1447
+ // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
1448
+ const normalizedPath = clientModulePath.replace(/\\/g, "/");
1449
+ const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
1450
+ const candidates = [
1451
+ normalizedExportName,
1452
+ inferClientExportNameFromPath(clientModulePath),
1453
+ inferClientExportNameFromRouteId(routeId),
1454
+ ].filter((candidate, index, values): candidate is string =>
1455
+ !!candidate && values.indexOf(candidate) === index
1456
+ );
1457
+ const importSpecifier = JSON.stringify(normalizedPath);
1458
+ const routeLabel = JSON.stringify(routeId);
1459
+ const commentRouteId = routeId.replace(/\*\//g, "* /");
1460
+ return `
1461
+ /**
1462
+ * Mandu Island: ${commentRouteId} (Generated)
1463
+ * Pure export - no side effects
1464
+ */
1465
+ import * as islandModule from ${importSpecifier};
1466
+
1467
+ const candidateExportNames = ${JSON.stringify(candidates)};
1468
+
1469
+ function resolveIslandExport(mod) {
1470
+ if (mod.default) return mod.default;
1471
+ for (const name of candidateExportNames) {
1472
+ if (mod[name]) return mod[name];
1473
+ }
1474
+ const runtimeExports = Object.keys(mod).filter((name) => name !== "__esModule");
1475
+ if (runtimeExports.length === 1) return mod[runtimeExports[0]];
1476
+ throw new Error(
1477
+ "[Mandu Island] " + ${routeLabel} + " must export a default component" +
1478
+ (candidateExportNames.length > 0 ? " or one of: " + candidateExportNames.join(", ") : "")
1479
+ );
1480
+ }
1481
+
1482
+ const island = resolveIslandExport(islandModule);
1483
+ export default island;
1484
+ `;
1485
+ }
1486
+
1487
+ function inferClientExportNameFromPath(clientModulePath: string): string | null {
1488
+ const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
1489
+ const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
1490
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix) ? withoutClientSuffix : null;
1491
+ }
1492
+
1493
+ function inferClientExportNameFromRouteId(routeId: string): string | null {
1494
+ const pascal = routeId
1495
+ .split(/[^A-Za-z0-9]+/)
1496
+ .filter(Boolean)
1497
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1498
+ .join("");
1499
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : null;
1500
+ }
1454
1501
 
1455
1502
  function generatePartialEntry(partialId: string, partialModulePath: string): string {
1456
1503
  const normalizedPath = partialModulePath.replace(/\\/g, "/");
@@ -1907,7 +1954,7 @@ async function buildIsland(
1907
1954
  const isDev = isDevelopmentBuild(options);
1908
1955
  try {
1909
1956
  // 엔트리 래퍼 생성
1910
- await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
1957
+ await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath, route.clientExportName));
1911
1958
 
1912
1959
  // 빌드
1913
1960
  // splitting 옵션: true면 공통 코드를 별도 청크로 추출
@@ -1960,7 +2007,7 @@ async function buildIsland(
1960
2007
  }
1961
2008
 
1962
2009
  const outputFile = Bun.file(actualOutputPath);
1963
- const content = await outputFile.text();
2010
+ const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
1964
2011
  const gzipped = Bun.gzipSync(Buffer.from(content));
1965
2012
 
1966
2013
  return {
@@ -1976,6 +2023,48 @@ async function buildIsland(
1976
2023
  }
1977
2024
  }
1978
2025
 
2026
+ async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
2027
+ const source = await Bun.file(outputPath).text();
2028
+ if (!isDev) return source;
2029
+
2030
+ const sanitized = removeFastRefreshSelfAliases(source);
2031
+ if (sanitized === source) return source;
2032
+
2033
+ await Bun.write(outputPath, sanitized);
2034
+ await validateGeneratedClientBundle(outputPath);
2035
+ return sanitized;
2036
+ }
2037
+
2038
+ function removeFastRefreshSelfAliases(source: string): string {
2039
+ return source.replace(FAST_REFRESH_SELF_ALIAS_PATTERN, (line, localName: string, offset: number) => {
2040
+ const priorSource = source.slice(0, offset);
2041
+ const declarationPattern = new RegExp(`(?:const|let|class|function)\\s+${escapeRegExp(localName)}\\b`);
2042
+ return declarationPattern.test(priorSource) ? "" : line;
2043
+ });
2044
+ }
2045
+
2046
+ async function validateGeneratedClientBundle(outputPath: string): Promise<void> {
2047
+ const tempOutDir = await fs.mkdtemp(path.join(path.dirname(outputPath), ".validate-"));
2048
+ try {
2049
+ const result = await safeBuild({
2050
+ entrypoints: [outputPath],
2051
+ outdir: tempOutDir,
2052
+ target: "browser",
2053
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
2054
+ });
2055
+ if (result.success) return;
2056
+
2057
+ const grouped = result.logs.map((log) => ` - ${log.message}`).join("\n");
2058
+ throw new Error(`Generated client bundle failed syntax validation (source: ${outputPath}):\n${grouped}`);
2059
+ } finally {
2060
+ await fs.rm(tempOutDir, { recursive: true, force: true }).catch(() => {});
2061
+ }
2062
+ }
2063
+
2064
+ function escapeRegExp(value: string): string {
2065
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2066
+ }
2067
+
1979
2068
  /**
1980
2069
  * 번들 매니페스트 생성
1981
2070
  */
@@ -2513,25 +2602,24 @@ export async function buildClientBundles(
2513
2602
  try {
2514
2603
  return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2515
2604
  } catch (error) {
2516
- return { ok: false as const, route, error: String(error) };
2605
+ return { ok: false as const, route, error: formatBundlerException(error) };
2517
2606
  }
2518
2607
  }),
2519
2608
  );
2520
2609
  for (const r of fullIslandResults) {
2521
- if (r.ok) {
2522
- outputs.push(r.result);
2523
- } else {
2524
- const errorStr = r.error;
2525
- if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
2526
- const clientModule = r.route.clientModule || "";
2527
- errors.push(
2528
- `[${r.route.id}] ${errorStr}\n` +
2529
- ` 💡 Hint: If your island imports from "@mandujs/core", change it to "@mandujs/core/client".\n` +
2530
- ` Client islands cannot use server-side modules. File: ${clientModule}`,
2531
- );
2532
- } else {
2533
- errors.push(`[${r.route.id}] ${errorStr}`);
2534
- }
2610
+ if (r.ok) {
2611
+ outputs.push(r.result);
2612
+ } else {
2613
+ const errorStr = r.error;
2614
+ if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
2615
+ const clientModule = r.route.clientModule || "";
2616
+ errors.push(
2617
+ `[${r.route.id}] ${errorStr}\n` +
2618
+ ` Hint: Check import paths and browser-compatible exports for this island. File: ${clientModule}`,
2619
+ );
2620
+ } else {
2621
+ errors.push(`[${r.route.id}] ${errorStr}`);
2622
+ }
2535
2623
  }
2536
2624
  }
2537
2625
 
@@ -2632,6 +2720,12 @@ export function printBundleStats(result: BundleResult): void {
2632
2720
  const partialCount = Object.keys(result.manifest.partials ?? {}).length;
2633
2721
  if (result.outputs.length === 0 && partialCount === 0) {
2634
2722
  console.log("No islands or partials to bundle (hydration: none or no client entry)");
2723
+ if (result.errors.length > 0) {
2724
+ console.log("\n⚠️ Errors:");
2725
+ for (const error of result.errors) {
2726
+ console.log(` ${error}`);
2727
+ }
2728
+ }
2635
2729
  return;
2636
2730
  }
2637
2731
 
@@ -2661,3 +2755,17 @@ export function printBundleStats(result: BundleResult): void {
2661
2755
 
2662
2756
  console.log("");
2663
2757
  }
2758
+
2759
+ function formatBundlerException(error: unknown): string {
2760
+ if (error instanceof AggregateError) {
2761
+ const parts = [String(error)];
2762
+ for (const nested of error.errors) {
2763
+ parts.push(` - ${formatBundlerException(nested).replace(/\n/g, "\n ")}`);
2764
+ }
2765
+ return parts.join("\n");
2766
+ }
2767
+ if (error instanceof Error) {
2768
+ return error.stack ?? error.message;
2769
+ }
2770
+ return String(error);
2771
+ }
@@ -18,38 +18,38 @@ import fs from "fs/promises";
18
18
  import { watch as fsWatch, type FSWatcher } from "fs";
19
19
  import { withPerf } from "../perf";
20
20
 
21
- /**
22
- * Tailwind CLI 실행 명령어를 결정한다.
23
- * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
24
- * `bun run mandu` 환경에서는 process.execPath (bun 절대 경로)를 사용한다.
25
- * Standalone Mandu binary에서는 process.execPath가 mandu.exe를 가리키므로
26
- * `mandu x @tailwindcss/cli`로 오해석된다. 이 경우 PATH에서 Bun을 찾는다.
27
- */
28
- function isBunExecutable(executablePath: string | undefined): boolean {
29
- if (!executablePath) return false;
30
- const base = path.basename(executablePath).toLowerCase();
31
- return base === "bun" || base === "bun.exe";
32
- }
33
-
34
- type WhichExecutable = (command: string) => string | null | undefined;
35
-
36
- function resolveBunExecutable(
37
- execPath = process.execPath,
38
- which: WhichExecutable = (command) => Bun.which(command),
39
- ): string {
40
- if (isBunExecutable(execPath)) return execPath;
41
- const fromPath = which("bun");
42
- if (fromPath) return fromPath;
43
- return process.platform === "win32" ? "bun.exe" : "bun";
44
- }
45
-
46
- function getTailwindCommand(
47
- args: string[],
48
- execPath = process.execPath,
49
- which?: WhichExecutable,
50
- ): string[] {
51
- return [resolveBunExecutable(execPath, which), "x", ...args];
52
- }
21
+ /**
22
+ * Tailwind CLI 실행 명령어를 결정한다.
23
+ * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
24
+ * `bun run mandu` 환경에서는 process.execPath (bun 절대 경로)를 사용한다.
25
+ * Standalone Mandu binary에서는 process.execPath가 mandu.exe를 가리키므로
26
+ * `mandu x @tailwindcss/cli`로 오해석된다. 이 경우 PATH에서 Bun을 찾는다.
27
+ */
28
+ function isBunExecutable(executablePath: string | undefined): boolean {
29
+ if (!executablePath) return false;
30
+ const base = path.basename(executablePath).toLowerCase();
31
+ return base === "bun" || base === "bun.exe";
32
+ }
33
+
34
+ type WhichExecutable = (command: string) => string | null | undefined;
35
+
36
+ function resolveBunExecutable(
37
+ execPath = process.execPath,
38
+ which: WhichExecutable = (command) => Bun.which(command),
39
+ ): string {
40
+ if (isBunExecutable(execPath)) return execPath;
41
+ const fromPath = which("bun");
42
+ if (fromPath) return fromPath;
43
+ return process.platform === "win32" ? "bun.exe" : "bun";
44
+ }
45
+
46
+ function getTailwindCommand(
47
+ args: string[],
48
+ execPath = process.execPath,
49
+ which?: WhichExecutable,
50
+ ): string[] {
51
+ return [resolveBunExecutable(execPath, which), "x", ...args];
52
+ }
53
53
 
54
54
  // ========== Types ==========
55
55
 
@@ -344,13 +344,13 @@ export function getCSSServerPath(): string {
344
344
  /**
345
345
  * CSS 링크 태그 생성
346
346
  */
347
- export function generateCSSLinkTag(isDev: boolean = false): string {
348
- const cacheBust = isDev ? `?t=${Date.now()}` : "";
349
- return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
350
- }
351
-
352
- export const __private = {
353
- getTailwindCommand,
354
- isBunExecutable,
355
- resolveBunExecutable,
356
- };
347
+ export function generateCSSLinkTag(isDev: boolean = false): string {
348
+ const cacheBust = isDev ? `?t=${Date.now()}` : "";
349
+ return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
350
+ }
351
+
352
+ export const __private = {
353
+ getTailwindCommand,
354
+ isBunExecutable,
355
+ resolveBunExecutable,
356
+ };