@timber-js/app 0.2.0-alpha.34 → 0.2.0-alpha.35

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 (230) hide show
  1. package/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
  2. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
  3. package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
  4. package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
  5. package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
  6. package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
  7. package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
  8. package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
  9. package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
  10. package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
  11. package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
  12. package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
  13. package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
  14. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
  15. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  16. package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
  17. package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
  18. package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
  19. package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
  20. package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
  21. package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
  22. package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
  23. package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
  24. package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
  25. package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
  26. package/dist/_chunks/wrappers-C1SN725w.js +331 -0
  27. package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
  28. package/dist/cache/index.js +1 -1
  29. package/dist/client/error-boundary.d.ts +10 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +1 -125
  32. package/dist/client/index.d.ts +2 -2
  33. package/dist/client/index.d.ts.map +1 -1
  34. package/dist/client/index.js +193 -90
  35. package/dist/client/index.js.map +1 -1
  36. package/dist/client/link.d.ts +8 -8
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-context.d.ts +2 -2
  39. package/dist/client/router.d.ts +25 -3
  40. package/dist/client/router.d.ts.map +1 -1
  41. package/dist/client/rsc-fetch.d.ts +23 -2
  42. package/dist/client/rsc-fetch.d.ts.map +1 -1
  43. package/dist/client/segment-cache.d.ts +1 -1
  44. package/dist/client/segment-cache.d.ts.map +1 -1
  45. package/dist/client/stale-reload.d.ts +15 -0
  46. package/dist/client/stale-reload.d.ts.map +1 -1
  47. package/dist/client/top-loader.d.ts +1 -1
  48. package/dist/client/top-loader.d.ts.map +1 -1
  49. package/dist/client/use-params.d.ts +2 -2
  50. package/dist/client/use-params.d.ts.map +1 -1
  51. package/dist/client/use-query-states.d.ts +1 -1
  52. package/dist/codec.d.ts +21 -0
  53. package/dist/codec.d.ts.map +1 -0
  54. package/dist/cookies/define-cookie.d.ts +33 -12
  55. package/dist/cookies/define-cookie.d.ts.map +1 -1
  56. package/dist/cookies/index.js +1 -81
  57. package/dist/index.d.ts +87 -12
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +346 -210
  60. package/dist/index.js.map +1 -1
  61. package/dist/params/define.d.ts +76 -0
  62. package/dist/params/define.d.ts.map +1 -0
  63. package/dist/params/index.d.ts +8 -0
  64. package/dist/params/index.d.ts.map +1 -0
  65. package/dist/params/index.js +104 -0
  66. package/dist/params/index.js.map +1 -0
  67. package/dist/plugins/adapter-build.d.ts.map +1 -1
  68. package/dist/plugins/build-manifest.d.ts.map +1 -1
  69. package/dist/plugins/client-chunks.d.ts +32 -0
  70. package/dist/plugins/client-chunks.d.ts.map +1 -0
  71. package/dist/plugins/entries.d.ts.map +1 -1
  72. package/dist/plugins/routing.d.ts.map +1 -1
  73. package/dist/plugins/server-bundle.d.ts.map +1 -1
  74. package/dist/plugins/static-build.d.ts.map +1 -1
  75. package/dist/routing/codegen.d.ts +2 -2
  76. package/dist/routing/codegen.d.ts.map +1 -1
  77. package/dist/routing/index.js +1 -1
  78. package/dist/routing/scanner.d.ts.map +1 -1
  79. package/dist/routing/status-file-lint.d.ts +2 -1
  80. package/dist/routing/status-file-lint.d.ts.map +1 -1
  81. package/dist/routing/types.d.ts +6 -4
  82. package/dist/routing/types.d.ts.map +1 -1
  83. package/dist/rsc-runtime/rsc.d.ts +1 -1
  84. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  85. package/dist/search-params/codecs.d.ts +1 -1
  86. package/dist/search-params/define.d.ts +153 -0
  87. package/dist/search-params/define.d.ts.map +1 -0
  88. package/dist/search-params/index.d.ts +4 -5
  89. package/dist/search-params/index.d.ts.map +1 -1
  90. package/dist/search-params/index.js +3 -474
  91. package/dist/search-params/registry.d.ts +1 -1
  92. package/dist/search-params/wrappers.d.ts +53 -0
  93. package/dist/search-params/wrappers.d.ts.map +1 -0
  94. package/dist/server/access-gate.d.ts +4 -0
  95. package/dist/server/access-gate.d.ts.map +1 -1
  96. package/dist/server/action-encryption.d.ts +76 -0
  97. package/dist/server/action-encryption.d.ts.map +1 -0
  98. package/dist/server/action-handler.d.ts.map +1 -1
  99. package/dist/server/als-registry.d.ts +4 -4
  100. package/dist/server/als-registry.d.ts.map +1 -1
  101. package/dist/server/build-manifest.d.ts +2 -2
  102. package/dist/server/early-hints.d.ts +13 -5
  103. package/dist/server/early-hints.d.ts.map +1 -1
  104. package/dist/server/error-boundary-wrapper.d.ts +4 -0
  105. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  106. package/dist/server/flight-injection-state.d.ts +78 -0
  107. package/dist/server/flight-injection-state.d.ts.map +1 -0
  108. package/dist/server/form-data.d.ts +29 -0
  109. package/dist/server/form-data.d.ts.map +1 -1
  110. package/dist/server/html-injectors.d.ts.map +1 -1
  111. package/dist/server/index.d.ts +1 -1
  112. package/dist/server/index.d.ts.map +1 -1
  113. package/dist/server/index.js +1819 -1629
  114. package/dist/server/index.js.map +1 -1
  115. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  116. package/dist/server/pipeline.d.ts.map +1 -1
  117. package/dist/server/request-context.d.ts +28 -40
  118. package/dist/server/request-context.d.ts.map +1 -1
  119. package/dist/server/route-element-builder.d.ts +7 -0
  120. package/dist/server/route-element-builder.d.ts.map +1 -1
  121. package/dist/server/route-matcher.d.ts +2 -2
  122. package/dist/server/route-matcher.d.ts.map +1 -1
  123. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  124. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  125. package/dist/server/slot-resolver.d.ts.map +1 -1
  126. package/dist/server/ssr-entry.d.ts.map +1 -1
  127. package/dist/server/ssr-render.d.ts +3 -0
  128. package/dist/server/ssr-render.d.ts.map +1 -1
  129. package/dist/server/tree-builder.d.ts +12 -8
  130. package/dist/server/tree-builder.d.ts.map +1 -1
  131. package/dist/server/types.d.ts +1 -3
  132. package/dist/server/types.d.ts.map +1 -1
  133. package/dist/server/version-skew.d.ts +61 -0
  134. package/dist/server/version-skew.d.ts.map +1 -0
  135. package/dist/shims/navigation-client.d.ts +1 -1
  136. package/dist/shims/navigation-client.d.ts.map +1 -1
  137. package/dist/shims/navigation.d.ts +1 -1
  138. package/dist/shims/navigation.d.ts.map +1 -1
  139. package/dist/utils/state-machine.d.ts +80 -0
  140. package/dist/utils/state-machine.d.ts.map +1 -0
  141. package/package.json +12 -8
  142. package/src/client/browser-entry.ts +55 -13
  143. package/src/client/error-boundary.tsx +18 -1
  144. package/src/client/index.ts +9 -1
  145. package/src/client/link.tsx +9 -9
  146. package/src/client/navigation-context.ts +2 -2
  147. package/src/client/router.ts +102 -55
  148. package/src/client/rsc-fetch.ts +63 -2
  149. package/src/client/segment-cache.ts +1 -1
  150. package/src/client/stale-reload.ts +28 -0
  151. package/src/client/top-loader.tsx +2 -2
  152. package/src/client/use-params.ts +3 -3
  153. package/src/client/use-query-states.ts +1 -1
  154. package/src/codec.ts +21 -0
  155. package/src/cookies/define-cookie.ts +69 -18
  156. package/src/index.ts +255 -65
  157. package/src/params/define.ts +260 -0
  158. package/src/params/index.ts +28 -0
  159. package/src/plugins/adapter-build.ts +6 -0
  160. package/src/plugins/build-manifest.ts +11 -0
  161. package/src/plugins/client-chunks.ts +65 -0
  162. package/src/plugins/entries.ts +3 -6
  163. package/src/plugins/routing.ts +40 -14
  164. package/src/plugins/server-bundle.ts +32 -1
  165. package/src/plugins/shims.ts +1 -1
  166. package/src/plugins/static-build.ts +8 -4
  167. package/src/routing/codegen.ts +109 -88
  168. package/src/routing/scanner.ts +55 -6
  169. package/src/routing/status-file-lint.ts +2 -1
  170. package/src/routing/types.ts +7 -4
  171. package/src/rsc-runtime/rsc.ts +2 -0
  172. package/src/search-params/codecs.ts +1 -1
  173. package/src/search-params/define.ts +504 -0
  174. package/src/search-params/index.ts +12 -18
  175. package/src/search-params/registry.ts +1 -1
  176. package/src/search-params/wrappers.ts +85 -0
  177. package/src/server/access-gate.tsx +38 -8
  178. package/src/server/action-encryption.ts +144 -0
  179. package/src/server/action-handler.ts +16 -0
  180. package/src/server/als-registry.ts +4 -4
  181. package/src/server/build-manifest.ts +4 -4
  182. package/src/server/early-hints.ts +36 -15
  183. package/src/server/error-boundary-wrapper.ts +57 -14
  184. package/src/server/flight-injection-state.ts +152 -0
  185. package/src/server/form-data.ts +76 -0
  186. package/src/server/html-injectors.ts +42 -26
  187. package/src/server/index.ts +2 -4
  188. package/src/server/node-stream-transforms.ts +68 -41
  189. package/src/server/pipeline.ts +98 -26
  190. package/src/server/request-context.ts +49 -124
  191. package/src/server/route-element-builder.ts +102 -99
  192. package/src/server/route-matcher.ts +2 -2
  193. package/src/server/rsc-entry/error-renderer.ts +3 -2
  194. package/src/server/rsc-entry/index.ts +26 -11
  195. package/src/server/rsc-entry/rsc-payload.ts +2 -2
  196. package/src/server/rsc-entry/ssr-renderer.ts +4 -4
  197. package/src/server/slot-resolver.ts +204 -206
  198. package/src/server/ssr-entry.ts +3 -1
  199. package/src/server/ssr-render.ts +3 -0
  200. package/src/server/tree-builder.ts +84 -48
  201. package/src/server/types.ts +1 -3
  202. package/src/server/version-skew.ts +104 -0
  203. package/src/shims/navigation-client.ts +1 -1
  204. package/src/shims/navigation.ts +1 -1
  205. package/src/utils/state-machine.ts +111 -0
  206. package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
  207. package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
  208. package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
  209. package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
  210. package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
  211. package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
  212. package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
  213. package/dist/client/error-boundary.js.map +0 -1
  214. package/dist/cookies/index.js.map +0 -1
  215. package/dist/plugins/dynamic-transform.d.ts +0 -72
  216. package/dist/plugins/dynamic-transform.d.ts.map +0 -1
  217. package/dist/search-params/analyze.d.ts +0 -54
  218. package/dist/search-params/analyze.d.ts.map +0 -1
  219. package/dist/search-params/builtin-codecs.d.ts +0 -105
  220. package/dist/search-params/builtin-codecs.d.ts.map +0 -1
  221. package/dist/search-params/create.d.ts +0 -106
  222. package/dist/search-params/create.d.ts.map +0 -1
  223. package/dist/search-params/index.js.map +0 -1
  224. package/dist/server/prerender.d.ts +0 -77
  225. package/dist/server/prerender.d.ts.map +0 -1
  226. package/src/plugins/dynamic-transform.ts +0 -161
  227. package/src/search-params/analyze.ts +0 -192
  228. package/src/search-params/builtin-codecs.ts +0 -228
  229. package/src/search-params/create.ts +0 -321
  230. package/src/server/prerender.ts +0 -139
@@ -42,6 +42,8 @@ import {
42
42
  } from './logger.js';
43
43
  import { callOnRequestError } from './instrumentation.js';
44
44
  import { RedirectSignal, DenySignal } from './primitives.js';
45
+ import { ParamCoercionError } from './route-element-builder.js';
46
+ import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
45
47
  import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
46
48
  import { findInterceptionMatch } from './pipeline-interception.js';
47
49
  import type { MiddlewareContext } from './types.js';
@@ -152,6 +154,42 @@ export interface PipelineConfig {
152
154
  ) => Response | Promise<Response>;
153
155
  }
154
156
 
157
+ // ─── Param Coercion ────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Run segment param coercion on the matched route's segments.
161
+ *
162
+ * Loads params.ts modules from segments that have them, extracts the
163
+ * segmentParams definition, and coerces raw string params through codecs.
164
+ * Throws ParamCoercionError if any codec fails (→ 404).
165
+ *
166
+ * This runs BEFORE middleware, so ctx.segmentParams is already typed.
167
+ * See design/07-routing.md §"Where Coercion Runs"
168
+ */
169
+ async function coerceSegmentParams(match: RouteMatch): Promise<void> {
170
+ const segments = match.segments as unknown as import('./route-matcher.js').ManifestSegmentNode[];
171
+
172
+ for (const segment of segments) {
173
+ // Only process segments that have a params.ts convention file
174
+ if (!segment.params) continue;
175
+
176
+ const mod = (await segment.params.load()) as Record<string, unknown>;
177
+ const segmentParamsDef = mod.segmentParams as
178
+ | { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
179
+ | undefined;
180
+
181
+ if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
182
+
183
+ try {
184
+ const coerced = segmentParamsDef.parse(match.params);
185
+ // Merge coerced values back into match.params
186
+ Object.assign(match.params, coerced);
187
+ } catch (err) {
188
+ throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
189
+ }
190
+ }
191
+ }
192
+
155
193
  // ─── Pipeline ──────────────────────────────────────────────────────────────
156
194
 
157
195
  /**
@@ -286,6 +324,24 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
286
324
  }
287
325
  }
288
326
 
327
+ /**
328
+ * Build a redirect Response from a RedirectSignal.
329
+ *
330
+ * For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
331
+ * so the client router can perform a soft SPA redirect. A raw 302 would be
332
+ * turned into an opaque redirect by fetch({redirect:'manual'}), crashing
333
+ * createFromFetch. See design/19-client-navigation.md.
334
+ */
335
+ function buildRedirectResponse(signal: RedirectSignal, req: Request, headers: Headers): Response {
336
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
337
+ if (isRsc) {
338
+ headers.set('X-Timber-Redirect', signal.location);
339
+ return new Response(null, { status: 204, headers });
340
+ }
341
+ headers.set('Location', signal.location);
342
+ return new Response(null, { status: signal.status, headers });
343
+ }
344
+
289
345
  async function handleRequest(req: Request, method: string, path: string): Promise<Response> {
290
346
  // Stage 1: URL canonicalization
291
347
  const url = new URL(req.url);
@@ -342,6 +398,21 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
342
398
  }
343
399
  }
344
400
 
401
+ // Stage 1c: Version skew detection (TIM-446).
402
+ // For RSC payload requests (client navigation), check if the client's
403
+ // deployment ID matches the current build. On mismatch, signal the
404
+ // client to do a full page reload instead of returning an RSC payload
405
+ // that references mismatched module IDs.
406
+ const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
407
+ if (isRscRequest) {
408
+ const skewCheck = checkVersionSkew(req);
409
+ if (!skewCheck.ok) {
410
+ const reloadHeaders = new Headers();
411
+ applyReloadHeaders(reloadHeaders);
412
+ return new Response(null, { status: 204, headers: reloadHeaders });
413
+ }
414
+ }
415
+
345
416
  // Stage 2: Route matching
346
417
  let match = matchRoute(canonicalPathname);
347
418
  let interception: InterceptionContext | undefined;
@@ -400,18 +471,37 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
400
471
  }
401
472
  }
402
473
 
474
+ // Stage 2c: Param coercion (before middleware)
475
+ // Load params.ts modules from matched segments and coerce raw string
476
+ // params through defineSegmentParams codecs. Coercion failure → 404
477
+ // (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
478
+ try {
479
+ await coerceSegmentParams(match);
480
+ } catch (error) {
481
+ if (error instanceof ParamCoercionError) {
482
+ return new Response(null, { status: 404 });
483
+ }
484
+ throw error;
485
+ }
486
+
403
487
  // Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
404
488
  if (match.middleware) {
405
489
  const ctx: MiddlewareContext = {
406
490
  req,
407
491
  requestHeaders: requestHeaderOverlay,
408
492
  headers: responseHeaders,
409
- params: match.params,
410
- searchParams: new URL(req.url).searchParams,
493
+ segmentParams: match.params,
411
494
  earlyHints: (hints) => {
412
495
  for (const hint of hints) {
413
- let value = `<${hint.href}>; rel=${hint.rel}`;
414
- if (hint.as !== undefined) value += `; as=${hint.as}`;
496
+ // Match Cloudflare's cached Early Hints attribute order: `as` before `rel`.
497
+ // Cloudflare caches Link headers and re-emits them on subsequent 200s.
498
+ // If our order differs, the browser sees duplicate preloads and warns.
499
+ let value: string;
500
+ if (hint.as !== undefined) {
501
+ value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
502
+ } else {
503
+ value = `<${hint.href}>; rel=${hint.rel}`;
504
+ }
415
505
  if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
416
506
  if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
417
507
  responseHeaders.append('Link', value);
@@ -443,20 +533,10 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
443
533
  applyRequestHeaderOverlay(requestHeaderOverlay);
444
534
  } catch (error) {
445
535
  setMutableCookieContext(false);
446
- // RedirectSignal from middleware → HTTP redirect (not an error).
447
- // For RSC payload requests (client navigation), return 204 + X-Timber-Redirect
448
- // so the client router can perform a soft SPA redirect. A raw 302 would be
449
- // turned into an opaque redirect by fetch({redirect:'manual'}), crashing
450
- // createFromFetch. See design/19-client-navigation.md.
536
+ // RedirectSignal from middleware → HTTP redirect (not an error)
451
537
  if (error instanceof RedirectSignal) {
452
538
  applyCookieJar(responseHeaders);
453
- const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
454
- if (isRsc) {
455
- responseHeaders.set('X-Timber-Redirect', error.location);
456
- return new Response(null, { status: 204, headers: responseHeaders });
457
- }
458
- responseHeaders.set('Location', error.location);
459
- return new Response(null, { status: error.status, headers: responseHeaders });
539
+ return buildRedirectResponse(error, req, responseHeaders);
460
540
  }
461
541
  // DenySignal from middleware → HTTP deny status
462
542
  if (error instanceof DenySignal) {
@@ -493,17 +573,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
493
573
  if (error instanceof DenySignal) {
494
574
  return new Response(null, { status: error.status });
495
575
  }
496
- // RedirectSignal leaked from render — honour the redirect.
497
- // For RSC payload requests, return 204 + X-Timber-Redirect so the
498
- // client router can perform a soft SPA redirect (same as middleware path).
576
+ // RedirectSignal leaked from render — honour the redirect
499
577
  if (error instanceof RedirectSignal) {
500
- const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
501
- if (isRsc) {
502
- responseHeaders.set('X-Timber-Redirect', error.location);
503
- return new Response(null, { status: 204, headers: responseHeaders });
504
- }
505
- responseHeaders.set('Location', error.location);
506
- return new Response(null, { status: error.status, headers: responseHeaders });
578
+ return buildRedirectResponse(error, req, responseHeaders);
507
579
  }
508
580
  logRenderError({ method, path, error });
509
581
  await fireOnRequestError(error, req, 'render');
@@ -10,8 +10,6 @@
10
10
  * See design/29-cookies.md for cookie mutation semantics.
11
11
  */
12
12
 
13
- import { createHmac, timingSafeEqual } from 'node:crypto';
14
- import type { Routes } from '#/index.js';
15
13
  import { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';
16
14
  import { isDebug } from './debug.js';
17
15
 
@@ -22,30 +20,6 @@ export { requestContextAls };
22
20
  // the ALS context persists for the entire request lifecycle including
23
21
  // async stream consumption by React's renderToReadableStream.
24
22
 
25
- // ─── Cookie Signing Secrets ──────────────────────────────────────────────
26
-
27
- /**
28
- * Module-level cookie signing secrets. Index 0 is the newest (used for signing).
29
- * All entries are tried for verification (key rotation support).
30
- *
31
- * Set by the framework at startup via `setCookieSecrets()`.
32
- * See design/29-cookies.md §"Signed Cookies"
33
- */
34
- let _cookieSecrets: string[] = [];
35
-
36
- /**
37
- * Configure the cookie signing secrets.
38
- *
39
- * Called by the framework during server initialization with values from
40
- * `cookies.secret` or `cookies.secrets` in timber.config.ts.
41
- *
42
- * The first secret (index 0) is used for signing new cookies.
43
- * All secrets are tried for verification (supports key rotation).
44
- */
45
- export function setCookieSecrets(secrets: string[]): void {
46
- _cookieSecrets = secrets.filter(Boolean);
47
- }
48
-
49
23
  // ─── Public API ───────────────────────────────────────────────────────────
50
24
 
51
25
  /**
@@ -109,12 +83,6 @@ export function cookies(): RequestCookies {
109
83
  return map.size;
110
84
  },
111
85
 
112
- getSigned(name: string): string | undefined {
113
- const raw = map.get(name);
114
- if (!raw || _cookieSecrets.length === 0) return undefined;
115
- return verifySignedCookie(raw, _cookieSecrets);
116
- },
117
-
118
86
  set(name: string, value: string, options?: CookieOptions): void {
119
87
  assertMutable(store, 'set');
120
88
  if (store.flushed) {
@@ -127,21 +95,10 @@ export function cookies(): RequestCookies {
127
95
  }
128
96
  return;
129
97
  }
130
- let storedValue = value;
131
- if (options?.signed) {
132
- if (_cookieSecrets.length === 0) {
133
- throw new Error(
134
- `[timber] cookies().set('${name}', ..., { signed: true }) requires ` +
135
- `cookies.secret or cookies.secrets in timber.config.ts.`
136
- );
137
- }
138
- storedValue = signCookieValue(value, _cookieSecrets[0]);
139
- }
140
98
  const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };
141
- store.cookieJar.set(name, { name, value: storedValue, options: opts });
142
- // Read-your-own-writes: update the parsed cookies map with the signed value
143
- // so getSigned() can verify it in the same request
144
- map.set(name, storedValue);
99
+ store.cookieJar.set(name, { name, value, options: opts });
100
+ // Read-your-own-writes: update the parsed cookies map
101
+ map.set(name, value);
145
102
  },
146
103
 
147
104
  delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {
@@ -190,43 +147,37 @@ export function cookies(): RequestCookies {
190
147
  }
191
148
 
192
149
  /**
193
- * Returns a Promise resolving to the current request's search params.
150
+ * Returns a Promise resolving to the current request's raw URLSearchParams.
151
+ *
152
+ * For typed, parsed search params, import the definition from params.ts
153
+ * and call `.load()` or `.parse()`:
194
154
  *
195
- * In `page.tsx`, `middleware.ts`, and `access.ts` the framework pre-parses the
196
- * route's `search-params.ts` definition and the Promise resolves to the typed
197
- * object. In all other server component contexts it resolves to raw
198
- * `URLSearchParams`.
155
+ * ```ts
156
+ * import { searchParams } from './params'
157
+ * const parsed = await searchParams.load()
158
+ * ```
199
159
  *
200
- * Returned as a Promise to match the `params` prop convention and to allow
201
- * future partial pre-rendering support where param resolution may be deferred.
160
+ * Or explicitly:
161
+ *
162
+ * ```ts
163
+ * import { rawSearchParams } from '@timber-js/app/server'
164
+ * import { searchParams } from './params'
165
+ * const parsed = searchParams.parse(await rawSearchParams())
166
+ * ```
202
167
  *
203
168
  * Throws if called outside a request context.
204
169
  */
205
- export function searchParams<R extends keyof Routes>(): Promise<Routes[R]['searchParams']>;
206
- export function searchParams(): Promise<URLSearchParams | Record<string, unknown>>;
207
- export function searchParams(): Promise<URLSearchParams | Record<string, unknown>> {
170
+ export function rawSearchParams(): Promise<URLSearchParams> {
208
171
  const store = requestContextAls.getStore();
209
172
  if (!store) {
210
173
  throw new Error(
211
- '[timber] searchParams() called outside of a request context. ' +
174
+ '[timber] rawSearchParams() called outside of a request context. ' +
212
175
  'It can only be used in middleware, access checks, server components, and server actions.'
213
176
  );
214
177
  }
215
178
  return store.searchParamsPromise;
216
179
  }
217
180
 
218
- /**
219
- * Replace the search params Promise for the current request with one that
220
- * resolves to the typed parsed result from the route's search-params.ts.
221
- * Called by the framework before rendering the page — not for app code.
222
- */
223
- export function setParsedSearchParams(parsed: Record<string, unknown>): void {
224
- const store = requestContextAls.getStore();
225
- if (store) {
226
- store.searchParamsPromise = Promise.resolve(parsed);
227
- }
228
- }
229
-
230
181
  // ─── Types ────────────────────────────────────────────────────────────────
231
182
 
232
183
  /**
@@ -257,12 +208,6 @@ export interface CookieOptions {
257
208
  sameSite?: 'strict' | 'lax' | 'none';
258
209
  /** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */
259
210
  partitioned?: boolean;
260
- /**
261
- * Sign the cookie value with HMAC-SHA256 for integrity verification.
262
- * Requires `cookies.secret` or `cookies.secrets` in timber.config.ts.
263
- * See design/29-cookies.md §"Signed Cookies".
264
- */
265
- signed?: boolean;
266
211
  }
267
212
 
268
213
  const DEFAULT_COOKIE_OPTIONS: CookieOptions = {
@@ -287,14 +232,6 @@ export interface RequestCookies {
287
232
  getAll(): Array<{ name: string; value: string }>;
288
233
  /** Number of cookies. */
289
234
  readonly size: number;
290
- /**
291
- * Get a signed cookie value, verifying its HMAC-SHA256 signature.
292
- * Returns undefined if the cookie is missing, the signature is invalid,
293
- * or no secrets are configured. Never throws.
294
- *
295
- * See design/29-cookies.md §"Signed Cookies"
296
- */
297
- getSigned(name: string): string | undefined;
298
235
  /** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */
299
236
  set(name: string, value: string, options?: CookieOptions): void;
300
237
  /** Delete a cookie. Only available in mutable contexts. */
@@ -354,6 +291,35 @@ export function markResponseFlushed(): void {
354
291
  }
355
292
  }
356
293
 
294
+ /**
295
+ * Build a Map of cookie name → value reflecting the current request's
296
+ * read-your-own-writes state. Includes incoming cookies plus any
297
+ * mutations from cookies().set() / cookies().delete() in the same request.
298
+ *
299
+ * Used by SSR renderers to populate NavContext.cookies so that
300
+ * useCookie()'s server snapshot matches the actual response state.
301
+ *
302
+ * See design/29-cookies.md §"Read-Your-Own-Writes"
303
+ * See design/triage/TIM-441-cookie-api-triage.md §4
304
+ */
305
+ export function getCookiesForSsr(): Map<string, string> {
306
+ const store = requestContextAls.getStore();
307
+ if (!store) {
308
+ throw new Error('[timber] getCookiesForSsr() called outside of a request context.');
309
+ }
310
+
311
+ // Trigger lazy parsing if not yet done
312
+ if (!store.parsedCookies) {
313
+ store.parsedCookies = parseCookieHeader(store.cookieHeader);
314
+ }
315
+
316
+ // The parsedCookies map already reflects read-your-own-writes:
317
+ // - cookies().set() updates the map via map.set(name, value)
318
+ // - cookies().delete() removes from the map via map.delete(name)
319
+ // Return a copy so callers can't mutate the internal map.
320
+ return new Map(store.parsedCookies);
321
+ }
322
+
357
323
  /**
358
324
  * Collect all Set-Cookie headers from the cookie jar.
359
325
  * Called by the framework at flush time to apply cookies to the response.
@@ -467,47 +433,6 @@ function parseCookieHeader(header: string): Map<string, string> {
467
433
  return map;
468
434
  }
469
435
 
470
- // ─── Cookie Signing ──────────────────────────────────────────────────────
471
-
472
- /**
473
- * Sign a cookie value with HMAC-SHA256.
474
- * Returns `value.hex_signature`.
475
- */
476
- function signCookieValue(value: string, secret: string): string {
477
- const signature = createHmac('sha256', secret).update(value).digest('hex');
478
- return `${value}.${signature}`;
479
- }
480
-
481
- /**
482
- * Verify a signed cookie value against an array of secrets.
483
- * Returns the original value if any secret produces a matching signature,
484
- * or undefined if none match. Uses timing-safe comparison.
485
- *
486
- * The signed format is `value.hex_signature` — split at the last `.`.
487
- */
488
- function verifySignedCookie(raw: string, secrets: string[]): string | undefined {
489
- const lastDot = raw.lastIndexOf('.');
490
- if (lastDot <= 0 || lastDot === raw.length - 1) return undefined;
491
-
492
- const value = raw.slice(0, lastDot);
493
- const signature = raw.slice(lastDot + 1);
494
-
495
- // Hex-encoded SHA-256 is always 64 chars
496
- if (signature.length !== 64) return undefined;
497
-
498
- const signatureBuffer = Buffer.from(signature, 'hex');
499
- // If the hex decode produced fewer bytes, the signature was not valid hex
500
- if (signatureBuffer.length !== 32) return undefined;
501
-
502
- for (const secret of secrets) {
503
- const expected = createHmac('sha256', secret).update(value).digest();
504
- if (timingSafeEqual(expected, signatureBuffer)) {
505
- return value;
506
- }
507
- }
508
- return undefined;
509
- }
510
-
511
436
  /** Serialize a CookieEntry into a Set-Cookie header value. */
512
437
  function serializeCookieEntry(entry: CookieEntry): string {
513
438
  const parts = [`${entry.name}=${entry.value}`];