@timber-js/app 0.2.0-alpha.24 → 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;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,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.24",
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)
@@ -107,55 +107,55 @@ export async function renderSsrStream(
107
107
  element: ReactNode,
108
108
  options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
109
109
  ): Promise<ReadableStream<Uint8Array>> {
110
- if (_useNodeStreams) {
111
- return renderViaPipeableStream(element, options);
112
- }
113
110
  return renderViaReadableStream(element, options);
114
111
  }
115
112
 
113
+ /** Whether the current platform uses native Node.js streams for SSR. */
114
+ export const useNodeStreams = _useNodeStreams;
115
+
116
116
  // ─── Node.js Path: renderToPipeableStream ────────────────────────────────────
117
117
  //
118
118
  // Uses React's Node.js-native API. HTML chunks flow through C++ stream
119
- // buffers with zero Promise allocations per chunk. The PassThrough stream
120
- // is converted to a Web ReadableStream via Readable.toWeb() (zero-copy
121
- // bridge available in Node.js 17+) for compatibility with downstream
122
- // 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.
123
123
 
124
- 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(
125
132
  element: ReactNode,
126
133
  options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
127
- ): Promise<ReadableStream<Uint8Array>> {
134
+ ): Promise<import('node:stream').Readable> {
128
135
  const signal = options?.signal;
129
136
  const deferMs = options?.deferSuspenseFor;
130
137
 
131
- return new Promise<ReadableStream<Uint8Array>>((resolve, reject) => {
138
+ return new Promise<import('node:stream').Readable>((resolve, reject) => {
132
139
  const passthrough = new _PassThrough!();
133
140
 
134
141
  let allReadyResolve: (() => void) | null = null;
135
142
  const allReady = new Promise<void>((r) => {
136
143
  allReadyResolve = r;
137
144
  });
138
- // Suppress unhandled rejection if nobody awaits allReady
139
145
  allReady.catch(() => {});
140
146
 
141
147
  const { pipe, abort } = _renderToPipeableStream!(element, {
142
148
  bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
143
149
 
144
150
  onShellReady() {
145
- // deferSuspenseFor: delay piping so React can resolve fast-completing
146
- // Suspense boundaries before we read the shell. When we delay, React
147
- // inlines resolved content instead of serializing fallbacks.
148
- // See design/05-streaming.md §"deferSuspenseFor"
149
151
  if (deferMs && deferMs > 0) {
150
152
  Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
151
153
  pipe(passthrough);
152
- const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
153
- resolve(wrapStreamWithErrorHandling(webStream, signal));
154
+ resolve(passthrough);
154
155
  });
155
156
  } else {
156
157
  pipe(passthrough);
157
- const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
158
- resolve(wrapStreamWithErrorHandling(webStream, signal));
158
+ resolve(passthrough);
159
159
  }
160
160
  },
161
161
 
@@ -168,13 +168,11 @@ async function renderViaPipeableStream(
168
168
  },
169
169
 
170
170
  onError(error: unknown) {
171
- // Suppress connection abort logging — not an application error.
172
171
  if (isAbortError(error) || signal?.aborted) return;
173
172
  console.error('[timber] SSR render error:', formatSsrError(error));
174
173
  },
175
174
  });
176
175
 
177
- // Wire up abort signal — cancel React rendering if the client disconnects.
178
176
  if (signal) {
179
177
  if (signal.aborted) {
180
178
  abort();
@@ -185,6 +183,13 @@ async function renderViaPipeableStream(
185
183
  });
186
184
  }
187
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
+
188
193
  // ─── Web Streams Path: renderToReadableStream ────────────────────────────────
189
194
  //
190
195
  // Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a