@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 CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "@mandujs/core",
3
- "version": "0.9.24",
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
+ }
@@ -58,20 +58,94 @@ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
58
58
  function generateRuntimeSource(): string {
59
59
  return `
60
60
  /**
61
- * Mandu Hydration Runtime v0.8.0 (Generated)
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
- const hydratedRoots = new Map();
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 컴포넌트 (정적 import된 React 사용)
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
- return definition.render(setupResult);
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, // Island 단위로 이미 분리됨
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
- const outputPath = path.join(outDir, outputName);
868
- const outputFile = Bun.file(outputPath);
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/${outputName}`,
1007
+ outputPath: `/.mandu/client/${actualOutputName}`,
876
1008
  size: outputFile.size,
877
1009
  gzipSize: gzipped.length,
878
1010
  };
@@ -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 && changedFile.endsWith(".client.ts")) {
94
- const basename = path.basename(changedFile, ".client.ts");
95
- const route = manifest.routes.find((r) => r.id === basename);
96
- if (route) {
97
- routeId = route.id;
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
 
@@ -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
  }
@@ -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
  };