@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.
- package/dist/rsc-runtime/ssr.d.ts +12 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +11 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/rsc-runtime/ssr.ts +50 -0
- package/src/rsc-runtime/vendor-types.d.ts +7 -0
- package/src/server/node-stream-transforms.ts +60 -0
- package/src/server/ssr-entry.ts +38 -5
|
@@ -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;
|
|
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;
|
|
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.
|
|
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",
|
package/src/rsc-runtime/ssr.ts
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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);
|