@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.31

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 (142) 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/compress-module.d.ts.map +1 -1
  22. package/dist/adapters/nitro.d.ts +17 -1
  23. package/dist/adapters/nitro.d.ts.map +1 -1
  24. package/dist/adapters/nitro.js +26 -9
  25. package/dist/adapters/nitro.js.map +1 -1
  26. package/dist/cache/fast-hash.d.ts +22 -0
  27. package/dist/cache/fast-hash.d.ts.map +1 -0
  28. package/dist/cache/index.js +52 -10
  29. package/dist/cache/index.js.map +1 -1
  30. package/dist/cache/register-cached-function.d.ts.map +1 -1
  31. package/dist/cache/timber-cache.d.ts.map +1 -1
  32. package/dist/client/error-boundary.js +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/link.d.ts.map +1 -1
  36. package/dist/client/router.d.ts.map +1 -1
  37. package/dist/client/segment-context.d.ts +1 -1
  38. package/dist/client/segment-context.d.ts.map +1 -1
  39. package/dist/client/segment-merger.d.ts.map +1 -1
  40. package/dist/client/stale-reload.d.ts.map +1 -1
  41. package/dist/client/top-loader.d.ts.map +1 -1
  42. package/dist/client/transition-root.d.ts +1 -1
  43. package/dist/client/transition-root.d.ts.map +1 -1
  44. package/dist/cookies/index.js +4 -4
  45. package/dist/fonts/css.d.ts +1 -0
  46. package/dist/fonts/css.d.ts.map +1 -1
  47. package/dist/fonts/local.d.ts +4 -2
  48. package/dist/fonts/local.d.ts.map +1 -1
  49. package/dist/index.d.ts +28 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +249 -21
  52. package/dist/index.js.map +1 -1
  53. package/dist/plugins/build-report.d.ts +11 -1
  54. package/dist/plugins/build-report.d.ts.map +1 -1
  55. package/dist/plugins/entries.d.ts +7 -0
  56. package/dist/plugins/entries.d.ts.map +1 -1
  57. package/dist/plugins/fonts.d.ts +9 -1
  58. package/dist/plugins/fonts.d.ts.map +1 -1
  59. package/dist/plugins/mdx.d.ts +6 -0
  60. package/dist/plugins/mdx.d.ts.map +1 -1
  61. package/dist/plugins/server-bundle.d.ts.map +1 -1
  62. package/dist/routing/index.js +1 -1
  63. package/dist/rsc-runtime/ssr.d.ts +12 -0
  64. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  65. package/dist/search-params/index.js +1 -1
  66. package/dist/server/access-gate.d.ts.map +1 -1
  67. package/dist/server/action-client.d.ts.map +1 -1
  68. package/dist/server/debug.d.ts +82 -0
  69. package/dist/server/debug.d.ts.map +1 -0
  70. package/dist/server/deny-renderer.d.ts.map +1 -1
  71. package/dist/server/dev-warnings.d.ts.map +1 -1
  72. package/dist/server/html-injectors.d.ts.map +1 -1
  73. package/dist/server/index.js +32 -23
  74. package/dist/server/index.js.map +1 -1
  75. package/dist/server/logger.d.ts.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +65 -0
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  78. package/dist/server/pipeline.d.ts +7 -4
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts.map +1 -1
  81. package/dist/server/request-context.d.ts.map +1 -1
  82. package/dist/server/route-element-builder.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  86. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  87. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  88. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  89. package/dist/server/ssr-entry.d.ts.map +1 -1
  90. package/dist/server/ssr-render.d.ts +34 -21
  91. package/dist/server/ssr-render.d.ts.map +1 -1
  92. package/dist/server/tracing.d.ts +10 -0
  93. package/dist/server/tracing.d.ts.map +1 -1
  94. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  95. package/dist/shims/image.d.ts +15 -15
  96. package/package.json +1 -1
  97. package/src/adapters/compress-module.ts +21 -4
  98. package/src/adapters/nitro.ts +31 -5
  99. package/src/cache/fast-hash.ts +34 -0
  100. package/src/cache/register-cached-function.ts +7 -3
  101. package/src/cache/timber-cache.ts +17 -10
  102. package/src/client/browser-entry.ts +10 -6
  103. package/src/client/link.tsx +14 -9
  104. package/src/client/router.ts +4 -6
  105. package/src/client/segment-context.ts +6 -1
  106. package/src/client/segment-merger.ts +2 -8
  107. package/src/client/stale-reload.ts +5 -7
  108. package/src/client/top-loader.tsx +8 -7
  109. package/src/client/transition-root.tsx +7 -1
  110. package/src/fonts/css.ts +2 -1
  111. package/src/fonts/local.ts +7 -3
  112. package/src/index.ts +35 -2
  113. package/src/plugins/build-report.ts +23 -3
  114. package/src/plugins/entries.ts +9 -4
  115. package/src/plugins/fonts.ts +171 -19
  116. package/src/plugins/mdx.ts +9 -5
  117. package/src/plugins/server-bundle.ts +4 -0
  118. package/src/rsc-runtime/ssr.ts +50 -0
  119. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  120. package/src/server/access-gate.tsx +3 -2
  121. package/src/server/action-client.ts +15 -5
  122. package/src/server/debug.ts +137 -0
  123. package/src/server/deny-renderer.ts +3 -2
  124. package/src/server/dev-warnings.ts +2 -1
  125. package/src/server/html-injectors.ts +30 -10
  126. package/src/server/logger.ts +4 -3
  127. package/src/server/node-stream-transforms.ts +315 -0
  128. package/src/server/pipeline.ts +34 -20
  129. package/src/server/primitives.ts +2 -1
  130. package/src/server/request-context.ts +3 -2
  131. package/src/server/route-element-builder.ts +1 -6
  132. package/src/server/rsc-entry/index.ts +50 -7
  133. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  134. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  135. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  136. package/src/server/rsc-prop-warnings.ts +3 -1
  137. package/src/server/ssr-entry.ts +130 -8
  138. package/src/server/ssr-render.ts +168 -57
  139. package/src/server/tracing.ts +23 -0
  140. package/src/server/waituntil-bridge.ts +4 -1
  141. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  142. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -1,7 +1,7 @@
1
- import { createHash } from 'node:crypto';
2
1
  import type { CacheHandler } from './index';
3
2
  import { stableStringify } from './stable-stringify';
4
3
  import { createSingleflight } from './singleflight';
4
+ import { fnv1aHash } from './fast-hash.js';
5
5
 
6
6
  const singleflight = createSingleflight();
7
7
 
@@ -27,11 +27,15 @@ export interface RegisterCachedFunctionOptions<Fn extends (...args: any[]) => an
27
27
  }
28
28
 
29
29
  /**
30
- * Generate a SHA-256 cache key from a stable function ID and serialized args.
30
+ * Generate a cache key from a stable function ID and serialized args.
31
+ *
32
+ * Uses FNV-1a (fast non-crypto hash) instead of SHA-256. The id prefix
33
+ * provides namespace isolation; the hash covers the args.
34
+ * See TIM-370.
31
35
  */
32
36
  function generateKey(id: string, args: unknown[]): string {
33
37
  const raw = id + ':' + stableStringify(args);
34
- return createHash('sha256').update(raw).digest('hex');
38
+ return id + ':' + fnv1aHash(raw);
35
39
  }
36
40
 
37
41
  /**
@@ -1,17 +1,23 @@
1
- import { createHash } from 'node:crypto';
2
1
  import type { CacheHandler, CacheOptions } from './index';
3
2
  import { stableStringify } from './stable-stringify';
4
3
  import { createSingleflight } from './singleflight';
5
- import { addSpanEvent } from '#/server/tracing.js';
4
+ import { addSpanEventSync } from '#/server/tracing.js';
5
+ import { fnv1aHash } from './fast-hash.js';
6
6
 
7
7
  const singleflight = createSingleflight();
8
8
 
9
9
  /**
10
- * Generate a SHA-256 cache key from function identity and serialized args.
10
+ * Generate a cache key from function identity and serialized args.
11
+ *
12
+ * Uses FNV-1a (fast non-crypto hash) instead of SHA-256. Cache keys don't
13
+ * need collision resistance — they need speed. The fnId prefix provides
14
+ * namespace isolation; the hash covers the args.
15
+ *
16
+ * See TIM-370 for perf motivation.
11
17
  */
12
18
  function defaultKeyGenerator(fnId: string, args: unknown[]): string {
13
19
  const raw = fnId + ':' + stableStringify(args);
14
- return createHash('sha256').update(raw).digest('hex');
20
+ return fnId + ':' + fnv1aHash(raw);
15
21
  }
16
22
 
17
23
  /**
@@ -57,8 +63,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
57
63
  const cached = await handler.get(key);
58
64
 
59
65
  if (cached && !cached.stale) {
60
- // Record as OTEL span event on enclosing span (not a child span)
61
- await addSpanEvent('timber.cache.hit', {
66
+ // Record as OTEL span event on enclosing span (not a child span).
67
+ // Fire-and-forget — no microtask overhead on the cache hot path.
68
+ addSpanEventSync('timber.cache.hit', {
62
69
  key,
63
70
  duration_ms: Math.round(performance.now() - cacheStart),
64
71
  });
@@ -66,8 +73,8 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
66
73
  }
67
74
 
68
75
  if (cached && cached.stale && opts.staleWhileRevalidate) {
69
- // Record stale cache hit as OTEL span event
70
- await addSpanEvent('timber.cache.hit', {
76
+ // Record stale cache hit as OTEL span event (fire-and-forget).
77
+ addSpanEventSync('timber.cache.hit', {
71
78
  key,
72
79
  duration_ms: Math.round(performance.now() - cacheStart),
73
80
  stale: true,
@@ -95,8 +102,8 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
95
102
  const tags = resolveTags(opts, args);
96
103
  await handler.set(key, result, { ttl: opts.ttl, tags });
97
104
 
98
- // Record cache miss as OTEL span event
99
- await addSpanEvent('timber.cache.miss', {
105
+ // Record cache miss as OTEL span event (fire-and-forget).
106
+ addSpanEventSync('timber.cache.miss', {
100
107
  key,
101
108
  duration_ms: Math.round(performance.now() - cacheStart),
102
109
  });
@@ -52,7 +52,11 @@ import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.
52
52
  // browser-links.ts removed — Link components own their click/hover handlers directly.
53
53
  // See LOCAL-340.
54
54
  import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
55
- import { isStaleClientReference, triggerStaleReload, clearStaleReloadFlag } from './stale-reload.js';
55
+ import {
56
+ isStaleClientReference,
57
+ triggerStaleReload,
58
+ clearStaleReloadFlag,
59
+ } from './stale-reload.js';
56
60
 
57
61
  // ─── Server Action Dispatch ──────────────────────────────────────
58
62
 
@@ -213,10 +217,7 @@ function findOverflowContainer(): HTMLElement | null {
213
217
  if (!(el instanceof HTMLElement)) continue;
214
218
  const style = getComputedStyle(el);
215
219
  const overflowY = style.overflowY;
216
- if (
217
- (overflowY === 'auto' || overflowY === 'scroll') &&
218
- el.scrollHeight > el.clientHeight
219
- ) {
220
+ if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
220
221
  return el;
221
222
  }
222
223
  }
@@ -381,7 +382,10 @@ function bootstrap(runtimeConfig: typeof config): void {
381
382
  element as React.ReactNode
382
383
  );
383
384
  const wrapped = createElement(TimberNuqsAdapter, null, withNav);
384
- const rootElement = createElement(TransitionRoot, { initial: wrapped, topLoaderConfig: _config.topLoader });
385
+ const rootElement = createElement(TransitionRoot, {
386
+ initial: wrapped,
387
+ topLoaderConfig: _config.topLoader,
388
+ });
385
389
  _reactRoot = hydrateRoot(document, rootElement, {
386
390
  // Suppress recoverable hydration errors from deny/error signals
387
391
  // inside Suspense boundaries. The server already handled these
@@ -335,7 +335,11 @@ export function Link({
335
335
  // Call onNavigate if provided — allows caller to cancel
336
336
  if (onNavigate) {
337
337
  let prevented = false;
338
- onNavigate({ preventDefault: () => { prevented = true; } });
338
+ onNavigate({
339
+ preventDefault: () => {
340
+ prevented = true;
341
+ },
342
+ });
339
343
  if (prevented) {
340
344
  event.preventDefault();
341
345
  return;
@@ -352,15 +356,16 @@ export function Link({
352
356
  : userOnClick; // External links — just pass through user's onClick
353
357
 
354
358
  // ─── Hover prefetch ──────────────────────────────────────────
355
- const handleMouseEnter = internal && prefetch
356
- ? (event: ReactMouseEvent<HTMLAnchorElement>) => {
357
- userOnMouseEnter?.(event);
358
- const router = getRouterOrNull();
359
- if (router) {
360
- router.prefetch(resolvedHref);
359
+ const handleMouseEnter =
360
+ internal && prefetch
361
+ ? (event: ReactMouseEvent<HTMLAnchorElement>) => {
362
+ userOnMouseEnter?.(event);
363
+ const router = getRouterOrNull();
364
+ if (router) {
365
+ router.prefetch(resolvedHref);
366
+ }
361
367
  }
362
- }
363
- : userOnMouseEnter;
368
+ : userOnMouseEnter;
364
369
 
365
370
  return (
366
371
  <a {...rest} href={resolvedHref} onClick={handleClick} onMouseEnter={handleMouseEnter}>
@@ -7,11 +7,7 @@ import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
8
  import { setCurrentParams } from './use-params.js';
9
9
  import { setNavigationState } from './navigation-context.js';
10
- import {
11
- SegmentElementCache,
12
- cacheSegmentElements,
13
- mergeSegmentTree,
14
- } from './segment-merger.js';
10
+ import { SegmentElementCache, cacheSegmentElements, mergeSegmentTree } from './segment-merger.js';
15
11
  import { fetchRscPayload, RedirectError } from './rsc-fetch.js';
16
12
  import type { FetchResult } from './rsc-fetch.js';
17
13
 
@@ -421,7 +417,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
421
417
  setPending(true, url);
422
418
  try {
423
419
  const headElements = await renderViaTransition(url, async () => {
424
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
420
+ const stateTree = segmentCache.serializeStateTree(
421
+ segmentElementCache.getMergeablePaths()
422
+ );
425
423
  const result = await fetchRscPayload(url, deps, stateTree);
426
424
  updateSegmentCache(result.segmentInfo);
427
425
  updateNavigationState(result.params, url);
@@ -52,7 +52,12 @@ interface SegmentProviderProps {
52
52
  * Wraps each layout to provide segment position context.
53
53
  * Injected by rsc-entry.ts during element tree construction.
54
54
  */
55
- export function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }: SegmentProviderProps) {
55
+ export function SegmentProvider({
56
+ segments,
57
+ segmentId: _segmentId,
58
+ parallelRouteKeys,
59
+ children,
60
+ }: SegmentProviderProps) {
56
61
  const value = useMemo(
57
62
  () => ({ segments, parallelRouteKeys }),
58
63
  // segments and parallelRouteKeys are static per layout — they don't change
@@ -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
  }
@@ -183,18 +183,19 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
183
183
  };
184
184
 
185
185
  // Clean up the finishing phase when the finish animation completes.
186
- const handleAnimationEnd = phase === 'finishing'
187
- ? (e: React.AnimationEvent) => {
188
- if (e.animationName === FINISH_KEYFRAMES) {
189
- setPhase('hidden');
186
+ const handleAnimationEnd =
187
+ phase === 'finishing'
188
+ ? (e: React.AnimationEvent) => {
189
+ if (e.animationName === FINISH_KEYFRAMES) {
190
+ setPhase('hidden');
191
+ }
190
192
  }
191
- }
192
- : undefined;
193
+ : undefined;
193
194
 
194
195
  return createElement(
195
196
  'div',
196
197
  {
197
- style: containerStyle,
198
+ 'style': containerStyle,
198
199
  'aria-hidden': 'true',
199
200
  'data-timber-top-loader': '',
200
201
  },
@@ -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
  }