rwsdk 1.5.3 → 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 () => {
@@ -1,5 +1,8 @@
1
1
  import { Plugin } from "vite";
2
2
  import { ConfigurableEsbuildOptions } from "./runDirectivesScan.mjs";
3
+ export declare const SSR_BRIDGE_ROLLDOWN_EXPERIMENTAL: {
4
+ lazyBarrel: boolean;
5
+ };
3
6
  export declare const configPlugin: ({ silent, projectRootDir, workerEntryPathname, clientFiles, serverFiles, clientEntryPoints, esbuildOptions, }: {
4
7
  silent: boolean;
5
8
  projectRootDir: string;
@@ -4,6 +4,16 @@ import { INTERMEDIATE_SSR_BRIDGE_PATH } from "../lib/constants.mjs";
4
4
  import { buildApp } from "./buildApp.mjs";
5
5
  import { externalModules } from "./constants.mjs";
6
6
  import { ssrBridgeWrapPlugin } from "./ssrBridgeWrapPlugin.mjs";
7
+ export const SSR_BRIDGE_ROLLDOWN_EXPERIMENTAL = {
8
+ // Rolldown's lazy barrel optimization can keep code that references imports it
9
+ // already pruned when `codeSplitting` is false. The SSR bridge intentionally
10
+ // disables code splitting, so keep lazy barrel off for this build until the
11
+ // upstream issue is fixed.
12
+ // See:
13
+ // - https://github.com/rolldown/rolldown/issues/9691#issuecomment-4666238497
14
+ // - https://github.com/rolldown/rolldown/issues/9964
15
+ lazyBarrel: false,
16
+ };
7
17
  export const configPlugin = ({ silent, projectRootDir, workerEntryPathname, clientFiles, serverFiles, clientEntryPoints, esbuildOptions, }) => ({
8
18
  name: "rwsdk:config",
9
19
  enforce: "pre",
@@ -173,6 +183,7 @@ export const configPlugin = ({ silent, projectRootDir, workerEntryPathname, clie
173
183
  },
174
184
  outDir: path.dirname(INTERMEDIATE_SSR_BRIDGE_PATH),
175
185
  rolldownOptions: {
186
+ experimental: SSR_BRIDGE_ROLLDOWN_EXPERIMENTAL,
176
187
  output: {
177
188
  // context(justinvdm, 15 Sep 2025): The SSR bundle is a
178
189
  // pre-compiled artifact. When the linker pass bundles it into
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { build } from "vite";
6
+ import { describe, expect, it } from "vitest";
7
+ import { SSR_BRIDGE_ROLLDOWN_EXPERIMENTAL } from "./configPlugin.mjs";
8
+ describe("SSR bridge Rolldown options", () => {
9
+ it("keeps side-effect-free barrels valid when code splitting is disabled", async () => {
10
+ const root = await mkdtemp(path.join(tmpdir(), "rwsdk-ssr-bridge-lazy-barrel-"));
11
+ try {
12
+ await mkdir(path.join(root, "src", "lib"), { recursive: true });
13
+ await writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module", private: true, sideEffects: false }));
14
+ await writeFile(path.join(root, "src", "lib", "eg.js"), `const primitives = { string: { tag: "s" }, number: { tag: "n" } };
15
+ const higher = { object: (shape) => ({ tag: "o", shape, parse: () => true }) };
16
+ export const eg = { ...primitives, ...higher };
17
+ `);
18
+ await writeFile(path.join(root, "src", "lib", "account.js"), `import { eg } from "./eg.js";
19
+ export const AccountSettings = eg.object({ a: eg.string, n: eg.number });
20
+ export const AccountMeta = eg.object({ b: eg.string });
21
+ `);
22
+ await writeFile(path.join(root, "src", "lib", "index.js"), `export * from "./account.js";
23
+ export const objectKeys = (o) => Object.keys(o);
24
+ `);
25
+ await writeFile(path.join(root, "src", "story.js"), `import { objectKeys } from "./lib/index.js";
26
+ export const run = () => (objectKeys ? "ok" : "no");
27
+ `);
28
+ await writeFile(path.join(root, "src", "entry.cjs"), `const { run } = require("./story.js");
29
+ console.log(run());
30
+ `);
31
+ await build({
32
+ configFile: false,
33
+ root,
34
+ logLevel: "silent",
35
+ build: {
36
+ ssr: true,
37
+ minify: true,
38
+ outDir: path.join(root, "dist"),
39
+ emptyOutDir: true,
40
+ lib: {
41
+ entry: {
42
+ index: path.join(root, "src", "entry.cjs"),
43
+ },
44
+ formats: ["es"],
45
+ fileName: () => "index.js",
46
+ },
47
+ rolldownOptions: {
48
+ experimental: SSR_BRIDGE_ROLLDOWN_EXPERIMENTAL,
49
+ output: {
50
+ codeSplitting: false,
51
+ },
52
+ },
53
+ },
54
+ });
55
+ const outputPath = path.join(root, "dist", "index.js");
56
+ const logs = [];
57
+ const originalLog = console.log;
58
+ console.log = (...args) => {
59
+ logs.push(args.join(" "));
60
+ };
61
+ try {
62
+ await import(pathToFileURL(outputPath).href);
63
+ }
64
+ finally {
65
+ console.log = originalLog;
66
+ }
67
+ expect(logs).toContain("ok");
68
+ }
69
+ finally {
70
+ await rm(root, { recursive: true, force: true });
71
+ }
72
+ });
73
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.5.3",
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": {