@timber-js/app 0.2.0-alpha.116 → 0.2.0-alpha.117

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 (39) hide show
  1. package/dist/_chunks/{actions-DAVm0FQe.js → actions-Cw-7NfJJ.js} +8 -1
  2. package/dist/_chunks/actions-Cw-7NfJJ.js.map +1 -0
  3. package/dist/_chunks/{resolve-schema-DO8B0mO9.js → resolve-schema-23Mc8aLi.js} +1 -1
  4. package/dist/_chunks/{resolve-schema-DO8B0mO9.js.map → resolve-schema-23Mc8aLi.js.map} +1 -1
  5. package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
  6. package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
  7. package/dist/_chunks/tracing-DvKLzCFa.js.map +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client/internal.js +1 -1
  10. package/dist/params/index.js +1 -1
  11. package/dist/server/als-registry.d.ts +12 -0
  12. package/dist/server/als-registry.d.ts.map +1 -1
  13. package/dist/server/index.js +1 -1
  14. package/dist/server/internal.js +68 -67
  15. package/dist/server/internal.js.map +1 -1
  16. package/dist/server/param-coercion.d.ts +18 -0
  17. package/dist/server/param-coercion.d.ts.map +1 -1
  18. package/dist/server/pipeline-phases.d.ts.map +1 -1
  19. package/dist/server/pipeline.d.ts +7 -0
  20. package/dist/server/pipeline.d.ts.map +1 -1
  21. package/dist/server/request-context.d.ts +9 -0
  22. package/dist/server/request-context.d.ts.map +1 -1
  23. package/dist/server/route-element-builder.d.ts.map +1 -1
  24. package/dist/server/route-matcher.d.ts.map +1 -1
  25. package/dist/server/slot-resolver.d.ts +1 -1
  26. package/dist/server/slot-resolver.d.ts.map +1 -1
  27. package/dist/server/tree-match.d.ts +41 -0
  28. package/dist/server/tree-match.d.ts.map +1 -0
  29. package/package.json +1 -1
  30. package/src/server/als-registry.ts +12 -0
  31. package/src/server/param-coercion.ts +58 -0
  32. package/src/server/pipeline-phases.ts +5 -0
  33. package/src/server/pipeline.ts +7 -0
  34. package/src/server/request-context.ts +31 -0
  35. package/src/server/route-element-builder.ts +12 -2
  36. package/src/server/route-matcher.ts +8 -168
  37. package/src/server/slot-resolver.ts +79 -112
  38. package/src/server/tree-match.ts +198 -0
  39. package/dist/_chunks/actions-DAVm0FQe.js.map +0 -1
@@ -26,6 +26,9 @@ import type { InterceptionContext, RouteMatch } from './pipeline.js';
26
26
  import { DenySignal, RedirectSignal } from './primitives.js';
27
27
  import { logRenderError } from './logger.js';
28
28
  import type { ManifestSegmentNode } from './route-matcher.js';
29
+ import { setSlotParams } from './request-context.js';
30
+ import { coerceSlotParams } from './param-coercion.js';
31
+ import { matchUrlParts } from './tree-match.js';
29
32
 
30
33
  type CreateElementFn = (...args: unknown[]) => React.ReactElement;
31
34
 
@@ -57,89 +60,6 @@ async function renderDefaultFallback(
57
60
  return h(DefaultComp, {});
58
61
  }
59
62
 
60
- // ─── Segment Tree Matching ──────────────────────────────────────────────────
61
-
62
- /**
63
- * Find a matching child node for a URL segment name.
64
- *
65
- * Tries matches in priority order:
66
- * 1. Static segment (exact name match)
67
- * 2. Dynamic segment ([param])
68
- * 3. Catch-all or optional-catch-all ([...param] / [[...param]])
69
- * 4. Group children (transparent wrappers)
70
- *
71
- * Returns `{ node, consumesRest }` where `consumesRest` is true for catch-all
72
- * segments that consume all remaining URL parts.
73
- */
74
- function findMatchingChild(
75
- children: ManifestSegmentNode[],
76
- segmentName: string
77
- ): { node: ManifestSegmentNode; consumesRest: boolean } | null {
78
- // 1. Static match
79
- for (const child of children) {
80
- if (child.segmentType === 'static' && child.segmentName === segmentName) {
81
- return { node: child, consumesRest: false };
82
- }
83
- }
84
-
85
- // 2. Dynamic match
86
- for (const child of children) {
87
- if (child.segmentType === 'dynamic') {
88
- return { node: child, consumesRest: false };
89
- }
90
- }
91
-
92
- // 3. Catch-all match — consumes all remaining segments
93
- for (const child of children) {
94
- if (child.segmentType === 'catch-all' || child.segmentType === 'optional-catch-all') {
95
- return { node: child, consumesRest: true };
96
- }
97
- }
98
-
99
- // 4. Group children (transparent)
100
- for (const child of children) {
101
- if (child.segmentType === 'group') {
102
- for (const groupChild of child.children ?? []) {
103
- if (groupChild.segmentName === segmentName) {
104
- return { node: groupChild, consumesRest: false };
105
- }
106
- }
107
- }
108
- }
109
-
110
- return null;
111
- }
112
-
113
- /**
114
- * Walk a segment tree from `startNode`, matching each part in `parts` against
115
- * child nodes. Returns the chain of matched nodes (including startNode) and the
116
- * final node, or null if any part fails to match.
117
- */
118
- function walkSegmentTree(
119
- startNode: ManifestSegmentNode,
120
- parts: { segmentName: string }[] | string[],
121
- initialChain: ManifestSegmentNode[] = [startNode]
122
- ): { chain: ManifestSegmentNode[]; leaf: ManifestSegmentNode } | null {
123
- const chain = [...initialChain];
124
- let currentNode = startNode;
125
-
126
- for (const part of parts) {
127
- const segName = typeof part === 'string' ? part : part.segmentName;
128
- const directChildren = currentNode.children ?? [];
129
- const match = findMatchingChild(directChildren, segName);
130
-
131
- if (!match) return null;
132
-
133
- chain.push(match.node);
134
- currentNode = match.node;
135
-
136
- // Catch-all segments consume all remaining parts
137
- if (match.consumesRest) break;
138
- }
139
-
140
- return { chain, leaf: currentNode };
141
- }
142
-
143
63
  // ─── Slot Element Resolution ────────────────────────────────────────────────
144
64
 
145
65
  /**
@@ -157,7 +77,8 @@ export async function resolveSlotElement(
157
77
  slotNode: ManifestSegmentNode,
158
78
  match: RouteMatch,
159
79
  h: CreateElementFn,
160
- interception?: InterceptionContext
80
+ interception?: InterceptionContext,
81
+ parentTreePath?: string
161
82
  ): Promise<React.ReactElement | null> {
162
83
  // When interception is active, try to match intercepting children in this
163
84
  // slot against the target pathname. If an intercepting child matches, render
@@ -169,6 +90,22 @@ export async function resolveSlotElement(
169
90
  : findSlotMatch(slotNode, match);
170
91
 
171
92
  if (slotMatch) {
93
+ // Store per-slot params in ALS so getSegmentParams(segmentPath) returns
94
+ // the slot's own param types (e.g. catch-all → string[] vs dynamic → string).
95
+ if (parentTreePath && Object.keys(slotMatch.slotParams).length > 0) {
96
+ // Build the full tree path: parent prefix + slot chain segment names.
97
+ const slotSuffix = slotMatch.chain
98
+ .map((s) => s.segmentName)
99
+ .filter(Boolean)
100
+ .join('/');
101
+ const prefix = parentTreePath === '/' ? '' : parentTreePath;
102
+ const fullSlotPath = `${prefix}/${slotSuffix}`;
103
+
104
+ // Coerce slot params through global schema codecs (same as main route).
105
+ const coerced = coerceSlotParams(slotMatch.chain, slotMatch.slotParams);
106
+ setSlotParams(fullSlotPath, coerced);
107
+ }
108
+
172
109
  const SlotPage = await loadComponent(slotMatch.page);
173
110
  if (SlotPage) {
174
111
  // Load default.tsx fallback for notFound() handling in the slot page.
@@ -334,6 +271,8 @@ interface SlotMatchResult {
334
271
  page: NonNullable<ManifestSegmentNode['page']>;
335
272
  /** The full chain of slot nodes traversed (slot root → … → leaf with page). */
336
273
  chain: ManifestSegmentNode[];
274
+ /** Params collected from the slot's segment chain (may differ from main route params). */
275
+ slotParams: Record<string, string | string[]>;
337
276
  }
338
277
 
339
278
  /**
@@ -374,26 +313,54 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
374
313
  // against the slot's children.
375
314
  const remainingSegments = parentIndex >= 0 ? segments.slice(parentIndex + 1) : [];
376
315
 
377
- // If no remaining segments, the slot's own page matches
378
- if (remainingSegments.length === 0) {
379
- if (slotNode.page) {
380
- return { page: slotNode.page, chain: [slotNode] };
381
- }
382
- // Check for optional-catch-all child — [[...slug]] matches zero segments
383
- for (const child of slotNode.children ?? []) {
384
- if (child.segmentType === 'optional-catch-all' && child.page) {
385
- return { page: child.page, chain: [slotNode, child] };
316
+ // Extract raw URL parts from the remaining main-route segments.
317
+ // Use rawSegmentParams (pre-coercion) to ensure we get actual URL strings,
318
+ // not codec-transformed values. Fall back to segmentParams for tests/compat.
319
+ const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
320
+ const urlParts = extractUrlParts(remainingSegments, rawParams);
321
+
322
+ // Match URL parts against the slot's segment tree using the shared walker.
323
+ // Handles zero parts (slot's own page), catch-all, optional-catch-all,
324
+ // groups, backtracking, and correct priority ordering.
325
+ const result = matchUrlParts(slotNode, urlParts);
326
+ if (!result) return null;
327
+
328
+ const leaf = result.chain[result.chain.length - 1];
329
+ if (!leaf.page) return null;
330
+
331
+ return { page: leaf.page, chain: result.chain, slotParams: result.params };
332
+ }
333
+
334
+ /**
335
+ * Extract raw URL path parts from a main-route segment chain.
336
+ *
337
+ * Each dynamic segment consumed one URL part (its param value).
338
+ * Static segments consumed their own name. Groups are transparent.
339
+ * Catch-all segments consumed multiple parts (their param value is string[]).
340
+ *
341
+ * Uses rawSegmentParams (pre-coercion) to get actual URL strings.
342
+ * Values are coerced to String() defensively in case a codec leaked
343
+ * a non-string value into the params map.
344
+ */
345
+ function extractUrlParts(
346
+ segments: ManifestSegmentNode[],
347
+ mainParams: Record<string, string | string[]>
348
+ ): string[] {
349
+ const parts: string[] = [];
350
+ for (const seg of segments) {
351
+ if (seg.segmentType === 'group') continue;
352
+ if (seg.segmentType === 'static') {
353
+ parts.push(seg.segmentName);
354
+ } else if (seg.paramName) {
355
+ const value = mainParams[seg.paramName];
356
+ if (Array.isArray(value)) {
357
+ parts.push(...value.map(String));
358
+ } else if (value !== undefined) {
359
+ parts.push(String(value));
386
360
  }
387
361
  }
388
- return null;
389
- }
390
-
391
- // Walk the slot's children to match remaining URL segments
392
- const result = walkSegmentTree(slotNode, remainingSegments);
393
- if (result && result.leaf.page) {
394
- return { page: result.leaf.page, chain: result.chain };
395
362
  }
396
- return null;
363
+ return parts;
397
364
  }
398
365
 
399
366
  /**
@@ -429,19 +396,19 @@ function findInterceptingMatch(
429
396
  }
430
397
  if (matchIdx < 0) continue;
431
398
 
432
- // Walk the intercepting child's sub-tree to match remaining target parts
399
+ // Match remaining target parts against the intercepting child's sub-tree.
433
400
  const remaining = targetParts.slice(matchIdx + 1);
434
-
435
- if (remaining.length === 0) {
436
- if (child.page) {
437
- return { page: child.page, chain: [slotNode, child] };
401
+ const result = matchUrlParts(child, remaining);
402
+ if (result) {
403
+ const leaf = result.chain[result.chain.length - 1];
404
+ if (leaf.page) {
405
+ return {
406
+ page: leaf.page,
407
+ // Prepend slotNode — matchUrlParts starts from child, not slot root
408
+ chain: [slotNode, ...result.chain],
409
+ slotParams: result.params,
410
+ };
438
411
  }
439
- continue;
440
- }
441
-
442
- const result = walkSegmentTree(child, remaining, [slotNode, child]);
443
- if (result && result.leaf.page) {
444
- return { page: result.leaf.page, chain: result.chain };
445
412
  }
446
413
  }
447
414
 
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Shared URL-matching tree walker for segment trees.
3
+ *
4
+ * Both the main route matcher (`route-matcher.ts`) and slot resolver
5
+ * (`slot-resolver.ts`) need to walk a SegmentNode tree, matching URL
6
+ * parts against children with the same priority semantics:
7
+ *
8
+ * static > dynamic > catch-all > optional-catch-all
9
+ *
10
+ * Before this module, each had its own walker with subtle divergences
11
+ * (different group handling, missing backtracking, incomplete
12
+ * optional-catch-all support). This shared walker is the single
13
+ * source of truth for URL-to-segment matching.
14
+ *
15
+ * See design/07-routing.md §"Request Lifecycle" and TIM-940.
16
+ */
17
+
18
+ import type { SegmentNode } from '../routing/types.js';
19
+
20
+ // ─── Public API ───────────────────────────────────────────────────────────
21
+
22
+ /** Result of matching URL parts against a segment tree. */
23
+ export interface TreeMatchResult<TFile> {
24
+ /** Matched segment chain from root to leaf, inclusive. */
25
+ chain: SegmentNode<TFile>[];
26
+ /** Collected params, keyed by param name. Catch-all params are string[]. */
27
+ params: Record<string, string | string[]>;
28
+ }
29
+
30
+ /**
31
+ * Match URL path parts against a segment tree.
32
+ *
33
+ * Walks the tree depth-first with backtracking, applying priority
34
+ * ordering globally across group boundaries. Returns the matched
35
+ * segment chain and collected params, or null if no route matches.
36
+ *
37
+ * A match requires a terminal node (one with `page` or `route`) at
38
+ * the leaf. Catch-all and optional-catch-all segments only match if
39
+ * they are terminal.
40
+ *
41
+ * @param root The root segment node to start matching from.
42
+ * @param parts URL path parts (e.g. ['blog', 'hello-world']).
43
+ * Empty array matches the root itself (if terminal).
44
+ */
45
+ export function matchUrlParts<TFile>(
46
+ root: SegmentNode<TFile>,
47
+ parts: string[]
48
+ ): TreeMatchResult<TFile> | null {
49
+ const chain: SegmentNode<TFile>[] = [];
50
+ const params: Record<string, string | string[]> = {};
51
+
52
+ if (matchNode(root, parts, 0, chain, params)) {
53
+ return { chain, params };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // ─── Internal ─────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * A child node flattened through group segments, along with the
62
+ * chain of group nodes that must appear in the segment chain
63
+ * before the child to preserve the tree path.
64
+ */
65
+ interface EffectiveChild<TFile> {
66
+ child: SegmentNode<TFile>;
67
+ groupChain: SegmentNode<TFile>[];
68
+ }
69
+
70
+ /**
71
+ * Flatten children through group segments.
72
+ *
73
+ * Groups are transparent for URL matching — their non-group descendants
74
+ * are returned with the chain of group nodes leading to them. This lets
75
+ * the caller apply priority ordering (static > dynamic > catch-all >
76
+ * optional-catch-all) across all groups uniformly instead of per-group.
77
+ */
78
+ function collectEffectiveChildren<TFile>(
79
+ node: SegmentNode<TFile>,
80
+ groupChain: SegmentNode<TFile>[] = []
81
+ ): EffectiveChild<TFile>[] {
82
+ const result: EffectiveChild<TFile>[] = [];
83
+ for (const child of node.children) {
84
+ if (child.segmentType === 'group') {
85
+ const nested = collectEffectiveChildren(child, [...groupChain, child]);
86
+ result.push(...nested);
87
+ } else {
88
+ result.push({ child, groupChain });
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /** A node is terminal if it can serve a response (has a page or route handler). */
95
+ function isTerminal<TFile>(node: SegmentNode<TFile>): boolean {
96
+ return !!(node.page || node.route);
97
+ }
98
+
99
+ /**
100
+ * Recursive depth-first matcher with backtracking.
101
+ *
102
+ * Mutates `chain` and `params` in place for performance; backtracks
103
+ * (restores previous state) when a candidate doesn't lead to a match.
104
+ */
105
+ function matchNode<TFile>(
106
+ node: SegmentNode<TFile>,
107
+ parts: string[],
108
+ index: number,
109
+ chain: SegmentNode<TFile>[],
110
+ params: Record<string, string | string[]>
111
+ ): boolean {
112
+ chain.push(node);
113
+
114
+ // All parts consumed — check if this node is terminal
115
+ if (index >= parts.length) {
116
+ if (isTerminal(node)) return true;
117
+
118
+ // Groups don't consume URL segments, so a group child's page can
119
+ // match at the same URL depth. Recurse into groups transparently.
120
+ for (const child of node.children) {
121
+ if (child.segmentType === 'group') {
122
+ if (matchNode(child, parts, index, chain, params)) {
123
+ return true;
124
+ }
125
+ }
126
+ }
127
+
128
+ // Optional catch-all with zero segments (direct and through groups).
129
+ // The param is NOT set — matching Next.js semantics for [[...slug]]
130
+ // with no path segments.
131
+ const effective = collectEffectiveChildren(node);
132
+ for (const { child, groupChain } of effective) {
133
+ if (child.segmentType === 'optional-catch-all' && isTerminal(child)) {
134
+ chain.push(...groupChain, child);
135
+ return true;
136
+ }
137
+ }
138
+
139
+ chain.pop();
140
+ return false;
141
+ }
142
+
143
+ const part = parts[index];
144
+
145
+ // Flatten children through groups so priority ordering applies globally.
146
+ const effective = collectEffectiveChildren(node);
147
+
148
+ // 1. Static segments (exact name match)
149
+ for (const { child, groupChain } of effective) {
150
+ if (child.segmentType === 'static' && child.segmentName === part) {
151
+ chain.push(...groupChain);
152
+ if (matchNode(child, parts, index + 1, chain, params)) {
153
+ return true;
154
+ }
155
+ chain.length -= groupChain.length;
156
+ }
157
+ }
158
+
159
+ // 2. Dynamic segments ([param])
160
+ for (const { child, groupChain } of effective) {
161
+ if (child.segmentType === 'dynamic' && child.paramName) {
162
+ chain.push(...groupChain);
163
+ const prev = params[child.paramName];
164
+ params[child.paramName] = part;
165
+ if (matchNode(child, parts, index + 1, chain, params)) {
166
+ return true;
167
+ }
168
+ // Backtrack
169
+ chain.length -= groupChain.length;
170
+ if (prev !== undefined) {
171
+ params[child.paramName] = prev;
172
+ } else {
173
+ delete params[child.paramName];
174
+ }
175
+ }
176
+ }
177
+
178
+ // 3. Catch-all segments ([...param]) — must be terminal
179
+ for (const { child, groupChain } of effective) {
180
+ if (child.segmentType === 'catch-all' && child.paramName && isTerminal(child)) {
181
+ chain.push(...groupChain, child);
182
+ params[child.paramName] = parts.slice(index);
183
+ return true;
184
+ }
185
+ }
186
+
187
+ // 4. Optional catch-all segments ([[...param]]) — must be terminal
188
+ for (const { child, groupChain } of effective) {
189
+ if (child.segmentType === 'optional-catch-all' && child.paramName && isTerminal(child)) {
190
+ chain.push(...groupChain, child);
191
+ params[child.paramName] = parts.slice(index);
192
+ return true;
193
+ }
194
+ }
195
+
196
+ chain.pop();
197
+ return false;
198
+ }