@timber-js/app 0.2.0-alpha.120 → 0.2.0-alpha.122

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 (157) hide show
  1. package/dist/_chunks/{actions-Cw-7NfJJ.js → actions-Dy8vOjFt.js} +6 -6
  2. package/dist/_chunks/actions-Dy8vOjFt.js.map +1 -0
  3. package/dist/_chunks/{build-output-helper-DhxiT18k.js → build-output-helper-DYOWsPjZ.js} +47 -6
  4. package/dist/_chunks/build-output-helper-DYOWsPjZ.js.map +1 -0
  5. package/dist/_chunks/cli-schema-sync-4HcQIziG.js +2 -0
  6. package/dist/_chunks/cli-schema-sync-VcCf1TFY.js +738 -0
  7. package/dist/_chunks/cli-schema-sync-VcCf1TFY.js.map +1 -0
  8. package/dist/_chunks/{codec-registry-V7ARyeLN.js → codec-registry-G9hD5LOq.js} +1 -1
  9. package/dist/_chunks/{codec-registry-V7ARyeLN.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
  10. package/dist/_chunks/{define-zQX6e1tN.js → define-CPVQaWbx.js} +3 -3
  11. package/dist/_chunks/{define-zQX6e1tN.js.map → define-CPVQaWbx.js.map} +1 -1
  12. package/dist/_chunks/{define-8zQ8Vm7x.js → define-jeHDXZGq.js} +3 -3
  13. package/dist/_chunks/{define-8zQ8Vm7x.js.map → define-jeHDXZGq.js.map} +1 -1
  14. package/dist/_chunks/{format-CR6v1pWE.js → format-BjN9HLu9.js} +1 -1
  15. package/dist/_chunks/{format-CR6v1pWE.js.map → format-BjN9HLu9.js.map} +1 -1
  16. package/dist/_chunks/{handler-store-iqnt2ghN.js → handler-store-CcwNX-gk.js} +1 -1
  17. package/dist/_chunks/{handler-store-iqnt2ghN.js.map → handler-store-CcwNX-gk.js.map} +1 -1
  18. package/dist/_chunks/{merge-search-params-CwxJzBLS.js → merge-search-params-az6ZiBeU.js} +1 -1
  19. package/dist/_chunks/{merge-search-params-CwxJzBLS.js.map → merge-search-params-az6ZiBeU.js.map} +1 -1
  20. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
  21. package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
  22. package/dist/_chunks/{navigation-root-DKsJ5Di7.js → navigation-root-DoRTeBm5.js} +2 -2
  23. package/dist/_chunks/{navigation-root-DKsJ5Di7.js.map → navigation-root-DoRTeBm5.js.map} +1 -1
  24. package/dist/_chunks/{registry-Dp_hJL4s.js → registry-BI9cgCt7.js} +1 -1
  25. package/dist/_chunks/{registry-Dp_hJL4s.js.map → registry-BI9cgCt7.js.map} +1 -1
  26. package/dist/_chunks/{resolve-schema-23Mc8aLi.js → resolve-schema-cURaYvaD.js} +15 -3
  27. package/dist/_chunks/resolve-schema-cURaYvaD.js.map +1 -0
  28. package/dist/_chunks/{router-ref-sektkr-t.js → router-ref-CpvjBqdu.js} +2 -2
  29. package/dist/_chunks/{router-ref-sektkr-t.js.map → router-ref-CpvjBqdu.js.map} +1 -1
  30. package/dist/_chunks/{schema-bridge-eL5vQDSA.js → schema-bridge-o0SwFtXW.js} +1 -1
  31. package/dist/_chunks/{schema-bridge-eL5vQDSA.js.map → schema-bridge-o0SwFtXW.js.map} +1 -1
  32. package/dist/_chunks/{segment-classify-CnfLLvir.js → segment-classify-BjfuctV2.js} +1 -1
  33. package/dist/_chunks/{segment-classify-CnfLLvir.js.map → segment-classify-BjfuctV2.js.map} +1 -1
  34. package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-EsfQpS--.js} +1 -1
  35. package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-EsfQpS--.js.map} +1 -1
  36. package/dist/_chunks/{ssr-data-BO5OvEvg.js → ssr-data-m9-cNEIw.js} +1 -1
  37. package/dist/_chunks/{ssr-data-BO5OvEvg.js.map → ssr-data-m9-cNEIw.js.map} +1 -1
  38. package/dist/_chunks/{stale-reload-BCPRG0Cv.js → stale-reload-BN2kcE06.js} +1 -1
  39. package/dist/_chunks/{stale-reload-BCPRG0Cv.js.map → stale-reload-BN2kcE06.js.map} +1 -1
  40. package/dist/_chunks/{use-query-states-D9SBlAfp.js → use-query-states-C9G6LP0F.js} +2 -2
  41. package/dist/_chunks/{use-query-states-D9SBlAfp.js.map → use-query-states-C9G6LP0F.js.map} +1 -1
  42. package/dist/_chunks/{use-segment-params-CskiK-59.js → use-segment-params-CtIAjqK0.js} +2 -2
  43. package/dist/_chunks/{use-segment-params-CskiK-59.js.map → use-segment-params-CtIAjqK0.js.map} +1 -1
  44. package/dist/_chunks/{validation-Bm3Je-09.js → validation-BMU5MPzP.js} +1 -1
  45. package/dist/_chunks/{validation-Bm3Je-09.js.map → validation-BMU5MPzP.js.map} +1 -1
  46. package/dist/_chunks/{walkers-DgOXkTts.js → walkers-tkw7sDlj.js} +125 -12
  47. package/dist/_chunks/walkers-tkw7sDlj.js.map +1 -0
  48. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  49. package/dist/adapters/cloudflare.js +1 -1
  50. package/dist/adapters/nitro.js +1 -1
  51. package/dist/cache/index.js +1 -1
  52. package/dist/cli-schema-sync.d.ts.map +1 -1
  53. package/dist/cli.js +1 -1
  54. package/dist/client/error-boundary.js +2 -2
  55. package/dist/client/index.js +10 -10
  56. package/dist/client/index.js.map +1 -1
  57. package/dist/client/internal.js +7 -7
  58. package/dist/client/link.d.ts +9 -5
  59. package/dist/client/link.d.ts.map +1 -1
  60. package/dist/codec.d.ts +18 -0
  61. package/dist/codec.d.ts.map +1 -1
  62. package/dist/codec.js +30 -1
  63. package/dist/codec.js.map +1 -1
  64. package/dist/cookies/index.js +3 -3
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +127 -26
  67. package/dist/index.js.map +1 -1
  68. package/dist/params/define-schema.d.ts +32 -4
  69. package/dist/params/define-schema.d.ts.map +1 -1
  70. package/dist/params/index.d.ts +1 -1
  71. package/dist/params/index.d.ts.map +1 -1
  72. package/dist/params/index.js +2 -2
  73. package/dist/params/index.js.map +1 -1
  74. package/dist/params/resolve-schema.d.ts.map +1 -1
  75. package/dist/plugins/build-manifest.d.ts.map +1 -1
  76. package/dist/plugins/cloudflare-wasm.d.ts +18 -0
  77. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
  78. package/dist/plugins/dev-server.d.ts.map +1 -1
  79. package/dist/plugins/routing.d.ts.map +1 -1
  80. package/dist/routing/codegen-shared.d.ts +5 -4
  81. package/dist/routing/codegen-shared.d.ts.map +1 -1
  82. package/dist/routing/index.d.ts +2 -0
  83. package/dist/routing/index.d.ts.map +1 -1
  84. package/dist/routing/index.js +4 -4
  85. package/dist/routing/scanner.d.ts.map +1 -1
  86. package/dist/routing/schema-validation.d.ts +35 -0
  87. package/dist/routing/schema-validation.d.ts.map +1 -0
  88. package/dist/search-params/index.js +3 -3
  89. package/dist/segment-params/index.js +1 -1
  90. package/dist/server/actions.d.ts +3 -8
  91. package/dist/server/actions.d.ts.map +1 -1
  92. package/dist/server/build-manifest.d.ts +6 -0
  93. package/dist/server/build-manifest.d.ts.map +1 -1
  94. package/dist/server/index.js +2 -2
  95. package/dist/server/internal.d.ts +1 -1
  96. package/dist/server/internal.d.ts.map +1 -1
  97. package/dist/server/internal.js +15 -6
  98. package/dist/server/internal.js.map +1 -1
  99. package/dist/server/metadata-routes.d.ts +23 -7
  100. package/dist/server/metadata-routes.d.ts.map +1 -1
  101. package/dist/server/pipeline-phases.d.ts.map +1 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/route-element-builder.d.ts +1 -1
  104. package/dist/server/route-element-builder.d.ts.map +1 -1
  105. package/dist/server/route-matcher.d.ts +4 -2
  106. package/dist/server/route-matcher.d.ts.map +1 -1
  107. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  108. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  109. package/dist/server/ssr-entry.d.ts.map +1 -1
  110. package/dist/server/tree-match.d.ts +12 -4
  111. package/dist/server/tree-match.d.ts.map +1 -1
  112. package/dist/server/types.d.ts +2 -2
  113. package/package.json +7 -6
  114. package/src/adapters/build-output-helper.ts +67 -5
  115. package/src/cli-schema-sync.ts +25 -4
  116. package/src/cli.ts +0 -0
  117. package/src/client/browser-entry/index.ts +1 -1
  118. package/src/client/link.tsx +9 -9
  119. package/src/codec.ts +51 -1
  120. package/src/index.ts +2 -0
  121. package/src/params/define-schema.ts +46 -5
  122. package/src/params/index.ts +7 -1
  123. package/src/params/resolve-schema.ts +28 -0
  124. package/src/plugins/build-manifest.ts +57 -5
  125. package/src/plugins/cloudflare-wasm.ts +103 -0
  126. package/src/plugins/dev-server.ts +4 -2
  127. package/src/plugins/routing.ts +31 -34
  128. package/src/routing/codegen-shared.ts +8 -5
  129. package/src/routing/codegen.ts +59 -5
  130. package/src/routing/index.ts +5 -0
  131. package/src/routing/link-codegen.ts +1 -1
  132. package/src/routing/scanner.ts +58 -0
  133. package/src/routing/schema-validation.ts +149 -0
  134. package/src/server/actions.ts +4 -14
  135. package/src/server/build-manifest.ts +7 -0
  136. package/src/server/internal.ts +7 -1
  137. package/src/server/metadata-routes.ts +44 -19
  138. package/src/server/pipeline-phases.ts +8 -0
  139. package/src/server/request-context.ts +0 -4
  140. package/src/server/route-element-builder.ts +57 -11
  141. package/src/server/route-matcher.ts +81 -49
  142. package/src/server/rsc-entry/index.ts +7 -1
  143. package/src/server/rsc-entry/render-route.ts +7 -1
  144. package/src/server/ssr-entry.ts +6 -0
  145. package/src/server/tree-match.ts +23 -11
  146. package/src/server/types.ts +2 -2
  147. package/LICENSE +0 -8
  148. package/dist/_chunks/actions-Cw-7NfJJ.js.map +0 -1
  149. package/dist/_chunks/build-output-helper-DhxiT18k.js.map +0 -1
  150. package/dist/_chunks/cli-schema-sync-XV0I47Gj.js +0 -2
  151. package/dist/_chunks/cli-schema-sync-q2wB0DyJ.js +0 -358
  152. package/dist/_chunks/cli-schema-sync-q2wB0DyJ.js.map +0 -1
  153. package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
  154. package/dist/_chunks/resolve-schema-23Mc8aLi.js.map +0 -1
  155. package/dist/_chunks/scanner-DmfoB7iK.js +0 -336
  156. package/dist/_chunks/scanner-DmfoB7iK.js.map +0 -1
  157. package/dist/_chunks/walkers-DgOXkTts.js.map +0 -1
@@ -63,6 +63,17 @@ export function toBracketKey(segmentType: string, paramName: string): string {
63
63
  }
64
64
  }
65
65
 
66
+ const CATCH_ALL_RE = /^\[\.\.\..+\]$/;
67
+ const OPTIONAL_CATCH_ALL_RE = /^\[\[\.\.\..+\]\]$/;
68
+
69
+ function isCatchAllKey(key: string): boolean {
70
+ return CATCH_ALL_RE.test(key) || OPTIONAL_CATCH_ALL_RE.test(key);
71
+ }
72
+
73
+ function isOptionalCatchAllKey(key: string): boolean {
74
+ return OPTIONAL_CATCH_ALL_RE.test(key);
75
+ }
76
+
66
77
  /**
67
78
  * Resolve the global schema's segmentParams into a map of bracket-keyed
68
79
  * param name → resolved Codec.
@@ -81,6 +92,23 @@ export function resolveSchemaCodecs(
81
92
  const resolved: Record<string, Codec<unknown>> = Object.create(null);
82
93
 
83
94
  for (const [bracketName, value] of Object.entries(segmentParams)) {
95
+ if (isCatchAllKey(bracketName) && isCodec(value) && !('__catchAll' in value)) {
96
+ throw new Error(
97
+ `[timber] Schema error: '${bracketName}' is a catch-all segment and requires an array codec ` +
98
+ `(e.g. codec.catchAll(codec.string)). Got a scalar codec.`
99
+ );
100
+ }
101
+ if (
102
+ isOptionalCatchAllKey(bracketName) &&
103
+ isCodec(value) &&
104
+ '__catchAll' in value &&
105
+ !('__optional' in value)
106
+ ) {
107
+ throw new Error(
108
+ `[timber] Schema error: '${bracketName}' is an optional catch-all segment and requires codec.optionalCatchAll() ` +
109
+ `(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`
110
+ );
111
+ }
84
112
  resolved[bracketName] = resolveCodec(bracketName, value);
85
113
  }
86
114
 
@@ -16,10 +16,13 @@
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
+ import { randomUUID, createHash } from 'node:crypto';
20
+ import { readFileSync } from 'node:fs';
20
21
  import type { Plugin, ResolvedConfig } from 'vite';
21
22
  import type { PluginContext } from '../plugin-context.js';
22
23
  import type { BuildManifest } from '../server/build-manifest.js';
24
+ import type { SegmentNode, RouteFile } from '../routing/types.js';
25
+ import { isDynamicMetadataExtension } from '../server/metadata-routes.js';
23
26
 
24
27
  // Rollup types used by generateBundle hook — imported from vite which re-exports them.
25
28
  // We define minimal interfaces here to avoid a direct 'rollup' dependency.
@@ -84,7 +87,7 @@ export function parseViteManifest(
84
87
  modulepreload[inputPath] = collectTransitiveDeps(inputPath, viteManifest, base);
85
88
  }
86
89
 
87
- return { css, js, modulepreload, fonts: {} };
90
+ return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
88
91
  }
89
92
 
90
93
  /**
@@ -197,7 +200,7 @@ export function buildManifestFromBundle(
197
200
  css['_global'] = allCss;
198
201
  }
199
202
 
200
- return { css, js, modulepreload, fonts: {} };
203
+ return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
201
204
  }
202
205
 
203
206
  /**
@@ -228,6 +231,50 @@ function collectTransitiveBundleDeps(
228
231
  return result;
229
232
  }
230
233
 
234
+ /**
235
+ * Walk the route tree and compute short content hashes for metadata route files
236
+ * (opengraph-image, icon, apple-icon) used for cache-busting
237
+ * the auto-linked URLs in <head>.
238
+ */
239
+ function computeMetadataRouteHashes(node: SegmentNode<RouteFile>): Record<string, string> {
240
+ const hashes: Record<string, string> = {};
241
+
242
+ if (node.metadataRoutes) {
243
+ for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
244
+ if (!isDynamicMetadataExtension(baseName, file.extension)) {
245
+ // For static files, hash the file content
246
+ try {
247
+ const content = readFileSync(file.filePath);
248
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
249
+ hashes[file.filePath] = hash;
250
+ } catch {
251
+ // File unreadable — skip hash, DEV_NONCE fallback applies
252
+ }
253
+ } else {
254
+ // For dynamic files (.ts/.tsx), hash the source file as a reasonable
255
+ // approximation — the output depends on runtime data too, but the
256
+ // source change is the best build-time signal we have.
257
+ try {
258
+ const content = readFileSync(file.filePath);
259
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
260
+ hashes[file.filePath] = hash;
261
+ } catch {
262
+ // File unreadable — skip
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ for (const child of node.children) {
269
+ Object.assign(hashes, computeMetadataRouteHashes(child));
270
+ }
271
+ for (const slot of Object.values(node.slots)) {
272
+ Object.assign(hashes, computeMetadataRouteHashes(slot));
273
+ }
274
+
275
+ return hashes;
276
+ }
277
+
231
278
  /**
232
279
  * Create the timber-build-manifest Vite plugin.
233
280
  *
@@ -278,7 +325,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
278
325
  '// Auto-generated build manifest — do not edit.',
279
326
  '// Dev mode: empty manifest (Vite HMR handles CSS/JS).',
280
327
  '',
281
- 'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {} };',
328
+ 'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
282
329
  '',
283
330
  'export default manifest;',
284
331
  ].join('\n');
@@ -292,7 +339,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
292
339
  '// Auto-generated build manifest — do not edit.',
293
340
  '// Production: reads manifest from globalThis (set by _timber-manifest-init.js).',
294
341
  '',
295
- 'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {} };',
342
+ 'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
296
343
  '',
297
344
  'export default manifest;',
298
345
  ].join('\n');
@@ -309,6 +356,11 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
309
356
  if (envName === 'client') {
310
357
  ctx.buildManifest = buildManifestFromBundle(bundle, resolvedBase, ctx.root);
311
358
 
359
+ // Compute cache-busting hashes for metadata route files
360
+ if (ctx.routeTree) {
361
+ ctx.buildManifest.metadataRouteHashes = computeMetadataRouteHashes(ctx.routeTree.root);
362
+ }
363
+
312
364
  // When client JavaScript is disabled, strip JS chunks from the bundle
313
365
  // so Rollup never writes them to disk. CSS assets are preserved —
314
366
  // they're still needed for server-rendered HTML.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * timber-cloudflare-wasm — Handle WASM modules for Cloudflare Workers builds.
3
+ *
4
+ * Cloudflare Workers can natively import `.wasm` files as compiled
5
+ * WebAssembly.Module. But Rolldown can't load `.wasm` files at build time.
6
+ *
7
+ * This plugin bridges the gap:
8
+ * 1. Adds the `workerd` resolve condition so libraries like @takumi-rs/wasm
9
+ * resolve to their Cloudflare-native entry (bare `.wasm` import)
10
+ * 2. Externalizes `.wasm` imports so Rolldown skips them
11
+ * 3. Copies the WASM files to the build output so wrangler can find them
12
+ *
13
+ * Only active for Cloudflare adapter production builds.
14
+ */
15
+
16
+ import { basename, resolve, dirname } from 'node:path';
17
+ import { copyFile, mkdir } from 'node:fs/promises';
18
+ import type { Plugin, ResolvedConfig } from 'vite';
19
+ import type { PluginContext } from '../plugin-context.js';
20
+
21
+ function isCloudflareAdapter(ctx: PluginContext): boolean {
22
+ const adapter = ctx.config.adapter as { name?: string } | undefined;
23
+ return adapter?.name === 'cloudflare';
24
+ }
25
+
26
+ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
27
+ // Track resolved WASM file paths so we can copy them to the output
28
+ const wasmFiles = new Map<string, string>();
29
+
30
+ const plugin: Plugin = {
31
+ name: 'timber-cloudflare-wasm',
32
+
33
+ configResolved(resolved: ResolvedConfig) {
34
+ if (resolved.command !== 'build') return;
35
+ if (!isCloudflareAdapter(ctx)) return;
36
+
37
+ // Prepend `workerd` to resolve conditions for server environments
38
+ // so @takumi-rs/wasm/auto resolves to the Cloudflare-native entry
39
+ // (bare WASM import) instead of the Vite entry (?url + fetch).
40
+ // Must happen in configResolved because the RSC plugin sets its own
41
+ // conditions during config — merging via config() would lose them.
42
+ for (const envName of ['rsc', 'ssr']) {
43
+ const env = resolved.environments[envName];
44
+ if (!env) continue;
45
+ const conditions = env.resolve.conditions;
46
+ if (!conditions.includes('workerd')) {
47
+ conditions.unshift('workerd');
48
+ }
49
+ }
50
+ },
51
+
52
+ resolveId: {
53
+ order: 'pre',
54
+ async handler(source, importer) {
55
+ if (!source.endsWith('.wasm')) return;
56
+
57
+ let absolutePath: string;
58
+ if (source.startsWith('.') && importer) {
59
+ absolutePath = resolve(dirname(importer), source);
60
+ } else {
61
+ return;
62
+ }
63
+
64
+ const filename = basename(absolutePath);
65
+ wasmFiles.set(filename, absolutePath);
66
+
67
+ return { id: './' + filename, external: true };
68
+ },
69
+ },
70
+
71
+ async writeBundle(options, bundle) {
72
+ if (wasmFiles.size === 0) return;
73
+
74
+ const outDir = options.dir;
75
+ if (!outDir) return;
76
+
77
+ // Find which output chunks reference WASM files and copy the WASM
78
+ // to the same directory so the relative imports resolve correctly.
79
+ const targetDirs = new Set<string>();
80
+ for (const chunk of Object.values(bundle)) {
81
+ if (chunk.type !== 'chunk') continue;
82
+ for (const filename of wasmFiles.keys()) {
83
+ if (chunk.code.includes(filename)) {
84
+ targetDirs.add(dirname(resolve(outDir, chunk.fileName)));
85
+ }
86
+ }
87
+ }
88
+
89
+ if (targetDirs.size === 0) return;
90
+
91
+ const copies: Promise<void>[] = [];
92
+ for (const dir of targetDirs) {
93
+ await mkdir(dir, { recursive: true });
94
+ for (const [filename, absolutePath] of wasmFiles) {
95
+ copies.push(copyFile(absolutePath, resolve(dir, filename)));
96
+ }
97
+ }
98
+ await Promise.all(copies);
99
+ },
100
+ };
101
+
102
+ return [plugin];
103
+ }
@@ -31,6 +31,7 @@ import {
31
31
  } from '../dev-tools/error-page.js';
32
32
  import { addVirtualModuleContext } from '../config-validation.js';
33
33
  import { compressResponse } from '../server/compress.js';
34
+ import { isMetadataRouteServePath } from '../server/metadata-routes.js';
34
35
 
35
36
  // ─── Constants ────────────────────────────────────────────────────────────
36
37
 
@@ -165,8 +166,9 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
165
166
  return;
166
167
  }
167
168
 
168
- // Pass through static asset requests
169
- if (isAssetRequest(url)) {
169
+ // Pass through static asset requests — but not metadata routes like
170
+ // /favicon.ico that match asset extensions but are handled by timber.
171
+ if (isAssetRequest(url) && !isMetadataRouteServePath(url)) {
170
172
  next();
171
173
  return;
172
174
  }
@@ -20,11 +20,7 @@ import {
20
20
  writeSegmentTypes,
21
21
  type SegmentInfo,
22
22
  } from '../routing/segment-codegen.js';
23
- import {
24
- collectDynamicSegments,
25
- findSchemaFile,
26
- parseExistingSchemaKeys,
27
- } from '../cli-schema-sync.js';
23
+ import { validateSchemaAgainstRoutes } from '../routing/schema-validation.js';
28
24
 
29
25
  import {
30
26
  lintConventions,
@@ -72,7 +68,7 @@ const SEGMENT_PREFIX = '\0$segment:';
72
68
  * File convention names we track for changes that require manifest regeneration.
73
69
  */
74
70
  const ROUTE_FILE_PATTERNS =
75
- /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
71
+ /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|apple-icon)\./;
76
72
 
77
73
  /**
78
74
  * Create the timber-routing Vite plugin.
@@ -157,10 +153,10 @@ export function timberRouting(ctx: PluginContext): Plugin {
157
153
  dirToRoute = buildDirToRouteMap(ctx.routeTree, ctx.appDir);
158
154
  writeCodegen(ctx);
159
155
 
160
- // Dev: warn about dynamic segments not registered in app/schema.ts.
161
- // Only logs a suggestion, never auto-modifies the schema file.
156
+ // Validate schema keys against filesystem segments.
157
+ // Warns on stale keys, bracket form mismatches, and missing keys.
162
158
  // See design/41-global-params.md §Auto-Scaffolding
163
- warnUnregisteredSegments(ctx);
159
+ warnSchemaValidation(ctx);
164
160
 
165
161
  // Lint conventions (empty app, missing methods, missing root layout)
166
162
  const conventionWarnings = lintConventions(ctx.routeTree, ctx.appDir);
@@ -346,34 +342,35 @@ export function timberRouting(ctx: PluginContext): Plugin {
346
342
  * When the route tree changes, any environment that has imported the manifest
347
343
  * needs to be invalidated so the next request/transform picks up the new tree.
348
344
  */
349
- /** Set of segments we've already warned about — avoid repeating on every rescan. */
350
- const _warnedSegments = new Set<string>();
345
+ /** Set of warning keys we've already emitted — avoid repeating on every rescan. */
346
+ const _warnedKeys = new Set<string>();
351
347
 
352
348
  /**
353
- * Warn about dynamic segments not registered in app/schema.ts.
354
- * Only fires when a schema file exists. Logs once per segment per session.
349
+ * Validate schema keys against the filesystem route tree.
350
+ * Warns on stale keys, bracket form mismatches, and missing keys.
351
+ * Uses the already-scanned route tree instead of re-scanning.
355
352
  */
356
- function warnUnregisteredSegments(ctx: PluginContext): void {
357
- const schemaPath = findSchemaFile(ctx.appDir);
358
- if (!schemaPath) return;
359
-
360
- const filesystemSegments = collectDynamicSegments(ctx.appDir);
361
- if (filesystemSegments.length === 0) return;
362
-
363
- const content = readFileSync(schemaPath, 'utf-8');
364
- const existingKeys = parseExistingSchemaKeys(content);
365
- const missing = filesystemSegments.filter(
366
- (seg) => !existingKeys.includes(seg) && !_warnedSegments.has(seg)
367
- );
368
-
369
- if (missing.length === 0) return;
370
-
371
- for (const seg of missing) _warnedSegments.add(seg);
372
-
373
- console.warn(
374
- `[timber] New segment${missing.length > 1 ? 's' : ''} not in schema: ${missing.join(', ')}\n` +
375
- ` Run: timber schema sync`
376
- );
353
+ function warnSchemaValidation(ctx: PluginContext): void {
354
+ if (!ctx.routeTree) return;
355
+ const warnings = validateSchemaAgainstRoutes(ctx.routeTree, ctx.appDir);
356
+
357
+ for (const w of warnings) {
358
+ const warnKey = `${w.type}:${w.key}`;
359
+ if (_warnedKeys.has(warnKey)) continue;
360
+ _warnedKeys.add(warnKey);
361
+
362
+ switch (w.type) {
363
+ case 'stale-key':
364
+ console.warn(`[timber] ${w.message}`);
365
+ break;
366
+ case 'bracket-mismatch':
367
+ console.warn(`[timber] ${w.message}`);
368
+ break;
369
+ case 'missing-key':
370
+ console.warn(`[timber] ${w.message}`);
371
+ break;
372
+ }
373
+ }
377
374
  }
378
375
 
379
376
  function invalidateManifest(server: ViteDevServer): void {
@@ -82,13 +82,16 @@ export function buildCodecChainType(
82
82
 
83
83
  /**
84
84
  * Build a TypeScript type expression for a single param using the global
85
- * schema's InferCodecType. Falls back to `fallback` when schema doesn't
86
- * declare this param.
85
+ * schema's InferCodecType. Falls back to the param's base type when
86
+ * schema doesn't declare this param (schemas are opt-in per key).
87
87
  *
88
- * Generates: `InferCodecType<typeof schema.segmentParams['[paramName]']>`
88
+ * Generates a conditional that checks key existence:
89
+ * `[bracketName] extends keyof typeof schema.segmentParams ? InferCodecType<...> : fallback`
89
90
  */
90
- export function buildSchemaParamType(p: ParamEntry): string {
91
- return `InferCodecType<typeof schema.segmentParams[${JSON.stringify(p.bracketName)}]>`;
91
+ export function buildSchemaParamType(p: ParamEntry, fallback?: string): string {
92
+ const key = JSON.stringify(p.bracketName);
93
+ const fb = fallback ?? p.type;
94
+ return `${key} extends keyof typeof schema['segmentParams'] ? InferCodecType<typeof schema.segmentParams[${key}]> : ${fb}`;
92
95
  }
93
96
 
94
97
  /**
@@ -321,10 +321,40 @@ function formatDeclarationFile(
321
321
  ? './' + relative(importBase, join(appDir, 'schema')).replace(/\\/g, '/')
322
322
  : '../app/schema';
323
323
  lines.push(`import type schema from '${schemaImport}';`);
324
- lines.push("import type { InferCodecType } from '@timber-js/app/params';");
324
+ lines.push("import type { InferCodecType, SegmentParamCodec } from '@timber-js/app/params';");
325
325
  lines.push('');
326
- // Emit TimberSegmentParams, TimberRoute, AllSegmentParams, RoutesWithParams
327
- lines.push(...formatSchemaTypes(routes));
326
+ // Collect all unique bracket keys from routes for key validation
327
+ const allBracketKeys = new Set<string>();
328
+ for (const route of routes) {
329
+ for (const p of route.params) {
330
+ allBracketKeys.add(p.bracketName);
331
+ }
332
+ }
333
+
334
+ // Augment the ValidDynamicSegmentKeysRegistry so defineSchema constrains keys
335
+ // to known filesystem segments. Unknown keys produce inline TS errors.
336
+ if (allBracketKeys.size > 0) {
337
+ lines.push("declare module '@timber-js/app/params' {");
338
+ lines.push(' interface ValidDynamicSegmentKeysRegistry {');
339
+ for (const key of [...allBracketKeys].sort()) {
340
+ lines.push(` ${JSON.stringify(key)}: true;`);
341
+ }
342
+ lines.push(' }');
343
+ lines.push('}');
344
+ lines.push('');
345
+ }
346
+ // Type-level validation: each schema value must be a Codec or Standard Schema.
347
+ // Running `pnpm run typecheck` will surface errors for invalid values.
348
+ lines.push('type _AssertSchemaValues = {');
349
+ lines.push(
350
+ " [K in keyof typeof schema['segmentParams']]: typeof schema['segmentParams'][K] extends SegmentParamCodec ? true : ['Error: value for', K, 'is not a valid Codec or Standard Schema'];"
351
+ );
352
+ lines.push('};');
353
+ // Generic constraint enforces that all values are `true` — if any value is
354
+ // an error tuple, TS reports a real diagnostic instead of silently computing it.
355
+ lines.push(
356
+ 'type _EnforceSchemaValues<_T extends Record<string, true> = _AssertSchemaValues> = true;'
357
+ );
328
358
  lines.push('');
329
359
  }
330
360
 
@@ -350,6 +380,16 @@ function formatDeclarationFile(
350
380
  }
351
381
 
352
382
  lines.push(' }');
383
+
384
+ // When a global schema exists, emit segment param types inside the
385
+ // @timber-js/app module augmentation so that declare module blocks
386
+ // for @timber-js/app/server and @timber-js/app/client can reference
387
+ // AllSegmentParams via `import type { AllSegmentParams } from '@timber-js/app'`.
388
+ if (hasSchema) {
389
+ lines.push('');
390
+ lines.push(...formatSchemaTypes(routes).map((l) => ` ${l}`));
391
+ }
392
+
353
393
  lines.push('}');
354
394
  lines.push('');
355
395
 
@@ -359,13 +399,20 @@ function formatDeclarationFile(
359
399
  // Generate @timber-js/app/server augmentation — typed getSegmentParams
360
400
  if (dynamicRoutes.length > 0) {
361
401
  lines.push("declare module '@timber-js/app/server' {");
402
+ if (hasSchema) {
403
+ lines.push(" import type { AllSegmentParams } from '@timber-js/app'");
404
+ }
362
405
  for (const route of dynamicRoutes) {
363
406
  const paramsType = formatParamsType(route.params, importBase, hasSchema);
364
407
  lines.push(
365
408
  ` export function getSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`
366
409
  );
367
410
  }
368
- lines.push(' export function getSegmentParams(): Record<string, string | string[]>');
411
+ lines.push(
412
+ hasSchema
413
+ ? ' export function getSegmentParams(): Partial<AllSegmentParams>'
414
+ : ' export function getSegmentParams(): Record<string, string | string[]>'
415
+ );
369
416
  lines.push('}');
370
417
  lines.push('');
371
418
  }
@@ -377,6 +424,9 @@ function formatDeclarationFile(
377
424
  lines.push(
378
425
  " import type { SearchParamsDefinition, SetParams, QueryStatesOptions, SearchParamCodec } from '@timber-js/app/search-params'"
379
426
  );
427
+ if (hasSchema) {
428
+ lines.push(" import type { AllSegmentParams } from '@timber-js/app'");
429
+ }
380
430
  lines.push('');
381
431
 
382
432
  // useSegmentParams overloads
@@ -387,7 +437,11 @@ function formatDeclarationFile(
387
437
  ` export function useSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`
388
438
  );
389
439
  }
390
- lines.push(' export function useSegmentParams(): Record<string, string | string[]>');
440
+ lines.push(
441
+ hasSchema
442
+ ? ' export function useSegmentParams(): Partial<AllSegmentParams>'
443
+ : ' export function useSegmentParams(): Record<string, string | string[]>'
444
+ );
391
445
  lines.push('');
392
446
  }
393
447
 
@@ -16,3 +16,8 @@ export { classifyUrlSegment, classifySegment } from './segment-classify.js';
16
16
  export type { UrlSegment, SegmentClassification } from './segment-classify.js';
17
17
  export { collectLeafRoutes } from './walkers.js';
18
18
  export type { LeafRoute, CollectLeafRoutesOptions } from './walkers.js';
19
+ export {
20
+ validateSchemaAgainstRoutes,
21
+ collectDynamicSegmentsFromTree,
22
+ } from './schema-validation.js';
23
+ export type { SchemaWarning } from './schema-validation.js';
@@ -83,7 +83,7 @@ export function formatLinkParamsType(
83
83
  const fields = params.map((p) => {
84
84
  const fallback = p.type === 'string' ? 'string | number' : p.type;
85
85
  const codecType = hasSchema
86
- ? buildSchemaParamType(p)
86
+ ? buildSchemaParamType(p, fallback)
87
87
  : buildCodecChainType(p, importBase, fallback);
88
88
  return `${p.name}: ${codecType}`;
89
89
  });
@@ -106,6 +106,10 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
106
106
  // e.g., /[id]/items/[id] — same param name in ancestor and descendant
107
107
  validateDuplicateParamNames(tree.root);
108
108
 
109
+ // Validate: detect same param name used with different bracket forms
110
+ // e.g., [id] in one route and [...id] in another
111
+ validateUniqueBracketForms(tree.root);
112
+
109
113
  return tree;
110
114
  }
111
115
 
@@ -457,6 +461,60 @@ function walkForDuplicateParams(node: SegmentNode, seen: Map<string, string>): v
457
461
  }
458
462
  }
459
463
 
464
+ /**
465
+ * Validate that each param name uses a single bracket form across the entire route tree.
466
+ *
467
+ * Different bracket forms produce different types:
468
+ * [id] → string
469
+ * [...id] → string[]
470
+ * [[...id]] → string[] | undefined
471
+ *
472
+ * If the same param name appears with multiple forms, the no-argument
473
+ * getSegmentParams() / useSegmentParams() return type becomes ambiguous.
474
+ *
475
+ * Same name + same form across different routes is fine and expected.
476
+ */
477
+ function validateUniqueBracketForms(root: SegmentNode): void {
478
+ const forms = new Map<string, { segmentType: SegmentType; urlPath: string }>();
479
+ collectBracketForms(root, forms);
480
+ }
481
+
482
+ function collectBracketForms(
483
+ node: SegmentNode,
484
+ forms: Map<string, { segmentType: SegmentType; urlPath: string }>
485
+ ): void {
486
+ if (node.paramName && node.segmentType !== 'static' && node.segmentType !== 'group') {
487
+ const existing = forms.get(node.paramName);
488
+ if (existing && existing.segmentType !== node.segmentType) {
489
+ const bracketExamples: Record<string, string> = {
490
+ 'dynamic': `[${node.paramName}]`,
491
+ 'catch-all': `[...${node.paramName}]`,
492
+ 'optional-catch-all': `[[...${node.paramName}]]`,
493
+ };
494
+ throw new Error(
495
+ `[timber] Param name '${node.paramName}' uses multiple bracket forms.\n` +
496
+ ` ${bracketExamples[existing.segmentType] ?? existing.segmentType} at: ${existing.urlPath}\n` +
497
+ ` ${bracketExamples[node.segmentType] ?? node.segmentType} at: ${node.urlPath || '/'}\n` +
498
+ ` Each param name must use a single bracket form across all routes.\n` +
499
+ ` Rename one of the segments to fix this (e.g., [${node.paramName}] → [${node.paramName}Id]).`
500
+ );
501
+ }
502
+ if (!existing) {
503
+ forms.set(node.paramName, {
504
+ segmentType: node.segmentType,
505
+ urlPath: node.urlPath || '/',
506
+ });
507
+ }
508
+ }
509
+
510
+ for (const child of node.children) {
511
+ collectBracketForms(child, forms);
512
+ }
513
+ for (const slotNode of Object.values(node.slots)) {
514
+ collectBracketForms(slotNode, forms);
515
+ }
516
+ }
517
+
460
518
  /**
461
519
  * Find a fixed-extension file (proxy.ts) in a directory.
462
520
  */