rwsdk 1.0.0-alpha.16 → 1.0.0-alpha.17

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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A utility to orchestrate and interleave two ReadableStreams (a document shell and an app shell)
3
+ * based on a set of markers within their content. This is designed to solve a specific
4
+ * race condition in streaming Server-Side Rendering (SSR) with Suspense.
5
+ *
6
+ * The logic is as follows:
7
+ * 1. Stream the document until a start marker is found.
8
+ * 2. Switch to the app stream and stream it until an end marker is found. This is the non-suspended shell.
9
+ * 3. Switch back to the document stream and stream it until the closing body tag. This sends the client script.
10
+ * 4. Switch back to the app stream and stream the remainder (the suspended content).
11
+ * 5. Switch back to the document stream and stream the remainder (closing body and html tags).
12
+ *
13
+ * @param outerHtml The stream for the document shell (`<Document>`).
14
+ * @param innerHtml The stream for the application's content.
15
+ * @param startMarker The marker in the document to start injecting the app.
16
+ * @param endMarker The marker in the app stream that signals the end of the initial, non-suspended render.
17
+ */
18
+ export declare function stitchDocumentAndAppStreams(outerHtml: ReadableStream<Uint8Array>, innerHtml: ReadableStream<Uint8Array>, startMarker: string, endMarker: string): ReadableStream<Uint8Array>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A utility to orchestrate and interleave two ReadableStreams (a document shell and an app shell)
3
+ * based on a set of markers within their content. This is designed to solve a specific
4
+ * race condition in streaming Server-Side Rendering (SSR) with Suspense.
5
+ *
6
+ * The logic is as follows:
7
+ * 1. Stream the document until a start marker is found.
8
+ * 2. Switch to the app stream and stream it until an end marker is found. This is the non-suspended shell.
9
+ * 3. Switch back to the document stream and stream it until the closing body tag. This sends the client script.
10
+ * 4. Switch back to the app stream and stream the remainder (the suspended content).
11
+ * 5. Switch back to the document stream and stream the remainder (closing body and html tags).
12
+ *
13
+ * @param outerHtml The stream for the document shell (`<Document>`).
14
+ * @param innerHtml The stream for the application's content.
15
+ * @param startMarker The marker in the document to start injecting the app.
16
+ * @param endMarker The marker in the app stream that signals the end of the initial, non-suspended render.
17
+ */
18
+ export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, endMarker) {
19
+ const decoder = new TextDecoder();
20
+ const encoder = new TextEncoder();
21
+ let outerReader;
22
+ let innerReader;
23
+ let buffer = "";
24
+ let outerBufferRemains = "";
25
+ let phase = "outer-head";
26
+ const pump = async (controller) => {
27
+ try {
28
+ if (phase === "outer-head") {
29
+ const { done, value } = await outerReader.read();
30
+ if (done) {
31
+ if (buffer)
32
+ controller.enqueue(encoder.encode(buffer));
33
+ controller.close();
34
+ return;
35
+ }
36
+ buffer += decoder.decode(value, { stream: true });
37
+ const markerIndex = buffer.indexOf(startMarker);
38
+ if (markerIndex !== -1) {
39
+ controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
40
+ outerBufferRemains = buffer.slice(markerIndex + startMarker.length);
41
+ buffer = "";
42
+ phase = "inner-shell";
43
+ }
44
+ else {
45
+ const flushIndex = buffer.lastIndexOf("\n");
46
+ if (flushIndex !== -1) {
47
+ controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
48
+ buffer = buffer.slice(flushIndex + 1);
49
+ }
50
+ }
51
+ }
52
+ else if (phase === "inner-shell") {
53
+ const { done, value } = await innerReader.read();
54
+ if (done) {
55
+ if (buffer)
56
+ controller.enqueue(encoder.encode(buffer));
57
+ phase = "outer-tail";
58
+ }
59
+ else {
60
+ buffer += decoder.decode(value, { stream: true });
61
+ const markerIndex = buffer.indexOf(endMarker);
62
+ if (markerIndex !== -1) {
63
+ const endOfMarkerIndex = markerIndex + endMarker.length;
64
+ controller.enqueue(encoder.encode(buffer.slice(0, endOfMarkerIndex)));
65
+ buffer = buffer.slice(endOfMarkerIndex);
66
+ phase = "outer-tail";
67
+ }
68
+ else {
69
+ const flushIndex = buffer.lastIndexOf("\n");
70
+ if (flushIndex !== -1) {
71
+ controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
72
+ buffer = buffer.slice(flushIndex + 1);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ else if (phase === "outer-tail") {
78
+ if (outerBufferRemains) {
79
+ buffer = outerBufferRemains;
80
+ outerBufferRemains = "";
81
+ }
82
+ const { done, value } = await outerReader.read();
83
+ if (done) {
84
+ if (buffer)
85
+ controller.enqueue(encoder.encode(buffer));
86
+ phase = "inner-suspended";
87
+ }
88
+ else {
89
+ buffer += decoder.decode(value, { stream: true });
90
+ const markerIndex = buffer.indexOf("</body>");
91
+ if (markerIndex !== -1) {
92
+ controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
93
+ buffer = buffer.slice(markerIndex);
94
+ phase = "inner-suspended";
95
+ }
96
+ else {
97
+ const flushIndex = buffer.lastIndexOf("\n");
98
+ if (flushIndex !== -1) {
99
+ controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
100
+ buffer = buffer.slice(flushIndex + 1);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ else if (phase === "inner-suspended") {
106
+ const { done, value } = await innerReader.read();
107
+ if (done) {
108
+ phase = "outer-end";
109
+ }
110
+ else {
111
+ controller.enqueue(value);
112
+ }
113
+ }
114
+ else if (phase === "outer-end") {
115
+ if (buffer) {
116
+ controller.enqueue(encoder.encode(buffer));
117
+ buffer = "";
118
+ }
119
+ const { done, value } = await outerReader.read();
120
+ if (done) {
121
+ controller.close();
122
+ return;
123
+ }
124
+ controller.enqueue(value);
125
+ }
126
+ await pump(controller);
127
+ }
128
+ catch (e) {
129
+ controller.error(e);
130
+ }
131
+ };
132
+ return new ReadableStream({
133
+ start(controller) {
134
+ outerReader = outerHtml.getReader();
135
+ innerReader = innerHtml.getReader();
136
+ pump(controller).catch((e) => controller.error(e));
137
+ },
138
+ cancel(reason) {
139
+ outerReader?.cancel(reason);
140
+ innerReader?.cancel(reason);
141
+ },
142
+ });
143
+ }
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Preloads } from "./preloads.js";
3
3
  import { Stylesheets } from "./stylesheets.js";
4
4
  import { renderHtmlStream, createThenableFromReadableStream, } from "rwsdk/__ssr_bridge";
5
- import { injectHtmlAtMarker } from "../lib/injectHtmlAtMarker.js";
5
+ import { stitchDocumentAndAppStreams } from "../lib/stitchDocumentAndAppStreams.js";
6
6
  export const renderDocumentHtmlStream = async ({ rscPayloadStream, Document, requestInfo, shouldSSR, onError, }) => {
7
7
  // Extract the app node from the RSC payload
8
8
  const rscAppThenable = createThenableFromReadableStream(rscPayloadStream);
9
- const { node: appNode } = (await rscAppThenable);
9
+ const { node: innerAppNode } = (await rscAppThenable);
10
10
  // todo(justinvdm, 18 Jun 2025): We can build on this later to allow users
11
11
  // surface context. e.g:
12
12
  // * we assign `user: requestInfo.clientCtx` here
@@ -21,7 +21,7 @@ export const renderDocumentHtmlStream = async ({ rscPayloadStream, Document, req
21
21
  // Create the outer document with a marker for injection
22
22
  const documentElement = (_jsxs(Document, { ...requestInfo, children: [_jsx("script", { nonce: requestInfo.rw.nonce, dangerouslySetInnerHTML: {
23
23
  __html: `globalThis.__RWSDK_CONTEXT = ${JSON.stringify(clientContext)}`,
24
- } }), _jsx(Stylesheets, { requestInfo: requestInfo }), _jsx(Preloads, { requestInfo: requestInfo }), _jsx("div", { id: "hydrate-root", dangerouslySetInnerHTML: { __html: "<!-- RWSDK_INJECT_APP_HTML -->" } })] }));
24
+ } }), _jsx(Stylesheets, { requestInfo: requestInfo }), _jsx(Preloads, { requestInfo: requestInfo }), _jsx("div", { id: "hydrate-root", children: _jsx("div", { id: "rwsdk-app-start" }) })] }));
25
25
  const outerHtmlStream = await renderHtmlStream({
26
26
  node: documentElement,
27
27
  requestInfo,
@@ -29,11 +29,11 @@ export const renderDocumentHtmlStream = async ({ rscPayloadStream, Document, req
29
29
  identifierPrefix: "__RWSDK_DOCUMENT__",
30
30
  });
31
31
  const appHtmlStream = await renderHtmlStream({
32
- node: appNode,
32
+ node: innerAppNode,
33
33
  requestInfo,
34
34
  onError,
35
35
  });
36
36
  // Stitch the streams together
37
- const stitchedStream = injectHtmlAtMarker(outerHtmlStream, appHtmlStream, "<!-- RWSDK_INJECT_APP_HTML -->");
37
+ const stitchedStream = stitchDocumentAndAppStreams(outerHtmlStream, appHtmlStream, '<div id="rwsdk-app-start"></div>', '<div id="rwsdk-app-end"></div>');
38
38
  return stitchedStream;
39
39
  };
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { renderDocumentHtmlStream } from "./render/renderDocumentHtmlStream";
3
3
  import { normalizeActionResult } from "./render/normalizeActionResult";
4
4
  import { renderToRscStream } from "./render/renderToRscStream";
@@ -68,6 +68,7 @@ export const defineApp = (routes) => {
68
68
  else {
69
69
  pageElement = _jsx(Page, { ...requestInfo });
70
70
  }
71
+ pageElement = (_jsxs(_Fragment, { children: [pageElement, _jsx("div", { id: "rwsdk-app-end" })] }));
71
72
  return pageElement;
72
73
  };
73
74
  const renderPage = async (requestInfo, Page, onError) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.17",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +0,0 @@
1
- /**
2
- * Injects HTML content from one stream into another stream at a specified marker.
3
- * This preserves streaming behavior by processing chunks incrementally without
4
- * buffering the entire streams.
5
- *
6
- * @param outerHtml - The outer HTML stream containing the marker
7
- * @param innerHtml - The inner HTML stream to inject at the marker
8
- * @param marker - The text marker where injection should occur
9
- * @returns A new ReadableStream with the inner HTML injected at the marker
10
- */
11
- export declare function injectHtmlAtMarker(outerHtml: ReadableStream<Uint8Array>, innerHtml: ReadableStream<Uint8Array>, marker: string): ReadableStream<Uint8Array>;
@@ -1,90 +0,0 @@
1
- /**
2
- * Injects HTML content from one stream into another stream at a specified marker.
3
- * This preserves streaming behavior by processing chunks incrementally without
4
- * buffering the entire streams.
5
- *
6
- * @param outerHtml - The outer HTML stream containing the marker
7
- * @param innerHtml - The inner HTML stream to inject at the marker
8
- * @param marker - The text marker where injection should occur
9
- * @returns A new ReadableStream with the inner HTML injected at the marker
10
- */
11
- export function injectHtmlAtMarker(outerHtml, innerHtml, marker) {
12
- const decoder = new TextDecoder();
13
- const encoder = new TextEncoder();
14
- let buffer = "";
15
- let injected = false;
16
- return new ReadableStream({
17
- async start(controller) {
18
- const outerReader = outerHtml.getReader();
19
- const flushText = (text) => {
20
- if (text.length > 0) {
21
- controller.enqueue(encoder.encode(text));
22
- }
23
- };
24
- const pumpInnerStream = async () => {
25
- const innerReader = innerHtml.getReader();
26
- try {
27
- while (true) {
28
- const { done, value } = await innerReader.read();
29
- if (done) {
30
- break;
31
- }
32
- controller.enqueue(value);
33
- }
34
- }
35
- finally {
36
- innerReader.releaseLock();
37
- }
38
- };
39
- try {
40
- while (true) {
41
- const { done, value } = await outerReader.read();
42
- if (done) {
43
- // End of outer stream - flush any remaining buffer
44
- if (buffer.length > 0) {
45
- flushText(buffer);
46
- }
47
- controller.close();
48
- break;
49
- }
50
- // Decode the chunk and add to buffer
51
- buffer += decoder.decode(value, { stream: true });
52
- if (!injected) {
53
- // Look for the marker in the buffer
54
- const markerIndex = buffer.indexOf(marker);
55
- if (markerIndex !== -1) {
56
- // Found the marker - emit everything before it
57
- flushText(buffer.slice(0, markerIndex));
58
- // Inject the inner HTML stream
59
- await pumpInnerStream();
60
- // Keep everything after the marker for next iteration
61
- buffer = buffer.slice(markerIndex + marker.length);
62
- injected = true;
63
- }
64
- else {
65
- // Marker not found yet - flush all but potential partial marker
66
- // Keep overlap to handle markers split across chunks
67
- const overlap = Math.max(0, marker.length - 1);
68
- const cutoff = Math.max(0, buffer.length - overlap);
69
- if (cutoff > 0) {
70
- flushText(buffer.slice(0, cutoff));
71
- buffer = buffer.slice(cutoff);
72
- }
73
- }
74
- }
75
- else {
76
- // Already injected - just pass through remaining content
77
- flushText(buffer);
78
- buffer = "";
79
- }
80
- }
81
- }
82
- catch (error) {
83
- controller.error(error);
84
- }
85
- finally {
86
- outerReader.releaseLock();
87
- }
88
- },
89
- });
90
- }