@mandujs/core 0.9.16 → 0.9.18

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.
@@ -0,0 +1,838 @@
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
+
21
+ // ========== Types ==========
22
+
23
+ /**
24
+ * Streaming SSR 에러 타입
25
+ */
26
+ export interface StreamingError {
27
+ error: Error;
28
+ /** Shell 전송 전 에러인지 여부 */
29
+ isShellError: boolean;
30
+ /** 복구 가능 여부 */
31
+ recoverable: boolean;
32
+ /** 타임스탬프 */
33
+ timestamp: number;
34
+ }
35
+
36
+ /**
37
+ * Streaming SSR 메트릭
38
+ */
39
+ export interface StreamingMetrics {
40
+ /** Shell ready까지 걸린 시간 (ms) */
41
+ shellReadyTime: number;
42
+ /** All ready까지 걸린 시간 (ms) */
43
+ allReadyTime: number;
44
+ /** Deferred chunk 개수 */
45
+ deferredChunkCount: number;
46
+ /** 에러 발생 여부 */
47
+ hasError: boolean;
48
+ /** 시작 시간 */
49
+ startTime: number;
50
+ }
51
+
52
+ export interface StreamingSSROptions {
53
+ /** 페이지 타이틀 */
54
+ title?: string;
55
+ /** HTML lang 속성 */
56
+ lang?: string;
57
+ /** 라우트 ID */
58
+ routeId?: string;
59
+ /** 라우트 패턴 */
60
+ routePattern?: string;
61
+ /** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
62
+ criticalData?: Record<string, unknown>;
63
+ /** Deferred 데이터 (Suspense 후 스트리밍) */
64
+ deferredData?: Record<string, unknown>;
65
+ /** Hydration 설정 */
66
+ hydration?: HydrationConfig;
67
+ /** 번들 매니페스트 */
68
+ bundleManifest?: BundleManifest;
69
+ /** 추가 head 태그 */
70
+ headTags?: string;
71
+ /** 개발 모드 여부 */
72
+ isDev?: boolean;
73
+ /** HMR 포트 */
74
+ hmrPort?: number;
75
+ /** Client-side Router 활성화 */
76
+ enableClientRouter?: boolean;
77
+ /** Streaming 타임아웃 (ms) */
78
+ streamTimeout?: number;
79
+ /** Shell 렌더링 후 콜백 */
80
+ onShellReady?: () => void;
81
+ /** 모든 컨텐츠 렌더링 후 콜백 */
82
+ onAllReady?: () => void;
83
+ /** Shell 전 에러 콜백 (500 반환 가능) */
84
+ onShellError?: (error: StreamingError) => void;
85
+ /** 스트리밍 중 에러 콜백 (이미 응답 시작됨 - fallback UI 삽입) */
86
+ onStreamError?: (error: StreamingError) => void;
87
+ /** 에러 콜백 (deprecated - onShellError/onStreamError 사용 권장) */
88
+ onError?: (error: Error) => void;
89
+ /** 메트릭 콜백 (observability) */
90
+ onMetrics?: (metrics: StreamingMetrics) => void;
91
+ }
92
+
93
+ export interface StreamingLoaderResult<T = unknown> {
94
+ /** 즉시 로드할 Critical 데이터 */
95
+ critical?: T;
96
+ /** 지연 로드할 Deferred 데이터 (Promise) */
97
+ deferred?: Promise<T>;
98
+ }
99
+
100
+ // ========== Serialization Guards ==========
101
+
102
+ /**
103
+ * 값이 JSON-serializable인지 검증
104
+ * Date, Map, Set, BigInt 등은 serializeProps에서 처리되지만
105
+ * 함수, Symbol, undefined는 문제가 됨
106
+ */
107
+ function isJSONSerializable(value: unknown, path: string = "root", isDev: boolean = false): { valid: boolean; issues: string[] } {
108
+ const issues: string[] = [];
109
+
110
+ function check(val: unknown, currentPath: string): void {
111
+ if (val === undefined) {
112
+ issues.push(`${currentPath}: undefined는 JSON으로 직렬화할 수 없습니다`);
113
+ return;
114
+ }
115
+
116
+ if (val === null) return;
117
+
118
+ const type = typeof val;
119
+
120
+ if (type === "function") {
121
+ issues.push(`${currentPath}: function은 JSON으로 직렬화할 수 없습니다`);
122
+ return;
123
+ }
124
+
125
+ if (type === "symbol") {
126
+ issues.push(`${currentPath}: symbol은 JSON으로 직렬화할 수 없습니다`);
127
+ return;
128
+ }
129
+
130
+ if (type === "bigint") {
131
+ // serializeProps에서 처리됨 - 경고만
132
+ if (isDev) {
133
+ console.warn(`[Mandu Streaming] ${currentPath}: BigInt가 감지됨 - 문자열로 변환됩니다`);
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (val instanceof Date || val instanceof Map || val instanceof Set || val instanceof URL || val instanceof RegExp) {
139
+ // serializeProps에서 처리됨
140
+ return;
141
+ }
142
+
143
+ if (Array.isArray(val)) {
144
+ val.forEach((item, index) => check(item, `${currentPath}[${index}]`));
145
+ return;
146
+ }
147
+
148
+ if (type === "object") {
149
+ for (const [key, v] of Object.entries(val as Record<string, unknown>)) {
150
+ check(v, `${currentPath}.${key}`);
151
+ }
152
+ return;
153
+ }
154
+
155
+ // string, number, boolean은 OK
156
+ }
157
+
158
+ check(value, path);
159
+
160
+ return {
161
+ valid: issues.length === 0,
162
+ issues,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * criticalData 검증 및 경고
168
+ * 개발 모드에서는 throw, 프로덕션에서는 경고만
169
+ */
170
+ function validateCriticalData(data: Record<string, unknown> | undefined, isDev: boolean): void {
171
+ if (!data) return;
172
+
173
+ const result = isJSONSerializable(data, "criticalData", isDev);
174
+
175
+ if (!result.valid) {
176
+ const message = `[Mandu Streaming] criticalData 직렬화 문제:\n${result.issues.join("\n")}`;
177
+
178
+ if (isDev) {
179
+ throw new Error(message);
180
+ } else {
181
+ console.error(message);
182
+ }
183
+ }
184
+ }
185
+
186
+ // ========== Streaming Warnings ==========
187
+
188
+ /**
189
+ * 프록시/버퍼링 관련 경고 (개발 모드)
190
+ */
191
+ function warnStreamingCaveats(isDev: boolean): void {
192
+ if (!isDev) return;
193
+
194
+ console.log(`[Mandu Streaming] 💡 Streaming SSR 주의사항:
195
+ - nginx/cloudflare 등 reverse proxy 사용 시 버퍼링 비활성화 필요
196
+ (nginx: proxy_buffering off; X-Accel-Buffering: no)
197
+ - compression 미들웨어가 chunk를 모으면 스트리밍 이점 사라짐
198
+ - Transfer-Encoding: chunked 헤더가 유지되어야 함`);
199
+ }
200
+
201
+ // ========== Error HTML Generation ==========
202
+
203
+ /**
204
+ * 스트리밍 중 에러 시 삽입할 에러 스크립트 생성
205
+ * Shell 이후 에러는 이 방식으로 클라이언트에 전달
206
+ */
207
+ function generateErrorScript(error: Error, routeId: string): string {
208
+ const safeMessage = error.message
209
+ .replace(/</g, "\\u003c")
210
+ .replace(/>/g, "\\u003e")
211
+ .replace(/"/g, "\\u0022");
212
+
213
+ return `<script>
214
+ (function() {
215
+ window.__MANDU_STREAMING_ERROR__ = {
216
+ routeId: "${routeId}",
217
+ message: "${safeMessage}",
218
+ timestamp: ${Date.now()}
219
+ };
220
+ console.error("[Mandu Streaming] 렌더링 중 에러:", "${safeMessage}");
221
+ window.dispatchEvent(new CustomEvent('mandu:streaming-error', {
222
+ detail: window.__MANDU_STREAMING_ERROR__
223
+ }));
224
+ })();
225
+ </script>`;
226
+ }
227
+
228
+ // ========== Suspense Wrappers ==========
229
+
230
+ /**
231
+ * Island를 Suspense로 감싸는 래퍼
232
+ * Streaming SSR에서 Island별 점진적 렌더링 지원
233
+ */
234
+ export function SuspenseIsland({
235
+ children,
236
+ fallback,
237
+ routeId,
238
+ priority = "visible",
239
+ bundleSrc,
240
+ }: {
241
+ children: ReactNode;
242
+ fallback?: ReactNode;
243
+ routeId: string;
244
+ priority?: HydrationPriority;
245
+ bundleSrc?: string;
246
+ }): ReactElement {
247
+ const defaultFallback = React.createElement("div", {
248
+ "data-mandu-island": routeId,
249
+ "data-mandu-priority": priority,
250
+ "data-mandu-src": bundleSrc,
251
+ "data-mandu-loading": "true",
252
+ style: { minHeight: "50px" },
253
+ }, React.createElement("div", {
254
+ className: "mandu-loading-skeleton",
255
+ style: {
256
+ background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)",
257
+ backgroundSize: "200% 100%",
258
+ animation: "mandu-shimmer 1.5s infinite",
259
+ height: "100%",
260
+ minHeight: "50px",
261
+ borderRadius: "4px",
262
+ },
263
+ }));
264
+
265
+ return React.createElement(
266
+ Suspense,
267
+ { fallback: fallback || defaultFallback },
268
+ React.createElement("div", {
269
+ "data-mandu-island": routeId,
270
+ "data-mandu-priority": priority,
271
+ "data-mandu-src": bundleSrc,
272
+ }, children)
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Deferred 데이터를 위한 Suspense 컴포넌트
278
+ * 데이터가 준비되면 children 렌더링
279
+ */
280
+ export function DeferredData<T>({
281
+ promise,
282
+ children,
283
+ fallback,
284
+ }: {
285
+ promise: Promise<T>;
286
+ children: (data: T) => ReactNode;
287
+ fallback?: ReactNode;
288
+ }): ReactElement {
289
+ // React 18 use() 훅 대신 Suspense + throw promise 패턴 사용
290
+ const AsyncComponent = React.lazy(async () => {
291
+ const data = await promise;
292
+ return {
293
+ default: () => React.createElement(React.Fragment, null, children(data)),
294
+ };
295
+ });
296
+
297
+ return React.createElement(
298
+ Suspense,
299
+ { fallback: fallback || React.createElement("span", null, "Loading...") },
300
+ React.createElement(AsyncComponent, null)
301
+ );
302
+ }
303
+
304
+ // ========== HTML Generation ==========
305
+
306
+ /**
307
+ * Streaming용 HTML Shell 생성 (<!DOCTYPE> ~ <div id="root">)
308
+ */
309
+ function generateHTMLShell(options: StreamingSSROptions): string {
310
+ const {
311
+ title = "Mandu App",
312
+ lang = "ko",
313
+ headTags = "",
314
+ bundleManifest,
315
+ } = options;
316
+
317
+ // Import map (module scripts 전에 위치해야 함)
318
+ let importMapScript = "";
319
+ if (bundleManifest?.importMap && Object.keys(bundleManifest.importMap.imports).length > 0) {
320
+ const importMapJson = JSON.stringify(bundleManifest.importMap, null, 2);
321
+ importMapScript = `<script type="importmap">${importMapJson}</script>`;
322
+ }
323
+
324
+ // Loading skeleton 애니메이션 스타일
325
+ const loadingStyles = `
326
+ <style>
327
+ @keyframes mandu-shimmer {
328
+ 0% { background-position: 200% 0; }
329
+ 100% { background-position: -200% 0; }
330
+ }
331
+ .mandu-loading-skeleton {
332
+ background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
333
+ background-size: 200% 100%;
334
+ animation: mandu-shimmer 1.5s infinite;
335
+ }
336
+ .mandu-stream-pending {
337
+ opacity: 0;
338
+ transition: opacity 0.3s ease-in;
339
+ }
340
+ .mandu-stream-ready {
341
+ opacity: 1;
342
+ }
343
+ </style>`;
344
+
345
+ return `<!DOCTYPE html>
346
+ <html lang="${lang}">
347
+ <head>
348
+ <meta charset="UTF-8">
349
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
350
+ <title>${title}</title>
351
+ ${loadingStyles}
352
+ ${headTags}
353
+ ${importMapScript}
354
+ </head>
355
+ <body>
356
+ <div id="root">`;
357
+ }
358
+
359
+ /**
360
+ * Streaming용 HTML Tail 생성 (</div id="root"> ~ </html>)
361
+ */
362
+ function generateHTMLTail(options: StreamingSSROptions): string {
363
+ const {
364
+ routeId,
365
+ routePattern,
366
+ criticalData,
367
+ deferredData,
368
+ bundleManifest,
369
+ isDev = false,
370
+ hmrPort,
371
+ enableClientRouter = false,
372
+ hydration,
373
+ } = options;
374
+
375
+ const scripts: string[] = [];
376
+
377
+ // 1. Critical 데이터 스크립트 (즉시 사용 가능)
378
+ if (criticalData && routeId) {
379
+ const wrappedData = {
380
+ [routeId]: {
381
+ serverData: criticalData,
382
+ timestamp: Date.now(),
383
+ streaming: true,
384
+ },
385
+ };
386
+ const json = serializeProps(wrappedData)
387
+ .replace(/</g, "\\u003c")
388
+ .replace(/>/g, "\\u003e")
389
+ .replace(/&/g, "\\u0026");
390
+ scripts.push(`<script id="__MANDU_DATA__" type="application/json">${json}</script>`);
391
+ scripts.push(`<script>window.__MANDU_DATA_RAW__ = document.getElementById('__MANDU_DATA__').textContent;</script>`);
392
+ }
393
+
394
+ // 2. 라우트 정보 스크립트
395
+ if (enableClientRouter && routeId) {
396
+ const routeInfo = {
397
+ id: routeId,
398
+ pattern: routePattern || "",
399
+ params: {},
400
+ streaming: true,
401
+ };
402
+ const json = JSON.stringify(routeInfo)
403
+ .replace(/</g, "\\u003c")
404
+ .replace(/>/g, "\\u003e");
405
+ scripts.push(`<script>window.__MANDU_ROUTE__ = ${json};</script>`);
406
+ }
407
+
408
+ // 3. Streaming 완료 마커 (클라이언트에서 감지용)
409
+ scripts.push(`<script>window.__MANDU_STREAMING_SHELL_READY__ = true;</script>`);
410
+
411
+ // 4. Island modulepreload
412
+ if (bundleManifest && routeId) {
413
+ const bundle = bundleManifest.bundles[routeId];
414
+ if (bundle) {
415
+ scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
416
+ }
417
+ }
418
+
419
+ // 5. Runtime 로드
420
+ if (bundleManifest?.shared.runtime) {
421
+ scripts.push(`<script type="module" src="${bundleManifest.shared.runtime}"></script>`);
422
+ }
423
+
424
+ // 6. Router 스크립트
425
+ if (enableClientRouter && bundleManifest?.shared?.router) {
426
+ scripts.push(`<script type="module" src="${bundleManifest.shared.router}"></script>`);
427
+ }
428
+
429
+ // 7. HMR 스크립트 (개발 모드)
430
+ if (isDev && hmrPort) {
431
+ scripts.push(generateHMRScript(hmrPort));
432
+ }
433
+
434
+ return `</div>
435
+ ${scripts.join("\n ")}
436
+ </body>
437
+ </html>`;
438
+ }
439
+
440
+ /**
441
+ * Deferred 데이터 인라인 스크립트 생성
442
+ * Streaming 중에 데이터 도착 시 DOM에 주입
443
+ */
444
+ function generateDeferredDataScript(routeId: string, key: string, data: unknown): string {
445
+ const json = serializeProps({ [key]: data })
446
+ .replace(/</g, "\\u003c")
447
+ .replace(/>/g, "\\u003e");
448
+
449
+ return `<script>
450
+ (function() {
451
+ window.__MANDU_DEFERRED__ = window.__MANDU_DEFERRED__ || {};
452
+ window.__MANDU_DEFERRED__["${routeId}"] = window.__MANDU_DEFERRED__["${routeId}"] || {};
453
+ Object.assign(window.__MANDU_DEFERRED__["${routeId}"], ${json});
454
+ window.dispatchEvent(new CustomEvent('mandu:deferred-data', { detail: { routeId: "${routeId}", key: "${key}" } }));
455
+ })();
456
+ </script>`;
457
+ }
458
+
459
+ /**
460
+ * HMR 스크립트 생성
461
+ */
462
+ function generateHMRScript(port: number): string {
463
+ const hmrPort = port + 1;
464
+ return `<script>
465
+ (function() {
466
+ var ws = null;
467
+ var reconnectAttempts = 0;
468
+ var maxReconnectAttempts = 10;
469
+
470
+ function connect() {
471
+ try {
472
+ ws = new WebSocket('ws://localhost:${hmrPort}');
473
+ ws.onopen = function() {
474
+ console.log('[Mandu HMR] Connected');
475
+ reconnectAttempts = 0;
476
+ };
477
+ ws.onmessage = function(e) {
478
+ try {
479
+ var msg = JSON.parse(e.data);
480
+ if (msg.type === 'reload' || msg.type === 'island-update') {
481
+ console.log('[Mandu HMR] Reloading...');
482
+ location.reload();
483
+ }
484
+ } catch(err) {}
485
+ };
486
+ ws.onclose = function() {
487
+ if (reconnectAttempts < maxReconnectAttempts) {
488
+ reconnectAttempts++;
489
+ setTimeout(connect, 1000 * reconnectAttempts);
490
+ }
491
+ };
492
+ } catch(err) {
493
+ setTimeout(connect, 1000);
494
+ }
495
+ }
496
+ connect();
497
+ })();
498
+ </script>`;
499
+ }
500
+
501
+ // ========== Main Streaming Functions ==========
502
+
503
+ /**
504
+ * React 컴포넌트를 ReadableStream으로 렌더링
505
+ * Bun/Web Streams API 기반
506
+ *
507
+ * 에러 처리:
508
+ * - Shell 전 에러: onShellError 호출 → 500 응답 가능
509
+ * - Shell 후 에러: onStreamError 호출 → 에러 스크립트 삽입
510
+ */
511
+ export async function renderToStream(
512
+ element: ReactElement,
513
+ options: StreamingSSROptions = {}
514
+ ): Promise<ReadableStream<Uint8Array>> {
515
+ const {
516
+ streamTimeout = 10000,
517
+ onShellReady,
518
+ onAllReady,
519
+ onShellError,
520
+ onStreamError,
521
+ onError,
522
+ onMetrics,
523
+ isDev = false,
524
+ routeId = "unknown",
525
+ criticalData,
526
+ } = options;
527
+
528
+ // 메트릭 수집
529
+ const metrics: StreamingMetrics = {
530
+ shellReadyTime: 0,
531
+ allReadyTime: 0,
532
+ deferredChunkCount: 0,
533
+ hasError: false,
534
+ startTime: Date.now(),
535
+ };
536
+
537
+ // criticalData 직렬화 검증
538
+ validateCriticalData(criticalData, isDev);
539
+
540
+ // 스트리밍 주의사항 경고 (첫 요청 시 1회만)
541
+ if (isDev && !(globalThis as any).__MANDU_STREAMING_WARNED__) {
542
+ warnStreamingCaveats(isDev);
543
+ (globalThis as any).__MANDU_STREAMING_WARNED__ = true;
544
+ }
545
+
546
+ const encoder = new TextEncoder();
547
+ const htmlShell = generateHTMLShell(options);
548
+ const htmlTail = generateHTMLTail(options);
549
+
550
+ let shellSent = false;
551
+ let hasShellError = false;
552
+
553
+ // React renderToReadableStream 호출
554
+ let reactStream: ReadableStream<Uint8Array>;
555
+ try {
556
+ reactStream = await renderToReadableStream(element, {
557
+ onError: (error: Error) => {
558
+ metrics.hasError = true;
559
+ const streamingError: StreamingError = {
560
+ error,
561
+ isShellError: !shellSent,
562
+ recoverable: shellSent, // Shell 후면 복구 가능 (에러 스크립트 삽입)
563
+ timestamp: Date.now(),
564
+ };
565
+
566
+ console.error("[Mandu Streaming] React render error:", error);
567
+
568
+ if (!shellSent) {
569
+ hasShellError = true;
570
+ onShellError?.(streamingError);
571
+ } else {
572
+ onStreamError?.(streamingError);
573
+ }
574
+
575
+ // deprecated 콜백 호환
576
+ onError?.(error);
577
+ },
578
+ });
579
+ } catch (error) {
580
+ // renderToReadableStream 자체 실패 (Shell 전 에러)
581
+ metrics.hasError = true;
582
+ const err = error instanceof Error ? error : new Error(String(error));
583
+ const streamingError: StreamingError = {
584
+ error: err,
585
+ isShellError: true,
586
+ recoverable: false,
587
+ timestamp: Date.now(),
588
+ };
589
+
590
+ onShellError?.(streamingError);
591
+ onError?.(err);
592
+
593
+ // 에러 응답을 위한 빈 스트림 반환
594
+ throw err;
595
+ }
596
+
597
+ // Shell 준비 완료 대기 (타임아웃 포함)
598
+ const timeoutPromise = new Promise<void>((_, reject) =>
599
+ setTimeout(() => reject(new Error("Shell render timeout")), streamTimeout)
600
+ );
601
+
602
+ try {
603
+ await Promise.race([reactStream.allReady, timeoutPromise]);
604
+ } catch {
605
+ // Timeout이나 에러 시에도 계속 진행 (이미 일부 렌더링된 경우)
606
+ if (isDev) {
607
+ console.warn("[Mandu Streaming] Shell render timed out or errored, continuing with partial content");
608
+ }
609
+ }
610
+
611
+ // Custom stream으로 래핑 (Shell + React Content + Tail)
612
+ let tailSent = false;
613
+ const reader = reactStream.getReader();
614
+
615
+ return new ReadableStream<Uint8Array>({
616
+ async start(controller) {
617
+ // Shell 전 에러면 에러 HTML 반환
618
+ if (hasShellError) {
619
+ const errorHtml = `<!DOCTYPE html>
620
+ <html><head><title>Error</title></head>
621
+ <body><h1>500 Server Error</h1><p>렌더링 중 오류가 발생했습니다.</p></body></html>`;
622
+ controller.enqueue(encoder.encode(errorHtml));
623
+ controller.close();
624
+ return;
625
+ }
626
+
627
+ // 1. HTML Shell 전송 (즉시 TTFB)
628
+ controller.enqueue(encoder.encode(htmlShell));
629
+ shellSent = true;
630
+ metrics.shellReadyTime = Date.now() - metrics.startTime;
631
+ onShellReady?.();
632
+ },
633
+
634
+ async pull(controller) {
635
+ try {
636
+ const { done, value } = await reader.read();
637
+
638
+ if (done) {
639
+ if (!tailSent) {
640
+ // 2. HTML Tail 전송 (스크립트, 데이터)
641
+ controller.enqueue(encoder.encode(htmlTail));
642
+ tailSent = true;
643
+ metrics.allReadyTime = Date.now() - metrics.startTime;
644
+ onAllReady?.();
645
+ onMetrics?.(metrics);
646
+ }
647
+ controller.close();
648
+ return;
649
+ }
650
+
651
+ // 3. React 컨텐츠 스트리밍
652
+ controller.enqueue(value);
653
+ } catch (error) {
654
+ const err = error instanceof Error ? error : new Error(String(error));
655
+ metrics.hasError = true;
656
+
657
+ console.error("[Mandu Streaming] Pull error:", err);
658
+
659
+ // 스트리밍 중 에러 - 에러 스크립트 삽입
660
+ const streamingError: StreamingError = {
661
+ error: err,
662
+ isShellError: false,
663
+ recoverable: true,
664
+ timestamp: Date.now(),
665
+ };
666
+ onStreamError?.(streamingError);
667
+
668
+ // 에러 스크립트 삽입
669
+ controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
670
+
671
+ if (!tailSent) {
672
+ controller.enqueue(encoder.encode(htmlTail));
673
+ tailSent = true;
674
+ metrics.allReadyTime = Date.now() - metrics.startTime;
675
+ onMetrics?.(metrics);
676
+ }
677
+ controller.close();
678
+ }
679
+ },
680
+
681
+ cancel() {
682
+ reader.cancel();
683
+ },
684
+ });
685
+ }
686
+
687
+ /**
688
+ * Streaming SSR Response 생성
689
+ *
690
+ * 헤더 설명:
691
+ * - Transfer-Encoding: chunked - 스트리밍 필수
692
+ * - X-Accel-Buffering: no - nginx 버퍼링 비활성화
693
+ * - Cache-Control: no-transform - 중간 프록시 변환 방지
694
+ */
695
+ export async function renderStreamingResponse(
696
+ element: ReactElement,
697
+ options: StreamingSSROptions = {}
698
+ ): Promise<Response> {
699
+ try {
700
+ const stream = await renderToStream(element, options);
701
+
702
+ return new Response(stream, {
703
+ status: 200,
704
+ headers: {
705
+ "Content-Type": "text/html; charset=utf-8",
706
+ "Transfer-Encoding": "chunked",
707
+ // Streaming 관련 헤더
708
+ "X-Content-Type-Options": "nosniff",
709
+ // nginx 버퍼링 비활성화 힌트
710
+ "X-Accel-Buffering": "no",
711
+ // 캐시 및 변환 방지 (Streaming은 동적)
712
+ "Cache-Control": "no-cache, no-store, must-revalidate, no-transform",
713
+ // Cloudflare 등 CDN 힌트
714
+ "CDN-Cache-Control": "no-store",
715
+ },
716
+ });
717
+ } catch (error) {
718
+ // Shell 전 에러 시 500 응답
719
+ const err = error instanceof Error ? error : new Error(String(error));
720
+ console.error("[Mandu Streaming] Response generation failed:", err);
721
+
722
+ return new Response(
723
+ `<!DOCTYPE html><html><head><title>500 Error</title></head>
724
+ <body><h1>500 Server Error</h1><p>${err.message}</p></body></html>`,
725
+ {
726
+ status: 500,
727
+ headers: {
728
+ "Content-Type": "text/html; charset=utf-8",
729
+ },
730
+ }
731
+ );
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Deferred 데이터와 함께 Streaming SSR 렌더링
737
+ * Critical 데이터는 즉시, Deferred 데이터는 준비되면 스트리밍
738
+ */
739
+ export async function renderWithDeferredData(
740
+ element: ReactElement,
741
+ options: StreamingSSROptions & {
742
+ deferredPromises?: Record<string, Promise<unknown>>;
743
+ }
744
+ ): Promise<Response> {
745
+ const { deferredPromises = {}, routeId = "default", ...restOptions } = options;
746
+
747
+ // Deferred 데이터 스크립트를 추가할 TransformStream
748
+ const encoder = new TextEncoder();
749
+ const deferredScripts: string[] = [];
750
+
751
+ // Deferred promises 처리
752
+ const deferredEntries = Object.entries(deferredPromises);
753
+ if (deferredEntries.length > 0) {
754
+ // 병렬로 모든 deferred 데이터 대기
755
+ Promise.all(
756
+ deferredEntries.map(async ([key, promise]) => {
757
+ try {
758
+ const data = await promise;
759
+ deferredScripts.push(generateDeferredDataScript(routeId, key, data));
760
+ } catch (error) {
761
+ console.error(`[Mandu Streaming] Deferred data error for ${key}:`, error);
762
+ }
763
+ })
764
+ );
765
+ }
766
+
767
+ // 기본 스트림 생성
768
+ const baseStream = await renderToStream(element, { ...restOptions, routeId });
769
+
770
+ // Deferred 스크립트 주입을 위한 Transform
771
+ const transformStream = new TransformStream<Uint8Array, Uint8Array>({
772
+ transform(chunk, controller) {
773
+ controller.enqueue(chunk);
774
+ },
775
+ flush(controller) {
776
+ // 모든 deferred 스크립트 추가
777
+ for (const script of deferredScripts) {
778
+ controller.enqueue(encoder.encode(script));
779
+ }
780
+ },
781
+ });
782
+
783
+ const finalStream = baseStream.pipeThrough(transformStream);
784
+
785
+ return new Response(finalStream, {
786
+ status: 200,
787
+ headers: {
788
+ "Content-Type": "text/html; charset=utf-8",
789
+ "Transfer-Encoding": "chunked",
790
+ "X-Content-Type-Options": "nosniff",
791
+ "Cache-Control": "no-cache, no-store, must-revalidate",
792
+ },
793
+ });
794
+ }
795
+
796
+ // ========== Loader Helpers ==========
797
+
798
+ /**
799
+ * Streaming Loader 헬퍼
800
+ * Critical과 Deferred 데이터를 분리하여 반환
801
+ *
802
+ * @example
803
+ * ```typescript
804
+ * export const loader = createStreamingLoader(async (ctx) => {
805
+ * return {
806
+ * critical: await getEssentialData(ctx),
807
+ * deferred: fetchOptionalData(ctx), // Promise 그대로 전달
808
+ * };
809
+ * });
810
+ * ```
811
+ */
812
+ export function createStreamingLoader<TCritical, TDeferred>(
813
+ loaderFn: (ctx: unknown) => Promise<StreamingLoaderResult<{ critical: TCritical; deferred: TDeferred }>>
814
+ ) {
815
+ return async (ctx: unknown) => {
816
+ const result = await loaderFn(ctx);
817
+ return {
818
+ critical: result.critical,
819
+ deferred: result.deferred,
820
+ };
821
+ };
822
+ }
823
+
824
+ /**
825
+ * Deferred 데이터 프라미스 래퍼
826
+ * Streaming 중 데이터 준비되면 클라이언트로 전송
827
+ */
828
+ export function defer<T>(promise: Promise<T>): Promise<T> {
829
+ return promise;
830
+ }
831
+
832
+ // ========== Exports ==========
833
+
834
+ export {
835
+ generateHTMLShell,
836
+ generateHTMLTail,
837
+ generateDeferredDataScript,
838
+ };