@timber-js/app 0.2.0-alpha.25 → 0.2.0-alpha.26
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.
- package/dist/server/node-stream-transforms.d.ts +54 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +16 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/server/node-stream-transforms.ts +255 -0
- package/src/server/ssr-entry.ts +56 -6
- package/src/server/ssr-render.ts +27 -27
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js native stream transforms for SSR HTML post-processing.
|
|
3
|
+
*
|
|
4
|
+
* These are Node.js Transform stream equivalents of the Web Stream
|
|
5
|
+
* transforms in html-injectors.ts. Used on Node.js/Bun where native
|
|
6
|
+
* streams (C++ backed) are faster than Web Streams (JS reimplementation).
|
|
7
|
+
*
|
|
8
|
+
* The transforms are pure string operations on HTML chunks — the same
|
|
9
|
+
* logic as the Web Stream versions, just wrapped in Node.js Transform
|
|
10
|
+
* instead of Web TransformStream.
|
|
11
|
+
*
|
|
12
|
+
* Architecture:
|
|
13
|
+
* renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
|
|
14
|
+
* → pipe(flightInjector) → Readable.toWeb() → Response
|
|
15
|
+
*
|
|
16
|
+
* All chunks stay in C++ Node.js stream buffers until the final
|
|
17
|
+
* Readable.toWeb() conversion for the Response body.
|
|
18
|
+
*/
|
|
19
|
+
import { Transform } from 'node:stream';
|
|
20
|
+
/**
|
|
21
|
+
* Node.js Transform that injects HTML content before </head>.
|
|
22
|
+
*
|
|
23
|
+
* Equivalent to injectHead() in html-injectors.ts. Streams chunks
|
|
24
|
+
* through immediately, keeping only a small trailing buffer to handle
|
|
25
|
+
* </head> split across chunk boundaries.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createNodeHeadInjector(headHtml: string): Transform;
|
|
28
|
+
/**
|
|
29
|
+
* Node.js Transform that merges RSC script tags into the HTML stream.
|
|
30
|
+
*
|
|
31
|
+
* Equivalent to injectRscPayload() in html-injectors.ts. Combines
|
|
32
|
+
* createInlinedRscStream + createFlightInjectionTransform into a single
|
|
33
|
+
* Node.js Transform.
|
|
34
|
+
*
|
|
35
|
+
* 1. Strips `</body></html>` from the shell so all subsequent content
|
|
36
|
+
* is at `<body>` level.
|
|
37
|
+
* 2. Reads RSC chunks from the provided ReadableStream and injects them
|
|
38
|
+
* as `<script>` tags after HTML chunks.
|
|
39
|
+
* 3. Re-emits `</body></html>` at the very end.
|
|
40
|
+
*
|
|
41
|
+
* The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
|
|
42
|
+
* stream). We read from it using the Web API — this is the one bridge
|
|
43
|
+
* point between Web Streams and Node.js streams in the pipeline.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createNodeFlightInjector(rscStream: ReadableStream<Uint8Array> | undefined): Transform;
|
|
46
|
+
/**
|
|
47
|
+
* Node.js Transform that catches post-shell streaming errors.
|
|
48
|
+
*
|
|
49
|
+
* Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
|
|
50
|
+
* Catches errors from React's streaming phase (deny/throw inside Suspense
|
|
51
|
+
* after the shell has flushed) and closes the stream cleanly.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createNodeErrorHandler(signal?: AbortSignal): Transform;
|
|
54
|
+
//# sourceMappingURL=node-stream-transforms.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAIxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAeD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,GAChD,SAAS,CAyGX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;
|
|
1
|
+
{"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAmCH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAwHnB;AAED,eAAe,SAAS,CAAC"}
|
|
@@ -39,6 +39,22 @@ export declare function renderSsrStream(element: ReactNode, options?: {
|
|
|
39
39
|
deferSuspenseFor?: number;
|
|
40
40
|
signal?: AbortSignal;
|
|
41
41
|
}): Promise<ReadableStream<Uint8Array>>;
|
|
42
|
+
/** Whether the current platform uses native Node.js streams for SSR. */
|
|
43
|
+
export declare const useNodeStreams: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Render via renderToPipeableStream, returning a Node.js Readable.
|
|
46
|
+
*
|
|
47
|
+
* The entire HTML rendering + post-processing pipeline stays in native
|
|
48
|
+
* Node.js streams (C++ backed). Only converted to Web ReadableStream
|
|
49
|
+
* at the very end for the Response body.
|
|
50
|
+
*/
|
|
51
|
+
export declare function renderSsrNodeStream(element: ReactNode, options?: {
|
|
52
|
+
bootstrapScriptContent?: string;
|
|
53
|
+
deferSuspenseFor?: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}): Promise<import('node:stream').Readable>;
|
|
56
|
+
/** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
|
|
57
|
+
export declare function nodeReadableToWeb(readable: import('node:stream').Readable): ReadableStream<Uint8Array>;
|
|
42
58
|
/**
|
|
43
59
|
* Wrap an HTML stream with error handling for the streaming phase.
|
|
44
60
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAqEvC;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,OAAO,aAAa,EAAE,QAAQ,CAAC,CAkDzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AA0CD;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CA2B5B;AAWD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,OAAO,GACvB,QAAQ,CASV"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.26",
|
|
4
4
|
"description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js native stream transforms for SSR HTML post-processing.
|
|
3
|
+
*
|
|
4
|
+
* These are Node.js Transform stream equivalents of the Web Stream
|
|
5
|
+
* transforms in html-injectors.ts. Used on Node.js/Bun where native
|
|
6
|
+
* streams (C++ backed) are faster than Web Streams (JS reimplementation).
|
|
7
|
+
*
|
|
8
|
+
* The transforms are pure string operations on HTML chunks — the same
|
|
9
|
+
* logic as the Web Stream versions, just wrapped in Node.js Transform
|
|
10
|
+
* instead of Web TransformStream.
|
|
11
|
+
*
|
|
12
|
+
* Architecture:
|
|
13
|
+
* renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
|
|
14
|
+
* → pipe(flightInjector) → Readable.toWeb() → Response
|
|
15
|
+
*
|
|
16
|
+
* All chunks stay in C++ Node.js stream buffers until the final
|
|
17
|
+
* Readable.toWeb() conversion for the Response body.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Transform } from 'node:stream';
|
|
21
|
+
|
|
22
|
+
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Node.js Transform that injects HTML content before </head>.
|
|
26
|
+
*
|
|
27
|
+
* Equivalent to injectHead() in html-injectors.ts. Streams chunks
|
|
28
|
+
* through immediately, keeping only a small trailing buffer to handle
|
|
29
|
+
* </head> split across chunk boundaries.
|
|
30
|
+
*/
|
|
31
|
+
export function createNodeHeadInjector(headHtml: string): Transform {
|
|
32
|
+
if (!headHtml) {
|
|
33
|
+
return new Transform({
|
|
34
|
+
transform(chunk, _enc, cb) {
|
|
35
|
+
cb(null, chunk);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = '</head>';
|
|
41
|
+
const tailLen = target.length - 1;
|
|
42
|
+
let injected = false;
|
|
43
|
+
let tail = '';
|
|
44
|
+
|
|
45
|
+
return new Transform({
|
|
46
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
47
|
+
if (injected) {
|
|
48
|
+
callback(null, chunk);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const text = tail + chunk.toString('utf-8');
|
|
53
|
+
const tagIndex = text.indexOf(target);
|
|
54
|
+
|
|
55
|
+
if (tagIndex !== -1) {
|
|
56
|
+
const before = text.slice(0, tagIndex);
|
|
57
|
+
const after = text.slice(tagIndex);
|
|
58
|
+
this.push(Buffer.from(before + headHtml + after, 'utf-8'));
|
|
59
|
+
injected = true;
|
|
60
|
+
tail = '';
|
|
61
|
+
callback();
|
|
62
|
+
} else {
|
|
63
|
+
const safeEnd = Math.max(0, text.length - tailLen);
|
|
64
|
+
if (safeEnd > 0) {
|
|
65
|
+
this.push(Buffer.from(text.slice(0, safeEnd), 'utf-8'));
|
|
66
|
+
}
|
|
67
|
+
tail = text.slice(safeEnd);
|
|
68
|
+
callback();
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
flush(callback) {
|
|
72
|
+
if (!injected && tail) {
|
|
73
|
+
this.push(Buffer.from(tail, 'utf-8'));
|
|
74
|
+
}
|
|
75
|
+
callback();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── RSC Flight Injection ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Escape a string for safe embedding inside a `<script>` tag within
|
|
84
|
+
* a JSON-encoded value. Same as htmlEscapeJsonString in html-injectors.ts.
|
|
85
|
+
*/
|
|
86
|
+
function htmlEscapeJsonString(str: string): string {
|
|
87
|
+
return str
|
|
88
|
+
.replace(/</g, '\\u003c')
|
|
89
|
+
.replace(/\u2028/g, '\\u2028')
|
|
90
|
+
.replace(/\u2029/g, '\\u2029');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Node.js Transform that merges RSC script tags into the HTML stream.
|
|
95
|
+
*
|
|
96
|
+
* Equivalent to injectRscPayload() in html-injectors.ts. Combines
|
|
97
|
+
* createInlinedRscStream + createFlightInjectionTransform into a single
|
|
98
|
+
* Node.js Transform.
|
|
99
|
+
*
|
|
100
|
+
* 1. Strips `</body></html>` from the shell so all subsequent content
|
|
101
|
+
* is at `<body>` level.
|
|
102
|
+
* 2. Reads RSC chunks from the provided ReadableStream and injects them
|
|
103
|
+
* as `<script>` tags after HTML chunks.
|
|
104
|
+
* 3. Re-emits `</body></html>` at the very end.
|
|
105
|
+
*
|
|
106
|
+
* The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
|
|
107
|
+
* stream). We read from it using the Web API — this is the one bridge
|
|
108
|
+
* point between Web Streams and Node.js streams in the pipeline.
|
|
109
|
+
*/
|
|
110
|
+
export function createNodeFlightInjector(
|
|
111
|
+
rscStream: ReadableStream<Uint8Array> | undefined
|
|
112
|
+
): Transform {
|
|
113
|
+
if (!rscStream) {
|
|
114
|
+
return new Transform({
|
|
115
|
+
transform(chunk, _enc, cb) {
|
|
116
|
+
cb(null, chunk);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const suffix = '</body></html>';
|
|
122
|
+
const suffixBuf = Buffer.from(suffix, 'utf-8');
|
|
123
|
+
const rscReader = rscStream.getReader();
|
|
124
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
125
|
+
|
|
126
|
+
let pullPromise: Promise<void> | null = null;
|
|
127
|
+
let donePulling = false;
|
|
128
|
+
let pullError: unknown = null;
|
|
129
|
+
let foundSuffix = false;
|
|
130
|
+
let htmlStreamFinished = false;
|
|
131
|
+
const pending: Buffer[] = [];
|
|
132
|
+
|
|
133
|
+
// Emit bootstrap signal
|
|
134
|
+
const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
|
|
135
|
+
pending.push(Buffer.from(bootstrap, 'utf-8'));
|
|
136
|
+
|
|
137
|
+
async function pullLoop(): Promise<void> {
|
|
138
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
139
|
+
try {
|
|
140
|
+
for (;;) {
|
|
141
|
+
const { done, value } = await rscReader.read();
|
|
142
|
+
if (done) {
|
|
143
|
+
donePulling = true;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
147
|
+
const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
|
|
148
|
+
pending.push(Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8'));
|
|
149
|
+
if (!htmlStreamFinished) {
|
|
150
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
pullError = err;
|
|
155
|
+
donePulling = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function drainPending(transform: Transform): void {
|
|
160
|
+
while (pending.length > 0) {
|
|
161
|
+
transform.push(pending.shift()!);
|
|
162
|
+
}
|
|
163
|
+
if (pullError) {
|
|
164
|
+
transform.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
|
|
165
|
+
pullError = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Transform({
|
|
170
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
171
|
+
if (!pullPromise) {
|
|
172
|
+
pullPromise = pullLoop();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (foundSuffix) {
|
|
176
|
+
this.push(chunk);
|
|
177
|
+
if (pending.length > 0) drainPending(this);
|
|
178
|
+
callback();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const text = chunk.toString('utf-8');
|
|
183
|
+
const idx = text.indexOf(suffix);
|
|
184
|
+
if (idx !== -1) {
|
|
185
|
+
foundSuffix = true;
|
|
186
|
+
const before = text.slice(0, idx);
|
|
187
|
+
const after = text.slice(idx + suffix.length);
|
|
188
|
+
if (before) this.push(Buffer.from(before, 'utf-8'));
|
|
189
|
+
if (pending.length > 0) drainPending(this);
|
|
190
|
+
if (after) this.push(Buffer.from(after, 'utf-8'));
|
|
191
|
+
} else {
|
|
192
|
+
this.push(chunk);
|
|
193
|
+
}
|
|
194
|
+
callback();
|
|
195
|
+
},
|
|
196
|
+
flush(callback) {
|
|
197
|
+
htmlStreamFinished = true;
|
|
198
|
+
|
|
199
|
+
const finish = () => {
|
|
200
|
+
drainPending(this);
|
|
201
|
+
if (foundSuffix) {
|
|
202
|
+
this.push(suffixBuf);
|
|
203
|
+
}
|
|
204
|
+
callback();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (donePulling) {
|
|
208
|
+
finish();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!pullPromise) {
|
|
212
|
+
pullPromise = pullLoop();
|
|
213
|
+
}
|
|
214
|
+
pullPromise.then(finish);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Error Handling ──────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const NOINDEX_SCRIPT =
|
|
222
|
+
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Node.js Transform that catches post-shell streaming errors.
|
|
226
|
+
*
|
|
227
|
+
* Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
|
|
228
|
+
* Catches errors from React's streaming phase (deny/throw inside Suspense
|
|
229
|
+
* after the shell has flushed) and closes the stream cleanly.
|
|
230
|
+
*/
|
|
231
|
+
export function createNodeErrorHandler(signal?: AbortSignal): Transform {
|
|
232
|
+
const transform = new Transform({
|
|
233
|
+
transform(chunk, _encoding, callback) {
|
|
234
|
+
callback(null, chunk);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
transform.on('error', (error) => {
|
|
239
|
+
const isAbort =
|
|
240
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
241
|
+
(error instanceof Error && error.name === 'AbortError') ||
|
|
242
|
+
signal?.aborted;
|
|
243
|
+
|
|
244
|
+
if (isAbort) {
|
|
245
|
+
transform.end();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.error('[timber] SSR streaming error (post-shell):', error.message || error);
|
|
250
|
+
transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
|
|
251
|
+
transform.end();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return transform;
|
|
255
|
+
}
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -17,7 +17,13 @@ import config from 'virtual:timber-config';
|
|
|
17
17
|
import { createFromReadableStream } from '#/rsc-runtime/ssr.js';
|
|
18
18
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
renderSsrStream,
|
|
22
|
+
renderSsrNodeStream,
|
|
23
|
+
nodeReadableToWeb,
|
|
24
|
+
useNodeStreams,
|
|
25
|
+
buildSsrResponse,
|
|
26
|
+
} from './ssr-render.js';
|
|
21
27
|
import { formatSsrError } from './error-formatter.js';
|
|
22
28
|
import { SsrStreamError } from './primitives.js';
|
|
23
29
|
import { injectHead, injectRscPayload } from './html-injectors.js';
|
|
@@ -151,6 +157,55 @@ export async function handleSsr(
|
|
|
151
157
|
// in the shell HTML. This executes immediately during parsing — even
|
|
152
158
|
// while Suspense boundaries are still streaming — triggering module
|
|
153
159
|
// loading via dynamic import() so hydration can start early.
|
|
160
|
+
//
|
|
161
|
+
// Two paths based on platform:
|
|
162
|
+
// - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
|
|
163
|
+
// Entire pipeline stays in C++ native streams until the Response boundary.
|
|
164
|
+
// - Workers: renderToReadableStream → Web TransformStream pipeline → Response
|
|
165
|
+
// Web Streams are V8-native C++ built-ins on Workers.
|
|
166
|
+
if (useNodeStreams) {
|
|
167
|
+
// Node.js fast path: full pipeline in native streams
|
|
168
|
+
const { createNodeHeadInjector, createNodeFlightInjector, createNodeErrorHandler } =
|
|
169
|
+
await import('./node-stream-transforms.js');
|
|
170
|
+
const { pipeline } = await import('node:stream/promises');
|
|
171
|
+
|
|
172
|
+
let nodeHtmlStream: import('node:stream').Readable;
|
|
173
|
+
try {
|
|
174
|
+
nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
|
|
175
|
+
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
176
|
+
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
177
|
+
signal: navContext.signal,
|
|
178
|
+
});
|
|
179
|
+
} catch (renderError) {
|
|
180
|
+
console.error(
|
|
181
|
+
'[timber] SSR shell failed from RSC stream error:',
|
|
182
|
+
formatSsrError(renderError)
|
|
183
|
+
);
|
|
184
|
+
throw new SsrStreamError(
|
|
185
|
+
'SSR renderToReadableStream failed due to RSC stream error',
|
|
186
|
+
renderError
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector
|
|
191
|
+
const errorHandler = createNodeErrorHandler(navContext.signal);
|
|
192
|
+
const headInjector = createNodeHeadInjector(navContext.headHtml);
|
|
193
|
+
const flightInjector = createNodeFlightInjector(navContext.rscStream);
|
|
194
|
+
|
|
195
|
+
// Pipe through the chain. pipeline() handles backpressure and error propagation.
|
|
196
|
+
// The last stream in the chain is the output — convert to Web ReadableStream
|
|
197
|
+
// only at the Response boundary.
|
|
198
|
+
const { PassThrough } = await import('node:stream');
|
|
199
|
+
const output = new PassThrough();
|
|
200
|
+
pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
|
|
201
|
+
// Pipeline errors are handled by errorHandler transform
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const webStream = nodeReadableToWeb(output);
|
|
205
|
+
return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Web Streams path (CF Workers / fallback)
|
|
154
209
|
let htmlStream: ReadableStream<Uint8Array>;
|
|
155
210
|
try {
|
|
156
211
|
htmlStream = await renderSsrStream(wrappedElement, {
|
|
@@ -159,11 +214,6 @@ export async function handleSsr(
|
|
|
159
214
|
signal: navContext.signal,
|
|
160
215
|
});
|
|
161
216
|
} catch (renderError) {
|
|
162
|
-
// SSR shell rendering failed — the RSC stream contained an error
|
|
163
|
-
// that wasn't caught by any error boundary in the decoded tree.
|
|
164
|
-
// Wrap in SsrStreamError so the RSC entry can handle it without
|
|
165
|
-
// re-executing server components via renderDenyPage.
|
|
166
|
-
// See LOCAL-293.
|
|
167
217
|
console.error(
|
|
168
218
|
'[timber] SSR shell failed from RSC stream error:',
|
|
169
219
|
formatSsrError(renderError)
|
package/src/server/ssr-render.ts
CHANGED
|
@@ -85,15 +85,10 @@ try {
|
|
|
85
85
|
_renderToPipeableStream = reactDomServer.renderToPipeableStream;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
} catch
|
|
88
|
+
} catch {
|
|
89
89
|
// node:stream or renderToPipeableStream not available — use Web Streams path
|
|
90
|
-
// eslint-disable-next-line no-console
|
|
91
|
-
console.log(`[timber] SSR stream detection: falling back to Web Streams (${e instanceof Error ? e.message : e})`);
|
|
92
90
|
}
|
|
93
91
|
|
|
94
|
-
// eslint-disable-next-line no-console
|
|
95
|
-
console.log(`[timber] SSR render path: ${_useNodeStreams ? 'renderToPipeableStream (Node.js native)' : 'renderToReadableStream (Web Streams)'}`);
|
|
96
|
-
|
|
97
92
|
/**
|
|
98
93
|
* Render a React element tree to a ReadableStream of HTML.
|
|
99
94
|
*
|
|
@@ -112,55 +107,55 @@ export async function renderSsrStream(
|
|
|
112
107
|
element: ReactNode,
|
|
113
108
|
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
114
109
|
): Promise<ReadableStream<Uint8Array>> {
|
|
115
|
-
if (_useNodeStreams) {
|
|
116
|
-
return renderViaPipeableStream(element, options);
|
|
117
|
-
}
|
|
118
110
|
return renderViaReadableStream(element, options);
|
|
119
111
|
}
|
|
120
112
|
|
|
113
|
+
/** Whether the current platform uses native Node.js streams for SSR. */
|
|
114
|
+
export const useNodeStreams = _useNodeStreams;
|
|
115
|
+
|
|
121
116
|
// ─── Node.js Path: renderToPipeableStream ────────────────────────────────────
|
|
122
117
|
//
|
|
123
118
|
// Uses React's Node.js-native API. HTML chunks flow through C++ stream
|
|
124
|
-
// buffers with zero Promise allocations per chunk.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
119
|
+
// buffers with zero Promise allocations per chunk. Returns a Node.js
|
|
120
|
+
// Readable — the caller (ssr-entry.ts) pipes through Node.js Transform
|
|
121
|
+
// streams for injectHead/injectRscPayload before converting to Web
|
|
122
|
+
// ReadableStream at the Response boundary.
|
|
128
123
|
|
|
129
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Render via renderToPipeableStream, returning a Node.js Readable.
|
|
126
|
+
*
|
|
127
|
+
* The entire HTML rendering + post-processing pipeline stays in native
|
|
128
|
+
* Node.js streams (C++ backed). Only converted to Web ReadableStream
|
|
129
|
+
* at the very end for the Response body.
|
|
130
|
+
*/
|
|
131
|
+
export async function renderSsrNodeStream(
|
|
130
132
|
element: ReactNode,
|
|
131
133
|
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
132
|
-
): Promise<
|
|
134
|
+
): Promise<import('node:stream').Readable> {
|
|
133
135
|
const signal = options?.signal;
|
|
134
136
|
const deferMs = options?.deferSuspenseFor;
|
|
135
137
|
|
|
136
|
-
return new Promise<
|
|
138
|
+
return new Promise<import('node:stream').Readable>((resolve, reject) => {
|
|
137
139
|
const passthrough = new _PassThrough!();
|
|
138
140
|
|
|
139
141
|
let allReadyResolve: (() => void) | null = null;
|
|
140
142
|
const allReady = new Promise<void>((r) => {
|
|
141
143
|
allReadyResolve = r;
|
|
142
144
|
});
|
|
143
|
-
// Suppress unhandled rejection if nobody awaits allReady
|
|
144
145
|
allReady.catch(() => {});
|
|
145
146
|
|
|
146
147
|
const { pipe, abort } = _renderToPipeableStream!(element, {
|
|
147
148
|
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
148
149
|
|
|
149
150
|
onShellReady() {
|
|
150
|
-
// deferSuspenseFor: delay piping so React can resolve fast-completing
|
|
151
|
-
// Suspense boundaries before we read the shell. When we delay, React
|
|
152
|
-
// inlines resolved content instead of serializing fallbacks.
|
|
153
|
-
// See design/05-streaming.md §"deferSuspenseFor"
|
|
154
151
|
if (deferMs && deferMs > 0) {
|
|
155
152
|
Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
|
|
156
153
|
pipe(passthrough);
|
|
157
|
-
|
|
158
|
-
resolve(wrapStreamWithErrorHandling(webStream, signal));
|
|
154
|
+
resolve(passthrough);
|
|
159
155
|
});
|
|
160
156
|
} else {
|
|
161
157
|
pipe(passthrough);
|
|
162
|
-
|
|
163
|
-
resolve(wrapStreamWithErrorHandling(webStream, signal));
|
|
158
|
+
resolve(passthrough);
|
|
164
159
|
}
|
|
165
160
|
},
|
|
166
161
|
|
|
@@ -173,13 +168,11 @@ async function renderViaPipeableStream(
|
|
|
173
168
|
},
|
|
174
169
|
|
|
175
170
|
onError(error: unknown) {
|
|
176
|
-
// Suppress connection abort logging — not an application error.
|
|
177
171
|
if (isAbortError(error) || signal?.aborted) return;
|
|
178
172
|
console.error('[timber] SSR render error:', formatSsrError(error));
|
|
179
173
|
},
|
|
180
174
|
});
|
|
181
175
|
|
|
182
|
-
// Wire up abort signal — cancel React rendering if the client disconnects.
|
|
183
176
|
if (signal) {
|
|
184
177
|
if (signal.aborted) {
|
|
185
178
|
abort();
|
|
@@ -190,6 +183,13 @@ async function renderViaPipeableStream(
|
|
|
190
183
|
});
|
|
191
184
|
}
|
|
192
185
|
|
|
186
|
+
/** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
|
|
187
|
+
export function nodeReadableToWeb(
|
|
188
|
+
readable: import('node:stream').Readable
|
|
189
|
+
): ReadableStream<Uint8Array> {
|
|
190
|
+
return _ReadableToWeb!(readable) as ReadableStream<Uint8Array>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
193
|
// ─── Web Streams Path: renderToReadableStream ────────────────────────────────
|
|
194
194
|
//
|
|
195
195
|
// Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a
|