@timber-js/app 0.2.0-alpha.171 → 0.2.0-alpha.173
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-O_LsyCE4.js → actions-pN8r5Vnh.js} +3 -3
- package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-pN8r5Vnh.js.map} +1 -1
- package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-2hT5kfsr.js} +2 -2
- package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-2hT5kfsr.js.map} +1 -1
- package/dist/_chunks/{canonicalize-Du3o_ptW.js → canonicalize-DQHyFClh.js} +2 -1
- package/dist/_chunks/canonicalize-DQHyFClh.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-B5FDplGI.js → cli-schema-sync-DvdvFwwE.js} +3 -3
- package/dist/_chunks/{cli-schema-sync-B5FDplGI.js.map → cli-schema-sync-DvdvFwwE.js.map} +1 -1
- package/dist/_chunks/{logger-AWfuX-KJ.js → logger-kUT0QH0K.js} +23 -1
- package/dist/_chunks/logger-kUT0QH0K.js.map +1 -0
- package/dist/_chunks/{walkers-DCoE-LJf.js → walkers-Bv63zfAC.js} +2 -2
- package/dist/_chunks/{walkers-DCoE-LJf.js.map → walkers-Bv63zfAC.js.map} +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.d.ts +3 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -5
- package/dist/cli.js.map +1 -1
- package/dist/client/internal.js +40 -6
- package/dist/client/internal.js.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -1
- package/dist/client/segment-cache.d.ts +20 -3
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +10 -2
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/slot-context.d.ts +10 -8
- package/dist/client/slot-context.d.ts.map +1 -1
- package/dist/client/slot-provider.d.ts +5 -0
- package/dist/client/slot-provider.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/routing/index.js +2 -2
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +26 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +1614 -1667
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +13 -0
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/metadata.d.ts +8 -0
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/prebuilt/slots.d.ts +33 -8
- package/dist/server/prebuilt/slots.d.ts.map +1 -1
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +15 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +9 -11
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +18 -10
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +2 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +46 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +36 -3
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +7 -0
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +15 -5
- package/src/client/rsc-fetch.ts +1 -1
- package/src/client/segment-cache.ts +83 -10
- package/src/client/segment-outlet.tsx +24 -2
- package/src/client/slot-context.ts +10 -8
- package/src/client/slot-provider.tsx +9 -2
- package/src/server/access-gate.tsx +28 -1
- package/src/server/als-registry.ts +44 -0
- package/src/server/cookie-context.ts +7 -1
- package/src/server/deny-renderer.ts +1 -1
- package/src/server/metadata-routes.ts +95 -0
- package/src/server/metadata.ts +21 -0
- package/src/server/prebuilt/slots.ts +39 -16
- package/src/server/prebuilt-builder.ts +6 -5
- package/src/server/prebuilt-runtime.ts +11 -11
- package/src/server/request-context.ts +53 -1
- package/src/server/route-element-builder.ts +72 -144
- package/src/server/rsc-entry/helpers.ts +68 -14
- package/src/server/rsc-entry/render-route.ts +11 -3
- package/src/server/rsc-entry/rsc-payload.ts +16 -3
- package/src/server/rsc-entry/ssr-renderer.ts +3 -1
- package/src/server/slot-resolver.ts +321 -12
- package/src/server/state-tree-diff.ts +104 -8
- package/src/server/tree-builder.ts +10 -0
- package/dist/_chunks/canonicalize-Du3o_ptW.js.map +0 -1
- package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
- package/dist/client/child-segment-context.d.ts +0 -22
- package/dist/client/child-segment-context.d.ts.map +0 -1
- package/dist/client/child-segment-outlet.d.ts +0 -18
- package/dist/client/child-segment-outlet.d.ts.map +0 -1
- package/dist/client/child-segment-provider.d.ts +0 -21
- package/dist/client/child-segment-provider.d.ts.map +0 -1
- package/src/client/child-segment-context.ts +0 -40
- package/src/client/child-segment-outlet.tsx +0 -25
- package/src/client/child-segment-provider.tsx +0 -27
|
@@ -14,6 +14,7 @@ import type { RouteMatch } from '../pipeline.js';
|
|
|
14
14
|
import type { RedirectSignal } from '../primitives.js';
|
|
15
15
|
import type { LayoutComponentEntry } from '../route-element-builder.js';
|
|
16
16
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
17
|
+
import type { SlotSkipEntry } from '../slot-resolver.js';
|
|
17
18
|
|
|
18
19
|
import {
|
|
19
20
|
buildRedirectResponse,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
createDebugChannelSink,
|
|
22
23
|
RSC_CONTENT_TYPE,
|
|
23
24
|
} from './helpers.js';
|
|
25
|
+
import { requestContextAls } from '../als-registry.js';
|
|
24
26
|
import type { RenderSignals } from './rsc-stream.js';
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -40,7 +42,8 @@ export async function buildRscPayloadResponse(
|
|
|
40
42
|
layoutComponents: LayoutComponentEntry[],
|
|
41
43
|
match: RouteMatch,
|
|
42
44
|
responseHeaders: Headers,
|
|
43
|
-
skippedSegments?: string[]
|
|
45
|
+
skippedSegments?: string[],
|
|
46
|
+
slotSkipInfo?: SlotSkipEntry[]
|
|
44
47
|
): Promise<Response> {
|
|
45
48
|
// Read the first chunk from the RSC stream before committing headers.
|
|
46
49
|
// Race the first read against signal detection — if an async component
|
|
@@ -194,9 +197,19 @@ export async function buildRscPayloadResponse(
|
|
|
194
197
|
// client rendering. No X-Timber-Head header needed. See TIM-1151.
|
|
195
198
|
|
|
196
199
|
// Send segment metadata so the client can populate its segment cache
|
|
197
|
-
// for state tree diffing on subsequent navigations.
|
|
200
|
+
// for state tree diffing on subsequent navigations. On the RSC payload
|
|
201
|
+
// path, taint results are available because the shell has rendered by
|
|
202
|
+
// the time we reach this point (first chunk already read from the stream).
|
|
198
203
|
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
199
|
-
const
|
|
204
|
+
const store = requestContextAls.getStore();
|
|
205
|
+
const segmentInfo = buildSegmentInfo(
|
|
206
|
+
segments,
|
|
207
|
+
layoutComponents,
|
|
208
|
+
slotSkipInfo,
|
|
209
|
+
store?.taintResults,
|
|
210
|
+
store?.orphanTaint,
|
|
211
|
+
skippedSegments
|
|
212
|
+
);
|
|
200
213
|
responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
|
|
201
214
|
|
|
202
215
|
// Send skipped segments so the client can merge the partial RSC payload
|
|
@@ -20,6 +20,7 @@ import type { RouteMatch } from '../pipeline.js';
|
|
|
20
20
|
import { SsrStreamError } from '../primitives.js';
|
|
21
21
|
import type { LayoutComponentEntry } from '../route-element-builder.js';
|
|
22
22
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
23
|
+
import type { SlotSkipEntry } from '../slot-resolver.js';
|
|
23
24
|
import type { NavContext } from '../ssr-bridge-types.js';
|
|
24
25
|
|
|
25
26
|
import { htmlEscapeJsonString } from '../flight-scripts.js';
|
|
@@ -100,6 +101,7 @@ interface SsrRenderOptions {
|
|
|
100
101
|
deferSuspenseFor: number;
|
|
101
102
|
/** Tier 2 global-error.tsx file, if present in app/. */
|
|
102
103
|
globalError?: GlobalErrorFile;
|
|
104
|
+
slotSkipInfo?: SlotSkipEntry[];
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/**
|
|
@@ -168,7 +170,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
168
170
|
// Skipped when client JS is disabled — no client JS to consume it.
|
|
169
171
|
const segmentScript = clientJsDisabled
|
|
170
172
|
? ''
|
|
171
|
-
: `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents)))}</script>`;
|
|
173
|
+
: `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents, opts.slotSkipInfo)))}</script>`;
|
|
172
174
|
|
|
173
175
|
// Embed route params in HTML so useSegmentParams() works on initial hydration.
|
|
174
176
|
// Without this, useSegmentParams() returns {} until the first client navigation.
|
|
@@ -29,6 +29,9 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
29
29
|
import { setSlotParams } from './request-context.js';
|
|
30
30
|
import { coerceSlotParams } from './param-coercion.js';
|
|
31
31
|
import { matchUrlParts } from './tree-match.js';
|
|
32
|
+
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
33
|
+
import { computeSlotKey, shouldSkipSlot, type ClientStateTree } from './state-tree-diff.js';
|
|
34
|
+
import { runInTaintScope } from './request-context.js';
|
|
32
35
|
|
|
33
36
|
type CreateElementFn = (...args: unknown[]) => React.ReactElement;
|
|
34
37
|
|
|
@@ -49,14 +52,26 @@ async function loadComponent(loader: {
|
|
|
49
52
|
/**
|
|
50
53
|
* Load and render the default.tsx fallback for a slot node.
|
|
51
54
|
* Returns null if the slot has no default.tsx or it has no default export.
|
|
55
|
+
*
|
|
56
|
+
* When taintKey is provided, wraps the component in a taint scope so
|
|
57
|
+
* accessor calls (getHeaders, cookies, etc.) inside the default.tsx
|
|
58
|
+
* are attributed to this slot instead of poisoning orphanTaint.
|
|
52
59
|
*/
|
|
53
60
|
async function renderDefaultFallback(
|
|
54
61
|
slotNode: ManifestSegmentNode,
|
|
55
|
-
h: CreateElementFn
|
|
62
|
+
h: CreateElementFn,
|
|
63
|
+
taintKey?: string
|
|
56
64
|
): Promise<React.ReactElement | null> {
|
|
57
65
|
if (!slotNode.default) return null;
|
|
58
66
|
const DefaultComp = await loadComponent(slotNode.default);
|
|
59
67
|
if (!DefaultComp) return null;
|
|
68
|
+
if (taintKey && !isClientReference(DefaultComp)) {
|
|
69
|
+
const TaintedDefault = async (props: Record<string, unknown>) =>
|
|
70
|
+
runInTaintScope(taintKey, () =>
|
|
71
|
+
(DefaultComp as (props: Record<string, unknown>) => unknown)(props)
|
|
72
|
+
);
|
|
73
|
+
return h(TaintedDefault, {});
|
|
74
|
+
}
|
|
60
75
|
return h(DefaultComp, {});
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -80,7 +95,9 @@ export async function resolveSlotElement(
|
|
|
80
95
|
match: RouteMatch,
|
|
81
96
|
h: CreateElementFn,
|
|
82
97
|
interception?: InterceptionContext,
|
|
83
|
-
parentTreePath?: string
|
|
98
|
+
parentTreePath?: string,
|
|
99
|
+
accessVerdicts?: SlotAccessVerdict[],
|
|
100
|
+
taintKey?: string
|
|
84
101
|
): Promise<React.ReactElement | null> {
|
|
85
102
|
// When interception is active, try to match intercepting children in this
|
|
86
103
|
// slot against the target pathname. If an intercepting child matches, render
|
|
@@ -115,7 +132,7 @@ export async function resolveSlotElement(
|
|
|
115
132
|
// degrade to default.tsx or null — not crash the page. This matches
|
|
116
133
|
// Next.js behavior. See design/02-rendering-pipeline.md
|
|
117
134
|
// §"Slot Access Failure = Graceful Degradation"
|
|
118
|
-
const denyFallback = await renderDefaultFallback(slotNode, h);
|
|
135
|
+
const denyFallback = await renderDefaultFallback(slotNode, h, taintKey);
|
|
119
136
|
|
|
120
137
|
// Build the slot page element.
|
|
121
138
|
// Client references ('use client' pages) must NOT be called as functions —
|
|
@@ -131,9 +148,11 @@ export async function resolveSlotElement(
|
|
|
131
148
|
if (isClientReference(SlotPage)) {
|
|
132
149
|
element = h(SlotPage, {});
|
|
133
150
|
} else {
|
|
151
|
+
const slotTaintKey = taintKey;
|
|
134
152
|
const SafeSlotPage = async (props: Record<string, unknown>) => {
|
|
153
|
+
const run = async () => (SlotPage as (props: Record<string, unknown>) => unknown)(props);
|
|
135
154
|
try {
|
|
136
|
-
return
|
|
155
|
+
return slotTaintKey ? await runInTaintScope(slotTaintKey, run) : await run();
|
|
137
156
|
} catch (error) {
|
|
138
157
|
// RedirectSignal must propagate — the pipeline handles redirects
|
|
139
158
|
// at the top level. Swallowing it here would silently return
|
|
@@ -161,7 +180,13 @@ export async function resolveSlotElement(
|
|
|
161
180
|
// intermediate slot segments (everything between slot root and leaf).
|
|
162
181
|
// Process innermost-first, same order as route-element-builder.ts
|
|
163
182
|
// handles main segments. The slot root (index 0) is handled below.
|
|
164
|
-
element = await wrapWithIntermediateSegments(
|
|
183
|
+
element = await wrapWithIntermediateSegments(
|
|
184
|
+
slotMatch.chain,
|
|
185
|
+
element,
|
|
186
|
+
h,
|
|
187
|
+
accessVerdicts,
|
|
188
|
+
taintKey
|
|
189
|
+
);
|
|
165
190
|
|
|
166
191
|
// Wrap with slot root's layout — INSIDE the access gate, so the layout
|
|
167
192
|
// server component never executes when access.ts denies. See TIM-1074.
|
|
@@ -174,7 +199,7 @@ export async function resolveSlotElement(
|
|
|
174
199
|
// On denial: denied.tsx → default.tsx → null (graceful degradation),
|
|
175
200
|
// rendered WITHOUT the denied slot's own layout.
|
|
176
201
|
// See design/04-authorization.md §"Slot-Level Auth".
|
|
177
|
-
element = await wrapWithAccessGate(slotNode, element, h);
|
|
202
|
+
element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts, taintKey);
|
|
178
203
|
|
|
179
204
|
// Wrap with slot root's error boundaries (outermost)
|
|
180
205
|
element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
|
|
@@ -196,8 +221,12 @@ export async function resolveSlotElement(
|
|
|
196
221
|
}
|
|
197
222
|
}
|
|
198
223
|
|
|
199
|
-
// No matching page — render default.tsx fallback
|
|
200
|
-
|
|
224
|
+
// No matching page — render default.tsx fallback.
|
|
225
|
+
// Per design/02-rendering-pipeline.md: "No access check for default.tsx."
|
|
226
|
+
// Access verdicts from the eager evaluation are not applied here.
|
|
227
|
+
// Pass taintKey so accessor calls inside default.tsx are scoped to this
|
|
228
|
+
// slot instead of poisoning orphanTaint for the entire request.
|
|
229
|
+
return renderDefaultFallback(slotNode, h, taintKey);
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
// ─── Element Wrapping Helpers ───────────────────────────────────────────────
|
|
@@ -221,13 +250,15 @@ export async function resolveSlotElement(
|
|
|
221
250
|
async function wrapWithIntermediateSegments(
|
|
222
251
|
chain: ManifestSegmentNode[],
|
|
223
252
|
element: React.ReactElement,
|
|
224
|
-
h: CreateElementFn
|
|
253
|
+
h: CreateElementFn,
|
|
254
|
+
accessVerdicts?: SlotAccessVerdict[],
|
|
255
|
+
taintKey?: string
|
|
225
256
|
): Promise<React.ReactElement> {
|
|
226
257
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
227
258
|
const seg = chain[i];
|
|
228
259
|
element = await wrapSegmentWithErrorBoundaries(seg, element, h);
|
|
229
260
|
element = await wrapWithLayout(seg, element, h);
|
|
230
|
-
element = await wrapWithAccessGate(seg, element, h);
|
|
261
|
+
element = await wrapWithAccessGate(seg, element, h, accessVerdicts, taintKey);
|
|
231
262
|
}
|
|
232
263
|
return element;
|
|
233
264
|
}
|
|
@@ -253,7 +284,9 @@ async function wrapWithLayout(
|
|
|
253
284
|
async function wrapWithAccessGate(
|
|
254
285
|
slotNode: ManifestSegmentNode,
|
|
255
286
|
element: React.ReactElement,
|
|
256
|
-
h: CreateElementFn
|
|
287
|
+
h: CreateElementFn,
|
|
288
|
+
accessVerdicts?: SlotAccessVerdict[],
|
|
289
|
+
taintKey?: string
|
|
257
290
|
): Promise<React.ReactElement> {
|
|
258
291
|
if (!slotNode.access) return element;
|
|
259
292
|
|
|
@@ -270,7 +303,17 @@ async function wrapWithAccessGate(
|
|
|
270
303
|
// Extract slot name from the directory name (strip @ prefix)
|
|
271
304
|
const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
|
|
272
305
|
|
|
273
|
-
const defaultFallback = await renderDefaultFallback(slotNode, h);
|
|
306
|
+
const defaultFallback = await renderDefaultFallback(slotNode, h, taintKey);
|
|
307
|
+
|
|
308
|
+
// Look up pre-computed verdict from eager evaluation. Only replay
|
|
309
|
+
// denial/redirect verdicts — 'pass' verdicts must NOT be replayed
|
|
310
|
+
// because the access function may warm React.cache (e.g., requireUser())
|
|
311
|
+
// for layout/page dedup, and replaying 'pass' skips the cache-warming
|
|
312
|
+
// call. 'error' verdicts are also excluded — the gate must re-run
|
|
313
|
+
// accessFn so the error reaches the slot's error boundary.
|
|
314
|
+
const preVerdict = accessVerdicts?.find(
|
|
315
|
+
(v) => v.node === slotNode && v.verdict !== 'pass' && v.verdict !== 'error'
|
|
316
|
+
);
|
|
274
317
|
|
|
275
318
|
return h(SlotAccessGate, {
|
|
276
319
|
accessFn,
|
|
@@ -279,6 +322,7 @@ async function wrapWithAccessGate(
|
|
|
279
322
|
createElement: h,
|
|
280
323
|
defaultFallback,
|
|
281
324
|
children: element,
|
|
325
|
+
verdict: preVerdict?.verdict,
|
|
282
326
|
});
|
|
283
327
|
}
|
|
284
328
|
|
|
@@ -439,3 +483,268 @@ function findInterceptingMatch(
|
|
|
439
483
|
|
|
440
484
|
return null;
|
|
441
485
|
}
|
|
486
|
+
|
|
487
|
+
// ─── Slot Access Evaluation ─────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/** Result of eagerly evaluating one segment's access.ts. */
|
|
490
|
+
export interface SlotAccessVerdict {
|
|
491
|
+
node: ManifestSegmentNode;
|
|
492
|
+
/**
|
|
493
|
+
* 'pass' — access allowed.
|
|
494
|
+
* DenySignal/RedirectSignal — access denied, replayed in SlotAccessGate.
|
|
495
|
+
* 'error' — unknown error. Forces full render. No verdict passed to
|
|
496
|
+
* SlotAccessGate, so it calls accessFn during render and the error
|
|
497
|
+
* reaches the slot's error boundary.
|
|
498
|
+
*/
|
|
499
|
+
verdict: 'pass' | 'error' | DenySignal | RedirectSignal;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Eagerly evaluate the access chain for a slot (root + intermediate segments).
|
|
504
|
+
*
|
|
505
|
+
* Runs each access.ts top-down (outermost first). If any denies, the chain
|
|
506
|
+
* stops (shallowest failure wins, matching segment access semantics).
|
|
507
|
+
*
|
|
508
|
+
* Verdicts are stored for replay in SlotAccessGate / wrapWithIntermediateSegments
|
|
509
|
+
* so access.ts is called exactly once per request.
|
|
510
|
+
*/
|
|
511
|
+
async function evaluateSlotAccessChain(
|
|
512
|
+
slotRoot: ManifestSegmentNode,
|
|
513
|
+
chain: ManifestSegmentNode[]
|
|
514
|
+
): Promise<SlotAccessVerdict[]> {
|
|
515
|
+
// Collect all nodes with access.ts: slot root + intermediate chain segments
|
|
516
|
+
const nodesWithAccess: ManifestSegmentNode[] = [];
|
|
517
|
+
if (slotRoot.access) nodesWithAccess.push(slotRoot);
|
|
518
|
+
for (let i = 1; i < chain.length; i++) {
|
|
519
|
+
if (chain[i].access) nodesWithAccess.push(chain[i]);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (nodesWithAccess.length === 0) return [];
|
|
523
|
+
|
|
524
|
+
const results: SlotAccessVerdict[] = [];
|
|
525
|
+
for (const node of nodesWithAccess) {
|
|
526
|
+
const accessFn = await loadComponent(node.access!);
|
|
527
|
+
if (!accessFn) {
|
|
528
|
+
results.push({ node, verdict: 'pass' });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await accessFn();
|
|
533
|
+
results.push({ node, verdict: 'pass' });
|
|
534
|
+
} catch (e) {
|
|
535
|
+
if (e instanceof DenySignal) {
|
|
536
|
+
results.push({ node, verdict: e });
|
|
537
|
+
break; // shallowest failure wins
|
|
538
|
+
}
|
|
539
|
+
if (e instanceof RedirectSignal) {
|
|
540
|
+
results.push({ node, verdict: e });
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
// Unknown error — record as 'error' to force full render (blocks
|
|
544
|
+
// canSkip via accessBlocked). No verdict is passed to SlotAccessGate
|
|
545
|
+
// (wrapWithAccessGate filters 'error' out), so the gate calls
|
|
546
|
+
// accessFn during render and the error reaches the error boundary.
|
|
547
|
+
results.push({ node, verdict: 'error' });
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return results;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ─── Slot Caching ──────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
export interface SlotSkipEntry {
|
|
557
|
+
slotKey: string;
|
|
558
|
+
parentSegmentId: string;
|
|
559
|
+
/**
|
|
560
|
+
* Whether the slot's components called request accessors (getHeaders,
|
|
561
|
+
* getSearchParams, cookies) during their last render. Replaces the
|
|
562
|
+
* unreliable AsyncFunction heuristic — taint-tracked via ALS.
|
|
563
|
+
*/
|
|
564
|
+
isRequestDependent: boolean;
|
|
565
|
+
/** Whether the slot's access.ts denied on this render. */
|
|
566
|
+
denied: boolean;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
interface ResolveSlotPropsArgs {
|
|
570
|
+
segment: ManifestSegmentNode;
|
|
571
|
+
segmentId: string;
|
|
572
|
+
match: RouteMatch;
|
|
573
|
+
h: CreateElementFn;
|
|
574
|
+
interception?: InterceptionContext;
|
|
575
|
+
parentTreePath: string;
|
|
576
|
+
departingUrl: string | null;
|
|
577
|
+
destinationUrl: string;
|
|
578
|
+
clientStateTree: ClientStateTree | null;
|
|
579
|
+
slotSkipInfo: SlotSkipEntry[];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Resolve all parallel route slots for a layout, wrapping each in a
|
|
584
|
+
* SegmentOutlet for client-side caching. Slots whose matched content
|
|
585
|
+
* hasn't changed between the departing and destination URLs are rendered
|
|
586
|
+
* with `skip=true` so the client keeps its cached content.
|
|
587
|
+
*/
|
|
588
|
+
export async function resolveSlotProps({
|
|
589
|
+
segment,
|
|
590
|
+
segmentId,
|
|
591
|
+
match,
|
|
592
|
+
h,
|
|
593
|
+
interception,
|
|
594
|
+
parentTreePath,
|
|
595
|
+
departingUrl,
|
|
596
|
+
destinationUrl,
|
|
597
|
+
clientStateTree,
|
|
598
|
+
slotSkipInfo,
|
|
599
|
+
}: ResolveSlotPropsArgs): Promise<Record<string, unknown>> {
|
|
600
|
+
const slotProps: Record<string, unknown> = {};
|
|
601
|
+
const slotEntries = Object.entries(segment.slots ?? {});
|
|
602
|
+
if (slotEntries.length === 0) return slotProps;
|
|
603
|
+
|
|
604
|
+
// Parse URLs to extract pathnames for slot skip comparison.
|
|
605
|
+
// The departing URL (X-Timber-URL) is an untrusted request header —
|
|
606
|
+
// catch parse failures and fall back to no-cache (full render).
|
|
607
|
+
const destParsed = new URL(destinationUrl, 'http://localhost');
|
|
608
|
+
let depParsed: URL | null = null;
|
|
609
|
+
if (departingUrl) {
|
|
610
|
+
try {
|
|
611
|
+
depParsed = new URL(departingUrl, 'http://localhost');
|
|
612
|
+
} catch {
|
|
613
|
+
// Malformed departing URL — disable slot skipping for this request
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const destinationPathname = destParsed.pathname;
|
|
617
|
+
const departingPathname = depParsed?.pathname ?? null;
|
|
618
|
+
|
|
619
|
+
// Compute URL parts for slot skip comparison.
|
|
620
|
+
// Include the owning segment (segIdx + 1) to match findSlotMatch,
|
|
621
|
+
// which slices at parentIndex + 1. Filter out the root segment
|
|
622
|
+
// (segmentName === '') — it maps to '/' and doesn't consume a URL part.
|
|
623
|
+
const segIdx = match.segments.indexOf(segment);
|
|
624
|
+
const parentSegments =
|
|
625
|
+
segIdx >= 0 ? match.segments.slice(0, segIdx + 1).filter((s) => s.segmentName !== '') : [];
|
|
626
|
+
const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
|
|
627
|
+
const parentConsumedParts = extractUrlParts(parentSegments, rawParams);
|
|
628
|
+
const sliceAt = parentConsumedParts.length;
|
|
629
|
+
|
|
630
|
+
function splitPathname(pathname: string): string[] {
|
|
631
|
+
return pathname === '/' ? [] : pathname.slice(1).split('/');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const destinationAll = splitPathname(destinationPathname);
|
|
635
|
+
const departingAll = departingPathname ? splitPathname(departingPathname) : null;
|
|
636
|
+
const destinationParts = destinationAll.slice(sliceAt);
|
|
637
|
+
const departingParts = departingAll ? departingAll.slice(sliceAt) : null;
|
|
638
|
+
const clientSlots = clientStateTree?.slots ?? null;
|
|
639
|
+
|
|
640
|
+
// Check if parent segment's URL parts changed (e.g., /users/1 → /users/2).
|
|
641
|
+
let parentParamsChanged = false;
|
|
642
|
+
if (departingAll) {
|
|
643
|
+
const depParent = departingAll.slice(0, sliceAt);
|
|
644
|
+
const destParent = destinationAll.slice(0, sliceAt);
|
|
645
|
+
parentParamsChanged =
|
|
646
|
+
depParent.length !== destParent.length || depParent.some((p, i) => p !== destParent[i]);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
for (const [slotName, slotNode] of slotEntries) {
|
|
650
|
+
const slotManifest = slotNode as ManifestSegmentNode;
|
|
651
|
+
const slotKey = computeSlotKey(segmentId, `@${slotName}`);
|
|
652
|
+
|
|
653
|
+
// Match the slot's sub-tree against the destination URL parts.
|
|
654
|
+
// Used for both the skip decision and eager access evaluation.
|
|
655
|
+
const destMatch = matchUrlParts(slotManifest, destinationParts);
|
|
656
|
+
|
|
657
|
+
// Seed slot params BEFORE eager access evaluation so that access.ts
|
|
658
|
+
// files calling getSegmentParams() see the slot's own coerced params,
|
|
659
|
+
// not the main route's. This is the same seeding that resolveSlotElement
|
|
660
|
+
// does, but we need it here for the eager path.
|
|
661
|
+
if (destMatch && parentTreePath && Object.keys(destMatch.params).length > 0) {
|
|
662
|
+
const slotSuffix = destMatch.chain
|
|
663
|
+
.map((s) => s.segmentName)
|
|
664
|
+
.filter(Boolean)
|
|
665
|
+
.join('/');
|
|
666
|
+
const prefix = parentTreePath === '/' ? '' : parentTreePath;
|
|
667
|
+
const fullSlotPath = `${prefix}/${slotSuffix}`;
|
|
668
|
+
const coerced = coerceSlotParams(destMatch.chain, destMatch.params);
|
|
669
|
+
setSlotParams(fullSlotPath, coerced);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Check non-access skip conditions first. If any of these fail,
|
|
673
|
+
// the slot can't be skipped regardless of access — no need to run
|
|
674
|
+
// the eager access evaluation (which would double-run access.ts
|
|
675
|
+
// since the in-tree gate also calls it during render).
|
|
676
|
+
const hasInterceptingChildren = slotManifest.children.some(
|
|
677
|
+
(c) => c.segmentType === 'intercepting'
|
|
678
|
+
);
|
|
679
|
+
const isSkipCandidate =
|
|
680
|
+
destMatch !== null &&
|
|
681
|
+
!interception &&
|
|
682
|
+
!hasInterceptingChildren &&
|
|
683
|
+
!parentParamsChanged &&
|
|
684
|
+
departingParts !== null &&
|
|
685
|
+
shouldSkipSlot({
|
|
686
|
+
slotKey,
|
|
687
|
+
clientSlots,
|
|
688
|
+
slotNode: slotManifest,
|
|
689
|
+
departingUrlParts: departingParts,
|
|
690
|
+
destinationUrlParts: destinationParts,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Eagerly evaluate the slot's access chain only for skip candidates.
|
|
694
|
+
// This serves two purposes:
|
|
695
|
+
// 1. Satisfies security principle #3 (auth always runs) for skipped slots
|
|
696
|
+
// 2. Determines whether access denied (denied slots must not be skipped)
|
|
697
|
+
//
|
|
698
|
+
// Non-skip-candidate slots skip eager evaluation — their access.ts
|
|
699
|
+
// runs during render via SlotAccessGate (normal path). Per design doc:
|
|
700
|
+
// "No access check for default.tsx" (destMatch null = unmatched slot).
|
|
701
|
+
const chainVerdicts = isSkipCandidate
|
|
702
|
+
? await evaluateSlotAccessChain(slotManifest, destMatch!.chain)
|
|
703
|
+
: [];
|
|
704
|
+
const accessBlocked = chainVerdicts.some((v) => v.verdict !== 'pass');
|
|
705
|
+
const canSkip = isSkipCandidate && !accessBlocked;
|
|
706
|
+
|
|
707
|
+
if (canSkip) {
|
|
708
|
+
// Access already ran eagerly (all verdicts 'pass') — principle #3 satisfied.
|
|
709
|
+
// No gate wrapper needed.
|
|
710
|
+
slotProps[slotName] = h(SegmentOutlet, {
|
|
711
|
+
segmentPath: slotKey,
|
|
712
|
+
skip: true,
|
|
713
|
+
children: null,
|
|
714
|
+
});
|
|
715
|
+
// Skipped slots don't render, so they're not request-dependent.
|
|
716
|
+
slotSkipInfo.push({
|
|
717
|
+
slotKey,
|
|
718
|
+
parentSegmentId: segmentId,
|
|
719
|
+
isRequestDependent: false,
|
|
720
|
+
denied: false,
|
|
721
|
+
});
|
|
722
|
+
} else {
|
|
723
|
+
const resolvedElement = await resolveSlotElement(
|
|
724
|
+
slotManifest,
|
|
725
|
+
match,
|
|
726
|
+
h,
|
|
727
|
+
interception,
|
|
728
|
+
parentTreePath,
|
|
729
|
+
chainVerdicts,
|
|
730
|
+
slotKey
|
|
731
|
+
);
|
|
732
|
+
slotProps[slotName] = h(SegmentOutlet, {
|
|
733
|
+
segmentPath: slotKey,
|
|
734
|
+
children: resolvedElement,
|
|
735
|
+
});
|
|
736
|
+
// Conservative pre-render default: true. Overridden by taint
|
|
737
|
+
// results in buildSegmentInfo() after renderToReadableStream
|
|
738
|
+
// completes — runInTaintScope records whether the slot page
|
|
739
|
+
// actually called getHeaders/cookies/getSearchParams.
|
|
740
|
+
slotSkipInfo.push({
|
|
741
|
+
slotKey,
|
|
742
|
+
parentSegmentId: segmentId,
|
|
743
|
+
isRequestDependent: true,
|
|
744
|
+
denied: accessBlocked,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return slotProps;
|
|
750
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { swallow } from './logger.js';
|
|
18
|
+
import { matchUrlParts } from './tree-match.js';
|
|
19
|
+
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
18
20
|
|
|
19
21
|
// ─── Segment Key Computation ─────────────────────────────────────
|
|
20
22
|
|
|
@@ -59,27 +61,48 @@ export function computeSegmentKeys(segments: SegmentKeyInput[]): string[] {
|
|
|
59
61
|
return keys;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Compute a unique key for a parallel route slot.
|
|
66
|
+
* Format: `{parentSegmentId}/@{slotName}`, e.g. `/@sidebar` or `/dashboard/@modal`.
|
|
67
|
+
*/
|
|
68
|
+
export function computeSlotKey(parentSegmentId: string, slotName: string): string {
|
|
69
|
+
const name = slotName.startsWith('@') ? slotName : `@${slotName}`;
|
|
70
|
+
const prefix = parentSegmentId === '/' ? '' : parentSegmentId;
|
|
71
|
+
return `${prefix}/${name}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
// ─── State Tree Parsing ──────────────────────────────────────────
|
|
63
75
|
|
|
76
|
+
/** Parsed client state tree with segment paths and optional slot paths. */
|
|
77
|
+
export interface ClientStateTree extends Set<string> {
|
|
78
|
+
slots?: Set<string> | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Parse the X-Timber-State-Tree header from a request.
|
|
66
83
|
*
|
|
67
|
-
* Returns a Set of segment paths the client has cached
|
|
84
|
+
* Returns a Set of segment paths the client has cached (with an
|
|
85
|
+
* additional `slots` property for cached slot paths), or null if
|
|
68
86
|
* the header is missing, malformed, or empty. Parsing happens before
|
|
69
87
|
* renderToReadableStream — not inside the React render pass.
|
|
70
88
|
*
|
|
71
|
-
* @returns
|
|
89
|
+
* @returns ClientStateTree with segments and slots, or null if no valid state tree
|
|
72
90
|
*/
|
|
73
|
-
export function parseClientStateTree(req: Request):
|
|
91
|
+
export function parseClientStateTree(req: Request): ClientStateTree | null {
|
|
74
92
|
const header = req.headers.get('X-Timber-State-Tree');
|
|
75
93
|
if (!header) return null;
|
|
76
94
|
|
|
77
95
|
try {
|
|
78
|
-
const parsed = JSON.parse(header) as { segments?: unknown };
|
|
96
|
+
const parsed = JSON.parse(header) as { segments?: unknown; slots?: unknown };
|
|
79
97
|
if (!Array.isArray(parsed.segments) || parsed.segments.length === 0) {
|
|
80
98
|
return null;
|
|
81
99
|
}
|
|
82
|
-
|
|
100
|
+
const result = new Set(parsed.segments as string[]) as ClientStateTree;
|
|
101
|
+
result.slots =
|
|
102
|
+
Array.isArray(parsed.slots) && parsed.slots.length > 0
|
|
103
|
+
? new Set(parsed.slots as string[])
|
|
104
|
+
: null;
|
|
105
|
+
return result;
|
|
83
106
|
} catch (err) {
|
|
84
107
|
swallow(err, 'malformed X-Timber-State-Tree header');
|
|
85
108
|
return null;
|
|
@@ -120,10 +143,83 @@ export function shouldSkipSegment(
|
|
|
120
143
|
if (!layoutComponent) return false;
|
|
121
144
|
if (isLeaf) return false;
|
|
122
145
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
146
|
+
// Pre-render guard: async layouts may depend on per-request data.
|
|
147
|
+
// Taint tracking (runInTaintScope) handles the post-render client-side
|
|
148
|
+
// caching decision via X-Timber-Segments. This server-side check remains
|
|
149
|
+
// conservative because taint results aren't available at element-build time.
|
|
126
150
|
if (layoutComponent.constructor.name === 'AsyncFunction') return false;
|
|
127
151
|
|
|
128
152
|
return true;
|
|
129
153
|
}
|
|
154
|
+
|
|
155
|
+
// ─── Slot Skip Decision ─────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
interface ShouldSkipSlotArgs {
|
|
158
|
+
slotKey: string;
|
|
159
|
+
clientSlots: Set<string> | null;
|
|
160
|
+
slotNode: ManifestSegmentNode;
|
|
161
|
+
departingUrlParts: string[];
|
|
162
|
+
destinationUrlParts: string[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Determine whether a parallel route slot can be skipped.
|
|
167
|
+
*
|
|
168
|
+
* A slot is skipped when ALL of:
|
|
169
|
+
* 1. The client has this slot cached (slotKey is in clientSlots)
|
|
170
|
+
* 2. The slot's matched page is the SAME for both the departing and
|
|
171
|
+
* destination URLs (same page file + same extracted params)
|
|
172
|
+
*
|
|
173
|
+
* The comparison uses matchUrlParts to find what each URL would match
|
|
174
|
+
* in the slot's sub-tree, then compares the matched page file and params.
|
|
175
|
+
*
|
|
176
|
+
* This is a performance optimization only, NOT a security boundary.
|
|
177
|
+
* Slot access.ts always runs via SlotAccessGate regardless.
|
|
178
|
+
*/
|
|
179
|
+
export function shouldSkipSlot({
|
|
180
|
+
slotKey,
|
|
181
|
+
clientSlots,
|
|
182
|
+
slotNode,
|
|
183
|
+
departingUrlParts,
|
|
184
|
+
destinationUrlParts,
|
|
185
|
+
}: ShouldSkipSlotArgs): boolean {
|
|
186
|
+
if (!clientSlots) return false;
|
|
187
|
+
if (!clientSlots.has(slotKey)) return false;
|
|
188
|
+
|
|
189
|
+
const departingMatch = matchUrlParts(slotNode, departingUrlParts);
|
|
190
|
+
const destinationMatch = matchUrlParts(slotNode, destinationUrlParts);
|
|
191
|
+
|
|
192
|
+
// Both null (no match) — slot shows default.tsx in both cases
|
|
193
|
+
if (!departingMatch && !destinationMatch) return true;
|
|
194
|
+
// One null, one not — match changed
|
|
195
|
+
if (!departingMatch || !destinationMatch) return false;
|
|
196
|
+
|
|
197
|
+
const departingLeaf = departingMatch.chain[departingMatch.chain.length - 1];
|
|
198
|
+
const destinationLeaf = destinationMatch.chain[destinationMatch.chain.length - 1];
|
|
199
|
+
|
|
200
|
+
// Both must have a page, and the page file must be the same
|
|
201
|
+
if (!departingLeaf.page || !destinationLeaf.page) {
|
|
202
|
+
return !departingLeaf.page && !destinationLeaf.page;
|
|
203
|
+
}
|
|
204
|
+
if (departingLeaf.page.filePath !== destinationLeaf.page.filePath) return false;
|
|
205
|
+
|
|
206
|
+
// Compare extracted params — if any differ, the slot content may change
|
|
207
|
+
return paramsEqual(departingMatch.params, destinationMatch.params);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function paramsEqual(
|
|
211
|
+
a: Record<string, string | string[]>,
|
|
212
|
+
b: Record<string, string | string[]>
|
|
213
|
+
): boolean {
|
|
214
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
215
|
+
for (const key of keys) {
|
|
216
|
+
const va = a[key];
|
|
217
|
+
const vb = b[key];
|
|
218
|
+
if (Array.isArray(va) && Array.isArray(vb)) {
|
|
219
|
+
if (va.length !== vb.length || va.some((v, i) => v !== vb[i])) return false;
|
|
220
|
+
} else if (va !== vb) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
@@ -180,6 +180,16 @@ export interface SlotAccessGateProps {
|
|
|
180
180
|
createElement: CreateElement;
|
|
181
181
|
defaultFallback: ReactNode;
|
|
182
182
|
children: ReactNode;
|
|
183
|
+
/**
|
|
184
|
+
* Pre-computed verdict from eager access evaluation. When provided,
|
|
185
|
+
* SlotAccessGate replays it synchronously instead of re-calling accessFn.
|
|
186
|
+
* 'pass' → render children. DenySignal → graceful degradation.
|
|
187
|
+
* undefined → call accessFn during render (backward compat, error re-run).
|
|
188
|
+
*/
|
|
189
|
+
verdict?:
|
|
190
|
+
| 'pass'
|
|
191
|
+
| import('./primitives.js').DenySignal
|
|
192
|
+
| import('./primitives.js').RedirectSignal;
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
/**
|