@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 +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/runtime/server.ts +38 -6
- 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.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
|
+
}
|
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
|
};
|
package/src/client/island.ts
CHANGED
|
@@ -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
|
-
):
|
|
177
|
+
): IslandEventHandle<T>["emit"] & IslandEventHandle<T> {
|
|
150
178
|
if (typeof window === "undefined") {
|
|
151
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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 {
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -74,7 +74,7 @@ function generateImportMap(manifest: BundleManifest): string {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Hydration 스크립트 태그 생성
|
|
77
|
-
* v0.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1031
|
+
if (isDev && injectedCount > 0) {
|
|
1032
|
+
console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
|
|
1033
|
+
}
|
|
1007
1034
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1035
|
+
// HTML 닫기 태그 추가 (</body></html>)
|
|
1036
|
+
controller.enqueue(encoder.encode(generateHTMLClose()));
|
|
1010
1037
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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: {
|