@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.
Files changed (97) hide show
  1. package/dist/_chunks/{actions-O_LsyCE4.js → actions-pN8r5Vnh.js} +3 -3
  2. package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-pN8r5Vnh.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-2hT5kfsr.js} +2 -2
  4. package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-2hT5kfsr.js.map} +1 -1
  5. package/dist/_chunks/{canonicalize-Du3o_ptW.js → canonicalize-DQHyFClh.js} +2 -1
  6. package/dist/_chunks/canonicalize-DQHyFClh.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-B5FDplGI.js → cli-schema-sync-DvdvFwwE.js} +3 -3
  8. package/dist/_chunks/{cli-schema-sync-B5FDplGI.js.map → cli-schema-sync-DvdvFwwE.js.map} +1 -1
  9. package/dist/_chunks/{logger-AWfuX-KJ.js → logger-kUT0QH0K.js} +23 -1
  10. package/dist/_chunks/logger-kUT0QH0K.js.map +1 -0
  11. package/dist/_chunks/{walkers-DCoE-LJf.js → walkers-Bv63zfAC.js} +2 -2
  12. package/dist/_chunks/{walkers-DCoE-LJf.js.map → walkers-Bv63zfAC.js.map} +1 -1
  13. package/dist/cache/index.js +1 -1
  14. package/dist/cli.d.ts +3 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +9 -5
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client/internal.js +40 -6
  19. package/dist/client/internal.js.map +1 -1
  20. package/dist/client/rsc-fetch.d.ts +1 -1
  21. package/dist/client/segment-cache.d.ts +20 -3
  22. package/dist/client/segment-cache.d.ts.map +1 -1
  23. package/dist/client/segment-outlet.d.ts +10 -2
  24. package/dist/client/segment-outlet.d.ts.map +1 -1
  25. package/dist/client/slot-context.d.ts +10 -8
  26. package/dist/client/slot-context.d.ts.map +1 -1
  27. package/dist/client/slot-provider.d.ts +5 -0
  28. package/dist/client/slot-provider.d.ts.map +1 -1
  29. package/dist/index.js +5 -5
  30. package/dist/routing/index.js +2 -2
  31. package/dist/server/access-gate.d.ts.map +1 -1
  32. package/dist/server/als-registry.d.ts +26 -0
  33. package/dist/server/als-registry.d.ts.map +1 -1
  34. package/dist/server/cookie-context.d.ts.map +1 -1
  35. package/dist/server/index.js +2 -2
  36. package/dist/server/internal.js +1614 -1667
  37. package/dist/server/internal.js.map +1 -1
  38. package/dist/server/metadata-routes.d.ts +13 -0
  39. package/dist/server/metadata-routes.d.ts.map +1 -1
  40. package/dist/server/metadata.d.ts +8 -0
  41. package/dist/server/metadata.d.ts.map +1 -1
  42. package/dist/server/prebuilt/slots.d.ts +33 -8
  43. package/dist/server/prebuilt/slots.d.ts.map +1 -1
  44. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  45. package/dist/server/request-context.d.ts +15 -0
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/route-element-builder.d.ts +9 -11
  48. package/dist/server/route-element-builder.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/helpers.d.ts +18 -10
  50. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/rsc-payload.d.ts +2 -1
  53. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  54. package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
  55. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  56. package/dist/server/slot-resolver.d.ts +46 -1
  57. package/dist/server/slot-resolver.d.ts.map +1 -1
  58. package/dist/server/state-tree-diff.d.ts +36 -3
  59. package/dist/server/state-tree-diff.d.ts.map +1 -1
  60. package/dist/server/tree-builder.d.ts +7 -0
  61. package/dist/server/tree-builder.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/cli.ts +15 -5
  64. package/src/client/rsc-fetch.ts +1 -1
  65. package/src/client/segment-cache.ts +83 -10
  66. package/src/client/segment-outlet.tsx +24 -2
  67. package/src/client/slot-context.ts +10 -8
  68. package/src/client/slot-provider.tsx +9 -2
  69. package/src/server/access-gate.tsx +28 -1
  70. package/src/server/als-registry.ts +44 -0
  71. package/src/server/cookie-context.ts +7 -1
  72. package/src/server/deny-renderer.ts +1 -1
  73. package/src/server/metadata-routes.ts +95 -0
  74. package/src/server/metadata.ts +21 -0
  75. package/src/server/prebuilt/slots.ts +39 -16
  76. package/src/server/prebuilt-builder.ts +6 -5
  77. package/src/server/prebuilt-runtime.ts +11 -11
  78. package/src/server/request-context.ts +47 -1
  79. package/src/server/route-element-builder.ts +72 -144
  80. package/src/server/rsc-entry/helpers.ts +68 -14
  81. package/src/server/rsc-entry/render-route.ts +11 -3
  82. package/src/server/rsc-entry/rsc-payload.ts +16 -3
  83. package/src/server/rsc-entry/ssr-renderer.ts +3 -1
  84. package/src/server/slot-resolver.ts +299 -8
  85. package/src/server/state-tree-diff.ts +104 -8
  86. package/src/server/tree-builder.ts +10 -0
  87. package/dist/_chunks/canonicalize-Du3o_ptW.js.map +0 -1
  88. package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
  89. package/dist/client/child-segment-context.d.ts +0 -22
  90. package/dist/client/child-segment-context.d.ts.map +0 -1
  91. package/dist/client/child-segment-outlet.d.ts +0 -18
  92. package/dist/client/child-segment-outlet.d.ts.map +0 -1
  93. package/dist/client/child-segment-provider.d.ts +0 -21
  94. package/dist/client/child-segment-provider.d.ts.map +0 -1
  95. package/src/client/child-segment-context.ts +0 -40
  96. package/src/client/child-segment-outlet.tsx +0 -25
  97. 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 segmentInfo = buildSegmentInfo(segments, layoutComponents);
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 await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
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, or null if
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 Set of sync segment paths, or null if no valid state tree
89
+ * @returns ClientStateTree with segments and slots, or null if no valid state tree
72
90
  */
73
- export function parseClientStateTree(req: Request): Set<string> | null {
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
- return new Set(parsed.segments as string[]);
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
- // Async layouts always re-render — they may depend on per-request data
124
- // (cookies, headers, database queries) that changes between navigations.
125
- // constructor.name check: AsyncFunction.name === 'AsyncFunction'
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":";;;;;;;;;;AA4CA,IAAa,6BAqBT;CACF,WAAW;EACT,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB,CAAC,KAAK;EACxB,mBAAmB,CAAC,IAAI;EACxB,WAAW;CACb;CACA,UAAU;EACR,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB,CAAC,KAAK;EACxB,mBAAmB,CAAC,IAAI;EACxB,WAAW;CACb;CACA,YAAY;EACV,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB,CAAC,MAAM;EACzB,mBAAmB,CAAC,IAAI;EACxB,WAAW;CACb;CACA,WAAW;EACT,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB,CAAC,KAAK;EACxB,mBAAmB,CAAC,MAAM,KAAK;EAC/B,WAAW;CACb;CACA,QAAQ;EACN,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB;GAAC;GAAO;GAAO;EAAK;EACtC,mBAAmB,CAAC,MAAM,KAAK;EAC/B,WAAW;EACX,gBAAgB;CAClB;CACA,mBAAmB;EACjB,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB,CAAC,OAAO,KAAK;EAC/B,mBAAmB,CAAC,MAAM,KAAK;EAC/B,WAAW;EACX,gBAAgB;CAClB;CAEA,cAAc;EACZ,MAAM;EACN,aAAa;EACb,UAAU;EACV,kBAAkB,CAAC,KAAK;EACxB,mBAAmB,CAAC,MAAM,KAAK;EAC/B,WAAW;EACX,gBAAgB;CAClB;AACF;;;;;;;;AAyDA,SAAgB,2BAA2B,UAAkB,WAA4B;CACvF,MAAM,aAAa,2BAA2B;CAC9C,IAAI,CAAC,YAAY,OAAO;CACxB,OAAO,WAAW,kBAAkB,SAAS,SAAS;AACxD;;;;;;;AAUA,SAAgB,sBAAsB,UAA4C;CAChF,MAAM,WAAW,SAAS,YAAY,GAAG;CACzC,IAAI,aAAa,IAAI,OAAO;CAE5B,MAAM,WAAW,SAAS,MAAM,GAAG,QAAQ;CAC3C,MAAM,MAAM,SAAS,MAAM,WAAW,CAAC;CAEvC,MAAM,aAAa,2BAA2B;CAC9C,IAAI,CAAC,YAAY,OAAO;CAExB,MAAM,WAAW,WAAW,iBAAiB,SAAS,GAAG;CACzD,MAAM,YAAY,WAAW,kBAAkB,SAAS,GAAG;CAE3D,IAAI,CAAC,YAAY,CAAC,WAAW,OAAO;CAEpC,OAAO;EACL,MAAM,WAAW;EACjB,aAAa,WAAW;EACxB,UAAU,WAAW;CACvB;AACF;;;;;AA8BA,SAAgB,0BAA0B,MAAiC;CACzE,KAAK,MAAM,cAAc,OAAO,OAAO,0BAA0B,GAC/D,IAAI,WAAW,SAAS,MAAM;EAC5B,IAAI,WAAW,gBACb,OAAO,GAAG,WAAW,UAAU,GAAG,WAAW;EAE/C,OAAO,WAAW;CACpB;CAEF,MAAM,IAAI,MAAM,yCAAyC,MAAM;AACjE;;;;;AAMA,IAAM,uCAAuB,IAAI,IAAY;AAC7C,KAAK,MAAM,cAAc,OAAO,OAAO,0BAA0B,GAC/D,IAAI,WAAW,gBACb,KAAK,MAAM,OAAO,CAAC,GAAG,WAAW,kBAAkB,WAAW,cAAc,GAC1E,qBAAqB,IAAI,GAAG,WAAW,UAAU,GAAG,KAAK;KAG3D,qBAAqB,IAAI,WAAW,SAAS;AAIjD,SAAgB,yBAAyB,UAA2B;CAClE,IAAI,cAAc,SAAS,MAAM,SAAS,YAAY,GAAG,IAAI,CAAC;CAC9D,MAAM,OAAO,YAAY,QAAQ,GAAG;CACpC,IAAI,QAAQ;CACZ,IAAI,SAAS,IAAI;EACf,QAAQ,YAAY,MAAM,OAAO,CAAC;EAClC,cAAc,YAAY,MAAM,GAAG,IAAI;CACzC;CACA,IAAI,CAAC,qBAAqB,IAAI,WAAW,GAAG,OAAO;CAGnD,IAAI,iDAAiD,KAAK,KAAK,GAAG,OAAO;CACzE,OAAO;AACT;;;;;;;;;;;;;AAgCA,SAAgB,yBAAyB,MAAyB,MAA6B;CAC7F,QAAQ,MAAR;EACE,KAAK,QACH,OAAO,CAAC;GAAE,KAAK;GAAQ,KAAK;GAAQ;EAAK,CAAC;EAC5C,KAAK,cACH,OAAO,CAAC;GAAE,KAAK;GAAQ,KAAK;GAAoB;EAAK,CAAC;EACxD,KAAK,YACH,OAAO,CAAC;GAAE,KAAK;GAAQ,KAAK;GAAY;EAAK,CAAC;EAChD,KAAK,mBACH,OAAO,CACL;GAAE,KAAK;GAAQ,UAAU;GAAY,SAAS;EAAK,GACnD;GAAE,KAAK;GAAQ,MAAM;GAAiB,SAAS;EAAK,CACtD;EACF,SACE,OAAO,CAAC;CACZ;AACF;;;;;;;;;;AC5TA,IAAa,uBAAuB;;AAGpC,IAAa,eAAe;;;;;;;;;;;;;AAc5B,SAAgB,aAAa,aAAqB,qBAAqB,MAA0B;CAG/F,IAAI,qBAAqB,KAAK,WAAW,GACvC,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAI;CAElC,IAAI,aAAa,KAAK,WAAW,GAC/B,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAI;CAKlC,IAAI;CACJ,IAAI;EACF,UAAU,mBAAmB,WAAW;CAC1C,QAAQ;EAEN,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAI;CAClC;CAIA,IAAI,QAAQ,SAAS,IAAI,GACvB,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAI;CAQlC,IAAI,WAAW,QAAQ,QAAQ,UAAU,GAAG;CAG5C,MAAM,WAAW,SAAS,MAAM,GAAG;CACnC,MAAM,WAAqB,CAAC;CAC5B,KAAK,MAAM,OAAO,UAChB,IAAI,QAAQ,MAAM;EAChB,IAAI,SAAS,UAAU,GAErB,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAI;EAElC,SAAS,IAAI;CACf,OAAO,IAAI,QAAQ,KACjB,SAAS,KAAK,GAAG;CAIrB,WAAW,SAAS,KAAK,GAAG,KAAK;CAGjC,IAAI,sBAAsB,SAAS,SAAS,KAAK,SAAS,SAAS,GAAG,GACpE,WAAW,SAAS,MAAM,GAAG,EAAE;CAGjC,OAAO;EAAE,IAAI;EAAM;CAAS;AAC9B"}