@mandujs/core 0.9.25 → 0.9.27

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.25",
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.27",
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
  };
@@ -140,15 +140,46 @@ export function useHydrated(): boolean {
140
140
  return true;
141
141
  }
142
142
 
143
+ /**
144
+ * Island 간 통신을 위한 이벤트 훅 반환 타입
145
+ */
146
+ export interface IslandEventHandle<T> {
147
+ /** 이벤트 발송 함수 */
148
+ emit: (data: T) => void;
149
+ /** 이벤트 리스너 해제 함수 (cleanup) */
150
+ cleanup: () => void;
151
+ }
152
+
143
153
  /**
144
154
  * Island 간 통신을 위한 이벤트 훅
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * // Island A
159
+ * const { emit, cleanup } = useIslandEvent<{ count: number }>(
160
+ * 'counter-update',
161
+ * (data) => console.log('Received:', data.count)
162
+ * );
163
+ *
164
+ * // 이벤트 발송
165
+ * emit({ count: 42 });
166
+ *
167
+ * // 컴포넌트 언마운트 시 cleanup
168
+ * useEffect(() => cleanup, []);
169
+ * ```
170
+ *
171
+ * @deprecated 새로운 API는 { emit, cleanup } 객체를 반환합니다.
172
+ * 하위 호환성을 위해 emit 함수에 cleanup 속성도 추가됩니다.
145
173
  */
146
174
  export function useIslandEvent<T = unknown>(
147
175
  eventName: string,
148
176
  handler: (data: T) => void
149
- ): (data: T) => void {
177
+ ): IslandEventHandle<T>["emit"] & IslandEventHandle<T> {
150
178
  if (typeof window === "undefined") {
151
- return () => {};
179
+ const noop = (() => {}) as IslandEventHandle<T>["emit"] & IslandEventHandle<T>;
180
+ noop.emit = noop;
181
+ noop.cleanup = () => {};
182
+ return noop;
152
183
  }
153
184
 
154
185
  // 이벤트 리스너 등록
@@ -160,10 +191,22 @@ export function useIslandEvent<T = unknown>(
160
191
 
161
192
  window.addEventListener(customEventName, listener as EventListener);
162
193
 
163
- // 이벤트 발송 함수 반환
164
- return (data: T) => {
194
+ // cleanup 함수
195
+ const cleanup = () => {
196
+ window.removeEventListener(customEventName, listener as EventListener);
197
+ };
198
+
199
+ // 이벤트 발송 함수
200
+ const emit = (data: T) => {
165
201
  window.dispatchEvent(new CustomEvent(customEventName, { detail: data }));
166
202
  };
203
+
204
+ // 하위 호환성: emit 함수에 cleanup 속성 추가
205
+ const result = emit as IslandEventHandle<T>["emit"] & IslandEventHandle<T>;
206
+ result.emit = emit;
207
+ result.cleanup = cleanup;
208
+
209
+ return result;
167
210
  }
168
211
 
169
212
  /**
@@ -254,3 +297,218 @@ export async function fetchApi<T>(
254
297
 
255
298
  return response.json();
256
299
  }
300
+
301
+ // ========== Client Partials/Slots API ==========
302
+
303
+ /**
304
+ * Partial Island 설정
305
+ * 페이지 내 특정 부분만 하이드레이션할 때 사용
306
+ */
307
+ export interface PartialConfig {
308
+ /** Partial 고유 ID */
309
+ id: string;
310
+ /** 하이드레이션 우선순위 */
311
+ priority?: "immediate" | "visible" | "idle" | "interaction";
312
+ /** 부모 Island ID (중첩 시) */
313
+ parentId?: string;
314
+ }
315
+
316
+ /**
317
+ * Partial Island 정의 타입
318
+ */
319
+ export interface PartialDefinition<TProps> {
320
+ /** Partial 컴포넌트 */
321
+ component: React.ComponentType<TProps>;
322
+ /** 초기 props (SSR에서 전달) */
323
+ initialProps?: TProps;
324
+ /** 하이드레이션 우선순위 */
325
+ priority?: "immediate" | "visible" | "idle" | "interaction";
326
+ }
327
+
328
+ /**
329
+ * 컴파일된 Partial
330
+ */
331
+ export interface CompiledPartial<TProps> {
332
+ /** Partial 정의 */
333
+ definition: PartialDefinition<TProps>;
334
+ /** Mandu Partial 마커 */
335
+ __mandu_partial: true;
336
+ /** Partial ID */
337
+ __mandu_partial_id?: string;
338
+ }
339
+
340
+ /**
341
+ * Partial Island 생성
342
+ * 페이지 내 특정 부분만 하이드레이션
343
+ *
344
+ * @example
345
+ * ```typescript
346
+ * // 검색 바만 별도 Island로 분리
347
+ * const SearchBarPartial = partial({
348
+ * component: SearchBar,
349
+ * priority: 'interaction', // 사용자 상호작용 시 하이드레이션
350
+ * });
351
+ *
352
+ * // 사용
353
+ * function Header() {
354
+ * return (
355
+ * <header>
356
+ * <Logo />
357
+ * <SearchBarPartial.Render query="" />
358
+ * </header>
359
+ * );
360
+ * }
361
+ * ```
362
+ */
363
+ export function partial<TProps extends Record<string, any>>(
364
+ definition: PartialDefinition<TProps>
365
+ ): CompiledPartial<TProps> & {
366
+ Render: React.ComponentType<TProps>;
367
+ } {
368
+ if (!definition.component) {
369
+ throw new Error("[Mandu Partial] component is required");
370
+ }
371
+
372
+ const compiled: CompiledPartial<TProps> = {
373
+ definition,
374
+ __mandu_partial: true,
375
+ };
376
+
377
+ // Render 컴포넌트 생성
378
+ const React = require("react");
379
+
380
+ const RenderComponent: React.FC<TProps> = (props) => {
381
+ return React.createElement(definition.component, props);
382
+ };
383
+
384
+ return Object.assign(compiled, { Render: RenderComponent });
385
+ }
386
+
387
+ /**
388
+ * Slot 정의 - 서버에서 렌더링되고 클라이언트에서 하이드레이션되는 영역
389
+ */
390
+ export interface SlotDefinition<TData, TProps> {
391
+ /** 슬롯 ID */
392
+ id: string;
393
+ /** 데이터 로더 (서버에서 실행) */
394
+ loader?: () => Promise<TData>;
395
+ /** 데이터를 props로 변환 */
396
+ transform?: (data: TData) => TProps;
397
+ /** 렌더링 컴포넌트 */
398
+ component: React.ComponentType<TProps>;
399
+ /** 하이드레이션 우선순위 */
400
+ priority?: "immediate" | "visible" | "idle" | "interaction";
401
+ /** 로딩 UI */
402
+ loading?: () => ReactNode;
403
+ /** 에러 UI */
404
+ errorBoundary?: (error: Error, reset: () => void) => ReactNode;
405
+ }
406
+
407
+ /**
408
+ * 컴파일된 Slot
409
+ */
410
+ export interface CompiledSlot<TData, TProps> {
411
+ definition: SlotDefinition<TData, TProps>;
412
+ __mandu_slot: true;
413
+ __mandu_slot_id: string;
414
+ }
415
+
416
+ /**
417
+ * Client Slot 생성
418
+ * 서버 데이터를 받아 클라이언트에서 하이드레이션되는 컴포넌트
419
+ *
420
+ * @example
421
+ * ```typescript
422
+ * // 댓글 영역을 별도 슬롯으로 분리
423
+ * const CommentsSlot = slot({
424
+ * id: 'comments',
425
+ * loader: async () => fetchComments(postId),
426
+ * transform: (data) => ({ comments: data.items }),
427
+ * component: CommentList,
428
+ * priority: 'visible',
429
+ * loading: () => <CommentsSkeleton />,
430
+ * });
431
+ * ```
432
+ */
433
+ export function slot<TData, TProps extends Record<string, any>>(
434
+ definition: SlotDefinition<TData, TProps>
435
+ ): CompiledSlot<TData, TProps> {
436
+ if (!definition.id) {
437
+ throw new Error("[Mandu Slot] id is required");
438
+ }
439
+ if (!definition.component) {
440
+ throw new Error("[Mandu Slot] component is required");
441
+ }
442
+
443
+ return {
444
+ definition,
445
+ __mandu_slot: true,
446
+ __mandu_slot_id: definition.id,
447
+ };
448
+ }
449
+
450
+ /**
451
+ * 여러 Partial을 그룹으로 관리
452
+ */
453
+ export interface PartialGroup {
454
+ /** 그룹에 Partial 추가 */
455
+ add: <TProps>(id: string, partial: CompiledPartial<TProps>) => void;
456
+ /** Partial 조회 */
457
+ get: <TProps>(id: string) => CompiledPartial<TProps> | undefined;
458
+ /** 모든 Partial ID 목록 */
459
+ ids: () => string[];
460
+ /** 특정 Partial 하이드레이션 트리거 */
461
+ hydrate: (id: string) => Promise<void>;
462
+ /** 모든 Partial 하이드레이션 */
463
+ hydrateAll: () => Promise<void>;
464
+ }
465
+
466
+ /**
467
+ * Partial 그룹 생성
468
+ *
469
+ * @example
470
+ * ```typescript
471
+ * const dashboardPartials = createPartialGroup();
472
+ *
473
+ * dashboardPartials.add('chart', ChartPartial);
474
+ * dashboardPartials.add('table', TablePartial);
475
+ *
476
+ * // 특정 부분만 하이드레이션
477
+ * await dashboardPartials.hydrate('chart');
478
+ * ```
479
+ */
480
+ export function createPartialGroup(): PartialGroup {
481
+ const partials = new Map<string, CompiledPartial<any>>();
482
+
483
+ return {
484
+ add: (id, partial) => {
485
+ partial.__mandu_partial_id = id;
486
+ partials.set(id, partial);
487
+ },
488
+ get: (id) => partials.get(id),
489
+ ids: () => Array.from(partials.keys()),
490
+ hydrate: async (id) => {
491
+ if (typeof window === "undefined") return;
492
+
493
+ const element = document.querySelector(`[data-mandu-partial="${id}"]`);
494
+ if (element) {
495
+ element.dispatchEvent(
496
+ new CustomEvent("mandu:hydrate-partial", { bubbles: true, detail: { id } })
497
+ );
498
+ }
499
+ },
500
+ hydrateAll: async () => {
501
+ if (typeof window === "undefined") return;
502
+
503
+ const elements = document.querySelectorAll("[data-mandu-partial]");
504
+ for (const el of elements) {
505
+ const id = el.getAttribute("data-mandu-partial");
506
+ if (id) {
507
+ el.dispatchEvent(
508
+ new CustomEvent("mandu:hydrate-partial", { bubbles: true, detail: { id } })
509
+ );
510
+ }
511
+ }
512
+ },
513
+ };
514
+ }
@@ -204,24 +204,49 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
204
204
 
205
205
  // ========== Static File Serving ==========
206
206
 
207
+ /**
208
+ * 경로가 허용된 디렉토리 내에 있는지 검증
209
+ * Path traversal 공격 방지
210
+ */
211
+ function isPathSafe(filePath: string, allowedDir: string): boolean {
212
+ const resolvedPath = path.resolve(filePath);
213
+ const resolvedAllowedDir = path.resolve(allowedDir);
214
+ // 경로가 허용된 디렉토리로 시작하는지 확인 (디렉토리 구분자 포함)
215
+ return resolvedPath.startsWith(resolvedAllowedDir + path.sep) ||
216
+ resolvedPath === resolvedAllowedDir;
217
+ }
218
+
207
219
  /**
208
220
  * 정적 파일 서빙
209
221
  * - /.mandu/client/* : 클라이언트 번들 (Island hydration)
210
222
  * - /public/* : 정적 에셋 (이미지, CSS 등)
211
223
  * - /favicon.ico : 파비콘
224
+ *
225
+ * 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
212
226
  */
213
227
  async function serveStaticFile(pathname: string): Promise<Response | null> {
214
228
  let filePath: string | null = null;
215
229
  let isBundleFile = false;
230
+ let allowedBaseDir: string;
231
+
232
+ // Path traversal 시도 조기 차단 (정규화 전 raw 체크)
233
+ if (pathname.includes("..")) {
234
+ return null;
235
+ }
216
236
 
217
237
  // 1. 클라이언트 번들 파일 (/.mandu/client/*)
218
238
  if (pathname.startsWith("/.mandu/client/")) {
219
- filePath = path.join(serverSettings.rootDir, pathname);
239
+ // pathname에서 prefix 제거 후 안전하게 조합
240
+ const relativePath = pathname.slice("/.mandu/client/".length);
241
+ allowedBaseDir = path.join(serverSettings.rootDir, ".mandu", "client");
242
+ filePath = path.join(allowedBaseDir, relativePath);
220
243
  isBundleFile = true;
221
244
  }
222
- // 2. Public 폴더 파일 (/public/* 또는 직접 접근)
245
+ // 2. Public 폴더 파일 (/public/*)
223
246
  else if (pathname.startsWith("/public/")) {
224
- filePath = path.join(serverSettings.rootDir, pathname);
247
+ const relativePath = pathname.slice("/public/".length);
248
+ allowedBaseDir = path.join(serverSettings.rootDir, "public");
249
+ filePath = path.join(allowedBaseDir, relativePath);
225
250
  }
226
251
  // 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
227
252
  else if (
@@ -230,11 +255,18 @@ async function serveStaticFile(pathname: string): Promise<Response | null> {
230
255
  pathname === "/sitemap.xml" ||
231
256
  pathname === "/manifest.json"
232
257
  ) {
233
- filePath = path.join(serverSettings.rootDir, serverSettings.publicDir, pathname);
258
+ // 고정된 파일명만 허용 (이미 위에서 정확히 매칭됨)
259
+ const filename = path.basename(pathname);
260
+ allowedBaseDir = path.join(serverSettings.rootDir, serverSettings.publicDir);
261
+ filePath = path.join(allowedBaseDir, filename);
262
+ } else {
263
+ return null; // 정적 파일이 아님
234
264
  }
235
265
 
236
- if (!filePath) {
237
- return null; // 정적 파일이 아님
266
+ // 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
267
+ if (!isPathSafe(filePath, allowedBaseDir!)) {
268
+ console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
269
+ return null;
238
270
  }
239
271
 
240
272
  try {
@@ -74,7 +74,7 @@ function generateImportMap(manifest: BundleManifest): string {
74
74
 
75
75
  /**
76
76
  * Hydration 스크립트 태그 생성
77
- * v0.8.0: Island 직접 로드 제거 - Runtime이 data-mandu-src에서 dynamic import
77
+ * v0.9.0: vendor, runtime 모두 modulepreload로 성능 최적화
78
78
  */
79
79
  function generateHydrationScripts(
80
80
  routeId: string,
@@ -88,8 +88,27 @@ function generateHydrationScripts(
88
88
  scripts.push(importMap);
89
89
  }
90
90
 
91
+ // Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화)
92
+ if (manifest.shared.vendor) {
93
+ scripts.push(`<link rel="modulepreload" href="${manifest.shared.vendor}">`);
94
+ }
95
+ if (manifest.importMap?.imports) {
96
+ const imports = manifest.importMap.imports;
97
+ // react-dom, react-dom/client 등 추가 preload
98
+ if (imports["react-dom"] && imports["react-dom"] !== manifest.shared.vendor) {
99
+ scripts.push(`<link rel="modulepreload" href="${imports["react-dom"]}">`);
100
+ }
101
+ if (imports["react-dom/client"]) {
102
+ scripts.push(`<link rel="modulepreload" href="${imports["react-dom/client"]}">`);
103
+ }
104
+ }
105
+
106
+ // Runtime modulepreload (hydration 실행 전 미리 로드)
107
+ if (manifest.shared.runtime) {
108
+ scripts.push(`<link rel="modulepreload" href="${manifest.shared.runtime}">`);
109
+ }
110
+
91
111
  // Island 번들 modulepreload (성능 최적화 - prefetch only)
92
- // v0.8.0: <script> 태그 제거 - Runtime이 dynamic import로 로드
93
112
  const bundle = manifest.bundles[routeId];
94
113
  if (bundle) {
95
114
  scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
@@ -461,7 +461,26 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
461
461
  // 3. Streaming 완료 마커 (클라이언트에서 감지용)
462
462
  scripts.push(`<script>window.__MANDU_STREAMING_SHELL_READY__ = true;</script>`);
463
463
 
464
- // 4. Island modulepreload
464
+ // 4. Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화)
465
+ if (bundleManifest?.shared.vendor) {
466
+ scripts.push(`<link rel="modulepreload" href="${bundleManifest.shared.vendor}">`);
467
+ }
468
+ if (bundleManifest?.importMap?.imports) {
469
+ const imports = bundleManifest.importMap.imports;
470
+ if (imports["react-dom"] && imports["react-dom"] !== bundleManifest.shared.vendor) {
471
+ scripts.push(`<link rel="modulepreload" href="${imports["react-dom"]}">`);
472
+ }
473
+ if (imports["react-dom/client"]) {
474
+ scripts.push(`<link rel="modulepreload" href="${imports["react-dom/client"]}">`);
475
+ }
476
+ }
477
+
478
+ // 5. Runtime modulepreload (hydration 실행 전 미리 로드)
479
+ if (bundleManifest?.shared.runtime) {
480
+ scripts.push(`<link rel="modulepreload" href="${bundleManifest.shared.runtime}">`);
481
+ }
482
+
483
+ // 6. Island modulepreload
465
484
  if (bundleManifest && routeId) {
466
485
  const bundle = bundleManifest.bundles[routeId];
467
486
  if (bundle) {
@@ -469,17 +488,17 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
469
488
  }
470
489
  }
471
490
 
472
- // 5. Runtime 로드
491
+ // 7. Runtime 로드
473
492
  if (bundleManifest?.shared.runtime) {
474
493
  scripts.push(`<script type="module" src="${bundleManifest.shared.runtime}"></script>`);
475
494
  }
476
495
 
477
- // 6. Router 스크립트
496
+ // 8. Router 스크립트
478
497
  if (enableClientRouter && bundleManifest?.shared?.router) {
479
498
  scripts.push(`<script type="module" src="${bundleManifest.shared.router}"></script>`);
480
499
  }
481
500
 
482
- // 7. HMR 스크립트 (개발 모드)
501
+ // 9. HMR 스크립트 (개발 모드)
483
502
  if (isDev && hmrPort) {
484
503
  scripts.push(generateHMRScript(hmrPort));
485
504
  }
@@ -969,58 +988,72 @@ export async function renderWithDeferredData(
969
988
  },
970
989
  });
971
990
 
972
- // 3. TransformStream: base stream 통과 + tail 이후 deferred 스크립트 주입
973
- const transformStream = new TransformStream<Uint8Array, Uint8Array>({
974
- transform(chunk, controller) {
975
- // base stream chunk 그대로 전달
976
- controller.enqueue(chunk);
977
- },
978
- async flush(controller) {
979
- // base stream 완료 후, deferred가 아직 안 끝났으면 잠시 대기
980
- // (단, deferredTimeout 내에서만)
981
- if (!allDeferredSettled) {
982
- const elapsed = Date.now() - startTime;
983
- let remainingTime = deferredTimeout - elapsed;
984
- if (streamTimeout && streamTimeout > 0) {
985
- const remainingStream = streamTimeout - elapsed;
986
- remainingTime = Math.min(remainingTime, remainingStream);
991
+ // 3. 수동 스트림 파이프라인 (Bun pipeThrough 호환성 문제 해결)
992
+ // base stream을 읽고 변환 후 → 새 스트림으로 출력
993
+ const reader = baseStream.getReader();
994
+
995
+ const finalStream = new ReadableStream<Uint8Array>({
996
+ async pull(controller) {
997
+ try {
998
+ const { done, value } = await reader.read();
999
+
1000
+ if (!done && value) {
1001
+ // base stream chunk 그대로 전달
1002
+ controller.enqueue(value);
1003
+ return;
987
1004
  }
988
- remainingTime = Math.max(0, remainingTime);
989
- if (remainingTime > 0) {
990
- await Promise.race([
991
- deferredSettledPromise,
992
- new Promise(resolve => setTimeout(resolve, remainingTime)),
993
- ]);
1005
+
1006
+ // base stream 완료 → flush 로직 실행
1007
+ // deferred가 아직 안 끝났으면 잠시 대기 (단, deferredTimeout 내에서만)
1008
+ if (!allDeferredSettled) {
1009
+ const elapsed = Date.now() - startTime;
1010
+ let remainingTime = deferredTimeout - elapsed;
1011
+ if (streamTimeout && streamTimeout > 0) {
1012
+ const remainingStream = streamTimeout - elapsed;
1013
+ remainingTime = Math.min(remainingTime, remainingStream);
1014
+ }
1015
+ remainingTime = Math.max(0, remainingTime);
1016
+ if (remainingTime > 0) {
1017
+ await Promise.race([
1018
+ deferredSettledPromise,
1019
+ new Promise(resolve => setTimeout(resolve, remainingTime)),
1020
+ ]);
1021
+ }
994
1022
  }
995
- }
996
1023
 
997
- // 준비된 deferred 스크립트만 주입 (실제 enqueue 기준 카운트)
998
- let injectedCount = 0;
999
- for (const script of readyScripts) {
1000
- controller.enqueue(encoder.encode(script));
1001
- injectedCount++;
1002
- }
1024
+ // 준비된 deferred 스크립트만 주입 (실제 enqueue 기준 카운트)
1025
+ let injectedCount = 0;
1026
+ for (const script of readyScripts) {
1027
+ controller.enqueue(encoder.encode(script));
1028
+ injectedCount++;
1029
+ }
1003
1030
 
1004
- if (isDev && injectedCount > 0) {
1005
- console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
1006
- }
1031
+ if (isDev && injectedCount > 0) {
1032
+ console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
1033
+ }
1007
1034
 
1008
- // HTML 닫기 태그 추가 (</body></html>)
1009
- controller.enqueue(encoder.encode(generateHTMLClose()));
1035
+ // HTML 닫기 태그 추가 (</body></html>)
1036
+ controller.enqueue(encoder.encode(generateHTMLClose()));
1010
1037
 
1011
- // 최종 메트릭 보고 (injectedCount가 실제 메트릭)
1012
- if (onMetrics && baseMetrics) {
1013
- onMetrics({
1014
- ...baseMetrics,
1015
- deferredChunkCount: injectedCount,
1016
- allReadyTime: Date.now() - startTime,
1017
- });
1038
+ // 최종 메트릭 보고 (injectedCount가 실제 메트릭)
1039
+ if (onMetrics && baseMetrics) {
1040
+ onMetrics({
1041
+ ...baseMetrics,
1042
+ deferredChunkCount: injectedCount,
1043
+ allReadyTime: Date.now() - startTime,
1044
+ });
1045
+ }
1046
+
1047
+ controller.close();
1048
+ } catch (error) {
1049
+ controller.error(error);
1018
1050
  }
1019
1051
  },
1052
+ cancel() {
1053
+ reader.cancel();
1054
+ },
1020
1055
  });
1021
1056
 
1022
- const finalStream = baseStream.pipeThrough(transformStream);
1023
-
1024
1057
  return new Response(finalStream, {
1025
1058
  status: 200,
1026
1059
  headers: {