@timber-js/app 0.2.0-alpha.25 → 0.2.0-alpha.27

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,CAgInB;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,CAsDzC;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.27",
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';
@@ -137,7 +143,9 @@ export async function handleSsr(
137
143
  // createFromReadableStream resolves client component references
138
144
  // (from "use client" modules) using the SSR environment's module
139
145
  // map, importing the actual components for server-side rendering.
146
+ const _s0 = performance.now();
140
147
  const element = createFromReadableStream(rscStream) as React.ReactNode;
148
+ const _s1 = performance.now();
141
149
 
142
150
  // Wrap with a server-safe nuqs adapter so that 'use client' components
143
151
  // that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
@@ -145,12 +153,67 @@ export async function handleSsr(
145
153
  // over after hydration. This provider supplies the request's search params
146
154
  // as a static snapshot so nuqs renders the right initial values on the server.
147
155
  const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
156
+ const _s2 = performance.now();
148
157
 
149
158
  // Render to HTML stream (waits for onShellReady).
150
159
  // Pass bootstrapScriptContent so React injects a non-deferred <script>
151
160
  // in the shell HTML. This executes immediately during parsing — even
152
161
  // while Suspense boundaries are still streaming — triggering module
153
162
  // loading via dynamic import() so hydration can start early.
163
+ //
164
+ // Two paths based on platform:
165
+ // - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
166
+ // Entire pipeline stays in C++ native streams until the Response boundary.
167
+ // - Workers: renderToReadableStream → Web TransformStream pipeline → Response
168
+ // Web Streams are V8-native C++ built-ins on Workers.
169
+ if (useNodeStreams) {
170
+ // Node.js fast path: full pipeline in native streams
171
+ const { createNodeHeadInjector, createNodeFlightInjector, createNodeErrorHandler } =
172
+ await import('./node-stream-transforms.js');
173
+ const { pipeline } = await import('node:stream/promises');
174
+
175
+ const _s3 = performance.now();
176
+ let nodeHtmlStream: import('node:stream').Readable;
177
+ try {
178
+ nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
179
+ bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
180
+ deferSuspenseFor: navContext.deferSuspenseFor,
181
+ signal: navContext.signal,
182
+ });
183
+ } catch (renderError) {
184
+ console.error(
185
+ '[timber] SSR shell failed from RSC stream error:',
186
+ formatSsrError(renderError)
187
+ );
188
+ throw new SsrStreamError(
189
+ 'SSR renderToReadableStream failed due to RSC stream error',
190
+ renderError
191
+ );
192
+ }
193
+
194
+ // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector
195
+ const errorHandler = createNodeErrorHandler(navContext.signal);
196
+ const headInjector = createNodeHeadInjector(navContext.headHtml);
197
+ const flightInjector = createNodeFlightInjector(navContext.rscStream);
198
+
199
+ // Pipe through the chain. pipeline() handles backpressure and error propagation.
200
+ // The last stream in the chain is the output — convert to Web ReadableStream
201
+ // only at the Response boundary.
202
+ const { PassThrough } = await import('node:stream');
203
+ const output = new PassThrough();
204
+ pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
205
+ // Pipeline errors are handled by errorHandler transform
206
+ });
207
+
208
+ const _s4 = performance.now();
209
+ // eslint-disable-next-line no-console
210
+ console.log(`[ssr-perf] decode=${(_s1 - _s0).toFixed(1)}ms nuqs=${(_s2 - _s1).toFixed(1)}ms imports=${(_s3 - _s2).toFixed(1)}ms renderToPipeable=${(_s4 - _s3).toFixed(1)}ms pipeline=${(performance.now() - _s4).toFixed(1)}ms total=${(performance.now() - _s0).toFixed(1)}ms`);
211
+
212
+ const webStream = nodeReadableToWeb(output);
213
+ return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
214
+ }
215
+
216
+ // Web Streams path (CF Workers / fallback)
154
217
  let htmlStream: ReadableStream<Uint8Array>;
155
218
  try {
156
219
  htmlStream = await renderSsrStream(wrappedElement, {
@@ -159,11 +222,6 @@ export async function handleSsr(
159
222
  signal: navContext.signal,
160
223
  });
161
224
  } 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
225
  console.error(
168
226
  '[timber] SSR shell failed from RSC stream error:',
169
227
  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,59 @@ 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) => {
139
+ const _startTime = performance.now();
137
140
  const passthrough = new _PassThrough!();
138
141
 
139
142
  let allReadyResolve: (() => void) | null = null;
140
143
  const allReady = new Promise<void>((r) => {
141
144
  allReadyResolve = r;
142
145
  });
143
- // Suppress unhandled rejection if nobody awaits allReady
144
146
  allReady.catch(() => {});
145
147
 
146
148
  const { pipe, abort } = _renderToPipeableStream!(element, {
147
149
  bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
148
150
 
149
151
  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"
152
+ const _shellReady = performance.now();
153
+ // eslint-disable-next-line no-console
154
+ console.log(`[ssr-perf] onShellReady in ${(_shellReady - _startTime).toFixed(1)}ms`);
154
155
  if (deferMs && deferMs > 0) {
155
156
  Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
156
157
  pipe(passthrough);
157
- const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
158
- resolve(wrapStreamWithErrorHandling(webStream, signal));
158
+ resolve(passthrough);
159
159
  });
160
160
  } else {
161
161
  pipe(passthrough);
162
- const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
163
- resolve(wrapStreamWithErrorHandling(webStream, signal));
162
+ resolve(passthrough);
164
163
  }
165
164
  },
166
165
 
@@ -173,13 +172,11 @@ async function renderViaPipeableStream(
173
172
  },
174
173
 
175
174
  onError(error: unknown) {
176
- // Suppress connection abort logging — not an application error.
177
175
  if (isAbortError(error) || signal?.aborted) return;
178
176
  console.error('[timber] SSR render error:', formatSsrError(error));
179
177
  },
180
178
  });
181
179
 
182
- // Wire up abort signal — cancel React rendering if the client disconnects.
183
180
  if (signal) {
184
181
  if (signal.aborted) {
185
182
  abort();
@@ -190,6 +187,13 @@ async function renderViaPipeableStream(
190
187
  });
191
188
  }
192
189
 
190
+ /** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
191
+ export function nodeReadableToWeb(
192
+ readable: import('node:stream').Readable
193
+ ): ReadableStream<Uint8Array> {
194
+ return _ReadableToWeb!(readable) as ReadableStream<Uint8Array>;
195
+ }
196
+
193
197
  // ─── Web Streams Path: renderToReadableStream ────────────────────────────────
194
198
  //
195
199
  // Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a