@mandujs/core 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.ko.md +304 -304
  2. package/README.md +653 -653
  3. package/package.json +8 -8
  4. package/src/brain/architecture/analyzer.ts +28 -26
  5. package/src/brain/doctor/analyzer.ts +1 -1
  6. package/src/bundler/build.ts +91 -91
  7. package/src/bundler/css.ts +302 -302
  8. package/src/bundler/dev.ts +0 -1
  9. package/src/change/history.ts +3 -3
  10. package/src/change/snapshot.ts +10 -9
  11. package/src/change/transaction.ts +2 -2
  12. package/src/client/Link.tsx +227 -227
  13. package/src/client/globals.ts +44 -44
  14. package/src/client/hooks.ts +267 -267
  15. package/src/client/index.ts +5 -5
  16. package/src/client/island.ts +8 -8
  17. package/src/client/router.ts +435 -435
  18. package/src/client/runtime.ts +23 -23
  19. package/src/client/serialize.ts +404 -404
  20. package/src/client/window-state.ts +101 -101
  21. package/src/config/mandu.ts +94 -96
  22. package/src/config/validate.ts +213 -215
  23. package/src/config/watcher.ts +311 -311
  24. package/src/constants.ts +40 -40
  25. package/src/content/content-layer.ts +314 -314
  26. package/src/content/content.test.ts +433 -433
  27. package/src/content/data-store.ts +245 -245
  28. package/src/content/digest.ts +133 -133
  29. package/src/content/index.ts +164 -164
  30. package/src/content/loader-context.ts +172 -172
  31. package/src/content/loaders/api.ts +216 -216
  32. package/src/content/loaders/file.ts +169 -169
  33. package/src/content/loaders/glob.ts +252 -252
  34. package/src/content/loaders/index.ts +34 -34
  35. package/src/content/loaders/types.ts +137 -137
  36. package/src/content/meta-store.ts +209 -209
  37. package/src/content/types.ts +282 -282
  38. package/src/content/watcher.ts +135 -135
  39. package/src/contract/client-safe.test.ts +42 -42
  40. package/src/contract/client-safe.ts +114 -114
  41. package/src/contract/client.ts +16 -16
  42. package/src/contract/define.ts +459 -459
  43. package/src/contract/handler.ts +10 -10
  44. package/src/contract/normalize.test.ts +276 -276
  45. package/src/contract/normalize.ts +404 -404
  46. package/src/contract/registry.test.ts +206 -206
  47. package/src/contract/registry.ts +568 -568
  48. package/src/contract/schema.ts +48 -48
  49. package/src/contract/types.ts +58 -58
  50. package/src/contract/validator.ts +32 -32
  51. package/src/devtools/ai/context-builder.ts +375 -375
  52. package/src/devtools/ai/index.ts +25 -25
  53. package/src/devtools/ai/mcp-connector.ts +465 -465
  54. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  55. package/src/devtools/client/catchers/index.ts +18 -18
  56. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  57. package/src/devtools/client/components/index.ts +39 -39
  58. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  59. package/src/devtools/client/components/mandu-character.tsx +241 -241
  60. package/src/devtools/client/components/overlay.tsx +368 -368
  61. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  62. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  63. package/src/devtools/client/components/panel/index.ts +32 -32
  64. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  65. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  66. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  67. package/src/devtools/client/filters/context-filters.ts +282 -282
  68. package/src/devtools/client/filters/index.ts +16 -16
  69. package/src/devtools/client/index.ts +63 -63
  70. package/src/devtools/client/persistence.ts +335 -335
  71. package/src/devtools/client/state-manager.ts +478 -478
  72. package/src/devtools/design-tokens.ts +263 -263
  73. package/src/devtools/hook/create-hook.ts +207 -207
  74. package/src/devtools/hook/index.ts +13 -13
  75. package/src/devtools/index.ts +439 -439
  76. package/src/devtools/init.ts +266 -266
  77. package/src/devtools/protocol.ts +237 -237
  78. package/src/devtools/server/index.ts +17 -17
  79. package/src/devtools/server/source-context.ts +444 -444
  80. package/src/devtools/types.ts +319 -319
  81. package/src/devtools/worker/index.ts +25 -25
  82. package/src/devtools/worker/redaction-worker.ts +222 -222
  83. package/src/devtools/worker/worker-manager.ts +409 -409
  84. package/src/error/classifier.ts +2 -2
  85. package/src/error/domains.ts +265 -265
  86. package/src/error/formatter.ts +32 -32
  87. package/src/error/result.ts +46 -46
  88. package/src/error/stack-analyzer.ts +5 -0
  89. package/src/error/types.ts +6 -6
  90. package/src/errors/extractor.ts +409 -409
  91. package/src/errors/index.ts +19 -19
  92. package/src/filling/auth.ts +308 -308
  93. package/src/filling/context.ts +569 -569
  94. package/src/filling/deps.ts +238 -238
  95. package/src/generator/contract-glue.ts +2 -1
  96. package/src/generator/generate.ts +12 -10
  97. package/src/generator/index.ts +3 -3
  98. package/src/generator/templates.ts +80 -79
  99. package/src/guard/analyzer.ts +360 -360
  100. package/src/guard/ast-analyzer.ts +806 -806
  101. package/src/guard/auto-correct.ts +1 -1
  102. package/src/guard/check.ts +128 -128
  103. package/src/guard/contract-guard.ts +9 -9
  104. package/src/guard/file-type.test.ts +24 -24
  105. package/src/guard/healing.ts +2 -0
  106. package/src/guard/index.ts +2 -0
  107. package/src/guard/negotiation.ts +430 -4
  108. package/src/guard/presets/atomic.ts +70 -70
  109. package/src/guard/presets/clean.ts +77 -77
  110. package/src/guard/presets/cqrs.test.ts +175 -0
  111. package/src/guard/presets/cqrs.ts +107 -0
  112. package/src/guard/presets/fsd.ts +79 -79
  113. package/src/guard/presets/hexagonal.ts +68 -68
  114. package/src/guard/presets/index.ts +291 -288
  115. package/src/guard/reporter.ts +445 -445
  116. package/src/guard/rules.ts +12 -12
  117. package/src/guard/statistics.ts +578 -578
  118. package/src/guard/suggestions.ts +358 -352
  119. package/src/guard/types.ts +348 -347
  120. package/src/guard/validator.ts +834 -834
  121. package/src/guard/watcher.ts +404 -404
  122. package/src/index.ts +1 -0
  123. package/src/intent/index.ts +310 -310
  124. package/src/island/index.ts +304 -304
  125. package/src/logging/index.ts +22 -22
  126. package/src/logging/transports.ts +365 -365
  127. package/src/paths.test.ts +47 -0
  128. package/src/paths.ts +47 -0
  129. package/src/plugins/index.ts +38 -38
  130. package/src/plugins/registry.ts +377 -377
  131. package/src/plugins/types.ts +363 -363
  132. package/src/report/build.ts +1 -1
  133. package/src/report/index.ts +1 -1
  134. package/src/router/fs-patterns.ts +387 -387
  135. package/src/router/fs-routes.ts +344 -401
  136. package/src/router/fs-scanner.ts +497 -497
  137. package/src/router/fs-types.ts +270 -278
  138. package/src/router/index.ts +81 -81
  139. package/src/runtime/boundary.tsx +232 -232
  140. package/src/runtime/compose.ts +222 -222
  141. package/src/runtime/lifecycle.ts +381 -381
  142. package/src/runtime/logger.test.ts +345 -345
  143. package/src/runtime/logger.ts +677 -677
  144. package/src/runtime/router.test.ts +476 -476
  145. package/src/runtime/router.ts +105 -105
  146. package/src/runtime/security.ts +155 -155
  147. package/src/runtime/server.ts +24 -24
  148. package/src/runtime/session-key.ts +328 -328
  149. package/src/runtime/ssr.ts +367 -367
  150. package/src/runtime/streaming-ssr.ts +1245 -1245
  151. package/src/runtime/trace.ts +144 -144
  152. package/src/seo/index.ts +214 -214
  153. package/src/seo/integration/ssr.ts +307 -307
  154. package/src/seo/render/basic.ts +427 -427
  155. package/src/seo/render/index.ts +143 -143
  156. package/src/seo/render/jsonld.ts +539 -539
  157. package/src/seo/render/opengraph.ts +191 -191
  158. package/src/seo/render/robots.ts +116 -116
  159. package/src/seo/render/sitemap.ts +137 -137
  160. package/src/seo/render/twitter.ts +126 -126
  161. package/src/seo/resolve/index.ts +353 -353
  162. package/src/seo/resolve/opengraph.ts +143 -143
  163. package/src/seo/resolve/robots.ts +73 -73
  164. package/src/seo/resolve/title.ts +94 -94
  165. package/src/seo/resolve/twitter.ts +73 -73
  166. package/src/seo/resolve/url.ts +97 -97
  167. package/src/seo/routes/index.ts +290 -290
  168. package/src/seo/types.ts +575 -575
  169. package/src/slot/validator.ts +39 -39
  170. package/src/spec/index.ts +3 -3
  171. package/src/spec/load.ts +76 -76
  172. package/src/spec/lock.ts +56 -56
  173. package/src/utils/bun.ts +8 -8
  174. package/src/utils/lru-cache.ts +75 -75
  175. package/src/utils/safe-io.ts +188 -188
  176. package/src/utils/string-safe.ts +298 -298
  177. package/src/watcher/rules.ts +5 -5
@@ -1,1245 +1,1245 @@
1
- /**
2
- * Mandu Streaming SSR
3
- * React 18 renderToReadableStream 기반 점진적 HTML 스트리밍
4
- *
5
- * 특징:
6
- * - TTFB 최소화 (Shell 즉시 전송)
7
- * - Suspense 경계에서 fallback → 실제 컨텐츠 스트리밍
8
- * - Critical/Deferred 데이터 분리
9
- * - Island Architecture와 완벽 통합
10
- */
11
-
12
- // Bun에서는 react-dom/server.browser에서 renderToReadableStream을 가져옴
13
- // Node.js 환경에서는 renderToPipeableStream 사용 필요
14
- import { renderToReadableStream } from "react-dom/server.browser";
15
- import type { ReactElement, ReactNode } from "react";
16
- import React, { Suspense } from "react";
17
- import type { BundleManifest } from "../bundler/types";
18
- import type { HydrationConfig, HydrationPriority } from "../spec/schema";
19
- import { serializeProps } from "../client/serialize";
20
- import type { Metadata, MetadataItem } from "../seo/types";
21
- import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
22
- import { PORTS, TIMEOUTS } from "../constants";
23
-
24
- // ========== Types ==========
25
-
26
- /**
27
- * Streaming SSR 에러 타입
28
- *
29
- * 에러 정책 (Error Policy):
30
- * 1. Stream 생성 실패 (renderToReadableStream throws)
31
- * → renderStreamingResponse에서 catch → 500 Response 반환
32
- * → 이 경우 StreamingError는 생성되지 않음
33
- *
34
- * 2. Shell 전 React 렌더링 에러 (onError called, shellSent=false)
35
- * → isShellError: true, recoverable: false
36
- * → onShellError 콜백 호출
37
- * → 스트림은 계속 진행 (빈 컨텐츠 or 부분 렌더링)
38
- *
39
- * 3. Shell 후 스트리밍 에러 (onError called, shellSent=true)
40
- * → isShellError: false, recoverable: true
41
- * → onStreamError 콜백 호출
42
- * → 에러 스크립트가 HTML에 삽입됨
43
- */
44
- export interface StreamingError {
45
- error: Error;
46
- /**
47
- * Shell 전송 전 에러인지 여부
48
- * - true: React 초기 렌더링 중 에러 (Shell 전송 전)
49
- * - false: 스트리밍 중 에러 (Shell 이미 전송됨)
50
- */
51
- isShellError: boolean;
52
- /**
53
- * 복구 가능 여부
54
- * - true: Shell 이후 에러 - 에러 스크립트 삽입으로 클라이언트 알림
55
- * - false: Shell 전 에러 - 사용자에게 불완전한 UI 표시될 수 있음
56
- */
57
- recoverable: boolean;
58
- /** 타임스탬프 */
59
- timestamp: number;
60
- }
61
-
62
- /**
63
- * Streaming SSR 메트릭
64
- */
65
- export interface StreamingMetrics {
66
- /** Shell ready까지 걸린 시간 (ms) */
67
- shellReadyTime: number;
68
- /** All ready까지 걸린 시간 (ms) */
69
- allReadyTime: number;
70
- /** Deferred chunk 개수 */
71
- deferredChunkCount: number;
72
- /** 에러 발생 여부 */
73
- hasError: boolean;
74
- /** 시작 시간 */
75
- startTime: number;
76
- }
77
-
78
- export interface StreamingSSROptions {
79
- /** 페이지 타이틀 (SEO metadata 사용 시 자동 설정됨) */
80
- title?: string;
81
- /** HTML lang 속성 */
82
- lang?: string;
83
- /** 라우트 ID */
84
- routeId?: string;
85
- /** 라우트 패턴 */
86
- routePattern?: string;
87
- /** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
88
- criticalData?: Record<string, unknown>;
89
- // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
90
- /** Hydration 설정 */
91
- hydration?: HydrationConfig;
92
- /** 번들 매니페스트 */
93
- bundleManifest?: BundleManifest;
94
- /** 추가 head 태그 (SEO metadata와 병합됨) */
95
- headTags?: string;
96
- /**
97
- * SEO 메타데이터 (Layout 체인 또는 단일 객체)
98
- * - 배열: [rootLayout, ...nestedLayouts, page] 순서로 병합
99
- * - 객체: 단일 정적 메타데이터
100
- */
101
- metadata?: MetadataItem[] | Metadata;
102
- /** 라우트 파라미터 (동적 메타데이터용) */
103
- routeParams?: Record<string, string>;
104
- /** 쿼리 파라미터 (동적 메타데이터용) */
105
- searchParams?: Record<string, string>;
106
- /** 개발 모드 여부 */
107
- isDev?: boolean;
108
- /** HMR 포트 */
109
- hmrPort?: number;
110
- /** Client-side Router 활성화 */
111
- enableClientRouter?: boolean;
112
- /** Streaming 타임아웃 (ms) - 전체 스트림 최대 시간 */
113
- streamTimeout?: number;
114
- /** Shell 렌더링 후 콜백 (TTFB 측정 시점) */
115
- onShellReady?: () => void;
116
- /** 모든 컨텐츠 렌더링 후 콜백 */
117
- onAllReady?: () => void;
118
- /**
119
- * Shell 전 에러 콜백
120
- * - React 초기 렌더링 중 에러 발생 시 호출
121
- * - 이 시점에서는 이미 스트림이 시작됨 (500 반환 불가)
122
- * - 로깅/모니터링 용도
123
- */
124
- onShellError?: (error: StreamingError) => void;
125
- /**
126
- * 스트리밍 중 에러 콜백
127
- * - Shell 전송 후 에러 발생 시 호출
128
- * - 에러 스크립트가 HTML에 자동 삽입됨
129
- * - 클라이언트에서 mandu:streaming-error 이벤트로 감지 가능
130
- */
131
- onStreamError?: (error: StreamingError) => void;
132
- /** 에러 콜백 (deprecated - onShellError/onStreamError 사용 권장) */
133
- onError?: (error: Error) => void;
134
- /** 메트릭 콜백 (observability) */
135
- onMetrics?: (metrics: StreamingMetrics) => void;
136
- /**
137
- * HTML 닫기 태그 생략 여부 (내부용)
138
- * true이면 </body></html>을 생략하여 deferred 스크립트 삽입 지점 확보
139
- */
140
- _skipHtmlClose?: boolean;
141
- /** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */
142
- cssPath?: string | false;
143
- }
144
-
145
- export interface StreamingLoaderResult<T = unknown> {
146
- /** 즉시 로드할 Critical 데이터 */
147
- critical?: T;
148
- /** 지연 로드할 Deferred 데이터 (Promise) */
149
- deferred?: Promise<T>;
150
- }
151
-
152
- // ========== Serialization Guards ==========
153
-
154
- /**
155
- * 값이 JSON-serializable인지 검증
156
- * Date, Map, Set, BigInt 등은 serializeProps에서 처리되지만
157
- * 함수, Symbol, undefined는 문제가 됨
158
- */
159
- function isJSONSerializable(value: unknown, path: string = "root", isDev: boolean = false): { valid: boolean; issues: string[] } {
160
- const issues: string[] = [];
161
-
162
- function check(val: unknown, currentPath: string): void {
163
- if (val === undefined) {
164
- issues.push(`${currentPath}: undefined는 JSON으로 직렬화할 수 없습니다`);
165
- return;
166
- }
167
-
168
- if (val === null) return;
169
-
170
- const type = typeof val;
171
-
172
- if (type === "function") {
173
- issues.push(`${currentPath}: function은 JSON으로 직렬화할 수 없습니다`);
174
- return;
175
- }
176
-
177
- if (type === "symbol") {
178
- issues.push(`${currentPath}: symbol은 JSON으로 직렬화할 수 없습니다`);
179
- return;
180
- }
181
-
182
- if (type === "bigint") {
183
- // serializeProps에서 처리됨 - 경고만
184
- if (isDev) {
185
- console.warn(`[Mandu Streaming] ${currentPath}: BigInt가 감지됨 - 문자열로 변환됩니다`);
186
- }
187
- return;
188
- }
189
-
190
- if (val instanceof Date || val instanceof Map || val instanceof Set || val instanceof URL || val instanceof RegExp) {
191
- // serializeProps에서 처리됨
192
- return;
193
- }
194
-
195
- if (Array.isArray(val)) {
196
- val.forEach((item, index) => check(item, `${currentPath}[${index}]`));
197
- return;
198
- }
199
-
200
- if (type === "object") {
201
- for (const [key, v] of Object.entries(val as Record<string, unknown>)) {
202
- check(v, `${currentPath}.${key}`);
203
- }
204
- return;
205
- }
206
-
207
- // string, number, boolean은 OK
208
- }
209
-
210
- check(value, path);
211
-
212
- return {
213
- valid: issues.length === 0,
214
- issues,
215
- };
216
- }
217
-
218
- /**
219
- * criticalData 검증 및 경고
220
- * 개발 모드에서는 throw, 프로덕션에서는 경고만
221
- */
222
- function validateCriticalData(data: Record<string, unknown> | undefined, isDev: boolean): void {
223
- if (!data) return;
224
-
225
- const result = isJSONSerializable(data, "criticalData", isDev);
226
-
227
- if (!result.valid) {
228
- const message = `[Mandu Streaming] criticalData 직렬화 문제:\n${result.issues.join("\n")}`;
229
-
230
- if (isDev) {
231
- throw new Error(message);
232
- } else {
233
- console.error(message);
234
- }
235
- }
236
- }
237
-
238
- // ========== Streaming Warnings ==========
239
-
240
- /**
241
- * 프록시/버퍼링 관련 경고 (개발 모드)
242
- */
243
- function warnStreamingCaveats(isDev: boolean): void {
244
- if (!isDev) return;
245
-
246
- console.log(`[Mandu Streaming] 💡 Streaming SSR 주의사항:
247
- - nginx/cloudflare 등 reverse proxy 사용 시 버퍼링 비활성화 필요
248
- (nginx: proxy_buffering off; X-Accel-Buffering: no)
249
- - compression 미들웨어가 chunk를 모으면 스트리밍 이점 사라짐
250
- - Transfer-Encoding: chunked 헤더가 유지되어야 함`);
251
- }
252
-
253
- // ========== Error HTML Generation ==========
254
-
255
- /**
256
- * 스트리밍 중 에러 시 삽입할 에러 스크립트 생성
257
- * Shell 이후 에러는 이 방식으로 클라이언트에 전달
258
- */
259
- function generateErrorScript(error: Error, routeId: string): string {
260
- const safeMessage = error.message
261
- .replace(/\\/g, "\\\\") // 백슬래시 먼저 (다른 이스케이프에 영향)
262
- .replace(/\n/g, "\\n") // 줄바꿈
263
- .replace(/\r/g, "\\r") // 캐리지 리턴
264
- .replace(/</g, "\\u003c") // XSS 방지
265
- .replace(/>/g, "\\u003e")
266
- .replace(/"/g, "\\u0022");
267
-
268
- return `<script>
269
- (function() {
270
- window.__MANDU_STREAMING_ERROR__ = {
271
- routeId: "${routeId}",
272
- message: "${safeMessage}",
273
- timestamp: ${Date.now()}
274
- };
275
- console.error("[Mandu Streaming] 렌더링 중 에러:", "${safeMessage}");
276
- window.dispatchEvent(new CustomEvent('mandu:streaming-error', {
277
- detail: window.__MANDU_STREAMING_ERROR__
278
- }));
279
- })();
280
- </script>`;
281
- }
282
-
283
- // ========== Suspense Wrappers ==========
284
-
285
- /**
286
- * Island를 Suspense로 감싸는 래퍼
287
- * Streaming SSR에서 Island별 점진적 렌더링 지원
288
- */
289
- export function SuspenseIsland({
290
- children,
291
- fallback,
292
- routeId,
293
- priority = "visible",
294
- bundleSrc,
295
- }: {
296
- children: ReactNode;
297
- fallback?: ReactNode;
298
- routeId: string;
299
- priority?: HydrationPriority;
300
- bundleSrc?: string;
301
- }): ReactElement {
302
- const defaultFallback = React.createElement("div", {
303
- "data-mandu-island": routeId,
304
- "data-mandu-priority": priority,
305
- "data-mandu-src": bundleSrc,
306
- "data-mandu-loading": "true",
307
- style: { minHeight: "50px" },
308
- }, React.createElement("div", {
309
- className: "mandu-loading-skeleton",
310
- style: {
311
- background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)",
312
- backgroundSize: "200% 100%",
313
- animation: "mandu-shimmer 1.5s infinite",
314
- height: "100%",
315
- minHeight: "50px",
316
- borderRadius: "4px",
317
- },
318
- }));
319
-
320
- return React.createElement(
321
- Suspense,
322
- { fallback: fallback || defaultFallback },
323
- React.createElement("div", {
324
- "data-mandu-island": routeId,
325
- "data-mandu-priority": priority,
326
- "data-mandu-src": bundleSrc,
327
- }, children)
328
- );
329
- }
330
-
331
- /**
332
- * Deferred 데이터를 위한 Suspense 컴포넌트
333
- * 데이터가 준비되면 children 렌더링
334
- */
335
- export function DeferredData<T>({
336
- promise,
337
- children,
338
- fallback,
339
- }: {
340
- promise: Promise<T>;
341
- children: (data: T) => ReactNode;
342
- fallback?: ReactNode;
343
- }): ReactElement {
344
- // React 18 use() 훅 대신 Suspense + throw promise 패턴 사용
345
- const AsyncComponent = React.lazy(async () => {
346
- const data = await promise;
347
- return {
348
- default: () => React.createElement(React.Fragment, null, children(data)),
349
- };
350
- });
351
-
352
- return React.createElement(
353
- Suspense,
354
- { fallback: fallback || React.createElement("span", null, "Loading...") },
355
- React.createElement(AsyncComponent, null)
356
- );
357
- }
358
-
359
- // ========== HTML Generation ==========
360
-
361
- /**
362
- * Streaming용 HTML Shell 생성 (<!DOCTYPE> ~ <div id="root">)
363
- */
364
- function generateHTMLShell(options: StreamingSSROptions): string {
365
- const {
366
- title = "Mandu App",
367
- lang = "ko",
368
- headTags = "",
369
- bundleManifest,
370
- routeId,
371
- hydration,
372
- cssPath,
373
- isDev = false,
374
- } = options;
375
-
376
- // CSS 링크 태그 생성
377
- // - cssPath가 string이면 해당 경로 사용
378
- // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
379
- const cssLinkTag = cssPath && cssPath !== false
380
- ? `<link rel="stylesheet" href="${cssPath}${isDev ? `?t=${Date.now()}` : ""}">`
381
- : "";
382
-
383
- // Import map (module scripts 전에 위치해야 함)
384
- let importMapScript = "";
385
- if (bundleManifest?.importMap && Object.keys(bundleManifest.importMap.imports).length > 0) {
386
- const importMapJson = JSON.stringify(bundleManifest.importMap, null, 2);
387
- importMapScript = `<script type="importmap">${importMapJson}</script>`;
388
- }
389
-
390
- // Loading skeleton 애니메이션 스타일
391
- const loadingStyles = `
392
- <style>
393
- @keyframes mandu-shimmer {
394
- 0% { background-position: 200% 0; }
395
- 100% { background-position: -200% 0; }
396
- }
397
- .mandu-loading-skeleton {
398
- background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
399
- background-size: 200% 100%;
400
- animation: mandu-shimmer 1.5s infinite;
401
- }
402
- .mandu-stream-pending {
403
- opacity: 0;
404
- transition: opacity 0.3s ease-in;
405
- }
406
- .mandu-stream-ready {
407
- opacity: 1;
408
- }
409
- </style>`;
410
-
411
- // Island wrapper (hydration이 필요한 경우)
412
- const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
413
- let islandOpenTag = "";
414
- if (needsHydration) {
415
- const bundle = bundleManifest.bundles[routeId];
416
- const bundleSrc = bundle?.js || "";
417
- const priority = hydration.priority || "visible";
418
- islandOpenTag = `<div data-mandu-island="${routeId}" data-mandu-src="${bundleSrc}" data-mandu-priority="${priority}">`;
419
- }
420
-
421
- // Import map은 module 스크립트보다 먼저 정의되어야 bare specifier 해석 가능
422
- return `<!DOCTYPE html>
423
- <html lang="${lang}">
424
- <head>
425
- <meta charset="UTF-8">
426
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
427
- <title>${title}</title>
428
- ${cssLinkTag}
429
- ${loadingStyles}
430
- ${importMapScript}
431
- ${headTags}
432
- </head>
433
- <body>
434
- <div id="root">${islandOpenTag}`;
435
- }
436
-
437
- /**
438
- * Streaming용 HTML Tail 스크립트 생성 (</div id="root"> ~ 스크립트들)
439
- * `</body></html>`은 포함하지 않음 - deferred 스크립트 삽입 지점 확보
440
- */
441
- function generateHTMLTailContent(options: StreamingSSROptions): string {
442
- const {
443
- routeId,
444
- routePattern,
445
- criticalData,
446
- bundleManifest,
447
- isDev = false,
448
- hmrPort,
449
- enableClientRouter = false,
450
- hydration,
451
- } = options;
452
-
453
- const scripts: string[] = [];
454
-
455
- // 1. Critical 데이터 스크립트 (즉시 사용 가능)
456
- if (criticalData && routeId) {
457
- const wrappedData = {
458
- [routeId]: {
459
- serverData: criticalData,
460
- timestamp: Date.now(),
461
- streaming: true,
462
- },
463
- };
464
- const json = serializeProps(wrappedData)
465
- .replace(/</g, "\\u003c")
466
- .replace(/>/g, "\\u003e")
467
- .replace(/&/g, "\\u0026");
468
- scripts.push(`<script id="__MANDU_DATA__" type="application/json">${json}</script>`);
469
- scripts.push(`<script>window.__MANDU_DATA_RAW__ = document.getElementById('__MANDU_DATA__').textContent;</script>`);
470
- }
471
-
472
- // 2. 라우트 정보 스크립트
473
- if (enableClientRouter && routeId) {
474
- const routeInfo = {
475
- id: routeId,
476
- pattern: routePattern || "",
477
- params: {},
478
- streaming: true,
479
- };
480
- const json = JSON.stringify(routeInfo)
481
- .replace(/</g, "\\u003c")
482
- .replace(/>/g, "\\u003e");
483
- scripts.push(`<script>window.__MANDU_ROUTE__ = ${json};</script>`);
484
- }
485
-
486
- // 3. Streaming 완료 마커 (클라이언트에서 감지용)
487
- scripts.push(`<script>window.__MANDU_STREAMING_SHELL_READY__ = true;</script>`);
488
-
489
- // 4. Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화)
490
- if (bundleManifest?.shared.vendor) {
491
- scripts.push(`<link rel="modulepreload" href="${bundleManifest.shared.vendor}">`);
492
- }
493
- if (bundleManifest?.importMap?.imports) {
494
- const imports = bundleManifest.importMap.imports;
495
- if (imports["react-dom"] && imports["react-dom"] !== bundleManifest.shared.vendor) {
496
- scripts.push(`<link rel="modulepreload" href="${imports["react-dom"]}">`);
497
- }
498
- if (imports["react-dom/client"]) {
499
- scripts.push(`<link rel="modulepreload" href="${imports["react-dom/client"]}">`);
500
- }
501
- }
502
-
503
- // 5. Runtime modulepreload (hydration 실행 전 미리 로드)
504
- if (bundleManifest?.shared.runtime) {
505
- scripts.push(`<link rel="modulepreload" href="${bundleManifest.shared.runtime}">`);
506
- }
507
-
508
- // 6. Island modulepreload
509
- if (bundleManifest && routeId) {
510
- const bundle = bundleManifest.bundles[routeId];
511
- if (bundle) {
512
- scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
513
- }
514
- }
515
-
516
- // 7. Runtime 로드
517
- if (bundleManifest?.shared.runtime) {
518
- scripts.push(`<script type="module" src="${bundleManifest.shared.runtime}"></script>`);
519
- }
520
-
521
- // 8. Router 스크립트
522
- if (enableClientRouter && bundleManifest?.shared?.router) {
523
- scripts.push(`<script type="module" src="${bundleManifest.shared.router}"></script>`);
524
- }
525
-
526
- // 9. HMR 스크립트 (개발 모드)
527
- if (isDev && hmrPort) {
528
- scripts.push(generateHMRScript(hmrPort));
529
- }
530
-
531
- // Island wrapper 닫기 (hydration이 필요한 경우)
532
- const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
533
- const islandCloseTag = needsHydration ? "</div>" : "";
534
-
535
- return `${islandCloseTag}</div>
536
- ${scripts.join("\n ")}`;
537
- }
538
-
539
- /**
540
- * HTML 문서 닫기 태그
541
- * Deferred 스크립트 삽입 후 호출
542
- */
543
- function generateHTMLClose(): string {
544
- return `
545
- </body>
546
- </html>`;
547
- }
548
-
549
- /**
550
- * Streaming용 HTML Tail 생성 (</div id="root"> ~ </html>)
551
- * 하위 호환성 유지 - 내부적으로 generateHTMLTailContent + generateHTMLClose 사용
552
- */
553
- function generateHTMLTail(options: StreamingSSROptions): string {
554
- return generateHTMLTailContent(options) + generateHTMLClose();
555
- }
556
-
557
- /**
558
- * Deferred 데이터 인라인 스크립트 생성
559
- * Streaming 중에 데이터 도착 시 DOM에 주입
560
- */
561
- function generateDeferredDataScript(routeId: string, key: string, data: unknown): string {
562
- const json = serializeProps({ [key]: data })
563
- .replace(/</g, "\\u003c")
564
- .replace(/>/g, "\\u003e");
565
-
566
- return `<script>
567
- (function() {
568
- window.__MANDU_DEFERRED__ = window.__MANDU_DEFERRED__ || {};
569
- window.__MANDU_DEFERRED__["${routeId}"] = window.__MANDU_DEFERRED__["${routeId}"] || {};
570
- Object.assign(window.__MANDU_DEFERRED__["${routeId}"], ${json});
571
- window.dispatchEvent(new CustomEvent('mandu:deferred-data', { detail: { routeId: "${routeId}", key: "${key}" } }));
572
- })();
573
- </script>`;
574
- }
575
-
576
- /**
577
- * HMR 스크립트 생성
578
- */
579
- function generateHMRScript(port: number): string {
580
- const hmrPort = port + PORTS.HMR_OFFSET;
581
- return `<script>
582
- (function() {
583
- var ws = null;
584
- var reconnectAttempts = 0;
585
- var maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
586
-
587
- function connect() {
588
- try {
589
- ws = new WebSocket('ws://localhost:${hmrPort}');
590
- ws.onopen = function() {
591
- console.log('[Mandu HMR] Connected');
592
- reconnectAttempts = 0;
593
- };
594
- ws.onmessage = function(e) {
595
- try {
596
- var msg = JSON.parse(e.data);
597
- if (msg.type === 'reload' || msg.type === 'island-update') {
598
- console.log('[Mandu HMR] Reloading...');
599
- location.reload();
600
- }
601
- } catch(err) {}
602
- };
603
- ws.onclose = function() {
604
- if (reconnectAttempts < maxReconnectAttempts) {
605
- reconnectAttempts++;
606
- setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY} * reconnectAttempts);
607
- }
608
- };
609
- } catch(err) {
610
- setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY});
611
- }
612
- }
613
- connect();
614
- })();
615
- </script>`;
616
- }
617
-
618
- // ========== Main Streaming Functions ==========
619
-
620
- /**
621
- * React 컴포넌트를 ReadableStream으로 렌더링
622
- * Bun/Web Streams API 기반
623
- *
624
- * 핵심 원칙:
625
- * - Shell은 즉시 전송 (TTFB 최소화)
626
- * - allReady는 메트릭용으로만 사용 (대기 안 함)
627
- * - Shell 전 에러는 throw → Response 레이어에서 500 처리
628
- * - Shell 후 에러는 에러 스크립트 삽입
629
- */
630
- export async function renderToStream(
631
- element: ReactElement,
632
- options: StreamingSSROptions = {}
633
- ): Promise<ReadableStream<Uint8Array>> {
634
- const {
635
- onShellReady,
636
- onAllReady,
637
- onShellError,
638
- onStreamError,
639
- onError,
640
- onMetrics,
641
- isDev = false,
642
- routeId = "unknown",
643
- criticalData,
644
- streamTimeout,
645
- } = options;
646
-
647
- // 메트릭 수집
648
- const metrics: StreamingMetrics = {
649
- shellReadyTime: 0,
650
- allReadyTime: 0,
651
- deferredChunkCount: 0,
652
- hasError: false,
653
- startTime: Date.now(),
654
- };
655
-
656
- // criticalData 직렬화 검증 (dev에서는 throw)
657
- validateCriticalData(criticalData, isDev);
658
-
659
- // 스트리밍 주의사항 경고 (첫 요청 시 1회만)
660
- if (isDev && !(globalThis as any).__MANDU_STREAMING_WARNED__) {
661
- warnStreamingCaveats(isDev);
662
- (globalThis as any).__MANDU_STREAMING_WARNED__ = true;
663
- }
664
-
665
- const encoder = new TextEncoder();
666
- const htmlShell = generateHTMLShell(options);
667
- // _skipHtmlClose가 true이면 </body></html> 생략 (deferred 스크립트 삽입용)
668
- const htmlTail = options._skipHtmlClose
669
- ? generateHTMLTailContent(options)
670
- : generateHTMLTail(options);
671
-
672
- let shellSent = false;
673
- let timedOut = false;
674
-
675
- // React renderToReadableStream 호출
676
- // 실패 시 throw → renderStreamingResponse에서 500 처리
677
- const reactStream = await renderToReadableStream(element, {
678
- onError: (error: Error) => {
679
- if (timedOut) return;
680
-
681
- metrics.hasError = true;
682
- const streamingError: StreamingError = {
683
- error,
684
- isShellError: !shellSent,
685
- recoverable: shellSent,
686
- timestamp: Date.now(),
687
- };
688
-
689
- console.error("[Mandu Streaming] React render error:", error);
690
-
691
- if (!shellSent) {
692
- // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
693
- onShellError?.(streamingError);
694
- } else {
695
- // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
696
- onStreamError?.(streamingError);
697
- }
698
-
699
- onError?.(error);
700
- },
701
- });
702
-
703
- // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
704
- reactStream.allReady.then(() => {
705
- metrics.allReadyTime = Date.now() - metrics.startTime;
706
- if (isDev) {
707
- console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
708
- }
709
- }).catch(() => {
710
- // 에러는 onError에서 이미 처리됨
711
- });
712
-
713
- // Custom stream으로 래핑 (Shell + React Content + Tail)
714
- let tailSent = false;
715
- const reader = reactStream.getReader();
716
- const deadline = streamTimeout && streamTimeout > 0
717
- ? metrics.startTime + streamTimeout
718
- : null;
719
-
720
- async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
721
- if (!deadline) {
722
- return reader.read();
723
- }
724
-
725
- const remaining = deadline - Date.now();
726
- if (remaining <= 0) {
727
- return null;
728
- }
729
-
730
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
731
- const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => {
732
- timeoutId = setTimeout(() => resolve({ kind: "timeout" }), remaining);
733
- });
734
-
735
- const readPromise = reader
736
- .read()
737
- .then((result) => ({ kind: "read" as const, result }))
738
- .catch((error) => ({ kind: "error" as const, error }));
739
-
740
- const result = await Promise.race([readPromise, timeoutPromise]);
741
-
742
- if (result.kind === "timeout") {
743
- return null;
744
- }
745
-
746
- if (timeoutId) clearTimeout(timeoutId);
747
-
748
- if (result.kind === "error") {
749
- throw result.error;
750
- }
751
-
752
- return result.result;
753
- }
754
-
755
- return new ReadableStream<Uint8Array>({
756
- async start(controller) {
757
- // Shell 즉시 전송 (TTFB 최소화의 핵심!)
758
- controller.enqueue(encoder.encode(htmlShell));
759
- shellSent = true;
760
- metrics.shellReadyTime = Date.now() - metrics.startTime;
761
- onShellReady?.();
762
- },
763
-
764
- async pull(controller) {
765
- try {
766
- const readResult = await readWithTimeout();
767
-
768
- // 타임아웃 발생
769
- if (!readResult) {
770
- const timeoutError = new Error(`Stream timeout: exceeded ${streamTimeout}ms`);
771
- metrics.hasError = true;
772
- timedOut = true;
773
- if (isDev) {
774
- console.warn(`[Mandu Streaming] Stream timeout after ${streamTimeout}ms`);
775
- }
776
-
777
- const streamingError: StreamingError = {
778
- error: timeoutError,
779
- isShellError: false,
780
- recoverable: true,
781
- timestamp: Date.now(),
782
- };
783
- onStreamError?.(streamingError);
784
-
785
- controller.enqueue(encoder.encode(generateErrorScript(timeoutError, routeId)));
786
-
787
- if (!tailSent) {
788
- controller.enqueue(encoder.encode(htmlTail));
789
- tailSent = true;
790
- metrics.allReadyTime = Date.now() - metrics.startTime;
791
- onMetrics?.(metrics);
792
- }
793
- controller.close();
794
- try {
795
- const cancelPromise = reader.cancel();
796
- if (cancelPromise) {
797
- cancelPromise.catch(() => {});
798
- }
799
- } catch {}
800
- return;
801
- }
802
-
803
- const { done, value } = readResult;
804
-
805
- if (done) {
806
- if (!tailSent) {
807
- controller.enqueue(encoder.encode(htmlTail));
808
- tailSent = true;
809
- // allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록
810
- if (metrics.allReadyTime === 0) {
811
- metrics.allReadyTime = Date.now() - metrics.startTime;
812
- }
813
- onAllReady?.();
814
- onMetrics?.(metrics);
815
- }
816
- controller.close();
817
- return;
818
- }
819
-
820
- // React 컨텐츠를 그대로 스트리밍
821
- controller.enqueue(value);
822
- } catch (error) {
823
- const err = error instanceof Error ? error : new Error(String(error));
824
- metrics.hasError = true;
825
-
826
- console.error("[Mandu Streaming] Pull error:", err);
827
-
828
- // Shell 후 에러 - 에러 스크립트 삽입
829
- const streamingError: StreamingError = {
830
- error: err,
831
- isShellError: false,
832
- recoverable: true,
833
- timestamp: Date.now(),
834
- };
835
- onStreamError?.(streamingError);
836
-
837
- controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
838
-
839
- if (!tailSent) {
840
- controller.enqueue(encoder.encode(htmlTail));
841
- tailSent = true;
842
- metrics.allReadyTime = Date.now() - metrics.startTime;
843
- onMetrics?.(metrics);
844
- }
845
- controller.close();
846
- }
847
- },
848
-
849
- cancel() {
850
- try {
851
- const cancelPromise = reader.cancel();
852
- if (cancelPromise) {
853
- cancelPromise.catch(() => {});
854
- }
855
- } catch {}
856
- },
857
- });
858
- }
859
-
860
- /**
861
- * Streaming SSR Response 생성
862
- *
863
- * 헤더 설명:
864
- * - X-Accel-Buffering: no - nginx 버퍼링 비활성화
865
- * - Cache-Control: no-transform - 중간 프록시 변환 방지
866
- *
867
- * 주의: Transfer-Encoding은 설정하지 않음
868
- * - WHATWG Response 환경에서 런타임이 자동 처리
869
- * - 명시적 설정은 오히려 문제 될 수 있음
870
- *
871
- * 에러 정책:
872
- * - renderToReadableStream 자체가 throw (stream 생성 실패)
873
- * → 여기서 catch → 500 Response 반환 (유일한 500 케이스)
874
- * - React onError 콜백 호출 (렌더링 중 에러)
875
- * → StreamingError로 래핑 → 콜백 호출
876
- * → 스트림은 계속 진행 (부분 렌더링 or 에러 스크립트 삽입)
877
- */
878
- export async function renderStreamingResponse(
879
- element: ReactElement,
880
- options: StreamingSSROptions = {}
881
- ): Promise<Response> {
882
- try {
883
- const stream = await renderToStream(element, options);
884
-
885
- return new Response(stream, {
886
- status: 200,
887
- headers: {
888
- "Content-Type": "text/html; charset=utf-8",
889
- // Transfer-Encoding은 런타임이 자동 처리 (명시 안 함)
890
- "X-Content-Type-Options": "nosniff",
891
- // nginx 버퍼링 비활성화 힌트
892
- "X-Accel-Buffering": "no",
893
- // 캐시 및 변환 방지 (Streaming은 동적)
894
- "Cache-Control": "no-store, no-transform",
895
- // CDN 힌트
896
- "CDN-Cache-Control": "no-store",
897
- },
898
- });
899
- } catch (error) {
900
- // renderToStream에서 throw된 에러 → 500 응답 (단일 책임)
901
- const err = error instanceof Error ? error : new Error(String(error));
902
- console.error("[Mandu Streaming] Render failed:", err);
903
-
904
- // XSS 방지
905
- const safeMessage = err.message
906
- .replace(/</g, "&lt;")
907
- .replace(/>/g, "&gt;");
908
-
909
- return new Response(
910
- `<!DOCTYPE html>
911
- <html lang="ko">
912
- <head>
913
- <meta charset="UTF-8">
914
- <title>500 Server Error</title>
915
- <style>
916
- body { font-family: system-ui, sans-serif; padding: 40px; background: #f5f5f5; }
917
- .error { background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
918
- h1 { color: #e53935; margin: 0 0 16px 0; }
919
- pre { background: #f5f5f5; padding: 12px; overflow-x: auto; }
920
- </style>
921
- </head>
922
- <body>
923
- <div class="error">
924
- <h1>500 Server Error</h1>
925
- <p>렌더링 중 오류가 발생했습니다.</p>
926
- ${options.isDev ? `<pre>${safeMessage}</pre>` : ""}
927
- </div>
928
- </body>
929
- </html>`,
930
- {
931
- status: 500,
932
- headers: {
933
- "Content-Type": "text/html; charset=utf-8",
934
- },
935
- }
936
- );
937
- }
938
- }
939
-
940
- /**
941
- * Deferred 데이터와 함께 Streaming SSR 렌더링
942
- *
943
- * 핵심 원칙:
944
- * - base stream은 즉시 시작 (TTFB 최소화)
945
- * - deferred는 병렬로 처리하되 스트림을 막지 않음
946
- * - 준비된 deferred만 tail 이후에 스크립트로 주입
947
- */
948
- export async function renderWithDeferredData(
949
- element: ReactElement,
950
- options: StreamingSSROptions & {
951
- deferredPromises?: Record<string, Promise<unknown>>;
952
- /** Deferred 타임아웃 (ms) - 이 시간 안에 resolve되지 않으면 포기 */
953
- deferredTimeout?: number;
954
- }
955
- ): Promise<Response> {
956
- const {
957
- deferredPromises = {},
958
- deferredTimeout = 5000,
959
- routeId = "default",
960
- onMetrics,
961
- isDev = false,
962
- ...restOptions
963
- } = options;
964
- const streamTimeout = options.streamTimeout;
965
-
966
- const encoder = new TextEncoder();
967
- const startTime = Date.now();
968
-
969
- // 준비된 deferred 스크립트를 담을 배열 (mutable)
970
- const readyScripts: string[] = [];
971
- let deferredChunkCount = 0;
972
- let allDeferredSettled = false;
973
-
974
- // 1. Deferred promises 병렬 시작 (막지 않음!)
975
- const deferredEntries = Object.entries(deferredPromises);
976
- const deferredSettledPromise = deferredEntries.length > 0
977
- ? Promise.allSettled(
978
- deferredEntries.map(async ([key, promise]) => {
979
- try {
980
- // 타임아웃 적용
981
- const timeoutPromise = new Promise<never>((_, reject) =>
982
- setTimeout(() => reject(new Error(`Deferred timeout: ${key}`)), deferredTimeout)
983
- );
984
- const data = await Promise.race([promise, timeoutPromise]);
985
-
986
- // 스크립트 생성 및 추가
987
- const script = generateDeferredDataScript(routeId, key, data);
988
- readyScripts.push(script);
989
- deferredChunkCount++;
990
-
991
- if (isDev) {
992
- console.log(`[Mandu Streaming] Deferred ready: ${key} (${Date.now() - startTime}ms)`);
993
- }
994
- } catch (error) {
995
- console.error(`[Mandu Streaming] Deferred error for ${key}:`, error);
996
- }
997
- })
998
- ).then(() => {
999
- allDeferredSettled = true;
1000
- })
1001
- : Promise.resolve().then(() => { allDeferredSettled = true; });
1002
-
1003
- // 2. Base stream 즉시 시작 (TTFB 최소화의 핵심!)
1004
- // _skipHtmlClose: true로 </body></html> 생략 → deferred 스크립트 삽입 지점 확보
1005
- let baseMetrics: StreamingMetrics | null = null;
1006
- const baseStream = await renderToStream(element, {
1007
- ...restOptions,
1008
- routeId,
1009
- isDev,
1010
- _skipHtmlClose: true, // deferred 스크립트를 </body> 전에 삽입하기 위해
1011
- onMetrics: (metrics) => {
1012
- baseMetrics = metrics;
1013
- },
1014
- });
1015
-
1016
- // 3. 수동 스트림 파이프라인 (Bun pipeThrough 호환성 문제 해결)
1017
- // base stream을 읽고 → 변환 후 → 새 스트림으로 출력
1018
- const reader = baseStream.getReader();
1019
-
1020
- const finalStream = new ReadableStream<Uint8Array>({
1021
- async pull(controller) {
1022
- try {
1023
- const { done, value } = await reader.read();
1024
-
1025
- if (!done && value) {
1026
- // base stream chunk 그대로 전달
1027
- controller.enqueue(value);
1028
- return;
1029
- }
1030
-
1031
- // base stream 완료 → flush 로직 실행
1032
- // deferred가 아직 안 끝났으면 잠시 대기 (단, deferredTimeout 내에서만)
1033
- if (!allDeferredSettled) {
1034
- const elapsed = Date.now() - startTime;
1035
- let remainingTime = deferredTimeout - elapsed;
1036
- if (streamTimeout && streamTimeout > 0) {
1037
- const remainingStream = streamTimeout - elapsed;
1038
- remainingTime = Math.min(remainingTime, remainingStream);
1039
- }
1040
- remainingTime = Math.max(0, remainingTime);
1041
- if (remainingTime > 0) {
1042
- await Promise.race([
1043
- deferredSettledPromise,
1044
- new Promise(resolve => setTimeout(resolve, remainingTime)),
1045
- ]);
1046
- }
1047
- }
1048
-
1049
- // 준비된 deferred 스크립트만 주입 (실제 enqueue 기준 카운트)
1050
- let injectedCount = 0;
1051
- for (const script of readyScripts) {
1052
- controller.enqueue(encoder.encode(script));
1053
- injectedCount++;
1054
- }
1055
-
1056
- if (isDev && injectedCount > 0) {
1057
- console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
1058
- }
1059
-
1060
- // HTML 닫기 태그 추가 (</body></html>)
1061
- controller.enqueue(encoder.encode(generateHTMLClose()));
1062
-
1063
- // 최종 메트릭 보고 (injectedCount가 실제 메트릭)
1064
- if (onMetrics && baseMetrics) {
1065
- onMetrics({
1066
- ...baseMetrics,
1067
- deferredChunkCount: injectedCount,
1068
- allReadyTime: Date.now() - startTime,
1069
- });
1070
- }
1071
-
1072
- controller.close();
1073
- } catch (error) {
1074
- controller.error(error);
1075
- }
1076
- },
1077
- cancel() {
1078
- reader.cancel();
1079
- },
1080
- });
1081
-
1082
- return new Response(finalStream, {
1083
- status: 200,
1084
- headers: {
1085
- "Content-Type": "text/html; charset=utf-8",
1086
- "X-Content-Type-Options": "nosniff",
1087
- "X-Accel-Buffering": "no",
1088
- "Cache-Control": "no-store, no-transform",
1089
- "CDN-Cache-Control": "no-store",
1090
- },
1091
- });
1092
- }
1093
-
1094
- // ========== Loader Helpers ==========
1095
-
1096
- /**
1097
- * Streaming Loader 헬퍼
1098
- * Critical과 Deferred 데이터를 분리하여 반환
1099
- *
1100
- * @example
1101
- * ```typescript
1102
- * export const loader = createStreamingLoader(async (ctx) => {
1103
- * return {
1104
- * critical: await getEssentialData(ctx),
1105
- * deferred: fetchOptionalData(ctx), // Promise 그대로 전달
1106
- * };
1107
- * });
1108
- * ```
1109
- */
1110
- export function createStreamingLoader<TCritical, TDeferred>(
1111
- loaderFn: (ctx: unknown) => Promise<StreamingLoaderResult<{ critical: TCritical; deferred: TDeferred }>>
1112
- ) {
1113
- return async (ctx: unknown) => {
1114
- const result = await loaderFn(ctx);
1115
- return {
1116
- critical: result.critical,
1117
- deferred: result.deferred,
1118
- };
1119
- };
1120
- }
1121
-
1122
- /**
1123
- * Deferred 데이터 프라미스 래퍼
1124
- * Streaming 중 데이터 준비되면 클라이언트로 전송
1125
- */
1126
- export function defer<T>(promise: Promise<T>): Promise<T> {
1127
- return promise;
1128
- }
1129
-
1130
- // ========== SEO Integration ==========
1131
-
1132
- /**
1133
- * SEO 메타데이터와 함께 Streaming SSR 렌더링
1134
- *
1135
- * Layout 체인에서 메타데이터를 자동으로 수집하고 병합하여
1136
- * HTML head에 삽입합니다.
1137
- *
1138
- * @example
1139
- * ```typescript
1140
- * // 정적 메타데이터
1141
- * const response = await renderWithSEO(<Page />, {
1142
- * metadata: {
1143
- * title: 'Home',
1144
- * description: 'Welcome to my site',
1145
- * openGraph: { type: 'website' },
1146
- * },
1147
- * })
1148
- *
1149
- * // Layout 체인 메타데이터
1150
- * const response = await renderWithSEO(<Page />, {
1151
- * metadata: [
1152
- * layoutMetadata, // { title: { template: '%s | Site' } }
1153
- * pageMetadata, // { title: 'Blog Post' }
1154
- * ],
1155
- * routeParams: { slug: 'hello' },
1156
- * })
1157
- * // → title: "Blog Post | Site"
1158
- * ```
1159
- */
1160
- export async function renderWithSEO(
1161
- element: ReactElement,
1162
- options: StreamingSSROptions = {}
1163
- ): Promise<Response> {
1164
- const { metadata, routeParams, searchParams, ...restOptions } = options;
1165
-
1166
- // SEO 메타데이터 처리
1167
- if (metadata) {
1168
- const seoOptions: SEOOptions = {
1169
- routeParams,
1170
- searchParams,
1171
- };
1172
-
1173
- // 배열이면 Layout 체인, 아니면 단일 메타데이터
1174
- if (Array.isArray(metadata)) {
1175
- seoOptions.metadata = metadata;
1176
- } else {
1177
- seoOptions.staticMetadata = metadata as Metadata;
1178
- }
1179
-
1180
- // SEO를 옵션에 주입
1181
- const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions);
1182
- return renderStreamingResponse(element, optionsWithSEO);
1183
- }
1184
-
1185
- // SEO 없이 기본 렌더링
1186
- return renderStreamingResponse(element, restOptions);
1187
- }
1188
-
1189
- /**
1190
- * Deferred 데이터 + SEO 메타데이터와 함께 Streaming SSR 렌더링
1191
- *
1192
- * @example
1193
- * ```typescript
1194
- * const response = await renderWithDeferredDataAndSEO(<Page />, {
1195
- * metadata: {
1196
- * title: post.title,
1197
- * openGraph: { images: [post.image] },
1198
- * },
1199
- * deferredPromises: {
1200
- * comments: fetchComments(postId),
1201
- * related: fetchRelatedPosts(postId),
1202
- * },
1203
- * })
1204
- * ```
1205
- */
1206
- export async function renderWithDeferredDataAndSEO(
1207
- element: ReactElement,
1208
- options: StreamingSSROptions & {
1209
- deferredPromises?: Record<string, Promise<unknown>>;
1210
- deferredTimeout?: number;
1211
- } = {}
1212
- ): Promise<Response> {
1213
- const { metadata, routeParams, searchParams, ...restOptions } = options;
1214
-
1215
- // SEO 메타데이터 처리
1216
- if (metadata) {
1217
- const seoOptions: SEOOptions = {
1218
- routeParams,
1219
- searchParams,
1220
- };
1221
-
1222
- if (Array.isArray(metadata)) {
1223
- seoOptions.metadata = metadata;
1224
- } else {
1225
- seoOptions.staticMetadata = metadata as Metadata;
1226
- }
1227
-
1228
- const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions);
1229
- return renderWithDeferredData(element, optionsWithSEO);
1230
- }
1231
-
1232
- return renderWithDeferredData(element, restOptions);
1233
- }
1234
-
1235
- // ========== Exports ==========
1236
-
1237
- export {
1238
- generateHTMLShell,
1239
- generateHTMLTail,
1240
- generateDeferredDataScript,
1241
- };
1242
-
1243
- // Re-export SEO integration utilities
1244
- export { resolveSEO, injectSEOIntoOptions } from "../seo/integration/ssr";
1245
- export type { SEOOptions, SEOResult } from "../seo/integration/ssr";
1
+ /**
2
+ * Mandu Streaming SSR
3
+ * React 18 renderToReadableStream 기반 점진적 HTML 스트리밍
4
+ *
5
+ * 특징:
6
+ * - TTFB 최소화 (Shell 즉시 전송)
7
+ * - Suspense 경계에서 fallback → 실제 컨텐츠 스트리밍
8
+ * - Critical/Deferred 데이터 분리
9
+ * - Island Architecture와 완벽 통합
10
+ */
11
+
12
+ // Bun에서는 react-dom/server.browser에서 renderToReadableStream을 가져옴
13
+ // Node.js 환경에서는 renderToPipeableStream 사용 필요
14
+ import { renderToReadableStream } from "react-dom/server.browser";
15
+ import type { ReactElement, ReactNode } from "react";
16
+ import React, { Suspense } from "react";
17
+ import type { BundleManifest } from "../bundler/types";
18
+ import type { HydrationConfig, HydrationPriority } from "../spec/schema";
19
+ import { serializeProps } from "../client/serialize";
20
+ import type { Metadata, MetadataItem } from "../seo/types";
21
+ import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
22
+ import { PORTS, TIMEOUTS } from "../constants";
23
+
24
+ // ========== Types ==========
25
+
26
+ /**
27
+ * Streaming SSR 에러 타입
28
+ *
29
+ * 에러 정책 (Error Policy):
30
+ * 1. Stream 생성 실패 (renderToReadableStream throws)
31
+ * → renderStreamingResponse에서 catch → 500 Response 반환
32
+ * → 이 경우 StreamingError는 생성되지 않음
33
+ *
34
+ * 2. Shell 전 React 렌더링 에러 (onError called, shellSent=false)
35
+ * → isShellError: true, recoverable: false
36
+ * → onShellError 콜백 호출
37
+ * → 스트림은 계속 진행 (빈 컨텐츠 or 부분 렌더링)
38
+ *
39
+ * 3. Shell 후 스트리밍 에러 (onError called, shellSent=true)
40
+ * → isShellError: false, recoverable: true
41
+ * → onStreamError 콜백 호출
42
+ * → 에러 스크립트가 HTML에 삽입됨
43
+ */
44
+ export interface StreamingError {
45
+ error: Error;
46
+ /**
47
+ * Shell 전송 전 에러인지 여부
48
+ * - true: React 초기 렌더링 중 에러 (Shell 전송 전)
49
+ * - false: 스트리밍 중 에러 (Shell 이미 전송됨)
50
+ */
51
+ isShellError: boolean;
52
+ /**
53
+ * 복구 가능 여부
54
+ * - true: Shell 이후 에러 - 에러 스크립트 삽입으로 클라이언트 알림
55
+ * - false: Shell 전 에러 - 사용자에게 불완전한 UI 표시될 수 있음
56
+ */
57
+ recoverable: boolean;
58
+ /** 타임스탬프 */
59
+ timestamp: number;
60
+ }
61
+
62
+ /**
63
+ * Streaming SSR 메트릭
64
+ */
65
+ export interface StreamingMetrics {
66
+ /** Shell ready까지 걸린 시간 (ms) */
67
+ shellReadyTime: number;
68
+ /** All ready까지 걸린 시간 (ms) */
69
+ allReadyTime: number;
70
+ /** Deferred chunk 개수 */
71
+ deferredChunkCount: number;
72
+ /** 에러 발생 여부 */
73
+ hasError: boolean;
74
+ /** 시작 시간 */
75
+ startTime: number;
76
+ }
77
+
78
+ export interface StreamingSSROptions {
79
+ /** 페이지 타이틀 (SEO metadata 사용 시 자동 설정됨) */
80
+ title?: string;
81
+ /** HTML lang 속성 */
82
+ lang?: string;
83
+ /** 라우트 ID */
84
+ routeId?: string;
85
+ /** 라우트 패턴 */
86
+ routePattern?: string;
87
+ /** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
88
+ criticalData?: Record<string, unknown>;
89
+ // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
90
+ /** Hydration 설정 */
91
+ hydration?: HydrationConfig;
92
+ /** 번들 매니페스트 */
93
+ bundleManifest?: BundleManifest;
94
+ /** 추가 head 태그 (SEO metadata와 병합됨) */
95
+ headTags?: string;
96
+ /**
97
+ * SEO 메타데이터 (Layout 체인 또는 단일 객체)
98
+ * - 배열: [rootLayout, ...nestedLayouts, page] 순서로 병합
99
+ * - 객체: 단일 정적 메타데이터
100
+ */
101
+ metadata?: MetadataItem[] | Metadata;
102
+ /** 라우트 파라미터 (동적 메타데이터용) */
103
+ routeParams?: Record<string, string>;
104
+ /** 쿼리 파라미터 (동적 메타데이터용) */
105
+ searchParams?: Record<string, string>;
106
+ /** 개발 모드 여부 */
107
+ isDev?: boolean;
108
+ /** HMR 포트 */
109
+ hmrPort?: number;
110
+ /** Client-side Router 활성화 */
111
+ enableClientRouter?: boolean;
112
+ /** Streaming 타임아웃 (ms) - 전체 스트림 최대 시간 */
113
+ streamTimeout?: number;
114
+ /** Shell 렌더링 후 콜백 (TTFB 측정 시점) */
115
+ onShellReady?: () => void;
116
+ /** 모든 컨텐츠 렌더링 후 콜백 */
117
+ onAllReady?: () => void;
118
+ /**
119
+ * Shell 전 에러 콜백
120
+ * - React 초기 렌더링 중 에러 발생 시 호출
121
+ * - 이 시점에서는 이미 스트림이 시작됨 (500 반환 불가)
122
+ * - 로깅/모니터링 용도
123
+ */
124
+ onShellError?: (error: StreamingError) => void;
125
+ /**
126
+ * 스트리밍 중 에러 콜백
127
+ * - Shell 전송 후 에러 발생 시 호출
128
+ * - 에러 스크립트가 HTML에 자동 삽입됨
129
+ * - 클라이언트에서 mandu:streaming-error 이벤트로 감지 가능
130
+ */
131
+ onStreamError?: (error: StreamingError) => void;
132
+ /** 에러 콜백 (deprecated - onShellError/onStreamError 사용 권장) */
133
+ onError?: (error: Error) => void;
134
+ /** 메트릭 콜백 (observability) */
135
+ onMetrics?: (metrics: StreamingMetrics) => void;
136
+ /**
137
+ * HTML 닫기 태그 생략 여부 (내부용)
138
+ * true이면 </body></html>을 생략하여 deferred 스크립트 삽입 지점 확보
139
+ */
140
+ _skipHtmlClose?: boolean;
141
+ /** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */
142
+ cssPath?: string | false;
143
+ }
144
+
145
+ export interface StreamingLoaderResult<T = unknown> {
146
+ /** 즉시 로드할 Critical 데이터 */
147
+ critical?: T;
148
+ /** 지연 로드할 Deferred 데이터 (Promise) */
149
+ deferred?: Promise<T>;
150
+ }
151
+
152
+ // ========== Serialization Guards ==========
153
+
154
+ /**
155
+ * 값이 JSON-serializable인지 검증
156
+ * Date, Map, Set, BigInt 등은 serializeProps에서 처리되지만
157
+ * 함수, Symbol, undefined는 문제가 됨
158
+ */
159
+ function isJSONSerializable(value: unknown, path: string = "root", isDev: boolean = false): { valid: boolean; issues: string[] } {
160
+ const issues: string[] = [];
161
+
162
+ function check(val: unknown, currentPath: string): void {
163
+ if (val === undefined) {
164
+ issues.push(`${currentPath}: undefined는 JSON으로 직렬화할 수 없습니다`);
165
+ return;
166
+ }
167
+
168
+ if (val === null) return;
169
+
170
+ const type = typeof val;
171
+
172
+ if (type === "function") {
173
+ issues.push(`${currentPath}: function은 JSON으로 직렬화할 수 없습니다`);
174
+ return;
175
+ }
176
+
177
+ if (type === "symbol") {
178
+ issues.push(`${currentPath}: symbol은 JSON으로 직렬화할 수 없습니다`);
179
+ return;
180
+ }
181
+
182
+ if (type === "bigint") {
183
+ // serializeProps에서 처리됨 - 경고만
184
+ if (isDev) {
185
+ console.warn(`[Mandu Streaming] ${currentPath}: BigInt가 감지됨 - 문자열로 변환됩니다`);
186
+ }
187
+ return;
188
+ }
189
+
190
+ if (val instanceof Date || val instanceof Map || val instanceof Set || val instanceof URL || val instanceof RegExp) {
191
+ // serializeProps에서 처리됨
192
+ return;
193
+ }
194
+
195
+ if (Array.isArray(val)) {
196
+ val.forEach((item, index) => check(item, `${currentPath}[${index}]`));
197
+ return;
198
+ }
199
+
200
+ if (type === "object") {
201
+ for (const [key, v] of Object.entries(val as Record<string, unknown>)) {
202
+ check(v, `${currentPath}.${key}`);
203
+ }
204
+ return;
205
+ }
206
+
207
+ // string, number, boolean은 OK
208
+ }
209
+
210
+ check(value, path);
211
+
212
+ return {
213
+ valid: issues.length === 0,
214
+ issues,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * criticalData 검증 및 경고
220
+ * 개발 모드에서는 throw, 프로덕션에서는 경고만
221
+ */
222
+ function validateCriticalData(data: Record<string, unknown> | undefined, isDev: boolean): void {
223
+ if (!data) return;
224
+
225
+ const result = isJSONSerializable(data, "criticalData", isDev);
226
+
227
+ if (!result.valid) {
228
+ const message = `[Mandu Streaming] criticalData 직렬화 문제:\n${result.issues.join("\n")}`;
229
+
230
+ if (isDev) {
231
+ throw new Error(message);
232
+ } else {
233
+ console.error(message);
234
+ }
235
+ }
236
+ }
237
+
238
+ // ========== Streaming Warnings ==========
239
+
240
+ /**
241
+ * 프록시/버퍼링 관련 경고 (개발 모드)
242
+ */
243
+ function warnStreamingCaveats(isDev: boolean): void {
244
+ if (!isDev) return;
245
+
246
+ console.log(`[Mandu Streaming] 💡 Streaming SSR 주의사항:
247
+ - nginx/cloudflare 등 reverse proxy 사용 시 버퍼링 비활성화 필요
248
+ (nginx: proxy_buffering off; X-Accel-Buffering: no)
249
+ - compression 미들웨어가 chunk를 모으면 스트리밍 이점 사라짐
250
+ - Transfer-Encoding: chunked 헤더가 유지되어야 함`);
251
+ }
252
+
253
+ // ========== Error HTML Generation ==========
254
+
255
+ /**
256
+ * 스트리밍 중 에러 시 삽입할 에러 스크립트 생성
257
+ * Shell 이후 에러는 이 방식으로 클라이언트에 전달
258
+ */
259
+ function generateErrorScript(error: Error, routeId: string): string {
260
+ const safeMessage = error.message
261
+ .replace(/\\/g, "\\\\") // 백슬래시 먼저 (다른 이스케이프에 영향)
262
+ .replace(/\n/g, "\\n") // 줄바꿈
263
+ .replace(/\r/g, "\\r") // 캐리지 리턴
264
+ .replace(/</g, "\\u003c") // XSS 방지
265
+ .replace(/>/g, "\\u003e")
266
+ .replace(/"/g, "\\u0022");
267
+
268
+ return `<script>
269
+ (function() {
270
+ window.__MANDU_STREAMING_ERROR__ = {
271
+ routeId: "${routeId}",
272
+ message: "${safeMessage}",
273
+ timestamp: ${Date.now()}
274
+ };
275
+ console.error("[Mandu Streaming] 렌더링 중 에러:", "${safeMessage}");
276
+ window.dispatchEvent(new CustomEvent('mandu:streaming-error', {
277
+ detail: window.__MANDU_STREAMING_ERROR__
278
+ }));
279
+ })();
280
+ </script>`;
281
+ }
282
+
283
+ // ========== Suspense Wrappers ==========
284
+
285
+ /**
286
+ * Island를 Suspense로 감싸는 래퍼
287
+ * Streaming SSR에서 Island별 점진적 렌더링 지원
288
+ */
289
+ export function SuspenseIsland({
290
+ children,
291
+ fallback,
292
+ routeId,
293
+ priority = "visible",
294
+ bundleSrc,
295
+ }: {
296
+ children: ReactNode;
297
+ fallback?: ReactNode;
298
+ routeId: string;
299
+ priority?: HydrationPriority;
300
+ bundleSrc?: string;
301
+ }): ReactElement {
302
+ const defaultFallback = React.createElement("div", {
303
+ "data-mandu-island": routeId,
304
+ "data-mandu-priority": priority,
305
+ "data-mandu-src": bundleSrc,
306
+ "data-mandu-loading": "true",
307
+ style: { minHeight: "50px" },
308
+ }, React.createElement("div", {
309
+ className: "mandu-loading-skeleton",
310
+ style: {
311
+ background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)",
312
+ backgroundSize: "200% 100%",
313
+ animation: "mandu-shimmer 1.5s infinite",
314
+ height: "100%",
315
+ minHeight: "50px",
316
+ borderRadius: "4px",
317
+ },
318
+ }));
319
+
320
+ return React.createElement(
321
+ Suspense,
322
+ { fallback: fallback || defaultFallback },
323
+ React.createElement("div", {
324
+ "data-mandu-island": routeId,
325
+ "data-mandu-priority": priority,
326
+ "data-mandu-src": bundleSrc,
327
+ }, children)
328
+ );
329
+ }
330
+
331
+ /**
332
+ * Deferred 데이터를 위한 Suspense 컴포넌트
333
+ * 데이터가 준비되면 children 렌더링
334
+ */
335
+ export function DeferredData<T>({
336
+ promise,
337
+ children,
338
+ fallback,
339
+ }: {
340
+ promise: Promise<T>;
341
+ children: (data: T) => ReactNode;
342
+ fallback?: ReactNode;
343
+ }): ReactElement {
344
+ // React 18 use() 훅 대신 Suspense + throw promise 패턴 사용
345
+ const AsyncComponent = React.lazy(async () => {
346
+ const data = await promise;
347
+ return {
348
+ default: () => React.createElement(React.Fragment, null, children(data)),
349
+ };
350
+ });
351
+
352
+ return React.createElement(
353
+ Suspense,
354
+ { fallback: fallback || React.createElement("span", null, "Loading...") },
355
+ React.createElement(AsyncComponent, null)
356
+ );
357
+ }
358
+
359
+ // ========== HTML Generation ==========
360
+
361
+ /**
362
+ * Streaming용 HTML Shell 생성 (<!DOCTYPE> ~ <div id="root">)
363
+ */
364
+ function generateHTMLShell(options: StreamingSSROptions): string {
365
+ const {
366
+ title = "Mandu App",
367
+ lang = "ko",
368
+ headTags = "",
369
+ bundleManifest,
370
+ routeId,
371
+ hydration,
372
+ cssPath,
373
+ isDev = false,
374
+ } = options;
375
+
376
+ // CSS 링크 태그 생성
377
+ // - cssPath가 string이면 해당 경로 사용
378
+ // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
379
+ const cssLinkTag = cssPath && cssPath !== false
380
+ ? `<link rel="stylesheet" href="${cssPath}${isDev ? `?t=${Date.now()}` : ""}">`
381
+ : "";
382
+
383
+ // Import map (module scripts 전에 위치해야 함)
384
+ let importMapScript = "";
385
+ if (bundleManifest?.importMap && Object.keys(bundleManifest.importMap.imports).length > 0) {
386
+ const importMapJson = JSON.stringify(bundleManifest.importMap, null, 2);
387
+ importMapScript = `<script type="importmap">${importMapJson}</script>`;
388
+ }
389
+
390
+ // Loading skeleton 애니메이션 스타일
391
+ const loadingStyles = `
392
+ <style>
393
+ @keyframes mandu-shimmer {
394
+ 0% { background-position: 200% 0; }
395
+ 100% { background-position: -200% 0; }
396
+ }
397
+ .mandu-loading-skeleton {
398
+ background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
399
+ background-size: 200% 100%;
400
+ animation: mandu-shimmer 1.5s infinite;
401
+ }
402
+ .mandu-stream-pending {
403
+ opacity: 0;
404
+ transition: opacity 0.3s ease-in;
405
+ }
406
+ .mandu-stream-ready {
407
+ opacity: 1;
408
+ }
409
+ </style>`;
410
+
411
+ // Island wrapper (hydration이 필요한 경우)
412
+ const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
413
+ let islandOpenTag = "";
414
+ if (needsHydration) {
415
+ const bundle = bundleManifest.bundles[routeId];
416
+ const bundleSrc = bundle?.js || "";
417
+ const priority = hydration.priority || "visible";
418
+ islandOpenTag = `<div data-mandu-island="${routeId}" data-mandu-src="${bundleSrc}" data-mandu-priority="${priority}">`;
419
+ }
420
+
421
+ // Import map은 module 스크립트보다 먼저 정의되어야 bare specifier 해석 가능
422
+ return `<!DOCTYPE html>
423
+ <html lang="${lang}">
424
+ <head>
425
+ <meta charset="UTF-8">
426
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
427
+ <title>${title}</title>
428
+ ${cssLinkTag}
429
+ ${loadingStyles}
430
+ ${importMapScript}
431
+ ${headTags}
432
+ </head>
433
+ <body>
434
+ <div id="root">${islandOpenTag}`;
435
+ }
436
+
437
+ /**
438
+ * Streaming용 HTML Tail 스크립트 생성 (</div id="root"> ~ 스크립트들)
439
+ * `</body></html>`은 포함하지 않음 - deferred 스크립트 삽입 지점 확보
440
+ */
441
+ function generateHTMLTailContent(options: StreamingSSROptions): string {
442
+ const {
443
+ routeId,
444
+ routePattern,
445
+ criticalData,
446
+ bundleManifest,
447
+ isDev = false,
448
+ hmrPort,
449
+ enableClientRouter = false,
450
+ hydration,
451
+ } = options;
452
+
453
+ const scripts: string[] = [];
454
+
455
+ // 1. Critical 데이터 스크립트 (즉시 사용 가능)
456
+ if (criticalData && routeId) {
457
+ const wrappedData = {
458
+ [routeId]: {
459
+ serverData: criticalData,
460
+ timestamp: Date.now(),
461
+ streaming: true,
462
+ },
463
+ };
464
+ const json = serializeProps(wrappedData)
465
+ .replace(/</g, "\\u003c")
466
+ .replace(/>/g, "\\u003e")
467
+ .replace(/&/g, "\\u0026");
468
+ scripts.push(`<script id="__MANDU_DATA__" type="application/json">${json}</script>`);
469
+ scripts.push(`<script>window.__MANDU_DATA_RAW__ = document.getElementById('__MANDU_DATA__').textContent;</script>`);
470
+ }
471
+
472
+ // 2. 라우트 정보 스크립트
473
+ if (enableClientRouter && routeId) {
474
+ const routeInfo = {
475
+ id: routeId,
476
+ pattern: routePattern || "",
477
+ params: {},
478
+ streaming: true,
479
+ };
480
+ const json = JSON.stringify(routeInfo)
481
+ .replace(/</g, "\\u003c")
482
+ .replace(/>/g, "\\u003e");
483
+ scripts.push(`<script>window.__MANDU_ROUTE__ = ${json};</script>`);
484
+ }
485
+
486
+ // 3. Streaming 완료 마커 (클라이언트에서 감지용)
487
+ scripts.push(`<script>window.__MANDU_STREAMING_SHELL_READY__ = true;</script>`);
488
+
489
+ // 4. Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화)
490
+ if (bundleManifest?.shared.vendor) {
491
+ scripts.push(`<link rel="modulepreload" href="${bundleManifest.shared.vendor}">`);
492
+ }
493
+ if (bundleManifest?.importMap?.imports) {
494
+ const imports = bundleManifest.importMap.imports;
495
+ if (imports["react-dom"] && imports["react-dom"] !== bundleManifest.shared.vendor) {
496
+ scripts.push(`<link rel="modulepreload" href="${imports["react-dom"]}">`);
497
+ }
498
+ if (imports["react-dom/client"]) {
499
+ scripts.push(`<link rel="modulepreload" href="${imports["react-dom/client"]}">`);
500
+ }
501
+ }
502
+
503
+ // 5. Runtime modulepreload (hydration 실행 전 미리 로드)
504
+ if (bundleManifest?.shared.runtime) {
505
+ scripts.push(`<link rel="modulepreload" href="${bundleManifest.shared.runtime}">`);
506
+ }
507
+
508
+ // 6. Island modulepreload
509
+ if (bundleManifest && routeId) {
510
+ const bundle = bundleManifest.bundles[routeId];
511
+ if (bundle) {
512
+ scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
513
+ }
514
+ }
515
+
516
+ // 7. Runtime 로드
517
+ if (bundleManifest?.shared.runtime) {
518
+ scripts.push(`<script type="module" src="${bundleManifest.shared.runtime}"></script>`);
519
+ }
520
+
521
+ // 8. Router 스크립트
522
+ if (enableClientRouter && bundleManifest?.shared?.router) {
523
+ scripts.push(`<script type="module" src="${bundleManifest.shared.router}"></script>`);
524
+ }
525
+
526
+ // 9. HMR 스크립트 (개발 모드)
527
+ if (isDev && hmrPort) {
528
+ scripts.push(generateHMRScript(hmrPort));
529
+ }
530
+
531
+ // Island wrapper 닫기 (hydration이 필요한 경우)
532
+ const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
533
+ const islandCloseTag = needsHydration ? "</div>" : "";
534
+
535
+ return `${islandCloseTag}</div>
536
+ ${scripts.join("\n ")}`;
537
+ }
538
+
539
+ /**
540
+ * HTML 문서 닫기 태그
541
+ * Deferred 스크립트 삽입 후 호출
542
+ */
543
+ function generateHTMLClose(): string {
544
+ return `
545
+ </body>
546
+ </html>`;
547
+ }
548
+
549
+ /**
550
+ * Streaming용 HTML Tail 생성 (</div id="root"> ~ </html>)
551
+ * 하위 호환성 유지 - 내부적으로 generateHTMLTailContent + generateHTMLClose 사용
552
+ */
553
+ function generateHTMLTail(options: StreamingSSROptions): string {
554
+ return generateHTMLTailContent(options) + generateHTMLClose();
555
+ }
556
+
557
+ /**
558
+ * Deferred 데이터 인라인 스크립트 생성
559
+ * Streaming 중에 데이터 도착 시 DOM에 주입
560
+ */
561
+ function generateDeferredDataScript(routeId: string, key: string, data: unknown): string {
562
+ const json = serializeProps({ [key]: data })
563
+ .replace(/</g, "\\u003c")
564
+ .replace(/>/g, "\\u003e");
565
+
566
+ return `<script>
567
+ (function() {
568
+ window.__MANDU_DEFERRED__ = window.__MANDU_DEFERRED__ || {};
569
+ window.__MANDU_DEFERRED__["${routeId}"] = window.__MANDU_DEFERRED__["${routeId}"] || {};
570
+ Object.assign(window.__MANDU_DEFERRED__["${routeId}"], ${json});
571
+ window.dispatchEvent(new CustomEvent('mandu:deferred-data', { detail: { routeId: "${routeId}", key: "${key}" } }));
572
+ })();
573
+ </script>`;
574
+ }
575
+
576
+ /**
577
+ * HMR 스크립트 생성
578
+ */
579
+ function generateHMRScript(port: number): string {
580
+ const hmrPort = port + PORTS.HMR_OFFSET;
581
+ return `<script>
582
+ (function() {
583
+ var ws = null;
584
+ var reconnectAttempts = 0;
585
+ var maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
586
+
587
+ function connect() {
588
+ try {
589
+ ws = new WebSocket('ws://localhost:${hmrPort}');
590
+ ws.onopen = function() {
591
+ console.log('[Mandu HMR] Connected');
592
+ reconnectAttempts = 0;
593
+ };
594
+ ws.onmessage = function(e) {
595
+ try {
596
+ var msg = JSON.parse(e.data);
597
+ if (msg.type === 'reload' || msg.type === 'island-update') {
598
+ console.log('[Mandu HMR] Reloading...');
599
+ location.reload();
600
+ }
601
+ } catch(err) {}
602
+ };
603
+ ws.onclose = function() {
604
+ if (reconnectAttempts < maxReconnectAttempts) {
605
+ reconnectAttempts++;
606
+ setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY} * reconnectAttempts);
607
+ }
608
+ };
609
+ } catch(err) {
610
+ setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY});
611
+ }
612
+ }
613
+ connect();
614
+ })();
615
+ </script>`;
616
+ }
617
+
618
+ // ========== Main Streaming Functions ==========
619
+
620
+ /**
621
+ * React 컴포넌트를 ReadableStream으로 렌더링
622
+ * Bun/Web Streams API 기반
623
+ *
624
+ * 핵심 원칙:
625
+ * - Shell은 즉시 전송 (TTFB 최소화)
626
+ * - allReady는 메트릭용으로만 사용 (대기 안 함)
627
+ * - Shell 전 에러는 throw → Response 레이어에서 500 처리
628
+ * - Shell 후 에러는 에러 스크립트 삽입
629
+ */
630
+ export async function renderToStream(
631
+ element: ReactElement,
632
+ options: StreamingSSROptions = {}
633
+ ): Promise<ReadableStream<Uint8Array>> {
634
+ const {
635
+ onShellReady,
636
+ onAllReady,
637
+ onShellError,
638
+ onStreamError,
639
+ onError,
640
+ onMetrics,
641
+ isDev = false,
642
+ routeId = "unknown",
643
+ criticalData,
644
+ streamTimeout,
645
+ } = options;
646
+
647
+ // 메트릭 수집
648
+ const metrics: StreamingMetrics = {
649
+ shellReadyTime: 0,
650
+ allReadyTime: 0,
651
+ deferredChunkCount: 0,
652
+ hasError: false,
653
+ startTime: Date.now(),
654
+ };
655
+
656
+ // criticalData 직렬화 검증 (dev에서는 throw)
657
+ validateCriticalData(criticalData, isDev);
658
+
659
+ // 스트리밍 주의사항 경고 (첫 요청 시 1회만)
660
+ if (isDev && !(globalThis as any).__MANDU_STREAMING_WARNED__) {
661
+ warnStreamingCaveats(isDev);
662
+ (globalThis as any).__MANDU_STREAMING_WARNED__ = true;
663
+ }
664
+
665
+ const encoder = new TextEncoder();
666
+ const htmlShell = generateHTMLShell(options);
667
+ // _skipHtmlClose가 true이면 </body></html> 생략 (deferred 스크립트 삽입용)
668
+ const htmlTail = options._skipHtmlClose
669
+ ? generateHTMLTailContent(options)
670
+ : generateHTMLTail(options);
671
+
672
+ let shellSent = false;
673
+ let timedOut = false;
674
+
675
+ // React renderToReadableStream 호출
676
+ // 실패 시 throw → renderStreamingResponse에서 500 처리
677
+ const reactStream = await renderToReadableStream(element, {
678
+ onError: (error: Error) => {
679
+ if (timedOut) return;
680
+
681
+ metrics.hasError = true;
682
+ const streamingError: StreamingError = {
683
+ error,
684
+ isShellError: !shellSent,
685
+ recoverable: shellSent,
686
+ timestamp: Date.now(),
687
+ };
688
+
689
+ console.error("[Mandu Streaming] React render error:", error);
690
+
691
+ if (!shellSent) {
692
+ // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
693
+ onShellError?.(streamingError);
694
+ } else {
695
+ // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
696
+ onStreamError?.(streamingError);
697
+ }
698
+
699
+ onError?.(error);
700
+ },
701
+ });
702
+
703
+ // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
704
+ reactStream.allReady.then(() => {
705
+ metrics.allReadyTime = Date.now() - metrics.startTime;
706
+ if (isDev) {
707
+ console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
708
+ }
709
+ }).catch(() => {
710
+ // 에러는 onError에서 이미 처리됨
711
+ });
712
+
713
+ // Custom stream으로 래핑 (Shell + React Content + Tail)
714
+ let tailSent = false;
715
+ const reader = reactStream.getReader();
716
+ const deadline = streamTimeout && streamTimeout > 0
717
+ ? metrics.startTime + streamTimeout
718
+ : null;
719
+
720
+ async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
721
+ if (!deadline) {
722
+ return reader.read();
723
+ }
724
+
725
+ const remaining = deadline - Date.now();
726
+ if (remaining <= 0) {
727
+ return null;
728
+ }
729
+
730
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
731
+ const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => {
732
+ timeoutId = setTimeout(() => resolve({ kind: "timeout" }), remaining);
733
+ });
734
+
735
+ const readPromise = reader
736
+ .read()
737
+ .then((result) => ({ kind: "read" as const, result }))
738
+ .catch((error) => ({ kind: "error" as const, error }));
739
+
740
+ const result = await Promise.race([readPromise, timeoutPromise]);
741
+
742
+ if (result.kind === "timeout") {
743
+ return null;
744
+ }
745
+
746
+ if (timeoutId) clearTimeout(timeoutId);
747
+
748
+ if (result.kind === "error") {
749
+ throw result.error;
750
+ }
751
+
752
+ return result.result;
753
+ }
754
+
755
+ return new ReadableStream<Uint8Array>({
756
+ async start(controller) {
757
+ // Shell 즉시 전송 (TTFB 최소화의 핵심!)
758
+ controller.enqueue(encoder.encode(htmlShell));
759
+ shellSent = true;
760
+ metrics.shellReadyTime = Date.now() - metrics.startTime;
761
+ onShellReady?.();
762
+ },
763
+
764
+ async pull(controller) {
765
+ try {
766
+ const readResult = await readWithTimeout();
767
+
768
+ // 타임아웃 발생
769
+ if (!readResult) {
770
+ const timeoutError = new Error(`Stream timeout: exceeded ${streamTimeout}ms`);
771
+ metrics.hasError = true;
772
+ timedOut = true;
773
+ if (isDev) {
774
+ console.warn(`[Mandu Streaming] Stream timeout after ${streamTimeout}ms`);
775
+ }
776
+
777
+ const streamingError: StreamingError = {
778
+ error: timeoutError,
779
+ isShellError: false,
780
+ recoverable: true,
781
+ timestamp: Date.now(),
782
+ };
783
+ onStreamError?.(streamingError);
784
+
785
+ controller.enqueue(encoder.encode(generateErrorScript(timeoutError, routeId)));
786
+
787
+ if (!tailSent) {
788
+ controller.enqueue(encoder.encode(htmlTail));
789
+ tailSent = true;
790
+ metrics.allReadyTime = Date.now() - metrics.startTime;
791
+ onMetrics?.(metrics);
792
+ }
793
+ controller.close();
794
+ try {
795
+ const cancelPromise = reader.cancel();
796
+ if (cancelPromise) {
797
+ cancelPromise.catch(() => {});
798
+ }
799
+ } catch {}
800
+ return;
801
+ }
802
+
803
+ const { done, value } = readResult;
804
+
805
+ if (done) {
806
+ if (!tailSent) {
807
+ controller.enqueue(encoder.encode(htmlTail));
808
+ tailSent = true;
809
+ // allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록
810
+ if (metrics.allReadyTime === 0) {
811
+ metrics.allReadyTime = Date.now() - metrics.startTime;
812
+ }
813
+ onAllReady?.();
814
+ onMetrics?.(metrics);
815
+ }
816
+ controller.close();
817
+ return;
818
+ }
819
+
820
+ // React 컨텐츠를 그대로 스트리밍
821
+ controller.enqueue(value);
822
+ } catch (error) {
823
+ const err = error instanceof Error ? error : new Error(String(error));
824
+ metrics.hasError = true;
825
+
826
+ console.error("[Mandu Streaming] Pull error:", err);
827
+
828
+ // Shell 후 에러 - 에러 스크립트 삽입
829
+ const streamingError: StreamingError = {
830
+ error: err,
831
+ isShellError: false,
832
+ recoverable: true,
833
+ timestamp: Date.now(),
834
+ };
835
+ onStreamError?.(streamingError);
836
+
837
+ controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
838
+
839
+ if (!tailSent) {
840
+ controller.enqueue(encoder.encode(htmlTail));
841
+ tailSent = true;
842
+ metrics.allReadyTime = Date.now() - metrics.startTime;
843
+ onMetrics?.(metrics);
844
+ }
845
+ controller.close();
846
+ }
847
+ },
848
+
849
+ cancel() {
850
+ try {
851
+ const cancelPromise = reader.cancel();
852
+ if (cancelPromise) {
853
+ cancelPromise.catch(() => {});
854
+ }
855
+ } catch {}
856
+ },
857
+ });
858
+ }
859
+
860
+ /**
861
+ * Streaming SSR Response 생성
862
+ *
863
+ * 헤더 설명:
864
+ * - X-Accel-Buffering: no - nginx 버퍼링 비활성화
865
+ * - Cache-Control: no-transform - 중간 프록시 변환 방지
866
+ *
867
+ * 주의: Transfer-Encoding은 설정하지 않음
868
+ * - WHATWG Response 환경에서 런타임이 자동 처리
869
+ * - 명시적 설정은 오히려 문제 될 수 있음
870
+ *
871
+ * 에러 정책:
872
+ * - renderToReadableStream 자체가 throw (stream 생성 실패)
873
+ * → 여기서 catch → 500 Response 반환 (유일한 500 케이스)
874
+ * - React onError 콜백 호출 (렌더링 중 에러)
875
+ * → StreamingError로 래핑 → 콜백 호출
876
+ * → 스트림은 계속 진행 (부분 렌더링 or 에러 스크립트 삽입)
877
+ */
878
+ export async function renderStreamingResponse(
879
+ element: ReactElement,
880
+ options: StreamingSSROptions = {}
881
+ ): Promise<Response> {
882
+ try {
883
+ const stream = await renderToStream(element, options);
884
+
885
+ return new Response(stream, {
886
+ status: 200,
887
+ headers: {
888
+ "Content-Type": "text/html; charset=utf-8",
889
+ // Transfer-Encoding은 런타임이 자동 처리 (명시 안 함)
890
+ "X-Content-Type-Options": "nosniff",
891
+ // nginx 버퍼링 비활성화 힌트
892
+ "X-Accel-Buffering": "no",
893
+ // 캐시 및 변환 방지 (Streaming은 동적)
894
+ "Cache-Control": "no-store, no-transform",
895
+ // CDN 힌트
896
+ "CDN-Cache-Control": "no-store",
897
+ },
898
+ });
899
+ } catch (error) {
900
+ // renderToStream에서 throw된 에러 → 500 응답 (단일 책임)
901
+ const err = error instanceof Error ? error : new Error(String(error));
902
+ console.error("[Mandu Streaming] Render failed:", err);
903
+
904
+ // XSS 방지
905
+ const safeMessage = err.message
906
+ .replace(/</g, "&lt;")
907
+ .replace(/>/g, "&gt;");
908
+
909
+ return new Response(
910
+ `<!DOCTYPE html>
911
+ <html lang="ko">
912
+ <head>
913
+ <meta charset="UTF-8">
914
+ <title>500 Server Error</title>
915
+ <style>
916
+ body { font-family: system-ui, sans-serif; padding: 40px; background: #f5f5f5; }
917
+ .error { background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
918
+ h1 { color: #e53935; margin: 0 0 16px 0; }
919
+ pre { background: #f5f5f5; padding: 12px; overflow-x: auto; }
920
+ </style>
921
+ </head>
922
+ <body>
923
+ <div class="error">
924
+ <h1>500 Server Error</h1>
925
+ <p>렌더링 중 오류가 발생했습니다.</p>
926
+ ${options.isDev ? `<pre>${safeMessage}</pre>` : ""}
927
+ </div>
928
+ </body>
929
+ </html>`,
930
+ {
931
+ status: 500,
932
+ headers: {
933
+ "Content-Type": "text/html; charset=utf-8",
934
+ },
935
+ }
936
+ );
937
+ }
938
+ }
939
+
940
+ /**
941
+ * Deferred 데이터와 함께 Streaming SSR 렌더링
942
+ *
943
+ * 핵심 원칙:
944
+ * - base stream은 즉시 시작 (TTFB 최소화)
945
+ * - deferred는 병렬로 처리하되 스트림을 막지 않음
946
+ * - 준비된 deferred만 tail 이후에 스크립트로 주입
947
+ */
948
+ export async function renderWithDeferredData(
949
+ element: ReactElement,
950
+ options: StreamingSSROptions & {
951
+ deferredPromises?: Record<string, Promise<unknown>>;
952
+ /** Deferred 타임아웃 (ms) - 이 시간 안에 resolve되지 않으면 포기 */
953
+ deferredTimeout?: number;
954
+ }
955
+ ): Promise<Response> {
956
+ const {
957
+ deferredPromises = {},
958
+ deferredTimeout = 5000,
959
+ routeId = "default",
960
+ onMetrics,
961
+ isDev = false,
962
+ ...restOptions
963
+ } = options;
964
+ const streamTimeout = options.streamTimeout;
965
+
966
+ const encoder = new TextEncoder();
967
+ const startTime = Date.now();
968
+
969
+ // 준비된 deferred 스크립트를 담을 배열 (mutable)
970
+ const readyScripts: string[] = [];
971
+ let deferredChunkCount = 0;
972
+ let allDeferredSettled = false;
973
+
974
+ // 1. Deferred promises 병렬 시작 (막지 않음!)
975
+ const deferredEntries = Object.entries(deferredPromises);
976
+ const deferredSettledPromise = deferredEntries.length > 0
977
+ ? Promise.allSettled(
978
+ deferredEntries.map(async ([key, promise]) => {
979
+ try {
980
+ // 타임아웃 적용
981
+ const timeoutPromise = new Promise<never>((_, reject) =>
982
+ setTimeout(() => reject(new Error(`Deferred timeout: ${key}`)), deferredTimeout)
983
+ );
984
+ const data = await Promise.race([promise, timeoutPromise]);
985
+
986
+ // 스크립트 생성 및 추가
987
+ const script = generateDeferredDataScript(routeId, key, data);
988
+ readyScripts.push(script);
989
+ deferredChunkCount++;
990
+
991
+ if (isDev) {
992
+ console.log(`[Mandu Streaming] Deferred ready: ${key} (${Date.now() - startTime}ms)`);
993
+ }
994
+ } catch (error) {
995
+ console.error(`[Mandu Streaming] Deferred error for ${key}:`, error);
996
+ }
997
+ })
998
+ ).then(() => {
999
+ allDeferredSettled = true;
1000
+ })
1001
+ : Promise.resolve().then(() => { allDeferredSettled = true; });
1002
+
1003
+ // 2. Base stream 즉시 시작 (TTFB 최소화의 핵심!)
1004
+ // _skipHtmlClose: true로 </body></html> 생략 → deferred 스크립트 삽입 지점 확보
1005
+ let baseMetrics: StreamingMetrics | null = null;
1006
+ const baseStream = await renderToStream(element, {
1007
+ ...restOptions,
1008
+ routeId,
1009
+ isDev,
1010
+ _skipHtmlClose: true, // deferred 스크립트를 </body> 전에 삽입하기 위해
1011
+ onMetrics: (metrics) => {
1012
+ baseMetrics = metrics;
1013
+ },
1014
+ });
1015
+
1016
+ // 3. 수동 스트림 파이프라인 (Bun pipeThrough 호환성 문제 해결)
1017
+ // base stream을 읽고 → 변환 후 → 새 스트림으로 출력
1018
+ const reader = baseStream.getReader();
1019
+
1020
+ const finalStream = new ReadableStream<Uint8Array>({
1021
+ async pull(controller) {
1022
+ try {
1023
+ const { done, value } = await reader.read();
1024
+
1025
+ if (!done && value) {
1026
+ // base stream chunk 그대로 전달
1027
+ controller.enqueue(value);
1028
+ return;
1029
+ }
1030
+
1031
+ // base stream 완료 → flush 로직 실행
1032
+ // deferred가 아직 안 끝났으면 잠시 대기 (단, deferredTimeout 내에서만)
1033
+ if (!allDeferredSettled) {
1034
+ const elapsed = Date.now() - startTime;
1035
+ let remainingTime = deferredTimeout - elapsed;
1036
+ if (streamTimeout && streamTimeout > 0) {
1037
+ const remainingStream = streamTimeout - elapsed;
1038
+ remainingTime = Math.min(remainingTime, remainingStream);
1039
+ }
1040
+ remainingTime = Math.max(0, remainingTime);
1041
+ if (remainingTime > 0) {
1042
+ await Promise.race([
1043
+ deferredSettledPromise,
1044
+ new Promise(resolve => setTimeout(resolve, remainingTime)),
1045
+ ]);
1046
+ }
1047
+ }
1048
+
1049
+ // 준비된 deferred 스크립트만 주입 (실제 enqueue 기준 카운트)
1050
+ let injectedCount = 0;
1051
+ for (const script of readyScripts) {
1052
+ controller.enqueue(encoder.encode(script));
1053
+ injectedCount++;
1054
+ }
1055
+
1056
+ if (isDev && injectedCount > 0) {
1057
+ console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
1058
+ }
1059
+
1060
+ // HTML 닫기 태그 추가 (</body></html>)
1061
+ controller.enqueue(encoder.encode(generateHTMLClose()));
1062
+
1063
+ // 최종 메트릭 보고 (injectedCount가 실제 메트릭)
1064
+ if (onMetrics && baseMetrics) {
1065
+ onMetrics({
1066
+ ...baseMetrics,
1067
+ deferredChunkCount: injectedCount,
1068
+ allReadyTime: Date.now() - startTime,
1069
+ });
1070
+ }
1071
+
1072
+ controller.close();
1073
+ } catch (error) {
1074
+ controller.error(error);
1075
+ }
1076
+ },
1077
+ cancel() {
1078
+ reader.cancel();
1079
+ },
1080
+ });
1081
+
1082
+ return new Response(finalStream, {
1083
+ status: 200,
1084
+ headers: {
1085
+ "Content-Type": "text/html; charset=utf-8",
1086
+ "X-Content-Type-Options": "nosniff",
1087
+ "X-Accel-Buffering": "no",
1088
+ "Cache-Control": "no-store, no-transform",
1089
+ "CDN-Cache-Control": "no-store",
1090
+ },
1091
+ });
1092
+ }
1093
+
1094
+ // ========== Loader Helpers ==========
1095
+
1096
+ /**
1097
+ * Streaming Loader 헬퍼
1098
+ * Critical과 Deferred 데이터를 분리하여 반환
1099
+ *
1100
+ * @example
1101
+ * ```typescript
1102
+ * export const loader = createStreamingLoader(async (ctx) => {
1103
+ * return {
1104
+ * critical: await getEssentialData(ctx),
1105
+ * deferred: fetchOptionalData(ctx), // Promise 그대로 전달
1106
+ * };
1107
+ * });
1108
+ * ```
1109
+ */
1110
+ export function createStreamingLoader<TCritical, TDeferred>(
1111
+ loaderFn: (ctx: unknown) => Promise<StreamingLoaderResult<{ critical: TCritical; deferred: TDeferred }>>
1112
+ ) {
1113
+ return async (ctx: unknown) => {
1114
+ const result = await loaderFn(ctx);
1115
+ return {
1116
+ critical: result.critical,
1117
+ deferred: result.deferred,
1118
+ };
1119
+ };
1120
+ }
1121
+
1122
+ /**
1123
+ * Deferred 데이터 프라미스 래퍼
1124
+ * Streaming 중 데이터 준비되면 클라이언트로 전송
1125
+ */
1126
+ export function defer<T>(promise: Promise<T>): Promise<T> {
1127
+ return promise;
1128
+ }
1129
+
1130
+ // ========== SEO Integration ==========
1131
+
1132
+ /**
1133
+ * SEO 메타데이터와 함께 Streaming SSR 렌더링
1134
+ *
1135
+ * Layout 체인에서 메타데이터를 자동으로 수집하고 병합하여
1136
+ * HTML head에 삽입합니다.
1137
+ *
1138
+ * @example
1139
+ * ```typescript
1140
+ * // 정적 메타데이터
1141
+ * const response = await renderWithSEO(<Page />, {
1142
+ * metadata: {
1143
+ * title: 'Home',
1144
+ * description: 'Welcome to my site',
1145
+ * openGraph: { type: 'website' },
1146
+ * },
1147
+ * })
1148
+ *
1149
+ * // Layout 체인 메타데이터
1150
+ * const response = await renderWithSEO(<Page />, {
1151
+ * metadata: [
1152
+ * layoutMetadata, // { title: { template: '%s | Site' } }
1153
+ * pageMetadata, // { title: 'Blog Post' }
1154
+ * ],
1155
+ * routeParams: { slug: 'hello' },
1156
+ * })
1157
+ * // → title: "Blog Post | Site"
1158
+ * ```
1159
+ */
1160
+ export async function renderWithSEO(
1161
+ element: ReactElement,
1162
+ options: StreamingSSROptions = {}
1163
+ ): Promise<Response> {
1164
+ const { metadata, routeParams, searchParams, ...restOptions } = options;
1165
+
1166
+ // SEO 메타데이터 처리
1167
+ if (metadata) {
1168
+ const seoOptions: SEOOptions = {
1169
+ routeParams,
1170
+ searchParams,
1171
+ };
1172
+
1173
+ // 배열이면 Layout 체인, 아니면 단일 메타데이터
1174
+ if (Array.isArray(metadata)) {
1175
+ seoOptions.metadata = metadata;
1176
+ } else {
1177
+ seoOptions.staticMetadata = metadata as Metadata;
1178
+ }
1179
+
1180
+ // SEO를 옵션에 주입
1181
+ const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions);
1182
+ return renderStreamingResponse(element, optionsWithSEO);
1183
+ }
1184
+
1185
+ // SEO 없이 기본 렌더링
1186
+ return renderStreamingResponse(element, restOptions);
1187
+ }
1188
+
1189
+ /**
1190
+ * Deferred 데이터 + SEO 메타데이터와 함께 Streaming SSR 렌더링
1191
+ *
1192
+ * @example
1193
+ * ```typescript
1194
+ * const response = await renderWithDeferredDataAndSEO(<Page />, {
1195
+ * metadata: {
1196
+ * title: post.title,
1197
+ * openGraph: { images: [post.image] },
1198
+ * },
1199
+ * deferredPromises: {
1200
+ * comments: fetchComments(postId),
1201
+ * related: fetchRelatedPosts(postId),
1202
+ * },
1203
+ * })
1204
+ * ```
1205
+ */
1206
+ export async function renderWithDeferredDataAndSEO(
1207
+ element: ReactElement,
1208
+ options: StreamingSSROptions & {
1209
+ deferredPromises?: Record<string, Promise<unknown>>;
1210
+ deferredTimeout?: number;
1211
+ } = {}
1212
+ ): Promise<Response> {
1213
+ const { metadata, routeParams, searchParams, ...restOptions } = options;
1214
+
1215
+ // SEO 메타데이터 처리
1216
+ if (metadata) {
1217
+ const seoOptions: SEOOptions = {
1218
+ routeParams,
1219
+ searchParams,
1220
+ };
1221
+
1222
+ if (Array.isArray(metadata)) {
1223
+ seoOptions.metadata = metadata;
1224
+ } else {
1225
+ seoOptions.staticMetadata = metadata as Metadata;
1226
+ }
1227
+
1228
+ const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions);
1229
+ return renderWithDeferredData(element, optionsWithSEO);
1230
+ }
1231
+
1232
+ return renderWithDeferredData(element, restOptions);
1233
+ }
1234
+
1235
+ // ========== Exports ==========
1236
+
1237
+ export {
1238
+ generateHTMLShell,
1239
+ generateHTMLTail,
1240
+ generateDeferredDataScript,
1241
+ };
1242
+
1243
+ // Re-export SEO integration utilities
1244
+ export { resolveSEO, injectSEOIntoOptions } from "../seo/integration/ssr";
1245
+ export type { SEOOptions, SEOResult } from "../seo/integration/ssr";