@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.
- package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
- package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
- package/dist/_chunks/debug-B3Gypr3D.js +108 -0
- package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
- package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
- package/dist/_chunks/format-RyoGQL74.js.map +1 -0
- package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
- package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
- package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-BQUC8PHn.js} +5 -4
- package/dist/_chunks/request-context-BQUC8PHn.js.map +1 -0
- package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
- package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
- package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
- package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
- package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
- package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
- package/dist/adapters/nitro.d.ts +17 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +17 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/fast-hash.d.ts +22 -0
- package/dist/cache/fast-hash.d.ts.map +1 -0
- package/dist/cache/index.js +52 -10
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/register-cached-function.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -4
- package/dist/fonts/css.d.ts +1 -0
- package/dist/fonts/css.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +4 -2
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -21
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +11 -1
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +7 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +9 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts +6 -0
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/search-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/debug.d.ts +82 -0
- package/dist/server/debug.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/dev-warnings.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +32 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -4
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +10 -0
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/waituntil-bridge.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +1 -1
- package/src/adapters/nitro.ts +43 -5
- package/src/cache/fast-hash.ts +34 -0
- package/src/cache/register-cached-function.ts +7 -3
- package/src/cache/timber-cache.ts +17 -10
- package/src/client/browser-entry.ts +10 -6
- package/src/client/link.tsx +14 -9
- package/src/client/router.ts +4 -6
- package/src/client/segment-context.ts +6 -1
- package/src/client/segment-merger.ts +2 -8
- package/src/client/stale-reload.ts +5 -7
- package/src/client/top-loader.tsx +23 -19
- package/src/client/transition-root.tsx +7 -1
- package/src/fonts/css.ts +2 -1
- package/src/fonts/local.ts +7 -3
- package/src/index.ts +35 -2
- package/src/plugins/build-report.ts +23 -3
- package/src/plugins/entries.ts +9 -4
- package/src/plugins/fonts.ts +171 -19
- package/src/plugins/mdx.ts +9 -5
- package/src/plugins/server-bundle.ts +4 -0
- package/src/server/access-gate.tsx +3 -2
- package/src/server/action-client.ts +15 -5
- package/src/server/debug.ts +137 -0
- package/src/server/deny-renderer.ts +3 -2
- package/src/server/dev-warnings.ts +2 -1
- package/src/server/html-injectors.ts +30 -10
- package/src/server/logger.ts +4 -3
- package/src/server/pipeline.ts +34 -20
- package/src/server/primitives.ts +2 -1
- package/src/server/request-context.ts +3 -2
- package/src/server/route-element-builder.ts +1 -6
- package/src/server/rsc-entry/index.ts +50 -7
- package/src/server/rsc-entry/rsc-payload.ts +42 -7
- package/src/server/rsc-entry/rsc-stream.ts +10 -5
- package/src/server/rsc-entry/ssr-renderer.ts +12 -5
- package/src/server/rsc-prop-warnings.ts +3 -1
- package/src/server/tracing.ts +23 -0
- package/src/server/waituntil-bridge.ts +4 -1
- package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
- 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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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,
|
|
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({
|
|
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
|
/**
|
package/src/fonts/local.ts
CHANGED
|
@@ -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
|
|
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('${
|
|
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
|
-
//
|
|
404
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 ────────────────────────────────────────────────────
|
package/src/plugins/entries.ts
CHANGED
|
@@ -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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|
package/src/plugins/fonts.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
|
|
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
|
package/src/plugins/mdx.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 (
|
|
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 (
|
|
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.',
|