@timber-js/app 0.2.0-alpha.171 → 0.2.0-alpha.172
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 +47 -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 +299 -8
- 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
|
|
|
@@ -80,7 +83,9 @@ export async function resolveSlotElement(
|
|
|
80
83
|
match: RouteMatch,
|
|
81
84
|
h: CreateElementFn,
|
|
82
85
|
interception?: InterceptionContext,
|
|
83
|
-
parentTreePath?: string
|
|
86
|
+
parentTreePath?: string,
|
|
87
|
+
accessVerdicts?: SlotAccessVerdict[],
|
|
88
|
+
taintKey?: string
|
|
84
89
|
): Promise<React.ReactElement | null> {
|
|
85
90
|
// When interception is active, try to match intercepting children in this
|
|
86
91
|
// slot against the target pathname. If an intercepting child matches, render
|
|
@@ -131,9 +136,11 @@ export async function resolveSlotElement(
|
|
|
131
136
|
if (isClientReference(SlotPage)) {
|
|
132
137
|
element = h(SlotPage, {});
|
|
133
138
|
} else {
|
|
139
|
+
const slotTaintKey = taintKey;
|
|
134
140
|
const SafeSlotPage = async (props: Record<string, unknown>) => {
|
|
141
|
+
const run = async () => (SlotPage as (props: Record<string, unknown>) => unknown)(props);
|
|
135
142
|
try {
|
|
136
|
-
return
|
|
143
|
+
return slotTaintKey ? await runInTaintScope(slotTaintKey, run) : await run();
|
|
137
144
|
} catch (error) {
|
|
138
145
|
// RedirectSignal must propagate — the pipeline handles redirects
|
|
139
146
|
// at the top level. Swallowing it here would silently return
|
|
@@ -161,7 +168,7 @@ export async function resolveSlotElement(
|
|
|
161
168
|
// intermediate slot segments (everything between slot root and leaf).
|
|
162
169
|
// Process innermost-first, same order as route-element-builder.ts
|
|
163
170
|
// handles main segments. The slot root (index 0) is handled below.
|
|
164
|
-
element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
|
|
171
|
+
element = await wrapWithIntermediateSegments(slotMatch.chain, element, h, accessVerdicts);
|
|
165
172
|
|
|
166
173
|
// Wrap with slot root's layout — INSIDE the access gate, so the layout
|
|
167
174
|
// server component never executes when access.ts denies. See TIM-1074.
|
|
@@ -174,7 +181,7 @@ export async function resolveSlotElement(
|
|
|
174
181
|
// On denial: denied.tsx → default.tsx → null (graceful degradation),
|
|
175
182
|
// rendered WITHOUT the denied slot's own layout.
|
|
176
183
|
// See design/04-authorization.md §"Slot-Level Auth".
|
|
177
|
-
element = await wrapWithAccessGate(slotNode, element, h);
|
|
184
|
+
element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts);
|
|
178
185
|
|
|
179
186
|
// Wrap with slot root's error boundaries (outermost)
|
|
180
187
|
element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
|
|
@@ -196,7 +203,9 @@ export async function resolveSlotElement(
|
|
|
196
203
|
}
|
|
197
204
|
}
|
|
198
205
|
|
|
199
|
-
// No matching page — render default.tsx fallback
|
|
206
|
+
// No matching page — render default.tsx fallback.
|
|
207
|
+
// Per design/02-rendering-pipeline.md: "No access check for default.tsx."
|
|
208
|
+
// Access verdicts from the eager evaluation are not applied here.
|
|
200
209
|
return renderDefaultFallback(slotNode, h);
|
|
201
210
|
}
|
|
202
211
|
|
|
@@ -221,13 +230,14 @@ export async function resolveSlotElement(
|
|
|
221
230
|
async function wrapWithIntermediateSegments(
|
|
222
231
|
chain: ManifestSegmentNode[],
|
|
223
232
|
element: React.ReactElement,
|
|
224
|
-
h: CreateElementFn
|
|
233
|
+
h: CreateElementFn,
|
|
234
|
+
accessVerdicts?: SlotAccessVerdict[]
|
|
225
235
|
): Promise<React.ReactElement> {
|
|
226
236
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
227
237
|
const seg = chain[i];
|
|
228
238
|
element = await wrapSegmentWithErrorBoundaries(seg, element, h);
|
|
229
239
|
element = await wrapWithLayout(seg, element, h);
|
|
230
|
-
element = await wrapWithAccessGate(seg, element, h);
|
|
240
|
+
element = await wrapWithAccessGate(seg, element, h, accessVerdicts);
|
|
231
241
|
}
|
|
232
242
|
return element;
|
|
233
243
|
}
|
|
@@ -253,7 +263,8 @@ async function wrapWithLayout(
|
|
|
253
263
|
async function wrapWithAccessGate(
|
|
254
264
|
slotNode: ManifestSegmentNode,
|
|
255
265
|
element: React.ReactElement,
|
|
256
|
-
h: CreateElementFn
|
|
266
|
+
h: CreateElementFn,
|
|
267
|
+
accessVerdicts?: SlotAccessVerdict[]
|
|
257
268
|
): Promise<React.ReactElement> {
|
|
258
269
|
if (!slotNode.access) return element;
|
|
259
270
|
|
|
@@ -272,6 +283,16 @@ async function wrapWithAccessGate(
|
|
|
272
283
|
|
|
273
284
|
const defaultFallback = await renderDefaultFallback(slotNode, h);
|
|
274
285
|
|
|
286
|
+
// Look up pre-computed verdict from eager evaluation. Only replay
|
|
287
|
+
// denial/redirect verdicts — 'pass' verdicts must NOT be replayed
|
|
288
|
+
// because the access function may warm React.cache (e.g., requireUser())
|
|
289
|
+
// for layout/page dedup, and replaying 'pass' skips the cache-warming
|
|
290
|
+
// call. 'error' verdicts are also excluded — the gate must re-run
|
|
291
|
+
// accessFn so the error reaches the slot's error boundary.
|
|
292
|
+
const preVerdict = accessVerdicts?.find(
|
|
293
|
+
(v) => v.node === slotNode && v.verdict !== 'pass' && v.verdict !== 'error'
|
|
294
|
+
);
|
|
295
|
+
|
|
275
296
|
return h(SlotAccessGate, {
|
|
276
297
|
accessFn,
|
|
277
298
|
DeniedComponent,
|
|
@@ -279,6 +300,7 @@ async function wrapWithAccessGate(
|
|
|
279
300
|
createElement: h,
|
|
280
301
|
defaultFallback,
|
|
281
302
|
children: element,
|
|
303
|
+
verdict: preVerdict?.verdict,
|
|
282
304
|
});
|
|
283
305
|
}
|
|
284
306
|
|
|
@@ -439,3 +461,272 @@ function findInterceptingMatch(
|
|
|
439
461
|
|
|
440
462
|
return null;
|
|
441
463
|
}
|
|
464
|
+
|
|
465
|
+
// ─── Slot Access Evaluation ─────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
/** Result of eagerly evaluating one segment's access.ts. */
|
|
468
|
+
export interface SlotAccessVerdict {
|
|
469
|
+
node: ManifestSegmentNode;
|
|
470
|
+
/**
|
|
471
|
+
* 'pass' — access allowed.
|
|
472
|
+
* DenySignal/RedirectSignal — access denied, replayed in SlotAccessGate.
|
|
473
|
+
* 'error' — unknown error. Forces full render. No verdict passed to
|
|
474
|
+
* SlotAccessGate, so it calls accessFn during render and the error
|
|
475
|
+
* reaches the slot's error boundary.
|
|
476
|
+
*/
|
|
477
|
+
verdict: 'pass' | 'error' | DenySignal | RedirectSignal;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Eagerly evaluate the access chain for a slot (root + intermediate segments).
|
|
482
|
+
*
|
|
483
|
+
* Runs each access.ts top-down (outermost first). If any denies, the chain
|
|
484
|
+
* stops (shallowest failure wins, matching segment access semantics).
|
|
485
|
+
*
|
|
486
|
+
* Verdicts are stored for replay in SlotAccessGate / wrapWithIntermediateSegments
|
|
487
|
+
* so access.ts is called exactly once per request.
|
|
488
|
+
*/
|
|
489
|
+
async function evaluateSlotAccessChain(
|
|
490
|
+
slotRoot: ManifestSegmentNode,
|
|
491
|
+
chain: ManifestSegmentNode[]
|
|
492
|
+
): Promise<SlotAccessVerdict[]> {
|
|
493
|
+
// Collect all nodes with access.ts: slot root + intermediate chain segments
|
|
494
|
+
const nodesWithAccess: ManifestSegmentNode[] = [];
|
|
495
|
+
if (slotRoot.access) nodesWithAccess.push(slotRoot);
|
|
496
|
+
for (let i = 1; i < chain.length; i++) {
|
|
497
|
+
if (chain[i].access) nodesWithAccess.push(chain[i]);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (nodesWithAccess.length === 0) return [];
|
|
501
|
+
|
|
502
|
+
const results: SlotAccessVerdict[] = [];
|
|
503
|
+
for (const node of nodesWithAccess) {
|
|
504
|
+
const accessFn = await loadComponent(node.access!);
|
|
505
|
+
if (!accessFn) {
|
|
506
|
+
results.push({ node, verdict: 'pass' });
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
await accessFn();
|
|
511
|
+
results.push({ node, verdict: 'pass' });
|
|
512
|
+
} catch (e) {
|
|
513
|
+
if (e instanceof DenySignal) {
|
|
514
|
+
results.push({ node, verdict: e });
|
|
515
|
+
break; // shallowest failure wins
|
|
516
|
+
}
|
|
517
|
+
if (e instanceof RedirectSignal) {
|
|
518
|
+
results.push({ node, verdict: e });
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
// Unknown error — record as 'error' to force full render (blocks
|
|
522
|
+
// canSkip via accessBlocked). No verdict is passed to SlotAccessGate
|
|
523
|
+
// (wrapWithAccessGate filters 'error' out), so the gate calls
|
|
524
|
+
// accessFn during render and the error reaches the error boundary.
|
|
525
|
+
results.push({ node, verdict: 'error' });
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return results;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ─── Slot Caching ──────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
export interface SlotSkipEntry {
|
|
535
|
+
slotKey: string;
|
|
536
|
+
parentSegmentId: string;
|
|
537
|
+
/**
|
|
538
|
+
* Whether the slot's components called request accessors (getHeaders,
|
|
539
|
+
* getSearchParams, cookies) during their last render. Replaces the
|
|
540
|
+
* unreliable AsyncFunction heuristic — taint-tracked via ALS.
|
|
541
|
+
*/
|
|
542
|
+
isRequestDependent: boolean;
|
|
543
|
+
/** Whether the slot's access.ts denied on this render. */
|
|
544
|
+
denied: boolean;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
interface ResolveSlotPropsArgs {
|
|
548
|
+
segment: ManifestSegmentNode;
|
|
549
|
+
segmentId: string;
|
|
550
|
+
match: RouteMatch;
|
|
551
|
+
h: CreateElementFn;
|
|
552
|
+
interception?: InterceptionContext;
|
|
553
|
+
parentTreePath: string;
|
|
554
|
+
departingUrl: string | null;
|
|
555
|
+
destinationUrl: string;
|
|
556
|
+
clientStateTree: ClientStateTree | null;
|
|
557
|
+
slotSkipInfo: SlotSkipEntry[];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Resolve all parallel route slots for a layout, wrapping each in a
|
|
562
|
+
* SegmentOutlet for client-side caching. Slots whose matched content
|
|
563
|
+
* hasn't changed between the departing and destination URLs are rendered
|
|
564
|
+
* with `skip=true` so the client keeps its cached content.
|
|
565
|
+
*/
|
|
566
|
+
export async function resolveSlotProps({
|
|
567
|
+
segment,
|
|
568
|
+
segmentId,
|
|
569
|
+
match,
|
|
570
|
+
h,
|
|
571
|
+
interception,
|
|
572
|
+
parentTreePath,
|
|
573
|
+
departingUrl,
|
|
574
|
+
destinationUrl,
|
|
575
|
+
clientStateTree,
|
|
576
|
+
slotSkipInfo,
|
|
577
|
+
}: ResolveSlotPropsArgs): Promise<Record<string, unknown>> {
|
|
578
|
+
const slotProps: Record<string, unknown> = {};
|
|
579
|
+
const slotEntries = Object.entries(segment.slots ?? {});
|
|
580
|
+
if (slotEntries.length === 0) return slotProps;
|
|
581
|
+
|
|
582
|
+
// Parse URLs to extract pathnames for slot skip comparison.
|
|
583
|
+
// The departing URL (X-Timber-URL) is an untrusted request header —
|
|
584
|
+
// catch parse failures and fall back to no-cache (full render).
|
|
585
|
+
const destParsed = new URL(destinationUrl, 'http://localhost');
|
|
586
|
+
let depParsed: URL | null = null;
|
|
587
|
+
if (departingUrl) {
|
|
588
|
+
try {
|
|
589
|
+
depParsed = new URL(departingUrl, 'http://localhost');
|
|
590
|
+
} catch {
|
|
591
|
+
// Malformed departing URL — disable slot skipping for this request
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const destinationPathname = destParsed.pathname;
|
|
595
|
+
const departingPathname = depParsed?.pathname ?? null;
|
|
596
|
+
|
|
597
|
+
// Compute URL parts for slot skip comparison.
|
|
598
|
+
// Include the owning segment (segIdx + 1) to match findSlotMatch,
|
|
599
|
+
// which slices at parentIndex + 1. Filter out the root segment
|
|
600
|
+
// (segmentName === '') — it maps to '/' and doesn't consume a URL part.
|
|
601
|
+
const segIdx = match.segments.indexOf(segment);
|
|
602
|
+
const parentSegments =
|
|
603
|
+
segIdx >= 0 ? match.segments.slice(0, segIdx + 1).filter((s) => s.segmentName !== '') : [];
|
|
604
|
+
const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
|
|
605
|
+
const parentConsumedParts = extractUrlParts(parentSegments, rawParams);
|
|
606
|
+
const sliceAt = parentConsumedParts.length;
|
|
607
|
+
|
|
608
|
+
function splitPathname(pathname: string): string[] {
|
|
609
|
+
return pathname === '/' ? [] : pathname.slice(1).split('/');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const destinationAll = splitPathname(destinationPathname);
|
|
613
|
+
const departingAll = departingPathname ? splitPathname(departingPathname) : null;
|
|
614
|
+
const destinationParts = destinationAll.slice(sliceAt);
|
|
615
|
+
const departingParts = departingAll ? departingAll.slice(sliceAt) : null;
|
|
616
|
+
const clientSlots = clientStateTree?.slots ?? null;
|
|
617
|
+
|
|
618
|
+
// Check if parent segment's URL parts changed (e.g., /users/1 → /users/2).
|
|
619
|
+
let parentParamsChanged = false;
|
|
620
|
+
if (departingAll) {
|
|
621
|
+
const depParent = departingAll.slice(0, sliceAt);
|
|
622
|
+
const destParent = destinationAll.slice(0, sliceAt);
|
|
623
|
+
parentParamsChanged =
|
|
624
|
+
depParent.length !== destParent.length || depParent.some((p, i) => p !== destParent[i]);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const [slotName, slotNode] of slotEntries) {
|
|
628
|
+
const slotManifest = slotNode as ManifestSegmentNode;
|
|
629
|
+
const slotKey = computeSlotKey(segmentId, `@${slotName}`);
|
|
630
|
+
|
|
631
|
+
// Match the slot's sub-tree against the destination URL parts.
|
|
632
|
+
// Used for both the skip decision and eager access evaluation.
|
|
633
|
+
const destMatch = matchUrlParts(slotManifest, destinationParts);
|
|
634
|
+
|
|
635
|
+
// Seed slot params BEFORE eager access evaluation so that access.ts
|
|
636
|
+
// files calling getSegmentParams() see the slot's own coerced params,
|
|
637
|
+
// not the main route's. This is the same seeding that resolveSlotElement
|
|
638
|
+
// does, but we need it here for the eager path.
|
|
639
|
+
if (destMatch && parentTreePath && Object.keys(destMatch.params).length > 0) {
|
|
640
|
+
const slotSuffix = destMatch.chain
|
|
641
|
+
.map((s) => s.segmentName)
|
|
642
|
+
.filter(Boolean)
|
|
643
|
+
.join('/');
|
|
644
|
+
const prefix = parentTreePath === '/' ? '' : parentTreePath;
|
|
645
|
+
const fullSlotPath = `${prefix}/${slotSuffix}`;
|
|
646
|
+
const coerced = coerceSlotParams(destMatch.chain, destMatch.params);
|
|
647
|
+
setSlotParams(fullSlotPath, coerced);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Check non-access skip conditions first. If any of these fail,
|
|
651
|
+
// the slot can't be skipped regardless of access — no need to run
|
|
652
|
+
// the eager access evaluation (which would double-run access.ts
|
|
653
|
+
// since the in-tree gate also calls it during render).
|
|
654
|
+
const hasInterceptingChildren = slotManifest.children.some(
|
|
655
|
+
(c) => c.segmentType === 'intercepting'
|
|
656
|
+
);
|
|
657
|
+
const isSkipCandidate =
|
|
658
|
+
destMatch !== null &&
|
|
659
|
+
!interception &&
|
|
660
|
+
!hasInterceptingChildren &&
|
|
661
|
+
!parentParamsChanged &&
|
|
662
|
+
departingParts !== null &&
|
|
663
|
+
shouldSkipSlot({
|
|
664
|
+
slotKey,
|
|
665
|
+
clientSlots,
|
|
666
|
+
slotNode: slotManifest,
|
|
667
|
+
departingUrlParts: departingParts,
|
|
668
|
+
destinationUrlParts: destinationParts,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Eagerly evaluate the slot's access chain only for skip candidates.
|
|
672
|
+
// This serves two purposes:
|
|
673
|
+
// 1. Satisfies security principle #3 (auth always runs) for skipped slots
|
|
674
|
+
// 2. Determines whether access denied (denied slots must not be skipped)
|
|
675
|
+
//
|
|
676
|
+
// Non-skip-candidate slots skip eager evaluation — their access.ts
|
|
677
|
+
// runs during render via SlotAccessGate (normal path). Per design doc:
|
|
678
|
+
// "No access check for default.tsx" (destMatch null = unmatched slot).
|
|
679
|
+
const chainVerdicts = isSkipCandidate
|
|
680
|
+
? await evaluateSlotAccessChain(slotManifest, destMatch!.chain)
|
|
681
|
+
: [];
|
|
682
|
+
const accessBlocked = chainVerdicts.some((v) => v.verdict !== 'pass');
|
|
683
|
+
const canSkip = isSkipCandidate && !accessBlocked;
|
|
684
|
+
|
|
685
|
+
if (canSkip) {
|
|
686
|
+
// Access already ran eagerly (all verdicts 'pass') — principle #3 satisfied.
|
|
687
|
+
// No gate wrapper needed.
|
|
688
|
+
slotProps[slotName] = h(SegmentOutlet, {
|
|
689
|
+
segmentPath: slotKey,
|
|
690
|
+
skip: true,
|
|
691
|
+
children: null,
|
|
692
|
+
});
|
|
693
|
+
// Skipped slots don't render, so they're not request-dependent.
|
|
694
|
+
slotSkipInfo.push({
|
|
695
|
+
slotKey,
|
|
696
|
+
parentSegmentId: segmentId,
|
|
697
|
+
isRequestDependent: false,
|
|
698
|
+
denied: false,
|
|
699
|
+
});
|
|
700
|
+
} else {
|
|
701
|
+
const resolvedElement = await resolveSlotElement(
|
|
702
|
+
slotManifest,
|
|
703
|
+
match,
|
|
704
|
+
h,
|
|
705
|
+
interception,
|
|
706
|
+
parentTreePath,
|
|
707
|
+
chainVerdicts,
|
|
708
|
+
slotKey
|
|
709
|
+
);
|
|
710
|
+
slotProps[slotName] = h(SegmentOutlet, {
|
|
711
|
+
segmentPath: slotKey,
|
|
712
|
+
children: resolvedElement,
|
|
713
|
+
});
|
|
714
|
+
// Conservative default: fully-rendered slots are marked as
|
|
715
|
+
// request-dependent. Element building doesn't execute server
|
|
716
|
+
// components (that happens during renderToReadableStream), so
|
|
717
|
+
// we can't observe whether the page/layout calls getHeaders()
|
|
718
|
+
// or cookies(). Marking as request-dependent means the client
|
|
719
|
+
// won't report the slot as cacheable, so it's always re-rendered.
|
|
720
|
+
// TODO(LOCAL-1127): Implement render-time taint tracking to
|
|
721
|
+
// allow skipping slots that don't actually read request context.
|
|
722
|
+
slotSkipInfo.push({
|
|
723
|
+
slotKey,
|
|
724
|
+
parentSegmentId: segmentId,
|
|
725
|
+
isRequestDependent: true,
|
|
726
|
+
denied: accessBlocked,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return slotProps;
|
|
732
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"canonicalize-Du3o_ptW.js","names":[],"sources":["../../src/server/metadata-routes.ts","../../src/server/canonicalize.ts"],"sourcesContent":["/**\n * Metadata route classification for timber.js.\n *\n * Metadata routes are file-based endpoints that generate well-known URLs for\n * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).\n *\n * These routes run through proxy.ts but NOT through middleware.ts or access.ts —\n * they are public endpoints by nature.\n *\n * See design/16-metadata.md §\"Metadata Routes\"\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Classification of a metadata route file. */\nexport interface MetadataRouteInfo {\n /** The metadata route type. */\n type: MetadataRouteType;\n /** The content type to serve this route with. */\n contentType: string;\n /** Whether this route can appear in nested segments (not just app root). */\n nestable: boolean;\n}\n\nexport type MetadataRouteType =\n | 'sitemap'\n | 'robots'\n | 'manifest'\n | 'favicon'\n | 'icon'\n | 'opengraph-image'\n | 'apple-icon';\n\n// ─── Convention Table ────────────────────────────────────────────────────────\n\n/**\n * All recognized metadata route file conventions.\n *\n * Each entry maps a base file name (without extension) to its route info.\n * The extensions determine whether the file is static or dynamic.\n *\n * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg\n * Dynamic extensions: .ts, .tsx\n */\nexport const METADATA_ROUTE_CONVENTIONS: Record<\n string,\n {\n type: MetadataRouteType;\n contentType: string;\n nestable: boolean;\n staticExtensions: string[];\n dynamicExtensions: string[];\n /**\n * The URL path basename this file serves at (relative to segment).\n * For image routes, the full serve path includes an extension via\n * `resolveServePathForFile()`.\n */\n servePath: string;\n /**\n * When set, image routes append `.{serveExtension}` to the serve path.\n * Dynamic handlers (`.ts`/`.tsx`) use this as the default. Static files\n * use their own extension instead. Non-image routes leave this undefined.\n */\n serveExtension?: string;\n }\n> = {\n 'sitemap': {\n type: 'sitemap',\n contentType: 'application/xml',\n nestable: true,\n staticExtensions: ['xml'],\n dynamicExtensions: ['ts'],\n servePath: 'sitemap.xml',\n },\n 'robots': {\n type: 'robots',\n contentType: 'text/plain',\n nestable: false,\n staticExtensions: ['txt'],\n dynamicExtensions: ['ts'],\n servePath: 'robots.txt',\n },\n 'manifest': {\n type: 'manifest',\n contentType: 'application/manifest+json',\n nestable: false,\n staticExtensions: ['json'],\n dynamicExtensions: ['ts'],\n servePath: 'manifest.webmanifest',\n },\n 'favicon': {\n type: 'favicon',\n contentType: 'image/x-icon',\n nestable: false,\n staticExtensions: ['ico'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'favicon.ico',\n },\n 'icon': {\n type: 'icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg', 'svg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'icon',\n serveExtension: 'png',\n },\n 'opengraph-image': {\n type: 'opengraph-image',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'opengraph-image',\n serveExtension: 'png',\n },\n\n 'apple-icon': {\n type: 'apple-icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'apple-icon',\n serveExtension: 'png',\n },\n};\n\n// ─── MIME Type Resolution ─────────────────────────────────────────────────────\n\n/**\n * Map of file extensions to MIME types for static metadata route files.\n * Used to resolve the generic `image/*` content type for static image files.\n */\nconst EXTENSION_MIME_TYPES: Record<string, string> = {\n xml: 'application/xml',\n txt: 'text/plain',\n json: 'application/json',\n ico: 'image/x-icon',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n};\n\n/**\n * Resolve the concrete MIME type for a static metadata route file.\n *\n * For generic content types like `image/*`, this resolves to the actual\n * MIME type based on the file extension (e.g. `image/png` for `.png`).\n *\n * @param conventionContentType - The content type from the convention table (may be generic like `image/*`)\n * @param extension - The file extension without leading dot (e.g. \"png\", \"xml\")\n * @returns The resolved MIME type\n */\nexport function resolveStaticContentType(conventionContentType: string, extension: string): string {\n if (conventionContentType.includes('*')) {\n return EXTENSION_MIME_TYPES[extension] ?? 'application/octet-stream';\n }\n return conventionContentType;\n}\n\n/**\n * Check if a file extension represents a static (non-code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"xml\", \"png\", \"ts\")\n * @returns true if this is a static file, false if dynamic or unrecognized\n */\nexport function isStaticMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.staticExtensions.includes(extension);\n}\n\n/**\n * Check if a file extension represents a dynamic (code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"ts\", \"tsx\")\n * @returns true if this is a dynamic file, false if static or unrecognized\n */\nexport function isDynamicMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.dynamicExtensions.includes(extension);\n}\n\n// ─── Classification ──────────────────────────────────────────────────────────\n\n/**\n * Classify a file name as a metadata route, or return null if it's not one.\n *\n * @param fileName - The full file name including extension (e.g. \"sitemap.xml\", \"icon.tsx\")\n * @returns Classification info, or null if not a metadata route\n */\nexport function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {\n const dotIndex = fileName.lastIndexOf('.');\n if (dotIndex === -1) return null;\n\n const baseName = fileName.slice(0, dotIndex);\n const ext = fileName.slice(dotIndex + 1);\n\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return null;\n\n const isStatic = convention.staticExtensions.includes(ext);\n const isDynamic = convention.dynamicExtensions.includes(ext);\n\n if (!isStatic && !isDynamic) return null;\n\n return {\n type: convention.type,\n contentType: convention.contentType,\n nestable: convention.nestable,\n };\n}\n\n/**\n * Resolve the serve path for a metadata route file.\n *\n * For image routes (icon, opengraph-image, apple-icon), the serve path includes\n * a file extension so CDNs cache correctly:\n * - Dynamic handlers (.ts/.tsx) use the convention's `serveExtension` (default: .png)\n * - Static files use their own extension (e.g., icon.svg → icon.svg)\n *\n * Non-image routes return the convention's `servePath` as-is (already includes\n * extension: sitemap.xml, robots.txt, etc.).\n */\nexport function resolveServePathForFile(baseName: string, filePath: string): string {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return baseName;\n\n if (!convention.serveExtension) return convention.servePath;\n\n const ext = filePath.slice(filePath.lastIndexOf('.') + 1);\n if (convention.staticExtensions.includes(ext)) {\n return `${convention.servePath}.${ext}`;\n }\n return `${convention.servePath}.${convention.serveExtension}`;\n}\n\n/**\n * Get the default serve path for a metadata route type (using default extension\n * for image routes). Used for auto-link generation when only the type is known.\n */\nexport function getMetadataRouteServePath(type: MetadataRouteType): string {\n for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.type === type) {\n if (convention.serveExtension) {\n return `${convention.servePath}.${convention.serveExtension}`;\n }\n return convention.servePath;\n }\n }\n throw new Error(`[timber] Unknown metadata route type: ${type}`);\n}\n\n/**\n * All possible serve path segments for metadata routes (includes extension\n * variants for image routes).\n */\nconst METADATA_SERVE_PATHS = new Set<string>();\nfor (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.serveExtension) {\n for (const ext of [...convention.staticExtensions, convention.serveExtension]) {\n METADATA_SERVE_PATHS.add(`${convention.servePath}.${ext}`);\n }\n } else {\n METADATA_SERVE_PATHS.add(convention.servePath);\n }\n}\n\nexport function isMetadataRouteServePath(pathname: string): boolean {\n let lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);\n const qIdx = lastSegment.indexOf('?');\n let query = '';\n if (qIdx !== -1) {\n query = lastSegment.slice(qIdx + 1);\n lastSegment = lastSegment.slice(0, qIdx);\n }\n if (!METADATA_SERVE_PATHS.has(lastSegment)) return false;\n // Vite module requests (e.g., /src/icon.svg?import) use special query params.\n // These are source assets, not metadata routes.\n if (/(?:^|&)(?:import|url|raw|worker|inline)(?:&|$)/.test(query)) return false;\n return true;\n}\n\n/** A <link> auto-link tag. */\nexport interface AutoLinkLink {\n tag: 'link';\n rel: string;\n href: string;\n type?: string;\n}\n\n/** A <meta> auto-link tag. */\nexport interface AutoLinkMeta {\n tag: 'meta';\n property?: string;\n name?: string;\n content: string;\n}\n\nexport type AutoLinkTag = AutoLinkLink | AutoLinkMeta;\n\n/**\n * Get the auto-link tags to inject into <head> for metadata route files\n * discovered in a segment.\n *\n * Returns link tags for icon/apple-icon/manifest, and meta tags for\n * opengraph-image (emits both og:image and twitter:image). Returns null\n * for types that don't auto-link (favicon, sitemap, robots).\n *\n * @param type - The metadata route type\n * @param href - The resolved URL path to the metadata route\n * @returns Tag descriptor(s) for the <head>, or null if no auto-link\n */\nexport function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {\n switch (type) {\n case 'icon':\n return [{ tag: 'link', rel: 'icon', href }];\n case 'apple-icon':\n return [{ tag: 'link', rel: 'apple-touch-icon', href }];\n case 'manifest':\n return [{ tag: 'link', rel: 'manifest', href }];\n case 'opengraph-image':\n return [\n { tag: 'meta', property: 'og:image', content: href },\n { tag: 'meta', name: 'twitter:image', content: href },\n ];\n default:\n return [];\n }\n}\n","/**\n * URL canonicalization — runs once at the request boundary.\n *\n * Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same\n * canonical path. No re-decoding occurs at any later stage.\n *\n * See design/07-routing.md §\"URL Canonicalization & Security\"\n */\n\n/** Result of canonicalization — either a clean path or a rejection. */\nexport type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };\n\n/**\n * Encoded separators that produce a 400 rejection.\n * %2f (/) and %5c (\\) cause path-confusion attacks.\n *\n * Shared between the runtime canonicalizer and the build-time route scanner\n * to ensure both enforce identical security rules. See design/13-security.md.\n */\nexport const ENCODED_SEPARATOR_RE = /%2f|%5c/i;\n\n/** Null byte — rejected. Shared with the route scanner. */\nexport const NULL_BYTE_RE = /%00/i;\n\n/**\n * Canonicalize a URL pathname.\n *\n * 1. Reject encoded separators (%2f, %5c) and null bytes (%00)\n * 2. Single percent-decode\n * 3. Collapse // → /\n * 4. Resolve .. segments (reject if escaping root)\n * 5. Strip trailing slash (except root \"/\")\n *\n * @param rawPathname - The raw pathname from the request URL (percent-encoded)\n * @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.\n */\nexport function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {\n // Step 1: Reject dangerous encoded sequences BEFORE decoding.\n // This must happen on the raw input so %252f doesn't bypass after a single decode.\n if (ENCODED_SEPARATOR_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n if (NULL_BYTE_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n\n // Step 2: Single percent-decode.\n // Double-encoded input (%2561 → %61) stays as %61 — not decoded again.\n let decoded: string;\n try {\n decoded = decodeURIComponent(rawPathname);\n } catch {\n // Malformed percent-encoding → 400\n return { ok: false, status: 400 };\n }\n\n // Reject null bytes that appeared after decoding (from valid %00-like sequences\n // that weren't caught above — belt and suspenders).\n if (decoded.includes('\\0')) {\n return { ok: false, status: 400 };\n }\n\n // Backslash is NOT a path separator — keep as literal character.\n // But reject if it would create // after normalization (e.g., /\\evil.com).\n // We do NOT convert \\ to / — it stays as a literal.\n\n // Step 3: Collapse consecutive slashes.\n let pathname = decoded.replace(/\\/\\/+/g, '/');\n\n // Step 4: Resolve .. and . segments.\n const segments = pathname.split('/');\n const resolved: string[] = [];\n for (const seg of segments) {\n if (seg === '..') {\n if (resolved.length <= 1) {\n // Trying to escape root — 400\n return { ok: false, status: 400 };\n }\n resolved.pop();\n } else if (seg !== '.') {\n resolved.push(seg);\n }\n }\n\n pathname = resolved.join('/') || '/';\n\n // Step 5: Strip trailing slash (except root \"/\").\n if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n return { ok: true, pathname };\n}\n"],"mappings":"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"}
|