@timber-js/app 0.1.37 → 0.1.39
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 +10 -0
- package/dist/adapters/compress-module.d.ts.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +120 -39
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/index.js +126 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/server/compress.d.ts +37 -0
- package/dist/server/compress.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +43 -0
- package/dist/server/state-tree-diff.d.ts.map +1 -0
- package/package.json +2 -3
- package/src/adapters/compress-module.ts +108 -0
- package/src/adapters/nitro.ts +31 -63
- package/src/plugins/dev-server.ts +7 -1
- package/src/server/compress.ts +200 -0
- package/src/server/route-element-builder.ts +27 -2
- package/src/server/rsc-entry/index.ts +9 -1
- package/src/server/state-tree-diff.ts +77 -0
|
@@ -32,6 +32,7 @@ import { setParsedSearchParams } from './request-context.js';
|
|
|
32
32
|
import type { SearchParamsDefinition } from '#/search-params/create.js';
|
|
33
33
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
34
34
|
import type { InterceptionContext } from './pipeline.js';
|
|
35
|
+
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
35
36
|
|
|
36
37
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
37
38
|
|
|
@@ -91,7 +92,8 @@ export class RouteSignalWithContext extends Error {
|
|
|
91
92
|
export async function buildRouteElement(
|
|
92
93
|
req: Request,
|
|
93
94
|
match: RouteMatch,
|
|
94
|
-
interception?: InterceptionContext
|
|
95
|
+
interception?: InterceptionContext,
|
|
96
|
+
clientStateTree?: Set<string> | null
|
|
95
97
|
): Promise<RouteElementResult> {
|
|
96
98
|
const segments = match.segments as unknown as ManifestSegmentNode[];
|
|
97
99
|
|
|
@@ -308,8 +310,32 @@ export async function buildRouteElement(
|
|
|
308
310
|
// 1. Error boundaries (status files + error.tsx)
|
|
309
311
|
// 2. Layout component — wraps children + parallel slots
|
|
310
312
|
// 3. SegmentProvider — records position for useSelectedLayoutSegment
|
|
313
|
+
//
|
|
314
|
+
// When clientStateTree is provided (from X-Timber-State-Tree header on
|
|
315
|
+
// client navigation), sync layouts the client already has are skipped.
|
|
316
|
+
// Access.ts already ran for ALL segments in the pre-render loop above.
|
|
317
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
311
318
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
312
319
|
const segment = segments[i];
|
|
320
|
+
const isLeaf = i === segments.length - 1;
|
|
321
|
+
const layoutComponent = layoutBySegment.get(segment);
|
|
322
|
+
|
|
323
|
+
// Check if this segment's layout can be skipped for partial rendering.
|
|
324
|
+
// Skipped segments: no layout wrapping, no error boundaries, no slots,
|
|
325
|
+
// no AccessGate in element tree (access already ran pre-render).
|
|
326
|
+
const skip = shouldSkipSegment(
|
|
327
|
+
segment.urlPath,
|
|
328
|
+
layoutComponent,
|
|
329
|
+
isLeaf,
|
|
330
|
+
clientStateTree ?? null
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
if (skip) {
|
|
334
|
+
// Skip this segment entirely — the client uses its cached version.
|
|
335
|
+
// Access.ts already ran in the pre-render loop (security guarantee).
|
|
336
|
+
// Metadata was already resolved above (head elements are correct).
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
313
339
|
|
|
314
340
|
// Wrap with error boundaries from this segment (inside layout).
|
|
315
341
|
element = await wrapSegmentWithErrorBoundaries(segment, element, h);
|
|
@@ -335,7 +361,6 @@ export async function buildRouteElement(
|
|
|
335
361
|
}
|
|
336
362
|
|
|
337
363
|
// Wrap with layout if this segment has one — traced with OTEL span
|
|
338
|
-
const layoutComponent = layoutBySegment.get(segment);
|
|
339
364
|
if (layoutComponent) {
|
|
340
365
|
// Resolve parallel slots for this layout
|
|
341
366
|
const slotProps: Record<string, unknown> = {};
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
escapeHtml,
|
|
60
60
|
isRscPayloadRequest,
|
|
61
61
|
} from './helpers.js';
|
|
62
|
+
import { parseClientStateTree } from '#/server/state-tree-diff.js';
|
|
62
63
|
import { buildRscPayloadResponse } from './rsc-payload.js';
|
|
63
64
|
import { renderRscStream } from './rsc-stream.js';
|
|
64
65
|
import { renderSsrResponse } from './ssr-renderer.js';
|
|
@@ -268,11 +269,18 @@ async function renderRoute(
|
|
|
268
269
|
return handleApiRoute(_req, match, segments, responseHeaders);
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
// Parse X-Timber-State-Tree for RSC payload requests (client navigation).
|
|
273
|
+
// The state tree lists sync segments the client has cached — the server
|
|
274
|
+
// skips re-rendering those layouts for a smaller, faster RSC payload.
|
|
275
|
+
// Only used for RSC requests — HTML requests always get a full render.
|
|
276
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
277
|
+
const clientStateTree = isRscPayloadRequest(_req) ? parseClientStateTree(_req) : null;
|
|
278
|
+
|
|
271
279
|
// Build the React element tree — loads modules, runs access checks,
|
|
272
280
|
// resolves metadata. DenySignal/RedirectSignal propagate for HTTP handling.
|
|
273
281
|
let routeResult;
|
|
274
282
|
try {
|
|
275
|
-
routeResult = await buildRouteElement(_req, match, interception);
|
|
283
|
+
routeResult = await buildRouteElement(_req, match, interception, clientStateTree);
|
|
276
284
|
} catch (error) {
|
|
277
285
|
// RouteSignalWithContext wraps DenySignal/RedirectSignal with layout context
|
|
278
286
|
if (error instanceof RouteSignalWithContext) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Tree Diffing — Server-side parsing and diffing of X-Timber-State-Tree.
|
|
3
|
+
*
|
|
4
|
+
* The client sends X-Timber-State-Tree on navigation requests, listing
|
|
5
|
+
* the sync segments it has cached. The server diffs this against the
|
|
6
|
+
* target route's segments to skip re-rendering unchanged sync layouts.
|
|
7
|
+
*
|
|
8
|
+
* This is a performance optimization only — NOT a security boundary.
|
|
9
|
+
* All access.ts files run regardless of the state tree content.
|
|
10
|
+
* A fabricated state tree can only cause extra rendering work or stale
|
|
11
|
+
* layouts — never auth bypass.
|
|
12
|
+
*
|
|
13
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
14
|
+
* See design/13-security.md §"State tree manipulation"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse the X-Timber-State-Tree header from a request.
|
|
19
|
+
*
|
|
20
|
+
* Returns a Set of segment paths the client has cached, or null if
|
|
21
|
+
* the header is missing, malformed, or empty. Parsing happens before
|
|
22
|
+
* renderToReadableStream — not inside the React render pass.
|
|
23
|
+
*
|
|
24
|
+
* @returns Set of sync segment paths, or null if no valid state tree
|
|
25
|
+
*/
|
|
26
|
+
export function parseClientStateTree(req: Request): Set<string> | null {
|
|
27
|
+
const header = req.headers.get('X-Timber-State-Tree');
|
|
28
|
+
if (!header) return null;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(header) as { segments?: unknown };
|
|
32
|
+
if (!Array.isArray(parsed.segments) || parsed.segments.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return new Set(parsed.segments as string[]);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Determine whether a segment's layout rendering can be skipped.
|
|
43
|
+
*
|
|
44
|
+
* A segment is skipped when ALL of the following are true:
|
|
45
|
+
* 1. The client has the segment in its state tree (clientSegments contains urlPath)
|
|
46
|
+
* 2. The layout is sync (not an async function — async layouts always re-render)
|
|
47
|
+
* 3. The segment is NOT the leaf (pages are never cached across navigations)
|
|
48
|
+
*
|
|
49
|
+
* Access.ts still runs for skipped segments — this is enforced by the caller
|
|
50
|
+
* (buildRouteElement) which runs all access checks before building the tree.
|
|
51
|
+
*
|
|
52
|
+
* @param urlPath - The segment's URL path (e.g., "/", "/dashboard")
|
|
53
|
+
* @param layoutComponent - The loaded layout component function
|
|
54
|
+
* @param isLeaf - Whether this is the leaf segment (page segment)
|
|
55
|
+
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
56
|
+
*/
|
|
57
|
+
export function shouldSkipSegment(
|
|
58
|
+
urlPath: string,
|
|
59
|
+
layoutComponent: ((...args: unknown[]) => unknown) | undefined,
|
|
60
|
+
isLeaf: boolean,
|
|
61
|
+
clientSegments: Set<string> | null
|
|
62
|
+
): boolean {
|
|
63
|
+
// No state tree → full render (initial load, refresh, etc.)
|
|
64
|
+
if (!clientSegments) return false;
|
|
65
|
+
|
|
66
|
+
// Leaf segments (pages) are never skipped
|
|
67
|
+
if (isLeaf) return false;
|
|
68
|
+
|
|
69
|
+
// No layout → nothing to skip
|
|
70
|
+
if (!layoutComponent) return false;
|
|
71
|
+
|
|
72
|
+
// Async layouts always re-render (they may depend on request context)
|
|
73
|
+
if (layoutComponent.constructor?.name === 'AsyncFunction') return false;
|
|
74
|
+
|
|
75
|
+
// Skip if the client already has this segment cached
|
|
76
|
+
return clientSegments.has(urlPath);
|
|
77
|
+
}
|