@timber-js/app 0.2.0-alpha.32 → 0.2.0-alpha.34
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/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.js +5 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +0 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cookies/index.js +0 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +22 -0
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/compress-module.ts +5 -2
- package/src/client/browser-entry.ts +39 -72
- package/src/server/compress.ts +25 -7
- package/src/server/node-stream-transforms.ts +47 -29
- package/src/server/rsc-entry/index.ts +15 -0
- package/src/server/rsc-entry/ssr-renderer.ts +16 -0
- package/src/server/ssr-entry.ts +49 -28
- package/src/server/ssr-render.ts +0 -7
|
@@ -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;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,
|
|
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,CAuHX;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,CA8BlB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA2FA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAkaD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BApR3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AAsRhD,wBAAiE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/ssr-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAGxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAYrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"ssr-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/ssr-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAGxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAYrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD,UAAU,gBAAgB;IACxB,GAAG,EAAE,OAAO,CAAC;IACb,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACtC,OAAO,EAAE,aAAa,CAAC;IACvB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,qBAAqB,CAAC;IACvC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqLjF"}
|
|
@@ -53,6 +53,28 @@ export interface NavContext {
|
|
|
53
53
|
* to a page-level deny. See LOCAL-298.
|
|
54
54
|
*/
|
|
55
55
|
_denyHandledByBoundary?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Mutable: SSR timing data populated by handleSsr().
|
|
58
|
+
* Read by the RSC entry to record sub-phase Server-Timing entries
|
|
59
|
+
* when `serverTiming: 'detailed'` is configured.
|
|
60
|
+
*
|
|
61
|
+
* This bridges the RSC→SSR environment boundary: the SSR entry populates
|
|
62
|
+
* these fields, the RSC entry reads them after callSsr() returns.
|
|
63
|
+
*/
|
|
64
|
+
_ssrTimings?: {
|
|
65
|
+
/** Time to decode RSC stream (createFromReadableStream/createFromNodeStream) */
|
|
66
|
+
decodeMs: number;
|
|
67
|
+
/** Time for Fizz to render the shell (onShellReady) */
|
|
68
|
+
shellMs: number;
|
|
69
|
+
/** Time for pipe() to flush shell bytes to the output stream */
|
|
70
|
+
pipeMs: number;
|
|
71
|
+
/** Time to set up Node.js Transform pipeline / Web Stream transforms */
|
|
72
|
+
pipelineMs: number;
|
|
73
|
+
/** Total SSR time (decode → response ready) */
|
|
74
|
+
totalMs: number;
|
|
75
|
+
/** Whether the Node.js native stream path was used */
|
|
76
|
+
nodeStreams: boolean;
|
|
77
|
+
};
|
|
56
78
|
}
|
|
57
79
|
/**
|
|
58
80
|
* Handle SSR: decode an RSC stream and render it to hydration-ready HTML.
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAuJnB;AAED,eAAe,SAAS,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
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;AAqEvC;;;;;;;;;;;;;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,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,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,OAAO,aAAa,EAAE,QAAQ,CAAC,
|
|
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;AAqEvC;;;;;;;;;;;;;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,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,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,OAAO,aAAa,EAAE,QAAQ,CAAC,CAkDzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AA0CD;;;;;;;;;;;;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.
|
|
3
|
+
"version": "0.2.0-alpha.34",
|
|
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",
|
|
@@ -26,7 +26,7 @@ export function generateCompressModule(): string {
|
|
|
26
26
|
// CompressionStream (Web API) on other runtimes. Brotli is left to CDNs/reverse
|
|
27
27
|
// proxies — at streaming quality levels its ratio advantage is marginal.
|
|
28
28
|
import { Readable } from 'node:stream';
|
|
29
|
-
import { createGzip } from 'node:zlib';
|
|
29
|
+
import { createGzip, constants } from 'node:zlib';
|
|
30
30
|
|
|
31
31
|
const COMPRESSIBLE_TYPES = new Set([
|
|
32
32
|
'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
|
|
@@ -82,7 +82,10 @@ function compressWithGzip(body) {
|
|
|
82
82
|
// Node Readable → Readable.toWeb() → Web ReadableStream
|
|
83
83
|
try {
|
|
84
84
|
const nodeReadable = Readable.fromWeb(body);
|
|
85
|
-
|
|
85
|
+
// Z_SYNC_FLUSH ensures each chunk is flushed immediately so the browser
|
|
86
|
+
// receives the HTML shell before Suspense boundaries resolve. Without it,
|
|
87
|
+
// gzip buffers internally and breaks streaming.
|
|
88
|
+
const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
86
89
|
const compressed = nodeReadable.pipe(gzip);
|
|
87
90
|
return Readable.toWeb(compressed);
|
|
88
91
|
} catch {
|
|
@@ -159,7 +159,17 @@ setServerCallback(async (id: string, args: unknown[]) => {
|
|
|
159
159
|
* Hydrates the server-rendered HTML with React, then initializes
|
|
160
160
|
* client-side navigation for SPA transitions.
|
|
161
161
|
*/
|
|
162
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Read the current scroll position.
|
|
164
|
+
*
|
|
165
|
+
* Checks window scroll first, then explicit `data-timber-scroll-restoration`
|
|
166
|
+
* containers. With segment tree merging, shared layouts are reconciled in
|
|
167
|
+
* place via `cloneElement` — React preserves their DOM and scroll state
|
|
168
|
+
* naturally. We don't need to auto-detect overflow containers; only
|
|
169
|
+
* explicitly marked containers are tracked.
|
|
170
|
+
*
|
|
171
|
+
* See design/19-client-navigation.md §"Overflow Scroll Containers".
|
|
172
|
+
*/
|
|
163
173
|
function getScrollY(): number {
|
|
164
174
|
if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
|
|
165
175
|
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
|
|
@@ -167,71 +177,17 @@ function getScrollY(): number {
|
|
|
167
177
|
for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
|
|
168
178
|
if ((el as HTMLElement).scrollTop > 0) return (el as HTMLElement).scrollTop;
|
|
169
179
|
}
|
|
170
|
-
// Auto-detect: if window isn't scrolled, check for overflow containers.
|
|
171
|
-
// Common pattern: layouts use a scrollable div (overflow-y: auto/scroll)
|
|
172
|
-
// inside a fixed-height parent (h-screen). In this case window.scrollY is
|
|
173
|
-
// always 0 and the real scroll position lives on the overflow container.
|
|
174
|
-
const container = findOverflowContainer();
|
|
175
|
-
if (container && container.scrollTop > 0) return container.scrollTop;
|
|
176
180
|
return 0;
|
|
177
181
|
}
|
|
178
182
|
|
|
179
|
-
/**
|
|
180
|
-
* Find the primary overflow scroll container in the document.
|
|
181
|
-
*
|
|
182
|
-
* Walks direct children of body and their immediate children looking for
|
|
183
|
-
* an element with overflow-y: auto|scroll that is actually scrollable
|
|
184
|
-
* (scrollHeight > clientHeight). Returns the first match, or null.
|
|
185
|
-
*
|
|
186
|
-
* This heuristic covers the common layout patterns:
|
|
187
|
-
* <body> → <root-layout> → <div class="overflow-y-auto">
|
|
188
|
-
* <body> → <root-layout> → <main> → <nested-layout overflow-y-auto>
|
|
189
|
-
*
|
|
190
|
-
* We limit depth to 3 to avoid expensive full-tree traversals while still
|
|
191
|
-
* reaching nested layout scroll containers (e.g., parallel route layouts
|
|
192
|
-
* inside a root layout's <main> element).
|
|
193
|
-
*
|
|
194
|
-
* DIVERGENCE FROM NEXT.JS: Next.js's ScrollAndFocusHandler scrolls only
|
|
195
|
-
* document.documentElement.scrollTop — it does NOT handle overflow containers.
|
|
196
|
-
* Layouts using h-screen + overflow-y-auto have the same scroll bug in Next.js.
|
|
197
|
-
* This heuristic is a deliberate improvement. The tradeoff is fragility: depth-3
|
|
198
|
-
* traversal may miss deeply nested containers or match the wrong element.
|
|
199
|
-
* See design/19-client-navigation.md §"Overflow Scroll Containers".
|
|
200
|
-
*/
|
|
201
|
-
function findOverflowContainer(): HTMLElement | null {
|
|
202
|
-
const candidates: HTMLElement[] = [];
|
|
203
|
-
// Check body's descendants up to depth 3. Depth 3 covers the common case:
|
|
204
|
-
// <body> → <root-layout-div> → <main> → <overflow-container>
|
|
205
|
-
// React context providers (SegmentProvider, NavigationProvider) don't add
|
|
206
|
-
// DOM elements, so depth 3 from body reaches nested layout scroll containers.
|
|
207
|
-
for (const child of document.body.children) {
|
|
208
|
-
candidates.push(child as HTMLElement);
|
|
209
|
-
for (const grandchild of child.children) {
|
|
210
|
-
candidates.push(grandchild as HTMLElement);
|
|
211
|
-
for (const greatGrandchild of grandchild.children) {
|
|
212
|
-
candidates.push(greatGrandchild as HTMLElement);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
for (const el of candidates) {
|
|
217
|
-
if (!(el instanceof HTMLElement)) continue;
|
|
218
|
-
const style = getComputedStyle(el);
|
|
219
|
-
const overflowY = style.overflowY;
|
|
220
|
-
if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
|
|
221
|
-
return el;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
183
|
function bootstrap(runtimeConfig: typeof config): void {
|
|
228
184
|
const _config = runtimeConfig;
|
|
229
185
|
|
|
230
|
-
// Take manual control of scroll restoration.
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
186
|
+
// Take manual control of scroll restoration. Even though segment tree
|
|
187
|
+
// merging preserves shared layout DOM via cloneElement (so React doesn't
|
|
188
|
+
// reset scroll on those elements), the root-level reactRoot.render() with
|
|
189
|
+
// a new element tree can still cause scroll resets on the document during
|
|
190
|
+
// reconciliation. Manual control ensures consistent behavior.
|
|
235
191
|
window.history.scrollRestoration = 'manual';
|
|
236
192
|
|
|
237
193
|
// Hydrate the React tree from the RSC payload.
|
|
@@ -247,6 +203,21 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
247
203
|
// For subsequent navigations, it's fetched from the server.
|
|
248
204
|
type FlightSegment = [isBootstrap: 0] | [isData: 1, data: string];
|
|
249
205
|
|
|
206
|
+
// On streaming pages, the browser entry module may load before the RSC
|
|
207
|
+
// payload scripts arrive. The <script id="_R_"> tag is in the shell (flushed
|
|
208
|
+
// on onShellReady), but the RSC bootstrap script `(self.__timber_f=...).push([0])`
|
|
209
|
+
// is injected by the flight injector AFTER Suspense resolution scripts.
|
|
210
|
+
// If the module import resolves before those scripts execute, __timber_f
|
|
211
|
+
// will be undefined.
|
|
212
|
+
//
|
|
213
|
+
// Fix: if __timber_f isn't available yet, pre-initialize it so the RSC
|
|
214
|
+
// bootstrap script's `(self.__timber_f=self.__timber_f||[]).push([0])` finds
|
|
215
|
+
// our array and pushes into it. This avoids a race condition that causes
|
|
216
|
+
// the browser entry to fall through to createRoot() (no hydration) on
|
|
217
|
+
// streaming pages.
|
|
218
|
+
if (!(self as unknown as Record<string, unknown>).__timber_f) {
|
|
219
|
+
(self as unknown as Record<string, FlightSegment[]>).__timber_f = [];
|
|
220
|
+
}
|
|
250
221
|
const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
|
|
251
222
|
|
|
252
223
|
let _reactRoot: Root | null = null;
|
|
@@ -424,23 +395,20 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
424
395
|
pushState: (data, unused, url) => window.history.pushState(data, unused, url),
|
|
425
396
|
replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
|
|
426
397
|
scrollTo: (x, y) => {
|
|
398
|
+
// Scroll the document viewport.
|
|
427
399
|
window.scrollTo(x, y);
|
|
428
400
|
document.documentElement.scrollTop = y;
|
|
429
401
|
document.body.scrollTop = y;
|
|
430
|
-
//
|
|
402
|
+
// Scroll any element explicitly marked as a scroll container.
|
|
403
|
+
// With segment tree merging, shared layouts (sidebars, nav bars)
|
|
404
|
+
// are reconciled in place via cloneElement — React preserves their
|
|
405
|
+
// DOM and scroll state naturally. We no longer auto-detect overflow
|
|
406
|
+
// containers, which previously found the wrong element (e.g.,
|
|
407
|
+
// scrolling a sidebar instead of the main content area).
|
|
408
|
+
// Use `data-timber-scroll-restoration` to opt in specific containers.
|
|
431
409
|
for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
|
|
432
410
|
(el as HTMLElement).scrollTop = y;
|
|
433
411
|
}
|
|
434
|
-
// Auto-detect overflow containers for layouts that scroll inside
|
|
435
|
-
// a fixed-height wrapper (e.g., h-screen + overflow-y-auto).
|
|
436
|
-
// In these layouts, window.scrollY is always 0 and the real scroll
|
|
437
|
-
// lives on the overflow container. Without this, forward navigation
|
|
438
|
-
// between pages that share a layout with parallel route slots won't
|
|
439
|
-
// scroll to top — the router's window.scrollTo(0,0) is a no-op.
|
|
440
|
-
const container = findOverflowContainer();
|
|
441
|
-
if (container) {
|
|
442
|
-
container.scrollTop = y;
|
|
443
|
-
}
|
|
444
412
|
},
|
|
445
413
|
getCurrentUrl: () => window.location.pathname + window.location.search,
|
|
446
414
|
getScrollY,
|
|
@@ -590,7 +558,6 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
590
558
|
scrollTimer = setTimeout(() => {
|
|
591
559
|
const state = window.history.state;
|
|
592
560
|
if (state && typeof state === 'object') {
|
|
593
|
-
// Use getScrollY to capture scroll from overflow containers too.
|
|
594
561
|
window.history.replaceState({ ...state, scrollY: getScrollY() }, '');
|
|
595
562
|
}
|
|
596
563
|
}, 100);
|
package/src/server/compress.ts
CHANGED
|
@@ -160,15 +160,33 @@ export function compressResponse(request: Request, response: Response): Response
|
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
// ─── Gzip (
|
|
163
|
+
// ─── Gzip (node:zlib with Z_SYNC_FLUSH) ──────────────────────────────────
|
|
164
|
+
//
|
|
165
|
+
// Uses node:zlib's createGzip with Z_SYNC_FLUSH so each chunk is flushed
|
|
166
|
+
// to the output immediately. The Web Platform CompressionStream API buffers
|
|
167
|
+
// internally and does NOT flush per-chunk — this kills streaming because
|
|
168
|
+
// the browser doesn't receive the HTML shell until the gzip stream closes
|
|
169
|
+
// (i.e. after all Suspense boundaries resolve).
|
|
170
|
+
//
|
|
171
|
+
// Z_SYNC_FLUSH adds ~2–5% size overhead vs Z_NO_FLUSH but preserves
|
|
172
|
+
// correct streaming behavior: the shell renders instantly, Suspense
|
|
173
|
+
// fallbacks are visible immediately, and streamed content appears
|
|
174
|
+
// progressively.
|
|
175
|
+
|
|
176
|
+
import { createGzip, constants } from 'node:zlib';
|
|
177
|
+
import { Readable } from 'node:stream';
|
|
164
178
|
|
|
165
179
|
/**
|
|
166
|
-
* Compress a ReadableStream with gzip
|
|
167
|
-
*
|
|
180
|
+
* Compress a ReadableStream with gzip, flushing each chunk immediately.
|
|
181
|
+
*
|
|
182
|
+
* Uses node:zlib's createGzip with Z_SYNC_FLUSH to ensure each HTML chunk
|
|
183
|
+
* (shell, Suspense resolution, RSC payload) is delivered to the browser
|
|
184
|
+
* as soon as it's available — preserving streaming semantics.
|
|
168
185
|
*/
|
|
169
186
|
function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
187
|
+
const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
188
|
+
const nodeInput = Readable.fromWeb(body as import('stream/web').ReadableStream);
|
|
189
|
+
nodeInput.pipe(gzip);
|
|
190
|
+
|
|
191
|
+
return Readable.toWeb(gzip) as ReadableStream<Uint8Array>;
|
|
174
192
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { Transform } from 'node:stream';
|
|
21
|
-
import { createGzip } from 'node:zlib';
|
|
21
|
+
import { createGzip, constants } from 'node:zlib';
|
|
22
22
|
|
|
23
23
|
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
24
24
|
|
|
@@ -124,18 +124,23 @@ export function createNodeFlightInjector(
|
|
|
124
124
|
const rscReader = rscStream.getReader();
|
|
125
125
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
126
126
|
|
|
127
|
-
let pullPromise: Promise<void> | null = null;
|
|
128
127
|
let donePulling = false;
|
|
129
128
|
let pullError: unknown = null;
|
|
130
129
|
let foundSuffix = false;
|
|
131
130
|
let htmlStreamFinished = false;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
// Reference to the transform instance, set on first transform() call.
|
|
132
|
+
// Used by pullLoop to push RSC chunks directly to the output without
|
|
133
|
+
// waiting for HTML chunks to trigger drainPending.
|
|
134
|
+
let transformRef: Transform | null = null;
|
|
135
|
+
|
|
136
|
+
// pullLoop reads RSC chunks and pushes them directly to the transform
|
|
137
|
+
// output as <script> tags. This ensures RSC data is delivered to the
|
|
138
|
+
// browser as soon as it's available — not deferred until the next HTML
|
|
139
|
+
// chunk. Critical for streaming: the shell RSC payload must arrive
|
|
140
|
+
// with the shell HTML so hydration can start before Suspense resolves.
|
|
138
141
|
async function pullLoop(): Promise<void> {
|
|
142
|
+
// Yield once so the first transform() call can set transformRef and
|
|
143
|
+
// emit the bootstrap signal before we start pushing data chunks.
|
|
139
144
|
await new Promise<void>((r) => setImmediate(r));
|
|
140
145
|
try {
|
|
141
146
|
for (;;) {
|
|
@@ -146,7 +151,12 @@ export function createNodeFlightInjector(
|
|
|
146
151
|
}
|
|
147
152
|
const decoded = decoder.decode(value, { stream: true });
|
|
148
153
|
const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
|
|
149
|
-
|
|
154
|
+
const scriptBuf = Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8');
|
|
155
|
+
// Push directly to the transform output — don't wait for an
|
|
156
|
+
// HTML chunk to trigger drainPending.
|
|
157
|
+
if (transformRef) {
|
|
158
|
+
transformRef.push(scriptBuf);
|
|
159
|
+
}
|
|
150
160
|
if (!htmlStreamFinished) {
|
|
151
161
|
await new Promise<void>((r) => setImmediate(r));
|
|
152
162
|
}
|
|
@@ -157,25 +167,21 @@ export function createNodeFlightInjector(
|
|
|
157
167
|
}
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
function drainPending(transform: Transform): void {
|
|
161
|
-
while (pending.length > 0) {
|
|
162
|
-
transform.push(pending.shift()!);
|
|
163
|
-
}
|
|
164
|
-
if (pullError) {
|
|
165
|
-
transform.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
|
|
166
|
-
pullError = null;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
170
|
return new Transform({
|
|
171
171
|
transform(chunk: Buffer, _encoding, callback) {
|
|
172
|
-
if (!
|
|
173
|
-
|
|
172
|
+
if (!transformRef) {
|
|
173
|
+
transformRef = this;
|
|
174
|
+
// Emit bootstrap signal with the first HTML chunk (the shell).
|
|
175
|
+
// This must arrive before any data chunks.
|
|
176
|
+
const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
|
|
177
|
+
this.push(Buffer.from(bootstrap, 'utf-8'));
|
|
178
|
+
// Start the pull loop — it will push RSC data chunks directly
|
|
179
|
+
// to the transform output as they arrive from the RSC stream.
|
|
180
|
+
pullLoop();
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
if (foundSuffix) {
|
|
177
184
|
this.push(chunk);
|
|
178
|
-
if (pending.length > 0) drainPending(this);
|
|
179
185
|
callback();
|
|
180
186
|
return;
|
|
181
187
|
}
|
|
@@ -187,7 +193,6 @@ export function createNodeFlightInjector(
|
|
|
187
193
|
const before = text.slice(0, idx);
|
|
188
194
|
const after = text.slice(idx + suffix.length);
|
|
189
195
|
if (before) this.push(Buffer.from(before, 'utf-8'));
|
|
190
|
-
if (pending.length > 0) drainPending(this);
|
|
191
196
|
if (after) this.push(Buffer.from(after, 'utf-8'));
|
|
192
197
|
} else {
|
|
193
198
|
this.push(chunk);
|
|
@@ -198,7 +203,10 @@ export function createNodeFlightInjector(
|
|
|
198
203
|
htmlStreamFinished = true;
|
|
199
204
|
|
|
200
205
|
const finish = () => {
|
|
201
|
-
|
|
206
|
+
if (pullError) {
|
|
207
|
+
this.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
202
210
|
if (foundSuffix) {
|
|
203
211
|
this.push(suffixBuf);
|
|
204
212
|
}
|
|
@@ -209,10 +217,16 @@ export function createNodeFlightInjector(
|
|
|
209
217
|
finish();
|
|
210
218
|
return;
|
|
211
219
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
// Wait for the RSC stream to finish before closing.
|
|
221
|
+
// pullLoop is already running and pushing directly.
|
|
222
|
+
const waitForPull = () => {
|
|
223
|
+
if (donePulling || pullError) {
|
|
224
|
+
finish();
|
|
225
|
+
} else {
|
|
226
|
+
setImmediate(waitForPull);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
waitForPull();
|
|
216
230
|
},
|
|
217
231
|
});
|
|
218
232
|
}
|
|
@@ -311,5 +325,9 @@ export function createNodeGzipCompressor(
|
|
|
311
325
|
responseHeaders.set('vary', 'Accept-Encoding');
|
|
312
326
|
}
|
|
313
327
|
|
|
314
|
-
|
|
328
|
+
// Z_SYNC_FLUSH ensures each chunk is flushed to the output immediately.
|
|
329
|
+
// Without it, gzip buffers internally and the browser doesn't receive
|
|
330
|
+
// the HTML shell until the gzip stream closes — breaking streaming.
|
|
331
|
+
// ~2–5% size overhead vs Z_NO_FLUSH but preserves correct streaming.
|
|
332
|
+
return createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
315
333
|
}
|
|
@@ -68,6 +68,7 @@ import { renderRscStream } from './rsc-stream.js';
|
|
|
68
68
|
import { renderSsrResponse } from './ssr-renderer.js';
|
|
69
69
|
import { callSsr } from './ssr-bridge.js';
|
|
70
70
|
import { isDebug, isDevMode, setDebugFromConfig } from '#/server/debug.js';
|
|
71
|
+
import { recordTiming } from '#/server/server-timing.js';
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
74
|
* Resolve the Server-Timing mode from timber.config.ts.
|
|
@@ -325,6 +326,7 @@ async function renderRoute(
|
|
|
325
326
|
// Build the React element tree — loads modules, runs access checks,
|
|
326
327
|
// resolves metadata. DenySignal/RedirectSignal propagate for HTTP handling.
|
|
327
328
|
let routeResult;
|
|
329
|
+
const _buildStart = performance.now();
|
|
328
330
|
try {
|
|
329
331
|
routeResult = await buildRouteElement(_req, match, interception, clientStateTree);
|
|
330
332
|
} catch (error) {
|
|
@@ -364,6 +366,13 @@ async function renderRoute(
|
|
|
364
366
|
throw error;
|
|
365
367
|
}
|
|
366
368
|
|
|
369
|
+
const _buildEnd = performance.now();
|
|
370
|
+
recordTiming({
|
|
371
|
+
name: 'build',
|
|
372
|
+
dur: Math.round(_buildEnd - _buildStart),
|
|
373
|
+
desc: 'build element tree',
|
|
374
|
+
});
|
|
375
|
+
|
|
367
376
|
const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
|
|
368
377
|
routeResult;
|
|
369
378
|
|
|
@@ -416,7 +425,13 @@ async function renderRoute(
|
|
|
416
425
|
}
|
|
417
426
|
|
|
418
427
|
// Render to RSC Flight stream with signal tracking.
|
|
428
|
+
const _rscStart = performance.now();
|
|
419
429
|
const { rscStream, signals } = renderRscStream(element, _req);
|
|
430
|
+
recordTiming({
|
|
431
|
+
name: 'rsc-init',
|
|
432
|
+
dur: Math.round(performance.now() - _rscStart),
|
|
433
|
+
desc: 'RSC stream init',
|
|
434
|
+
});
|
|
420
435
|
|
|
421
436
|
// Synchronous redirect — redirect() in access.ts or a non-async component
|
|
422
437
|
// throws during renderToReadableStream creation. Return HTTP redirect.
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { renderErrorPage } from './error-renderer.js';
|
|
32
32
|
import { callSsr } from './ssr-bridge.js';
|
|
33
33
|
import type { RenderSignals } from './rsc-stream.js';
|
|
34
|
+
import { recordTiming } from '#/server/server-timing.js';
|
|
34
35
|
|
|
35
36
|
interface SsrRenderOptions {
|
|
36
37
|
req: Request;
|
|
@@ -156,6 +157,21 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
156
157
|
try {
|
|
157
158
|
const ssrResponse = await callSsr(ssrStream, navContext);
|
|
158
159
|
|
|
160
|
+
// Record SSR sub-phase timings for Server-Timing header (detailed mode).
|
|
161
|
+
// These are populated by handleSsr() in the SSR environment and passed
|
|
162
|
+
// back via navContext._ssrTimings across the RSC→SSR boundary.
|
|
163
|
+
if (navContext._ssrTimings) {
|
|
164
|
+
const t = navContext._ssrTimings;
|
|
165
|
+
recordTiming({ name: 'ssr-decode', dur: t.decodeMs, desc: 'RSC Flight decode' });
|
|
166
|
+
recordTiming({ name: 'ssr-shell', dur: t.shellMs, desc: 'Fizz onShellReady' });
|
|
167
|
+
recordTiming({ name: 'ssr-pipeline', dur: t.pipelineMs, desc: 'stream transforms' });
|
|
168
|
+
recordTiming({
|
|
169
|
+
name: 'ssr-total',
|
|
170
|
+
dur: t.totalMs,
|
|
171
|
+
desc: t.nodeStreams ? 'SSR (Node streams)' : 'SSR (Web Streams)',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
// Signal promotion: check if any signals were captured during rendering
|
|
160
176
|
// inside Suspense boundaries. If no signals are present yet, yield one
|
|
161
177
|
// microtask so async component rejections propagate to the RSC onError
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -126,6 +126,29 @@ export interface NavContext {
|
|
|
126
126
|
* to a page-level deny. See LOCAL-298.
|
|
127
127
|
*/
|
|
128
128
|
_denyHandledByBoundary?: boolean;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Mutable: SSR timing data populated by handleSsr().
|
|
132
|
+
* Read by the RSC entry to record sub-phase Server-Timing entries
|
|
133
|
+
* when `serverTiming: 'detailed'` is configured.
|
|
134
|
+
*
|
|
135
|
+
* This bridges the RSC→SSR environment boundary: the SSR entry populates
|
|
136
|
+
* these fields, the RSC entry reads them after callSsr() returns.
|
|
137
|
+
*/
|
|
138
|
+
_ssrTimings?: {
|
|
139
|
+
/** Time to decode RSC stream (createFromReadableStream/createFromNodeStream) */
|
|
140
|
+
decodeMs: number;
|
|
141
|
+
/** Time for Fizz to render the shell (onShellReady) */
|
|
142
|
+
shellMs: number;
|
|
143
|
+
/** Time for pipe() to flush shell bytes to the output stream */
|
|
144
|
+
pipeMs: number;
|
|
145
|
+
/** Time to set up Node.js Transform pipeline / Web Stream transforms */
|
|
146
|
+
pipelineMs: number;
|
|
147
|
+
/** Total SSR time (decode → response ready) */
|
|
148
|
+
totalMs: number;
|
|
149
|
+
/** Whether the Node.js native stream path was used */
|
|
150
|
+
nodeStreams: boolean;
|
|
151
|
+
};
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
/**
|
|
@@ -182,11 +205,8 @@ export async function handleSsr(
|
|
|
182
205
|
// createFromReadableStream resolves client component references
|
|
183
206
|
// (from "use client" modules) using the SSR environment's module
|
|
184
207
|
// map, importing the actual components for server-side rendering.
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
console.log(
|
|
188
|
-
`[diag] nodeImports=${!!_nodeStreamImports} nodeStreamDecode=${hasNodeStreamDecode} rscStream=${rscStream?.constructor?.name}`
|
|
189
|
-
);
|
|
208
|
+
const _ssrStart = performance.now();
|
|
209
|
+
|
|
190
210
|
// Decode the RSC stream into a React element tree.
|
|
191
211
|
// On Node.js: convert Web ReadableStream → Node Readable → createFromNodeStream
|
|
192
212
|
// (eliminates Promise-per-chunk overhead from Web Streams reader)
|
|
@@ -200,21 +220,13 @@ export async function handleSsr(
|
|
|
200
220
|
} else {
|
|
201
221
|
element = createFromReadableStream(rscStream) as React.ReactNode;
|
|
202
222
|
}
|
|
203
|
-
const
|
|
223
|
+
const _decodeEnd = performance.now();
|
|
204
224
|
|
|
205
225
|
// Wrap with a server-safe nuqs adapter so that 'use client' components
|
|
206
226
|
// that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
|
|
207
|
-
// The client-side TimberNuqsAdapter (injected by browser-entry.ts) takes
|
|
208
|
-
// over after hydration. This provider supplies the request's search params
|
|
209
|
-
// as a static snapshot so nuqs renders the right initial values on the server.
|
|
210
227
|
const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
|
|
211
|
-
const _s2 = performance.now();
|
|
212
228
|
|
|
213
229
|
// Render to HTML stream (waits for onShellReady).
|
|
214
|
-
// Pass bootstrapScriptContent so React injects a non-deferred <script>
|
|
215
|
-
// in the shell HTML. This executes immediately during parsing — even
|
|
216
|
-
// while Suspense boundaries are still streaming — triggering module
|
|
217
|
-
// loading via dynamic import() so hydration can start early.
|
|
218
230
|
//
|
|
219
231
|
// Two paths based on platform:
|
|
220
232
|
// - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
|
|
@@ -231,7 +243,6 @@ export async function handleSsr(
|
|
|
231
243
|
PassThrough,
|
|
232
244
|
} = _nodeStreamImports;
|
|
233
245
|
|
|
234
|
-
const _s3 = performance.now();
|
|
235
246
|
let nodeHtmlStream: import('node:stream').Readable;
|
|
236
247
|
try {
|
|
237
248
|
nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
|
|
@@ -249,29 +260,27 @@ export async function handleSsr(
|
|
|
249
260
|
renderError
|
|
250
261
|
);
|
|
251
262
|
}
|
|
263
|
+
const _renderEnd = performance.now();
|
|
252
264
|
|
|
253
|
-
// Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector → gzip
|
|
254
265
|
const errorHandler = createNodeErrorHandler(navContext.signal);
|
|
255
266
|
const headInjector = createNodeHeadInjector(navContext.headHtml);
|
|
256
267
|
const flightInjector = createNodeFlightInjector(navContext.rscStream);
|
|
257
268
|
|
|
258
|
-
// Pipe through the chain. pipeline() handles backpressure and error propagation.
|
|
259
|
-
// The last stream in the chain is the output — convert to Web ReadableStream
|
|
260
|
-
// only at the Response boundary.
|
|
261
|
-
// Note: gzip compression is still handled by compressResponse() in the Nitro
|
|
262
|
-
// entry via Web Streams CompressionStream. Moving it into this Node.js pipeline
|
|
263
|
-
// requires the request headers (Accept-Encoding) which NavContext doesn't carry.
|
|
264
|
-
// TODO: pass request headers through NavContext to enable inline Node.js gzip.
|
|
265
269
|
const output = new PassThrough();
|
|
266
270
|
pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
|
|
267
271
|
// Pipeline errors are handled by errorHandler transform
|
|
268
272
|
});
|
|
273
|
+
const _pipelineEnd = performance.now();
|
|
269
274
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
+
// Record SSR sub-timings for Server-Timing header (detailed mode).
|
|
276
|
+
navContext._ssrTimings = {
|
|
277
|
+
decodeMs: Math.round(_decodeEnd - _ssrStart),
|
|
278
|
+
shellMs: Math.round(_renderEnd - _decodeEnd),
|
|
279
|
+
pipeMs: 0, // pipe() timing is inside renderSsrNodeStream
|
|
280
|
+
pipelineMs: Math.round(_pipelineEnd - _renderEnd),
|
|
281
|
+
totalMs: Math.round(_pipelineEnd - _ssrStart),
|
|
282
|
+
nodeStreams: true,
|
|
283
|
+
};
|
|
275
284
|
|
|
276
285
|
const webStream = nodeReadableToWeb(output);
|
|
277
286
|
return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
|
|
@@ -296,10 +305,22 @@ export async function handleSsr(
|
|
|
296
305
|
);
|
|
297
306
|
}
|
|
298
307
|
|
|
308
|
+
const _renderEnd = performance.now();
|
|
309
|
+
|
|
299
310
|
// Inject metadata into <head>, then interleave RSC payload chunks
|
|
300
311
|
// into the body as they arrive from the tee'd RSC stream.
|
|
301
312
|
let outputStream = injectHead(htmlStream, navContext.headHtml);
|
|
302
313
|
outputStream = injectRscPayload(outputStream, navContext.rscStream);
|
|
314
|
+
const _pipelineEnd = performance.now();
|
|
315
|
+
|
|
316
|
+
navContext._ssrTimings = {
|
|
317
|
+
decodeMs: Math.round(_decodeEnd - _ssrStart),
|
|
318
|
+
shellMs: Math.round(_renderEnd - _decodeEnd),
|
|
319
|
+
pipeMs: 0,
|
|
320
|
+
pipelineMs: Math.round(_pipelineEnd - _renderEnd),
|
|
321
|
+
totalMs: Math.round(_pipelineEnd - _ssrStart),
|
|
322
|
+
nodeStreams: false,
|
|
323
|
+
};
|
|
303
324
|
|
|
304
325
|
// Build and return the Response.
|
|
305
326
|
return buildSsrResponse(outputStream, navContext.statusCode, navContext.responseHeaders);
|