@timber-js/app 0.2.0-alpha.2 → 0.2.0-alpha.21

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 (128) hide show
  1. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  3. package/dist/_chunks/debug-B3Gypr3D.js +108 -0
  4. package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
  6. package/dist/_chunks/format-RyoGQL74.js.map +1 -0
  7. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  8. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  9. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  11. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-BQUC8PHn.js} +5 -4
  12. package/dist/_chunks/request-context-BQUC8PHn.js.map +1 -0
  13. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  15. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  16. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  17. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  21. package/dist/adapters/nitro.d.ts +17 -1
  22. package/dist/adapters/nitro.d.ts.map +1 -1
  23. package/dist/adapters/nitro.js +17 -5
  24. package/dist/adapters/nitro.js.map +1 -1
  25. package/dist/cache/fast-hash.d.ts +22 -0
  26. package/dist/cache/fast-hash.d.ts.map +1 -0
  27. package/dist/cache/index.js +52 -10
  28. package/dist/cache/index.js.map +1 -1
  29. package/dist/cache/register-cached-function.d.ts.map +1 -1
  30. package/dist/cache/timber-cache.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +1 -1
  32. package/dist/client/index.js +3 -3
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/link.d.ts.map +1 -1
  35. package/dist/client/router.d.ts.map +1 -1
  36. package/dist/client/segment-context.d.ts +1 -1
  37. package/dist/client/segment-context.d.ts.map +1 -1
  38. package/dist/client/segment-merger.d.ts.map +1 -1
  39. package/dist/client/stale-reload.d.ts.map +1 -1
  40. package/dist/client/top-loader.d.ts.map +1 -1
  41. package/dist/client/transition-root.d.ts +1 -1
  42. package/dist/client/transition-root.d.ts.map +1 -1
  43. package/dist/cookies/index.js +4 -4
  44. package/dist/fonts/css.d.ts +1 -0
  45. package/dist/fonts/css.d.ts.map +1 -1
  46. package/dist/fonts/local.d.ts +4 -2
  47. package/dist/fonts/local.d.ts.map +1 -1
  48. package/dist/index.d.ts +28 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +249 -21
  51. package/dist/index.js.map +1 -1
  52. package/dist/plugins/build-report.d.ts +11 -1
  53. package/dist/plugins/build-report.d.ts.map +1 -1
  54. package/dist/plugins/entries.d.ts +7 -0
  55. package/dist/plugins/entries.d.ts.map +1 -1
  56. package/dist/plugins/fonts.d.ts +9 -1
  57. package/dist/plugins/fonts.d.ts.map +1 -1
  58. package/dist/plugins/mdx.d.ts +6 -0
  59. package/dist/plugins/mdx.d.ts.map +1 -1
  60. package/dist/plugins/server-bundle.d.ts.map +1 -1
  61. package/dist/routing/index.js +1 -1
  62. package/dist/search-params/index.js +1 -1
  63. package/dist/server/access-gate.d.ts.map +1 -1
  64. package/dist/server/action-client.d.ts.map +1 -1
  65. package/dist/server/debug.d.ts +82 -0
  66. package/dist/server/debug.d.ts.map +1 -0
  67. package/dist/server/deny-renderer.d.ts.map +1 -1
  68. package/dist/server/dev-warnings.d.ts.map +1 -1
  69. package/dist/server/html-injectors.d.ts.map +1 -1
  70. package/dist/server/index.js +32 -23
  71. package/dist/server/index.js.map +1 -1
  72. package/dist/server/logger.d.ts.map +1 -1
  73. package/dist/server/pipeline.d.ts +7 -4
  74. package/dist/server/pipeline.d.ts.map +1 -1
  75. package/dist/server/primitives.d.ts.map +1 -1
  76. package/dist/server/request-context.d.ts.map +1 -1
  77. package/dist/server/route-element-builder.d.ts.map +1 -1
  78. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  79. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  80. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  81. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  83. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  84. package/dist/server/tracing.d.ts +10 -0
  85. package/dist/server/tracing.d.ts.map +1 -1
  86. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  87. package/dist/shims/image.d.ts +15 -15
  88. package/package.json +1 -1
  89. package/src/adapters/nitro.ts +43 -5
  90. package/src/cache/fast-hash.ts +34 -0
  91. package/src/cache/register-cached-function.ts +7 -3
  92. package/src/cache/timber-cache.ts +17 -10
  93. package/src/client/browser-entry.ts +10 -6
  94. package/src/client/link.tsx +14 -9
  95. package/src/client/router.ts +4 -6
  96. package/src/client/segment-context.ts +6 -1
  97. package/src/client/segment-merger.ts +2 -8
  98. package/src/client/stale-reload.ts +5 -7
  99. package/src/client/top-loader.tsx +23 -19
  100. package/src/client/transition-root.tsx +7 -1
  101. package/src/fonts/css.ts +2 -1
  102. package/src/fonts/local.ts +7 -3
  103. package/src/index.ts +35 -2
  104. package/src/plugins/build-report.ts +23 -3
  105. package/src/plugins/entries.ts +9 -4
  106. package/src/plugins/fonts.ts +171 -19
  107. package/src/plugins/mdx.ts +9 -5
  108. package/src/plugins/server-bundle.ts +4 -0
  109. package/src/server/access-gate.tsx +3 -2
  110. package/src/server/action-client.ts +15 -5
  111. package/src/server/debug.ts +137 -0
  112. package/src/server/deny-renderer.ts +3 -2
  113. package/src/server/dev-warnings.ts +2 -1
  114. package/src/server/html-injectors.ts +30 -10
  115. package/src/server/logger.ts +4 -3
  116. package/src/server/pipeline.ts +34 -20
  117. package/src/server/primitives.ts +2 -1
  118. package/src/server/request-context.ts +3 -2
  119. package/src/server/route-element-builder.ts +1 -6
  120. package/src/server/rsc-entry/index.ts +50 -7
  121. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  122. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  123. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  124. package/src/server/rsc-prop-warnings.ts +3 -1
  125. package/src/server/tracing.ts +23 -0
  126. package/src/server/waituntil-bridge.ts +4 -1
  127. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  128. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -186,10 +186,7 @@ function walkChildren(children: ReactNode, out: CachedSegmentEntry[]): void {
186
186
  * Cache all segment subtrees from a fully-rendered RSC element tree.
187
187
  * Call this after every full RSC payload render (navigate, refresh, hydration).
188
188
  */
189
- export function cacheSegmentElements(
190
- element: unknown,
191
- cache: SegmentElementCache
192
- ): void {
189
+ export function cacheSegmentElements(element: unknown, cache: SegmentElementCache): void {
193
190
  const segments = extractSegments(element);
194
191
  for (const entry of segments) {
195
192
  cache.set(entry.segmentPath, entry);
@@ -208,10 +205,7 @@ export function cacheSegmentElements(
208
205
  */
209
206
  type TreePath = Array<{ element: ReactElement; childIndex: number }>;
210
207
 
211
- function findSegmentProviderPath(
212
- node: ReactElement,
213
- targetPath?: string
214
- ): TreePath | null {
208
+ function findSegmentProviderPath(node: ReactElement, targetPath?: string): TreePath | null {
215
209
  const children = (node.props as { children?: ReactNode }).children;
216
210
  if (children == null) return null;
217
211
 
@@ -29,7 +29,7 @@ const RELOAD_FLAG_KEY = '__timber_stale_reload';
29
29
  export function isStaleClientReference(error: unknown): boolean {
30
30
  if (!(error instanceof Error)) return false;
31
31
  const msg = error.message;
32
- return msg.includes('Could not find the module');
32
+ return msg.includes('Could not find the module') || msg.includes('client reference not found');
33
33
  }
34
34
 
35
35
  /**
@@ -48,8 +48,8 @@ export function triggerStaleReload(): boolean {
48
48
  if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
49
49
  console.warn(
50
50
  '[timber] Stale client reference detected again after reload. ' +
51
- 'Not reloading to prevent infinite loop. ' +
52
- 'This may indicate a deployment issue — try a hard refresh.'
51
+ 'Not reloading to prevent infinite loop. ' +
52
+ 'This may indicate a deployment issue — try a hard refresh.'
53
53
  );
54
54
  return false;
55
55
  }
@@ -59,7 +59,7 @@ export function triggerStaleReload(): boolean {
59
59
 
60
60
  console.warn(
61
61
  '[timber] Stale client reference detected — the server has been ' +
62
- 'redeployed with new bundles. Reloading to pick up the new version.'
62
+ 'redeployed with new bundles. Reloading to pick up the new version.'
63
63
  );
64
64
 
65
65
  window.location.reload();
@@ -67,9 +67,7 @@ export function triggerStaleReload(): boolean {
67
67
  } catch {
68
68
  // sessionStorage may be unavailable (private browsing, storage full, etc.)
69
69
  // Fall back to reloading without loop protection
70
- console.warn(
71
- '[timber] Stale client reference detected. Reloading page.'
72
- );
70
+ console.warn('[timber] Stale client reference detected. Reloading page.');
73
71
  window.location.reload();
74
72
  return true;
75
73
  }
@@ -60,6 +60,7 @@ const DEFAULT_Z_INDEX = 1600;
60
60
  // Unique keyframes name to avoid collisions with user styles.
61
61
  const CRAWL_KEYFRAMES = '__timber_top_loader_crawl';
62
62
  const APPEAR_KEYFRAMES = '__timber_top_loader_appear';
63
+ const FINISH_KEYFRAMES = '__timber_top_loader_finish';
63
64
 
64
65
  // Track whether the @keyframes rules have been injected into the document.
65
66
  let keyframesInjected = false;
@@ -83,6 +84,11 @@ function ensureKeyframes(): void {
83
84
  from { opacity: 0; }
84
85
  to { opacity: 1; }
85
86
  }
87
+ @keyframes ${FINISH_KEYFRAMES} {
88
+ 0% { width: 90%; opacity: 1; }
89
+ 50% { width: 100%; opacity: 1; }
90
+ 100% { width: 100%; opacity: 0; }
91
+ }
86
92
  `;
87
93
  document.head.appendChild(style);
88
94
  keyframesInjected = true;
@@ -161,14 +167,13 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
161
167
  ].join(', '),
162
168
  }
163
169
  : {
164
- // Finishing: snap to 100% width (200ms), THEN fade out (200ms).
165
- // The opacity transition is delayed so the user sees the bar
166
- // reach 100% before it disappears. Without the delay, both
167
- // transitions run simultaneously and the bar fades before the
168
- // fill animation is visible.
169
- width: '100%',
170
- opacity: 0,
171
- transition: 'width 200ms ease, opacity 200ms ease 200ms',
170
+ // Finishing: fill to 100% then fade out via a keyframe animation.
171
+ // We use a keyframe instead of a CSS transition because the
172
+ // animation-to-transition handoff is unreliable the browser
173
+ // may not capture the animated width as the transition's "from"
174
+ // value when both the animation removal and transition are
175
+ // applied in the same render frame.
176
+ animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,
172
177
  }),
173
178
  ...(shadow
174
179
  ? {
@@ -177,24 +182,23 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
177
182
  : {}),
178
183
  };
179
184
 
180
- // Clean up the finishing phase when the CSS transition completes.
181
- // onTransitionEnd fires once per transitioned property — we act on
182
- // the first one (opacity) and ignore subsequent (width).
183
- const handleTransitionEnd = phase === 'finishing'
184
- ? (e: React.TransitionEvent) => {
185
- if (e.propertyName === 'opacity') {
186
- setPhase('hidden');
185
+ // Clean up the finishing phase when the finish animation completes.
186
+ const handleAnimationEnd =
187
+ phase === 'finishing'
188
+ ? (e: React.AnimationEvent) => {
189
+ if (e.animationName === FINISH_KEYFRAMES) {
190
+ setPhase('hidden');
191
+ }
187
192
  }
188
- }
189
- : undefined;
193
+ : undefined;
190
194
 
191
195
  return createElement(
192
196
  'div',
193
197
  {
194
- style: containerStyle,
198
+ 'style': containerStyle,
195
199
  'aria-hidden': 'true',
196
200
  'data-timber-top-loader': '',
197
201
  },
198
- createElement('div', { style: barStyle, onTransitionEnd: handleTransitionEnd })
202
+ createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })
199
203
  );
200
204
  }
@@ -62,7 +62,13 @@ let _navigateTransition:
62
62
  * Non-navigation renders:
63
63
  * transitionRender(newWrappedElement);
64
64
  */
65
- export function TransitionRoot({ initial, topLoaderConfig }: { initial: ReactNode; topLoaderConfig?: TopLoaderConfig }): ReactNode {
65
+ export function TransitionRoot({
66
+ initial,
67
+ topLoaderConfig,
68
+ }: {
69
+ initial: ReactNode;
70
+ topLoaderConfig?: TopLoaderConfig;
71
+ }): ReactNode {
66
72
  const [element, setElement] = useState<ReactNode>(initial);
67
73
  const [pendingUrl, setPendingUrl] = useState<string | null>(null);
68
74
  const [, startTransition] = useTransition();
package/src/fonts/css.ts CHANGED
@@ -39,6 +39,7 @@ export function generateFontFaces(descriptors: FontFaceDescriptor[]): string {
39
39
  * ```css
40
40
  * .timber-font-inter {
41
41
  * --font-sans: 'Inter', 'Inter Fallback', system-ui, sans-serif;
42
+ * font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
42
43
  * }
43
44
  * ```
44
45
  */
@@ -47,7 +48,7 @@ export function generateVariableClass(
47
48
  variable: string,
48
49
  fontFamily: string
49
50
  ): string {
50
- return `.${className} {\n ${variable}: ${fontFamily};\n}`;
51
+ return `.${className} {\n ${variable}: ${fontFamily};\n font-family: ${fontFamily};\n}`;
51
52
  }
52
53
 
53
54
  /**
@@ -100,18 +100,22 @@ export function generateFamilyName(sources: LocalFontSrc[]): string {
100
100
  * Generate @font-face descriptors for local font sources.
101
101
  *
102
102
  * Each source entry produces one @font-face rule. The `src` descriptor
103
- * uses a `url()` pointing to the resolved file path with the inferred format.
103
+ * uses a `url()` pointing to the served path under `/_timber/fonts/`.
104
+ * The `urlPrefix` defaults to `/_timber/fonts` — the path used by both
105
+ * the dev server middleware and the production build output.
104
106
  */
105
107
  export function generateLocalFontFaces(
106
108
  family: string,
107
109
  sources: LocalFontSrc[],
108
- display: string
110
+ display: string,
111
+ urlPrefix = '/_timber/fonts'
109
112
  ): FontFaceDescriptor[] {
110
113
  return sources.map((entry) => {
111
114
  const format = inferFontFormat(entry.path);
115
+ const basename = entry.path.split('/').pop() ?? entry.path;
112
116
  return {
113
117
  family,
114
- src: `url('${entry.path}') format('${format}')`,
118
+ src: `url('${urlPrefix}/${basename}') format('${format}')`,
115
119
  weight: entry.weight,
116
120
  style: entry.style,
117
121
  display,
package/src/index.ts CHANGED
@@ -46,6 +46,18 @@ export interface ResolvedClientJavascript {
46
46
 
47
47
  export interface TimberUserConfig {
48
48
  output?: 'server' | 'static';
49
+ /**
50
+ * Enable timber debug logging in production builds.
51
+ *
52
+ * When `true`, timber's own diagnostics (dev warnings, verbose logging)
53
+ * are active even in production mode. React stays in production mode —
54
+ * only timber's logs are affected.
55
+ *
56
+ * Can also be enabled at runtime via the `TIMBER_DEBUG` environment variable.
57
+ *
58
+ * Default: `false`.
59
+ */
60
+ debug?: boolean;
49
61
  /**
50
62
  * Control client-side JavaScript output.
51
63
  *
@@ -95,6 +107,22 @@ export interface TimberUserConfig {
95
107
  /** Array of signing secrets for key rotation. Index 0 signs; all verify. */
96
108
  secrets?: string[];
97
109
  };
110
+ /**
111
+ * Control Server-Timing header output.
112
+ *
113
+ * - `'detailed'` — per-phase breakdown (proxy, middleware, render). Useful
114
+ * for APM / network monitoring. Exposes phase names to clients.
115
+ * - `'total'` — single `total;dur=N` entry. Safe to expose, gives
116
+ * browser DevTools useful timing without internal details.
117
+ * - `false` — no Server-Timing header at all.
118
+ *
119
+ * Default: `'detailed'` in dev, `'total'` in production.
120
+ *
121
+ * This is separate from `debug` / `TIMBER_DEBUG` — it's an intentional
122
+ * decision to expose timing data to clients, not a side effect of debug
123
+ * logging.
124
+ */
125
+ serverTiming?: 'detailed' | 'total' | false;
98
126
  /**
99
127
  * Override the app directory location. By default, timber auto-detects
100
128
  * `app/` at the project root, falling back to `src/app/`.
@@ -400,8 +428,13 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
400
428
  ssr: 'virtual:timber-ssr-entry',
401
429
  client: 'virtual:timber-browser-entry',
402
430
  },
403
- // No custom clientChunks Rolldown handles natural code splitting.
404
- // See design/27-chunking-strategy.md and LOCAL-337.
431
+ // Group all client reference wrappers into a single chunk instead of
432
+ // creating one tiny file per "use client" module. Without this, each
433
+ // server chunk's client references become a separate entry point,
434
+ // producing many sub-500B wrapper files (e.g., 30-byte re-exports).
435
+ // A single group eliminates 10+ unnecessary HTTP requests.
436
+ // See design/27-chunking-strategy.md and TIM-440.
437
+ clientChunks: () => 'client-refs',
405
438
  });
406
439
  }
407
440
  );
@@ -80,7 +80,17 @@ interface RouteInfo {
80
80
  entryFilePath: string | null;
81
81
  }
82
82
 
83
- /** Walk the route tree and collect all leaf routes (pages + API endpoints). */
83
+ /**
84
+ * Walk the route tree and collect all leaf routes (pages + API endpoints).
85
+ *
86
+ * Parallel slots (`@artists`, `@shows`, etc.) are intentionally skipped —
87
+ * they render alongside the parent page at the same URL and are not
88
+ * separately URL-addressable. Their JS is captured in shared/layout chunks.
89
+ *
90
+ * After collection, entries are deduplicated by URL path so that overlapping
91
+ * route groups (e.g. `(browse)` and `(marketing)` both producing `/`) only
92
+ * appear once. The entry with the largest route-specific size wins.
93
+ */
84
94
  export function collectRoutes(tree: RouteTree): RouteInfo[] {
85
95
  const routes: RouteInfo[] = [];
86
96
 
@@ -95,12 +105,22 @@ export function collectRoutes(tree: RouteTree): RouteInfo[] {
95
105
  routes.push({ path, segments: currentChain, entryFilePath: node.route.filePath });
96
106
  }
97
107
 
108
+ // Recurse into child segments only — skip parallel slots (node.slots)
98
109
  for (const child of node.children) walk(child, currentChain);
99
- for (const slot of node.slots.values()) walk(slot, currentChain);
100
110
  }
101
111
 
102
112
  walk(tree.root, []);
103
- return routes;
113
+
114
+ // Deduplicate entries with the same URL path (e.g. from overlapping route groups).
115
+ // Keep the entry with the longest segment chain (most specific match).
116
+ const seen = new Map<string, RouteInfo>();
117
+ for (const route of routes) {
118
+ const existing = seen.get(route.path);
119
+ if (!existing || route.segments.length > existing.segments.length) {
120
+ seen.set(route.path, route);
121
+ }
122
+ }
123
+ return Array.from(seen.values());
104
124
  }
105
125
 
106
126
  // ─── Report formatting ────────────────────────────────────────────────────
@@ -110,6 +110,8 @@ function generateConfigModule(ctx: PluginContext): string {
110
110
  slowRequestMs: ctx.config.slowRequestMs ?? 3000,
111
111
  cookieSecrets,
112
112
  topLoader: ctx.config.topLoader,
113
+ debug: ctx.config.debug ?? false,
114
+ serverTiming: ctx.config.serverTiming,
113
115
  };
114
116
 
115
117
  return [
@@ -128,11 +130,14 @@ function generateConfigModule(ctx: PluginContext): string {
128
130
  * Checks for instrumentation.ts, .js, and .mjs — matching the same
129
131
  * extensions as timber.config.ts detection.
130
132
  */
131
- function detectInstrumentationFile(root: string): string | null {
133
+ export function detectInstrumentationFile(root: string): string | null {
132
134
  const extensions = ['.ts', '.js', '.mjs'];
133
- for (const ext of extensions) {
134
- const candidate = resolve(root, `instrumentation${ext}`);
135
- if (existsSync(candidate)) return candidate;
135
+ const dirs = [root, resolve(root, 'src')];
136
+ for (const dir of dirs) {
137
+ for (const ext of extensions) {
138
+ const candidate = resolve(dir, `instrumentation${ext}`);
139
+ if (existsSync(candidate)) return candidate;
140
+ }
136
141
  }
137
142
  return null;
138
143
  }
@@ -14,13 +14,15 @@
14
14
  * Design doc: 24-fonts.md
15
15
  */
16
16
 
17
- import type { Plugin } from 'vite';
17
+ import type { Plugin, ViteDevServer } from 'vite';
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { resolve, normalize } from 'node:path';
18
20
  import type { PluginContext } from '#/index.js';
19
21
  import type { ExtractedFont, GoogleFontConfig } from '#/fonts/types.js';
20
22
  import type { ManifestFontEntry } from '#/server/build-manifest.js';
21
- import { generateVariableClass, generateFontFamilyClass } from '#/fonts/css.js';
23
+ import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from '#/fonts/css.js';
22
24
  import { generateFallbackCss, buildFontStack } from '#/fonts/fallbacks.js';
23
- import { processLocalFont } from '#/fonts/local.js';
25
+ import { processLocalFont, generateLocalFontFaces } from '#/fonts/local.js';
24
26
  import { inferFontFormat } from '#/fonts/local.js';
25
27
  import { downloadAndCacheFonts, type CachedFont } from '#/fonts/google.js';
26
28
  import {
@@ -34,6 +36,23 @@ const VIRTUAL_LOCAL = '@timber/fonts/local';
34
36
  const RESOLVED_GOOGLE = '\0@timber/fonts/google';
35
37
  const RESOLVED_LOCAL = '\0@timber/fonts/local';
36
38
 
39
+ /**
40
+ * Virtual side-effect module that registers font CSS on globalThis.
41
+ *
42
+ * When a file calls localFont() or a Google font function, the transform
43
+ * hook injects `import 'virtual:timber-font-css-register'` into that file.
44
+ * This virtual module sets `globalThis.__timber_font_css` with the combined
45
+ * @font-face CSS. The RSC entry reads it at render time to inline a <style> tag.
46
+ *
47
+ * This approach avoids timing issues because:
48
+ * 1. The font file is in the RSC module graph (imported by layout.tsx)
49
+ * 2. The side-effect import is added to the font file during transform
50
+ * 3. When layout.tsx is loaded, fonts.ts runs → side-effect module runs → globalThis is set
51
+ * 4. RSC entry renders → reads globalThis → inlines <style>
52
+ */
53
+ const VIRTUAL_FONT_CSS_REGISTER = 'virtual:timber-font-css-register';
54
+ const RESOLVED_FONT_CSS_REGISTER = '\0virtual:timber-font-css-register';
55
+
37
56
  /**
38
57
  * Registry of fonts extracted during transform.
39
58
  * Keyed by a unique font ID derived from family + config.
@@ -242,27 +261,44 @@ function generateLocalVirtualModule(): string {
242
261
  ].join('\n');
243
262
  }
244
263
 
264
+ /**
265
+ * Generate CSS for a single extracted font.
266
+ *
267
+ * Includes @font-face rules (for local fonts), fallback @font-face,
268
+ * and the scoped class rule.
269
+ */
270
+ export function generateFontCss(font: ExtractedFont): string {
271
+ const cssParts: string[] = [];
272
+
273
+ if (font.provider === 'local' && font.localSources) {
274
+ const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
275
+ const faceCss = generateFontFaces(faces);
276
+ if (faceCss) cssParts.push(faceCss);
277
+ }
278
+
279
+ const fallbackCss = generateFallbackCss(font.family);
280
+ if (fallbackCss) cssParts.push(fallbackCss);
281
+
282
+ if (font.variable) {
283
+ cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
284
+ } else {
285
+ cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
286
+ }
287
+
288
+ return cssParts.join('\n\n');
289
+ }
290
+
245
291
  /**
246
292
  * Generate the CSS output for all extracted fonts.
247
293
  *
248
- * Includes @font-face rules, fallback @font-face rules, and scoped classes.
294
+ * Includes @font-face rules for local fonts, fallback @font-face rules,
295
+ * and scoped classes.
249
296
  */
250
297
  export function generateAllFontCss(registry: FontRegistry): string {
251
298
  const cssParts: string[] = [];
252
-
253
299
  for (const font of registry.values()) {
254
- // Generate fallback @font-face if metrics are available
255
- const fallbackCss = generateFallbackCss(font.family);
256
- if (fallbackCss) cssParts.push(fallbackCss);
257
-
258
- // Generate scoped class
259
- if (font.variable) {
260
- cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
261
- } else {
262
- cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
263
- }
300
+ cssParts.push(generateFontCss(font));
264
301
  }
265
-
266
302
  return cssParts.join('\n\n');
267
303
  }
268
304
 
@@ -359,23 +395,112 @@ export function timberFonts(ctx: PluginContext): Plugin {
359
395
  name: 'timber-fonts',
360
396
 
361
397
  /**
362
- * Resolve `@timber/fonts/google` and `@timber/fonts/local` to virtual modules.
398
+ * Resolve `@timber/fonts/google`, `@timber/fonts/local`,
399
+ * and `virtual:timber-font-css` virtual modules.
400
+ *
401
+ * Handles \0 prefix and root prefix stripping for RSC/SSR
402
+ * environments where the RSC plugin re-imports virtual modules
403
+ * with additional prefixes.
363
404
  */
364
405
  resolveId(id: string) {
365
- if (id === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
366
- if (id === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
406
+ // Strip \0 prefix (RSC plugin re-imports)
407
+ let cleanId = id.startsWith('\0') ? id.slice(1) : id;
408
+ // Strip root prefix (SSR build entries)
409
+ if (cleanId.startsWith(ctx.root)) {
410
+ const stripped = cleanId.slice(ctx.root.length);
411
+ if (stripped.startsWith('/') || stripped.startsWith('\\')) {
412
+ cleanId = stripped.slice(1);
413
+ } else {
414
+ cleanId = stripped;
415
+ }
416
+ }
417
+
418
+ if (cleanId === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
419
+ if (cleanId === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
420
+ if (cleanId === VIRTUAL_FONT_CSS_REGISTER) return RESOLVED_FONT_CSS_REGISTER;
367
421
  return null;
368
422
  },
369
423
 
370
424
  /**
371
425
  * Return generated source for font virtual modules.
426
+ *
427
+ * `virtual:timber-font-css` exports the combined @font-face CSS
428
+ * as a string. The RSC entry imports it and inlines a <style> tag.
429
+ * Because this is loaded lazily (on first request), the font
430
+ * registry is always populated by the time it's needed.
372
431
  */
373
432
  load(id: string) {
374
433
  if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(registry);
375
434
  if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
435
+
436
+ if (id === RESOLVED_FONT_CSS_REGISTER) {
437
+ const css = generateAllFontCss(registry);
438
+ // Side-effect module: sets font CSS on globalThis for the RSC entry to read.
439
+ return `globalThis.__timber_font_css = ${JSON.stringify(css)};`;
440
+ }
376
441
  return null;
377
442
  },
378
443
 
444
+ /**
445
+ * Serve local font files and font CSS in dev mode under `/_timber/fonts/`.
446
+ *
447
+ * Serves:
448
+ * - `/_timber/fonts/fonts.css` — combined @font-face + scoped class CSS
449
+ * - `/_timber/fonts/<filename>` — individual font files from the registry
450
+ *
451
+ * Only files registered in the font registry are served.
452
+ * Paths are validated to prevent directory traversal.
453
+ */
454
+ configureServer(server: ViteDevServer) {
455
+ server.middlewares.use((req, res, next) => {
456
+ const url = req.url;
457
+ if (!url || !url.startsWith('/_timber/fonts/')) return next();
458
+
459
+ const requestedFilename = url.slice('/_timber/fonts/'.length);
460
+ // Reject path traversal attempts
461
+ if (requestedFilename.includes('..') || requestedFilename.includes('/')) {
462
+ res.statusCode = 400;
463
+ res.end('Bad request');
464
+ return;
465
+ }
466
+
467
+ // Font CSS is now injected via Vite's CSS pipeline (virtual:timber-font-css modules).
468
+ // This middleware only serves font binary files (woff2, etc.).
469
+
470
+ // Find the matching font file in the registry
471
+ for (const font of registry.values()) {
472
+ if (font.provider !== 'local' || !font.localSources) continue;
473
+ for (const src of font.localSources) {
474
+ const basename = src.path.split('/').pop() ?? '';
475
+ if (basename === requestedFilename) {
476
+ const absolutePath = normalize(resolve(src.path));
477
+ if (!existsSync(absolutePath)) {
478
+ res.statusCode = 404;
479
+ res.end('Not found');
480
+ return;
481
+ }
482
+ const data = readFileSync(absolutePath);
483
+ const ext = absolutePath.split('.').pop()?.toLowerCase();
484
+ const mimeMap: Record<string, string> = {
485
+ woff2: 'font/woff2',
486
+ woff: 'font/woff',
487
+ ttf: 'font/ttf',
488
+ otf: 'font/otf',
489
+ eot: 'application/vnd.ms-fontopen',
490
+ };
491
+ res.setHeader('Content-Type', mimeMap[ext ?? ''] ?? 'application/octet-stream');
492
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
493
+ res.setHeader('Access-Control-Allow-Origin', '*');
494
+ res.end(data);
495
+ return;
496
+ }
497
+ }
498
+ }
499
+
500
+ next();
501
+ });
502
+ },
503
+
379
504
  /**
380
505
  * Download and cache Google Fonts during production builds.
381
506
  *
@@ -499,6 +624,11 @@ export function timberFonts(ctx: PluginContext): Plugin {
499
624
  }
500
625
 
501
626
  if (transformedCode !== code) {
627
+ // Inject side-effect import that registers font CSS on globalThis.
628
+ // The RSC entry reads globalThis.__timber_font_css to inline a <style> tag.
629
+ if (registry.size > 0) {
630
+ transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
631
+ }
502
632
  return { code: transformedCode, map: null };
503
633
  }
504
634
 
@@ -525,6 +655,28 @@ export function timberFonts(ctx: PluginContext): Plugin {
525
655
  });
526
656
  }
527
657
 
658
+ // Emit local font files as assets
659
+ for (const font of registry.values()) {
660
+ if (font.provider !== 'local' || !font.localSources) continue;
661
+ for (const src of font.localSources) {
662
+ const absolutePath = normalize(resolve(src.path));
663
+ if (!existsSync(absolutePath)) {
664
+ this.warn(`Local font file not found: ${absolutePath}`);
665
+ continue;
666
+ }
667
+ const basename = src.path.split('/').pop() ?? src.path;
668
+ const data = readFileSync(absolutePath);
669
+ this.emitFile({
670
+ type: 'asset',
671
+ fileName: `_timber/fonts/${basename}`,
672
+ source: data,
673
+ });
674
+ }
675
+ }
676
+
677
+ // Font CSS is emitted by Vite's CSS pipeline via virtual:timber-font-css modules.
678
+ // We only need to emit font binary files and update the build manifest here.
679
+
528
680
  if (!ctx.buildManifest) return;
529
681
 
530
682
  // Build a lookup from font family → cached files for manifest entries
@@ -16,19 +16,23 @@ import type { PluginContext } from '#/index.js';
16
16
  const MDX_EXTENSIONS = ['mdx', 'md'];
17
17
 
18
18
  /**
19
- * Check if mdx-components.tsx (or .ts, .jsx, .js) exists at the project root.
19
+ * Check if mdx-components.tsx (or .ts, .jsx, .js) exists at the project root
20
+ * or in src/. Root takes precedence, matching Next.js behavior.
20
21
  * Returns the absolute path if found, otherwise undefined.
21
22
  */
22
- function findMdxComponents(root: string): string | undefined {
23
+ export function findMdxComponents(root: string): string | undefined {
23
24
  const candidates = [
24
25
  'mdx-components.tsx',
25
26
  'mdx-components.ts',
26
27
  'mdx-components.jsx',
27
28
  'mdx-components.js',
28
29
  ];
29
- for (const name of candidates) {
30
- const p = join(root, name);
31
- if (existsSync(p)) return p;
30
+ const dirs = [root, join(root, 'src')];
31
+ for (const dir of dirs) {
32
+ for (const name of candidates) {
33
+ const p = join(dir, name);
34
+ if (existsSync(p)) return p;
35
+ }
32
36
  }
33
37
  return undefined;
34
38
  }
@@ -65,6 +65,10 @@ export function timberServerBundle(): Plugin[] {
65
65
  // eliminated by Rollup's tree-shaking. Without this, the runtime
66
66
  // check falls through on platforms where process.env is empty
67
67
  // (e.g. Cloudflare Workers), causing dev code to run in production.
68
+ // Define process.env.NODE_ENV so dev-only React code is tree-shaken.
69
+ // TIMBER_DEBUG is intentionally NOT defined here — it must remain a
70
+ // runtime check so `isDebug()` in server/debug.ts can read it at
71
+ // request time. See TIM-365.
68
72
  const serverDefine = {
69
73
  'process.env.NODE_ENV': JSON.stringify('production'),
70
74
  };
@@ -16,6 +16,7 @@
16
16
  import { DenySignal, RedirectSignal } from './primitives.js';
17
17
  import type { AccessGateProps, SlotAccessGateProps, ReactElement } from './tree-builder.js';
18
18
  import { withSpan, setSpanAttribute } from './tracing.js';
19
+ import { isDebug } from './debug.js';
19
20
 
20
21
  // ─── AccessGate ─────────────────────────────────────────────────────────────
21
22
 
@@ -114,7 +115,7 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactE
114
115
  // slot would redirect the entire page, which breaks the contract that
115
116
  // slot failure is graceful degradation.
116
117
  if (error instanceof RedirectSignal) {
117
- if (process.env.NODE_ENV !== 'production') {
118
+ if (isDebug()) {
118
119
  console.error(
119
120
  '[timber] redirect() is not allowed in slot access.ts. ' +
120
121
  'Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. ' +
@@ -127,7 +128,7 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactE
127
128
 
128
129
  // Unhandled error — re-throw so error boundaries can catch it.
129
130
  // Dev-mode warning: slot access should use deny(), not throw.
130
- if (process.env.NODE_ENV !== 'production') {
131
+ if (isDebug()) {
131
132
  console.warn(
132
133
  '[timber] Unhandled error in slot access.ts. ' +
133
134
  'Use deny() for access control, not unhandled throws.',