@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.
@@ -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;AA6BH;;;;;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,CA4EnB;AAED,eAAe,SAAS,CAAC"}
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;AA0EvC;;;;;;;;;;;;;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,CAKrC;AAkHD;;;;;;;;;;;;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"}
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.25",
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
+ }
@@ -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 { renderSsrStream, buildSsrResponse } from './ssr-render.js';
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)
@@ -85,15 +85,10 @@ try {
85
85
  _renderToPipeableStream = reactDomServer.renderToPipeableStream;
86
86
  }
87
87
  }
88
- } catch (e) {
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. The PassThrough stream
125
- // is converted to a Web ReadableStream via Readable.toWeb() (zero-copy
126
- // bridge available in Node.js 17+) for compatibility with downstream
127
- // Web Stream transforms (injectHead, injectRscPayload).
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
- async function renderViaPipeableStream(
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<ReadableStream<Uint8Array>> {
134
+ ): Promise<import('node:stream').Readable> {
133
135
  const signal = options?.signal;
134
136
  const deferMs = options?.deferSuspenseFor;
135
137
 
136
- return new Promise<ReadableStream<Uint8Array>>((resolve, reject) => {
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
- const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
158
- resolve(wrapStreamWithErrorHandling(webStream, signal));
154
+ resolve(passthrough);
159
155
  });
160
156
  } else {
161
157
  pipe(passthrough);
162
- const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
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