@timber-js/app 0.2.0-alpha.36 → 0.2.0-alpha.37

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 (61) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
  3. package/dist/_chunks/{define-cookie-w5GWm_bL.js → define-cookie-BmKbSyp0.js} +4 -4
  4. package/dist/_chunks/{define-cookie-w5GWm_bL.js.map → define-cookie-BmKbSyp0.js.map} +1 -1
  5. package/dist/_chunks/{error-boundary-TYEQJZ1-.js → error-boundary-BAN3751q.js} +1 -1
  6. package/dist/_chunks/{error-boundary-TYEQJZ1-.js.map → error-boundary-BAN3751q.js.map} +1 -1
  7. package/dist/_chunks/{request-context-CZz_T0Bc.js → request-context-BxYIJM24.js} +59 -4
  8. package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
  9. package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
  10. package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
  11. package/dist/_chunks/{tracing-BPyIzIdu.js → tracing-CuXiCP5p.js} +1 -1
  12. package/dist/_chunks/{tracing-BPyIzIdu.js.map → tracing-CuXiCP5p.js.map} +1 -1
  13. package/dist/_chunks/{wrappers-C1SN725w.js → wrappers-C6J0nNji.js} +2 -2
  14. package/dist/_chunks/{wrappers-C1SN725w.js.map → wrappers-C6J0nNji.js.map} +1 -1
  15. package/dist/cache/index.js +1 -1
  16. package/dist/client/error-boundary.js +1 -1
  17. package/dist/client/index.d.ts +1 -0
  18. package/dist/client/index.d.ts.map +1 -1
  19. package/dist/client/index.js +25 -8
  20. package/dist/client/index.js.map +1 -1
  21. package/dist/client/link.d.ts +15 -1
  22. package/dist/client/link.d.ts.map +1 -1
  23. package/dist/cookies/index.js +1 -1
  24. package/dist/params/index.js +1 -1
  25. package/dist/search-params/index.js +1 -1
  26. package/dist/server/access-gate.d.ts.map +1 -1
  27. package/dist/server/als-registry.d.ts +14 -0
  28. package/dist/server/als-registry.d.ts.map +1 -1
  29. package/dist/server/index.d.ts +2 -2
  30. package/dist/server/index.d.ts.map +1 -1
  31. package/dist/server/index.js +42 -26
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/pipeline.d.ts.map +1 -1
  34. package/dist/server/primitives.d.ts +30 -3
  35. package/dist/server/primitives.d.ts.map +1 -1
  36. package/dist/server/request-context.d.ts +39 -0
  37. package/dist/server/request-context.d.ts.map +1 -1
  38. package/dist/server/route-element-builder.d.ts.map +1 -1
  39. package/dist/server/slot-resolver.d.ts +1 -1
  40. package/dist/server/slot-resolver.d.ts.map +1 -1
  41. package/dist/server/tree-builder.d.ts +7 -4
  42. package/dist/server/tree-builder.d.ts.map +1 -1
  43. package/dist/shared/merge-search-params.d.ts +22 -0
  44. package/dist/shared/merge-search-params.d.ts.map +1 -0
  45. package/package.json +6 -7
  46. package/src/cli.ts +0 -0
  47. package/src/client/index.ts +1 -0
  48. package/src/client/link.tsx +57 -3
  49. package/src/server/access-gate.tsx +6 -5
  50. package/src/server/als-registry.ts +14 -0
  51. package/src/server/index.ts +3 -0
  52. package/src/server/pipeline.ts +6 -0
  53. package/src/server/primitives.ts +47 -5
  54. package/src/server/request-context.ts +69 -1
  55. package/src/server/route-element-builder.ts +10 -16
  56. package/src/server/slot-resolver.ts +10 -19
  57. package/src/server/tree-builder.ts +13 -15
  58. package/src/shared/merge-search-params.ts +48 -0
  59. package/dist/_chunks/request-context-CZz_T0Bc.js.map +0 -1
  60. package/dist/_chunks/segment-context-Dpq2XOKg.js +0 -34
  61. package/dist/_chunks/segment-context-Dpq2XOKg.js.map +0 -1
@@ -178,6 +178,72 @@ export function rawSearchParams(): Promise<URLSearchParams> {
178
178
  return store.searchParamsPromise;
179
179
  }
180
180
 
181
+ /**
182
+ * Returns a Promise resolving to the current request's coerced segment params.
183
+ *
184
+ * Segment params are set by the pipeline after route matching and param
185
+ * coercion (via params.ts codecs). When no params.ts exists, values are
186
+ * raw strings. When codecs are defined, values are already coerced
187
+ * (e.g., `id` is a `number` if `defineSegmentParams({ id: z.coerce.number() })`).
188
+ *
189
+ * This is the primary way page and layout components access route params:
190
+ *
191
+ * ```ts
192
+ * import { rawSegmentParams } from '@timber-js/app/server'
193
+ *
194
+ * export default async function Page() {
195
+ * const { slug } = await rawSegmentParams()
196
+ * // ...
197
+ * }
198
+ * ```
199
+ *
200
+ * Throws if called outside a request context.
201
+ */
202
+ export function rawSegmentParams(): Promise<Record<string, string | string[]>> {
203
+ const store = requestContextAls.getStore();
204
+ if (!store) {
205
+ throw new Error(
206
+ '[timber] rawSegmentParams() called outside of a request context. ' +
207
+ 'It can only be used in middleware, access checks, server components, and server actions.'
208
+ );
209
+ }
210
+ if (!store.segmentParamsPromise) {
211
+ throw new Error(
212
+ '[timber] rawSegmentParams() called before route matching completed. ' +
213
+ 'Segment params are not available until after the route is matched.'
214
+ );
215
+ }
216
+ return store.segmentParamsPromise;
217
+ }
218
+
219
+ /**
220
+ * Set the segment params promise on the current request context.
221
+ * Called by the pipeline after route matching and param coercion.
222
+ *
223
+ * @internal — framework use only
224
+ */
225
+ export function setSegmentParams(params: Record<string, string | string[]>): void {
226
+ const store = requestContextAls.getStore();
227
+ if (!store) {
228
+ throw new Error('[timber] setSegmentParams() called outside of a request context.');
229
+ }
230
+ store.segmentParamsPromise = Promise.resolve(params);
231
+ }
232
+
233
+ /**
234
+ * Returns the raw search string from the current request URL (e.g. "?foo=bar").
235
+ * Synchronous — safe for use in `redirect()` which throws synchronously.
236
+ *
237
+ * Returns empty string if called outside a request context (non-throwing for
238
+ * use in redirect's optional preserveSearchParams path).
239
+ *
240
+ * @internal — used by redirect() for preserveSearchParams support.
241
+ */
242
+ export function getRequestSearchString(): string {
243
+ const store = requestContextAls.getStore();
244
+ return store?.searchString ?? '';
245
+ }
246
+
181
247
  // ─── Types ────────────────────────────────────────────────────────────────
182
248
 
183
249
  /**
@@ -253,11 +319,13 @@ export interface RequestCookies {
253
319
  */
254
320
  export function runWithRequestContext<T>(req: Request, fn: () => T): T {
255
321
  const originalCopy = new Headers(req.headers);
322
+ const parsedUrl = new URL(req.url);
256
323
  const store: RequestContextStore = {
257
324
  headers: freezeHeaders(req.headers),
258
325
  originalHeaders: originalCopy,
259
326
  cookieHeader: req.headers.get('cookie') ?? '',
260
- searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),
327
+ searchParamsPromise: Promise.resolve(parsedUrl.searchParams),
328
+ searchString: parsedUrl.search,
261
329
  cookieJar: new Map(),
262
330
  flushed: false,
263
331
  mutableContext: false,
@@ -110,7 +110,7 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
110
110
  ` // Before\n` +
111
111
  ` export async function generateMetadata({ params }) { ... }\n\n` +
112
112
  ` // After\n` +
113
- ` export async function metadata({ params }) { ... }`
113
+ ` export async function metadata() { ... }`
114
114
  );
115
115
  }
116
116
  }
@@ -119,19 +119,21 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
119
119
  * Extract and resolve metadata from a module (layout or page).
120
120
  * Handles both static metadata objects and async metadata functions.
121
121
  * Returns the resolved Metadata, or null if none exported.
122
+ *
123
+ * Metadata functions no longer receive { params } — they access params
124
+ * via rawSegmentParams() from ALS, same as page/layout components.
122
125
  */
123
126
  async function extractMetadata(
124
127
  mod: Record<string, unknown>,
125
- segment: ManifestSegmentNode,
126
- paramsPromise: Promise<Record<string, string | string[]>>
128
+ segment: ManifestSegmentNode
127
129
  ): Promise<Metadata | null> {
128
130
  if (typeof mod.metadata === 'function') {
129
- type MetadataFn = (props: Record<string, unknown>) => Promise<Metadata>;
131
+ type MetadataFn = () => Promise<Metadata>;
130
132
  return (
131
133
  (await withSpan(
132
134
  'timber.metadata',
133
135
  { 'timber.segment': segment.segmentName ?? segment.urlPath },
134
- () => (mod.metadata as MetadataFn)({ params: paramsPromise })
136
+ () => (mod.metadata as MetadataFn)()
135
137
  )) ?? null
136
138
  );
137
139
  }
@@ -172,9 +174,6 @@ export async function buildRouteElement(
172
174
  ): Promise<RouteElementResult> {
173
175
  const segments = match.segments as unknown as ManifestSegmentNode[];
174
176
 
175
- // Params are passed as a Promise to match Next.js 15+ convention.
176
- const paramsPromise = Promise.resolve(match.params);
177
-
178
177
  // Load all modules along the segment chain
179
178
  const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
180
179
  const layoutComponents: LayoutComponentEntry[] = [];
@@ -199,7 +198,7 @@ export async function buildRouteElement(
199
198
  // middleware and rendering. See coerceSegmentParams() in pipeline.ts.
200
199
 
201
200
  rejectLegacyGenerateMetadata(mod, segment.layout.filePath ?? segment.urlPath);
202
- const layoutMetadata = await extractMetadata(mod, segment, paramsPromise);
201
+ const layoutMetadata = await extractMetadata(mod, segment);
203
202
  if (layoutMetadata) {
204
203
  metadataEntries.push({ metadata: layoutMetadata, isPage: false });
205
204
  }
@@ -217,7 +216,7 @@ export async function buildRouteElement(
217
216
  PageComponent = mod.default as (...args: unknown[]) => unknown;
218
217
  }
219
218
  rejectLegacyGenerateMetadata(mod, segment.page.filePath ?? segment.urlPath);
220
- const pageMetadata = await extractMetadata(mod, segment, paramsPromise);
219
+ const pageMetadata = await extractMetadata(mod, segment);
221
220
  if (pageMetadata) {
222
221
  metadataEntries.push({ metadata: pageMetadata, isPage: true });
223
222
  }
@@ -317,9 +316,7 @@ export async function buildRouteElement(
317
316
  );
318
317
  };
319
318
 
320
- let element = h(TracedPage, {
321
- params: paramsPromise,
322
- });
319
+ let element = h(TracedPage, {});
323
320
 
324
321
  // Build a lookup of layout components by segment for O(1) access.
325
322
  const layoutBySegment = new Map(
@@ -399,7 +396,6 @@ export async function buildRouteElement(
399
396
  if (accessFn) {
400
397
  element = h(AccessGate, {
401
398
  accessFn,
402
- params: match.params,
403
399
  segmentName: segment.segmentName,
404
400
  verdict: accessVerdicts.get(i),
405
401
  children: element,
@@ -416,7 +412,6 @@ export async function buildRouteElement(
416
412
  slotProps[slotName] = await resolveSlotElement(
417
413
  slotNode as ManifestSegmentNode,
418
414
  match,
419
- paramsPromise,
420
415
  h,
421
416
  interception
422
417
  );
@@ -447,7 +442,6 @@ export async function buildRouteElement(
447
442
  parallelRouteKeys,
448
443
  children: h(TracedLayout, {
449
444
  ...slotProps,
450
- params: paramsPromise,
451
445
  children: element,
452
446
  }),
453
447
  });
@@ -45,13 +45,12 @@ async function loadComponent(loader: {
45
45
  */
46
46
  async function renderDefaultFallback(
47
47
  slotNode: ManifestSegmentNode,
48
- paramsPromise: Promise<Record<string, string | string[]>>,
49
48
  h: CreateElementFn
50
49
  ): Promise<React.ReactElement | null> {
51
50
  if (!slotNode.default) return null;
52
51
  const DefaultComp = await loadComponent(slotNode.default);
53
52
  if (!DefaultComp) return null;
54
- return h(DefaultComp, { params: paramsPromise });
53
+ return h(DefaultComp, {});
55
54
  }
56
55
 
57
56
  // ─── Segment Tree Matching ──────────────────────────────────────────────────
@@ -153,7 +152,6 @@ function walkSegmentTree(
153
152
  export async function resolveSlotElement(
154
153
  slotNode: ManifestSegmentNode,
155
154
  match: RouteMatch,
156
- paramsPromise: Promise<Record<string, string | string[]>>,
157
155
  h: CreateElementFn,
158
156
  interception?: InterceptionContext
159
157
  ): Promise<React.ReactElement | null> {
@@ -174,7 +172,7 @@ export async function resolveSlotElement(
174
172
  // degrade to default.tsx or null — not crash the page. This matches
175
173
  // Next.js behavior. See design/02-rendering-pipeline.md
176
174
  // §"Slot Access Failure = Graceful Degradation"
177
- const denyFallback = await renderDefaultFallback(slotNode, paramsPromise, h);
175
+ const denyFallback = await renderDefaultFallback(slotNode, h);
178
176
 
179
177
  // Wrap the slot page to catch DenySignal (from notFound() or deny())
180
178
  // at the component level. This prevents the signal from reaching the
@@ -192,23 +190,21 @@ export async function resolveSlotElement(
192
190
  }
193
191
  };
194
192
 
195
- let element: React.ReactElement = h(SafeSlotPage, {
196
- params: paramsPromise,
197
- });
193
+ let element: React.ReactElement = h(SafeSlotPage, {});
198
194
 
199
195
  // Wrap with error boundaries and layouts from intermediate slot segments
200
196
  // (everything between slot root and leaf). Process innermost-first, same
201
197
  // order as route-element-builder.ts handles main segments. The slot root
202
198
  // (index 0) is handled separately after the access gate below.
203
- element = await wrapWithIntermediateSegments(slotMatch.chain, element, paramsPromise, h);
199
+ element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
204
200
 
205
201
  // Wrap in SlotAccessGate if slot root has access.ts.
206
202
  // On denial: denied.tsx → default.tsx → null (graceful degradation).
207
203
  // See design/04-authorization.md §"Slot-Level Auth".
208
- element = await wrapWithAccessGate(slotNode, element, paramsPromise, h);
204
+ element = await wrapWithAccessGate(slotNode, element, h);
209
205
 
210
206
  // Wrap with slot root's layout (outermost, outside access gate)
211
- element = await wrapWithLayout(slotNode, element, paramsPromise, h);
207
+ element = await wrapWithLayout(slotNode, element, h);
212
208
 
213
209
  // Wrap with slot root's error boundaries (outermost)
214
210
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
@@ -231,7 +227,7 @@ export async function resolveSlotElement(
231
227
  }
232
228
 
233
229
  // No matching page — render default.tsx fallback
234
- return renderDefaultFallback(slotNode, paramsPromise, h);
230
+ return renderDefaultFallback(slotNode, h);
235
231
  }
236
232
 
237
233
  // ─── Element Wrapping Helpers ───────────────────────────────────────────────
@@ -244,13 +240,12 @@ export async function resolveSlotElement(
244
240
  async function wrapWithIntermediateSegments(
245
241
  chain: ManifestSegmentNode[],
246
242
  element: React.ReactElement,
247
- paramsPromise: Promise<Record<string, string | string[]>>,
248
243
  h: CreateElementFn
249
244
  ): Promise<React.ReactElement> {
250
245
  for (let i = chain.length - 1; i > 0; i--) {
251
246
  const seg = chain[i];
252
247
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
253
- element = await wrapWithLayout(seg, element, paramsPromise, h);
248
+ element = await wrapWithLayout(seg, element, h);
254
249
  }
255
250
  return element;
256
251
  }
@@ -261,13 +256,12 @@ async function wrapWithIntermediateSegments(
261
256
  async function wrapWithLayout(
262
257
  node: ManifestSegmentNode,
263
258
  element: React.ReactElement,
264
- paramsPromise: Promise<Record<string, string | string[]>>,
265
259
  h: CreateElementFn
266
260
  ): Promise<React.ReactElement> {
267
261
  if (!node.layout) return element;
268
262
  const Layout = await loadComponent(node.layout);
269
263
  if (!Layout) return element;
270
- return h(Layout, { params: paramsPromise, children: element });
264
+ return h(Layout, { children: element });
271
265
  }
272
266
 
273
267
  /**
@@ -277,7 +271,6 @@ async function wrapWithLayout(
277
271
  async function wrapWithAccessGate(
278
272
  slotNode: ManifestSegmentNode,
279
273
  element: React.ReactElement,
280
- paramsPromise: Promise<Record<string, string | string[]>>,
281
274
  h: CreateElementFn
282
275
  ): Promise<React.ReactElement> {
283
276
  if (!slotNode.access) return element;
@@ -295,12 +288,10 @@ async function wrapWithAccessGate(
295
288
  // Extract slot name from the directory name (strip @ prefix)
296
289
  const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
297
290
 
298
- const defaultFallback = await renderDefaultFallback(slotNode, paramsPromise, h);
299
- const params = await paramsPromise;
291
+ const defaultFallback = await renderDefaultFallback(slotNode, h);
300
292
 
301
293
  return h(SlotAccessGate, {
302
294
  accessFn,
303
- params,
304
295
  DeniedComponent,
305
296
  slotName,
306
297
  createElement: h,
@@ -46,8 +46,13 @@ export type SlotElements = Map<string, ReactElement>;
46
46
  export interface TreeBuilderConfig {
47
47
  /** The matched segment chain from root to leaf. */
48
48
  segments: SegmentNode[];
49
- /** Route params extracted by the matcher (catch-all segments produce string[]). */
50
- params: Record<string, string | string[]>;
49
+ /**
50
+ * Route params extracted by the matcher (catch-all segments produce string[]).
51
+ * @deprecated Params are now accessed via rawSegmentParams() from ALS.
52
+ * This field is kept for backward compatibility but is no longer used
53
+ * by the tree builder itself.
54
+ */
55
+ params?: Record<string, string | string[]>;
51
56
  /** Loads a route file's module. */
52
57
  loadModule: ModuleLoader;
53
58
  /** React.createElement or equivalent. */
@@ -76,7 +81,6 @@ export interface TreeBuilderConfig {
76
81
  */
77
82
  export interface AccessGateProps {
78
83
  accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
79
- params: Record<string, string | string[]>;
80
84
  /** Segment name for dev logging (e.g. "authenticated", "dashboard"). */
81
85
  segmentName?: string;
82
86
  /**
@@ -102,7 +106,6 @@ export interface AccessGateProps {
102
106
  */
103
107
  export interface SlotAccessGateProps {
104
108
  accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
105
- params: Record<string, string | string[]>;
106
109
  /** The denied.tsx component (not a pre-built element). null if no denied.tsx exists. */
107
110
  DeniedComponent: ((...args: unknown[]) => unknown) | null;
108
111
  /** Slot directory name without @ prefix (e.g. "admin", "sidebar"). */
@@ -149,7 +152,7 @@ export interface TreeBuildResult {
149
152
  * Parallel slots are resolved at each layout level and composed as named props.
150
153
  */
151
154
  export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeBuildResult> {
152
- const { segments, params, loadModule, createElement, errorBoundaryComponent } = config;
155
+ const { segments, loadModule, createElement, errorBoundaryComponent } = config;
153
156
 
154
157
  if (segments.length === 0) {
155
158
  throw new Error('[timber] buildElementTree: empty segment chain');
@@ -173,8 +176,8 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
173
176
  );
174
177
  }
175
178
 
176
- // Build the page element with params prop
177
- let element: ReactElement = createElement(PageComponent, { params });
179
+ // Build the page element params are accessed via rawSegmentParams() from ALS
180
+ let element: ReactElement = createElement(PageComponent, {});
178
181
 
179
182
  // Build tree bottom-up: wrap page, then walk segments from leaf to root
180
183
  for (let i = segments.length - 1; i >= 0; i--) {
@@ -195,7 +198,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
195
198
  const accessFn = accessModule.default as AccessGateProps['accessFn'];
196
199
  element = createElement('timber:access-gate', {
197
200
  accessFn,
198
- params,
199
201
  segmentName: segment.segmentName,
200
202
  children: element,
201
203
  } satisfies AccessGateProps);
@@ -215,7 +217,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
215
217
  for (const [slotName, slotNode] of segment.slots) {
216
218
  slotProps[slotName] = await buildSlotElement(
217
219
  slotNode,
218
- params,
219
220
  loadModule,
220
221
  createElement,
221
222
  errorBoundaryComponent
@@ -225,7 +226,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
225
226
 
226
227
  element = createElement(LayoutComponent, {
227
228
  ...slotProps,
228
- params,
229
229
  children: element,
230
230
  });
231
231
  }
@@ -245,7 +245,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
245
245
  */
246
246
  async function buildSlotElement(
247
247
  slotNode: SegmentNode,
248
- params: Record<string, string | string[]>,
249
248
  loadModule: ModuleLoader,
250
249
  createElement: CreateElement,
251
250
  errorBoundaryComponent: unknown
@@ -262,10 +261,10 @@ async function buildSlotElement(
262
261
 
263
262
  // If no page, render default.tsx or null
264
263
  if (!PageComponent) {
265
- return DefaultComponent ? createElement(DefaultComponent, { params }) : null;
264
+ return DefaultComponent ? createElement(DefaultComponent, {}) : null;
266
265
  }
267
266
 
268
- let element: ReactElement = createElement(PageComponent, { params });
267
+ let element: ReactElement = createElement(PageComponent, {});
269
268
 
270
269
  // Wrap in error boundaries
271
270
  element = await wrapWithErrorBoundaries(
@@ -287,11 +286,10 @@ async function buildSlotElement(
287
286
  const DeniedComponent =
288
287
  (deniedModule?.default as ((...args: unknown[]) => ReactElement) | undefined) ?? null;
289
288
 
290
- const defaultFallback = DefaultComponent ? createElement(DefaultComponent, { params }) : null;
289
+ const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
291
290
 
292
291
  element = createElement('timber:slot-access-gate', {
293
292
  accessFn,
294
- params,
295
293
  DeniedComponent,
296
294
  slotName: slotNode.segmentName.replace(/^@/, ''),
297
295
  createElement,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared utility for merging preserved search params into a target URL.
3
+ *
4
+ * Used by both <Link> (client) and redirect() (server). Extracted to a shared
5
+ * module to avoid importing client code ('use client') from server modules.
6
+ */
7
+
8
+ /**
9
+ * Merge preserved search params from the current URL into a target href.
10
+ *
11
+ * When `preserve` is `true`, all current search params are merged.
12
+ * When `preserve` is a `string[]`, only the named params are merged.
13
+ *
14
+ * The target href's own search params take precedence — preserved params
15
+ * are only added if the target doesn't already define them.
16
+ *
17
+ * @param targetHref - The resolved target href (may already contain query string)
18
+ * @param currentSearch - The current URL's search string (e.g. "?private=access&page=2")
19
+ * @param preserve - `true` to preserve all, or `string[]` to preserve specific params
20
+ * @returns The target href with preserved search params merged in
21
+ */
22
+ export function mergePreservedSearchParams(
23
+ targetHref: string,
24
+ currentSearch: string,
25
+ preserve: true | string[]
26
+ ): string {
27
+ const currentParams = new URLSearchParams(currentSearch);
28
+ if (currentParams.size === 0) return targetHref;
29
+
30
+ // Split target into path and existing query
31
+ const qIdx = targetHref.indexOf('?');
32
+ const targetPath = qIdx >= 0 ? targetHref.slice(0, qIdx) : targetHref;
33
+ const targetParams = new URLSearchParams(qIdx >= 0 ? targetHref.slice(qIdx + 1) : '');
34
+
35
+ // Collect params to preserve (that aren't already in the target)
36
+ const merged = new URLSearchParams(targetParams);
37
+ for (const [key, value] of currentParams) {
38
+ // Only preserve if: (a) not already in target, and (b) included in preserve list
39
+ if (!targetParams.has(key)) {
40
+ if (preserve === true || preserve.includes(key)) {
41
+ merged.append(key, value);
42
+ }
43
+ }
44
+ }
45
+
46
+ const qs = merged.toString();
47
+ return qs ? `${targetPath}?${qs}` : targetPath;
48
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"request-context-CZz_T0Bc.js","names":[],"sources":["../../src/server/request-context.ts"],"sourcesContent":["/**\n * Request Context — per-request ALS store for headers() and cookies().\n *\n * Follows the same pattern as tracing.ts: a module-level AsyncLocalStorage\n * instance, public accessor functions that throw outside request scope,\n * and a framework-internal `runWithRequestContext()` to establish scope.\n *\n * See design/04-authorization.md §\"AccessContext does not include cookies or headers\"\n * and design/11-platform.md §\"AsyncLocalStorage\".\n * See design/29-cookies.md for cookie mutation semantics.\n */\n\nimport { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';\nimport { isDebug } from './debug.js';\n\n// Re-export the ALS for framework-internal consumers that need direct access.\nexport { requestContextAls };\n\n// No fallback needed — we use enterWith() instead of run() to ensure\n// the ALS context persists for the entire request lifecycle including\n// async stream consumption by React's renderToReadableStream.\n\n// ─── Public API ───────────────────────────────────────────────────────────\n\n/**\n * Returns a read-only view of the current request's headers.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n */\nexport function headers(): ReadonlyHeaders {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] headers() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.headers;\n}\n\n/**\n * Returns a cookie accessor for the current request.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n *\n * Read methods (.get, .has, .getAll) are always available and reflect\n * read-your-own-writes from .set() calls in the same request.\n *\n * Mutation methods (.set, .delete, .clear) are only available in mutable\n * contexts (middleware.ts, server actions, route.ts handlers). Calling them\n * in read-only contexts (access.ts, server components) throws.\n *\n * See design/29-cookies.md\n */\nexport function cookies(): RequestCookies {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] cookies() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n\n // Parse cookies lazily on first access\n if (!store.parsedCookies) {\n store.parsedCookies = parseCookieHeader(store.cookieHeader);\n }\n\n const map = store.parsedCookies;\n return {\n get(name: string): string | undefined {\n return map.get(name);\n },\n has(name: string): boolean {\n return map.has(name);\n },\n getAll(): Array<{ name: string; value: string }> {\n return Array.from(map.entries()).map(([name, value]) => ({ name, value }));\n },\n get size(): number {\n return map.size;\n },\n\n set(name: string, value: string, options?: CookieOptions): void {\n assertMutable(store, 'set');\n if (store.flushed) {\n if (isDebug()) {\n console.warn(\n `[timber] warn: cookies().set('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };\n store.cookieJar.set(name, { name, value, options: opts });\n // Read-your-own-writes: update the parsed cookies map\n map.set(name, value);\n },\n\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {\n assertMutable(store, 'delete');\n if (store.flushed) {\n if (isDebug()) {\n console.warn(\n `[timber] warn: cookies().delete('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n const opts: CookieOptions = {\n ...DEFAULT_COOKIE_OPTIONS,\n ...options,\n maxAge: 0,\n expires: new Date(0),\n };\n store.cookieJar.set(name, { name, value: '', options: opts });\n // Remove from read view\n map.delete(name);\n },\n\n clear(): void {\n assertMutable(store, 'clear');\n if (store.flushed) return;\n // Delete every incoming cookie\n for (const name of Array.from(map.keys())) {\n store.cookieJar.set(name, {\n name,\n value: '',\n options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0, expires: new Date(0) },\n });\n }\n map.clear();\n },\n\n toString(): string {\n return Array.from(map.entries())\n .map(([name, value]) => `${name}=${value}`)\n .join('; ');\n },\n };\n}\n\n/**\n * Returns a Promise resolving to the current request's raw URLSearchParams.\n *\n * For typed, parsed search params, import the definition from params.ts\n * and call `.load()` or `.parse()`:\n *\n * ```ts\n * import { searchParams } from './params'\n * const parsed = await searchParams.load()\n * ```\n *\n * Or explicitly:\n *\n * ```ts\n * import { rawSearchParams } from '@timber-js/app/server'\n * import { searchParams } from './params'\n * const parsed = searchParams.parse(await rawSearchParams())\n * ```\n *\n * Throws if called outside a request context.\n */\nexport function rawSearchParams(): Promise<URLSearchParams> {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] rawSearchParams() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.searchParamsPromise;\n}\n\n// ─── Types ────────────────────────────────────────────────────────────────\n\n/**\n * Read-only Headers interface. The standard Headers class is mutable;\n * this type narrows it to read-only methods. The underlying object is\n * still a Headers instance, but user code should not mutate it.\n */\nexport type ReadonlyHeaders = Pick<\n Headers,\n 'get' | 'has' | 'entries' | 'keys' | 'values' | 'forEach' | typeof Symbol.iterator\n>;\n\n/** Options for setting a cookie. See design/29-cookies.md. */\nexport interface CookieOptions {\n /** Domain scope. Default: omitted (current domain only). */\n domain?: string;\n /** URL path scope. Default: '/'. */\n path?: string;\n /** Expiration date. Mutually exclusive with maxAge. */\n expires?: Date;\n /** Max age in seconds. Mutually exclusive with expires. */\n maxAge?: number;\n /** Prevent client-side JS access. Default: true. */\n httpOnly?: boolean;\n /** Only send over HTTPS. Default: true. */\n secure?: boolean;\n /** Cross-site request policy. Default: 'lax'. */\n sameSite?: 'strict' | 'lax' | 'none';\n /** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */\n partitioned?: boolean;\n}\n\nconst DEFAULT_COOKIE_OPTIONS: CookieOptions = {\n path: '/',\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n};\n\n/**\n * Cookie accessor returned by `cookies()`.\n *\n * Read methods are always available. Mutation methods throw in read-only\n * contexts (access.ts, server components).\n */\nexport interface RequestCookies {\n /** Get a cookie value by name. Returns undefined if not present. */\n get(name: string): string | undefined;\n /** Check if a cookie exists. */\n has(name: string): boolean;\n /** Get all cookies as an array of { name, value } pairs. */\n getAll(): Array<{ name: string; value: string }>;\n /** Number of cookies. */\n readonly size: number;\n /** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */\n set(name: string, value: string, options?: CookieOptions): void;\n /** Delete a cookie. Only available in mutable contexts. */\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void;\n /** Delete all cookies. Only available in mutable contexts. */\n clear(): void;\n /** Serialize cookies as a Cookie header string. */\n toString(): string;\n}\n\n// ─── Framework-Internal Helpers ───────────────────────────────────────────\n\n/**\n * Run a callback within a request context. Used by the pipeline to establish\n * per-request ALS scope so that `headers()` and `cookies()` work.\n *\n * @param req - The incoming Request object.\n * @param fn - The function to run within the request context.\n */\nexport function runWithRequestContext<T>(req: Request, fn: () => T): T {\n const originalCopy = new Headers(req.headers);\n const store: RequestContextStore = {\n headers: freezeHeaders(req.headers),\n originalHeaders: originalCopy,\n cookieHeader: req.headers.get('cookie') ?? '',\n searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),\n cookieJar: new Map(),\n flushed: false,\n mutableContext: false,\n };\n return requestContextAls.run(store, fn);\n}\n\n/**\n * Enable cookie mutation for the current context. Called by the framework\n * when entering middleware.ts, server actions, or route.ts handlers.\n *\n * See design/29-cookies.md §\"Context Tracking\"\n */\nexport function setMutableCookieContext(mutable: boolean): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.mutableContext = mutable;\n }\n}\n\n/**\n * Mark the response as flushed (headers committed). After this point,\n * cookie mutations log a warning instead of throwing.\n *\n * See design/29-cookies.md §\"Streaming Constraint: Post-Flush Cookie Warning\"\n */\nexport function markResponseFlushed(): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.flushed = true;\n }\n}\n\n/**\n * Build a Map of cookie name → value reflecting the current request's\n * read-your-own-writes state. Includes incoming cookies plus any\n * mutations from cookies().set() / cookies().delete() in the same request.\n *\n * Used by SSR renderers to populate NavContext.cookies so that\n * useCookie()'s server snapshot matches the actual response state.\n *\n * See design/29-cookies.md §\"Read-Your-Own-Writes\"\n * See design/triage/TIM-441-cookie-api-triage.md §4\n */\nexport function getCookiesForSsr(): Map<string, string> {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error('[timber] getCookiesForSsr() called outside of a request context.');\n }\n\n // Trigger lazy parsing if not yet done\n if (!store.parsedCookies) {\n store.parsedCookies = parseCookieHeader(store.cookieHeader);\n }\n\n // The parsedCookies map already reflects read-your-own-writes:\n // - cookies().set() updates the map via map.set(name, value)\n // - cookies().delete() removes from the map via map.delete(name)\n // Return a copy so callers can't mutate the internal map.\n return new Map(store.parsedCookies);\n}\n\n/**\n * Collect all Set-Cookie headers from the cookie jar.\n * Called by the framework at flush time to apply cookies to the response.\n *\n * Returns an array of serialized Set-Cookie header values.\n */\nexport function getSetCookieHeaders(): string[] {\n const store = requestContextAls.getStore();\n if (!store) return [];\n return Array.from(store.cookieJar.values()).map(serializeCookieEntry);\n}\n\n/**\n * Apply middleware-injected request headers to the current request context.\n *\n * Called by the pipeline after middleware.ts runs. Merges overlay headers\n * on top of the original request headers so downstream code (access.ts,\n * server components, server actions) sees them via `headers()`.\n *\n * The original request headers are never mutated — a new frozen Headers\n * object is created with the overlay applied on top.\n *\n * See design/07-routing.md §\"Request Header Injection\"\n */\nexport function applyRequestHeaderOverlay(overlay: Headers): void {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error('[timber] applyRequestHeaderOverlay() called outside of a request context.');\n }\n\n // Check if the overlay has any headers — skip if empty\n let hasOverlay = false;\n overlay.forEach(() => {\n hasOverlay = true;\n });\n if (!hasOverlay) return;\n\n // Merge: start with original headers, overlay on top\n const merged = new Headers(store.originalHeaders);\n overlay.forEach((value, key) => {\n merged.set(key, value);\n });\n store.headers = freezeHeaders(merged);\n}\n\n// ─── Read-Only Headers ────────────────────────────────────────────────────\n\nconst MUTATING_METHODS = new Set(['set', 'append', 'delete']);\n\n/**\n * Wrap a Headers object in a Proxy that throws on mutating methods.\n * Object.freeze doesn't work on Headers (native internal slots), so we\n * intercept property access and reject set/append/delete at runtime.\n *\n * Read methods (get, has, entries, etc.) must be bound to the underlying\n * Headers instance because they access private #headersList slots.\n */\nfunction freezeHeaders(source: Headers): Headers {\n const copy = new Headers(source);\n return new Proxy(copy, {\n get(target, prop) {\n if (typeof prop === 'string' && MUTATING_METHODS.has(prop)) {\n return () => {\n throw new Error(\n `[timber] headers() returns a read-only Headers object. ` +\n `Calling .${prop}() is not allowed. ` +\n `Use ctx.requestHeaders in middleware to inject headers for downstream components.`\n );\n };\n }\n const value = Reflect.get(target, prop);\n // Bind methods to the real Headers instance so private slot access works\n if (typeof value === 'function') {\n return value.bind(target);\n }\n return value;\n },\n });\n}\n\n// ─── Cookie Helpers ───────────────────────────────────────────────────────\n\n/** Throw if cookie mutation is attempted in a read-only context. */\nfunction assertMutable(store: RequestContextStore, method: string): void {\n if (!store.mutableContext) {\n throw new Error(\n `[timber] cookies().${method}() cannot be called in this context.\\n` +\n ` Set cookies in middleware.ts, server actions, or route.ts handlers.`\n );\n }\n}\n\n/**\n * Parse a Cookie header string into a Map of name → value pairs.\n * Follows RFC 6265 §4.2.1: cookies are semicolon-separated key=value pairs.\n */\nfunction parseCookieHeader(header: string): Map<string, string> {\n const map = new Map<string, string>();\n if (!header) return map;\n\n for (const pair of header.split(';')) {\n const eqIndex = pair.indexOf('=');\n if (eqIndex === -1) continue;\n const name = pair.slice(0, eqIndex).trim();\n const value = pair.slice(eqIndex + 1).trim();\n if (name) {\n map.set(name, value);\n }\n }\n\n return map;\n}\n\n/** Serialize a CookieEntry into a Set-Cookie header value. */\nfunction serializeCookieEntry(entry: CookieEntry): string {\n const parts = [`${entry.name}=${entry.value}`];\n const opts = entry.options;\n\n if (opts.domain) parts.push(`Domain=${opts.domain}`);\n if (opts.path) parts.push(`Path=${opts.path}`);\n if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);\n if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);\n if (opts.httpOnly) parts.push('HttpOnly');\n if (opts.secure) parts.push('Secure');\n if (opts.sameSite) {\n parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);\n }\n if (opts.partitioned) parts.push('Partitioned');\n\n return parts.join('; ');\n}\n"],"mappings":"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"}
@@ -1,34 +0,0 @@
1
- import { createContext, createElement, useContext, useMemo } from "react";
2
- //#region src/client/segment-context.ts
3
- /**
4
- * Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.
5
- *
6
- * Each layout in the segment tree is wrapped with a SegmentProvider that stores
7
- * the URL segments from root to the current layout level. The hooks read this
8
- * context to determine which child segments are active below the calling layout.
9
- *
10
- * The context value is intentionally minimal: just the segment path array and
11
- * parallel route keys. No internal cache details are exposed.
12
- *
13
- * Design docs: design/19-client-navigation.md, design/14-ecosystem.md
14
- */
15
- var SegmentContext = createContext(null);
16
- /** Read the segment context. Returns null if no provider is above this component. */
17
- function useSegmentContext() {
18
- return useContext(SegmentContext);
19
- }
20
- /**
21
- * Wraps each layout to provide segment position context.
22
- * Injected by rsc-entry.ts during element tree construction.
23
- */
24
- function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }) {
25
- const value = useMemo(() => ({
26
- segments,
27
- parallelRouteKeys
28
- }), [segments.join("/"), parallelRouteKeys.join(",")]);
29
- return createElement(SegmentContext.Provider, { value }, children);
30
- }
31
- //#endregion
32
- export { useSegmentContext as n, SegmentProvider as t };
33
-
34
- //# sourceMappingURL=segment-context-Dpq2XOKg.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"segment-context-Dpq2XOKg.js","names":[],"sources":["../../src/client/segment-context.ts"],"sourcesContent":["/**\n * Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.\n *\n * Each layout in the segment tree is wrapped with a SegmentProvider that stores\n * the URL segments from root to the current layout level. The hooks read this\n * context to determine which child segments are active below the calling layout.\n *\n * The context value is intentionally minimal: just the segment path array and\n * parallel route keys. No internal cache details are exposed.\n *\n * Design docs: design/19-client-navigation.md, design/14-ecosystem.md\n */\n\n'use client';\n\nimport { createContext, useContext, createElement, useMemo } from 'react';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface SegmentContextValue {\n /** URL segments from root to this layout (e.g. ['', 'dashboard', 'settings']) */\n segments: string[];\n /** Parallel route slot keys available at this layout level (e.g. ['sidebar', 'modal']) */\n parallelRouteKeys: string[];\n}\n\n// ─── Context ─────────────────────────────────────────────────────\n\nconst SegmentContext = createContext<SegmentContextValue | null>(null);\n\n/** Read the segment context. Returns null if no provider is above this component. */\nexport function useSegmentContext(): SegmentContextValue | null {\n return useContext(SegmentContext);\n}\n\n// ─── Provider ────────────────────────────────────────────────────\n\ninterface SegmentProviderProps {\n segments: string[];\n /**\n * Unique identifier for this segment, used by the client-side segment\n * merger for element caching. For route groups this includes the group\n * name (e.g., \"/(marketing)\") since groups share their parent's urlPath.\n * Falls back to the reconstructed path from `segments` if not provided.\n */\n segmentId?: string;\n parallelRouteKeys: string[];\n children: React.ReactNode;\n}\n\n/**\n * Wraps each layout to provide segment position context.\n * Injected by rsc-entry.ts during element tree construction.\n */\nexport function SegmentProvider({\n segments,\n segmentId: _segmentId,\n parallelRouteKeys,\n children,\n}: SegmentProviderProps) {\n const value = useMemo(\n () => ({ segments, parallelRouteKeys }),\n // segments and parallelRouteKeys are static per layout — they don't change\n // across navigations. The layout's position in the tree is fixed.\n // Intentionally using derived keys — segments/parallelRouteKeys are static per layout\n [segments.join('/'), parallelRouteKeys.join(',')]\n );\n return createElement(SegmentContext.Provider, { value }, children);\n}\n"],"mappings":";;;;;;;;;;;;;;AA4BA,IAAM,iBAAiB,cAA0C,KAAK;;AAGtE,SAAgB,oBAAgD;AAC9D,QAAO,WAAW,eAAe;;;;;;AAsBnC,SAAgB,gBAAgB,EAC9B,UACA,WAAW,YACX,mBACA,YACuB;CACvB,MAAM,QAAQ,eACL;EAAE;EAAU;EAAmB,GAItC,CAAC,SAAS,KAAK,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAC,CAClD;AACD,QAAO,cAAc,eAAe,UAAU,EAAE,OAAO,EAAE,SAAS"}