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