@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.
Files changed (113) hide show
  1. package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
  2. package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
  4. package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
  7. package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
  8. package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
  9. package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
  10. package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
  11. package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
  12. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  14. package/dist/adapters/nitro.d.ts +11 -0
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +77 -64
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.d.ts +3 -0
  19. package/dist/cache/index.d.ts.map +1 -1
  20. package/dist/cache/index.js +1 -1
  21. package/dist/cache/redis-handler.d.ts +1 -0
  22. package/dist/cache/redis-handler.d.ts.map +1 -1
  23. package/dist/cache/tag-aware-handler.d.ts +1 -0
  24. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/cli.js +2 -2
  27. package/dist/client/internal.js +1 -2
  28. package/dist/client/internal.js.map +1 -1
  29. package/dist/client/segment-cache.d.ts.map +1 -1
  30. package/dist/client/slot-context.d.ts +29 -0
  31. package/dist/client/slot-context.d.ts.map +1 -0
  32. package/dist/client/slot-outlet.d.ts +16 -0
  33. package/dist/client/slot-outlet.d.ts.map +1 -0
  34. package/dist/client/slot-provider.d.ts +20 -0
  35. package/dist/client/slot-provider.d.ts.map +1 -0
  36. package/dist/config-types.d.ts +2 -1
  37. package/dist/config-types.d.ts.map +1 -1
  38. package/dist/dev-tools/logs.d.ts.map +1 -1
  39. package/dist/index.js +293 -124
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugin-context.d.ts +27 -0
  42. package/dist/plugin-context.d.ts.map +1 -1
  43. package/dist/plugins/cache.d.ts.map +1 -1
  44. package/dist/plugins/client-chunks.d.ts.map +1 -1
  45. package/dist/plugins/dev-server.d.ts.map +1 -1
  46. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  47. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  48. package/dist/plugins/prebuilt.d.ts.map +1 -1
  49. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  50. package/dist/routing/index.js +2 -2
  51. package/dist/server/html-injector-core.d.ts +30 -9
  52. package/dist/server/html-injector-core.d.ts.map +1 -1
  53. package/dist/server/html-injectors.d.ts.map +1 -1
  54. package/dist/server/index.js +1 -1
  55. package/dist/server/internal.js +346 -51
  56. package/dist/server/internal.js.map +1 -1
  57. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  58. package/dist/server/pipeline-phases.d.ts.map +1 -1
  59. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  60. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  61. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  62. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  63. package/dist/server/prebuilt/slots.d.ts +74 -0
  64. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  65. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  66. package/dist/server/prebuilt-runtime.d.ts +12 -2
  67. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  68. package/dist/server/route-element-builder.d.ts.map +1 -1
  69. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  70. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  71. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  72. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  73. package/dist/server/state-tree-diff.d.ts +26 -3
  74. package/dist/server/state-tree-diff.d.ts.map +1 -1
  75. package/docs/api/34-api-config.mdx +165 -3
  76. package/docs/learn/00-introduction.mdx +78 -44
  77. package/docs/learn/13-configuration.mdx +27 -8
  78. package/package.json +3 -2
  79. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  80. package/src/adapters/nitro.ts +82 -64
  81. package/src/cache/index.ts +25 -2
  82. package/src/cache/redis-handler.ts +27 -7
  83. package/src/cache/tag-aware-handler.ts +5 -1
  84. package/src/cache/timber-cache.ts +21 -10
  85. package/src/client/segment-cache.ts +4 -5
  86. package/src/client/slot-context.ts +48 -0
  87. package/src/client/slot-outlet.tsx +22 -0
  88. package/src/client/slot-provider.tsx +25 -0
  89. package/src/config-types.ts +2 -1
  90. package/src/dev-tools/logs.ts +7 -0
  91. package/src/plugin-context.ts +54 -0
  92. package/src/plugins/cache.ts +1 -2
  93. package/src/plugins/client-chunks.ts +42 -1
  94. package/src/plugins/dev-server.ts +12 -69
  95. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  96. package/src/plugins/prebuilt.ts +82 -127
  97. package/src/plugins/prerender-sugar.ts +1 -2
  98. package/src/server/html-injector-core.ts +85 -27
  99. package/src/server/html-injectors.ts +5 -1
  100. package/src/server/node-stream-transforms.ts +6 -1
  101. package/src/server/pipeline-phases.ts +4 -1
  102. package/src/server/prebuilt/cache-key.ts +74 -0
  103. package/src/server/prebuilt/key-discipline.ts +53 -0
  104. package/src/server/prebuilt/slots.ts +167 -0
  105. package/src/server/prebuilt-builder.ts +57 -23
  106. package/src/server/prebuilt-runtime.ts +144 -60
  107. package/src/server/route-element-builder.ts +82 -73
  108. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  109. package/src/server/rsc-entry/helpers.ts +12 -10
  110. package/src/server/rsc-entry/index.ts +16 -70
  111. package/src/server/state-tree-diff.ts +49 -4
  112. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  113. 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
- // Wrap from innermost (leaf) to outermost (root), processing every
519
- // segment in the chain. Each segment may contribute:
520
- // 1. Error boundaries (status files + error.tsx)
521
- // 2. Layout component — wraps children + parallel slots
522
- // 3. SegmentProvider — records position for useSelectedLayoutSegment
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
- // When clientStateTree is provided (from X-Timber-State-Tree header on
525
- // client navigation), sync layouts the client already has are skipped.
526
- // Access.ts was pre-checked eagerly above for metadata gating (TIM-1027).
527
- // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
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
- // hasRenderedLayoutBelow tracks whether a non-skipped layout has been
530
- // seen below the current segment. A segment can ONLY be skipped if
531
- // there is a rendered layout below it — the client merger can only
532
- // replace inner SegmentProviders (client component boundaries), not
533
- // page content embedded in a layout's server-rendered output.
534
- // Without this guard, skipping the innermost layout causes the merger
535
- // to drop the layout entirely and replace it with just the page.
536
- let hasRenderedLayoutBelow = false;
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
- // Check if this segment's layout can be skipped for partial rendering.
550
- // Skipped segments: no layout wrapping, no error boundaries, no slots,
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(segment.urlPath);
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
- // For route groups, urlPath is shared with the parent (both "/"),
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 (const segment of segments) {
191
+ for (let i = 0; i < segments.length; i++) {
192
+ const segment = segments[i];
190
193
  const component = layoutBySegment.get(segment);
191
- const isAsync = component?.constructor?.name === 'AsyncFunction';
192
-
193
- // Compute segmentId — matches the key used by SegmentOutlet.
194
- // Route groups share their parent's urlPath, so include the group
195
- // name to disambiguate (e.g., "/(marketing)" vs "/").
196
- const segmentId =
197
- segment.segmentType === 'group'
198
- ? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
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 { loadModule } from '../safe-load.js';
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
- renderDenyFallback: async (deny, req, responseHeaders, matchedRoute) => {
377
- // Render the deny page (403.tsx, 404.tsx, etc.) for DenySignals
378
- // that escape from middleware or the render phase.
379
- //
380
- // When the pipeline already matched a route (the common case — matching
381
- // runs before middleware and rendering), resolve the status file against
382
- // the matched chain so colocated files like a nested `403.tsx` or an API
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
- * - Route groups are never skipped (siblings share urlPath)
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 urlPath - The segment's URL path (e.g., "/", "/dashboard")
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
- urlPath: string,
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(urlPath)) return false;
119
+ if (!clientSegments.has(segmentKey)) return false;
75
120
  if (!layoutComponent) return false;
76
121
  if (isLeaf) return false;
77
122