@timber-js/app 0.2.0-alpha.110 → 0.2.0-alpha.112

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 (156) hide show
  1. package/dist/_chunks/{actions-D6gyxlGQ.js → actions-ILhDUzES.js} +46 -10
  2. package/dist/_chunks/actions-ILhDUzES.js.map +1 -0
  3. package/dist/_chunks/{build-output-helper-5TK2Emwp.js → build-output-helper-DhxiT18k.js} +1 -1
  4. package/dist/_chunks/{build-output-helper-5TK2Emwp.js.map → build-output-helper-DhxiT18k.js.map} +1 -1
  5. package/dist/_chunks/cli-schema-sync-BhXdbmgS.js +2 -0
  6. package/dist/_chunks/cli-schema-sync-DFZmnmqG.js +357 -0
  7. package/dist/_chunks/cli-schema-sync-DFZmnmqG.js.map +1 -0
  8. package/dist/_chunks/codec-registry-V7ARyeLN.js +21 -0
  9. package/dist/_chunks/codec-registry-V7ARyeLN.js.map +1 -0
  10. package/dist/_chunks/{define-Dn98bPbb.js → define-8zQ8Vm7x.js} +3 -3
  11. package/dist/_chunks/{define-Dn98bPbb.js.map → define-8zQ8Vm7x.js.map} +1 -1
  12. package/dist/_chunks/{define-BflNsrwD.js → define-a0CCE6Nx.js} +3 -3
  13. package/dist/_chunks/{define-BflNsrwD.js.map → define-a0CCE6Nx.js.map} +1 -1
  14. package/dist/_chunks/{format-CYBGxKtc.js → format-CR6v1pWE.js} +1 -1
  15. package/dist/_chunks/{format-CYBGxKtc.js.map → format-CR6v1pWE.js.map} +1 -1
  16. package/dist/_chunks/{handler-store-B_df5Npe.js → handler-store-iqnt2ghN.js} +1 -1
  17. package/dist/_chunks/{handler-store-B_df5Npe.js.map → handler-store-iqnt2ghN.js.map} +1 -1
  18. package/dist/_chunks/{merge-search-params-Cm_KIWDX.js → merge-search-params-CwxJzBLS.js} +1 -1
  19. package/dist/_chunks/{merge-search-params-Cm_KIWDX.js.map → merge-search-params-CwxJzBLS.js.map} +1 -1
  20. package/dist/_chunks/{navigation-root-2ypaZQDx.js → navigation-root-CY3pRMxF.js} +2 -2
  21. package/dist/_chunks/{navigation-root-2ypaZQDx.js.map → navigation-root-CY3pRMxF.js.map} +1 -1
  22. package/dist/_chunks/{registry-DB93kT5c.js → registry-Dp_hJL4s.js} +1 -1
  23. package/dist/_chunks/{registry-DB93kT5c.js.map → registry-Dp_hJL4s.js.map} +1 -1
  24. package/dist/_chunks/resolve-schema-DO8B0mO9.js +65 -0
  25. package/dist/_chunks/resolve-schema-DO8B0mO9.js.map +1 -0
  26. package/dist/_chunks/{router-ref-TkUdmqtM.js → router-ref-sektkr-t.js} +2 -2
  27. package/dist/_chunks/{router-ref-TkUdmqtM.js.map → router-ref-sektkr-t.js.map} +1 -1
  28. package/dist/_chunks/scanner-DmfoB7iK.js +336 -0
  29. package/dist/_chunks/scanner-DmfoB7iK.js.map +1 -0
  30. package/dist/_chunks/{schema-bridge-C3xl_vfb.js → schema-bridge-eL5vQDSA.js} +1 -1
  31. package/dist/_chunks/{schema-bridge-C3xl_vfb.js.map → schema-bridge-eL5vQDSA.js.map} +1 -1
  32. package/dist/_chunks/{segment-context-Dc-GbSTe.js → segment-context-EsfQpS--.js} +1 -1
  33. package/dist/_chunks/{segment-context-Dc-GbSTe.js.map → segment-context-EsfQpS--.js.map} +1 -1
  34. package/dist/_chunks/{ssr-data-DzuI0bIV.js → ssr-data-BO5OvEvg.js} +1 -1
  35. package/dist/_chunks/{ssr-data-DzuI0bIV.js.map → ssr-data-BO5OvEvg.js.map} +1 -1
  36. package/dist/_chunks/{stale-reload-D6FlD3xn.js → stale-reload-BCPRG0Cv.js} +1 -1
  37. package/dist/_chunks/{stale-reload-D6FlD3xn.js.map → stale-reload-BCPRG0Cv.js.map} +1 -1
  38. package/dist/_chunks/tracing-DvKLzCFa.js.map +1 -1
  39. package/dist/_chunks/{use-query-states-B-cLl8mx.js → use-query-states-D9SBlAfp.js} +2 -2
  40. package/dist/_chunks/{use-query-states-B-cLl8mx.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
  41. package/dist/_chunks/{use-segment-params-07V1TIxL.js → use-segment-params-BSt_O2cb.js} +2 -2
  42. package/dist/_chunks/{use-segment-params-07V1TIxL.js.map → use-segment-params-BSt_O2cb.js.map} +1 -1
  43. package/dist/_chunks/{validation-DfyuMoK2.js → validation-Bm3Je-09.js} +1 -1
  44. package/dist/_chunks/{validation-DfyuMoK2.js.map → validation-Bm3Je-09.js.map} +1 -1
  45. package/dist/_chunks/{walkers-DQn5NNrf.js → walkers-DGTmJT0f.js} +109 -353
  46. package/dist/_chunks/walkers-DGTmJT0f.js.map +1 -0
  47. package/dist/adapters/cloudflare.js +1 -1
  48. package/dist/adapters/nitro.js +1 -1
  49. package/dist/cache/index.js +3 -2
  50. package/dist/cache/index.js.map +1 -1
  51. package/dist/cache/sizeof.d.ts.map +1 -1
  52. package/dist/cli-schema-sync.d.ts +37 -0
  53. package/dist/cli-schema-sync.d.ts.map +1 -0
  54. package/dist/cli.d.ts +8 -1
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +34 -3
  57. package/dist/cli.js.map +1 -1
  58. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  59. package/dist/client/error-boundary.js +5 -3
  60. package/dist/client/error-boundary.js.map +1 -1
  61. package/dist/client/index.js +27 -9
  62. package/dist/client/index.js.map +1 -1
  63. package/dist/client/internal.js +7 -7
  64. package/dist/client/link.d.ts.map +1 -1
  65. package/dist/codec.js +1 -1
  66. package/dist/cookies/index.js +3 -3
  67. package/dist/dev-tools/browser-logs.d.ts.map +1 -1
  68. package/dist/dev-tools/error-page.d.ts.map +1 -1
  69. package/dist/dev-tools/logs.d.ts.map +1 -1
  70. package/dist/dev-tools/overlay.d.ts +1 -1
  71. package/dist/dev-tools/overlay.d.ts.map +1 -1
  72. package/dist/index.d.ts +1 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +98 -236
  75. package/dist/index.js.map +1 -1
  76. package/dist/params/codec-registry.d.ts +26 -0
  77. package/dist/params/codec-registry.d.ts.map +1 -0
  78. package/dist/params/define-schema.d.ts +52 -0
  79. package/dist/params/define-schema.d.ts.map +1 -0
  80. package/dist/params/index.d.ts +5 -0
  81. package/dist/params/index.d.ts.map +1 -0
  82. package/dist/params/index.js +33 -0
  83. package/dist/params/index.js.map +1 -0
  84. package/dist/params/resolve-schema.d.ts +34 -0
  85. package/dist/params/resolve-schema.d.ts.map +1 -0
  86. package/dist/plugins/mdx.d.ts.map +1 -1
  87. package/dist/plugins/routing.d.ts.map +1 -1
  88. package/dist/routing/codegen-shared.d.ts +8 -0
  89. package/dist/routing/codegen-shared.d.ts.map +1 -1
  90. package/dist/routing/codegen-types.d.ts +5 -0
  91. package/dist/routing/codegen-types.d.ts.map +1 -1
  92. package/dist/routing/codegen.d.ts +5 -0
  93. package/dist/routing/codegen.d.ts.map +1 -1
  94. package/dist/routing/index.js +2 -1
  95. package/dist/routing/link-codegen.d.ts +2 -2
  96. package/dist/routing/link-codegen.d.ts.map +1 -1
  97. package/dist/search-params/index.js +3 -3
  98. package/dist/segment-params/index.js +1 -1
  99. package/dist/server/als-registry.d.ts +7 -0
  100. package/dist/server/als-registry.d.ts.map +1 -1
  101. package/dist/server/coalesce-tick.d.ts +40 -0
  102. package/dist/server/coalesce-tick.d.ts.map +1 -0
  103. package/dist/server/fallback-error.d.ts.map +1 -1
  104. package/dist/server/html-injectors.d.ts.map +1 -1
  105. package/dist/server/index.js +2 -2
  106. package/dist/server/internal.js +30 -4
  107. package/dist/server/internal.js.map +1 -1
  108. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  109. package/dist/server/param-coercion.d.ts +12 -2
  110. package/dist/server/param-coercion.d.ts.map +1 -1
  111. package/dist/server/pipeline-phases.d.ts.map +1 -1
  112. package/dist/server/pipeline.d.ts +18 -0
  113. package/dist/server/pipeline.d.ts.map +1 -1
  114. package/dist/server/request-context.d.ts +17 -3
  115. package/dist/server/request-context.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  117. package/dist/server/rsc-entry/index.d.ts +8 -14
  118. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  119. package/package.json +11 -6
  120. package/src/cache/sizeof.ts +3 -1
  121. package/src/cli-schema-sync.ts +206 -0
  122. package/src/cli.ts +49 -2
  123. package/src/client/browser-entry/action-dispatch.ts +15 -5
  124. package/src/client/browser-entry/index.ts +6 -0
  125. package/src/client/error-boundary.tsx +2 -2
  126. package/src/client/link.tsx +25 -1
  127. package/src/dev-tools/browser-logs.ts +5 -2
  128. package/src/dev-tools/error-page.ts +3 -6
  129. package/src/dev-tools/logs.ts +6 -2
  130. package/src/dev-tools/overlay.ts +18 -5
  131. package/src/index.ts +5 -0
  132. package/src/params/codec-registry.ts +34 -0
  133. package/src/params/define-schema.ts +72 -0
  134. package/src/params/index.ts +11 -0
  135. package/src/params/resolve-schema.ts +88 -0
  136. package/src/plugins/dev-server.ts +24 -16
  137. package/src/plugins/mdx.ts +5 -1
  138. package/src/plugins/routing.ts +107 -2
  139. package/src/routing/codegen-shared.ts +11 -0
  140. package/src/routing/codegen-types.ts +5 -0
  141. package/src/routing/codegen.ts +129 -21
  142. package/src/routing/link-codegen.ts +19 -5
  143. package/src/server/als-registry.ts +7 -0
  144. package/src/server/coalesce-tick.ts +75 -0
  145. package/src/server/fallback-error.ts +3 -3
  146. package/src/server/html-injectors.ts +11 -22
  147. package/src/server/node-stream-transforms.ts +6 -16
  148. package/src/server/param-coercion.ts +55 -2
  149. package/src/server/pipeline-phases.ts +8 -0
  150. package/src/server/pipeline.ts +19 -0
  151. package/src/server/request-context.ts +58 -3
  152. package/src/server/rsc-entry/helpers.ts +3 -2
  153. package/src/server/rsc-entry/index.ts +28 -35
  154. package/LICENSE +0 -8
  155. package/dist/_chunks/actions-D6gyxlGQ.js.map +0 -1
  156. package/dist/_chunks/walkers-DQn5NNrf.js.map +0 -1
@@ -9,10 +9,12 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
12
13
  import type { RouteTree, SegmentNode } from './types.js';
13
14
  import type { ParamEntry, RouteEntry } from './codegen-types.js';
14
15
  import {
15
16
  buildCodecChainType,
17
+ buildSchemaParamType,
16
18
  emitResolveSegmentFieldHelper,
17
19
  formatSearchParamsType,
18
20
  } from './codegen-shared.js';
@@ -28,6 +30,11 @@ export interface CodegenOptions {
28
30
  * Defaults to appDir when not provided (preserves backward compat for tests).
29
31
  */
30
32
  outputDir?: string;
33
+ /**
34
+ * Whether a global schema (app/schema.ts) exists.
35
+ * Auto-detected from appDir when not explicitly set.
36
+ */
37
+ hasSchema?: boolean;
31
38
  }
32
39
 
33
40
  /**
@@ -37,8 +44,11 @@ export interface CodegenOptions {
37
44
  * interface that maps every route path to its params and searchParams shape.
38
45
  */
39
46
  export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}): string {
47
+ // Auto-detect schema from appDir when not explicitly set
48
+ const hasSchema = options.hasSchema ?? detectSchema(options.appDir);
49
+
40
50
  const routes: RouteEntry[] = [];
41
- collectRoutes(tree.root, [], [], routes);
51
+ collectRoutes(tree.root, [], [], routes, hasSchema);
42
52
 
43
53
  // Sort routes alphabetically for deterministic output
44
54
  routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
@@ -46,7 +56,18 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
46
56
  // When outputDir differs from appDir, import paths must be relative to outputDir
47
57
  const importBase = options.outputDir ?? options.appDir;
48
58
 
49
- return formatDeclarationFile(routes, importBase);
59
+ return formatDeclarationFile(routes, importBase, hasSchema);
60
+ }
61
+
62
+ /** Schema file extensions to check, in priority order. */
63
+ const SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
64
+
65
+ /**
66
+ * Detect whether app/schema.{ts,tsx,js,jsx} exists.
67
+ */
68
+ function detectSchema(appDir: string | undefined): boolean {
69
+ if (!appDir) return false;
70
+ return SCHEMA_EXTENSIONS.some((ext) => existsSync(join(appDir, `schema${ext}`)));
50
71
  }
51
72
 
52
73
  /**
@@ -59,7 +80,8 @@ function collectRoutes(
59
80
  node: SegmentNode,
60
81
  ancestorParams: ParamEntry[],
61
82
  ancestorParamsFiles: string[],
62
- routes: RouteEntry[]
83
+ routes: RouteEntry[],
84
+ hasSchema: boolean
63
85
  ): void {
64
86
  // TIM-834: Identify this segment's own params.ts (if it has a
65
87
  // segmentParams export). The full chain of params.ts files in the
@@ -83,6 +105,7 @@ function collectRoutes(
83
105
  const legacyFallback = ownParamsFile ? undefined : findLegacyParamsExport(node);
84
106
  params.push({
85
107
  name: node.paramName,
108
+ bracketName: node.segmentName,
86
109
  type: paramTypeForSegment(node.segmentType),
87
110
  // Codec chain populated at leaf time. We carry the per-segment
88
111
  // legacy fallback (if any) so leaf-time resolution can fall back
@@ -117,6 +140,7 @@ function collectRoutes(
117
140
  : undefined;
118
141
  return {
119
142
  name: p.name,
143
+ bracketName: p.bracketName,
120
144
  type: p.type,
121
145
  codecFilePaths,
122
146
  };
@@ -127,6 +151,7 @@ function collectRoutes(
127
151
  params: resolvedParams,
128
152
  hasSearchParams: false,
129
153
  isApiRoute,
154
+ hasSchema,
130
155
  };
131
156
 
132
157
  // Detect searchParams export from params.ts (primary) or page.tsx (fallback)
@@ -145,12 +170,12 @@ function collectRoutes(
145
170
 
146
171
  // Recurse into children
147
172
  for (const child of node.children) {
148
- collectRoutes(child, params, nextAncestorFiles, routes);
173
+ collectRoutes(child, params, nextAncestorFiles, routes, hasSchema);
149
174
  }
150
175
 
151
176
  // Recurse into slots (they share the parent's URL path, but may have their own pages)
152
177
  for (const slot of Object.values(node.slots)) {
153
- collectRoutes(slot, params, nextAncestorFiles, routes);
178
+ collectRoutes(slot, params, nextAncestorFiles, routes, hasSchema);
154
179
  }
155
180
  }
156
181
 
@@ -205,10 +230,71 @@ function findLegacyParamsExport(node: SegmentNode): string | undefined {
205
230
  return undefined;
206
231
  }
207
232
 
233
+ /**
234
+ * Emit the schema-based type exports: TimberSegmentParams, TimberRoute,
235
+ * AllSegmentParams, RoutesWithParams.
236
+ *
237
+ * These types use InferCodecType references so they stay in sync with
238
+ * the schema without hardcoding resolved types.
239
+ *
240
+ * Design doc: design/41-global-params.md §Codegen Output
241
+ */
242
+ function formatSchemaTypes(routes: RouteEntry[]): string[] {
243
+ const lines: string[] = [];
244
+
245
+ lines.push('/** Union of all valid route patterns. */');
246
+ lines.push('export type TimberRoute = keyof TimberSegmentParams;');
247
+ lines.push('');
248
+ lines.push('/** Maps each route pattern to its accumulated segment params. */');
249
+ lines.push('export interface TimberSegmentParams {');
250
+
251
+ for (const route of routes) {
252
+ if (route.params.length === 0) {
253
+ lines.push(` '${route.urlPath}': {};`);
254
+ } else {
255
+ lines.push(` '${route.urlPath}': {`);
256
+ for (const p of route.params) {
257
+ lines.push(` ${p.name}: ${buildSchemaParamType(p)};`);
258
+ }
259
+ lines.push(' };');
260
+ }
261
+ }
262
+
263
+ lines.push('}');
264
+ lines.push('');
265
+
266
+ // AllSegmentParams — flat union of all param types across all routes
267
+ lines.push('/** All segment params (union of all route params). */');
268
+ lines.push('export type AllSegmentParams = {');
269
+ lines.push(
270
+ ' [K in keyof TimberSegmentParams as TimberSegmentParams[K] extends Record<string, never>'
271
+ );
272
+ lines.push(' ? never');
273
+ lines.push(' : K]: TimberSegmentParams[K];');
274
+ lines.push('} extends infer U');
275
+ lines.push(' ? { [K in keyof U[keyof U]]: U[keyof U][K] }');
276
+ lines.push(' : never;');
277
+ lines.push('');
278
+
279
+ // RoutesWithParams — filter routes by required param keys
280
+ lines.push(
281
+ '/** Filters TimberRoute to routes whose accumulated params include all listed keys. */'
282
+ );
283
+ lines.push('export type RoutesWithParams<K extends keyof AllSegmentParams> = {');
284
+ lines.push(' [R in TimberRoute]: K extends keyof TimberSegmentParams[R] ? R : never;');
285
+ lines.push('}[TimberRoute];');
286
+
287
+ return lines;
288
+ }
289
+
208
290
  /**
209
291
  * Format the collected routes into a TypeScript declaration file.
210
292
  */
211
- function formatDeclarationFile(routes: RouteEntry[], importBase?: string): string {
293
+ function formatDeclarationFile(
294
+ routes: RouteEntry[],
295
+ importBase?: string,
296
+ hasSchema?: boolean
297
+ ): string {
212
298
  const lines: string[] = [];
213
299
 
214
300
  lines.push('// This file is auto-generated by timber.js route map codegen.');
@@ -221,16 +307,29 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
221
307
  lines.push('export {};');
222
308
  lines.push('');
223
309
 
224
- // TIM-834 P2: emit the shared codec-resolution helper type ONCE so the
225
- // per-param chain conditionals reference it instead of inlining the
226
- // fallback in both branches (which grows O(2^N) in chain depth).
227
- lines.push(emitResolveSegmentFieldHelper());
228
- lines.push('');
310
+ if (hasSchema) {
311
+ // Schema-based codegen: import schema and InferCodecType
312
+ lines.push("import type schema from '../app/schema';");
313
+ lines.push("import type { InferCodecType } from '@timber-js/app/params';");
314
+ lines.push('');
315
+ // Emit TimberSegmentParams, TimberRoute, AllSegmentParams, RoutesWithParams
316
+ lines.push(...formatSchemaTypes(routes));
317
+ lines.push('');
318
+ }
319
+
320
+ if (!hasSchema) {
321
+ // TIM-834 P2: emit the shared codec-resolution helper type ONCE so the
322
+ // per-param chain conditionals reference it instead of inlining the
323
+ // fallback in both branches (which grows O(2^N) in chain depth).
324
+ lines.push(emitResolveSegmentFieldHelper());
325
+ lines.push('');
326
+ }
327
+
229
328
  lines.push("declare module '@timber-js/app' {");
230
329
  lines.push(' interface Routes {');
231
330
 
232
331
  for (const route of routes) {
233
- const paramsType = formatParamsType(route.params, importBase);
332
+ const paramsType = formatParamsType(route.params, importBase, hasSchema);
234
333
  const searchParamsType = formatSearchParamsType(route, importBase);
235
334
 
236
335
  lines.push(` '${route.urlPath}': {`);
@@ -243,15 +342,22 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
243
342
  lines.push('}');
244
343
  lines.push('');
245
344
 
246
- // Generate @timber-js/app/server augmentation — typed searchParams() generic
247
345
  const pageRoutes = routes.filter((r) => !r.isApiRoute);
346
+ const dynamicRoutes = routes.filter((r) => r.params.length > 0);
248
347
 
249
- // Note: searchParams() always returns Promise<URLSearchParams>.
250
- // Typed parsing is done via definition.parse(searchParams()).
251
- // No module augmentation needed for @timber-js/app/server.
348
+ // Generate @timber-js/app/server augmentation — typed getSegmentParams
349
+ if (dynamicRoutes.length > 0) {
350
+ lines.push("declare module '@timber-js/app/server' {");
351
+ for (const route of dynamicRoutes) {
352
+ const paramsType = formatParamsType(route.params, importBase, hasSchema);
353
+ lines.push(` export function getSegmentParams(route: '${route.urlPath}'): ${paramsType}`);
354
+ }
355
+ lines.push(' export function getSegmentParams(): Record<string, string | string[]>');
356
+ lines.push('}');
357
+ lines.push('');
358
+ }
252
359
 
253
360
  // Generate overloads for @timber-js/app/client
254
- const dynamicRoutes = routes.filter((r) => r.params.length > 0);
255
361
 
256
362
  if (dynamicRoutes.length > 0 || pageRoutes.length > 0) {
257
363
  lines.push("declare module '@timber-js/app/client' {");
@@ -263,7 +369,7 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
263
369
  // useParams overloads
264
370
  if (dynamicRoutes.length > 0) {
265
371
  for (const route of dynamicRoutes) {
266
- const paramsType = formatParamsType(route.params, importBase);
372
+ const paramsType = formatParamsType(route.params, importBase, hasSchema);
267
373
  lines.push(` export function useSegmentParams(route: '${route.urlPath}'): ${paramsType}`);
268
374
  }
269
375
  lines.push(' export function useSegmentParams(): Record<string, string | string[]>');
@@ -290,7 +396,7 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
290
396
  // confusing "Type 'string' is not assignable to type 'never'" cascade.
291
397
  if (pageRoutes.length > 0) {
292
398
  lines.push(' // Typed Link overloads — per-route (block 1 / emitted first)');
293
- lines.push(...formatTypedLinkOverloads(pageRoutes, importBase));
399
+ lines.push(...formatTypedLinkOverloads(pageRoutes, importBase, hasSchema));
294
400
  lines.push('');
295
401
  }
296
402
 
@@ -315,13 +421,15 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
315
421
  /**
316
422
  * Format the params type for a route entry.
317
423
  */
318
- function formatParamsType(params: ParamEntry[], importBase?: string): string {
424
+ function formatParamsType(params: ParamEntry[], importBase?: string, hasSchema?: boolean): string {
319
425
  if (params.length === 0) {
320
426
  return '{}';
321
427
  }
322
428
 
323
429
  const fields = params.map((p) => {
324
- const codecType = buildCodecChainType(p, importBase, p.type);
430
+ const codecType = hasSchema
431
+ ? buildSchemaParamType(p)
432
+ : buildCodecChainType(p, importBase, p.type);
325
433
  return `${p.name}: ${codecType}`;
326
434
  });
327
435
  return `{ ${fields.join('; ')} }`;
@@ -24,7 +24,11 @@
24
24
  */
25
25
 
26
26
  import type { ParamEntry, RouteEntry } from './codegen-types.js';
27
- import { buildCodecChainType, formatSearchParamsType } from './codegen-shared.js';
27
+ import {
28
+ buildCodecChainType,
29
+ buildSchemaParamType,
30
+ formatSearchParamsType,
31
+ } from './codegen-shared.js';
28
32
 
29
33
  /** Shared Link base-props type literal used in every emitted call signature. */
30
34
  export const LINK_BASE_PROPS_TYPE =
@@ -67,14 +71,20 @@ export function buildResolvedPattern(route: RouteEntry): string | null {
67
71
  * When the segment's params chain (TIM-834) declares a typed codec, the
68
72
  * inferred type from the codec wins via a nested conditional.
69
73
  */
70
- export function formatLinkParamsType(params: ParamEntry[], importBase?: string): string {
74
+ export function formatLinkParamsType(
75
+ params: ParamEntry[],
76
+ importBase?: string,
77
+ hasSchema?: boolean
78
+ ): string {
71
79
  if (params.length === 0) {
72
80
  return '{}';
73
81
  }
74
82
 
75
83
  const fields = params.map((p) => {
76
84
  const fallback = p.type === 'string' ? 'string | number' : p.type;
77
- const codecType = buildCodecChainType(p, importBase, fallback);
85
+ const codecType = hasSchema
86
+ ? buildSchemaParamType(p)
87
+ : buildCodecChainType(p, importBase, fallback);
78
88
  return `${p.name}: ${codecType}`;
79
89
  });
80
90
  return `{ ${fields.join('; ')} }`;
@@ -192,7 +202,11 @@ export function formatLinkCatchAllOverloads(): string[] {
192
202
  * so the resolved-template-vs-pattern ordering reduces to placing the
193
203
  * pattern variant before the resolved-template variant inside the union.
194
204
  */
195
- export function formatTypedLinkOverloads(routes: RouteEntry[], importBase?: string): string[] {
205
+ export function formatTypedLinkOverloads(
206
+ routes: RouteEntry[],
207
+ importBase?: string,
208
+ hasSchema?: boolean
209
+ ): string[] {
196
210
  const lines: string[] = [];
197
211
  const baseProps = LINK_BASE_PROPS_TYPE;
198
212
 
@@ -215,7 +229,7 @@ export function formatTypedLinkOverloads(routes: RouteEntry[], importBase?: stri
215
229
  // segmentParams. `Record<string, unknown>` keeps generic spreads
216
230
  // compiling and gives a readable error if a non-object is passed.
217
231
  const paramsProp = hasDynamicParams
218
- ? `segmentParams: ${formatLinkParamsType(route.params, importBase)}`
232
+ ? `segmentParams: ${formatLinkParamsType(route.params, importBase, hasSchema)}`
219
233
  : 'segmentParams?: Record<string, unknown>';
220
234
 
221
235
  const searchParamsType = route.hasSearchParams
@@ -59,6 +59,13 @@ export interface RequestContextStore {
59
59
  * See design/07-routing.md §"params.ts — Convention File for Typed Params"
60
60
  */
61
61
  segmentParams?: Record<string, string | string[]>;
62
+ /**
63
+ * The matched route pattern (e.g. '/[artistSlug]/[year]').
64
+ * Set by the pipeline alongside segmentParams. Used by
65
+ * getSegmentParams() for dev-mode route mismatch validation.
66
+ * See design/41-global-params.md §Runtime Validation
67
+ */
68
+ matchedRoutePattern?: string;
62
69
  /** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */
63
70
  cookieJar: Map<string, CookieEntry>;
64
71
  /** Whether the response has flushed (headers committed). */
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Single-shot `setImmediate` coalescing primitive.
3
+ *
4
+ * Multiple synchronous `schedule()` calls within one event-loop tick
5
+ * result in exactly one `flush` invocation at the end of that tick.
6
+ * No recursive scheduling, no busy-wait, no polling loop.
7
+ *
8
+ * Both `createBufferedTransformStream` (Web) and
9
+ * `createNodeBufferedTransform` (Node) use this helper so the invariant
10
+ * — and this comment — live in one place.
11
+ *
12
+ * **Not a polling loop.** Uses a single-shot `setImmediate` per flush
13
+ * cycle — no recursive scheduling, no busy-wait. See design/02
14
+ * §"No Polling".
15
+ */
16
+
17
+ export interface TickCoalescer {
18
+ /**
19
+ * Request a flush at the end of the current tick. If a flush is already
20
+ * scheduled, this is a no-op. Returns the pending promise (or undefined
21
+ * if the flush already fired and no new one has been scheduled).
22
+ */
23
+ schedule(): void;
24
+
25
+ /**
26
+ * The pending flush promise, if any. Callers (e.g. `flush()` in a
27
+ * TransformStream) can `await` this to wait for the scheduled flush
28
+ * to complete before closing the stream.
29
+ */
30
+ readonly pending: Promise<void> | undefined;
31
+
32
+ /**
33
+ * Cancel any pending scheduled flush and invoke the flush callback
34
+ * synchronously. Used by Node `Transform.flush()` which needs to
35
+ * drain the buffer immediately before calling back.
36
+ */
37
+ flushSync(): void;
38
+ }
39
+
40
+ /**
41
+ * Create a tick coalescer that calls `onFlush` at most once per tick.
42
+ */
43
+ export function createTickCoalescer(onFlush: () => void): TickCoalescer {
44
+ let pending: Promise<void> | undefined;
45
+ let immediateHandle: ReturnType<typeof setImmediate> | null = null;
46
+
47
+ function doFlush(): void {
48
+ immediateHandle = null;
49
+ pending = undefined;
50
+ onFlush();
51
+ }
52
+
53
+ return {
54
+ schedule() {
55
+ if (immediateHandle !== null) return;
56
+ pending = new Promise<void>((resolve) => {
57
+ immediateHandle = setImmediate(() => {
58
+ doFlush();
59
+ resolve();
60
+ });
61
+ });
62
+ },
63
+
64
+ get pending() {
65
+ return pending;
66
+ },
67
+
68
+ flushSync() {
69
+ if (immediateHandle !== null) {
70
+ clearImmediate(immediateHandle);
71
+ doFlush();
72
+ }
73
+ },
74
+ };
75
+ }
@@ -15,6 +15,7 @@ import type { ManifestSegmentNode } from './route-matcher.js';
15
15
  import type { ClientBootstrapConfig } from './html-injectors.js';
16
16
  import type { LayoutEntry } from './deny-renderer.js';
17
17
  import type { GlobalErrorFile } from './rsc-entry/error-renderer.js';
18
+ import { swallow } from './logger.js';
18
19
  import { logRenderError } from './logger.js';
19
20
  import { loadModule } from './safe-load.js';
20
21
  import { sourceMapError } from './dev-source-map.js';
@@ -105,9 +106,8 @@ export async function renderDevErrorPage(error: unknown, projectRoot?: string):
105
106
  '</head>',
106
107
  ' <script type="module" src="/@vite/client"></script>\n</head>'
107
108
  );
108
- } catch {
109
- // If the shared template fails (e.g., circular dep, missing module),
110
- // fall back to a minimal error page.
109
+ } catch (e) {
110
+ swallow(e, 'dev error page template failed, using minimal fallback');
111
111
  html = minimalErrorPage(err);
112
112
  }
113
113
 
@@ -31,6 +31,7 @@ import {
31
31
  } from './html-injector-core.js';
32
32
  import { flightChunkScript } from './flight-scripts.js';
33
33
  import { withTimeout, RenderTimeoutError } from './render-timeout.js';
34
+ import { createTickCoalescer } from './coalesce-tick.js';
34
35
 
35
36
  // ─── Buffered Transform ──────────────────────────────────────────────────────
36
37
 
@@ -58,11 +59,11 @@ export function createBufferedTransformStream(
58
59
  options: BufferedTransformOptions = {}
59
60
  ): TransformStream<Uint8Array, Uint8Array> {
60
61
  const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
61
- let pendingFlush: Promise<void> | undefined;
62
+ let controller: TransformStreamDefaultController<Uint8Array> | null = null;
62
63
 
63
- const drainTo = (controller: TransformStreamDefaultController<Uint8Array>) => {
64
+ const drainTo = () => {
64
65
  const merged = buffer.drain();
65
- if (!merged) return;
66
+ if (!merged || !controller) return;
66
67
  try {
67
68
  controller.enqueue(merged);
68
69
  } catch {
@@ -70,32 +71,20 @@ export function createBufferedTransformStream(
70
71
  }
71
72
  };
72
73
 
74
+ const coalescer = createTickCoalescer(drainTo);
75
+
73
76
  return new TransformStream<Uint8Array, Uint8Array>({
74
- transform(chunk, controller) {
77
+ transform(chunk, ctrl) {
78
+ controller = ctrl;
75
79
  const overCap = buffer.append(chunk);
76
80
  if (overCap) {
77
- // Buffer too large to hold — flush synchronously now.
78
- drainTo(controller);
81
+ drainTo();
79
82
  return;
80
83
  }
81
- if (pendingFlush) return;
82
- // Single-shot setImmediate — fires once at the end of this tick,
83
- // then the promise resolves. NOT a recursive loop. See design/02
84
- // §"No Polling".
85
- pendingFlush = new Promise<void>((resolve) => {
86
- setImmediate(() => {
87
- try {
88
- drainTo(controller);
89
- } finally {
90
- pendingFlush = undefined;
91
- resolve();
92
- }
93
- });
94
- });
84
+ coalescer.schedule();
95
85
  },
96
86
  flush() {
97
- // Wait for any pending scheduled flush to complete.
98
- return pendingFlush;
87
+ return coalescer.pending;
99
88
  },
100
89
  });
101
90
  }
@@ -33,6 +33,7 @@ import {
33
33
  suffixFlush,
34
34
  } from './html-injector-core.js';
35
35
  import { logStreamingError } from './logger.js';
36
+ import { createTickCoalescer } from './coalesce-tick.js';
36
37
 
37
38
  // ─── Buffered Transform ──────────────────────────────────────────────────────
38
39
 
@@ -58,37 +59,26 @@ export interface NodeBufferedTransformOptions {
58
59
  */
59
60
  export function createNodeBufferedTransform(options: NodeBufferedTransformOptions = {}): Transform {
60
61
  const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
61
- let pendingImmediate: ReturnType<typeof setImmediate> | null = null;
62
62
 
63
63
  const flushBuffer = () => {
64
64
  const merged = buffer.drain();
65
65
  if (merged) transform.push(merged);
66
66
  };
67
67
 
68
+ const coalescer = createTickCoalescer(flushBuffer);
69
+
68
70
  const transform = new Transform({
69
71
  transform(chunk: Buffer, _encoding, callback) {
70
72
  const overCap = buffer.append(chunk);
71
73
  if (overCap) {
72
- // Buffer too large to hold — flush synchronously now.
73
74
  flushBuffer();
74
- } else if (!pendingImmediate) {
75
- // Schedule a deferred flush for end of this tick.
76
- // Single-shot setImmediate — NOT a recursive loop. See design/02
77
- // §"No Polling".
78
- pendingImmediate = setImmediate(() => {
79
- pendingImmediate = null;
80
- flushBuffer();
81
- });
75
+ } else {
76
+ coalescer.schedule();
82
77
  }
83
78
  callback();
84
79
  },
85
80
  flush(callback) {
86
- // Cancel any pending deferred flush and flush synchronously.
87
- if (pendingImmediate) {
88
- clearImmediate(pendingImmediate);
89
- pendingImmediate = null;
90
- }
91
- flushBuffer();
81
+ coalescer.flushSync();
92
82
  callback();
93
83
  },
94
84
  });
@@ -12,23 +12,45 @@
12
12
  * 404 page. See design/07-routing.md §"Where Coercion Runs".
13
13
  */
14
14
 
15
+ import type { Codec } from '../codec.js';
16
+ import { toBracketKey } from '../params/resolve-schema.js';
15
17
  import type { RouteMatch } from './pipeline.js';
16
18
  import { sanitizeParamValue } from './pipeline-helpers.js';
17
19
  import { loadModule } from './safe-load.js';
18
20
  import { ParamCoercionError } from './route-element-builder.js';
19
21
  import { isDebug } from './debug.js';
20
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Module-level global codec store (TIM-931)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Global schema codecs, set once at startup from virtual:timber-schema. */
28
+ let _globalCodecs: Record<string, Codec<unknown>> | null = null;
29
+
30
+ /**
31
+ * Register the global schema codecs. Called once from the RSC entry
32
+ * at server startup when app/schema.ts provides segment param codecs.
33
+ * @internal
34
+ */
35
+ export function setGlobalSchemaCodecs(codecs: Record<string, Codec<unknown>> | null): void {
36
+ _globalCodecs = codecs;
37
+ }
38
+
21
39
  /**
22
40
  * Run segment param coercion on the matched route's segments.
23
41
  *
24
- * Loads params.ts modules from segments that have them, extracts the
25
- * segmentParams definition, and coerces raw string params through codecs.
42
+ * When `globalCodecs` is provided (from app/schema.ts), uses the global
43
+ * codec map keyed by bare param name. Otherwise falls back to loading
44
+ * per-segment params.ts modules.
45
+ *
26
46
  * Throws ParamCoercionError if any codec fails (→ 404).
27
47
  *
28
48
  * This runs BEFORE middleware, so ctx.segmentParams is already typed.
29
49
  * See design/07-routing.md §"Where Coercion Runs"
50
+ * See design/41-global-params.md §"Pipeline Integration"
30
51
  */
31
52
  export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
53
+ const globalCodecs = _globalCodecs;
32
54
  // Unconditionally install a null-prototype target so the invariant
33
55
  // "match.segmentParams is null-prototype" holds from the first line,
34
56
  // regardless of whether any segment has a codec.
@@ -40,6 +62,37 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
40
62
  }
41
63
  match.segmentParams = mergeTarget as RouteMatch['segmentParams'];
42
64
 
65
+ // TIM-931/TIM-936: When a global schema is available, use it for coercion
66
+ // instead of per-segment params.ts files. The global codec map is keyed
67
+ // by bracket name (e.g. '[id]', '[...slug]') to avoid collisions between
68
+ // distinct segment types that share a param name.
69
+ if (globalCodecs) {
70
+ for (const segment of match.segments) {
71
+ if (!segment.paramName) continue;
72
+ const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
73
+ const codec = globalCodecs[bracketKey];
74
+ if (!codec) continue; // no codec in schema → keep raw string
75
+
76
+ const key = segment.paramName;
77
+ try {
78
+ mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key] as string | string[]));
79
+ } catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ if (isDebug()) {
82
+ console.warn(
83
+ `[timber] Global schema codec rejected value for param "${key}"\n` +
84
+ ` Error: ${message}\n` +
85
+ ` Raw value: ${JSON.stringify(mergeTarget[key])}\n` +
86
+ ` Hint: check the codec in app/schema.ts for key '${bracketKey}'.`
87
+ );
88
+ }
89
+ throw new ParamCoercionError(message);
90
+ }
91
+ }
92
+ return;
93
+ }
94
+
95
+ // Legacy path: per-segment params.ts coercion
43
96
  for (const segment of match.segments) {
44
97
  // Only process segments that have a params.ts convention file
45
98
  if (!segment.params) continue;
@@ -20,6 +20,7 @@ import {
20
20
  applyRequestHeaderOverlay,
21
21
  setMutableCookieContext,
22
22
  setSegmentParams,
23
+ setMatchedRoutePattern,
23
24
  } from './request-context.js';
24
25
  import { withSpan } from './tracing.js';
25
26
  import { logRenderError } from './logger.js';
@@ -418,6 +419,13 @@ export async function handleRequest(
418
419
  // See design/07-routing.md §"params.ts — Convention File for Typed Params"
419
420
  setSegmentParams(match.segmentParams);
420
421
 
422
+ // Store the matched route pattern for dev-mode validation in getSegmentParams().
423
+ // The leaf segment's urlPath is the accumulated route pattern.
424
+ const leaf = match.segments[match.segments.length - 1];
425
+ if (leaf) {
426
+ setMatchedRoutePattern(leaf.urlPath);
427
+ }
428
+
421
429
  // Bypass middleware for synthetic re-render requests built by the
422
430
  // action-dispatch wrapper after a no-JS form validation failure.
423
431
  // The wrapper has already executed middleware once on the inbound POST;