@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.
- 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 +69 -9
|
@@ -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 {
|
|
@@ -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
|
-
|
|
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 (
|
|
220
|
+
if (_nodeStreamImports) {
|
|
170
221
|
// Node.js fast path: full pipeline in native streams
|
|
171
|
-
const {
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|