@mandujs/core 0.9.24 → 0.9.26
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/package.json +54 -54
- package/src/bundler/build.ts +143 -11
- package/src/bundler/dev.ts +15 -6
- package/src/bundler/types.ts +8 -0
- package/src/client/index.ts +31 -1
- package/src/client/island.ts +262 -4
- package/src/contract/index.ts +1 -0
- package/src/contract/normalize.test.ts +276 -0
- package/src/contract/normalize.ts +404 -0
- package/src/contract/schema.ts +44 -9
- package/src/contract/validator.ts +232 -1
- package/src/openapi/generator.ts +75 -11
- package/src/runtime/index.ts +4 -3
- package/src/runtime/logger.test.ts +345 -0
- package/src/runtime/logger.ts +677 -0
- package/src/runtime/ssr.ts +21 -2
- package/src/runtime/streaming-ssr.ts +79 -46
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.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
|
-
"./*": "./src/*"
|
|
12
|
-
},
|
|
13
|
-
"files": [
|
|
14
|
-
"src/**/*"
|
|
15
|
-
],
|
|
16
|
-
"scripts": {
|
|
17
|
-
"test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
|
|
18
|
-
"test:hydration": "bun test tests/hydration",
|
|
19
|
-
"test:streaming": "bun test tests/streaming-ssr",
|
|
20
|
-
"test:watch": "bun test --watch"
|
|
21
|
-
},
|
|
22
|
-
"devDependencies": {
|
|
23
|
-
"@happy-dom/global-registrator": "^15.0.0"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"mandu",
|
|
27
|
-
"framework",
|
|
28
|
-
"agent",
|
|
29
|
-
"ai",
|
|
30
|
-
"code-generation"
|
|
31
|
-
],
|
|
32
|
-
"repository": {
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/konamgil/mandu.git",
|
|
35
|
-
"directory": "packages/core"
|
|
36
|
-
},
|
|
37
|
-
"author": "konamgil",
|
|
38
|
-
"license": "MIT",
|
|
39
|
-
"publishConfig": {
|
|
40
|
-
"access": "public"
|
|
41
|
-
},
|
|
42
|
-
"engines": {
|
|
43
|
-
"bun": ">=1.0.0"
|
|
44
|
-
},
|
|
45
|
-
"peerDependencies": {
|
|
46
|
-
"react": ">=18.0.0",
|
|
47
|
-
"react-dom": ">=18.0.0",
|
|
48
|
-
"zod": ">=3.0.0"
|
|
49
|
-
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"chokidar": "^5.0.0",
|
|
52
|
-
"ollama": "^0.6.3"
|
|
53
|
-
}
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mandujs/core",
|
|
3
|
+
"version": "0.9.26",
|
|
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
|
+
"./*": "./src/*"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/**/*"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
|
|
18
|
+
"test:hydration": "bun test tests/hydration",
|
|
19
|
+
"test:streaming": "bun test tests/streaming-ssr",
|
|
20
|
+
"test:watch": "bun test --watch"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@happy-dom/global-registrator": "^15.0.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mandu",
|
|
27
|
+
"framework",
|
|
28
|
+
"agent",
|
|
29
|
+
"ai",
|
|
30
|
+
"code-generation"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/konamgil/mandu.git",
|
|
35
|
+
"directory": "packages/core"
|
|
36
|
+
},
|
|
37
|
+
"author": "konamgil",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": ">=18.0.0",
|
|
47
|
+
"react-dom": ">=18.0.0",
|
|
48
|
+
"zod": ">=3.0.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"chokidar": "^5.0.0",
|
|
52
|
+
"ollama": "^0.6.3"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/bundler/build.ts
CHANGED
|
@@ -58,20 +58,94 @@ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
|
|
|
58
58
|
function generateRuntimeSource(): string {
|
|
59
59
|
return `
|
|
60
60
|
/**
|
|
61
|
-
* Mandu Hydration Runtime v0.
|
|
61
|
+
* Mandu Hydration Runtime v0.9.0 (Generated)
|
|
62
62
|
* Fresh-style dynamic import architecture
|
|
63
|
+
* + Error Boundary & Loading fallback support
|
|
63
64
|
*/
|
|
64
65
|
|
|
65
66
|
// React 정적 import (Island와 같은 인스턴스 공유)
|
|
66
|
-
import React from 'react';
|
|
67
|
+
import React, { useState, useEffect, Component } from 'react';
|
|
67
68
|
import { hydrateRoot } from 'react-dom/client';
|
|
68
69
|
|
|
69
|
-
// Hydrated roots 추적 (unmount용)
|
|
70
|
-
|
|
70
|
+
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
71
|
+
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
72
|
+
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
71
73
|
|
|
72
74
|
// 서버 데이터
|
|
73
75
|
const getServerData = (id) => (window.__MANDU_DATA__ || {})[id]?.serverData || {};
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Error Boundary 컴포넌트 (Class Component)
|
|
79
|
+
* Island의 errorBoundary 옵션을 지원
|
|
80
|
+
*/
|
|
81
|
+
class IslandErrorBoundary extends Component {
|
|
82
|
+
constructor(props) {
|
|
83
|
+
super(props);
|
|
84
|
+
this.state = { hasError: false, error: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static getDerivedStateFromError(error) {
|
|
88
|
+
return { hasError: true, error };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
componentDidCatch(error, errorInfo) {
|
|
92
|
+
console.error('[Mandu] Island error:', this.props.islandId, error, errorInfo);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
reset = () => {
|
|
96
|
+
this.setState({ hasError: false, error: null });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
render() {
|
|
100
|
+
if (this.state.hasError) {
|
|
101
|
+
// 커스텀 errorBoundary가 있으면 사용
|
|
102
|
+
if (this.props.errorBoundary) {
|
|
103
|
+
return this.props.errorBoundary(this.state.error, this.reset);
|
|
104
|
+
}
|
|
105
|
+
// 기본 에러 UI
|
|
106
|
+
return React.createElement('div', {
|
|
107
|
+
className: 'mandu-island-error',
|
|
108
|
+
style: {
|
|
109
|
+
padding: '16px',
|
|
110
|
+
background: '#fef2f2',
|
|
111
|
+
border: '1px solid #fecaca',
|
|
112
|
+
borderRadius: '8px',
|
|
113
|
+
color: '#dc2626',
|
|
114
|
+
}
|
|
115
|
+
}, [
|
|
116
|
+
React.createElement('strong', { key: 'title' }, '⚠️ 오류 발생'),
|
|
117
|
+
React.createElement('p', { key: 'msg', style: { margin: '8px 0', fontSize: '14px' } },
|
|
118
|
+
this.state.error?.message || '알 수 없는 오류'
|
|
119
|
+
),
|
|
120
|
+
React.createElement('button', {
|
|
121
|
+
key: 'btn',
|
|
122
|
+
onClick: this.reset,
|
|
123
|
+
style: {
|
|
124
|
+
padding: '6px 12px',
|
|
125
|
+
background: '#dc2626',
|
|
126
|
+
color: 'white',
|
|
127
|
+
border: 'none',
|
|
128
|
+
borderRadius: '4px',
|
|
129
|
+
cursor: 'pointer',
|
|
130
|
+
}
|
|
131
|
+
}, '다시 시도')
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
return this.props.children;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Loading Wrapper 컴포넌트
|
|
140
|
+
* Island의 loading 옵션을 지원
|
|
141
|
+
*/
|
|
142
|
+
function IslandLoadingWrapper({ children, loading, isReady }) {
|
|
143
|
+
if (!isReady && loading) {
|
|
144
|
+
return loading();
|
|
145
|
+
}
|
|
146
|
+
return children;
|
|
147
|
+
}
|
|
148
|
+
|
|
75
149
|
/**
|
|
76
150
|
* Hydration 스케줄러
|
|
77
151
|
*/
|
|
@@ -121,6 +195,7 @@ function scheduleHydration(element, src, priority) {
|
|
|
121
195
|
/**
|
|
122
196
|
* Island 로드 및 hydrate (핵심 함수)
|
|
123
197
|
* Dynamic import로 Island 모듈 로드 후 렌더링
|
|
198
|
+
* Error Boundary 및 Loading fallback 지원
|
|
124
199
|
*/
|
|
125
200
|
async function loadAndHydrate(element, src) {
|
|
126
201
|
const id = element.getAttribute('data-mandu-island');
|
|
@@ -138,10 +213,31 @@ async function loadAndHydrate(element, src) {
|
|
|
138
213
|
const { definition } = island;
|
|
139
214
|
const data = getServerData(id);
|
|
140
215
|
|
|
141
|
-
// Island 컴포넌트 (
|
|
216
|
+
// Island 컴포넌트 (Error Boundary + Loading 지원)
|
|
142
217
|
function IslandComponent() {
|
|
218
|
+
const [isReady, setIsReady] = useState(false);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
setIsReady(true);
|
|
222
|
+
}, []);
|
|
223
|
+
|
|
224
|
+
// setup 호출 및 render
|
|
143
225
|
const setupResult = definition.setup(data);
|
|
144
|
-
|
|
226
|
+
const content = definition.render(setupResult);
|
|
227
|
+
|
|
228
|
+
// Loading wrapper 적용
|
|
229
|
+
const wrappedContent = definition.loading
|
|
230
|
+
? React.createElement(IslandLoadingWrapper, {
|
|
231
|
+
loading: definition.loading,
|
|
232
|
+
isReady,
|
|
233
|
+
}, content)
|
|
234
|
+
: content;
|
|
235
|
+
|
|
236
|
+
// Error Boundary 적용
|
|
237
|
+
return React.createElement(IslandErrorBoundary, {
|
|
238
|
+
islandId: id,
|
|
239
|
+
errorBoundary: definition.errorBoundary,
|
|
240
|
+
}, wrappedContent);
|
|
145
241
|
}
|
|
146
242
|
|
|
147
243
|
// Hydrate (SSR DOM 재사용 + 이벤트 연결)
|
|
@@ -166,6 +262,12 @@ async function loadAndHydrate(element, src) {
|
|
|
166
262
|
} catch (error) {
|
|
167
263
|
console.error('[Mandu] Hydration failed for', id, error);
|
|
168
264
|
element.setAttribute('data-mandu-error', 'true');
|
|
265
|
+
|
|
266
|
+
// 에러 이벤트 발송
|
|
267
|
+
element.dispatchEvent(new CustomEvent('mandu:hydration-error', {
|
|
268
|
+
bubbles: true,
|
|
269
|
+
detail: { id, error: error.message }
|
|
270
|
+
}));
|
|
169
271
|
}
|
|
170
272
|
}
|
|
171
273
|
|
|
@@ -174,6 +276,7 @@ async function loadAndHydrate(element, src) {
|
|
|
174
276
|
*/
|
|
175
277
|
function hydrateIslands() {
|
|
176
278
|
const islands = document.querySelectorAll('[data-mandu-island]');
|
|
279
|
+
const seenIds = new Set();
|
|
177
280
|
|
|
178
281
|
for (const el of islands) {
|
|
179
282
|
const id = el.getAttribute('data-mandu-island');
|
|
@@ -185,6 +288,13 @@ function hydrateIslands() {
|
|
|
185
288
|
continue;
|
|
186
289
|
}
|
|
187
290
|
|
|
291
|
+
// 중복 ID 경고
|
|
292
|
+
if (seenIds.has(id)) {
|
|
293
|
+
console.warn('[Mandu] Duplicate island id detected:', id, '- skipping');
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
seenIds.add(id);
|
|
297
|
+
|
|
188
298
|
scheduleHydration(el, src, priority);
|
|
189
299
|
}
|
|
190
300
|
}
|
|
@@ -841,14 +951,15 @@ async function buildIsland(
|
|
|
841
951
|
await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
|
|
842
952
|
|
|
843
953
|
// 빌드
|
|
954
|
+
// splitting 옵션: true면 공통 코드를 별도 청크로 추출
|
|
844
955
|
const result = await Bun.build({
|
|
845
956
|
entrypoints: [entryPath],
|
|
846
957
|
outdir: outDir,
|
|
847
|
-
naming: outputName,
|
|
958
|
+
naming: options.splitting ? "[name]-[hash].js" : outputName,
|
|
848
959
|
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
849
960
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
850
961
|
target: "browser",
|
|
851
|
-
splitting: false,
|
|
962
|
+
splitting: options.splitting ?? false,
|
|
852
963
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
853
964
|
define: {
|
|
854
965
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
@@ -864,15 +975,36 @@ async function buildIsland(
|
|
|
864
975
|
}
|
|
865
976
|
|
|
866
977
|
// 출력 파일 정보
|
|
867
|
-
|
|
868
|
-
|
|
978
|
+
// splitting 활성화 시 Bun.build 결과에서 실제 출력 파일 찾기
|
|
979
|
+
let actualOutputPath: string;
|
|
980
|
+
let actualOutputName: string;
|
|
981
|
+
|
|
982
|
+
if (options.splitting && result.outputs.length > 0) {
|
|
983
|
+
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
984
|
+
const entryOutput = result.outputs.find(
|
|
985
|
+
(o) => o.kind === "entry-point" || o.path.includes(route.id)
|
|
986
|
+
);
|
|
987
|
+
if (entryOutput) {
|
|
988
|
+
actualOutputPath = entryOutput.path;
|
|
989
|
+
actualOutputName = path.basename(entryOutput.path);
|
|
990
|
+
} else {
|
|
991
|
+
actualOutputPath = result.outputs[0].path;
|
|
992
|
+
actualOutputName = path.basename(result.outputs[0].path);
|
|
993
|
+
}
|
|
994
|
+
} else {
|
|
995
|
+
// 일반 모드: 예상 경로 사용
|
|
996
|
+
actualOutputPath = path.join(outDir, outputName);
|
|
997
|
+
actualOutputName = outputName;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const outputFile = Bun.file(actualOutputPath);
|
|
869
1001
|
const content = await outputFile.text();
|
|
870
1002
|
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
871
1003
|
|
|
872
1004
|
return {
|
|
873
1005
|
routeId: route.id,
|
|
874
1006
|
entrypoint: route.clientModule!,
|
|
875
|
-
outputPath: `/.mandu/client/${
|
|
1007
|
+
outputPath: `/.mandu/client/${actualOutputName}`,
|
|
876
1008
|
size: outputFile.size,
|
|
877
1009
|
gzipSize: gzipped.length,
|
|
878
1010
|
};
|
package/src/bundler/dev.ts
CHANGED
|
@@ -89,12 +89,21 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
89
89
|
// clientModule 매핑에서 routeId 찾기
|
|
90
90
|
let routeId = clientModuleToRoute.get(normalizedPath);
|
|
91
91
|
|
|
92
|
-
// .client.ts 파일인 경우 파일명에서 routeId 추출
|
|
93
|
-
if (!routeId
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
92
|
+
// .client.ts 또는 .client.tsx 파일인 경우 파일명에서 routeId 추출
|
|
93
|
+
if (!routeId) {
|
|
94
|
+
let basename: string | null = null;
|
|
95
|
+
|
|
96
|
+
if (changedFile.endsWith(".client.ts")) {
|
|
97
|
+
basename = path.basename(changedFile, ".client.ts");
|
|
98
|
+
} else if (changedFile.endsWith(".client.tsx")) {
|
|
99
|
+
basename = path.basename(changedFile, ".client.tsx");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (basename) {
|
|
103
|
+
const route = manifest.routes.find((r) => r.id === basename);
|
|
104
|
+
if (route) {
|
|
105
|
+
routeId = route.id;
|
|
106
|
+
}
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
|
package/src/bundler/types.ts
CHANGED
|
@@ -103,4 +103,12 @@ export interface BundlerOptions {
|
|
|
103
103
|
external?: string[];
|
|
104
104
|
/** 환경 변수 주입 */
|
|
105
105
|
define?: Record<string, string>;
|
|
106
|
+
/**
|
|
107
|
+
* 코드 스플리팅 활성화
|
|
108
|
+
* - true: Island들 간 공통 코드를 별도 청크로 추출 (캐시 효율 향상)
|
|
109
|
+
* - false: 각 Island가 독립 번들로 생성 (기본값)
|
|
110
|
+
*
|
|
111
|
+
* 주의: splitting=true인 경우 청크 파일 관리가 필요합니다.
|
|
112
|
+
*/
|
|
113
|
+
splitting?: boolean;
|
|
106
114
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -33,11 +33,23 @@ export {
|
|
|
33
33
|
useHydrated,
|
|
34
34
|
useIslandEvent,
|
|
35
35
|
fetchApi,
|
|
36
|
+
// Partials/Slots API
|
|
37
|
+
partial,
|
|
38
|
+
slot,
|
|
39
|
+
createPartialGroup,
|
|
36
40
|
type IslandDefinition,
|
|
37
41
|
type IslandMetadata,
|
|
38
42
|
type CompiledIsland,
|
|
39
43
|
type FetchOptions,
|
|
40
44
|
type WrapComponentOptions,
|
|
45
|
+
type IslandEventHandle,
|
|
46
|
+
// Partials/Slots Types
|
|
47
|
+
type PartialConfig,
|
|
48
|
+
type PartialDefinition,
|
|
49
|
+
type CompiledPartial,
|
|
50
|
+
type SlotDefinition,
|
|
51
|
+
type CompiledSlot,
|
|
52
|
+
type PartialGroup,
|
|
41
53
|
} from "./island";
|
|
42
54
|
|
|
43
55
|
// Runtime API
|
|
@@ -96,7 +108,7 @@ export {
|
|
|
96
108
|
} from "./serialize";
|
|
97
109
|
|
|
98
110
|
// Re-export as Mandu namespace for consistent API
|
|
99
|
-
import { island, wrapComponent } from "./island";
|
|
111
|
+
import { island, wrapComponent, partial, slot, createPartialGroup } from "./island";
|
|
100
112
|
import { navigate, prefetch, initializeRouter } from "./router";
|
|
101
113
|
import { Link, NavLink } from "./Link";
|
|
102
114
|
|
|
@@ -147,4 +159,22 @@ export const ManduClient = {
|
|
|
147
159
|
* @see NavLink
|
|
148
160
|
*/
|
|
149
161
|
NavLink,
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create a partial island for granular hydration
|
|
165
|
+
* @see partial
|
|
166
|
+
*/
|
|
167
|
+
partial,
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create a slot for server-rendered content with client hydration
|
|
171
|
+
* @see slot
|
|
172
|
+
*/
|
|
173
|
+
slot,
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a group of partials for coordinated hydration
|
|
177
|
+
* @see createPartialGroup
|
|
178
|
+
*/
|
|
179
|
+
createPartialGroup,
|
|
150
180
|
};
|