@mandujs/core 0.20.9 → 0.21.0

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.
package/README.md CHANGED
@@ -162,7 +162,7 @@ watcher.start();
162
162
  listPresets().forEach(p => console.log(p.name, p.description));
163
163
  ```
164
164
 
165
- ### Presets
165
+ ### Presets (6)
166
166
 
167
167
  | Preset | Layers | Use Case |
168
168
  |--------|--------|----------|
@@ -171,6 +171,7 @@ listPresets().forEach(p => console.log(p.name, p.description));
171
171
  | `clean` | api, application, domain, infra, shared | Backend |
172
172
  | `hexagonal` | adapters, ports, application, domain | DDD |
173
173
  | `atomic` | pages, templates, organisms, molecules, atoms | UI |
174
+ | `cqrs` | commands, queries, events, dto, application, domain | Event-sourced apps |
174
175
 
175
176
  ### AST-based Analysis
176
177
 
package/package.json CHANGED
@@ -1,69 +1,69 @@
1
- {
2
- "name": "@mandujs/core",
3
- "version": "0.20.9",
4
- "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts",
10
- "./client": "./src/client/index.ts",
11
- "./middleware": "./src/middleware/index.ts",
12
- "./testing": "./src/testing/index.ts",
13
- "./plugins": "./src/plugins/index.ts",
14
- "./error": "./src/error/index.ts",
15
- "./observability": "./src/observability/index.ts",
16
- "./bundler/prerender": "./src/bundler/prerender.ts",
17
- "./*": "./src/*"
18
- },
19
- "files": [
20
- "src/**/*"
21
- ],
22
- "scripts": {
23
- "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
24
- "test:hydration": "bun test tests/hydration",
25
- "test:streaming": "bun test tests/streaming-ssr",
26
- "test:watch": "bun test --watch"
27
- },
28
- "devDependencies": {
29
- "@happy-dom/global-registrator": "^15.0.0"
30
- },
31
- "keywords": [
32
- "mandu",
33
- "framework",
34
- "agent",
35
- "ai",
36
- "code-generation"
37
- ],
38
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/konamgil/mandu.git",
41
- "directory": "packages/core"
42
- },
43
- "author": "konamgil",
44
- "license": "MPL-2.0",
45
- "publishConfig": {
46
- "access": "public"
47
- },
48
- "engines": {
49
- "bun": ">=1.0.0"
50
- },
51
- "peerDependencies": {
52
- "react": "^19.0.0",
53
- "react-dom": "^19.0.0",
54
- "@tailwindcss/cli": ">=4.0.0"
55
- },
56
- "peerDependenciesMeta": {
57
- "@tailwindcss/cli": {
58
- "optional": true
59
- }
60
- },
61
- "dependencies": {
62
- "chokidar": "^5.0.0",
63
- "fast-glob": "^3.3.2",
64
- "glob": "^13.0.0",
65
- "minimatch": "^10.1.1",
66
- "ollama": "^0.6.3",
67
- "zod": "^3.23.8"
68
- }
69
- }
1
+ {
2
+ "name": "@mandujs/core",
3
+ "version": "0.21.0",
4
+ "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./client": "./src/client/index.ts",
11
+ "./middleware": "./src/middleware/index.ts",
12
+ "./testing": "./src/testing/index.ts",
13
+ "./plugins": "./src/plugins/index.ts",
14
+ "./error": "./src/error/index.ts",
15
+ "./observability": "./src/observability/index.ts",
16
+ "./bundler/prerender": "./src/bundler/prerender.ts",
17
+ "./*": "./src/*"
18
+ },
19
+ "files": [
20
+ "src/**/*"
21
+ ],
22
+ "scripts": {
23
+ "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
24
+ "test:hydration": "bun test tests/hydration",
25
+ "test:streaming": "bun test tests/streaming-ssr",
26
+ "test:watch": "bun test --watch"
27
+ },
28
+ "devDependencies": {
29
+ "@happy-dom/global-registrator": "^15.0.0"
30
+ },
31
+ "keywords": [
32
+ "mandu",
33
+ "framework",
34
+ "agent",
35
+ "ai",
36
+ "code-generation"
37
+ ],
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/konamgil/mandu.git",
41
+ "directory": "packages/core"
42
+ },
43
+ "author": "konamgil",
44
+ "license": "MPL-2.0",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "engines": {
49
+ "bun": ">=1.0.0"
50
+ },
51
+ "peerDependencies": {
52
+ "react": "^19.0.0",
53
+ "react-dom": "^19.0.0",
54
+ "@tailwindcss/cli": ">=4.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@tailwindcss/cli": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "dependencies": {
62
+ "chokidar": "^5.0.0",
63
+ "fast-glob": "^3.3.2",
64
+ "glob": "^13.0.0",
65
+ "minimatch": "^10.1.1",
66
+ "ollama": "^0.6.3",
67
+ "zod": "^3.23.8"
68
+ }
69
+ }
@@ -1473,6 +1473,44 @@ export async function buildClientBundles(
1473
1473
  // Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
1474
1474
  // (이전 빌드의 stale 매니페스트 참조 방지)
1475
1475
  if (hydratedRoutes.length === 0) {
1476
+ // #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
1477
+ if (options.skipFrameworkBundles) {
1478
+ const manifestPath = path.join(rootDir, ".mandu/manifest.json");
1479
+ try {
1480
+ const manifestRaw = await fs.readFile(manifestPath, "utf-8");
1481
+ let existing: BundleManifest;
1482
+ try {
1483
+ existing = JSON.parse(manifestRaw) as BundleManifest;
1484
+ } catch (parseError) {
1485
+ // #186 hardening: corrupt JSON이면 silent overwrite 대신 경고 + full build로 fallback
1486
+ console.warn(
1487
+ `[Mandu] Existing manifest is corrupt, falling back to full build: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
1488
+ );
1489
+ throw parseError;
1490
+ }
1491
+ // #186 hardening: 필수 필드 검증 (shared / bundles 누락 시 fallback)
1492
+ if (!existing || typeof existing !== "object" || !existing.shared || !existing.bundles) {
1493
+ console.warn("[Mandu] Existing manifest missing required fields, falling back to full build");
1494
+ throw new Error("invalid manifest shape");
1495
+ }
1496
+ return {
1497
+ success: true,
1498
+ outputs: [],
1499
+ errors: [],
1500
+ manifest: existing,
1501
+ stats: {
1502
+ totalSize: 0,
1503
+ totalGzipSize: 0,
1504
+ largestBundle: { routeId: "", size: 0 },
1505
+ buildTime: 0,
1506
+ bundleCount: 0,
1507
+ },
1508
+ };
1509
+ } catch {
1510
+ // 기존 manifest 없음/corrupt/invalid → full path로 fallback
1511
+ }
1512
+ }
1513
+
1476
1514
  // Dev 모드에서는 DevTools 번들 빌드 (island 없어도 동작해야 함)
1477
1515
  const isDev = env === "development";
1478
1516
  if (isDev) {
@@ -1506,13 +1544,18 @@ export async function buildClientBundles(
1506
1544
  if (options.targetRouteIds && options.targetRouteIds.length > 0) {
1507
1545
  const targetRoutes = hydratedRoutes.filter((r) => options.targetRouteIds!.includes(r.id));
1508
1546
 
1509
- for (const route of targetRoutes) {
1510
- try {
1511
- const result = await buildIsland(route, rootDir, outDir, options);
1512
- outputs.push(result);
1513
- } catch (error) {
1514
- errors.push(`[${route.id}] ${String(error)}`);
1515
- }
1547
+ const targetResults = await Promise.all(
1548
+ targetRoutes.map(async (route) => {
1549
+ try {
1550
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
1551
+ } catch (error) {
1552
+ return { ok: false as const, routeId: route.id, error: String(error) };
1553
+ }
1554
+ }),
1555
+ );
1556
+ for (const r of targetResults) {
1557
+ if (r.ok) outputs.push(r.result);
1558
+ else errors.push(`[${r.routeId}] ${r.error}`);
1516
1559
  }
1517
1560
 
1518
1561
  // 기존 매니페스트를 읽어 변경된 Island만 갱신
@@ -1552,6 +1595,122 @@ export async function buildClientBundles(
1552
1595
  return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
1553
1596
  }
1554
1597
 
1598
+ // #185: Framework-internal 번들 스킵 모드
1599
+ // 사용자 코드(src/shared 등) 변경 시 runtime/router/vendor/devtools 재빌드는 낭비.
1600
+ // 기존 매니페스트를 로드해 framework 출력 경로만 재사용하고 사용자 island만 재빌드.
1601
+ if (options.skipFrameworkBundles) {
1602
+ let existingManifest: BundleManifest;
1603
+ try {
1604
+ const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
1605
+ existingManifest = JSON.parse(manifestData) as BundleManifest;
1606
+ } catch (parseError) {
1607
+ // 기존 매니페스트 없음/corrupt → 경고 후 full build로 fallback
1608
+ if (parseError instanceof SyntaxError) {
1609
+ console.warn(
1610
+ `[Mandu] Existing manifest is corrupt, falling back to full build: ${parseError.message}`,
1611
+ );
1612
+ }
1613
+ return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
1614
+ }
1615
+
1616
+ // #186 hardening: 필수 필드 검증 — 누락 시 full build로 fallback
1617
+ if (
1618
+ !existingManifest ||
1619
+ typeof existingManifest !== "object" ||
1620
+ !existingManifest.shared ||
1621
+ !existingManifest.bundles
1622
+ ) {
1623
+ console.warn(
1624
+ "[Mandu] Existing manifest missing required fields (shared/bundles), falling back to full build",
1625
+ );
1626
+ return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
1627
+ }
1628
+
1629
+ // Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
1630
+ for (const route of hydratedRoutes) {
1631
+ if (!route.clientModule) continue;
1632
+ const clientModulePath = path.join(rootDir, route.clientModule);
1633
+ try {
1634
+ const source = await fs.readFile(clientModulePath, "utf-8");
1635
+ const wrongImportPattern = /(?:import|from)\s+['"]@mandujs\/core['"]|require\s*\(\s*['"]@mandujs\/core['"]\s*\)/;
1636
+ if (wrongImportPattern.test(source)) {
1637
+ errors.push(
1638
+ `[${route.id}] Island file "${route.clientModule}" imports from "@mandujs/core" which is a server-side module.\n` +
1639
+ ` Fix: Change the import to "@mandujs/core/client".`,
1640
+ );
1641
+ }
1642
+ } catch {
1643
+ // 파일 읽기 실패는 나중 빌드에서 catch됨
1644
+ }
1645
+ }
1646
+
1647
+ const islandResults = await Promise.all(
1648
+ hydratedRoutes.map(async (route) => {
1649
+ try {
1650
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
1651
+ } catch (error) {
1652
+ return { ok: false as const, routeId: route.id, error: String(error) };
1653
+ }
1654
+ }),
1655
+ );
1656
+ for (const r of islandResults) {
1657
+ if (r.ok) outputs.push(r.result);
1658
+ else errors.push(`[${r.routeId}] ${r.error}`);
1659
+ }
1660
+
1661
+ // Per-island bundle 재빌드 (이미 병렬)
1662
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
1663
+ const perIslandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
1664
+ if (islandFiles.length > 0) {
1665
+ const perIslandResults = await Promise.all(
1666
+ islandFiles.map(async (entry) => {
1667
+ try {
1668
+ return await buildPerIslandBundle(entry, outDir, options);
1669
+ } catch (error) {
1670
+ errors.push(`[island:${entry.name}] ${String(error)}`);
1671
+ return null;
1672
+ }
1673
+ }),
1674
+ );
1675
+ for (const result of perIslandResults) {
1676
+ if (result) perIslandBundles.push(result);
1677
+ }
1678
+ }
1679
+
1680
+ // 기존 manifest를 기반으로 bundles / islands 엔트리만 교체 (framework 경로는 유지)
1681
+ for (const output of outputs) {
1682
+ if (existingManifest.bundles[output.routeId]) {
1683
+ existingManifest.bundles[output.routeId].js = output.outputPath;
1684
+ } else {
1685
+ const route = hydratedRoutes.find((r) => r.id === output.routeId);
1686
+ const hydration = route ? getRouteHydration(route) : null;
1687
+ existingManifest.bundles[output.routeId] = {
1688
+ js: output.outputPath,
1689
+ dependencies: ["_runtime", "_react"],
1690
+ priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1691
+ };
1692
+ }
1693
+ }
1694
+ if (perIslandBundles.length > 0) {
1695
+ existingManifest.islands = existingManifest.islands || {};
1696
+ for (const ib of perIslandBundles) {
1697
+ existingManifest.islands[ib.name] = {
1698
+ js: ib.js,
1699
+ route: ib.route,
1700
+ priority: ib.priority,
1701
+ };
1702
+ }
1703
+ }
1704
+
1705
+ await fs.writeFile(
1706
+ path.join(rootDir, ".mandu/manifest.json"),
1707
+ JSON.stringify(existingManifest, null, 2),
1708
+ );
1709
+
1710
+ const stats = calculateStats(outputs, startTime);
1711
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
1712
+ }
1713
+
1555
1714
  // 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
1556
1715
  const isDev = env === "development";
1557
1716
  const runtimePromise = buildRuntime(outDir, options);
@@ -1602,23 +1761,30 @@ export async function buildClientBundles(
1602
1761
  }
1603
1762
  }
1604
1763
 
1605
- // 5. 각 Island 번들 빌드
1606
- for (const route of hydratedRoutes) {
1607
- try {
1608
- const result = await buildIsland(route, rootDir, outDir, options);
1609
- outputs.push(result);
1610
- } catch (error) {
1611
- const errorStr = String(error);
1612
- // Detect common mistake: importing @mandujs/core (server module) in client island
1764
+ // 5. 각 Island 번들 병렬 빌드 (#185: L1631의 per-island와 일관성 확보)
1765
+ const fullIslandResults = await Promise.all(
1766
+ hydratedRoutes.map(async (route) => {
1767
+ try {
1768
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
1769
+ } catch (error) {
1770
+ return { ok: false as const, route, error: String(error) };
1771
+ }
1772
+ }),
1773
+ );
1774
+ for (const r of fullIslandResults) {
1775
+ if (r.ok) {
1776
+ outputs.push(r.result);
1777
+ } else {
1778
+ const errorStr = r.error;
1613
1779
  if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
1614
- const clientModule = route.clientModule || "";
1780
+ const clientModule = r.route.clientModule || "";
1615
1781
  errors.push(
1616
- `[${route.id}] ${errorStr}\n` +
1782
+ `[${r.route.id}] ${errorStr}\n` +
1617
1783
  ` 💡 Hint: If your island imports from "@mandujs/core", change it to "@mandujs/core/client".\n` +
1618
- ` Client islands cannot use server-side modules. File: ${clientModule}`
1784
+ ` Client islands cannot use server-side modules. File: ${clientModule}`,
1619
1785
  );
1620
1786
  } else {
1621
- errors.push(`[${route.id}] ${errorStr}`);
1787
+ errors.push(`[${r.route.id}] ${errorStr}`);
1622
1788
  }
1623
1789
  }
1624
1790
  }
@@ -10,6 +10,12 @@ import { PORTS, TIMEOUTS } from "../constants";
10
10
  import path from "path";
11
11
  import fs from "fs";
12
12
 
13
+ /**
14
+ * #184: 공통 디렉토리 변경 시 사용하는 sentinel.
15
+ * `onSSRChange`에 특정 파일 경로 대신 이 상수를 전달하면 "전체 SSR 레지스트리 invalidate" 의미.
16
+ */
17
+ export const SSR_CHANGE_WILDCARD = "*";
18
+
13
19
  export interface DevBundlerOptions {
14
20
  /** 프로젝트 루트 */
15
21
  rootDir: string;
@@ -21,14 +27,16 @@ export interface DevBundlerOptions {
21
27
  onError?: (error: Error, routeId?: string) => void;
22
28
  /**
23
29
  * SSR 파일 변경 콜백 (page.tsx, layout.tsx 등)
24
- * 클라이언트 번들 리빌드 없이 서버 핸들러 재등록이 필요한 경우 호출
30
+ * 클라이언트 번들 리빌드 없이 서버 핸들러 재등록이 필요한 경우 호출.
31
+ * `SSR_CHANGE_WILDCARD` ("*")를 받으면 전체 레지스트리 invalidate 의미 (#184).
32
+ * Promise 반환 시 await 되므로 레지스트리 clear가 완료된 후 HMR reload broadcast 가능.
25
33
  */
26
- onSSRChange?: (filePath: string) => void;
34
+ onSSRChange?: (filePath: string) => void | Promise<void>;
27
35
  /**
28
36
  * API route 파일 변경 콜백 (route.ts 등)
29
37
  * API 핸들러 재등록이 필요한 경우 호출
30
38
  */
31
- onAPIChange?: (filePath: string) => void;
39
+ onAPIChange?: (filePath: string) => void | Promise<void>;
32
40
  /**
33
41
  * 추가 watch 디렉토리 (공통 컴포넌트 등)
34
42
  * 상대 경로 또는 절대 경로 모두 지원
@@ -56,6 +64,21 @@ export interface DevBundler {
56
64
  close: () => void;
57
65
  }
58
66
 
67
+ /**
68
+ * #180: 파일 경로 비교를 위한 정규화.
69
+ * - 절대 경로로 변환 (path.resolve)
70
+ * - 백슬래시 → 포워드슬래시
71
+ * - Windows에서는 case-insensitive 매칭 (소문자화)
72
+ *
73
+ * 동적 라우트 폴더(`[lang]` 등) 변경 감지가 누락되던 문제는 watcher가 보고하는
74
+ * `path.join(dir, filename)`과 `serverModuleSet` 등록 시의 `path.resolve(rootDir, ...)`
75
+ * 가 드라이브 문자 대소문자/슬래시 표기 차이로 어긋나서 발생했음.
76
+ */
77
+ function normalizeFsPath(p: string): string {
78
+ const resolved = path.resolve(p).replace(/\\/g, "/");
79
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
80
+ }
81
+
59
82
  // 기본 공통 컴포넌트 디렉토리 목록
60
83
  const DEFAULT_COMMON_DIRS = [
61
84
  "src/components",
@@ -114,7 +137,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
114
137
  for (const route of manifest.routes) {
115
138
  if (route.clientModule) {
116
139
  const absPath = path.resolve(rootDir, route.clientModule);
117
- const normalizedPath = absPath.replace(/\\/g, "/");
140
+ const normalizedPath = normalizeFsPath(absPath);
118
141
  clientModuleToRoute.set(normalizedPath, route.id);
119
142
 
120
143
  // Also register *.client.tsx/ts files in the same directory (#140)
@@ -122,7 +145,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
122
145
  const dir = path.dirname(absPath);
123
146
  const baseStem = path.basename(absPath).replace(/\.(island|client)\.(tsx?|jsx?)$/, "");
124
147
  for (const ext of [".client.tsx", ".client.ts", ".client.jsx", ".client.js"]) {
125
- const clientPath = path.join(dir, baseStem + ext).replace(/\\/g, "/");
148
+ const clientPath = normalizeFsPath(path.join(dir, baseStem + ext));
126
149
  if (clientPath !== normalizedPath) {
127
150
  clientModuleToRoute.set(clientPath, route.id);
128
151
  }
@@ -134,23 +157,23 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
134
157
 
135
158
  // SSR 모듈 등록 (page.tsx, layout.tsx) — #151
136
159
  if (route.componentModule) {
137
- const absPath = path.resolve(rootDir, route.componentModule).replace(/\\/g, "/");
138
- serverModuleSet.add(absPath);
139
- watchDirs.add(path.dirname(path.resolve(rootDir, route.componentModule)));
160
+ const absPath = path.resolve(rootDir, route.componentModule);
161
+ serverModuleSet.add(normalizeFsPath(absPath));
162
+ watchDirs.add(path.dirname(absPath));
140
163
  }
141
164
  if (route.layoutChain) {
142
165
  for (const layoutPath of route.layoutChain) {
143
- const absPath = path.resolve(rootDir, layoutPath).replace(/\\/g, "/");
144
- serverModuleSet.add(absPath);
145
- watchDirs.add(path.dirname(path.resolve(rootDir, layoutPath)));
166
+ const absPath = path.resolve(rootDir, layoutPath);
167
+ serverModuleSet.add(normalizeFsPath(absPath));
168
+ watchDirs.add(path.dirname(absPath));
146
169
  }
147
170
  }
148
171
 
149
172
  // Track API route modules for hot-reload
150
173
  if (route.kind === "api" && route.module) {
151
- const absPath = path.resolve(rootDir, route.module).replace(/\\/g, "/");
152
- apiModuleSet.add(absPath);
153
- watchDirs.add(path.dirname(path.resolve(rootDir, route.module)));
174
+ const absPath = path.resolve(rootDir, route.module);
175
+ apiModuleSet.add(normalizeFsPath(absPath));
176
+ watchDirs.add(path.dirname(absPath));
154
177
  }
155
178
  }
156
179
 
@@ -194,9 +217,9 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
194
217
 
195
218
  // 파일이 공통 디렉토리에 있는지 확인
196
219
  const isInCommonDir = (filePath: string): boolean => {
197
- const normalizedFile = path.resolve(filePath).replace(/\\/g, "/");
220
+ const normalizedFile = normalizeFsPath(filePath);
198
221
  for (const commonDir of commonWatchDirs) {
199
- const normalizedCommon = path.resolve(commonDir).replace(/\\/g, "/");
222
+ const normalizedCommon = normalizeFsPath(commonDir);
200
223
  if (normalizedFile.startsWith(normalizedCommon + "/")) {
201
224
  return true;
202
225
  }
@@ -232,23 +255,38 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
232
255
  };
233
256
 
234
257
  const _doBuild = async (changedFile: string) => {
235
- const normalizedPath = changedFile.replace(/\\/g, "/");
258
+ const normalizedPath = normalizeFsPath(changedFile);
236
259
 
237
- // 공통 컴포넌트 디렉토리 변경 → 전체 재빌드 (targetRouteIds 없이)
260
+ // 공통 컴포넌트 디렉토리 변경 → Island만 재빌드 + SSR 레지스트리 invalidate (#184, #185)
238
261
  if (isInCommonDir(changedFile)) {
239
262
  console.log(`\n🔄 Common file changed: ${path.basename(changedFile)}`);
240
- console.log(` Rebuilding all islands...`);
263
+ console.log(` Rebuilding islands (framework bundles skipped)...`);
241
264
  const startTime = performance.now();
242
265
 
243
266
  try {
267
+ // #185: framework 번들 (runtime/router/vendor/devtools) 스킵 — 사용자 코드 변경 시 불필요
244
268
  const result = await buildClientBundles(manifest, rootDir, {
245
269
  minify: false,
246
270
  sourcemap: true,
271
+ skipFrameworkBundles: true,
247
272
  });
248
273
 
249
274
  const buildTime = performance.now() - startTime;
250
275
 
251
276
  if (result.success) {
277
+ // #184: common dir 변경은 SSR 모듈 캐시 invalidation이 필요 — wildcard 시그널
278
+ // 빌드 성공한 경우에만 SSR 레지스트리를 clear (실패 시 마지막 good state 유지)
279
+ // 주의: Bun의 transitive ESM 캐시는 프로세스 레벨이라 이 시그널만으로는
280
+ // `src/shared/**`을 transitive하게 import하는 SSR 모듈까지 완전히 갱신되지 않음.
281
+ // 진짜 해결은 subprocess/worker 기반 SSR eval이 필요 (follow-up 이슈).
282
+ if (onSSRChange) {
283
+ try {
284
+ await Promise.resolve(onSSRChange(SSR_CHANGE_WILDCARD));
285
+ } catch (ssrError) {
286
+ console.warn(`⚠️ SSR invalidation failed:`, ssrError instanceof Error ? ssrError.message : ssrError);
287
+ }
288
+ }
289
+
252
290
  console.log(`✅ Rebuilt ${result.stats.bundleCount} islands in ${buildTime.toFixed(0)}ms`);
253
291
  onRebuild?.({
254
292
  routeId: "*", // 전체 재빌드 표시
@@ -257,6 +295,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
257
295
  });
258
296
  } else {
259
297
  console.error(`❌ Build failed:`, result.errors);
298
+ console.log(` ⏳ SSR registry not invalidated (keeping last good state)`);
260
299
  onRebuild?.({
261
300
  routeId: "*",
262
301
  success: false,
@@ -279,10 +318,10 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
279
318
  // Fallback for *.client.tsx/ts: find route whose clientModule is in the same directory (#140)
280
319
  // basename matching (e.g. "page" !== "index") is unreliable — use directory-based matching instead
281
320
  if (!routeId && (changedFile.endsWith(".client.ts") || changedFile.endsWith(".client.tsx"))) {
282
- const changedDir = path.dirname(path.resolve(rootDir, changedFile)).replace(/\\/g, "/");
321
+ const changedDir = normalizeFsPath(path.dirname(path.resolve(rootDir, changedFile)));
283
322
  const matchedRoute = manifest.routes.find((r) => {
284
323
  if (!r.clientModule) return false;
285
- const routeDir = path.dirname(path.resolve(rootDir, r.clientModule)).replace(/\\/g, "/");
324
+ const routeDir = normalizeFsPath(path.dirname(path.resolve(rootDir, r.clientModule)));
286
325
  return routeDir === changedDir;
287
326
  });
288
327
  if (matchedRoute) {
@@ -137,4 +137,12 @@ export interface BundlerOptions {
137
137
  * - 미지정 시 전체 빌드 (기본값)
138
138
  */
139
139
  targetRouteIds?: string[];
140
+ /**
141
+ * Framework-internal 번들 (runtime/router/vendor/devtools) 스킵 (#185)
142
+ * - true: 사용자 island만 재빌드, runtime/router/vendor/devtools 출력은 기존 매니페스트에서 재사용
143
+ * - 주 용도: 공통 디렉토리(src/shared 등) 파일 변경 시 dev watcher가 수행하는 rebuild.
144
+ * 사용자 코드 변경은 framework 번들 출력에 영향을 주지 않으므로 재빌드 불필요.
145
+ * - 안전성: 기존 `.mandu/manifest.json` 이 없으면 자동으로 full build로 fallback.
146
+ */
147
+ skipFrameworkBundles?: boolean;
140
148
  }
@@ -100,6 +100,7 @@ const DevConfigSchema = z
100
100
  .object({
101
101
  hmr: z.boolean().default(true),
102
102
  watchDirs: z.array(z.string()).default([]),
103
+ observability: z.boolean().default(true),
103
104
  })
104
105
  .strict();
105
106
 
@@ -85,22 +85,13 @@ export type MCPConnectionStatus =
85
85
  // ============================================================================
86
86
 
87
87
  /**
88
- * #176: 런타임에 현재 페이지 호스트/포트에서 MCP WebSocket URL을 유추한다.
89
- * Bun 번들러가 `typeof window`를 컴파일 dead-code 제거하므로
90
- * 함수로 감싸서 런타임 평가를 보장한다.
88
+ * #181: 이전에는 `ws://{host}:{port}/mcp` 형태로 자동 유추했으나, dev 서버는
89
+ * 해당 엔드포인트를 제공하지 않아 브라우저가 reconnect 루프에 빠져 멈추는 문제가 있었음.
90
+ * 이제는 명시적인 `serverUrl`을 받지 못하면 connect() 자체가 실패하도록 한다.
91
+ * 호출자가 의도적으로 MCP 엔드포인트 URL을 넘겨야만 WebSocket을 시도한다.
91
92
  */
92
- function resolveDefaultServerUrl(): string {
93
- try {
94
- // eslint-disable-next-line no-restricted-globals
95
- if (globalThis.document && globalThis.location) {
96
- return `ws://${globalThis.location.hostname}:${globalThis.location.port}/mcp`;
97
- }
98
- } catch { /* SSR / Node / Bun 환경 */ }
99
- return 'ws://localhost:3333/mcp';
100
- }
101
-
102
93
  const DEFAULT_OPTIONS: Required<MCPConnectorOptions> = {
103
- serverUrl: '', // connect() 시 resolveDefaultServerUrl()로 대체
94
+ serverUrl: '',
104
95
  connectionTimeout: 5000,
105
96
  requestTimeout: 30000,
106
97
  // #175: MCP 서버가 없을 수 있으므로 기본 비활성화
@@ -150,6 +141,19 @@ export class MCPConnector {
150
141
  return;
151
142
  }
152
143
 
144
+ // #181: 명시적 serverUrl이 없으면 즉시 실패. 자동 URL 유추는
145
+ // 존재하지 않는 /mcp 엔드포인트로 향해 브라우저를 멈추게 했음.
146
+ const url = this.options.serverUrl;
147
+ if (!url) {
148
+ const err = new Error(
149
+ '[Mandu Kitchen] MCPConnector.connect() requires an explicit serverUrl. ' +
150
+ 'No MCP WebSocket endpoint is started by the dev server.',
151
+ );
152
+ this.handleConnectionError(err);
153
+ reject(err);
154
+ return;
155
+ }
156
+
153
157
  this.setStatus('connecting');
154
158
 
155
159
  const timeout = setTimeout(() => {
@@ -158,8 +162,6 @@ export class MCPConnector {
158
162
  }, this.options.connectionTimeout);
159
163
 
160
164
  try {
161
- // #176: 빈 serverUrl이면 런타임에서 현재 페이지 기준으로 유추
162
- const url = this.options.serverUrl || resolveDefaultServerUrl();
163
165
  this.ws = new WebSocket(url);
164
166
 
165
167
  this.ws.onopen = () => {
@@ -35,7 +35,10 @@ const styles = {
35
35
  userSelect: 'none' as const,
36
36
  fontFamily: typography.fontFamily.mono,
37
37
  textTransform: 'uppercase' as const,
38
- border: `1px solid ${colors.background.light}`,
38
+ // #183: borderColor longhand override 충돌을 피하기 위해 shorthand를 분리
39
+ borderWidth: '1px',
40
+ borderStyle: 'solid',
41
+ borderColor: colors.background.light,
39
42
  },
40
43
  content: {
41
44
  display: 'flex',