rwsdk 1.5.4 → 1.5.5

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.
@@ -19,6 +19,24 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
19
19
  const decoder = new TextDecoder();
20
20
  const encoder = new TextEncoder();
21
21
  const nonHoistedTagPattern = /<(?!(?:\/)?(?:title|meta|link|style|base)[\s>\/])(?![!?])/i;
22
+ // Longest hoistable name is 5 chars (title/style); a match candidate at
23
+ // index i needs buffer[i..] to have at least 1 (optional "/") + 5 (name)
24
+ // + 1 (terminating char) = 7 chars of context past "<" before it can be
25
+ // trusted as final. Otherwise the tag name may be mid-stream-truncated,
26
+ // causing a false-positive match on e.g. "<t" and severing <title>.
27
+ const MIN_TRAILING_CONTEXT = 7;
28
+ function findTrustedMatch(buf, streamDone) {
29
+ const match = buf.match(nonHoistedTagPattern);
30
+ if (!match || typeof match.index !== "number")
31
+ return null;
32
+ if (streamDone)
33
+ return match; // no more data coming; trust it
34
+ // buf.length - match.index counts the "<" itself, so subtract 1 to
35
+ // get the number of confirmed characters *after* it.
36
+ if (buf.length - match.index - 1 < MIN_TRAILING_CONTEXT)
37
+ return null; // ambiguous, wait for more data
38
+ return match;
39
+ }
22
40
  let sourceReader;
23
41
  let appBodyController = null;
24
42
  let buffer = "";
@@ -35,7 +53,7 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
35
53
  const { done, value } = await sourceReader.read();
36
54
  if (done) {
37
55
  if (buffer) {
38
- const match = buffer.match(nonHoistedTagPattern);
56
+ const match = findTrustedMatch(buffer, true);
39
57
  if (match && typeof match.index === "number") {
40
58
  const hoistedPart = buffer.slice(0, match.index);
41
59
  controller.enqueue(encoder.encode(hoistedPart));
@@ -52,7 +70,7 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
52
70
  return;
53
71
  }
54
72
  buffer += decoder.decode(value, { stream: true });
55
- const match = buffer.match(nonHoistedTagPattern);
73
+ const match = findTrustedMatch(buffer, false);
56
74
  if (match && typeof match.index === "number") {
57
75
  const hoistedPart = buffer.slice(0, match.index);
58
76
  const appPart = buffer.slice(match.index);
@@ -76,9 +94,17 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
76
94
  }
77
95
  else {
78
96
  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);
97
+ // Never flush past a point that could still be part of
98
+ // an ambiguous, not-yet-trusted candidate tag. Only
99
+ // flush content strictly before the last unresolved
100
+ // "<" in the buffer, so a truncated tag name can never
101
+ // be split across a flush boundary.
102
+ const earliestOpenBracket = buffer.lastIndexOf("<");
103
+ const safeFlushLimit = earliestOpenBracket === -1 ? buffer.length : earliestOpenBracket;
104
+ const effectiveFlushIndex = Math.min(flushIndex, safeFlushLimit - 1);
105
+ if (flushIndex !== -1 && effectiveFlushIndex >= 0) {
106
+ controller.enqueue(encoder.encode(buffer.slice(0, effectiveFlushIndex + 1)));
107
+ buffer = buffer.slice(effectiveFlushIndex + 1);
82
108
  }
83
109
  await pump();
84
110
  }
@@ -155,6 +155,60 @@ describe("stitchDocumentAndAppStreams", () => {
155
155
  const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
156
156
  expect(result.trim().startsWith(`<!DOCTYPE html>`)).toBe(true);
157
157
  });
158
+ it("does not split an unclosed title tag across a chunk boundary", async () => {
159
+ const outerHtml = `<!DOCTYPE html>
160
+ <html>
161
+ <head>
162
+ <meta charset="utf-8" />
163
+ </head>
164
+ <body>
165
+ ${startMarker}
166
+ <script src="/client.js"></script>
167
+ </body>
168
+ </html>`;
169
+ // The ">" of the closing </title> arrives in the next chunk. A naive
170
+ // regex scan of the first chunk can falsely treat the "</title" as a
171
+ // non-hoisted tag boundary, leaving the <title> unclosed in <head>.
172
+ const innerHtmlChunks = [
173
+ `<title>Page Title</title`,
174
+ `><div>App content</div>${endMarker}`,
175
+ ];
176
+ const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
177
+ expect(result).toContain(`<title>Page Title</title>`);
178
+ expect(result).toMatch(/<head>[\s\S]*<title>Page Title<\/title>[\s\S]*<\/head>/);
179
+ expect(result).toContain(`<div>App content</div>`);
180
+ const titleIndex = result.indexOf(`<title>Page Title</title>`);
181
+ const headCloseIndex = result.indexOf(`</head>`);
182
+ const bodyIndex = result.indexOf(`<body>`);
183
+ expect(titleIndex).toBeLessThan(headCloseIndex);
184
+ expect(titleIndex).toBeLessThan(bodyIndex);
185
+ });
186
+ it("does not truncate a hoisted tag name across a chunk boundary", async () => {
187
+ const outerHtml = `<!DOCTYPE html>
188
+ <html>
189
+ <head>
190
+ <meta charset="utf-8" />
191
+ </head>
192
+ <body>
193
+ ${startMarker}
194
+ <script src="/client.js"></script>
195
+ </body>
196
+ </html>`;
197
+ // The "<title" tag name is split between chunks. A naive regex scan of
198
+ // the first chunk (ending at "<t") can falsely treat that "<" as a
199
+ // non-hoisted tag boundary and fail to hoist the title.
200
+ const innerHtmlChunks = [
201
+ `<t`,
202
+ `itle>Page Title</title><div>App content</div>${endMarker}`,
203
+ ];
204
+ const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
205
+ expect(result).toContain(`<title>Page Title</title>`);
206
+ expect(result).toMatch(/<head>[\s\S]*<title>Page Title<\/title>[\s\S]*<\/head>/);
207
+ expect(result).toContain(`<div>App content</div>`);
208
+ const titleIndex = result.indexOf(`<title>Page Title</title>`);
209
+ const headCloseIndex = result.indexOf(`</head>`);
210
+ expect(titleIndex).toBeLessThan(headCloseIndex);
211
+ });
158
212
  });
159
213
  describe("basic stitching flow", () => {
160
214
  it("stitches document head, app shell, and document tail", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {