@timber-js/app 0.2.0-alpha.22 → 0.2.0-alpha.23

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.
@@ -5,6 +5,17 @@
5
5
  * independently of the Vite RSC plugin runtime (which provides
6
6
  * createFromReadableStream for decoding RSC streams).
7
7
  *
8
+ * Uses a platform-adaptive rendering strategy:
9
+ * - **Node.js / Bun**: `renderToPipeableStream` — React pipes HTML chunks
10
+ * through Node.js native streams (C++ implementation). Each chunk flows
11
+ * through libuv buffers with zero Promise overhead.
12
+ * - **Cloudflare Workers / Edge**: `renderToReadableStream` — React outputs
13
+ * to Web Streams which are V8-native C++ built-ins on these platforms.
14
+ *
15
+ * The detection is automatic at runtime. Both paths produce a Web
16
+ * `ReadableStream<Uint8Array>` so downstream transforms (injectHead,
17
+ * injectRscPayload, compression) work identically regardless of platform.
18
+ *
8
19
  * Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
9
20
  * 18-build-system.md §"Entry Files"
10
21
  */
@@ -12,24 +23,15 @@ import type { ReactNode } from 'react';
12
23
  /**
13
24
  * Render a React element tree to a ReadableStream of HTML.
14
25
  *
15
- * Uses renderToReadableStream (NOT renderToString) for streaming SSR.
26
+ * Automatically selects the optimal rendering path for the platform:
27
+ * - Node.js/Bun: `renderToPipeableStream` → Node.js native streams → `Readable.toWeb()`
28
+ * - CF Workers/Edge: `renderToReadableStream` → native Web Streams
29
+ *
16
30
  * The returned stream begins yielding after onShellReady — everything
17
31
  * outside <Suspense> boundaries is in the shell.
18
32
  *
19
- * With progressive streaming, the RSC stream is piped directly to SSR
20
- * without buffering. If deny() was called outside a Suspense boundary,
21
- * the RSC stream encodes an error in the shell — renderToReadableStream
22
- * rejects, and the RSC entry catches this to render a deny page with
23
- * the correct HTTP status code. If deny() was inside Suspense, the shell
24
- * succeeds (200 committed) and the error streams as an error boundary.
25
- *
26
33
  * @param element - The React element tree decoded from the RSC stream
27
34
  * @param options - Optional configuration
28
- * @param options.bootstrapScriptContent - Inline JS injected by React as a
29
- * non-deferred `<script>` in the shell HTML. Executes immediately during
30
- * parsing — even while Suspense boundaries are still streaming. Used to
31
- * kick off module loading via dynamic `import()` so hydration can start
32
- * before the HTML stream closes.
33
35
  * @returns A ReadableStream of HTML bytes with hydration markers
34
36
  */
35
37
  export declare function renderSsrStream(element: ReactNode, options?: {
@@ -42,9 +44,9 @@ export declare function renderSsrStream(element: ReactNode, options?: {
42
44
  *
43
45
  * During progressive RSC→SSR streaming, errors in Suspense boundaries
44
46
  * (e.g. deny() inside Suspense, throws in async components) cause
45
- * React DOM's renderToReadableStream to error after the shell has been
46
- * flushed. Without this wrapper, the stream error becomes an unhandled
47
- * promise rejection that crashes the process.
47
+ * React DOM's stream to error after the shell has been flushed. Without
48
+ * this wrapper, the stream error becomes an unhandled promise rejection
49
+ * that crashes the process.
48
50
  *
49
51
  * The wrapper catches streaming-phase errors, logs them, and closes
50
52
  * the output stream cleanly. The shell (headers, status code, content
@@ -57,11 +59,6 @@ export declare function wrapStreamWithErrorHandling(stream: ReadableStream<Uint8
57
59
  * status code and headers from the navigation context.
58
60
  *
59
61
  * Sets content-type to text/html if not already set by middleware.
60
- *
61
- * @param htmlStream - The HTML stream from renderSsrStream
62
- * @param statusCode - The committed HTTP status code from RSC
63
- * @param responseHeaders - Response headers from middleware/proxy
64
- * @returns A Response ready to send to the client
65
62
  */
66
63
  export declare function buildSsrResponse(htmlStream: ReadableStream<Uint8Array>, statusCode: number, responseHeaders: Headers): Response;
67
64
  //# sourceMappingURL=ssr-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAmBvC;;;;;;;;;;;;;;;;;;;;;;GAsBG;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,CAmDrC;AAED;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CAkC5B;AAeD;;;;;;;;;;GAUG;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;AA0DvC;;;;;;;;;;;;;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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.22",
3
+ "version": "0.2.0-alpha.23",
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",
@@ -5,12 +5,23 @@
5
5
  * independently of the Vite RSC plugin runtime (which provides
6
6
  * createFromReadableStream for decoding RSC streams).
7
7
  *
8
+ * Uses a platform-adaptive rendering strategy:
9
+ * - **Node.js / Bun**: `renderToPipeableStream` — React pipes HTML chunks
10
+ * through Node.js native streams (C++ implementation). Each chunk flows
11
+ * through libuv buffers with zero Promise overhead.
12
+ * - **Cloudflare Workers / Edge**: `renderToReadableStream` — React outputs
13
+ * to Web Streams which are V8-native C++ built-ins on these platforms.
14
+ *
15
+ * The detection is automatic at runtime. Both paths produce a Web
16
+ * `ReadableStream<Uint8Array>` so downstream transforms (injectHead,
17
+ * injectRscPayload, compression) work identically regardless of platform.
18
+ *
8
19
  * Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
9
20
  * 18-build-system.md §"Entry Files"
10
21
  */
11
22
 
12
23
  import type { ReactNode } from 'react';
13
- import { renderToReadableStream } from 'react-dom/server';
24
+ import { renderToReadableStream, renderToPipeableStream } from 'react-dom/server';
14
25
 
15
26
  import { formatSsrError } from './error-formatter.js';
16
27
 
@@ -28,60 +39,167 @@ import { formatSsrError } from './error-formatter.js';
28
39
  const NOINDEX_SCRIPT =
29
40
  '<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
30
41
 
42
+ // ─── Platform Detection ──────────────────────────────────────────────────────
43
+ //
44
+ // Detect whether we're running on a platform with native Node.js streams.
45
+ // On Node.js/Bun, `node:stream` is backed by C++ (libuv). On Cloudflare
46
+ // Workers, `node:stream` via nodejs_compat is a JS polyfill — Web Streams
47
+ // are the faster path there (V8-native C++ built-ins).
48
+ //
49
+ // We detect once at module load to avoid per-request overhead.
50
+ // The check: process.versions.node exists AND we can import node:stream.
51
+ // Cloudflare Workers with nodejs_compat may polyfill process.versions but
52
+ // the streams won't be native. The Readable.toWeb check confirms native support.
53
+
54
+ let _useNodeStreams = false;
55
+ let _PassThrough: typeof import('node:stream').PassThrough | null = null;
56
+ let _ReadableToWeb: ((readable: import('node:stream').Readable) => ReadableStream) | null = null;
57
+
58
+ try {
59
+ // Dynamic import to avoid bundling node:stream for CF Workers builds.
60
+ // On Node.js/Bun this resolves to native C++ streams.
61
+ // On CF Workers this either fails or returns a JS polyfill.
62
+ const nodeStream = await import('node:stream');
63
+ if (
64
+ typeof nodeStream.PassThrough === 'function' &&
65
+ typeof nodeStream.Readable.toWeb === 'function' &&
66
+ // Real Node.js — not a polyfill. Polyfills typically don't set
67
+ // process.release.name to 'node'.
68
+ typeof process !== 'undefined' &&
69
+ process.release?.name === 'node'
70
+ ) {
71
+ _useNodeStreams = true;
72
+ _PassThrough = nodeStream.PassThrough;
73
+ _ReadableToWeb = nodeStream.Readable.toWeb as (
74
+ readable: import('node:stream').Readable
75
+ ) => ReadableStream;
76
+ }
77
+ } catch {
78
+ // node:stream not available — use Web Streams path
79
+ }
80
+
31
81
  /**
32
82
  * Render a React element tree to a ReadableStream of HTML.
33
83
  *
34
- * Uses renderToReadableStream (NOT renderToString) for streaming SSR.
84
+ * Automatically selects the optimal rendering path for the platform:
85
+ * - Node.js/Bun: `renderToPipeableStream` → Node.js native streams → `Readable.toWeb()`
86
+ * - CF Workers/Edge: `renderToReadableStream` → native Web Streams
87
+ *
35
88
  * The returned stream begins yielding after onShellReady — everything
36
89
  * outside <Suspense> boundaries is in the shell.
37
90
  *
38
- * With progressive streaming, the RSC stream is piped directly to SSR
39
- * without buffering. If deny() was called outside a Suspense boundary,
40
- * the RSC stream encodes an error in the shell — renderToReadableStream
41
- * rejects, and the RSC entry catches this to render a deny page with
42
- * the correct HTTP status code. If deny() was inside Suspense, the shell
43
- * succeeds (200 committed) and the error streams as an error boundary.
44
- *
45
91
  * @param element - The React element tree decoded from the RSC stream
46
92
  * @param options - Optional configuration
47
- * @param options.bootstrapScriptContent - Inline JS injected by React as a
48
- * non-deferred `<script>` in the shell HTML. Executes immediately during
49
- * parsing — even while Suspense boundaries are still streaming. Used to
50
- * kick off module loading via dynamic `import()` so hydration can start
51
- * before the HTML stream closes.
52
93
  * @returns A ReadableStream of HTML bytes with hydration markers
53
94
  */
54
95
  export async function renderSsrStream(
55
96
  element: ReactNode,
56
97
  options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
98
+ ): Promise<ReadableStream<Uint8Array>> {
99
+ if (_useNodeStreams) {
100
+ return renderViaPipeableStream(element, options);
101
+ }
102
+ return renderViaReadableStream(element, options);
103
+ }
104
+
105
+ // ─── Node.js Path: renderToPipeableStream ────────────────────────────────────
106
+ //
107
+ // Uses React's Node.js-native API. HTML chunks flow through C++ stream
108
+ // buffers with zero Promise allocations per chunk. The PassThrough stream
109
+ // is converted to a Web ReadableStream via Readable.toWeb() (zero-copy
110
+ // bridge available in Node.js 17+) for compatibility with downstream
111
+ // Web Stream transforms (injectHead, injectRscPayload).
112
+
113
+ async function renderViaPipeableStream(
114
+ element: ReactNode,
115
+ options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
116
+ ): Promise<ReadableStream<Uint8Array>> {
117
+ const signal = options?.signal;
118
+ const deferMs = options?.deferSuspenseFor;
119
+
120
+ return new Promise<ReadableStream<Uint8Array>>((resolve, reject) => {
121
+ const passthrough = new _PassThrough!();
122
+
123
+ let allReadyResolve: (() => void) | null = null;
124
+ const allReady = new Promise<void>((r) => {
125
+ allReadyResolve = r;
126
+ });
127
+ // Suppress unhandled rejection if nobody awaits allReady
128
+ allReady.catch(() => {});
129
+
130
+ const { pipe, abort } = renderToPipeableStream(element, {
131
+ bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
132
+
133
+ onShellReady() {
134
+ // deferSuspenseFor: delay piping so React can resolve fast-completing
135
+ // Suspense boundaries before we read the shell. When we delay, React
136
+ // inlines resolved content instead of serializing fallbacks.
137
+ // See design/05-streaming.md §"deferSuspenseFor"
138
+ if (deferMs && deferMs > 0) {
139
+ Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
140
+ pipe(passthrough);
141
+ const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
142
+ resolve(wrapStreamWithErrorHandling(webStream, signal));
143
+ });
144
+ } else {
145
+ pipe(passthrough);
146
+ const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
147
+ resolve(wrapStreamWithErrorHandling(webStream, signal));
148
+ }
149
+ },
150
+
151
+ onAllReady() {
152
+ allReadyResolve?.();
153
+ },
154
+
155
+ onShellError(error: unknown) {
156
+ reject(error);
157
+ },
158
+
159
+ onError(error: unknown) {
160
+ // Suppress connection abort logging — not an application error.
161
+ if (isAbortError(error) || signal?.aborted) return;
162
+ console.error('[timber] SSR render error:', formatSsrError(error));
163
+ },
164
+ });
165
+
166
+ // Wire up abort signal — cancel React rendering if the client disconnects.
167
+ if (signal) {
168
+ if (signal.aborted) {
169
+ abort();
170
+ } else {
171
+ signal.addEventListener('abort', () => abort(), { once: true });
172
+ }
173
+ }
174
+ });
175
+ }
176
+
177
+ // ─── Web Streams Path: renderToReadableStream ────────────────────────────────
178
+ //
179
+ // Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a
180
+ // V8-native C++ built-in, making this the fastest path for that platform.
181
+ // On Node.js, Web Streams are a JS reimplementation — slower, but this path
182
+ // is only used as a fallback when Node.js native streams aren't available.
183
+
184
+ async function renderViaReadableStream(
185
+ element: ReactNode,
186
+ options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
57
187
  ): Promise<ReadableStream<Uint8Array>> {
58
188
  const signal = options?.signal;
59
189
  const stream = await renderToReadableStream(element, {
60
190
  bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
61
191
  signal,
62
192
  onError(error: unknown) {
63
- // Suppress logging for connection aborts — the user refreshed or
64
- // navigated away, not an application error.
65
193
  if (isAbortError(error) || signal?.aborted) return;
66
194
  console.error('[timber] SSR render error:', formatSsrError(error));
67
195
  },
68
196
  });
69
197
 
70
198
  // Prevent unhandled promise rejection from streaming-phase errors.
71
- // React DOM Server exposes `allReady` — a promise that resolves when
72
- // ALL content (including Suspense boundaries) has been rendered. If a
73
- // streaming-phase error occurs (e.g. React boundary flush failure),
74
- // `allReady` rejects independently of the stream. Without this catch,
75
- // the rejection becomes an unhandled promise rejection that crashes
76
- // the Node.js process.
77
199
  stream.allReady.catch(() => {});
78
200
 
79
201
  // deferSuspenseFor hold: delay the first read so React can resolve
80
202
  // fast-completing Suspense boundaries before we read the shell HTML.
81
- // renderToReadableStream generates HTML lazily on pull — if we wait
82
- // before reading, React resolves pending boundaries and inlines their
83
- // content instead of serializing fallbacks. Race allReady against
84
- // deferSuspenseFor so we don't wait longer than necessary.
85
203
  // See design/05-streaming.md §"deferSuspenseFor"
86
204
  const deferMs = options?.deferSuspenseFor;
87
205
  if (deferMs && deferMs > 0) {
@@ -91,30 +209,19 @@ export async function renderSsrStream(
91
209
  ]);
92
210
  }
93
211
 
94
- // renderToReadableStream resolves after onShellReady by default.
95
- // The stream is ready to read — the shell (everything outside
96
- // Suspense boundaries) is available. Suspense content streams
97
- // into the open connection as it resolves.
98
- //
99
- // Wrap the stream in an error-resilient transform. With progressive
100
- // streaming, errors inside Suspense boundaries (e.g. deny() or throws
101
- // in async components) cause React's stream to error during the flush
102
- // phase. The onError callback logs the error, but the stream error
103
- // would become an unhandled promise rejection and crash the process.
104
- // The transform catches these post-shell streaming errors and closes
105
- // the stream cleanly — the shell (with correct status code) has
106
- // already been sent.
107
212
  return wrapStreamWithErrorHandling(stream, signal);
108
213
  }
109
214
 
215
+ // ─── Shared Utilities ────────────────────────────────────────────────────────
216
+
110
217
  /**
111
218
  * Wrap an HTML stream with error handling for the streaming phase.
112
219
  *
113
220
  * During progressive RSC→SSR streaming, errors in Suspense boundaries
114
221
  * (e.g. deny() inside Suspense, throws in async components) cause
115
- * React DOM's renderToReadableStream to error after the shell has been
116
- * flushed. Without this wrapper, the stream error becomes an unhandled
117
- * promise rejection that crashes the process.
222
+ * React DOM's stream to error after the shell has been flushed. Without
223
+ * this wrapper, the stream error becomes an unhandled promise rejection
224
+ * that crashes the process.
118
225
  *
119
226
  * The wrapper catches streaming-phase errors, logs them, and closes
120
227
  * the output stream cleanly. The shell (headers, status code, content
@@ -138,17 +245,10 @@ export function wrapStreamWithErrorHandling(
138
245
  }
139
246
  controller.enqueue(value);
140
247
  } catch (error) {
141
- // Connection abort (user refreshed or navigated away) — close
142
- // silently without logging. This is not an application error.
143
248
  if (isAbortError(error) || signal?.aborted) {
144
249
  controller.close();
145
250
  return;
146
251
  }
147
- // Streaming-phase error (e.g. React boundary flush failure,
148
- // deny() or throw inside Suspense after flush).
149
- // The shell has already been sent with status 200. Inject a
150
- // noindex meta tag so search engines don't index this error page,
151
- // then close cleanly. See design/05-streaming.md.
152
252
  console.error('[timber] SSR streaming error (post-shell):', formatSsrError(error));
153
253
  controller.enqueue(encoder.encode(NOINDEX_SCRIPT));
154
254
  controller.close();
@@ -162,10 +262,6 @@ export function wrapStreamWithErrorHandling(
162
262
 
163
263
  /**
164
264
  * Check if an error is an abort error (connection closed by client).
165
- *
166
- * When the browser aborts a request (page refresh, navigation away),
167
- * the AbortSignal fires and React/streams throw an AbortError. This
168
- * is not an application error — suppress it from error boundaries and logs.
169
265
  */
170
266
  function isAbortError(error: unknown): boolean {
171
267
  if (error instanceof DOMException && error.name === 'AbortError') return true;
@@ -178,11 +274,6 @@ function isAbortError(error: unknown): boolean {
178
274
  * status code and headers from the navigation context.
179
275
  *
180
276
  * Sets content-type to text/html if not already set by middleware.
181
- *
182
- * @param htmlStream - The HTML stream from renderSsrStream
183
- * @param statusCode - The committed HTTP status code from RSC
184
- * @param responseHeaders - Response headers from middleware/proxy
185
- * @returns A Response ready to send to the client
186
277
  */
187
278
  export function buildSsrResponse(
188
279
  htmlStream: ReadableStream<Uint8Array>,