@timber-js/app 0.1.21 → 0.1.22

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 (135) hide show
  1. package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
  2. package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
  3. package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
  4. package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
  6. package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
  7. package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
  8. package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
  9. package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
  10. package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
  11. package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
  12. package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
  13. package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
  14. package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
  15. package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
  16. package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
  17. package/dist/cache/index.js +2 -1
  18. package/dist/cache/index.js.map +1 -1
  19. package/dist/client/error-boundary.js +1 -1
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.d.ts.map +1 -1
  22. package/dist/client/index.js +18 -17
  23. package/dist/client/index.js.map +1 -1
  24. package/dist/client/router-ref.d.ts.map +1 -1
  25. package/dist/client/ssr-data.d.ts +3 -0
  26. package/dist/client/ssr-data.d.ts.map +1 -1
  27. package/dist/client/state.d.ts +47 -0
  28. package/dist/client/state.d.ts.map +1 -0
  29. package/dist/client/types.d.ts +10 -1
  30. package/dist/client/types.d.ts.map +1 -1
  31. package/dist/client/unload-guard.d.ts +3 -0
  32. package/dist/client/unload-guard.d.ts.map +1 -1
  33. package/dist/client/use-params.d.ts +3 -0
  34. package/dist/client/use-params.d.ts.map +1 -1
  35. package/dist/client/use-search-params.d.ts +3 -0
  36. package/dist/client/use-search-params.d.ts.map +1 -1
  37. package/dist/cookies/index.js +4 -2
  38. package/dist/cookies/index.js.map +1 -1
  39. package/dist/index.js +4 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugins/shims.d.ts.map +1 -1
  42. package/dist/routing/index.js +1 -1
  43. package/dist/routing/scanner.d.ts.map +1 -1
  44. package/dist/rsc-runtime/browser.d.ts +13 -0
  45. package/dist/rsc-runtime/browser.d.ts.map +1 -0
  46. package/dist/rsc-runtime/rsc.d.ts +14 -0
  47. package/dist/rsc-runtime/rsc.d.ts.map +1 -0
  48. package/dist/rsc-runtime/ssr.d.ts +13 -0
  49. package/dist/rsc-runtime/ssr.d.ts.map +1 -0
  50. package/dist/search-params/builtin-codecs.d.ts +105 -0
  51. package/dist/search-params/builtin-codecs.d.ts.map +1 -0
  52. package/dist/search-params/index.d.ts +1 -0
  53. package/dist/search-params/index.d.ts.map +1 -1
  54. package/dist/search-params/index.js +167 -2
  55. package/dist/search-params/index.js.map +1 -1
  56. package/dist/server/actions.d.ts +2 -7
  57. package/dist/server/actions.d.ts.map +1 -1
  58. package/dist/server/als-registry.d.ts +80 -0
  59. package/dist/server/als-registry.d.ts.map +1 -0
  60. package/dist/server/early-hints-sender.d.ts.map +1 -1
  61. package/dist/server/form-flash.d.ts.map +1 -1
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.d.ts.map +1 -1
  64. package/dist/server/index.js +242 -76
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/server/metadata-routes.d.ts +27 -0
  67. package/dist/server/metadata-routes.d.ts.map +1 -1
  68. package/dist/server/pipeline.d.ts +7 -0
  69. package/dist/server/pipeline.d.ts.map +1 -1
  70. package/dist/server/primitives.d.ts +14 -6
  71. package/dist/server/primitives.d.ts.map +1 -1
  72. package/dist/server/request-context.d.ts +2 -32
  73. package/dist/server/request-context.d.ts.map +1 -1
  74. package/dist/server/route-matcher.d.ts +5 -0
  75. package/dist/server/route-matcher.d.ts.map +1 -1
  76. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  77. package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
  78. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
  79. package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
  80. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
  81. package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
  82. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
  83. package/dist/server/rsc-prop-warnings.d.ts +53 -0
  84. package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
  85. package/dist/server/server-timing.d.ts +49 -0
  86. package/dist/server/server-timing.d.ts.map +1 -0
  87. package/dist/server/tracing.d.ts +2 -6
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/server/types.d.ts +11 -0
  90. package/dist/server/types.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/src/client/browser-entry.ts +1 -1
  93. package/src/client/index.ts +1 -1
  94. package/src/client/router-ref.ts +6 -12
  95. package/src/client/ssr-data.ts +25 -9
  96. package/src/client/state.ts +83 -0
  97. package/src/client/types.ts +18 -1
  98. package/src/client/unload-guard.ts +6 -3
  99. package/src/client/use-params.ts +10 -13
  100. package/src/client/use-search-params.ts +9 -5
  101. package/src/plugins/shims.ts +26 -2
  102. package/src/routing/scanner.ts +18 -2
  103. package/src/rsc-runtime/browser.ts +18 -0
  104. package/src/rsc-runtime/rsc.ts +19 -0
  105. package/src/rsc-runtime/ssr.ts +13 -0
  106. package/src/search-params/builtin-codecs.ts +228 -0
  107. package/src/search-params/index.ts +11 -0
  108. package/src/server/action-handler.ts +1 -1
  109. package/src/server/actions.ts +4 -10
  110. package/src/server/als-registry.ts +116 -0
  111. package/src/server/deny-renderer.ts +1 -1
  112. package/src/server/early-hints-sender.ts +1 -3
  113. package/src/server/form-flash.ts +1 -5
  114. package/src/server/index.ts +1 -0
  115. package/src/server/metadata-routes.ts +61 -0
  116. package/src/server/pipeline.ts +164 -38
  117. package/src/server/primitives.ts +110 -6
  118. package/src/server/request-context.ts +8 -36
  119. package/src/server/route-matcher.ts +25 -2
  120. package/src/server/rsc-entry/error-renderer.ts +1 -1
  121. package/src/server/rsc-entry/index.ts +42 -380
  122. package/src/server/rsc-entry/rsc-payload.ts +126 -0
  123. package/src/server/rsc-entry/rsc-stream.ts +162 -0
  124. package/src/server/rsc-entry/ssr-renderer.ts +228 -0
  125. package/src/server/rsc-prop-warnings.ts +187 -0
  126. package/src/server/server-timing.ts +132 -0
  127. package/src/server/ssr-entry.ts +1 -1
  128. package/src/server/tracing.ts +3 -11
  129. package/src/server/types.ts +16 -0
  130. package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
  131. package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
  132. package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
  133. package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
  134. package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
  135. package/dist/_chunks/tracing-BtOwb8O6.js.map +0 -1
@@ -17,8 +17,18 @@
17
17
  * APIs, as it's imported by 'use client' hooks that are bundled for the browser.
18
18
  * The ALS instance lives in ssr-entry.ts (server-only); this module only holds
19
19
  * a reference to the provider function.
20
+ *
21
+ * All mutable state is delegated to client/state.ts for singleton guarantees.
22
+ * See design/18-build-system.md §"Singleton State Registry"
20
23
  */
21
24
 
25
+ import {
26
+ ssrDataProvider,
27
+ currentSsrData,
28
+ _setSsrDataProvider,
29
+ _setCurrentSsrData,
30
+ } from './state.js';
31
+
22
32
  // ─── Types ────────────────────────────────────────────────────────
23
33
 
24
34
  export interface SsrData {
@@ -44,8 +54,16 @@ export interface SsrData {
44
54
  // Server-side code (ssr-entry.ts) registers a provider that reads
45
55
  // from AsyncLocalStorage. This avoids importing node:async_hooks
46
56
  // in this browser-bundled module.
47
-
48
- let _ssrDataProvider: (() => SsrData | undefined) | undefined;
57
+ //
58
+ // Module singleton guarantee: In Vite's SSR environment, both
59
+ // ssr-entry.ts (via #/client/ssr-data.js) and client component hooks
60
+ // (via @timber-js/app/client) must resolve to the SAME module instance
61
+ // of this file. The timber-shims plugin ensures this by remapping
62
+ // @timber-js/app/client → src/client/index.ts in the SSR environment.
63
+ // Without this remap, @timber-js/app/client resolves to dist/ (via
64
+ // package.json exports), creating a split where registerSsrDataProvider
65
+ // writes to one instance but getSsrData reads from another.
66
+ // See timber-shims plugin resolveId for details.
49
67
 
50
68
  /**
51
69
  * Register an ALS-backed SSR data provider. Called once at module load
@@ -56,15 +74,13 @@ let _ssrDataProvider: (() => SsrData | undefined) | undefined;
56
74
  * concurrent requests with streaming Suspense.
57
75
  */
58
76
  export function registerSsrDataProvider(provider: () => SsrData | undefined): void {
59
- _ssrDataProvider = provider;
77
+ _setSsrDataProvider(provider);
60
78
  }
61
79
 
62
80
  // ─── Module-Level Fallback ────────────────────────────────────────
63
81
  //
64
82
  // Used by tests and as a fallback when no ALS provider is registered.
65
83
 
66
- let currentSsrData: SsrData | undefined;
67
-
68
84
  /**
69
85
  * Set the SSR data for the current request via module-level state.
70
86
  *
@@ -72,7 +88,7 @@ let currentSsrData: SsrData | undefined;
72
88
  * This function is retained for tests and as a fallback.
73
89
  */
74
90
  export function setSsrData(data: SsrData): void {
75
- currentSsrData = data;
91
+ _setCurrentSsrData(data);
76
92
  }
77
93
 
78
94
  /**
@@ -82,7 +98,7 @@ export function setSsrData(data: SsrData): void {
82
98
  * This function is retained for tests and as a fallback.
83
99
  */
84
100
  export function clearSsrData(): void {
85
- currentSsrData = undefined;
101
+ _setCurrentSsrData(undefined);
86
102
  }
87
103
 
88
104
  /**
@@ -95,8 +111,8 @@ export function clearSsrData(): void {
95
111
  * Used by client hooks' server snapshot functions.
96
112
  */
97
113
  export function getSsrData(): SsrData | undefined {
98
- if (_ssrDataProvider) {
99
- return _ssrDataProvider();
114
+ if (ssrDataProvider) {
115
+ return ssrDataProvider();
100
116
  }
101
117
  return currentSsrData;
102
118
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Centralized client singleton state registry.
3
+ *
4
+ * ALL mutable module-level state that must have singleton semantics across
5
+ * the client bundle lives here. Individual modules (router-ref.ts, ssr-data.ts,
6
+ * use-params.ts, use-search-params.ts, unload-guard.ts) import from this file
7
+ * and re-export thin wrapper functions.
8
+ *
9
+ * Why: In Vite dev, a module is instantiated separately if reached via different
10
+ * import paths (e.g., relative `./foo.js` vs barrel `@timber-js/app/client`).
11
+ * By centralizing all mutable state in a single module that is always reached
12
+ * through the same dependency chain (barrel → wrapper → state.ts), we guarantee
13
+ * a single instance of every piece of shared state.
14
+ *
15
+ * DO NOT import this file from outside client/. Server code must never depend
16
+ * on client state. The barrel (client/index.ts) is the public entry point.
17
+ *
18
+ * See design/18-build-system.md §"Module Singleton Strategy" and
19
+ * §"Singleton State Registry".
20
+ */
21
+
22
+ import type { RouterInstance } from './router.js';
23
+ import type { SsrData } from './ssr-data.js';
24
+
25
+ // ─── Router (from router-ref.ts) ──────────────────────────────────────────
26
+
27
+ /** The global router singleton — set once during bootstrap. */
28
+ export let globalRouter: RouterInstance | null = null;
29
+
30
+ export function _setGlobalRouter(router: RouterInstance | null): void {
31
+ globalRouter = router;
32
+ }
33
+
34
+ // ─── SSR Data Provider (from ssr-data.ts) ──────────────────────────────────
35
+
36
+ /**
37
+ * ALS-backed SSR data provider. When registered, getSsrData() reads from
38
+ * this function (ALS store) instead of module-level currentSsrData.
39
+ */
40
+ export let ssrDataProvider: (() => SsrData | undefined) | undefined;
41
+
42
+ export function _setSsrDataProvider(provider: (() => SsrData | undefined) | undefined): void {
43
+ ssrDataProvider = provider;
44
+ }
45
+
46
+ /** Fallback SSR data for tests and environments without ALS. */
47
+ export let currentSsrData: SsrData | undefined;
48
+
49
+ export function _setCurrentSsrData(data: SsrData | undefined): void {
50
+ currentSsrData = data;
51
+ }
52
+
53
+ // ─── Route Params (from use-params.ts) ──────────────────────────────────────
54
+
55
+ /** Current route params snapshot — replaced (not mutated) on each navigation. */
56
+ export let currentParams: Record<string, string | string[]> = {};
57
+
58
+ export function _setCurrentParams(params: Record<string, string | string[]>): void {
59
+ currentParams = params;
60
+ }
61
+
62
+ /** Listeners notified when currentParams changes. */
63
+ export const paramsListeners = new Set<() => void>();
64
+
65
+ // ─── Search Params Cache (from use-search-params.ts) ────────────────────────
66
+
67
+ /** Cached search string — avoids reparsing when URL hasn't changed. */
68
+ export let cachedSearch = '';
69
+ export let cachedSearchParams = new URLSearchParams();
70
+
71
+ export function _setCachedSearch(search: string, params: URLSearchParams): void {
72
+ cachedSearch = search;
73
+ cachedSearchParams = params;
74
+ }
75
+
76
+ // ─── Unload Guard (from unload-guard.ts) ─────────────────────────────────────
77
+
78
+ /** Whether the page is currently being unloaded. */
79
+ export let unloading = false;
80
+
81
+ export function _setUnloading(value: boolean): void {
82
+ unloading = value;
83
+ }
@@ -1,4 +1,21 @@
1
- export interface RenderErrorDigest<Code extends string = string, Data = unknown> {
1
+ /**
2
+ * A value that is safe to pass through `JSON.stringify` without data loss.
3
+ *
4
+ * Mirrors the server-side type. Defined separately to avoid importing server
5
+ * modules into the client bundle.
6
+ */
7
+ export type JsonSerializable =
8
+ | string
9
+ | number
10
+ | boolean
11
+ | null
12
+ | JsonSerializable[]
13
+ | { [key: string]: JsonSerializable };
14
+
15
+ export interface RenderErrorDigest<
16
+ Code extends string = string,
17
+ Data extends JsonSerializable = JsonSerializable,
18
+ > {
2
19
  code: Code;
3
20
  data: Data;
4
21
  }
@@ -8,20 +8,23 @@
8
8
  * unloaded so error boundaries and error handlers can suppress abort-related
9
9
  * errors during the unload window.
10
10
  *
11
+ * Mutable state is delegated to client/state.ts for singleton guarantees.
12
+ * See design/18-build-system.md §"Singleton State Registry"
13
+ *
11
14
  * See design/10-error-handling.md §"Known limitation: deny() inside Suspense and hydration"
12
15
  */
13
16
 
14
- let unloading = false;
17
+ import { unloading, _setUnloading } from './state.js';
15
18
 
16
19
  if (typeof window !== 'undefined') {
17
20
  window.addEventListener('beforeunload', () => {
18
- unloading = true;
21
+ _setUnloading(true);
19
22
  });
20
23
 
21
24
  // Also detect pagehide for bfcache-aware browsers (Safari).
22
25
  // pagehide fires for both navigations and page hide events.
23
26
  window.addEventListener('pagehide', () => {
24
- unloading = true;
27
+ _setUnloading(true);
25
28
  });
26
29
  }
27
30
 
@@ -23,31 +23,28 @@
23
23
  * params change during client-side navigation. This matches the pattern
24
24
  * used by usePathname() and useSearchParams().
25
25
  *
26
+ * All mutable state is delegated to client/state.ts for singleton guarantees.
27
+ * See design/18-build-system.md §"Singleton State Registry"
28
+ *
26
29
  * Design doc: design/09-typescript.md §"Typed Routes"
27
30
  */
28
31
 
29
32
  import { useSyncExternalStore } from 'react';
30
33
  import type { Routes } from '#/index.js';
31
34
  import { getSsrData } from './ssr-data.js';
35
+ import { currentParams, _setCurrentParams, paramsListeners } from './state.js';
32
36
 
33
37
  // ---------------------------------------------------------------------------
34
- // Module-level state + subscribe/notify pattern
38
+ // Module-level subscribe/notify pattern — state lives in state.ts
35
39
  // ---------------------------------------------------------------------------
36
40
 
37
- // The current params snapshot. Replaced (not mutated) on each navigation
38
- // so that React's Object.is check on the snapshot detects changes.
39
- let currentParams: Record<string, string | string[]> = {};
40
-
41
- // Listeners notified when currentParams changes.
42
- const listeners = new Set<() => void>();
43
-
44
41
  /**
45
42
  * Subscribe to params changes. Called by useSyncExternalStore.
46
43
  * Exported for testing — not intended for direct use by app code.
47
44
  */
48
45
  export function subscribe(callback: () => void): () => void {
49
- listeners.add(callback);
50
- return () => listeners.delete(callback);
46
+ paramsListeners.add(callback);
47
+ return () => paramsListeners.delete(callback);
51
48
  }
52
49
 
53
50
  /**
@@ -87,7 +84,7 @@ function getServerSnapshot(): Record<string, string | string[]> {
87
84
  * module-level fallback path.
88
85
  */
89
86
  export function setCurrentParams(params: Record<string, string | string[]>): void {
90
- currentParams = params;
87
+ _setCurrentParams(params);
91
88
  }
92
89
 
93
90
  /**
@@ -98,7 +95,7 @@ export function setCurrentParams(params: Record<string, string | string[]>): voi
98
95
  * {old tree, new params} intermediate state.
99
96
  */
100
97
  export function notifyParamsListeners(): void {
101
- for (const listener of listeners) {
98
+ for (const listener of paramsListeners) {
102
99
  listener();
103
100
  }
104
101
  }
@@ -125,7 +122,7 @@ export function useParams<R extends keyof Routes>(route: R): Routes[R]['params']
125
122
  export function useParams(route?: string): Record<string, string | string[]>;
126
123
  export function useParams(_route?: string): Record<string, string | string[]> {
127
124
  // useSyncExternalStore handles both client and SSR:
128
- // - Client: calls getSnapshot() → reads module-level currentParams
125
+ // - Client: calls getSnapshot() → reads currentParams from state.ts
129
126
  // - SSR: calls getServerSnapshot() → reads from ALS-backed getSsrData()
130
127
  //
131
128
  // We must always call the hook (Rules of Hooks — no conditional hook calls).
@@ -14,10 +14,14 @@
14
14
  *
15
15
  * During SSR, reads the request search params from the SSR ALS context
16
16
  * (populated by ssr-entry.ts) instead of window.location.
17
+ *
18
+ * All mutable state is delegated to client/state.ts for singleton guarantees.
19
+ * See design/18-build-system.md §"Singleton State Registry"
17
20
  */
18
21
 
19
22
  import { useSyncExternalStore } from 'react';
20
23
  import { getSsrData } from './ssr-data.js';
24
+ import { cachedSearch, cachedSearchParams, _setCachedSearch } from './state.js';
21
25
 
22
26
  function getSearch(): string {
23
27
  if (typeof window !== 'undefined') return window.location.search;
@@ -43,16 +47,16 @@ function subscribe(callback: () => void): () => void {
43
47
 
44
48
  // Cache the last search string and its parsed URLSearchParams to avoid
45
49
  // creating a new object on every render when the URL hasn't changed.
46
- let cachedSearch = '';
47
- let cachedParams = new URLSearchParams();
50
+ // State lives in client/state.ts for singleton guarantees.
48
51
 
49
52
  function getSearchParams(): URLSearchParams {
50
53
  const search = getSearch();
51
54
  if (search !== cachedSearch) {
52
- cachedSearch = search;
53
- cachedParams = new URLSearchParams(search);
55
+ const params = new URLSearchParams(search);
56
+ _setCachedSearch(search, params);
57
+ return params;
54
58
  }
55
- return cachedParams;
59
+ return cachedSearchParams;
56
60
  }
57
61
 
58
62
  function getServerSearchParams(): URLSearchParams {
@@ -92,8 +92,10 @@ export function timberShims(_ctx: PluginContext): Plugin {
92
92
  * instance as framework internals (which import via #/). This ensures
93
93
  * a single requestContextAls and _getRscFallback variable.
94
94
  *
95
- * @timber-js/app/client is NOT mapped here it resolves to dist/ via
96
- * package.json exports, where 'use client' is preserved on the entry.
95
+ * @timber-js/app/client is resolved to src/ in the SSR environment so
96
+ * client hooks share the same module instance as ssr-entry.ts internals.
97
+ * In RSC it resolves to dist/ (via package.json exports) where 'use client'
98
+ * is preserved on the entry for client boundary detection.
97
99
  */
98
100
  resolveId(id: string) {
99
101
  // Poison pill packages — resolve to virtual modules handled by load()
@@ -127,6 +129,28 @@ export function timberShims(_ctx: PluginContext): Plugin {
127
129
  return resolve(PKG_ROOT, 'src', 'server', 'index.ts');
128
130
  }
129
131
 
132
+ // @timber-js/app/client → src/ in the SSR environment so client hooks
133
+ // (useParams, usePathname, etc.) share the same module instance as
134
+ // ssr-entry.ts's internal imports (via #/client/...).
135
+ //
136
+ // Without this remap, @timber-js/app/client resolves to dist/ (via
137
+ // package.json exports), creating a module instance split: ssr-entry.ts
138
+ // registers the ALS-backed SSR data provider on the src/ instance of
139
+ // ssr-data.ts, but client component hooks read getSsrData() from the
140
+ // dist/ instance — which has no provider. Result: hooks like useParams()
141
+ // return empty defaults during SSR.
142
+ //
143
+ // This remap is SSR-only. The RSC environment still resolves to dist/
144
+ // where 'use client' is preserved on the entry (needed for client
145
+ // boundary detection). The client (browser) environment uses dist/
146
+ // for bundling.
147
+ if (cleanId === '@timber-js/app/client') {
148
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
149
+ if (envName === 'ssr') {
150
+ return resolve(PKG_ROOT, 'src', 'client', 'index.ts');
151
+ }
152
+ }
153
+
130
154
  return null;
131
155
  },
132
156
 
@@ -19,7 +19,10 @@ import type {
19
19
  InterceptionMarker,
20
20
  } from './types.js';
21
21
  import { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
22
- import { classifyMetadataRoute } from '#/server/metadata-routes.js';
22
+ import {
23
+ classifyMetadataRoute,
24
+ isDynamicMetadataExtension,
25
+ } from '#/server/metadata-routes.js';
23
26
 
24
27
  /**
25
28
  * Pattern matching encoded path delimiters that must be rejected during route discovery.
@@ -317,13 +320,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
317
320
  }
318
321
 
319
322
  // Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
323
+ // Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
324
+ // When both exist for the same base name, dynamic takes precedence.
320
325
  // See design/16-metadata.md §"Metadata Routes"
321
326
  const metaInfo = classifyMetadataRoute(entry);
322
327
  if (metaInfo) {
323
328
  if (!node.metadataRoutes) {
324
329
  node.metadataRoutes = new Map();
325
330
  }
326
- node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
331
+ const existing = node.metadataRoutes.get(name);
332
+ if (existing) {
333
+ // Dynamic > static precedence: only overwrite if the new file is dynamic
334
+ // or the existing file is static (dynamic always wins).
335
+ const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
336
+ const newIsDynamic = isDynamicMetadataExtension(name, ext);
337
+ if (newIsDynamic || !existingIsDynamic) {
338
+ node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
339
+ }
340
+ } else {
341
+ node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
342
+ }
327
343
  }
328
344
  }
329
345
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Browser Runtime Adapter — Re-exports from @vitejs/plugin-rsc/browser.
3
+ *
4
+ * This module insulates the rest of the framework from direct imports of
5
+ * @vitejs/plugin-rsc. The plugin is pre-1.0 and its API surface will change.
6
+ * By routing all browser-environment imports through this single file, a
7
+ * breaking upstream change only requires updating one place.
8
+ *
9
+ * Keep this as thin pass-through re-exports — the value is the single choke
10
+ * point, not abstraction.
11
+ */
12
+
13
+ export {
14
+ createFromReadableStream,
15
+ createFromFetch,
16
+ setServerCallback,
17
+ encodeReply,
18
+ } from '@vitejs/plugin-rsc/browser';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * RSC Runtime Adapter — Re-exports from @vitejs/plugin-rsc/rsc.
3
+ *
4
+ * This module insulates the rest of the framework from direct imports of
5
+ * @vitejs/plugin-rsc. The plugin is pre-1.0 and its API surface will change.
6
+ * By routing all RSC-environment imports through this single file, a breaking
7
+ * upstream change only requires updating one place instead of every file that
8
+ * touches the RSC runtime.
9
+ *
10
+ * Keep this as thin pass-through re-exports — the value is the single choke
11
+ * point, not abstraction.
12
+ */
13
+
14
+ export {
15
+ renderToReadableStream,
16
+ loadServerAction,
17
+ decodeReply,
18
+ decodeAction,
19
+ } from '@vitejs/plugin-rsc/rsc';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SSR Runtime Adapter — Re-exports from @vitejs/plugin-rsc/ssr.
3
+ *
4
+ * This module insulates the rest of the framework from direct imports of
5
+ * @vitejs/plugin-rsc. The plugin is pre-1.0 and its API surface will change.
6
+ * By routing all SSR-environment imports through this single file, a breaking
7
+ * upstream change only requires updating one place.
8
+ *
9
+ * Keep this as thin pass-through re-exports — the value is the single choke
10
+ * point, not abstraction.
11
+ */
12
+
13
+ export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';