1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
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
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
337
338
339
340
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
403
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
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
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
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
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687/**
* Behavioral tests for tick-buffered RSC streaming.
*
* The tick-buffered TransformStream in app-dev-server.ts interleaves RSC
* <script> tags into the HTML stream between React Fizz flush cycles. These
* tests exercise the actual TransformStream algorithm (replicated from the
* generated SSR entry) to verify:
*
* 1. RSC scripts are interleaved between HTML flush cycles
* 2. No RSC scripts are injected mid-HTML-chunk (DOM corruption case)
* 3. The __VINEXT_RSC_DONE__ signal appears after all content
* 4. Head injection happens correctly
* 5. Multiple HTML chunks in the same macrotask are batched correctly
*
* This complements the string-matching tests in app-router.test.ts which
* verify the generated code contains the right constructs, but don't
* exercise the actual streaming behavior.
*
* NOTE: The helpers below replicate the core algorithm from generateSsrEntry()
* rather than importing it, because the production code is emitted as a
* string literal inside a generated module โ it's not importable as a
* function. Two production behaviors are intentionally omitted here since
* they are orthogonal to the streaming/interleaving logic being tested:
* - fixFlightHints(): rewrites as="stylesheet" โ as="style" in RSC hints
* - fixPreloadAs(): rewrites as="stylesheet" โ as="style" in HTML preloads
*/
import { describe, it, expect } from "vitest";
import { safeJsonStringify } from "../packages/vinext/src/server/html.js";
// โโโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/**
* Replicate createRscEmbedTransform from the generated SSR entry.
* This reads from an RSC embed stream in the background and provides
* flush()/finalize() methods to emit <script> tags.
*/
function createRscEmbedTransform(embedStream: ReadableStream<Uint8Array>) {
const reader = embedStream.getReader();
const decoder = new TextDecoder();
let _done = false;
let pendingChunks: string[] = [];
let reading = false;
async function pumpReader() {
if (reading) return;
reading = true;
try {
while (true) {
const result = await reader.read();
if (result.done) {
_done = true;
break;
}
pendingChunks.push(decoder.decode(result.value, { stream: true }));
}
} catch {
_done = true;
}
reading = false;
}
const pumpPromise = pumpReader();
return {
flush() {
if (pendingChunks.length === 0) return "";
const chunks = pendingChunks;
pendingChunks = [];
let scripts = "";
for (const chunk of chunks) {
scripts +=
"<script>self.__VINEXT_RSC_CHUNKS__=self.__VINEXT_RSC_CHUNKS__||[];self.__VINEXT_RSC_CHUNKS__.push(" +
safeJsonStringify(chunk) +
")</script>";
}
return scripts;
},
async finalize() {
await pumpPromise;
let scripts = this.flush();
scripts += "<script>self.__VINEXT_RSC_DONE__=true</script>";
return scripts;
},
};
}
/**
* Create the tick-buffered TransformStream that interleaves RSC scripts
* between HTML flush cycles. Replicated from the generated SSR entry
* in app-dev-server.ts.
*/
function createTickBufferedTransform(
rscEmbed: ReturnType<typeof createRscEmbedTransform>,
injectHTML: string = "",
) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let injected = false;
let buffered: string[] = [];
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
const text = decoder.decode(chunk, { stream: true });
buffered.push(text);
if (timeoutId !== null) return;
timeoutId = setTimeout(() => {
for (const buf of buffered) {
if (!injected) {
const headEnd = buf.indexOf("</head>");
if (headEnd !== -1) {
const before = buf.slice(0, headEnd);
const after = buf.slice(headEnd);
controller.enqueue(encoder.encode(before + injectHTML + after));
injected = true;
continue;
}
}
controller.enqueue(encoder.encode(buf));
}
buffered = [];
const rscScripts = rscEmbed.flush();
if (rscScripts) {
controller.enqueue(encoder.encode(rscScripts));
}
timeoutId = null;
}, 0);
},
async flush(controller) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
for (const buf of buffered) {
if (!injected) {
const headEnd = buf.indexOf("</head>");
if (headEnd !== -1) {
const before = buf.slice(0, headEnd);
const after = buf.slice(headEnd);
controller.enqueue(encoder.encode(before + injectHTML + after));
injected = true;
continue;
}
}
controller.enqueue(encoder.encode(buf));
}
buffered = [];
if (!injected && injectHTML) {
controller.enqueue(encoder.encode(injectHTML));
}
const finalScripts = await rscEmbed.finalize();
if (finalScripts) {
controller.enqueue(encoder.encode(finalScripts));
}
},
});
}
/**
* Create a ReadableStream from an array of string chunks, with optional
* delay between groups to simulate React Fizz flush cycles.
*
* Each entry in `chunkGroups` is an array of strings that are written
* synchronously (same macrotask), simulating how Fizz flushes multiple
* chunks within one flushCompletedQueues call.
*
* Between groups, a macrotask boundary is inserted via setTimeout(0).
*/
function createMockHtmlStream(
chunkGroups: string[][],
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
for (let i = 0; i < chunkGroups.length; i++) {
// Write all chunks in this group synchronously (same macrotask)
for (const chunk of chunkGroups[i]) {
controller.enqueue(encoder.encode(chunk));
}
// Wait for next macrotask between groups (simulates Fizz flush boundary)
if (i < chunkGroups.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
controller.close();
},
});
}
/**
* Create a mock RSC embed stream that emits chunks with controllable timing.
* Returns the stream and a controller to push chunks / close.
*/
function createMockRscStream() {
let streamController: ReadableStreamDefaultController<Uint8Array>;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
});
return {
stream,
push(data: string) {
streamController.enqueue(encoder.encode(data));
},
close() {
streamController.close();
},
};
}
/**
* Collect all output from a ReadableStream into a single string.
*/
async function collectStream(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
}
return result;
}
/**
* Collect output from a ReadableStream as individual string chunks,
* preserving the chunk boundaries from the TransformStream output.
*/
async function collectStreamChunks(stream: ReadableStream<Uint8Array>): Promise<string[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
return chunks;
}
// โโโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("Tick-buffered RSC streaming (behavioral)", () => {
it("interleaves RSC scripts between HTML flush cycles", async () => {
// Set up: RSC stream with two chunks pushed before HTML starts
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
rsc.push('1:["$","div",null,{"children":"Hello"}]\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Give the RSC reader time to consume the chunks
await new Promise((resolve) => setTimeout(resolve, 10));
// HTML stream: two flush cycles
// Cycle 1: shell with head
// Cycle 2: body content after Suspense resolves
const htmlStream = createMockHtmlStream([
["<html><head></head><body><div id='root'>"],
["<div>Suspense resolved</div></div></body></html>"],
]);
// Close RSC stream before second flush cycle completes
setTimeout(() => rsc.close(), 5);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// RSC scripts should be present in output
expect(output).toContain("__VINEXT_RSC_CHUNKS__");
// Done signal should be present
expect(output).toContain("__VINEXT_RSC_DONE__=true");
// HTML content should be intact
expect(output).toContain("<div id='root'>");
expect(output).toContain("<div>Suspense resolved</div>");
});
it("does not inject scripts mid-HTML-chunk (DOM corruption prevention)", async () => {
// This tests the core safety invariant: when React Fizz flushes multiple
// chunks synchronously (same macrotask), scripts must NOT appear between them.
// For example, Fizz might split an SVG element across chunks:
// chunk1: "<svg><linearGradi"
// chunk2: "ent></linearGradient></svg>"
// Injecting a <script> between these would corrupt the DOM.
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
// Simulate Fizz splitting an element across chunks in the SAME macrotask
const htmlStream = createMockHtmlStream([
[
"<html><head></head><body><svg><linearGradi",
"ent id='g1'></linearGradient></svg>",
],
]);
rsc.close();
const transform = createTickBufferedTransform(rscEmbed);
const chunks = await collectStreamChunks(htmlStream.pipeThrough(transform));
// Reconstruct the full output
const output = chunks.join("");
// The split SVG element must be intact โ no script between the two parts
expect(output).toContain(
"<svg><linearGradient id='g1'></linearGradient></svg>"
);
// RSC scripts should still be present (after the HTML, not mid-element)
expect(output).toContain("__VINEXT_RSC_CHUNKS__");
// Verify no <script> tag appears between the split HTML fragments
// by checking that the linearGradient element is contiguous
const svgStart = output.indexOf("<svg>");
const svgEnd = output.indexOf("</svg>") + "</svg>".length;
const svgContent = output.slice(svgStart, svgEnd);
expect(svgContent).not.toContain("<script>");
});
it("batches multiple same-macrotask HTML chunks before injecting RSC scripts", async () => {
const rsc = createMockRscStream();
rsc.push('0:D{"name":"layout"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
// Three HTML chunks in the same macrotask (simulates large Fizz flush)
const htmlStream = createMockHtmlStream([
[
"<html><head></head><body>",
"<div>chunk1</div>",
"<div>chunk2</div>",
],
]);
rsc.close();
const transform = createTickBufferedTransform(rscEmbed);
const chunks = await collectStreamChunks(htmlStream.pipeThrough(transform));
const output = chunks.join("");
// All three HTML chunks should appear contiguously (no script between them)
expect(output).toContain(
"<body><div>chunk1</div><div>chunk2</div>"
);
// RSC scripts should appear AFTER all the HTML chunks
const htmlEnd = output.indexOf("<div>chunk2</div>") + "<div>chunk2</div>".length;
const scriptStart = output.indexOf("__VINEXT_RSC_CHUNKS__");
expect(scriptStart).toBeGreaterThan(htmlEnd);
});
it("delivers RSC chunks progressively across multiple flush cycles", async () => {
const rsc = createMockRscStream();
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Push RSC chunk before first HTML flush
rsc.push('0:D{"name":"layout"}\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// We need to manually orchestrate timing between HTML flushes and RSC pushes.
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
// Flush cycle 1: HTML shell
controller.enqueue(encoder.encode("<html><head></head><body>"));
// Wait for macrotask boundary (so transform flushes cycle 1)
await new Promise((resolve) => setTimeout(resolve, 20));
// Push second RSC chunk between flush cycles
rsc.push('1:["$","div",null,{"children":"Resolved boundary 1"}]\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// Flush cycle 2: first Suspense boundary resolves
controller.enqueue(encoder.encode("<div>Boundary 1</div>"));
await new Promise((resolve) => setTimeout(resolve, 20));
// Push third RSC chunk
rsc.push('2:["$","span",null,{"children":"Resolved boundary 2"}]\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// Flush cycle 3: second Suspense boundary resolves
controller.enqueue(encoder.encode("<div>Boundary 2</div></body></html>"));
rsc.close();
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// All HTML should be present
expect(output).toContain("<html><head></head><body>");
expect(output).toContain("<div>Boundary 1</div>");
expect(output).toContain("<div>Boundary 2</div>");
// RSC scripts should be present for all chunks
expect(output).toContain("__VINEXT_RSC_CHUNKS__");
// Done signal at the end
expect(output).toContain("__VINEXT_RSC_DONE__=true");
// The done signal should come AFTER all HTML content
const lastHtmlPos = output.indexOf("</html>");
const donePos = output.indexOf("__VINEXT_RSC_DONE__=true");
expect(donePos).toBeGreaterThan(lastHtmlPos);
});
it("injects head content before </head>", async () => {
const rsc = createMockRscStream();
rsc.close(); // No RSC chunks needed for this test
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const injectHTML = '<script>self.__VINEXT_RSC_PARAMS__={"slug":"test"}</script>';
const htmlStream = createMockHtmlStream([
["<html><head><title>Test</title></head><body>content</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed, injectHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Injected content should appear before </head>
const injectPos = output.indexOf('__VINEXT_RSC_PARAMS__');
const headEndPos = output.indexOf("</head>");
expect(injectPos).toBeGreaterThan(-1);
expect(injectPos).toBeLessThan(headEndPos);
// Original head content should be preserved
expect(output).toContain("<title>Test</title>");
});
it("handles head injection when </head> is in a later chunk", async () => {
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const injectHTML = '<meta name="injected" content="true">';
// </head> appears in the second flush cycle
const htmlStream = createMockHtmlStream([
["<html><head><title>Test</title>"],
["</head><body>content</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed, injectHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Injected content should appear before </head>
const injectPos = output.indexOf('name="injected"');
const headEndPos = output.indexOf("</head>");
expect(injectPos).toBeGreaterThan(-1);
expect(injectPos).toBeLessThan(headEndPos);
});
it("still injects head content even without </head> in stream", async () => {
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const injectHTML = '<meta name="fallback">';
// No </head> in the HTML at all (edge case)
const htmlStream = createMockHtmlStream([
["<body>content</body>"],
]);
const transform = createTickBufferedTransform(rscEmbed, injectHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Should still inject the content (at the end, via flush handler fallback)
expect(output).toContain('name="fallback"');
});
it("emits __VINEXT_RSC_DONE__ signal even with empty RSC stream", async () => {
const rsc = createMockRscStream();
rsc.close(); // Close immediately โ no RSC chunks
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([
["<html><head></head><body>Hello</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Done signal must always be present so the browser knows streaming is complete
expect(output).toContain("__VINEXT_RSC_DONE__=true");
// No RSC chunks should be present
expect(output).not.toContain("__VINEXT_RSC_CHUNKS__");
});
it("handles RSC chunks arriving after HTML stream closes", async () => {
// Edge case: RSC stream outlives HTML stream (slow async server components)
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([
["<html><head></head><body>Shell</body></html>"],
]);
// Push more RSC data after a delay, then close
setTimeout(() => {
rsc.push('1:["$","div",null,{"children":"Late data"}]\n');
rsc.close();
}, 30);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// HTML should be present
expect(output).toContain("Shell");
// The late RSC chunk should still be emitted (finalize waits for RSC stream)
expect(output).toContain("__VINEXT_RSC_CHUNKS__");
// Done signal must be present
expect(output).toContain("__VINEXT_RSC_DONE__=true");
});
it("preserves RSC chunk ordering", async () => {
const rsc = createMockRscStream();
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Push chunks in specific order
rsc.push('0:D{"name":"layout"}\n');
rsc.push('1:D{"name":"page"}\n');
rsc.push('2:["$","div",null,{}]\n');
rsc.close();
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([
["<html><head></head><body>content</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Extract RSC script contents to verify ordering
const scriptRegex = /__VINEXT_RSC_CHUNKS__\.push\(([^)]+)\)/g;
const matches: string[] = [];
let match;
while ((match = scriptRegex.exec(output)) !== null) {
matches.push(match[1]);
}
// Should have 3 RSC chunks in order
expect(matches.length).toBe(3);
// Chunks are now text strings, so just parse the JSON strings directly
const chunk0 = JSON.parse(matches[0]);
const chunk1 = JSON.parse(matches[1]);
const chunk2 = JSON.parse(matches[2]);
expect(chunk0).toContain('0:D{"name":"layout"}');
expect(chunk1).toContain('1:D{"name":"page"}');
expect(chunk2).toContain('2:["$","div"');
});
it("handles large number of interleaved flush cycles correctly", async () => {
const rsc = createMockRscStream();
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Push initial RSC chunk
rsc.push('0:D{"name":"root"}\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// Create an HTML stream with many flush cycles (simulates deeply nested Suspense)
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode("<html><head></head><body>"));
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 15));
// Push RSC chunk for this boundary
rsc.push(`${i + 1}:["$","div",null,{"children":"Boundary ${i}"}]\n`);
await new Promise((resolve) => setTimeout(resolve, 5));
controller.enqueue(
encoder.encode(`<div data-boundary="${i}">Content ${i}</div>`)
);
}
await new Promise((resolve) => setTimeout(resolve, 10));
rsc.close();
controller.enqueue(encoder.encode("</body></html>"));
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// All 5 boundaries should be in the HTML
for (let i = 0; i < 5; i++) {
expect(output).toContain(`data-boundary="${i}"`);
expect(output).toContain(`Content ${i}`);
}
// All RSC chunks should be present (initial + 5 boundaries = 6 total)
// Count .push() calls, not raw occurrences of __VINEXT_RSC_CHUNKS__
// (each script tag contains __VINEXT_RSC_CHUNKS__ multiple times in the init pattern)
const scriptCount = (output.match(/__VINEXT_RSC_CHUNKS__\.push\(/g) || []).length;
expect(scriptCount).toBe(6);
// Done signal at the end
expect(output).toContain("__VINEXT_RSC_DONE__=true");
// Done signal should be the last script
const lastChunksPos = output.lastIndexOf("__VINEXT_RSC_CHUNKS__");
const donePos = output.indexOf("__VINEXT_RSC_DONE__");
expect(donePos).toBeGreaterThan(lastChunksPos);
});
it("XSS-safe: safeJsonStringify prevents </script> breakout in RSC data", async () => {
const rsc = createMockRscStream();
// Push RSC data that contains a </script> payload
const malicious = '0:["$","div",null,{"dangerouslySetInnerHTML":{"__html":"</script><script>alert(1)</script>"}}]\n';
rsc.push(malicious);
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([
["<html><head></head><body>content</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// The output should contain the RSC chunk
expect(output).toContain("__VINEXT_RSC_CHUNKS__");
// RSC data is now stored as a JSON text string. safeJsonStringify escapes
// <, >, and & characters so that </script> in the RSC data cannot break
// out of the enclosing <script> tag. The < and > characters are escaped
// to \\u003c and \\u003e respectively.
// The raw string "</script>" should NOT appear outside of the proper
// script tags we control. Count actual <script> and </script> tags โ
// they should be balanced (our tags only, not the malicious payload).
// lgtm[js/bad-tag-filter] โ counting tags to verify XSS protection, not filtering HTML
const openScripts = (output.match(/<script>/g) || []).length;
const closeScripts = (output.match(/<\/script>/g) || []).length;
expect(openScripts).toBe(closeScripts);
// The malicious raw HTML should NOT appear as actual HTML in the output
expect(output).not.toContain("alert(1)</script>");
// Verify the </script> characters are escaped in the JSON string output.
// safeJsonStringify escapes '<' to '\\u003c' and '>' to '\\u003e',
// so the malicious payload is safely neutralized.
expect(output).toContain("\\u003c/script\\u003e");
expect(output).not.toContain(",60,47,115,99,114,105,112,116,62,");
});
});