rwsdk 1.0.0-beta.27-test.20251115092208 → 1.0.0-beta.27-test.20251116215153
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.
|
@@ -15,37 +15,149 @@
|
|
|
15
15
|
* @param startMarker The marker in the document to start injecting the app.
|
|
16
16
|
* @param endMarker The marker in the app stream that signals the end of the initial, non-suspended render.
|
|
17
17
|
*/
|
|
18
|
+
function splitStreamOnFirstNonHoistedTag(sourceStream) {
|
|
19
|
+
const decoder = new TextDecoder();
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const nonHoistedTagPattern = /<(?!(?:\/)?(?:title|meta|link|style|base)[\s>\/])(?![!?])/i;
|
|
22
|
+
let sourceReader;
|
|
23
|
+
let appBodyController = null;
|
|
24
|
+
let buffer = "";
|
|
25
|
+
let hoistedTagsDone = false;
|
|
26
|
+
const hoistedTagsStream = new ReadableStream({
|
|
27
|
+
start(controller) {
|
|
28
|
+
sourceReader = sourceStream.getReader();
|
|
29
|
+
const pump = async () => {
|
|
30
|
+
try {
|
|
31
|
+
if (hoistedTagsDone) {
|
|
32
|
+
controller.close();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const { done, value } = await sourceReader.read();
|
|
36
|
+
if (done) {
|
|
37
|
+
if (buffer) {
|
|
38
|
+
const match = buffer.match(nonHoistedTagPattern);
|
|
39
|
+
if (match && typeof match.index === "number") {
|
|
40
|
+
const hoistedPart = buffer.slice(0, match.index);
|
|
41
|
+
controller.enqueue(encoder.encode(hoistedPart));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
controller.enqueue(encoder.encode(buffer));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
controller.close();
|
|
48
|
+
hoistedTagsDone = true;
|
|
49
|
+
if (appBodyController) {
|
|
50
|
+
appBodyController.close();
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
buffer += decoder.decode(value, { stream: true });
|
|
55
|
+
const match = buffer.match(nonHoistedTagPattern);
|
|
56
|
+
if (match && typeof match.index === "number") {
|
|
57
|
+
const hoistedPart = buffer.slice(0, match.index);
|
|
58
|
+
const appPart = buffer.slice(match.index);
|
|
59
|
+
buffer = "";
|
|
60
|
+
controller.enqueue(encoder.encode(hoistedPart));
|
|
61
|
+
controller.close();
|
|
62
|
+
hoistedTagsDone = true;
|
|
63
|
+
if (appBodyController) {
|
|
64
|
+
if (appPart) {
|
|
65
|
+
appBodyController.enqueue(encoder.encode(appPart));
|
|
66
|
+
}
|
|
67
|
+
while (true) {
|
|
68
|
+
const { done, value } = await sourceReader.read();
|
|
69
|
+
if (done) {
|
|
70
|
+
appBodyController.close();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
appBodyController.enqueue(value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const flushIndex = buffer.lastIndexOf("\n");
|
|
79
|
+
if (flushIndex !== -1) {
|
|
80
|
+
controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
|
|
81
|
+
buffer = buffer.slice(flushIndex + 1);
|
|
82
|
+
}
|
|
83
|
+
await pump();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
controller.error(e);
|
|
88
|
+
if (appBodyController) {
|
|
89
|
+
appBodyController.error(e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
pump().catch((e) => {
|
|
94
|
+
controller.error(e);
|
|
95
|
+
if (appBodyController) {
|
|
96
|
+
appBodyController.error(e);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const appBodyStream = new ReadableStream({
|
|
102
|
+
start(controller) {
|
|
103
|
+
appBodyController = controller;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
return [hoistedTagsStream, appBodyStream];
|
|
107
|
+
}
|
|
18
108
|
export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, endMarker) {
|
|
109
|
+
const [hoistedTagsStream, appBodyStream] = splitStreamOnFirstNonHoistedTag(innerHtml);
|
|
19
110
|
const decoder = new TextDecoder();
|
|
20
111
|
const encoder = new TextEncoder();
|
|
21
112
|
let outerReader;
|
|
22
113
|
let innerReader;
|
|
114
|
+
let hoistedTagsReader;
|
|
23
115
|
let buffer = "";
|
|
24
116
|
let outerBufferRemains = "";
|
|
25
|
-
let
|
|
117
|
+
let innerSuspendedRemains = "";
|
|
118
|
+
let phase = "enqueue-hoisted";
|
|
26
119
|
const pump = async (controller) => {
|
|
27
120
|
try {
|
|
28
|
-
if (phase === "
|
|
29
|
-
const { done, value } = await
|
|
121
|
+
if (phase === "enqueue-hoisted") {
|
|
122
|
+
const { done, value } = await hoistedTagsReader.read();
|
|
30
123
|
if (done) {
|
|
31
|
-
|
|
32
|
-
controller.enqueue(encoder.encode(buffer));
|
|
33
|
-
controller.close();
|
|
34
|
-
return;
|
|
124
|
+
phase = "outer-head";
|
|
35
125
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
126
|
+
else {
|
|
127
|
+
controller.enqueue(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (phase === "outer-head") {
|
|
131
|
+
const { done, value } = await outerReader.read();
|
|
132
|
+
if (done) {
|
|
133
|
+
if (buffer) {
|
|
134
|
+
const markerIndex = buffer.indexOf(startMarker);
|
|
135
|
+
if (markerIndex !== -1) {
|
|
136
|
+
controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
|
|
137
|
+
outerBufferRemains = buffer.slice(markerIndex + startMarker.length);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
controller.enqueue(encoder.encode(buffer));
|
|
141
|
+
}
|
|
142
|
+
buffer = "";
|
|
143
|
+
}
|
|
42
144
|
phase = "inner-shell";
|
|
43
145
|
}
|
|
44
146
|
else {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
buffer
|
|
147
|
+
buffer += decoder.decode(value, { stream: true });
|
|
148
|
+
const markerIndex = buffer.indexOf(startMarker);
|
|
149
|
+
if (markerIndex !== -1) {
|
|
150
|
+
controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
|
|
151
|
+
outerBufferRemains = buffer.slice(markerIndex + startMarker.length);
|
|
152
|
+
buffer = "";
|
|
153
|
+
phase = "inner-shell";
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const flushIndex = buffer.lastIndexOf("\n");
|
|
157
|
+
if (flushIndex !== -1) {
|
|
158
|
+
controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
|
|
159
|
+
buffer = buffer.slice(flushIndex + 1);
|
|
160
|
+
}
|
|
49
161
|
}
|
|
50
162
|
}
|
|
51
163
|
}
|
|
@@ -62,7 +174,8 @@ export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, e
|
|
|
62
174
|
if (markerIndex !== -1) {
|
|
63
175
|
const endOfMarkerIndex = markerIndex + endMarker.length;
|
|
64
176
|
controller.enqueue(encoder.encode(buffer.slice(0, endOfMarkerIndex)));
|
|
65
|
-
|
|
177
|
+
innerSuspendedRemains = buffer.slice(endOfMarkerIndex);
|
|
178
|
+
buffer = "";
|
|
66
179
|
phase = "outer-tail";
|
|
67
180
|
}
|
|
68
181
|
else {
|
|
@@ -81,8 +194,17 @@ export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, e
|
|
|
81
194
|
}
|
|
82
195
|
const { done, value } = await outerReader.read();
|
|
83
196
|
if (done) {
|
|
84
|
-
if (buffer)
|
|
85
|
-
|
|
197
|
+
if (buffer) {
|
|
198
|
+
const markerIndex = buffer.indexOf("</body>");
|
|
199
|
+
if (markerIndex !== -1) {
|
|
200
|
+
controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
|
|
201
|
+
buffer = buffer.slice(markerIndex);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
controller.enqueue(encoder.encode(buffer));
|
|
205
|
+
buffer = "";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
86
208
|
phase = "inner-suspended";
|
|
87
209
|
}
|
|
88
210
|
else {
|
|
@@ -103,6 +225,10 @@ export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, e
|
|
|
103
225
|
}
|
|
104
226
|
}
|
|
105
227
|
else if (phase === "inner-suspended") {
|
|
228
|
+
if (innerSuspendedRemains) {
|
|
229
|
+
controller.enqueue(encoder.encode(innerSuspendedRemains));
|
|
230
|
+
innerSuspendedRemains = "";
|
|
231
|
+
}
|
|
106
232
|
const { done, value } = await innerReader.read();
|
|
107
233
|
if (done) {
|
|
108
234
|
phase = "outer-end";
|
|
@@ -132,12 +258,14 @@ export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, e
|
|
|
132
258
|
return new ReadableStream({
|
|
133
259
|
start(controller) {
|
|
134
260
|
outerReader = outerHtml.getReader();
|
|
135
|
-
innerReader =
|
|
261
|
+
innerReader = appBodyStream.getReader();
|
|
262
|
+
hoistedTagsReader = hoistedTagsStream.getReader();
|
|
136
263
|
pump(controller).catch((e) => controller.error(e));
|
|
137
264
|
},
|
|
138
265
|
cancel(reason) {
|
|
139
266
|
outerReader?.cancel(reason);
|
|
140
267
|
innerReader?.cancel(reason);
|
|
268
|
+
hoistedTagsReader?.cancel(reason);
|
|
141
269
|
},
|
|
142
270
|
});
|
|
143
271
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { stitchDocumentAndAppStreams } from "./stitchDocumentAndAppStreams.js";
|
|
3
|
+
function stringToStream(str) {
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
return new ReadableStream({
|
|
6
|
+
start(controller) {
|
|
7
|
+
controller.enqueue(encoder.encode(str));
|
|
8
|
+
controller.close();
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function streamToString(stream) {
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
const reader = stream.getReader();
|
|
15
|
+
let result = "";
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
function pump() {
|
|
18
|
+
return reader
|
|
19
|
+
.read()
|
|
20
|
+
.then(({ done, value }) => {
|
|
21
|
+
if (done) {
|
|
22
|
+
resolve(result);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
result += decoder.decode(value, { stream: true });
|
|
26
|
+
return pump();
|
|
27
|
+
})
|
|
28
|
+
.catch(reject);
|
|
29
|
+
}
|
|
30
|
+
return pump();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function createChunkedStream(chunks) {
|
|
34
|
+
const encoder = new TextEncoder();
|
|
35
|
+
return new ReadableStream({
|
|
36
|
+
start(controller) {
|
|
37
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
38
|
+
controller.enqueue(encoder.encode(chunks[i]));
|
|
39
|
+
}
|
|
40
|
+
controller.close();
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
describe("stitchDocumentAndAppStreams", () => {
|
|
45
|
+
const startMarker = '<div id="rwsdk-app-start" />';
|
|
46
|
+
const endMarker = '<div id="rwsdk-app-end"></div>';
|
|
47
|
+
describe("meta tag hoisting", () => {
|
|
48
|
+
it("extracts and prepends single title tag", async () => {
|
|
49
|
+
const outerHtml = `<!DOCTYPE html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8" />
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
${startMarker}
|
|
56
|
+
<script src="/client.js"></script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`;
|
|
59
|
+
const innerHtml = `<title>Page Title</title><div>App content</div>${endMarker}`;
|
|
60
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
61
|
+
expect(result).toContain(`<title>Page Title</title>`);
|
|
62
|
+
expect(result.indexOf(`<title>Page Title</title>`)).toBeLessThan(result.indexOf(`<!DOCTYPE html>`));
|
|
63
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
64
|
+
});
|
|
65
|
+
it("extracts and prepends multiple hoisted tags", async () => {
|
|
66
|
+
const outerHtml = `<!DOCTYPE html>
|
|
67
|
+
<html>
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="utf-8" />
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
${startMarker}
|
|
73
|
+
<script src="/client.js"></script>
|
|
74
|
+
</body>
|
|
75
|
+
</html>`;
|
|
76
|
+
const innerHtml = `<title>Page Title</title><meta name="description" content="Test" /><link rel="stylesheet" href="/styles.css" /><div>App content</div>${endMarker}`;
|
|
77
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
78
|
+
expect(result).toContain(`<title>Page Title</title>`);
|
|
79
|
+
expect(result).toContain(`<meta name="description" content="Test" />`);
|
|
80
|
+
expect(result).toContain(`<link rel="stylesheet" href="/styles.css" />`);
|
|
81
|
+
const hoistedStart = result.indexOf(`<title>Page Title</title>`);
|
|
82
|
+
const doctypeStart = result.indexOf(`<!DOCTYPE html>`);
|
|
83
|
+
expect(hoistedStart).toBeLessThan(doctypeStart);
|
|
84
|
+
});
|
|
85
|
+
it("handles app stream with no hoisted tags", async () => {
|
|
86
|
+
const outerHtml = `<!DOCTYPE html>
|
|
87
|
+
<html>
|
|
88
|
+
<head>
|
|
89
|
+
<meta charset="utf-8" />
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
${startMarker}
|
|
93
|
+
<script src="/client.js"></script>
|
|
94
|
+
</body>
|
|
95
|
+
</html>`;
|
|
96
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
97
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
98
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
99
|
+
expect(result).not.toContain(`<title>`);
|
|
100
|
+
});
|
|
101
|
+
it("handles hoisted tags split across chunks", async () => {
|
|
102
|
+
const outerHtml = `<!DOCTYPE html>
|
|
103
|
+
<html>
|
|
104
|
+
<head>
|
|
105
|
+
<meta charset="utf-8" />
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
${startMarker}
|
|
109
|
+
<script src="/client.js"></script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>`;
|
|
112
|
+
const innerHtmlChunks = [
|
|
113
|
+
`<title>Page `,
|
|
114
|
+
`Title</title><meta name="description" `,
|
|
115
|
+
`content="Test" /><div>App content</div>${endMarker}`,
|
|
116
|
+
];
|
|
117
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
|
|
118
|
+
expect(result).toContain(`<title>Page Title</title>`);
|
|
119
|
+
expect(result).toContain(`<meta name="description" content="Test" />`);
|
|
120
|
+
const hoistedStart = result.indexOf(`<title>Page Title</title>`);
|
|
121
|
+
const doctypeStart = result.indexOf(`<!DOCTYPE html>`);
|
|
122
|
+
expect(hoistedStart).toBeLessThan(doctypeStart);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("basic stitching flow", () => {
|
|
126
|
+
it("stitches document head, app shell, and document tail", async () => {
|
|
127
|
+
const outerHtml = `<!DOCTYPE html>
|
|
128
|
+
<html>
|
|
129
|
+
<head>
|
|
130
|
+
<meta charset="utf-8" />
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
${startMarker}
|
|
134
|
+
<script src="/client.js"></script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>`;
|
|
137
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
138
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
139
|
+
expect(result).toContain(`<!DOCTYPE html>`);
|
|
140
|
+
expect(result).toContain(`<head>`);
|
|
141
|
+
expect(result).toContain(`<meta charset="utf-8" />`);
|
|
142
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
143
|
+
expect(result).toContain(`<script src="/client.js"></script>`);
|
|
144
|
+
expect(result).toContain(`</body>`);
|
|
145
|
+
expect(result).toContain(`</html>`);
|
|
146
|
+
const doctypeIndex = result.indexOf(`<!DOCTYPE html>`);
|
|
147
|
+
const appContentIndex = result.indexOf(`<div>App content</div>`);
|
|
148
|
+
const scriptIndex = result.indexOf(`<script src="/client.js"></script>`);
|
|
149
|
+
const bodyCloseIndex = result.indexOf(`</body>`);
|
|
150
|
+
expect(doctypeIndex).toBeLessThan(appContentIndex);
|
|
151
|
+
expect(appContentIndex).toBeLessThan(scriptIndex);
|
|
152
|
+
expect(scriptIndex).toBeLessThan(bodyCloseIndex);
|
|
153
|
+
});
|
|
154
|
+
it("removes start marker from output", async () => {
|
|
155
|
+
const outerHtml = `<!DOCTYPE html>
|
|
156
|
+
<html>
|
|
157
|
+
<body>
|
|
158
|
+
${startMarker}
|
|
159
|
+
<script src="/client.js"></script>
|
|
160
|
+
</body>
|
|
161
|
+
</html>`;
|
|
162
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
163
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
164
|
+
expect(result).not.toContain(startMarker);
|
|
165
|
+
});
|
|
166
|
+
it("preserves end marker in output", async () => {
|
|
167
|
+
const outerHtml = `<!DOCTYPE html>
|
|
168
|
+
<html>
|
|
169
|
+
<body>
|
|
170
|
+
${startMarker}
|
|
171
|
+
<script src="/client.js"></script>
|
|
172
|
+
</body>
|
|
173
|
+
</html>`;
|
|
174
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
175
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
176
|
+
expect(result).toContain(endMarker);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe("suspense boundaries (suspended content)", () => {
|
|
180
|
+
it("streams suspended content after script tag", async () => {
|
|
181
|
+
const outerHtml = `<!DOCTYPE html>
|
|
182
|
+
<html>
|
|
183
|
+
<head>
|
|
184
|
+
<meta charset="utf-8" />
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
${startMarker}
|
|
188
|
+
<script src="/client.js"></script>
|
|
189
|
+
</body>
|
|
190
|
+
</html>`;
|
|
191
|
+
const innerHtml = `<div>Initial content</div>${endMarker}<div>Suspended content</div>`;
|
|
192
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
193
|
+
expect(result).toContain(`<div>Initial content</div>`);
|
|
194
|
+
expect(result).toContain(`<script src="/client.js"></script>`);
|
|
195
|
+
expect(result).toContain(`<div>Suspended content</div>`);
|
|
196
|
+
const initialIndex = result.indexOf(`<div>Initial content</div>`);
|
|
197
|
+
const scriptIndex = result.indexOf(`<script src="/client.js"></script>`);
|
|
198
|
+
const suspendedIndex = result.indexOf(`<div>Suspended content</div>`);
|
|
199
|
+
const bodyCloseIndex = result.indexOf(`</body>`);
|
|
200
|
+
expect(initialIndex).toBeLessThan(scriptIndex);
|
|
201
|
+
expect(scriptIndex).toBeLessThan(suspendedIndex);
|
|
202
|
+
expect(suspendedIndex).toBeLessThan(bodyCloseIndex);
|
|
203
|
+
});
|
|
204
|
+
it("handles multiple suspended content chunks", async () => {
|
|
205
|
+
const outerHtml = `<!DOCTYPE html>
|
|
206
|
+
<html>
|
|
207
|
+
<body>
|
|
208
|
+
${startMarker}
|
|
209
|
+
<script src="/client.js"></script>
|
|
210
|
+
</body>
|
|
211
|
+
</html>`;
|
|
212
|
+
const innerHtml = `<div>Initial</div>${endMarker}<div>Suspended 1</div><div>Suspended 2</div>`;
|
|
213
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
214
|
+
expect(result).toContain(`<div>Initial</div>`);
|
|
215
|
+
expect(result).toContain(`<div>Suspended 1</div>`);
|
|
216
|
+
expect(result).toContain(`<div>Suspended 2</div>`);
|
|
217
|
+
const scriptIndex = result.indexOf(`<script src="/client.js"></script>`);
|
|
218
|
+
const suspended1Index = result.indexOf(`<div>Suspended 1</div>`);
|
|
219
|
+
const suspended2Index = result.indexOf(`<div>Suspended 2</div>`);
|
|
220
|
+
expect(scriptIndex).toBeLessThan(suspended1Index);
|
|
221
|
+
expect(suspended1Index).toBeLessThan(suspended2Index);
|
|
222
|
+
});
|
|
223
|
+
it("handles app stream with no suspended content", async () => {
|
|
224
|
+
const outerHtml = `<!DOCTYPE html>
|
|
225
|
+
<html>
|
|
226
|
+
<body>
|
|
227
|
+
${startMarker}
|
|
228
|
+
<script src="/client.js"></script>
|
|
229
|
+
</body>
|
|
230
|
+
</html>`;
|
|
231
|
+
const innerHtml = `<div>Initial content</div>${endMarker}`;
|
|
232
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
233
|
+
expect(result).toContain(`<div>Initial content</div>`);
|
|
234
|
+
expect(result).toContain(`<script src="/client.js"></script>`);
|
|
235
|
+
expect(result).not.toContain(`Suspended`);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
describe("chunked streams", () => {
|
|
239
|
+
it("handles document stream split across chunks", async () => {
|
|
240
|
+
const outerHtmlChunks = [
|
|
241
|
+
`<!DOCTYPE html>\n<html>\n<head>\n`,
|
|
242
|
+
` <meta charset="utf-8" />\n</head>\n<body>\n`,
|
|
243
|
+
` ${startMarker}\n <script src="/client.js"></script>\n</body>\n</html>`,
|
|
244
|
+
];
|
|
245
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
246
|
+
const result = await streamToString(stitchDocumentAndAppStreams(createChunkedStream(outerHtmlChunks), stringToStream(innerHtml), startMarker, endMarker));
|
|
247
|
+
expect(result).toContain(`<!DOCTYPE html>`);
|
|
248
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
249
|
+
expect(result).toContain(`<script src="/client.js"></script>`);
|
|
250
|
+
});
|
|
251
|
+
it("handles app stream split across chunks", async () => {
|
|
252
|
+
const outerHtml = `<!DOCTYPE html>
|
|
253
|
+
<html>
|
|
254
|
+
<body>
|
|
255
|
+
${startMarker}
|
|
256
|
+
<script src="/client.js"></script>
|
|
257
|
+
</body>
|
|
258
|
+
</html>`;
|
|
259
|
+
const innerHtmlChunks = [
|
|
260
|
+
`<div>Initial `,
|
|
261
|
+
`content</div>${endMarker}`,
|
|
262
|
+
`<div>Suspended content</div>`,
|
|
263
|
+
];
|
|
264
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
|
|
265
|
+
expect(result).toContain(`<div>Initial content</div>`);
|
|
266
|
+
expect(result).toContain(`<div>Suspended content</div>`);
|
|
267
|
+
});
|
|
268
|
+
it("handles markers split across chunks", async () => {
|
|
269
|
+
const outerHtml = `<!DOCTYPE html>
|
|
270
|
+
<html>
|
|
271
|
+
<body>
|
|
272
|
+
${startMarker}
|
|
273
|
+
<script src="/client.js"></script>
|
|
274
|
+
</body>
|
|
275
|
+
</html>`;
|
|
276
|
+
const innerHtmlChunks = [
|
|
277
|
+
`<div>App content</div><div id="rwsdk-app-end`,
|
|
278
|
+
`"></div>`,
|
|
279
|
+
];
|
|
280
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
|
|
281
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
282
|
+
expect(result).toContain(endMarker);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
describe("edge cases", () => {
|
|
286
|
+
it("handles empty app stream", async () => {
|
|
287
|
+
const outerHtml = `<!DOCTYPE html>
|
|
288
|
+
<html>
|
|
289
|
+
<body>
|
|
290
|
+
${startMarker}
|
|
291
|
+
<script src="/client.js"></script>
|
|
292
|
+
</body>
|
|
293
|
+
</html>`;
|
|
294
|
+
const innerHtml = ``;
|
|
295
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
296
|
+
expect(result).toContain(`<!DOCTYPE html>`);
|
|
297
|
+
expect(result).toContain(`<script src="/client.js"></script>`);
|
|
298
|
+
expect(result).toContain(`</body>`);
|
|
299
|
+
});
|
|
300
|
+
it("handles empty outer stream", async () => {
|
|
301
|
+
const outerHtml = ``;
|
|
302
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
303
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
304
|
+
expect(result).toBe(`<div>App content</div>${endMarker}`);
|
|
305
|
+
});
|
|
306
|
+
it("handles app stream ending before end marker", async () => {
|
|
307
|
+
const outerHtml = `<!DOCTYPE html>
|
|
308
|
+
<html>
|
|
309
|
+
<body>
|
|
310
|
+
${startMarker}
|
|
311
|
+
<script src="/client.js"></script>
|
|
312
|
+
</body>
|
|
313
|
+
</html>`;
|
|
314
|
+
const innerHtml = `<div>App content</div>`;
|
|
315
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
316
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
317
|
+
expect(result).toContain(`<script src="/client.js"></script>`);
|
|
318
|
+
});
|
|
319
|
+
it("handles outer stream ending before start marker", async () => {
|
|
320
|
+
const outerHtml = `<!DOCTYPE html>
|
|
321
|
+
<html>
|
|
322
|
+
<head>
|
|
323
|
+
<meta charset="utf-8" />
|
|
324
|
+
</head>`;
|
|
325
|
+
const innerHtml = `<div>App content</div>${endMarker}`;
|
|
326
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
327
|
+
expect(result).toContain(`<!DOCTYPE html>`);
|
|
328
|
+
expect(result).toContain(`<meta charset="utf-8" />`);
|
|
329
|
+
expect(result).toContain(`<div>App content</div>`);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
describe("complete flow verification", () => {
|
|
333
|
+
it("correctly orders all phases: head -> shell -> script -> suspended -> close", async () => {
|
|
334
|
+
const outerHtml = `<!DOCTYPE html>
|
|
335
|
+
<html>
|
|
336
|
+
<head>
|
|
337
|
+
<meta charset="utf-8" />
|
|
338
|
+
<title>Test Page</title>
|
|
339
|
+
</head>
|
|
340
|
+
<body>
|
|
341
|
+
${startMarker}
|
|
342
|
+
<script src="/client.js"></script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>`;
|
|
345
|
+
const innerHtml = `<div>Initial content</div>${endMarker}<div>Suspended content</div>`;
|
|
346
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
347
|
+
const doctypeIndex = result.indexOf(`<!DOCTYPE html>`);
|
|
348
|
+
const headIndex = result.indexOf(`<head>`);
|
|
349
|
+
const titleIndex = result.indexOf(`<title>Test Page</title>`);
|
|
350
|
+
const initialIndex = result.indexOf(`<div>Initial content</div>`);
|
|
351
|
+
const scriptIndex = result.indexOf(`<script src="/client.js"></script>`);
|
|
352
|
+
const suspendedIndex = result.indexOf(`<div>Suspended content</div>`);
|
|
353
|
+
const bodyCloseIndex = result.indexOf(`</body>`);
|
|
354
|
+
const htmlCloseIndex = result.indexOf(`</html>`);
|
|
355
|
+
expect(doctypeIndex).toBeLessThan(headIndex);
|
|
356
|
+
expect(headIndex).toBeLessThan(titleIndex);
|
|
357
|
+
expect(titleIndex).toBeLessThan(initialIndex);
|
|
358
|
+
expect(initialIndex).toBeLessThan(scriptIndex);
|
|
359
|
+
expect(scriptIndex).toBeLessThan(suspendedIndex);
|
|
360
|
+
expect(suspendedIndex).toBeLessThan(bodyCloseIndex);
|
|
361
|
+
expect(bodyCloseIndex).toBeLessThan(htmlCloseIndex);
|
|
362
|
+
});
|
|
363
|
+
it("preserves content structure and markers", async () => {
|
|
364
|
+
const outerHtml = `<!DOCTYPE html>
|
|
365
|
+
<html>
|
|
366
|
+
<body>
|
|
367
|
+
${startMarker}
|
|
368
|
+
<script src="/client.js"></script>
|
|
369
|
+
</body>
|
|
370
|
+
</html>`;
|
|
371
|
+
const innerHtml = `<div id="app">
|
|
372
|
+
<h1>Hello</h1>
|
|
373
|
+
<p>World</p>
|
|
374
|
+
</div>${endMarker}<div>More content</div>`;
|
|
375
|
+
const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
|
|
376
|
+
expect(result).toContain(`<div id="app">`);
|
|
377
|
+
expect(result).toContain(`<h1>Hello</h1>`);
|
|
378
|
+
expect(result).toContain(`<p>World</p>`);
|
|
379
|
+
expect(result).toContain(endMarker);
|
|
380
|
+
expect(result).toContain(`<div>More content</div>`);
|
|
381
|
+
expect(result).not.toContain(startMarker);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
package/package.json
CHANGED