@timber-js/app 0.2.0-alpha.27 → 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;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"}
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.27",
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 {
@@ -32,6 +36,41 @@ import { withSpan } from './tracing.js';
32
36
  import { setCurrentParams } from '#/client/use-params.js';
33
37
  import { registerSsrDataProvider, type SsrData } from '#/client/ssr-data.js';
34
38
 
39
+ // Pre-import Node.js stream modules at module load time — not per-request.
40
+ // Dynamic imports of node-stream-transforms and node:stream/promises were
41
+ // costing 3-17ms per request due to module resolution overhead.
42
+ let _nodeStreamImports: {
43
+ createNodeHeadInjector: typeof import('./node-stream-transforms.js').createNodeHeadInjector;
44
+ createNodeFlightInjector: typeof import('./node-stream-transforms.js').createNodeFlightInjector;
45
+ createNodeErrorHandler: typeof import('./node-stream-transforms.js').createNodeErrorHandler;
46
+ pipeline: typeof import('node:stream/promises').pipeline;
47
+ PassThrough: typeof import('node:stream').PassThrough;
48
+ ReadableFromWeb: (
49
+ webStream: import('stream/web').ReadableStream
50
+ ) => import('node:stream').Readable;
51
+ } | null = null;
52
+
53
+ if (useNodeStreams) {
54
+ try {
55
+ const [transforms, streamPromises, stream] = await Promise.all([
56
+ import('./node-stream-transforms.js'),
57
+ import('node:stream/promises'),
58
+ import('node:stream'),
59
+ ]);
60
+ _nodeStreamImports = {
61
+ createNodeHeadInjector: transforms.createNodeHeadInjector,
62
+ createNodeFlightInjector: transforms.createNodeFlightInjector,
63
+ createNodeErrorHandler: transforms.createNodeErrorHandler,
64
+ pipeline: streamPromises.pipeline,
65
+ PassThrough: stream.PassThrough,
66
+ ReadableFromWeb: (webStream: import('stream/web').ReadableStream) =>
67
+ stream.Readable.fromWeb(webStream) as import('node:stream').Readable,
68
+ };
69
+ } catch {
70
+ // Fall back to Web Streams path
71
+ }
72
+ }
73
+
35
74
  // ─── SSR Data ALS ─────────────────────────────────────────────────────────
36
75
  //
37
76
  // Per-request SSR data stored in AsyncLocalStorage, ensuring correct
@@ -144,7 +183,19 @@ export async function handleSsr(
144
183
  // (from "use client" modules) using the SSR environment's module
145
184
  // map, importing the actual components for server-side rendering.
146
185
  const _s0 = performance.now();
147
- 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
+ }
148
199
  const _s1 = performance.now();
149
200
 
150
201
  // Wrap with a server-safe nuqs adapter so that 'use client' components
@@ -166,11 +217,15 @@ export async function handleSsr(
166
217
  // Entire pipeline stays in C++ native streams until the Response boundary.
167
218
  // - Workers: renderToReadableStream → Web TransformStream pipeline → Response
168
219
  // Web Streams are V8-native C++ built-ins on Workers.
169
- if (useNodeStreams) {
220
+ if (_nodeStreamImports) {
170
221
  // 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');
222
+ const {
223
+ createNodeHeadInjector,
224
+ createNodeFlightInjector,
225
+ createNodeErrorHandler,
226
+ pipeline,
227
+ PassThrough,
228
+ } = _nodeStreamImports;
174
229
 
175
230
  const _s3 = performance.now();
176
231
  let nodeHtmlStream: import('node:stream').Readable;
@@ -191,7 +246,7 @@ export async function handleSsr(
191
246
  );
192
247
  }
193
248
 
194
- // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector
249
+ // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector → gzip
195
250
  const errorHandler = createNodeErrorHandler(navContext.signal);
196
251
  const headInjector = createNodeHeadInjector(navContext.headHtml);
197
252
  const flightInjector = createNodeFlightInjector(navContext.rscStream);
@@ -199,7 +254,10 @@ export async function handleSsr(
199
254
  // Pipe through the chain. pipeline() handles backpressure and error propagation.
200
255
  // The last stream in the chain is the output — convert to Web ReadableStream
201
256
  // only at the Response boundary.
202
- const { PassThrough } = await import('node:stream');
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.
203
261
  const output = new PassThrough();
204
262
  pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
205
263
  // Pipeline errors are handled by errorHandler transform
@@ -207,7 +265,9 @@ export async function handleSsr(
207
265
 
208
266
  const _s4 = performance.now();
209
267
  // 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`);
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
+ );
211
271
 
212
272
  const webStream = nodeReadableToWeb(output);
213
273
  return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);