@timber-js/app 0.2.0-alpha.161 → 0.2.0-alpha.164
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/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
- package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
- package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
- package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
- package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
- package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
- package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
- package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/nitro.d.ts +11 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +77 -64
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +3 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/redis-handler.d.ts +1 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts +1 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/internal.js +1 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/slot-context.d.ts +29 -0
- package/dist/client/slot-context.d.ts.map +1 -0
- package/dist/client/slot-outlet.d.ts +16 -0
- package/dist/client/slot-outlet.d.ts.map +1 -0
- package/dist/client/slot-provider.d.ts +20 -0
- package/dist/client/slot-provider.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.js +293 -124
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +27 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -1
- package/dist/plugins/prerender-sugar.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/html-injector-core.d.ts +30 -9
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +346 -51
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +4 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +23 -0
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
- package/dist/server/prebuilt/slots.d.ts +74 -0
- package/dist/server/prebuilt/slots.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts +12 -2
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +26 -3
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/docs/api/34-api-config.mdx +165 -3
- package/docs/learn/00-introduction.mdx +78 -44
- package/docs/learn/13-configuration.mdx +27 -8
- package/package.json +3 -2
- package/src/adapters/cloudflare-kv-cache.ts +5 -1
- package/src/adapters/nitro.ts +82 -64
- package/src/cache/index.ts +25 -2
- package/src/cache/redis-handler.ts +27 -7
- package/src/cache/tag-aware-handler.ts +5 -1
- package/src/cache/timber-cache.ts +21 -10
- package/src/client/segment-cache.ts +4 -5
- package/src/client/slot-context.ts +48 -0
- package/src/client/slot-outlet.tsx +22 -0
- package/src/client/slot-provider.tsx +25 -0
- package/src/config-types.ts +2 -1
- package/src/dev-tools/logs.ts +7 -0
- package/src/plugin-context.ts +54 -0
- package/src/plugins/cache.ts +1 -2
- package/src/plugins/client-chunks.ts +42 -1
- package/src/plugins/dev-server.ts +12 -69
- package/src/plugins/prebuilt-options-analysis.ts +175 -0
- package/src/plugins/prebuilt.ts +82 -127
- package/src/plugins/prerender-sugar.ts +1 -2
- package/src/server/html-injector-core.ts +85 -27
- package/src/server/html-injectors.ts +5 -1
- package/src/server/node-stream-transforms.ts +6 -1
- package/src/server/pipeline-phases.ts +4 -1
- package/src/server/prebuilt/cache-key.ts +74 -0
- package/src/server/prebuilt/key-discipline.ts +53 -0
- package/src/server/prebuilt/slots.ts +167 -0
- package/src/server/prebuilt-builder.ts +57 -23
- package/src/server/prebuilt-runtime.ts +144 -60
- package/src/server/route-element-builder.ts +82 -73
- package/src/server/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/helpers.ts +12 -10
- package/src/server/rsc-entry/index.ts +16 -70
- package/src/server/state-tree-diff.ts +49 -4
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
|
@@ -49,7 +49,7 @@ import { ChildSegmentProvider } from '../client/child-segment-provider.js';
|
|
|
49
49
|
|
|
50
50
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
51
51
|
import type { InterceptionContext } from './pipeline.js';
|
|
52
|
-
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
52
|
+
import { shouldSkipSegment, computeSegmentKeys } from './state-tree-diff.js';
|
|
53
53
|
import type { HeadElement } from './metadata.js';
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -515,61 +515,96 @@ export async function buildRouteElement(
|
|
|
515
515
|
// The client uses this to merge the partial payload with its cached segments.
|
|
516
516
|
const skippedSegments: string[] = [];
|
|
517
517
|
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
518
|
+
// Compute state-tree keys for all segments. Route groups accumulate
|
|
519
|
+
// ancestor group names for globally unique keys (e.g., "/(a)/(shared)"
|
|
520
|
+
// instead of just "/(shared)"). This is the single key computation —
|
|
521
|
+
// the same keys are used for skip decisions, SegmentOutlet props, and
|
|
522
|
+
// the X-Timber-Segments header (via buildSegmentInfo).
|
|
523
|
+
const segmentKeys = computeSegmentKeys(segments);
|
|
524
|
+
|
|
525
|
+
// Pre-compute the contiguous skippable prefix (top-down).
|
|
523
526
|
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
527
|
+
// Skipped segments must form a contiguous prefix from root to avoid
|
|
528
|
+
// non-contiguous skips that break the client merge. Walk from root
|
|
529
|
+
// toward leaf, marking segments as skippable. Stop at the first
|
|
530
|
+
// segment that can't be skipped (async, not in client tree, leaf).
|
|
531
|
+
// Layoutless segments are transparent (no outlet, no layout to skip).
|
|
528
532
|
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
// there
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
533
|
+
// After finding the prefix, validate: the first non-skipped layout
|
|
534
|
+
// must exist in the client's state tree (the client needs a mounted
|
|
535
|
+
// outlet there to receive the partial payload).
|
|
536
|
+
const skippableSet = new Set<number>();
|
|
537
|
+
if (clientStateTree) {
|
|
538
|
+
for (let i = 0; i < segments.length; i++) {
|
|
539
|
+
const isLeaf = i === segments.length - 1;
|
|
540
|
+
const layoutComponent = layoutBySegment.get(segments[i]);
|
|
541
|
+
|
|
542
|
+
// Never skip denied segments — their AccessGate must render the
|
|
543
|
+
// deny page, not reuse the client's cached (passing) layout.
|
|
544
|
+
if (i >= firstDeniedIndex) break;
|
|
545
|
+
|
|
546
|
+
if (shouldSkipSegment(segmentKeys[i], layoutComponent, isLeaf, clientStateTree)) {
|
|
547
|
+
skippableSet.add(i);
|
|
548
|
+
} else if (layoutComponent) {
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Ensure there's a merge point: a non-skipped layout whose key is
|
|
554
|
+
// in the client's state tree (so the client has a mounted outlet).
|
|
555
|
+
// If all layouts are in the prefix, keep the innermost as merge point.
|
|
556
|
+
if (skippableSet.size > 0) {
|
|
557
|
+
let mergePointIndex = -1;
|
|
558
|
+
for (let i = 0; i < segments.length; i++) {
|
|
559
|
+
if (skippableSet.has(i)) continue;
|
|
560
|
+
if (layoutBySegment.has(segments[i])) {
|
|
561
|
+
mergePointIndex = i;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (mergePointIndex === -1) {
|
|
567
|
+
// All layouts are skippable — keep the innermost as merge point
|
|
568
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
569
|
+
if (skippableSet.has(i) && layoutBySegment.has(segments[i])) {
|
|
570
|
+
skippableSet.delete(i);
|
|
571
|
+
mergePointIndex = i;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Cross-section guard: at least one rendered layout below the
|
|
578
|
+
// prefix must exist in the client's state tree (the client needs
|
|
579
|
+
// a mounted outlet to receive the partial payload). Check ALL
|
|
580
|
+
// non-skipped layouts, not just the merge point — async layouts
|
|
581
|
+
// are excluded from the client state tree but may sit above a
|
|
582
|
+
// sync layout that IS in the tree.
|
|
583
|
+
let hasClientOutlet = false;
|
|
584
|
+
for (let j = 0; j < segments.length; j++) {
|
|
585
|
+
if (skippableSet.has(j)) continue;
|
|
586
|
+
if (layoutBySegment.has(segments[j]) && clientStateTree.has(segmentKeys[j])) {
|
|
587
|
+
hasClientOutlet = true;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!hasClientOutlet) {
|
|
592
|
+
skippableSet.clear();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
537
597
|
let outermostSegmentProvider: React.ReactElement | null = null;
|
|
538
|
-
// Track whether any rendered inner layout also exists in the client's
|
|
539
|
-
// state tree. This prevents cross-section skipping: e.g., navigating
|
|
540
|
-
// from /(group-a) to /dashboard shouldn't skip root "/" because the
|
|
541
|
-
// client has no mounted outlet at "/dashboard".
|
|
542
|
-
let innerRenderedInClientTree = false;
|
|
543
598
|
|
|
544
599
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
545
600
|
const segment = segments[i];
|
|
546
601
|
const isLeaf = i === segments.length - 1;
|
|
547
602
|
const layoutComponent = layoutBySegment.get(segment);
|
|
548
603
|
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
// no AccessGate in element tree (access already ran pre-render).
|
|
552
|
-
//
|
|
553
|
-
// Additional constraints beyond shouldSkipSegment:
|
|
554
|
-
// - Must have a rendered layout below (so the merger can find an
|
|
555
|
-
// inner SegmentProvider to splice the new content into)
|
|
556
|
-
// - Route groups are never skipped because sibling groups share the
|
|
557
|
-
// same urlPath (e.g., /(marketing) and /(app) both have "/"),
|
|
558
|
-
// which would cause the wrong cached layout to be reused
|
|
559
|
-
// - At least one inner rendered layout must exist in the client's
|
|
560
|
-
// state tree, ensuring the client has a mounted outlet to receive
|
|
561
|
-
// the partial payload
|
|
562
|
-
const skip =
|
|
563
|
-
shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
|
|
564
|
-
hasRenderedLayoutBelow &&
|
|
565
|
-
segment.segmentType !== 'group' &&
|
|
566
|
-
innerRenderedInClientTree;
|
|
567
|
-
|
|
568
|
-
if (skip) {
|
|
569
|
-
// Skip this segment's layout/error boundaries — the client uses its cached version.
|
|
570
|
-
// Metadata was already resolved above (head elements are correct).
|
|
604
|
+
// Skip decision was pre-computed in the top-down prefix pass.
|
|
605
|
+
if (skippableSet.has(i)) {
|
|
571
606
|
// Record for X-Timber-Skipped-Segments header (outermost first, so prepend).
|
|
572
|
-
skippedSegments.unshift(
|
|
607
|
+
skippedSegments.unshift(segmentKeys[i]);
|
|
573
608
|
|
|
574
609
|
// SECURITY: Even though the layout is skipped, AccessGate MUST still
|
|
575
610
|
// wrap the element tree. access.ts runs on every navigation regardless
|
|
@@ -579,9 +614,6 @@ export async function buildRouteElement(
|
|
|
579
614
|
const accessMod = await loadModule(segment.access);
|
|
580
615
|
const accessFn = accessMod.default as (() => unknown) | undefined;
|
|
581
616
|
if (accessFn) {
|
|
582
|
-
// Pass verdict for denied/redirected segments so AccessGate replays
|
|
583
|
-
// without re-execution. Passing segments omit verdict so AccessGate
|
|
584
|
-
// re-runs access during render for React.cache population.
|
|
585
617
|
const verdict = accessVerdicts.get(i);
|
|
586
618
|
element = h(AccessGate, {
|
|
587
619
|
accessFn,
|
|
@@ -596,23 +628,6 @@ export async function buildRouteElement(
|
|
|
596
628
|
continue;
|
|
597
629
|
}
|
|
598
630
|
|
|
599
|
-
// This segment is rendered — mark that future (outer) segments have
|
|
600
|
-
// a rendered layout below them and can safely be skipped.
|
|
601
|
-
if (layoutComponent) {
|
|
602
|
-
hasRenderedLayoutBelow = true;
|
|
603
|
-
// Use segmentId for the client state tree check. Route groups share
|
|
604
|
-
// their parent's urlPath (both "/"), but their segmentId includes
|
|
605
|
-
// the group name (e.g., "/(group-a)"), so the check correctly
|
|
606
|
-
// fails when the client has never visited that group.
|
|
607
|
-
const outletKey =
|
|
608
|
-
segment.segmentType === 'group'
|
|
609
|
-
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
610
|
-
: segment.urlPath;
|
|
611
|
-
if (clientStateTree?.has(outletKey)) {
|
|
612
|
-
innerRenderedInClientTree = true;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
631
|
// Wrap with error boundaries from this segment (inside layout).
|
|
617
632
|
// Keep ALL error boundaries (including 4xx) — they're the safety net for
|
|
618
633
|
// DenySignal from nested server components that escape AccessGate/PageDenyBoundary
|
|
@@ -654,13 +669,7 @@ export async function buildRouteElement(
|
|
|
654
669
|
const segmentPath = rawPath.length === 2 && rawPath[1] === '' ? [''] : rawPath;
|
|
655
670
|
const parallelRouteKeys = Object.keys(segment.slots ?? {});
|
|
656
671
|
|
|
657
|
-
|
|
658
|
-
// so include the group name to distinguish them. Used for both OTEL
|
|
659
|
-
// span labels and client-side element caching (segmentId).
|
|
660
|
-
const segmentId =
|
|
661
|
-
segment.segmentType === 'group'
|
|
662
|
-
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
663
|
-
: segment.urlPath;
|
|
672
|
+
const segmentId = segmentKeys[i];
|
|
664
673
|
|
|
665
674
|
// Build the layout element.
|
|
666
675
|
// Layouts receive <ChildSegmentOutlet /> as children — a stable client
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deny fallback renderer — renders the deny page (403.tsx, 404.tsx, etc.)
|
|
3
|
+
* for DenySignals that escape from middleware or the render phase.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from index.ts (TIM-968) as a pure move. The factory captures
|
|
6
|
+
* the root segment and client bootstrap config once at handler creation;
|
|
7
|
+
* the returned function is wired into `PipelineConfig.renderDenyFallback`
|
|
8
|
+
* and reused by the action-dispatch wrapper.
|
|
9
|
+
*
|
|
10
|
+
* Design docs: 04-authorization.md, 10-error-handling.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LayoutEntry } from '../deny-renderer.js';
|
|
14
|
+
import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
|
|
15
|
+
import type { ClientBootstrapConfig } from '../html-injectors.js';
|
|
16
|
+
import { logRenderError } from '../logger.js';
|
|
17
|
+
import type { PipelineConfig, RouteMatch } from '../pipeline.js';
|
|
18
|
+
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
19
|
+
import { loadModule } from '../safe-load.js';
|
|
20
|
+
import { createDebugChannelSink, isRscPayloadRequest } from './helpers.js';
|
|
21
|
+
import { callSsr } from './ssr-bridge.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create the pipeline's deny fallback renderer.
|
|
25
|
+
*
|
|
26
|
+
* When the pipeline already matched a route (the common case — matching
|
|
27
|
+
* runs before middleware and rendering), resolve the status file against
|
|
28
|
+
* the matched chain so colocated files like a nested `403.tsx` or an API
|
|
29
|
+
* route's `401.json` are picked up. Fall back to the root chain when no
|
|
30
|
+
* match is available (e.g. proxy-stage deny before route matching).
|
|
31
|
+
* See TIM-822, design/04-authorization.md, design/10-error-handling.md.
|
|
32
|
+
*/
|
|
33
|
+
export function createDenyFallbackRenderer(options: {
|
|
34
|
+
rootSegment: ManifestSegmentNode;
|
|
35
|
+
clientBootstrap: ClientBootstrapConfig;
|
|
36
|
+
}): NonNullable<PipelineConfig['renderDenyFallback']> {
|
|
37
|
+
const { rootSegment, clientBootstrap } = options;
|
|
38
|
+
return async (deny, req, responseHeaders, matchedRoute) => {
|
|
39
|
+
const chain: ManifestSegmentNode[] = matchedRoute ? matchedRoute.segments : [rootSegment];
|
|
40
|
+
const layoutComponents: LayoutEntry[] = [];
|
|
41
|
+
try {
|
|
42
|
+
for (const segment of chain) {
|
|
43
|
+
if (!segment.layout) continue;
|
|
44
|
+
const mod = await loadModule(segment.layout);
|
|
45
|
+
if (mod.default) {
|
|
46
|
+
layoutComponents.push({
|
|
47
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
48
|
+
segment,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (layoutError) {
|
|
53
|
+
// Layout failed to load — proceed without it. Partial layout chains
|
|
54
|
+
// are acceptable; the deny renderer wraps in whatever loaded.
|
|
55
|
+
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: layoutError });
|
|
56
|
+
}
|
|
57
|
+
// Reuse the matched route's params/middleware metadata when present so
|
|
58
|
+
// downstream rendering sees the real route shape. Otherwise synthesise
|
|
59
|
+
// a root-only stub (no params, no middleware).
|
|
60
|
+
const match: RouteMatch = matchedRoute ?? {
|
|
61
|
+
segments: chain,
|
|
62
|
+
segmentParams: {},
|
|
63
|
+
middlewareChain: [],
|
|
64
|
+
};
|
|
65
|
+
// For client navigation (Accept: text/x-component), the timber router
|
|
66
|
+
// decodes the response as React Flight. Returning HTML to a Flight
|
|
67
|
+
// decoder breaks navigation, so dispatch to the RSC renderer when the
|
|
68
|
+
// request is an RSC payload request — mirroring the normal render path.
|
|
69
|
+
// See design/19-client-navigation.md §"RSC Payload Handling" and TIM-823.
|
|
70
|
+
if (isRscPayloadRequest(req)) {
|
|
71
|
+
return renderDenyPageAsRsc(
|
|
72
|
+
deny,
|
|
73
|
+
chain,
|
|
74
|
+
layoutComponents,
|
|
75
|
+
req,
|
|
76
|
+
responseHeaders,
|
|
77
|
+
createDebugChannelSink
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return renderDenyPage(
|
|
81
|
+
deny,
|
|
82
|
+
chain,
|
|
83
|
+
layoutComponents,
|
|
84
|
+
req,
|
|
85
|
+
match,
|
|
86
|
+
responseHeaders,
|
|
87
|
+
clientBootstrap,
|
|
88
|
+
createDebugChannelSink,
|
|
89
|
+
callSsr
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
8
8
|
import { swallow } from '../logger.js';
|
|
9
|
+
import { computeSegmentKeys } from '../state-tree-diff.js';
|
|
9
10
|
|
|
10
11
|
/** RSC content type for client navigation payload requests. */
|
|
11
12
|
export const RSC_CONTENT_TYPE = 'text/x-component';
|
|
@@ -184,19 +185,20 @@ export function buildSegmentInfo(
|
|
|
184
185
|
layoutComponents.map(({ component, segment }) => [segment, component])
|
|
185
186
|
);
|
|
186
187
|
|
|
188
|
+
const segmentKeys = computeSegmentKeys(segments);
|
|
187
189
|
const result: Array<{ path: string; segmentId?: string; isAsync: boolean }> = [];
|
|
188
190
|
|
|
189
|
-
for (
|
|
191
|
+
for (let i = 0; i < segments.length; i++) {
|
|
192
|
+
const segment = segments[i];
|
|
190
193
|
const component = layoutBySegment.get(segment);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
: segment.urlPath;
|
|
194
|
+
|
|
195
|
+
// Only emit entries for segments with layouts. Layoutless segments
|
|
196
|
+
// have no SegmentOutlet and must not appear in the client's merge
|
|
197
|
+
// target search (buildSegmentUpdates).
|
|
198
|
+
if (!component) continue;
|
|
199
|
+
|
|
200
|
+
const isAsync = component.constructor?.name === 'AsyncFunction';
|
|
201
|
+
const segmentId = segmentKeys[i];
|
|
200
202
|
|
|
201
203
|
const entry: { path: string; segmentId?: string; isAsync: boolean } = {
|
|
202
204
|
path: segment.urlPath,
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
* references. The stream is then passed to the SSR entry (in a separate
|
|
13
13
|
* Vite environment) which decodes it and renders HTML.
|
|
14
14
|
*
|
|
15
|
+
* This file is intentionally orchestration-only (audited: TIM-968). It
|
|
16
|
+
* wires virtual modules, config, and one-time startup (cache handler,
|
|
17
|
+
* instrumentation, CDN purge, prebuilt payload source) into the pipeline
|
|
18
|
+
* and delegates all rendering work to siblings in this directory
|
|
19
|
+
* (render-route.ts, error-renderer.ts, deny-fallback.ts,
|
|
20
|
+
* revalidate-renderer.ts, wrap-action-dispatch.ts, ...). Don't add
|
|
21
|
+
* rendering or response-shaping logic here — extract it to a sibling.
|
|
22
|
+
*
|
|
15
23
|
* Design docs: 18-build-system.md §"Entry Files", 02-rendering-pipeline.md
|
|
16
24
|
*/
|
|
17
25
|
|
|
@@ -38,8 +46,6 @@ import globalSchemaCodecs from 'virtual:timber-schema';
|
|
|
38
46
|
import { wrapPipelineWithActionDispatch } from './wrap-action-dispatch.js';
|
|
39
47
|
import type { BodyLimitsConfig } from '../body-limits.js';
|
|
40
48
|
import type { BuildManifest } from '../build-manifest.js';
|
|
41
|
-
import type { LayoutEntry } from '../deny-renderer.js';
|
|
42
|
-
import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
|
|
43
49
|
import { resolveLogMode } from '../../dev-tools/logger.js';
|
|
44
50
|
import { sendEarlyHints103 } from '../early-hints-sender.js';
|
|
45
51
|
import { collectEarlyHintHeaders } from '../early-hints.js';
|
|
@@ -47,19 +53,15 @@ import { buildClientScripts } from '../html-injectors.js';
|
|
|
47
53
|
import type { InterceptionContext, PipelineConfig, RouteMatch } from '../pipeline.js';
|
|
48
54
|
import { createPipeline } from '../pipeline.js';
|
|
49
55
|
import { coerceSegmentParams, setGlobalSchemaCodecs } from '../param-coercion.js';
|
|
50
|
-
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
51
56
|
import { createMetadataRouteMatcher, createRouteMatcher } from '../route-matcher.js';
|
|
52
57
|
import { initDevTracing } from '../tracing.js';
|
|
53
58
|
import { createRevalidateRendererFactory } from './revalidate-renderer.js';
|
|
54
59
|
|
|
55
60
|
import { renderFallbackError as renderFallback } from '../fallback-error.js';
|
|
56
61
|
import { loadInstrumentation } from '../instrumentation.js';
|
|
57
|
-
import {
|
|
58
|
-
import { logRenderError } from '../logger.js';
|
|
62
|
+
import { createDenyFallbackRenderer } from './deny-fallback.js';
|
|
59
63
|
import { renderNoMatchPage } from './error-renderer.js';
|
|
60
|
-
import { createDebugChannelSink, isRscPayloadRequest } from './helpers.js';
|
|
61
64
|
import { renderRoute } from './render-route.js';
|
|
62
|
-
import { callSsr } from './ssr-bridge.js';
|
|
63
65
|
import { isDebug, isDevMode, setDebugFromConfig } from '../debug.js';
|
|
64
66
|
import { setSourceMapCallback } from '../dev-source-map.js';
|
|
65
67
|
import { requestContextAls } from '../als-registry.js';
|
|
@@ -373,69 +375,13 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
373
375
|
// set by the dev server after import, read at request time.
|
|
374
376
|
isDev ? pipelineConfig.devHmrOptions : undefined
|
|
375
377
|
),
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
// route's `401.json` are picked up. Fall back to the root chain when no
|
|
384
|
-
// match is available (e.g. proxy-stage deny before route matching).
|
|
385
|
-
// See TIM-822, design/04-authorization.md, design/10-error-handling.md.
|
|
386
|
-
const chain: ManifestSegmentNode[] = matchedRoute ? matchedRoute.segments : [manifest.root];
|
|
387
|
-
const layoutComponents: LayoutEntry[] = [];
|
|
388
|
-
try {
|
|
389
|
-
for (const segment of chain) {
|
|
390
|
-
if (!segment.layout) continue;
|
|
391
|
-
const mod = await loadModule(segment.layout);
|
|
392
|
-
if (mod.default) {
|
|
393
|
-
layoutComponents.push({
|
|
394
|
-
component: mod.default as (...args: unknown[]) => unknown,
|
|
395
|
-
segment,
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
} catch (layoutError) {
|
|
400
|
-
// Layout failed to load — proceed without it. Partial layout chains
|
|
401
|
-
// are acceptable; the deny renderer wraps in whatever loaded.
|
|
402
|
-
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: layoutError });
|
|
403
|
-
}
|
|
404
|
-
// Reuse the matched route's params/middleware metadata when present so
|
|
405
|
-
// downstream rendering sees the real route shape. Otherwise synthesise
|
|
406
|
-
// a root-only stub (no params, no middleware).
|
|
407
|
-
const match: RouteMatch = matchedRoute ?? {
|
|
408
|
-
segments: chain,
|
|
409
|
-
segmentParams: {},
|
|
410
|
-
middlewareChain: [],
|
|
411
|
-
};
|
|
412
|
-
// For client navigation (Accept: text/x-component), the timber router
|
|
413
|
-
// decodes the response as React Flight. Returning HTML to a Flight
|
|
414
|
-
// decoder breaks navigation, so dispatch to the RSC renderer when the
|
|
415
|
-
// request is an RSC payload request — mirroring the normal render path.
|
|
416
|
-
// See design/19-client-navigation.md §"RSC Payload Handling" and TIM-823.
|
|
417
|
-
if (isRscPayloadRequest(req)) {
|
|
418
|
-
return renderDenyPageAsRsc(
|
|
419
|
-
deny,
|
|
420
|
-
chain,
|
|
421
|
-
layoutComponents,
|
|
422
|
-
req,
|
|
423
|
-
responseHeaders,
|
|
424
|
-
createDebugChannelSink
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
return renderDenyPage(
|
|
428
|
-
deny,
|
|
429
|
-
chain,
|
|
430
|
-
layoutComponents,
|
|
431
|
-
req,
|
|
432
|
-
match,
|
|
433
|
-
responseHeaders,
|
|
434
|
-
clientBootstrap,
|
|
435
|
-
createDebugChannelSink,
|
|
436
|
-
callSsr
|
|
437
|
-
);
|
|
438
|
-
},
|
|
378
|
+
// Deny fallback — renders the deny page (403.tsx, 404.tsx, etc.) for
|
|
379
|
+
// DenySignals that escape from middleware or the render phase.
|
|
380
|
+
// See deny-fallback.ts, design/04-authorization.md, design/10-error-handling.md.
|
|
381
|
+
renderDenyFallback: createDenyFallbackRenderer({
|
|
382
|
+
rootSegment: manifest.root,
|
|
383
|
+
clientBootstrap,
|
|
384
|
+
}),
|
|
439
385
|
// Auto-generated sitemap handler — enabled when sitemap.enabled is true
|
|
440
386
|
// and no user-authored sitemap exists at the app root.
|
|
441
387
|
// See design/16-metadata.md §"Auto-generated Sitemap"
|
|
@@ -16,6 +16,51 @@
|
|
|
16
16
|
|
|
17
17
|
import { swallow } from './logger.js';
|
|
18
18
|
|
|
19
|
+
// ─── Segment Key Computation ─────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Segment node shape expected by computeSegmentKeys.
|
|
23
|
+
* Matches the relevant fields from ManifestSegmentNode without
|
|
24
|
+
* importing it (avoids circular deps from routing → server).
|
|
25
|
+
*/
|
|
26
|
+
interface SegmentKeyInput {
|
|
27
|
+
urlPath: string;
|
|
28
|
+
segmentName?: string;
|
|
29
|
+
segmentType?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute state-tree keys for a segment chain.
|
|
34
|
+
*
|
|
35
|
+
* Non-group segments use their urlPath as-is. Route groups accumulate
|
|
36
|
+
* ancestor group names to produce globally unique keys:
|
|
37
|
+
* app/(a)/(shared)/dashboard → keys: ['/', '/(a)', '/(a)/(shared)', '/dashboard']
|
|
38
|
+
*
|
|
39
|
+
* This is the single source of truth for segment keys — used by the
|
|
40
|
+
* element builder (skip decisions, SegmentOutlet props), segment info
|
|
41
|
+
* (X-Timber-Segments header), and the client cache/state tree.
|
|
42
|
+
*/
|
|
43
|
+
export function computeSegmentKeys(segments: SegmentKeyInput[]): string[] {
|
|
44
|
+
const keys: string[] = [];
|
|
45
|
+
let prevKey = '';
|
|
46
|
+
|
|
47
|
+
for (const segment of segments) {
|
|
48
|
+
if (segment.segmentType === 'group') {
|
|
49
|
+
const base = prevKey === '/' ? '' : prevKey;
|
|
50
|
+
const key = `${base}/${segment.segmentName}`;
|
|
51
|
+
keys.push(key);
|
|
52
|
+
prevKey = key;
|
|
53
|
+
} else {
|
|
54
|
+
keys.push(segment.urlPath);
|
|
55
|
+
prevKey = segment.urlPath;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return keys;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── State Tree Parsing ──────────────────────────────────────────
|
|
63
|
+
|
|
19
64
|
/**
|
|
20
65
|
* Parse the X-Timber-State-Tree header from a request.
|
|
21
66
|
*
|
|
@@ -54,24 +99,24 @@ export function parseClientStateTree(req: Request): Set<string> | null {
|
|
|
54
99
|
*
|
|
55
100
|
* Additional constraints enforced by the caller (buildRouteElement):
|
|
56
101
|
* - Must have a rendered layout below (so SegmentOutlet has content to show)
|
|
57
|
-
* -
|
|
102
|
+
* - Skipped segments must form a contiguous prefix from root
|
|
58
103
|
*
|
|
59
104
|
* Access.ts still runs for skipped segments — this is enforced by the caller
|
|
60
105
|
* which wraps with AccessGate regardless of skip status.
|
|
61
106
|
*
|
|
62
|
-
* @param
|
|
107
|
+
* @param segmentKey - The segment's state-tree key from computeSegmentKeys()
|
|
63
108
|
* @param layoutComponent - The loaded layout component function
|
|
64
109
|
* @param isLeaf - Whether this is the leaf segment (page segment)
|
|
65
110
|
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
66
111
|
*/
|
|
67
112
|
export function shouldSkipSegment(
|
|
68
|
-
|
|
113
|
+
segmentKey: string,
|
|
69
114
|
layoutComponent: ((...args: unknown[]) => unknown) | undefined,
|
|
70
115
|
isLeaf: boolean,
|
|
71
116
|
clientSegments: Set<string> | null
|
|
72
117
|
): boolean {
|
|
73
118
|
if (!clientSegments) return false;
|
|
74
|
-
if (!clientSegments.has(
|
|
119
|
+
if (!clientSegments.has(segmentKey)) return false;
|
|
75
120
|
if (!layoutComponent) return false;
|
|
76
121
|
if (isLeaf) return false;
|
|
77
122
|
|