@mandujs/core 0.20.10 → 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 +2 -1
- package/package.json +69 -69
- package/src/bundler/build.ts +185 -19
- package/src/bundler/dev.ts +60 -21
- package/src/bundler/types.ts +8 -0
- package/src/config/validate.ts +1 -0
- package/src/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- package/src/runtime/server.ts +161 -3
- package/src/testing/index.ts +189 -189
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.
|
|
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
|
+
}
|
package/src/bundler/build.ts
CHANGED
|
@@ -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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
}
|
package/src/bundler/dev.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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)
|
|
138
|
-
serverModuleSet.add(absPath);
|
|
139
|
-
watchDirs.add(path.dirname(
|
|
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)
|
|
144
|
-
serverModuleSet.add(absPath);
|
|
145
|
-
watchDirs.add(path.dirname(
|
|
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)
|
|
152
|
-
apiModuleSet.add(absPath);
|
|
153
|
-
watchDirs.add(path.dirname(
|
|
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 =
|
|
220
|
+
const normalizedFile = normalizeFsPath(filePath);
|
|
198
221
|
for (const commonDir of commonWatchDirs) {
|
|
199
|
-
const normalizedCommon =
|
|
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
|
|
258
|
+
const normalizedPath = normalizeFsPath(changedFile);
|
|
236
259
|
|
|
237
|
-
// 공통 컴포넌트 디렉토리 변경 →
|
|
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
|
|
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))
|
|
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))
|
|
324
|
+
const routeDir = normalizeFsPath(path.dirname(path.resolve(rootDir, r.clientModule)));
|
|
286
325
|
return routeDir === changedDir;
|
|
287
326
|
});
|
|
288
327
|
if (matchedRoute) {
|
package/src/bundler/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/config/validate.ts
CHANGED
|
@@ -85,22 +85,13 @@ export type MCPConnectionStatus =
|
|
|
85
85
|
// ============================================================================
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* #
|
|
89
|
-
*
|
|
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: '',
|
|
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
|
-
|
|
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',
|