@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
@@ -264,8 +264,11 @@ export function timberDevBrowserLogs(ctx: PluginContext): Plugin {
264
264
  } else {
265
265
  process.stdout.write(formatted + '\n');
266
266
  }
267
- } catch {
268
- // Never let log forwarding crash the server
267
+ } catch (e) {
268
+ // Use process.stderr directly to avoid re-entering patched console
269
+ process.stderr.write(
270
+ `[timber] log forwarding failed: ${e instanceof Error ? e.message : e}\n`
271
+ );
269
272
  }
270
273
  });
271
274
  },
@@ -14,11 +14,7 @@
14
14
  * Design doc: 21-dev-server.md §"Error Overlay"
15
15
  */
16
16
 
17
- import {
18
- extractComponentStack,
19
- parseFirstAppFrame,
20
- type ErrorPhase,
21
- } from './overlay.js';
17
+ import { extractComponentStack, parseFirstAppFrame, type ErrorPhase } from './overlay.js';
22
18
  import { classifyStack } from './stack-classifier.js';
23
19
  import { escapeHtml } from '../server/utils/escape-html.js';
24
20
 
@@ -72,7 +68,8 @@ function readSourceContext(
72
68
  highlight: start + 1 + i === line,
73
69
  })),
74
70
  };
75
- } catch {
71
+ } catch (e) {
72
+ console.debug('[timber] source context extraction failed:', e instanceof Error ? e.message : e);
76
73
  return null;
77
74
  }
78
75
  }
@@ -238,8 +238,12 @@ function patchConsole(server: ViteDevServer, projectRoot: string): () => void {
238
238
  };
239
239
 
240
240
  server.hot.send('timber:server-log', payload);
241
- } catch {
242
- // Never let log forwarding break the server
241
+ } catch (e) {
242
+ // Use the original console.debug to avoid re-entering the patched handler
243
+ originals.get('debug')!(
244
+ '[timber] server log forwarding failed:',
245
+ e instanceof Error ? e.message : e
246
+ );
243
247
  }
244
248
  };
245
249
  }
@@ -73,7 +73,12 @@ export const PHASE_LABELS: Record<ErrorPhase, string> = {
73
73
 
74
74
  // ─── Frame Classification (re-exported from stack-classifier) ───────────────
75
75
 
76
- export { classifyFrame, classifyStack, type FrameType, type ClassifiedFrame } from './stack-classifier.js';
76
+ export {
77
+ classifyFrame,
78
+ classifyStack,
79
+ type FrameType,
80
+ type ClassifiedFrame,
81
+ } from './stack-classifier.js';
77
82
 
78
83
  // ─── Component Stack Extraction ─────────────────────────────────────────────
79
84
 
@@ -252,7 +257,11 @@ export function calculateModuleRunnerOffset(): number {
252
257
  const beforeBody = src.slice(0, bodyIndex);
253
258
  const newlineCount = (beforeBody.match(/\n/g) || []).length;
254
259
  _cachedOffset = newlineCount;
255
- } catch {
260
+ } catch (e) {
261
+ console.debug(
262
+ '[timber] overlay offset calculation failed:',
263
+ e instanceof Error ? e.message : e
264
+ );
256
265
  _cachedOffset = 2; // fallback to known-good value
257
266
  }
258
267
 
@@ -351,7 +360,11 @@ function rewriteStacktrace(
351
360
  const { TraceMap: TM, originalPositionFor: opf } = getTraceMapping();
352
361
  const traced = new TM(rawSourceMap);
353
362
  pos = opf(traced, { line: origLine, column: origCol });
354
- } catch {
363
+ } catch (e) {
364
+ console.debug(
365
+ '[timber] source map tracing failed:',
366
+ e instanceof Error ? e.message : e
367
+ );
355
368
  return input;
356
369
  }
357
370
 
@@ -450,7 +463,7 @@ export function sendErrorToOverlay(
450
463
  loc: loc ? { file: loc.file, line: loc.line, column: loc.column } : undefined,
451
464
  },
452
465
  });
453
- } catch {
454
- // Overlay send must never crash the dev server
466
+ } catch (e) {
467
+ console.debug('[timber] overlay error send failed:', e instanceof Error ? e.message : e);
455
468
  }
456
469
  }
package/src/index.ts CHANGED
@@ -51,6 +51,11 @@ import {
51
51
 
52
52
  export type { TimberUserConfig } from './config-types.js';
53
53
 
54
+ // Global segment param schema types — re-exported from root for convenience.
55
+ // The value export `defineSchema` lives at '@timber-js/app/params' (canonical).
56
+ // See design/41-global-params.md.
57
+ export type { InferCodecType, SchemaConfig } from './params/define-schema.js';
58
+
54
59
  // Metadata route handler types — re-exported so user-authored metadata
55
60
  // route files (sitemap.ts, robots.ts, manifest.ts, icon.tsx, etc.) can
56
61
  // import them from the package root without reaching into ./server.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Client-side registry for schema segment param codecs.
3
+ *
4
+ * The Link component uses codec `serialize` functions to convert typed
5
+ * segment param values back to URL-safe strings. Without this registry,
6
+ * Link falls back to `String(value)` — which works for simple types
7
+ * but breaks for custom codecs (e.g., Date → '2026-04-10').
8
+ *
9
+ * Populated at startup by the browser entry importing `virtual:timber-schema`.
10
+ * Also usable on the server (isomorphic).
11
+ *
12
+ * Design doc: design/41-global-params.md §"Link Component"
13
+ */
14
+
15
+ import type { Codec } from '../codec.js';
16
+
17
+ let _codecs: Record<string, Codec<unknown>> | null = null;
18
+
19
+ /**
20
+ * Register the resolved schema codecs for use by Link's URL interpolation.
21
+ * Called once at startup from the browser entry or RSC entry.
22
+ * @internal
23
+ */
24
+ export function setLinkCodecs(codecs: Record<string, Codec<unknown>> | null): void {
25
+ _codecs = codecs;
26
+ }
27
+
28
+ /**
29
+ * Look up a codec by bracket key (e.g. '[id]', '[...slug]').
30
+ * Returns undefined if no schema is registered or the key has no codec.
31
+ */
32
+ export function getLinkCodec(bracketKey: string): Codec<unknown> | undefined {
33
+ return _codecs?.[bracketKey] ?? undefined;
34
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * defineSchema — global segment param schema definition.
3
+ *
4
+ * Identity function at runtime; the type information is what matters.
5
+ * The Vite plugin reads the schema at build time for codegen, and
6
+ * the pipeline reads it at runtime for coercion.
7
+ *
8
+ * Design doc: design/41-global-params.md
9
+ */
10
+
11
+ import type { Codec } from '../codec.js';
12
+ import type { StandardSchemaV1 } from '../schema-bridge.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Infer the output type from a Codec, StandardSchemaV1, or nuqs parser.
20
+ *
21
+ * Used by codegen to reference schema types without hardcoding:
22
+ * `InferCodecType<typeof schema.segmentParams['[year]']>`
23
+ */
24
+ export type InferCodecType<V> =
25
+ V extends Codec<infer T>
26
+ ? T
27
+ : V extends StandardSchemaV1<infer T>
28
+ ? T
29
+ : V extends { parseServerSide: (value: string) => infer T }
30
+ ? T
31
+ : string;
32
+
33
+ /** Acceptable codec value for segment params: a Codec or Standard Schema. */
34
+ type SegmentParamCodec = Codec<unknown> | StandardSchemaV1<unknown>;
35
+
36
+ /** Configuration object for defineSchema. */
37
+ export interface SchemaConfig<S extends Record<string, SegmentParamCodec>> {
38
+ segmentParams: S;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Factory
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Define the global segment param schema.
47
+ *
48
+ * Each key uses the bracket syntax from the filesystem (`[id]`, `[...slug]`,
49
+ * `[[...slug]]`) mapped to a codec. This makes the relationship between
50
+ * filesystem segments and codecs visually obvious.
51
+ *
52
+ * A dynamic segment with no key here defaults to `string` at codegen time.
53
+ *
54
+ * ```ts
55
+ * // app/schema.ts
56
+ * import { defineSchema, codec } from '@timber-js/app';
57
+ * import { z } from 'zod';
58
+ *
59
+ * export default defineSchema({
60
+ * segmentParams: {
61
+ * '[artistSlug]': codec.string,
62
+ * '[year]': codec.integer,
63
+ * '[id]': z.coerce.number().int(),
64
+ * },
65
+ * });
66
+ * ```
67
+ */
68
+ export function defineSchema<S extends Record<string, SegmentParamCodec>>(
69
+ config: SchemaConfig<S>
70
+ ): SchemaConfig<S> {
71
+ return config;
72
+ }
@@ -0,0 +1,11 @@
1
+ // @timber-js/app/params — Global segment param schema and typed accessors.
2
+ //
3
+ // This is the import path for defineSchema, InferCodecType, and
4
+ // the future getSegmentParams/useSegmentParams with route narrowing.
5
+ //
6
+ // See design/41-global-params.md
7
+
8
+ export { defineSchema } from './define-schema.js';
9
+ export type { InferCodecType, SchemaConfig } from './define-schema.js';
10
+ export { resolveSchemaCodecs, toBracketKey } from './resolve-schema.js';
11
+ export { setLinkCodecs, getLinkCodec } from './codec-registry.js';
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Schema resolution — resolves the global schema's segmentParams into
3
+ * a runtime-ready codec map keyed by bracket name (e.g. '[id]').
4
+ *
5
+ * Bracket keys are preserved so that `[id]`, `[...id]`, and `[[...id]]`
6
+ * remain distinct entries. The coercion layer reconstructs bracket keys
7
+ * from matched segment metadata to look up the correct codec.
8
+ *
9
+ * Design doc: design/41-global-params.md §Pipeline Integration
10
+ */
11
+
12
+ import type { Codec } from '../codec.js';
13
+ import { isCodec, isStandardSchema, validateSync } from '../schema-bridge.js';
14
+ import type { StandardSchemaV1 } from '../schema-bridge.js';
15
+
16
+ /**
17
+ * Wrap a Standard Schema into a Codec for route params.
18
+ * Parse throws on failure (invalid param → 404). Serialize returns string.
19
+ */
20
+ function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
21
+ return {
22
+ parse(value: string | string[] | undefined): T {
23
+ const result = validateSync(schema, value);
24
+ if (!result.issues) {
25
+ return result.value;
26
+ }
27
+ const messages = result.issues.map((i) => i.message).join(', ');
28
+ throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
29
+ },
30
+ serialize(value: T): string | null {
31
+ if (value === null || value === undefined) return null;
32
+ if (Array.isArray(value)) return value.join('/');
33
+ return String(value);
34
+ },
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Resolve a schema entry to a Codec. Accepts Codec<T> or StandardSchemaV1<T>.
40
+ */
41
+ function resolveCodec(fieldName: string, value: unknown): Codec<unknown> {
42
+ if (isCodec(value)) return value;
43
+ if (isStandardSchema(value)) return fromParamSchema(fieldName, value);
44
+ throw new Error(`[timber] Schema field '${fieldName}' is not a valid codec or Standard Schema.`);
45
+ }
46
+
47
+ /**
48
+ * Reconstruct the bracket key for a segment from its type and param name.
49
+ * Used by the coercion layer to look up codecs in the bracket-keyed map.
50
+ *
51
+ * 'dynamic' + 'id' → '[id]'
52
+ * 'catch-all' + 'slug' → '[...slug]'
53
+ * 'optional-catch-all' + 'slug' → '[[...slug]]'
54
+ */
55
+ export function toBracketKey(segmentType: string, paramName: string): string {
56
+ switch (segmentType) {
57
+ case 'catch-all':
58
+ return `[...${paramName}]`;
59
+ case 'optional-catch-all':
60
+ return `[[...${paramName}]]`;
61
+ default:
62
+ return `[${paramName}]`;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve the global schema's segmentParams into a map of bracket-keyed
68
+ * param name → resolved Codec.
69
+ *
70
+ * Keys are preserved exactly as written in `app/schema.ts` so that
71
+ * `[id]`, `[...id]`, and `[[...id]]` remain distinct entries.
72
+ * The coercion layer in `param-coercion.ts` reconstructs bracket keys
73
+ * from matched segment metadata to look up the correct codec.
74
+ *
75
+ * Input: { '[id]': z.coerce.number(), '[...slug]': codec.string }
76
+ * Output: { '[id]': Codec<number>, '[...slug]': Codec<string> }
77
+ */
78
+ export function resolveSchemaCodecs(
79
+ segmentParams: Record<string, unknown>
80
+ ): Record<string, Codec<unknown>> {
81
+ const resolved: Record<string, Codec<unknown>> = Object.create(null);
82
+
83
+ for (const [bracketName, value] of Object.entries(segmentParams)) {
84
+ resolved[bracketName] = resolveCodec(bracketName, value);
85
+ }
86
+
87
+ return resolved;
88
+ }
@@ -190,20 +190,25 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
190
190
  handler = rscModule.default as (req: Request) => Promise<Response>;
191
191
 
192
192
  // Wire pipeline errors into the browser error overlay.
193
- // setDevPipelineErrorHandler is only defined in dev (rsc-entry.ts exports it).
194
- // The handler receives optional RSC debug component data (component names,
195
- // environments, stack frames) from the Flight debug channel for render errors.
196
- const setHandler = rscModule.setDevPipelineErrorHandler as
197
- | ((
198
- fn: (
193
+ // Set devPipelineErrorHandler directly on the pipeline config —
194
+ // no module-level singleton. The handler receives optional RSC debug
195
+ // component data (component names, environments, stack frames) from
196
+ // the Flight debug channel for render errors.
197
+ const config = rscModule.pipelineConfig as
198
+ | {
199
+ devPipelineErrorHandler?: (
199
200
  error: Error,
200
201
  phase: string,
201
- debugComponents?: Array<{ name: string; env: string | null; stack: unknown[] | null }>
202
- ) => void
203
- ) => void)
202
+ debugComponents?: Array<{
203
+ name: string;
204
+ env: string | null;
205
+ stack: unknown[] | null;
206
+ }>
207
+ ) => void;
208
+ }
204
209
  | undefined;
205
- if (typeof setHandler === 'function') {
206
- setHandler((error, _phase, debugComponents) => {
210
+ if (config) {
211
+ config.devPipelineErrorHandler = (error, _phase, debugComponents) => {
207
212
  sendErrorToOverlay(
208
213
  server,
209
214
  error,
@@ -211,7 +216,7 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
211
216
  projectRoot,
212
217
  debugComponents
213
218
  );
214
- });
219
+ };
215
220
  }
216
221
 
217
222
  // Wire source-map handler so error pages show original positions.
@@ -316,8 +321,11 @@ function respond500(
316
321
  res.setHeader('content-type', 'text/html; charset=utf-8');
317
322
  res.end(html);
318
323
  return;
319
- } catch {
320
- // Fall through to text/plain if the HTML generator fails
324
+ } catch (e) {
325
+ console.debug(
326
+ '[timber] dev error page generation failed, falling back to text/plain:',
327
+ e instanceof Error ? e.message : e
328
+ );
321
329
  }
322
330
  }
323
331
 
@@ -524,8 +532,8 @@ function listenForClientErrors(server: ViteDevServer, projectRoot: string): void
524
532
  loc: loc ? { file: loc.file, line: loc.line, column: loc.column } : undefined,
525
533
  },
526
534
  });
527
- } catch {
528
- // Overlay send must never crash the dev server
535
+ } catch (e) {
536
+ console.debug('[timber] overlay send failed:', e instanceof Error ? e.message : e);
529
537
  }
530
538
  });
531
539
  }
@@ -92,7 +92,11 @@ async function tryImport(name: string, projectRoot: string): Promise<unknown | u
92
92
  try {
93
93
  const mod = await import(name);
94
94
  return mod.default ?? mod;
95
- } catch {
95
+ } catch (e) {
96
+ console.debug(
97
+ `[timber] optional MDX dependency '${name}' not available:`,
98
+ e instanceof Error ? e.message : e
99
+ );
96
100
  return undefined;
97
101
  }
98
102
  }
@@ -15,6 +15,11 @@ import { join } from 'node:path';
15
15
  import { scanRoutes } from '../routing/scanner.js';
16
16
  import { generateRouteMap } from '../routing/codegen.js';
17
17
  import { collectInterceptionRewrites } from '../routing/interception.js';
18
+ import {
19
+ collectDynamicSegments,
20
+ findSchemaFile,
21
+ parseExistingSchemaKeys,
22
+ } from '../cli-schema-sync.js';
18
23
 
19
24
  import {
20
25
  lintConventions,
@@ -42,11 +47,19 @@ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
42
47
  const SEARCH_PARAMS_REGISTRY_ID = 'virtual:timber-search-params-registry';
43
48
  const RESOLVED_SEARCH_PARAMS_REGISTRY_ID = `\0${SEARCH_PARAMS_REGISTRY_ID}`;
44
49
 
50
+ // TIM-931: Schema virtual module.
51
+ //
52
+ // When app/schema.ts exists, this module imports it and resolves the
53
+ // bracket-keyed segmentParams codecs into a bare-name codec map for
54
+ // runtime coercion. When no schema exists, exports null.
55
+ const SCHEMA_ID = 'virtual:timber-schema';
56
+ const RESOLVED_SCHEMA_ID = `\0${SCHEMA_ID}`;
57
+
45
58
  /**
46
59
  * File convention names we track for changes that require manifest regeneration.
47
60
  */
48
61
  const ROUTE_FILE_PATTERNS =
49
- /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
62
+ /\/(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)\./;
50
63
 
51
64
  /**
52
65
  * Create the timber-routing Vite plugin.
@@ -126,6 +139,11 @@ export function timberRouting(ctx: PluginContext): Plugin {
126
139
  ctx.timer.end('route-scan');
127
140
  writeCodegen(ctx);
128
141
 
142
+ // Dev: warn about dynamic segments not registered in app/schema.ts.
143
+ // Only logs a suggestion, never auto-modifies the schema file.
144
+ // See design/41-global-params.md §Auto-Scaffolding
145
+ warnUnregisteredSegments(ctx);
146
+
129
147
  // Lint conventions (empty app, missing methods, missing root layout)
130
148
  const conventionWarnings = lintConventions(ctx.routeTree, ctx.appDir);
131
149
  const newConventionWarnings = conventionWarnings.filter((w) => !warnedFiles.has(w.id));
@@ -168,6 +186,11 @@ export function timberRouting(ctx: PluginContext): Plugin {
168
186
  return RESOLVED_SEARCH_PARAMS_REGISTRY_ID;
169
187
  }
170
188
 
189
+ // TIM-931: Schema virtual module
190
+ if (cleanId === SCHEMA_ID || cleanId.endsWith(`/${SCHEMA_ID}`)) {
191
+ return RESOLVED_SCHEMA_ID;
192
+ }
193
+
171
194
  return null;
172
195
  },
173
196
 
@@ -193,6 +216,10 @@ export function timberRouting(ctx: PluginContext): Plugin {
193
216
  return generateSearchParamsRegistryModule(ctx.routeTree!);
194
217
  }
195
218
 
219
+ if (id === RESOLVED_SCHEMA_ID) {
220
+ return generateSchemaModule(ctx.appDir);
221
+ }
222
+
196
223
  return null;
197
224
  },
198
225
 
@@ -275,12 +302,46 @@ export function timberRouting(ctx: PluginContext): Plugin {
275
302
  * When the route tree changes, any environment that has imported the manifest
276
303
  * needs to be invalidated so the next request/transform picks up the new tree.
277
304
  */
305
+ /** Set of segments we've already warned about — avoid repeating on every rescan. */
306
+ const _warnedSegments = new Set<string>();
307
+
308
+ /**
309
+ * Warn about dynamic segments not registered in app/schema.ts.
310
+ * Only fires when a schema file exists. Logs once per segment per session.
311
+ */
312
+ function warnUnregisteredSegments(ctx: PluginContext): void {
313
+ const schemaPath = findSchemaFile(ctx.appDir);
314
+ if (!schemaPath) return;
315
+
316
+ const filesystemSegments = collectDynamicSegments(ctx.appDir);
317
+ if (filesystemSegments.length === 0) return;
318
+
319
+ const content = readFileSync(schemaPath, 'utf-8');
320
+ const existingKeys = parseExistingSchemaKeys(content);
321
+ const missing = filesystemSegments.filter(
322
+ (seg) => !existingKeys.includes(seg) && !_warnedSegments.has(seg)
323
+ );
324
+
325
+ if (missing.length === 0) return;
326
+
327
+ for (const seg of missing) _warnedSegments.add(seg);
328
+
329
+ console.warn(
330
+ `[timber] New segment${missing.length > 1 ? 's' : ''} not in schema: ${missing.join(', ')}\n` +
331
+ ` Run: timber schema sync`
332
+ );
333
+ }
334
+
278
335
  function invalidateManifest(server: ViteDevServer): void {
279
336
  for (const envName of Object.keys(server.environments)) {
280
337
  const env = server.environments[envName];
281
338
  if (!env?.moduleGraph) continue;
282
339
 
283
- for (const virtualId of [RESOLVED_VIRTUAL_ID, RESOLVED_SEARCH_PARAMS_REGISTRY_ID]) {
340
+ for (const virtualId of [
341
+ RESOLVED_VIRTUAL_ID,
342
+ RESOLVED_SEARCH_PARAMS_REGISTRY_ID,
343
+ RESOLVED_SCHEMA_ID,
344
+ ]) {
284
345
  const mod = env.moduleGraph.getModuleById(virtualId);
285
346
  if (mod) {
286
347
  env.moduleGraph.invalidateModule(mod);
@@ -301,6 +362,50 @@ function invalidateManifest(server: ViteDevServer): void {
301
362
  * <Link> can look up the definition from the original `href` prop before
302
363
  * segmentParams are interpolated.
303
364
  */
365
+ /** Schema file extensions to check, in priority order. */
366
+ const SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
367
+
368
+ /**
369
+ * TIM-931: Generate the virtual:timber-schema module.
370
+ *
371
+ * When app/schema.{ts,tsx,js,jsx} exists, generates a module that
372
+ * imports the schema and resolves its segmentParams codecs into a
373
+ * bare-name codec map for runtime coercion.
374
+ *
375
+ * When no schema exists, exports null.
376
+ */
377
+ function generateSchemaModule(appDir: string): string {
378
+ let schemaPath: string | null = null;
379
+ for (const ext of SCHEMA_EXTENSIONS) {
380
+ const candidate = join(appDir, `schema${ext}`);
381
+ if (existsSync(candidate)) {
382
+ schemaPath = candidate;
383
+ break;
384
+ }
385
+ }
386
+
387
+ if (!schemaPath) {
388
+ return [
389
+ '// Auto-generated schema loader — no app/schema.ts found.',
390
+ 'export default null;',
391
+ ].join('\n');
392
+ }
393
+
394
+ return [
395
+ '// Auto-generated schema loader — do not edit.',
396
+ '// Generated by timber-routing plugin (TIM-931).',
397
+ '',
398
+ `import schema from ${JSON.stringify(schemaPath)};`,
399
+ `import { resolveSchemaCodecs } from '@timber-js/app/params';`,
400
+ '',
401
+ 'const globalCodecs = schema?.segmentParams',
402
+ ' ? resolveSchemaCodecs(schema.segmentParams)',
403
+ ' : null;',
404
+ '',
405
+ 'export default globalCodecs;',
406
+ ].join('\n');
407
+ }
408
+
304
409
  function generateSearchParamsRegistryModule(tree: RouteTree): string {
305
410
  const entries: Array<{ urlPath: string; filePath: string }> = [];
306
411
 
@@ -80,6 +80,17 @@ export function buildCodecChainType(
80
80
  return inner;
81
81
  }
82
82
 
83
+ /**
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.
87
+ *
88
+ * Generates: `InferCodecType<typeof schema.segmentParams['[paramName]']>`
89
+ */
90
+ export function buildSchemaParamType(p: ParamEntry): string {
91
+ return `InferCodecType<typeof schema.segmentParams[${JSON.stringify(p.bracketName)}]>`;
92
+ }
93
+
83
94
  /**
84
95
  * Format the searchParams type for a route entry.
85
96
  *
@@ -18,10 +18,15 @@ export interface RouteEntry {
18
18
  searchParamsPagePath?: string;
19
19
  /** Whether this is an API route (route.ts) vs page route */
20
20
  isApiRoute: boolean;
21
+ /** Whether a global schema (app/schema.ts) exists for this app */
22
+ hasSchema?: boolean;
21
23
  }
22
24
 
23
25
  export interface ParamEntry {
24
26
  name: string;
27
+ /** Bracket-wrapped segment name as it appears in the filesystem and schema.
28
+ * e.g. '[id]', '[...slug]', '[[...slug]]' */
29
+ bracketName: string;
25
30
  type: 'string' | 'string[]' | 'string[] | undefined';
26
31
  /**
27
32
  * Ordered list of params.ts files in the route chain that may declare