@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
@@ -0,0 +1,260 @@
1
+ /**
2
+ * defineParams — factory for typed route param coercion.
3
+ *
4
+ * Creates a ParamsDefinition that coerces raw string params from the
5
+ * URL into typed values. Used by exporting from layout.tsx (segment-level)
6
+ * or page.tsx (fallback).
7
+ *
8
+ * Reuses the shared Codec<T> protocol with Standard Schema auto-detection,
9
+ * same pattern as defineSearchParams. Runtime constraints are stricter:
10
+ * - serialize must return string (not null — path segments can't be omitted)
11
+ * - parse throwing → 404 (invalid param value)
12
+ *
13
+ * Design doc: design/07a-route-params-triage.md
14
+ */
15
+
16
+ import type { Codec } from '#/codec.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Infer the output type from a Codec or StandardSchemaV1. */
23
+ export type InferParamField<V> =
24
+ V extends Codec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;
25
+
26
+ /** Acceptable field value for defineParams: a Codec or a Standard Schema. */
27
+ export type ParamField<T = unknown> = Codec<T> | StandardSchemaV1<T>;
28
+
29
+ /**
30
+ * A typed route params definition.
31
+ *
32
+ * Returned by defineParams(). Provides parse (string → typed) and
33
+ * serialize (typed → string) for each declared param.
34
+ */
35
+ export interface ParamsDefinition<T extends Record<string, unknown>> {
36
+ /** Parse raw string params into typed values. Throws on invalid values. */
37
+ parse(raw: Record<string, string | string[]>): T;
38
+
39
+ /** Serialize typed values back to strings for URL construction. */
40
+ serialize(values: T): Record<string, string>;
41
+
42
+ /** Read-only codec map. */
43
+ codecs: { [K in keyof T]: Codec<T[K]> };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Standard Schema interface (subset — same as in search-params/define.ts)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ interface StandardSchemaV1<Output = unknown> {
51
+ '~standard': {
52
+ validate(
53
+ value: unknown
54
+ ):
55
+ | { value: Output; issues?: undefined }
56
+ | { value?: undefined; issues: ReadonlyArray<{ message: string }> }
57
+ | Promise<
58
+ | { value: Output; issues?: undefined }
59
+ | { value?: undefined; issues: ReadonlyArray<{ message: string }> }
60
+ >;
61
+ };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Internal helpers
66
+ // ---------------------------------------------------------------------------
67
+
68
+ function isStandardSchema(value: unknown): value is StandardSchemaV1 {
69
+ return (
70
+ typeof value === 'object' &&
71
+ value !== null &&
72
+ '~standard' in value &&
73
+ typeof (value as StandardSchemaV1)['~standard']?.validate === 'function'
74
+ );
75
+ }
76
+
77
+ function isCodec(value: unknown): value is Codec<unknown> {
78
+ return (
79
+ typeof value === 'object' &&
80
+ value !== null &&
81
+ typeof (value as Codec<unknown>).parse === 'function' &&
82
+ typeof (value as Codec<unknown>).serialize === 'function'
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Validate sync for Standard Schema (same helper as search-params/codecs.ts).
88
+ */
89
+ function validateSync<Output>(
90
+ schema: StandardSchemaV1<Output>,
91
+ value: unknown
92
+ ):
93
+ | { value: Output; issues?: undefined }
94
+ | { value?: undefined; issues: ReadonlyArray<{ message: string }> } {
95
+ const result = schema['~standard'].validate(value);
96
+ if (result instanceof Promise) {
97
+ throw new Error(
98
+ '[timber] defineParams: schema returned a Promise — only sync schemas are supported.'
99
+ );
100
+ }
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * Wrap a Standard Schema into a Codec for route params.
106
+ *
107
+ * Unlike fromSchema for search params:
108
+ * - Parse throws on failure (no fallback to default)
109
+ * - Serialize returns string (not null)
110
+ */
111
+ function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
112
+ return {
113
+ parse(value: string | string[] | undefined): T {
114
+ // Route params are always strings (single segment) or string[] (catch-all)
115
+ const input = Array.isArray(value) ? value : value;
116
+
117
+ const result = validateSync(schema, input);
118
+ if (!result.issues) {
119
+ return result.value;
120
+ }
121
+
122
+ // For route params, parse failure means the param is invalid → throw
123
+ const messages = result.issues.map((i) => i.message).join(', ');
124
+ throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
125
+ },
126
+
127
+ serialize(value: T): string | null {
128
+ if (value === null || value === undefined) {
129
+ return null;
130
+ }
131
+ // Catch-all segments produce arrays — join with '/' for path reconstruction
132
+ if (Array.isArray(value)) {
133
+ return value.join('/');
134
+ }
135
+ return String(value);
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Resolve a field value to a Codec. Auto-detects Standard Schema objects.
142
+ */
143
+ function resolveField(fieldName: string, value: ParamField): Codec<unknown> {
144
+ if (isCodec(value)) {
145
+ return value;
146
+ }
147
+
148
+ if (isStandardSchema(value)) {
149
+ return fromParamSchema(fieldName, value);
150
+ }
151
+
152
+ throw new Error(
153
+ `[timber] defineParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +
154
+ `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +
155
+ `(Zod, Valibot, ArkType).`
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Validate that no codec's serialize returns null.
161
+ * Route params are structural — they must produce a valid path segment.
162
+ */
163
+ function validateSerialize(codecMap: Record<string, Codec<unknown>>): void {
164
+ for (const [key, codec] of Object.entries(codecMap)) {
165
+ // Test serialize with a sample parsed value to check for null
166
+ // We can't exhaustively test, but we can check that serialize(parse("test"))
167
+ // doesn't return null for a basic input.
168
+ try {
169
+ const testValue = codec.parse('test');
170
+ const serialized = codec.serialize(testValue);
171
+ if (serialized === null) {
172
+ throw new Error(
173
+ `[timber] defineParams: field '${key}' codec.serialize() returned null.\n` +
174
+ ` Route params are path segments — they cannot be omitted.\n` +
175
+ ` Ensure serialize() always returns a string.`
176
+ );
177
+ }
178
+ } catch (e) {
179
+ // parse('test') may throw for strict codecs (e.g., number-only).
180
+ // That's fine — it means the codec validates. We only care about
181
+ // serialize returning null, which we can't test without a valid value.
182
+ if (e instanceof Error && e.message.includes('returned null')) {
183
+ throw e;
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Factory
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Create a ParamsDefinition from a map of codecs and/or Standard Schema objects.
195
+ *
196
+ * ```ts
197
+ * // app/products/[id]/layout.tsx
198
+ * import { defineParams } from '@timber-js/app/params'
199
+ * import { z } from 'zod/v4'
200
+ *
201
+ * export const params = defineParams({
202
+ * id: z.coerce.number().int().positive(),
203
+ * })
204
+ *
205
+ * export default function Layout({ children }) { return children }
206
+ * ```
207
+ */
208
+ export function defineSegmentParams<C extends Record<string, ParamField>>(
209
+ codecs: C
210
+ ): ParamsDefinition<{ [K in keyof C]: InferParamField<C[K]> }> {
211
+ type T = { [K in keyof C]: InferParamField<C[K]> };
212
+
213
+ const resolvedCodecs: Record<string, Codec<unknown>> = {};
214
+
215
+ for (const [key, value] of Object.entries(codecs)) {
216
+ resolvedCodecs[key] = resolveField(key, value as ParamField);
217
+ }
218
+
219
+ // Validate that serialize doesn't return null
220
+ validateSerialize(resolvedCodecs);
221
+
222
+ // ---- parse ----
223
+ function parse(raw: Record<string, string | string[]>): T {
224
+ const result: Record<string, unknown> = {};
225
+
226
+ for (const [key, codec] of Object.entries(resolvedCodecs)) {
227
+ const rawValue = raw[key];
228
+ // Route params are always present (the route matched)
229
+ result[key] = codec.parse(rawValue);
230
+ }
231
+
232
+ return result as T;
233
+ }
234
+
235
+ // ---- serialize ----
236
+ function serialize(values: T): Record<string, string> {
237
+ const result: Record<string, string> = {};
238
+
239
+ for (const [key, codec] of Object.entries(resolvedCodecs)) {
240
+ const serialized = codec.serialize(values[key as keyof T] as unknown);
241
+ if (serialized === null) {
242
+ throw new Error(
243
+ `[timber] params.serialize: field '${key}' serialized to null. ` +
244
+ `Route params must produce a valid path segment.`
245
+ );
246
+ }
247
+ result[key] = serialized;
248
+ }
249
+
250
+ return result;
251
+ }
252
+
253
+ const definition: ParamsDefinition<T> = {
254
+ parse,
255
+ serialize,
256
+ codecs: resolvedCodecs as { [K in keyof T]: Codec<T[K]> },
257
+ };
258
+
259
+ return definition;
260
+ }
@@ -0,0 +1,28 @@
1
+ // @timber-js/app/params — Typed route param coercion and search param definitions
2
+ //
3
+ // This is the primary import path for both segmentParams and searchParams.
4
+ // params.ts convention files import from here.
5
+ //
6
+ // See design/07-routing.md §"params.ts Convention File"
7
+
8
+ // --- Segment params (route path param coercion) ---
9
+ export type { ParamsDefinition, InferParamField, ParamField } from './define.js';
10
+ export { defineSegmentParams } from './define.js';
11
+
12
+ // --- Search params (re-exported from search-params for convenience) ---
13
+ // This lets params.ts import both from a single path:
14
+ // import { defineSegmentParams, defineSearchParams } from '@timber-js/app/params'
15
+ export { defineSearchParams } from '#/search-params/define.js';
16
+
17
+ // --- Codec utilities (re-exported for convenience) ---
18
+ export { fromSchema, fromArraySchema } from '#/search-params/codecs.js';
19
+ export { withDefault, withUrlKey } from '#/search-params/wrappers.js';
20
+ export type { Codec } from '#/codec.js';
21
+ export type {
22
+ SearchParamCodec,
23
+ SearchParamsDefinition,
24
+ SetParams,
25
+ SetParamsOptions,
26
+ QueryStatesOptions,
27
+ CodecMap,
28
+ } from '#/search-params/define.js';
@@ -50,6 +50,12 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
50
50
  : ctx.buildManifest;
51
51
  const json = JSON.stringify(manifest);
52
52
  manifestInit = `globalThis.__TIMBER_BUILD_MANIFEST__ = ${json};\n`;
53
+
54
+ // Embed the deployment ID for version skew detection (TIM-446).
55
+ // The server reads this at startup via setDeploymentId().
56
+ if (ctx.deploymentId) {
57
+ manifestInit += `globalThis.__TIMBER_DEPLOYMENT_ID__ = ${JSON.stringify(ctx.deploymentId)};\n`;
58
+ }
53
59
  }
54
60
 
55
61
  // Strip JS from the RSC plugin's assets manifest when client JS
@@ -16,6 +16,7 @@
16
16
  * Design docs: 18-build-system.md §"Build Manifest", 02-rendering-pipeline.md §"Early Hints"
17
17
  */
18
18
 
19
+ import { randomUUID } from 'node:crypto';
19
20
  import type { Plugin, ResolvedConfig } from 'vite';
20
21
  import type { PluginContext } from '#/index.js';
21
22
  import type { BuildManifest } from '#/server/build-manifest.js';
@@ -242,6 +243,16 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
242
243
  configResolved(config: ResolvedConfig) {
243
244
  resolvedBase = config.base;
244
245
  isDev = config.command === 'serve';
246
+
247
+ // Generate a per-build deployment ID early so virtual:timber-config
248
+ // can serialize it during the load hook (which runs before generateBundle).
249
+ // A random UUID is used instead of a content hash — determinism provides
250
+ // no real benefit here since you almost never redeploy identical code,
251
+ // and a random ID avoids the timing problem where the content hash was
252
+ // only available after generateBundle (TIM-452).
253
+ if (!isDev && !ctx.deploymentId) {
254
+ ctx.deploymentId = randomUUID().replace(/-/g, '').slice(0, 16);
255
+ }
245
256
  },
246
257
 
247
258
  resolveId(id: string) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Client chunk grouping strategy for @vitejs/plugin-rsc.
3
+ *
4
+ * Groups client reference modules by layout boundary to balance route-scoped
5
+ * code splitting with HTTP request count. A constant group name would collapse
6
+ * all routes into one chunk (every page downloads every client component).
7
+ * Per-serverChunk grouping creates many sub-500B files. Layout-boundary
8
+ * grouping is the middle ground.
9
+ *
10
+ * See design/27-chunking-strategy.md, TIM-440, TIM-499.
11
+ */
12
+
13
+ /**
14
+ * Derive a chunk group name for a client reference module.
15
+ *
16
+ * Groups by the first non-group route segment under appDir so that all
17
+ * client components belonging to the same layout boundary land in one
18
+ * chunk. For example:
19
+ * - `facade:app/dashboard/settings/page.tsx` → `"client-dashboard"`
20
+ * - `facade:app/(group-a)/group-page-a/page.tsx` → `"client-group-page-a"`
21
+ * - `facade:app/layout.tsx` (root layout) → `"client-shared"`
22
+ * - `shared:...` (shared across chunks) → `"client-shared"`
23
+ *
24
+ * This balances route-scoped code splitting with HTTP request count:
25
+ * fewer chunks than per-serverChunk, but still avoids downloading every
26
+ * client component on every page.
27
+ */
28
+ export function clientChunkGroup(
29
+ meta: { id: string; normalizedId: string; serverChunk: string },
30
+ appDir: string
31
+ ): string {
32
+ const { normalizedId, serverChunk } = meta;
33
+
34
+ // Shared chunks (not associated with a single route entry) get one group.
35
+ if (serverChunk.startsWith('shared:')) return 'client-shared';
36
+
37
+ // Derive the layout boundary from the file's location relative to the
38
+ // app directory. normalizedId is root-relative (e.g. "app/dashboard/shell.tsx"
39
+ // or "src/app/dashboard/shell.tsx"). We find the "app/" prefix and walk
40
+ // segments after it to find the first non-group directory.
41
+ const relPath = normalizedId.replace(/\\/g, '/');
42
+
43
+ // Find the app directory boundary in the path. The last segment of appDir
44
+ // is the app folder name (usually "app").
45
+ const appDirName = appDir.replace(/\\/g, '/').split('/').pop() || 'app';
46
+ const appIdx = relPath.indexOf(appDirName + '/');
47
+ if (appIdx === -1) return 'client-shared';
48
+
49
+ const withinApp = relPath.slice(appIdx + appDirName.length + 1);
50
+ const segments = withinApp.split('/');
51
+
52
+ // Find first directory segment that isn't a route group like (group-a)
53
+ for (const seg of segments) {
54
+ // Skip the filename itself
55
+ if (seg.includes('.')) break;
56
+ // Skip route groups — parenthesized segments like (group-a)
57
+ if (seg.startsWith('(') && seg.endsWith(')')) continue;
58
+ // Found a real route segment
59
+ return `client-${seg}`;
60
+ }
61
+
62
+ // Root-level files (layout.tsx, page.tsx directly in app/) go into the
63
+ // shared group since the root layout is loaded on every page.
64
+ return 'client-shared';
65
+ }
@@ -95,11 +95,6 @@ function stripRootPrefix(id: string, root: string): string {
95
95
  * Serializes output mode and feature flags for runtime consumption.
96
96
  */
97
97
  function generateConfigModule(ctx: PluginContext): string {
98
- // Resolve cookie secrets: `secret` shorthand expands to `secrets: [secret]`
99
- const cookieSecrets =
100
- ctx.config.cookies?.secrets ??
101
- (ctx.config.cookies?.secret ? [ctx.config.cookies.secret] : undefined);
102
-
103
98
  const runtimeConfig = {
104
99
  output: ctx.config.output ?? 'server',
105
100
  csrf: ctx.config.csrf ?? true,
@@ -108,10 +103,12 @@ function generateConfigModule(ctx: PluginContext): string {
108
103
  dev: ctx.dev ?? false,
109
104
  slowPhaseMs: ctx.config.dev?.slowPhaseMs ?? 200,
110
105
  slowRequestMs: ctx.config.slowRequestMs ?? 3000,
111
- cookieSecrets,
112
106
  topLoader: ctx.config.topLoader,
113
107
  debug: ctx.config.debug ?? false,
114
108
  serverTiming: ctx.config.serverTiming,
109
+ // Per-build deployment ID for version skew detection (TIM-446).
110
+ // Null in dev mode — HMR handles code updates without full reloads.
111
+ deploymentId: ctx.deploymentId ?? null,
115
112
  };
116
113
 
117
114
  return [
@@ -28,7 +28,7 @@ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
28
28
  * File convention names we track for changes that require manifest regeneration.
29
29
  */
30
30
  const ROUTE_FILE_PATTERNS =
31
- /\/(page|layout|middleware|access|route|error|default|denied|search-params|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
31
+ /\/(page|layout|middleware|access|route|error|default|denied|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
32
32
 
33
33
  /**
34
34
  * Create the timber-routing Vite plugin.
@@ -168,20 +168,49 @@ export function timberRouting(ctx: PluginContext): Plugin {
168
168
  // Watch the app directory
169
169
  devServer.watcher.add(ctx.appDir);
170
170
 
171
- const handleFileChange = (filePath: string) => {
172
- // Only react to route-significant files in the app directory
171
+ /** Snapshot of the last generated manifest, used to detect structural changes. */
172
+ let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
173
+
174
+ /**
175
+ * Handle a route-significant file being added or removed.
176
+ * Always triggers a full-reload since the route tree structure changed.
177
+ */
178
+ const handleStructuralChange = (filePath: string) => {
173
179
  if (!filePath.startsWith(ctx.appDir)) return;
174
180
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
175
181
 
176
- // Rescan the route tree
177
182
  rescan();
178
-
179
- // Invalidate the virtual module in all environments
183
+ lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
180
184
  invalidateManifest(devServer);
181
185
  };
182
186
 
183
- devServer.watcher.on('add', handleFileChange);
184
- devServer.watcher.on('unlink', handleFileChange);
187
+ /**
188
+ * Handle a route file's content changing.
189
+ *
190
+ * Most content edits (JSX changes, fixing typos) don't affect route
191
+ * metadata — Vite's React Fast Refresh handles those via normal HMR.
192
+ * Only rescan and full-reload when route metadata actually changed
193
+ * (e.g., searchParams export added/removed, metadata export changed).
194
+ */
195
+ const handleContentChange = (filePath: string) => {
196
+ if (!filePath.startsWith(ctx.appDir)) return;
197
+ if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
198
+
199
+ rescan();
200
+ const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
201
+ if (newManifest !== lastManifest) {
202
+ lastManifest = newManifest;
203
+ invalidateManifest(devServer);
204
+ }
205
+ // Otherwise: content edit didn't change route metadata — let Vite HMR handle it
206
+ };
207
+
208
+ devServer.watcher.on('add', handleStructuralChange);
209
+ devServer.watcher.on('unlink', handleStructuralChange);
210
+ // Watch content changes to page files — searchParams detection depends
211
+ // on file contents (export const searchParams), not just file presence.
212
+ // But only full-reload when route metadata actually changes.
213
+ devServer.watcher.on('change', handleContentChange);
185
214
  // Also watch renames (which are add+unlink) — handled by the above
186
215
  },
187
216
  };
@@ -301,12 +330,9 @@ function generateManifestModule(tree: RouteTree): string {
301
330
  `${nextIndent}denied: { load: ${v}, filePath: ${JSON.stringify(node.denied.filePath)} },`
302
331
  );
303
332
  }
304
- if (node.searchParams) {
305
- const v = addImport(node.searchParams);
306
- parts.push(
307
- `${nextIndent}searchParams: { load: ${v}, filePath: ${JSON.stringify(node.searchParams.filePath)} },`
308
- );
309
- }
333
+ // searchParams is now a named export from page.tsx, not a separate file.
334
+ // The page module's searchParams export is loaded via the page's lazy import.
335
+ // Runtime registration happens in the route loader using the page module.
310
336
 
311
337
  // Status-code files
312
338
  if (node.statusFiles && node.statusFiles.size > 0) {
@@ -137,5 +137,36 @@ export function timberServerBundle(): Plugin[] {
137
137
  },
138
138
  };
139
139
 
140
- return [bundlePlugin, esmInitFixPlugin];
140
+ // Fix Rolldown's `createRequire(import.meta.url)` CJS interop shim for
141
+ // Cloudflare Workers. Rolldown emits this for CJS dependencies (e.g.
142
+ // @opentelemetry/context-async-hooks) that use `require()`. On Workers,
143
+ // `import.meta.url` is `undefined` for non-entry modules, causing:
144
+ // TypeError: The argument 'path' must be a file URL object, a file URL
145
+ // string, or an absolute path string. Received 'undefined'
146
+ //
147
+ // The fix: provide a fallback URL when `import.meta.url` is undefined.
148
+ // The actual URL doesn't matter — `createRequire` only needs it for
149
+ // resolving relative paths, but the only `__require()` calls are for
150
+ // Node built-ins (events, async_hooks) which resolve from any base.
151
+ //
152
+ // The top-level `ssr: { target: 'webworker' }` was supposed to prevent
153
+ // this, but it doesn't propagate to custom environments (rsc) in Vite's
154
+ // Environment API. See LOCAL-405.
155
+ const createRequireFixPlugin: Plugin = {
156
+ name: 'timber-create-require-fix',
157
+ applyToEnvironment(environment) {
158
+ return environment.name === 'rsc' || environment.name === 'ssr';
159
+ },
160
+ renderChunk(code) {
161
+ const pattern = 'createRequire(import.meta.url)';
162
+ if (!code.includes(pattern)) return null;
163
+
164
+ return {
165
+ code: code.replace(pattern, 'createRequire(import.meta.url || "file:///app")'),
166
+ map: null,
167
+ };
168
+ },
169
+ };
170
+
171
+ return [bundlePlugin, esmInitFixPlugin, createRequireFixPlugin];
141
172
  }
@@ -137,7 +137,7 @@ export function timberShims(_ctx: PluginContext): Plugin {
137
137
  // package.json exports), creating a module instance split: ssr-entry.ts
138
138
  // registers the ALS-backed SSR data provider on the src/ instance of
139
139
  // ssr-data.ts, but client component hooks read getSsrData() from the
140
- // dist/ instance — which has no provider. Result: hooks like useParams()
140
+ // dist/ instance — which has no provider. Result: hooks like useSegmentParams()
141
141
  // return empty defaults during SSR.
142
142
  //
143
143
  // This remap is SSR-only. The RSC environment still resolves to dist/
@@ -163,9 +163,6 @@ export function validateStaticMode(
163
163
  * - transform: Validates source files for static mode violations
164
164
  */
165
165
  export function timberStaticBuild(ctx: PluginContext): Plugin {
166
- const isStatic = ctx.config.output === 'static';
167
- const clientJavascriptDisabled = ctx.clientJavascript.disabled;
168
-
169
166
  return {
170
167
  name: 'timber-static-build',
171
168
 
@@ -177,6 +174,11 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
177
174
  * - When client JS disabled: 'use client' / 'use server' directives → build error
178
175
  */
179
176
  transform(code: string, id: string) {
177
+ // Read ctx.config lazily inside the hook — not at plugin construction
178
+ // time — so file-based config from timber.config.ts is respected.
179
+ // See TIM-451.
180
+ const isStatic = ctx.config.output === 'static';
181
+
180
182
  // Only active in static mode
181
183
  if (!isStatic) return null;
182
184
 
@@ -189,7 +191,9 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
189
191
  // Only check JS/TS files
190
192
  if (!/\.[jt]sx?$/.test(id)) return null;
191
193
 
192
- const errors = validateStaticMode(code, id, { clientJavascriptDisabled });
194
+ const errors = validateStaticMode(code, id, {
195
+ clientJavascriptDisabled: ctx.clientJavascript.disabled,
196
+ });
193
197
 
194
198
  if (errors.length > 0) {
195
199
  // Format all errors into a single build error message