@timber-js/app 0.2.0-alpha.28 → 0.2.0-alpha.29

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.
@@ -10,4 +10,16 @@
10
10
  * point, not abstraction.
11
11
  */
12
12
  export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
13
+ /**
14
+ * Decode an RSC Flight stream from a Node.js Readable.
15
+ *
16
+ * Uses the vendored createFromNodeStream which reads via Node.js native
17
+ * streams (C++ libuv) instead of Web ReadableStream (JS Promise per chunk).
18
+ *
19
+ * Returns null if createFromNodeStream isn't available (CF Workers, etc),
20
+ * signaling the caller to use createFromReadableStream instead.
21
+ */
22
+ export declare function createFromNodeStream(stream: import('node:stream').Readable): React.ReactNode | null;
23
+ /** Whether the Node.js stream RSC decode path is available. */
24
+ export declare const hasNodeStreamDecode: boolean;
13
25
  //# sourceMappingURL=ssr.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../../src/rsc-runtime/ssr.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../../src/rsc-runtime/ssr.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAiClE;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,OAAO,aAAa,EAAE,QAAQ,GACrC,KAAK,CAAC,SAAS,GAAG,IAAI,CAGxB;AAED,+DAA+D;AAC/D,eAAO,MAAM,mBAAmB,SAAiC,CAAC"}
@@ -51,4 +51,15 @@ export declare function createNodeFlightInjector(rscStream: ReadableStream<Uint8
51
51
  * after the shell has flushed) and closes the stream cleanly.
52
52
  */
53
53
  export declare function createNodeErrorHandler(signal?: AbortSignal): Transform;
54
+ /**
55
+ * Create a Node.js gzip Transform using native node:zlib.
56
+ *
57
+ * Uses `createGzip()` which is backed by C++ zlib — significantly faster
58
+ * than the Web Streams `CompressionStream` API (which is a JS wrapper
59
+ * around the same zlib but with per-chunk Promise overhead).
60
+ *
61
+ * Returns null if the response shouldn't be compressed (wrong content type,
62
+ * client doesn't accept gzip, already encoded, etc.).
63
+ */
64
+ export declare function createNodeGzipCompressor(requestHeaders: Headers, responseHeaders: Headers): Transform | null;
54
65
  //# sourceMappingURL=node-stream-transforms.d.ts.map
@@ -1 +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
+ {"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;AAKxC;;;;;;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;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA0BlB"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAiEH;;;;;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,CA6HnB;AAED,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0EH;;;;;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,CAqJnB;AAED,eAAe,SAAS,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.28",
3
+ "version": "0.2.0-alpha.29",
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",
@@ -11,3 +11,53 @@
11
11
  */
12
12
 
13
13
  export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
14
+
15
+ // ─── Node.js Stream Path ─────────────────────────────────────────────────────
16
+ //
17
+ // On Node.js, createFromNodeStream reads RSC Flight data from a Node.js
18
+ // Readable (C++ backed) instead of a Web ReadableStream (JS reimplementation).
19
+ // Eliminates Promise-per-chunk overhead on the SSR decode path.
20
+ //
21
+ // The plugin only exports createFromReadableStream (via client.edge.js).
22
+ // createFromNodeStream lives in the vendored client.node.js. We import it
23
+ // directly and wire up the same serverConsumerManifest the plugin uses.
24
+
25
+ import { createServerConsumerManifest } from '@vitejs/plugin-rsc/ssr';
26
+
27
+ type CreateFromNodeStreamFn = (
28
+ stream: import('node:stream').Readable,
29
+ manifest: ReturnType<typeof createServerConsumerManifest>,
30
+ options?: Record<string, unknown>
31
+ ) => React.ReactNode;
32
+
33
+ let _createFromNodeStream: CreateFromNodeStreamFn | null = null;
34
+
35
+ try {
36
+ if (typeof process !== 'undefined' && process.release?.name === 'node') {
37
+ const clientNode = await import('@vitejs/plugin-rsc/vendor/react-server-dom/client.node');
38
+ if (typeof clientNode.createFromNodeStream === 'function') {
39
+ _createFromNodeStream = clientNode.createFromNodeStream;
40
+ }
41
+ }
42
+ } catch {
43
+ // Not available — fall back to createFromReadableStream
44
+ }
45
+
46
+ /**
47
+ * Decode an RSC Flight stream from a Node.js Readable.
48
+ *
49
+ * Uses the vendored createFromNodeStream which reads via Node.js native
50
+ * streams (C++ libuv) instead of Web ReadableStream (JS Promise per chunk).
51
+ *
52
+ * Returns null if createFromNodeStream isn't available (CF Workers, etc),
53
+ * signaling the caller to use createFromReadableStream instead.
54
+ */
55
+ export function createFromNodeStream(
56
+ stream: import('node:stream').Readable
57
+ ): React.ReactNode | null {
58
+ if (!_createFromNodeStream) return null;
59
+ return _createFromNodeStream(stream, createServerConsumerManifest());
60
+ }
61
+
62
+ /** Whether the Node.js stream RSC decode path is available. */
63
+ export const hasNodeStreamDecode = _createFromNodeStream !== null;
@@ -0,0 +1,7 @@
1
+ declare module '@vitejs/plugin-rsc/vendor/react-server-dom/client.node' {
2
+ export function createFromNodeStream(
3
+ stream: import('node:stream').Readable,
4
+ manifest: unknown,
5
+ options?: Record<string, unknown>
6
+ ): React.ReactNode;
7
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { Transform } from 'node:stream';
21
+ import { createGzip } from 'node:zlib';
21
22
 
22
23
  // ─── Head Injection ──────────────────────────────────────────────────────────
23
24
 
@@ -253,3 +254,62 @@ export function createNodeErrorHandler(signal?: AbortSignal): Transform {
253
254
 
254
255
  return transform;
255
256
  }
257
+
258
+ // ─── Compression ─────────────────────────────────────────────────────────────
259
+
260
+ const COMPRESSIBLE_TYPES = new Set([
261
+ 'text/html',
262
+ 'text/css',
263
+ 'text/plain',
264
+ 'text/xml',
265
+ 'text/javascript',
266
+ 'text/x-component',
267
+ 'application/json',
268
+ 'application/javascript',
269
+ 'application/xml',
270
+ 'application/xhtml+xml',
271
+ 'application/rss+xml',
272
+ 'application/atom+xml',
273
+ 'image/svg+xml',
274
+ ]);
275
+
276
+ /**
277
+ * Create a Node.js gzip Transform using native node:zlib.
278
+ *
279
+ * Uses `createGzip()` which is backed by C++ zlib — significantly faster
280
+ * than the Web Streams `CompressionStream` API (which is a JS wrapper
281
+ * around the same zlib but with per-chunk Promise overhead).
282
+ *
283
+ * Returns null if the response shouldn't be compressed (wrong content type,
284
+ * client doesn't accept gzip, already encoded, etc.).
285
+ */
286
+ export function createNodeGzipCompressor(
287
+ requestHeaders: Headers,
288
+ responseHeaders: Headers
289
+ ): Transform | null {
290
+ // Check Accept-Encoding
291
+ const acceptEncoding = requestHeaders.get('accept-encoding') || '';
292
+ if (!acceptEncoding.includes('gzip')) return null;
293
+
294
+ // Check content type is compressible
295
+ const contentType = responseHeaders.get('content-type') || '';
296
+ const mimeType = contentType.split(';')[0].trim().toLowerCase();
297
+ if (!COMPRESSIBLE_TYPES.has(mimeType)) return null;
298
+
299
+ // Don't double-compress
300
+ if (responseHeaders.has('content-encoding')) return null;
301
+
302
+ // Set response headers for gzip
303
+ responseHeaders.set('content-encoding', 'gzip');
304
+ responseHeaders.delete('content-length');
305
+ const existingVary = responseHeaders.get('vary');
306
+ if (existingVary) {
307
+ if (!existingVary.toLowerCase().includes('accept-encoding')) {
308
+ responseHeaders.set('vary', existingVary + ', Accept-Encoding');
309
+ }
310
+ } else {
311
+ responseHeaders.set('vary', 'Accept-Encoding');
312
+ }
313
+
314
+ return createGzip();
315
+ }
@@ -14,7 +14,11 @@
14
14
 
15
15
  // @ts-expect-error — virtual module provided by timber-entries plugin
16
16
  import config from 'virtual:timber-config';
17
- import { createFromReadableStream } from '#/rsc-runtime/ssr.js';
17
+ import {
18
+ createFromReadableStream,
19
+ createFromNodeStream,
20
+ hasNodeStreamDecode,
21
+ } from '#/rsc-runtime/ssr.js';
18
22
  import { AsyncLocalStorage } from 'node:async_hooks';
19
23
 
20
24
  import {
@@ -41,6 +45,9 @@ let _nodeStreamImports: {
41
45
  createNodeErrorHandler: typeof import('./node-stream-transforms.js').createNodeErrorHandler;
42
46
  pipeline: typeof import('node:stream/promises').pipeline;
43
47
  PassThrough: typeof import('node:stream').PassThrough;
48
+ ReadableFromWeb: (
49
+ webStream: import('stream/web').ReadableStream
50
+ ) => import('node:stream').Readable;
44
51
  } | null = null;
45
52
 
46
53
  if (useNodeStreams) {
@@ -56,6 +63,8 @@ if (useNodeStreams) {
56
63
  createNodeErrorHandler: transforms.createNodeErrorHandler,
57
64
  pipeline: streamPromises.pipeline,
58
65
  PassThrough: stream.PassThrough,
66
+ ReadableFromWeb: (webStream: import('stream/web').ReadableStream) =>
67
+ stream.Readable.fromWeb(webStream) as import('node:stream').Readable,
59
68
  };
60
69
  } catch {
61
70
  // Fall back to Web Streams path
@@ -174,7 +183,19 @@ export async function handleSsr(
174
183
  // (from "use client" modules) using the SSR environment's module
175
184
  // map, importing the actual components for server-side rendering.
176
185
  const _s0 = performance.now();
177
- const element = createFromReadableStream(rscStream) as React.ReactNode;
186
+ // Decode the RSC stream into a React element tree.
187
+ // On Node.js: convert Web ReadableStream → Node Readable → createFromNodeStream
188
+ // (eliminates Promise-per-chunk overhead from Web Streams reader)
189
+ // On Workers: createFromReadableStream (Web Streams are V8-native C++ there)
190
+ let element: React.ReactNode;
191
+ if (hasNodeStreamDecode && _nodeStreamImports) {
192
+ const nodeRscStream = _nodeStreamImports.ReadableFromWeb(
193
+ rscStream as import('stream/web').ReadableStream
194
+ );
195
+ element = createFromNodeStream(nodeRscStream) as React.ReactNode;
196
+ } else {
197
+ element = createFromReadableStream(rscStream) as React.ReactNode;
198
+ }
178
199
  const _s1 = performance.now();
179
200
 
180
201
  // Wrap with a server-safe nuqs adapter so that 'use client' components
@@ -198,7 +219,13 @@ export async function handleSsr(
198
219
  // Web Streams are V8-native C++ built-ins on Workers.
199
220
  if (_nodeStreamImports) {
200
221
  // Node.js fast path: full pipeline in native streams
201
- const { createNodeHeadInjector, createNodeFlightInjector, createNodeErrorHandler, pipeline, PassThrough } = _nodeStreamImports;
222
+ const {
223
+ createNodeHeadInjector,
224
+ createNodeFlightInjector,
225
+ createNodeErrorHandler,
226
+ pipeline,
227
+ PassThrough,
228
+ } = _nodeStreamImports;
202
229
 
203
230
  const _s3 = performance.now();
204
231
  let nodeHtmlStream: import('node:stream').Readable;
@@ -219,7 +246,7 @@ export async function handleSsr(
219
246
  );
220
247
  }
221
248
 
222
- // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector
249
+ // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector → gzip
223
250
  const errorHandler = createNodeErrorHandler(navContext.signal);
224
251
  const headInjector = createNodeHeadInjector(navContext.headHtml);
225
252
  const flightInjector = createNodeFlightInjector(navContext.rscStream);
@@ -227,6 +254,10 @@ export async function handleSsr(
227
254
  // Pipe through the chain. pipeline() handles backpressure and error propagation.
228
255
  // The last stream in the chain is the output — convert to Web ReadableStream
229
256
  // only at the Response boundary.
257
+ // Note: gzip compression is still handled by compressResponse() in the Nitro
258
+ // entry via Web Streams CompressionStream. Moving it into this Node.js pipeline
259
+ // requires the request headers (Accept-Encoding) which NavContext doesn't carry.
260
+ // TODO: pass request headers through NavContext to enable inline Node.js gzip.
230
261
  const output = new PassThrough();
231
262
  pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
232
263
  // Pipeline errors are handled by errorHandler transform
@@ -234,7 +265,9 @@ export async function handleSsr(
234
265
 
235
266
  const _s4 = performance.now();
236
267
  // eslint-disable-next-line no-console
237
- 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`);
268
+ console.log(
269
+ `[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`
270
+ );
238
271
 
239
272
  const webStream = nodeReadableToWeb(output);
240
273
  return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);