@timber-js/app 0.2.0-alpha.7 → 0.2.0-alpha.9
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/{tracing-Cwn7697K.js → tracing-CemImE6h.js} +16 -2
- package/dist/_chunks/{tracing-Cwn7697K.js.map → tracing-CemImE6h.js.map} +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- 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 +51 -9
- 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/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/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -34
- package/dist/index.js.map +1 -1
- package/dist/plugins/fonts.d.ts +7 -0
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/index.js +9 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/response-cache.d.ts +5 -4
- package/dist/server/response-cache.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/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/package.json +1 -1
- package/src/adapters/nitro.ts +6 -1
- 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 +4 -6
- package/src/client/top-loader.tsx +8 -7
- package/src/client/transition-root.tsx +7 -1
- package/src/index.ts +1 -2
- package/src/plugins/entries.ts +1 -1
- package/src/plugins/fonts.ts +54 -43
- package/src/server/action-client.ts +7 -1
- package/src/server/pipeline.ts +7 -0
- package/src/server/response-cache.ts +169 -36
- package/src/server/route-element-builder.ts +1 -6
- package/src/server/rsc-entry/index.ts +9 -6
- package/src/server/rsc-entry/rsc-payload.ts +42 -10
- package/src/server/rsc-entry/rsc-stream.ts +9 -5
- package/src/server/rsc-entry/ssr-renderer.ts +11 -8
- package/src/server/tracing.ts +23 -0
- package/src/server/waituntil-bridge.ts +4 -1
package/src/plugins/fonts.ts
CHANGED
|
@@ -36,8 +36,15 @@ const VIRTUAL_LOCAL = '@timber/fonts/local';
|
|
|
36
36
|
const RESOLVED_GOOGLE = '\0@timber/fonts/google';
|
|
37
37
|
const RESOLVED_LOCAL = '\0@timber/fonts/local';
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Virtual module that exports the combined font CSS string.
|
|
41
|
+
*
|
|
42
|
+
* The RSC entry imports this module and inlines it as a <style> tag.
|
|
43
|
+
* Unlike a config-based approach, this module is loaded lazily (on first
|
|
44
|
+
* request), so it always has up-to-date font data from the registry.
|
|
45
|
+
*/
|
|
46
|
+
const VIRTUAL_FONT_CSS = 'virtual:timber-font-css';
|
|
47
|
+
const RESOLVED_FONT_CSS = '\0virtual:timber-font-css';
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
50
|
* Registry of fonts extracted during transform.
|
|
@@ -247,6 +254,33 @@ function generateLocalVirtualModule(): string {
|
|
|
247
254
|
].join('\n');
|
|
248
255
|
}
|
|
249
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Generate CSS for a single extracted font.
|
|
259
|
+
*
|
|
260
|
+
* Includes @font-face rules (for local fonts), fallback @font-face,
|
|
261
|
+
* and the scoped class rule.
|
|
262
|
+
*/
|
|
263
|
+
export function generateFontCss(font: ExtractedFont): string {
|
|
264
|
+
const cssParts: string[] = [];
|
|
265
|
+
|
|
266
|
+
if (font.provider === 'local' && font.localSources) {
|
|
267
|
+
const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
|
|
268
|
+
const faceCss = generateFontFaces(faces);
|
|
269
|
+
if (faceCss) cssParts.push(faceCss);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const fallbackCss = generateFallbackCss(font.family);
|
|
273
|
+
if (fallbackCss) cssParts.push(fallbackCss);
|
|
274
|
+
|
|
275
|
+
if (font.variable) {
|
|
276
|
+
cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
|
|
277
|
+
} else {
|
|
278
|
+
cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return cssParts.join('\n\n');
|
|
282
|
+
}
|
|
283
|
+
|
|
250
284
|
/**
|
|
251
285
|
* Generate the CSS output for all extracted fonts.
|
|
252
286
|
*
|
|
@@ -255,27 +289,9 @@ function generateLocalVirtualModule(): string {
|
|
|
255
289
|
*/
|
|
256
290
|
export function generateAllFontCss(registry: FontRegistry): string {
|
|
257
291
|
const cssParts: string[] = [];
|
|
258
|
-
|
|
259
292
|
for (const font of registry.values()) {
|
|
260
|
-
|
|
261
|
-
if (font.provider === 'local' && font.localSources) {
|
|
262
|
-
const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
|
|
263
|
-
const faceCss = generateFontFaces(faces);
|
|
264
|
-
if (faceCss) cssParts.push(faceCss);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Generate fallback @font-face if metrics are available
|
|
268
|
-
const fallbackCss = generateFallbackCss(font.family);
|
|
269
|
-
if (fallbackCss) cssParts.push(fallbackCss);
|
|
270
|
-
|
|
271
|
-
// Generate scoped class
|
|
272
|
-
if (font.variable) {
|
|
273
|
-
cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
|
|
274
|
-
} else {
|
|
275
|
-
cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
|
|
276
|
-
}
|
|
293
|
+
cssParts.push(generateFontCss(font));
|
|
277
294
|
}
|
|
278
|
-
|
|
279
295
|
return cssParts.join('\n\n');
|
|
280
296
|
}
|
|
281
297
|
|
|
@@ -372,20 +388,32 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
372
388
|
name: 'timber-fonts',
|
|
373
389
|
|
|
374
390
|
/**
|
|
375
|
-
* Resolve `@timber/fonts/google
|
|
391
|
+
* Resolve `@timber/fonts/google`, `@timber/fonts/local`,
|
|
392
|
+
* and `virtual:timber-font-css` virtual modules.
|
|
376
393
|
*/
|
|
377
394
|
resolveId(id: string) {
|
|
378
395
|
if (id === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
|
|
379
396
|
if (id === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
|
|
397
|
+
if (id === VIRTUAL_FONT_CSS) return RESOLVED_FONT_CSS;
|
|
380
398
|
return null;
|
|
381
399
|
},
|
|
382
400
|
|
|
383
401
|
/**
|
|
384
402
|
* Return generated source for font virtual modules.
|
|
403
|
+
*
|
|
404
|
+
* `virtual:timber-font-css` exports the combined @font-face CSS
|
|
405
|
+
* as a string. The RSC entry imports it and inlines a <style> tag.
|
|
406
|
+
* Because this is loaded lazily (on first request), the font
|
|
407
|
+
* registry is always populated by the time it's needed.
|
|
385
408
|
*/
|
|
386
409
|
load(id: string) {
|
|
387
410
|
if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(registry);
|
|
388
411
|
if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
|
|
412
|
+
|
|
413
|
+
if (id === RESOLVED_FONT_CSS) {
|
|
414
|
+
const css = generateAllFontCss(registry);
|
|
415
|
+
return `export default ${JSON.stringify(css)};`;
|
|
416
|
+
}
|
|
389
417
|
return null;
|
|
390
418
|
},
|
|
391
419
|
|
|
@@ -412,14 +440,8 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
412
440
|
return;
|
|
413
441
|
}
|
|
414
442
|
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
const css = generateAllFontCss(registry);
|
|
418
|
-
res.setHeader('Content-Type', 'text/css');
|
|
419
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
420
|
-
res.end(css);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
443
|
+
// Font CSS is now injected via Vite's CSS pipeline (virtual:timber-font-css modules).
|
|
444
|
+
// This middleware only serves font binary files (woff2, etc.).
|
|
423
445
|
|
|
424
446
|
// Find the matching font file in the registry
|
|
425
447
|
for (const font of registry.values()) {
|
|
@@ -578,10 +600,6 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
578
600
|
}
|
|
579
601
|
|
|
580
602
|
if (transformedCode !== code) {
|
|
581
|
-
// Mark that fonts are in use so the RSC entry injects a <link> tag.
|
|
582
|
-
if (registry.size > 0 && !ctx.fontCssUrl) {
|
|
583
|
-
ctx.fontCssUrl = FONT_CSS_PATH;
|
|
584
|
-
}
|
|
585
603
|
return { code: transformedCode, map: null };
|
|
586
604
|
}
|
|
587
605
|
|
|
@@ -627,15 +645,8 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
627
645
|
}
|
|
628
646
|
}
|
|
629
647
|
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
if (fontCss) {
|
|
633
|
-
this.emitFile({
|
|
634
|
-
type: 'asset',
|
|
635
|
-
fileName: '_timber/fonts/fonts.css',
|
|
636
|
-
source: fontCss,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
648
|
+
// Font CSS is emitted by Vite's CSS pipeline via virtual:timber-font-css modules.
|
|
649
|
+
// We only need to emit font binary files and update the build manifest here.
|
|
639
650
|
|
|
640
651
|
if (!ctx.buildManifest) return;
|
|
641
652
|
|
|
@@ -295,8 +295,14 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
295
295
|
// Determine input — either FormData (from useActionState) or direct arg
|
|
296
296
|
let rawInput: unknown;
|
|
297
297
|
if (args.length === 2 && args[1] instanceof FormData) {
|
|
298
|
-
// Called as (prevState, formData) by React useActionState
|
|
298
|
+
// Called as (prevState, formData) by React useActionState (with-JS path)
|
|
299
299
|
rawInput = schema ? parseFormData(args[1]) : args[1];
|
|
300
|
+
} else if (args.length === 1 && args[0] instanceof FormData) {
|
|
301
|
+
// No-JS path: React's decodeAction binds FormData as the sole argument.
|
|
302
|
+
// The form POSTs without JavaScript, decodeAction resolves the server
|
|
303
|
+
// reference and binds the FormData, then executeAction calls fn() with
|
|
304
|
+
// no additional args — so the bound FormData arrives as args[0].
|
|
305
|
+
rawInput = schema ? parseFormData(args[0]) : args[0];
|
|
300
306
|
} else {
|
|
301
307
|
// Direct call: action(input)
|
|
302
308
|
rawInput = args[0];
|
package/src/server/pipeline.ts
CHANGED
|
@@ -487,7 +487,14 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
487
487
|
return new Response(null, { status: error.status });
|
|
488
488
|
}
|
|
489
489
|
// RedirectSignal leaked from render — honour the redirect.
|
|
490
|
+
// For RSC payload requests, return 204 + X-Timber-Redirect so the
|
|
491
|
+
// client router can perform a soft SPA redirect (same as middleware path).
|
|
490
492
|
if (error instanceof RedirectSignal) {
|
|
493
|
+
const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
494
|
+
if (isRsc) {
|
|
495
|
+
responseHeaders.set('X-Timber-Redirect', error.location);
|
|
496
|
+
return new Response(null, { status: 204, headers: responseHeaders });
|
|
497
|
+
}
|
|
491
498
|
responseHeaders.set('Location', error.location);
|
|
492
499
|
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
493
500
|
}
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* re-executing the RSC-to-SSR pipeline. Entries have a short TTL
|
|
11
11
|
* (default 5s) and the cache has a bounded size (default 150 entries).
|
|
12
12
|
*
|
|
13
|
-
* Cache keys are compound:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Cache keys are compound: pathname + search + isRscPayload + Vary'd headers.
|
|
14
|
+
* Only GET requests are cached. Responses with Set-Cookie, Cache-Control:
|
|
15
|
+
* no-store/private, or error/redirect status codes are never cached.
|
|
16
|
+
* When `publicOnly` is true (default), requests with Cookie or Authorization
|
|
17
|
+
* headers bypass the cache entirely.
|
|
17
18
|
*
|
|
18
19
|
* See design/02-rendering-pipeline.md, design/31-benchmarking.md.
|
|
19
20
|
*/
|
|
@@ -65,16 +66,11 @@ interface CacheEntry {
|
|
|
65
66
|
headers: [string, string][];
|
|
66
67
|
/** Timestamp when this entry was created. */
|
|
67
68
|
createdAt: number;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
interface SingleflightResult {
|
|
74
|
-
/** Non-null when the response wasn't cacheable — only the first caller gets it. */
|
|
75
|
-
response: Response | null;
|
|
76
|
-
/** Non-null when the response was cached — all callers construct from this. */
|
|
77
|
-
entry: CacheEntry | null;
|
|
69
|
+
/**
|
|
70
|
+
* The Vary header value from the original response, if any.
|
|
71
|
+
* Used to build variant-aware cache keys for subsequent requests.
|
|
72
|
+
*/
|
|
73
|
+
vary: string | null;
|
|
78
74
|
}
|
|
79
75
|
|
|
80
76
|
// ─── LRU Cache ─────────────────────────────────────────────────────────────
|
|
@@ -154,6 +150,59 @@ export interface ResponseCache {
|
|
|
154
150
|
clear(): void;
|
|
155
151
|
}
|
|
156
152
|
|
|
153
|
+
// ─── Cache-Control parsing ─────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if a Cache-Control header value contains directives that forbid
|
|
157
|
+
* storing the response in a shared cache. We check for `no-store` and
|
|
158
|
+
* `private` — both indicate the response must not be reused.
|
|
159
|
+
*
|
|
160
|
+
* This is intentionally simple: we don't parse `max-age`, `s-maxage`,
|
|
161
|
+
* `must-revalidate`, etc. This is a short-TTL render cache, not an HTTP
|
|
162
|
+
* cache — we just need to respect explicit "don't cache this" signals.
|
|
163
|
+
*/
|
|
164
|
+
function hasCacheControlNoStore(headerValue: string | null): boolean {
|
|
165
|
+
if (!headerValue) return false;
|
|
166
|
+
// Split on comma, trim whitespace, check for no-store or private directives.
|
|
167
|
+
// Case-insensitive per HTTP spec.
|
|
168
|
+
const lower = headerValue.toLowerCase();
|
|
169
|
+
return lower.includes('no-store') || lower.includes('private');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Vary header handling ──────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse a Vary header value into a sorted list of header names.
|
|
176
|
+
* Returns null if there is no Vary header or it's empty.
|
|
177
|
+
* Returns ['*'] if Vary: * (meaning the response varies on everything —
|
|
178
|
+
* effectively uncacheable).
|
|
179
|
+
*/
|
|
180
|
+
function parseVaryHeader(headerValue: string | null): string[] | null {
|
|
181
|
+
if (!headerValue) return null;
|
|
182
|
+
const trimmed = headerValue.trim();
|
|
183
|
+
if (trimmed === '') return null;
|
|
184
|
+
if (trimmed === '*') return ['*'];
|
|
185
|
+
|
|
186
|
+
// Split on comma, normalize to lowercase, sort for deterministic keys
|
|
187
|
+
return trimmed
|
|
188
|
+
.split(',')
|
|
189
|
+
.map((h) => h.trim().toLowerCase())
|
|
190
|
+
.filter((h) => h.length > 0)
|
|
191
|
+
.sort();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a Vary-aware suffix for the cache key. For each header name in the
|
|
196
|
+
* Vary list, append the request's value for that header. This ensures that
|
|
197
|
+
* requests with different Accept-Language (for example) get different cache
|
|
198
|
+
* entries.
|
|
199
|
+
*/
|
|
200
|
+
function buildVarySuffix(req: Request, varyHeaders: string[]): string {
|
|
201
|
+
return varyHeaders.map((h) => `${h}=${req.headers.get(h) ?? ''}`).join('&');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Factory ───────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
157
206
|
/**
|
|
158
207
|
* Create a response cache with singleflight deduplication and LRU caching.
|
|
159
208
|
*/
|
|
@@ -161,7 +210,22 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
161
210
|
const lru = new LruCache(config.maxSize, config.ttlMs);
|
|
162
211
|
const singleflight = createSingleflight();
|
|
163
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Known Vary headers per path. When a response includes a Vary header,
|
|
215
|
+
* we store the parsed header names so that subsequent requests to the
|
|
216
|
+
* same path can include the Vary'd header values in their cache key
|
|
217
|
+
* BEFORE rendering (i.e., on cache lookup, not just after the first
|
|
218
|
+
* render). This avoids the "first request always misses" problem for
|
|
219
|
+
* Vary'd responses.
|
|
220
|
+
*/
|
|
221
|
+
const knownVaryHeaders = new Map<string, string[]>();
|
|
222
|
+
|
|
164
223
|
function buildCacheKey(req: Request, isRscPayload: boolean): string | null {
|
|
224
|
+
// Never cache non-GET requests. POST/PUT/DELETE have side effects and
|
|
225
|
+
// may carry per-request state (e.g., form flash data via ALS) that makes
|
|
226
|
+
// the rendered output unique even for the same URL.
|
|
227
|
+
if (req.method !== 'GET') return null;
|
|
228
|
+
|
|
165
229
|
// When publicOnly is true, skip caching for authenticated requests
|
|
166
230
|
if (config.publicOnly) {
|
|
167
231
|
if (req.headers.has('Cookie') || req.headers.has('Authorization')) {
|
|
@@ -170,13 +234,30 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
170
234
|
}
|
|
171
235
|
|
|
172
236
|
const url = new URL(req.url);
|
|
173
|
-
|
|
237
|
+
// Include search params in the cache key. Pages that use searchParams
|
|
238
|
+
// (e.g., ?sort=asc, ?page=2) produce different output per query string.
|
|
239
|
+
let key = `${url.pathname}${url.search}:${isRscPayload ? 'rsc' : 'html'}`;
|
|
240
|
+
|
|
241
|
+
// If we've seen a Vary header for this path before, include the varied
|
|
242
|
+
// request header values in the key so different variants get different
|
|
243
|
+
// cache entries.
|
|
244
|
+
const pathKey = `${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
|
|
245
|
+
const varyHeaders = knownVaryHeaders.get(pathKey);
|
|
246
|
+
if (varyHeaders) {
|
|
247
|
+
if (varyHeaders[0] === '*') {
|
|
248
|
+
// Vary: * means the response varies on everything — uncacheable
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
key += ':' + buildVarySuffix(req, varyHeaders);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return key;
|
|
174
255
|
}
|
|
175
256
|
|
|
176
257
|
/**
|
|
177
258
|
* Check if a response is cacheable.
|
|
178
|
-
* Responses with Set-Cookie headers
|
|
179
|
-
*
|
|
259
|
+
* Responses with Set-Cookie headers, Cache-Control: no-store/private,
|
|
260
|
+
* error/redirect status codes, or Vary: * are never cached.
|
|
180
261
|
*/
|
|
181
262
|
function isCacheable(response: Response): boolean {
|
|
182
263
|
// Don't cache error responses
|
|
@@ -188,6 +269,14 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
188
269
|
// Don't cache responses with Set-Cookie (user-specific state)
|
|
189
270
|
if (response.headers.has('Set-Cookie')) return false;
|
|
190
271
|
|
|
272
|
+
// Respect Cache-Control: no-store and private directives.
|
|
273
|
+
// If the application explicitly says "don't cache this," we obey.
|
|
274
|
+
if (hasCacheControlNoStore(response.headers.get('Cache-Control'))) return false;
|
|
275
|
+
|
|
276
|
+
// Vary: * means the response varies on everything — don't cache
|
|
277
|
+
const vary = parseVaryHeader(response.headers.get('Vary'));
|
|
278
|
+
if (vary && vary[0] === '*') return false;
|
|
279
|
+
|
|
191
280
|
// Only cache responses with a body
|
|
192
281
|
if (!response.body) return false;
|
|
193
282
|
|
|
@@ -196,13 +285,27 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
196
285
|
|
|
197
286
|
/** Construct a fresh Response from a cache entry (each caller gets their own). */
|
|
198
287
|
function responseFromEntry(entry: CacheEntry): Response {
|
|
288
|
+
// Null-body statuses (204, 304) cannot have a body per HTTP spec.
|
|
289
|
+
// The Response constructor throws if you pass a body with these statuses.
|
|
290
|
+
const isNullBody = entry.status === 204 || entry.status === 304;
|
|
199
291
|
// slice(0) creates a copy so each caller owns their buffer
|
|
200
|
-
return new Response(entry.body.slice(0), {
|
|
292
|
+
return new Response(isNullBody ? null : entry.body.slice(0), {
|
|
201
293
|
status: entry.status,
|
|
202
294
|
headers: entry.headers,
|
|
203
295
|
});
|
|
204
296
|
}
|
|
205
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Record the Vary header from a response so future requests to the same
|
|
300
|
+
* path include varied header values in their cache key.
|
|
301
|
+
*/
|
|
302
|
+
function recordVaryHeaders(pathKey: string, response: Response): void {
|
|
303
|
+
const vary = parseVaryHeader(response.headers.get('Vary'));
|
|
304
|
+
if (vary) {
|
|
305
|
+
knownVaryHeaders.set(pathKey, vary);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
206
309
|
return {
|
|
207
310
|
async getOrRender(
|
|
208
311
|
req: Request,
|
|
@@ -211,7 +314,9 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
211
314
|
): Promise<Response> {
|
|
212
315
|
const cacheKey = buildCacheKey(req, isRscPayload);
|
|
213
316
|
|
|
214
|
-
// No cache key = skip caching entirely
|
|
317
|
+
// No cache key = skip caching and singleflight entirely.
|
|
318
|
+
// This covers POST requests, authenticated requests (publicOnly),
|
|
319
|
+
// and Vary: * responses.
|
|
215
320
|
if (cacheKey === null) {
|
|
216
321
|
return renderFn();
|
|
217
322
|
}
|
|
@@ -223,18 +328,41 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
223
328
|
}
|
|
224
329
|
|
|
225
330
|
// Singleflight: concurrent requests to the same key share one render.
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
331
|
+
// We buffer the response body into an ArrayBuffer so ALL callers —
|
|
332
|
+
// including the singleflight leader — get independent copies.
|
|
333
|
+
// This fixes the body-loss bug where the leader consumed the body
|
|
334
|
+
// and concurrent waiters got an empty response.
|
|
335
|
+
const result: CacheEntry | null = await singleflight.do(cacheKey, async () => {
|
|
229
336
|
const response = await renderFn();
|
|
230
337
|
|
|
338
|
+
// Record Vary headers for future cache key construction
|
|
339
|
+
const url = new URL(req.url);
|
|
340
|
+
const pathKey = `${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
|
|
341
|
+
recordVaryHeaders(pathKey, response);
|
|
342
|
+
|
|
231
343
|
if (!isCacheable(response)) {
|
|
232
|
-
|
|
344
|
+
// Buffer the body even for non-cacheable responses so the
|
|
345
|
+
// singleflight leader and all concurrent waiters each get
|
|
346
|
+
// an independent copy. Without this, the leader consumes
|
|
347
|
+
// the body stream and waiters get an empty response.
|
|
348
|
+
const body = await response.arrayBuffer();
|
|
349
|
+
const headers: [string, string][] = [];
|
|
350
|
+
response.headers.forEach((value, key) => {
|
|
351
|
+
headers.push([key, value]);
|
|
352
|
+
});
|
|
353
|
+
// Return as a CacheEntry shape but DON'T store in LRU.
|
|
354
|
+
// Callers construct Responses from this, but it won't be
|
|
355
|
+
// reused for future requests.
|
|
356
|
+
return {
|
|
357
|
+
body,
|
|
358
|
+
status: response.status,
|
|
359
|
+
headers,
|
|
360
|
+
createdAt: Date.now(),
|
|
361
|
+
vary: response.headers.get('Vary'),
|
|
362
|
+
};
|
|
233
363
|
}
|
|
234
364
|
|
|
235
365
|
// Buffer the response body for caching.
|
|
236
|
-
// The original Response body is consumed here — callers get copies
|
|
237
|
-
// from the cached ArrayBuffer.
|
|
238
366
|
const body = await response.arrayBuffer();
|
|
239
367
|
const headers: [string, string][] = [];
|
|
240
368
|
response.headers.forEach((value, key) => {
|
|
@@ -246,24 +374,28 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
246
374
|
status: response.status,
|
|
247
375
|
headers,
|
|
248
376
|
createdAt: Date.now(),
|
|
377
|
+
vary: response.headers.get('Vary'),
|
|
249
378
|
};
|
|
250
379
|
|
|
251
|
-
|
|
380
|
+
// Re-check the cache key now that we know the Vary headers.
|
|
381
|
+
// The initial key may not have included Vary'd header values
|
|
382
|
+
// if this was the first request to this path. Rebuild the key
|
|
383
|
+
// with the now-known Vary headers for correct LRU storage.
|
|
384
|
+
const updatedKey = buildCacheKey(req, isRscPayload);
|
|
385
|
+
if (updatedKey) {
|
|
386
|
+
lru.set(updatedKey, entry);
|
|
387
|
+
}
|
|
252
388
|
|
|
253
|
-
return
|
|
389
|
+
return entry;
|
|
254
390
|
});
|
|
255
391
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// responses (errors, redirects, Set-Cookie) are rare under concurrent
|
|
261
|
-
// identical requests, and the status + headers are still correct.
|
|
262
|
-
if (result.response) {
|
|
263
|
-
return result.response;
|
|
392
|
+
if (result === null) {
|
|
393
|
+
// Shouldn't happen — singleflight always returns a result.
|
|
394
|
+
// Defensive fallback: re-render.
|
|
395
|
+
return renderFn();
|
|
264
396
|
}
|
|
265
397
|
|
|
266
|
-
return responseFromEntry(result
|
|
398
|
+
return responseFromEntry(result);
|
|
267
399
|
},
|
|
268
400
|
|
|
269
401
|
get size() {
|
|
@@ -272,6 +404,7 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
|
|
|
272
404
|
|
|
273
405
|
clear() {
|
|
274
406
|
lru.clear();
|
|
407
|
+
knownVaryHeaders.clear();
|
|
275
408
|
},
|
|
276
409
|
};
|
|
277
410
|
}
|
|
@@ -352,12 +352,7 @@ export async function buildRouteElement(
|
|
|
352
352
|
// same urlPath (e.g., /(marketing) and /(app) both have "/"),
|
|
353
353
|
// which would cause the wrong cached layout to be reused
|
|
354
354
|
const skip =
|
|
355
|
-
shouldSkipSegment(
|
|
356
|
-
segment.urlPath,
|
|
357
|
-
layoutComponent,
|
|
358
|
-
isLeaf,
|
|
359
|
-
clientStateTree ?? null
|
|
360
|
-
) &&
|
|
355
|
+
shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
|
|
361
356
|
hasRenderedLayoutBelow &&
|
|
362
357
|
segment.segmentType !== 'group';
|
|
363
358
|
|
|
@@ -23,6 +23,8 @@ import config from 'virtual:timber-config';
|
|
|
23
23
|
import buildManifest from 'virtual:timber-build-manifest';
|
|
24
24
|
// @ts-expect-error — virtual module provided by timber-entries plugin
|
|
25
25
|
import loadUserInstrumentation from 'virtual:timber-instrumentation';
|
|
26
|
+
// @ts-expect-error — virtual module provided by timber-fonts plugin
|
|
27
|
+
import fontCss from 'virtual:timber-font-css';
|
|
26
28
|
|
|
27
29
|
import type { FormRerender } from '#/server/action-handler.js';
|
|
28
30
|
import { handleActionRequest, isActionRequest } from '#/server/action-handler.js';
|
|
@@ -368,7 +370,8 @@ async function renderRoute(
|
|
|
368
370
|
throw error;
|
|
369
371
|
}
|
|
370
372
|
|
|
371
|
-
const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
|
|
373
|
+
const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
|
|
374
|
+
routeResult;
|
|
372
375
|
|
|
373
376
|
// Build head HTML for injection into the SSR output.
|
|
374
377
|
// Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
|
|
@@ -385,11 +388,11 @@ async function renderRoute(
|
|
|
385
388
|
headHtml += buildCssLinkTags(cssUrls);
|
|
386
389
|
}
|
|
387
390
|
|
|
388
|
-
//
|
|
389
|
-
// The
|
|
390
|
-
|
|
391
|
-
if (
|
|
392
|
-
headHtml += `<
|
|
391
|
+
// Inline font CSS as a <style> tag — @font-face rules and scoped classes.
|
|
392
|
+
// The virtual:timber-font-css module is loaded lazily, so the font registry
|
|
393
|
+
// is always populated by the time we get here (no timing issues).
|
|
394
|
+
if (fontCss) {
|
|
395
|
+
headHtml += `<style data-timber-fonts>${fontCss}</style>`;
|
|
393
396
|
}
|
|
394
397
|
|
|
395
398
|
const fontEntries = collectRouteFonts(segments, typedManifest);
|
|
@@ -45,18 +45,45 @@ export async function buildRscPayloadResponse(
|
|
|
45
45
|
skippedSegments?: string[]
|
|
46
46
|
): Promise<Response> {
|
|
47
47
|
// Read the first chunk from the RSC stream before committing headers.
|
|
48
|
+
// Race the first read against signal detection — if an async component
|
|
49
|
+
// throws a RedirectSignal or DenySignal, the onError callback fires
|
|
50
|
+
// signals.onSignal() and we can react immediately without waiting for
|
|
51
|
+
// the full macrotask queue.
|
|
52
|
+
//
|
|
53
|
+
// The rejection chain for an async-wrapped page component:
|
|
54
|
+
// 1. PageComponent throws RedirectSignal
|
|
55
|
+
// 2. withSpan catches and re-throws (microtask 1)
|
|
56
|
+
// 3. TracedPage promise rejects (microtask 2)
|
|
57
|
+
// 4. React Flight rejection handler → onError (microtask 3+)
|
|
58
|
+
//
|
|
59
|
+
// Promise.race reacts the instant onError fires, eliminating the
|
|
60
|
+
// per-request setTimeout(0) macrotask delay for the common case
|
|
61
|
+
// (no signal). A 50ms ceiling timeout guards against edge cases
|
|
62
|
+
// where onError never fires.
|
|
48
63
|
const reader = rscStream.getReader();
|
|
49
|
-
const
|
|
64
|
+
const signalDetected = new Promise<void>((resolve) => {
|
|
65
|
+
signals.onSignal = resolve;
|
|
66
|
+
});
|
|
50
67
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
type RaceResult =
|
|
69
|
+
| { type: 'data'; chunk: ReadableStreamReadResult<Uint8Array> }
|
|
70
|
+
| { type: 'signal' };
|
|
71
|
+
|
|
72
|
+
const first: RaceResult = await Promise.race([
|
|
73
|
+
reader.read().then((chunk) => ({ type: 'data' as const, chunk })),
|
|
74
|
+
signalDetected.then(() => ({ type: 'signal' as const })),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// If data arrived first, still check signals — they may have fired
|
|
78
|
+
// concurrently. Also do a final ceiling timeout check for edge cases
|
|
79
|
+
// where the signal fires just after the first read resolves.
|
|
80
|
+
if (first.type === 'data' && !signals.redirectSignal && !signals.denySignal) {
|
|
81
|
+
// Brief yield to let any in-flight microtask rejections complete.
|
|
82
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Detach the callback — no longer needed after this point.
|
|
86
|
+
signals.onSignal = undefined;
|
|
60
87
|
|
|
61
88
|
// Check for redirect/deny signals detected during initial rendering
|
|
62
89
|
const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
|
|
@@ -75,6 +102,11 @@ export async function buildRscPayloadResponse(
|
|
|
75
102
|
);
|
|
76
103
|
}
|
|
77
104
|
|
|
105
|
+
// Extract the first chunk from the race result.
|
|
106
|
+
// If the signal won the race, read the first chunk now (the stream
|
|
107
|
+
// was already cancelled above, but we need a firstRead shape below).
|
|
108
|
+
const firstRead = first.type === 'data' ? first.chunk : await reader.read();
|
|
109
|
+
|
|
78
110
|
// Reconstruct the stream: prepend the buffered first chunk,
|
|
79
111
|
// then continue piping from the original reader.
|
|
80
112
|
const patchedStream = new ReadableStream<Uint8Array>({
|
|
@@ -24,11 +24,17 @@ import { isDebug } from '#/server/debug.js';
|
|
|
24
24
|
*
|
|
25
25
|
* Signals fire asynchronously via `onError` during stream consumption.
|
|
26
26
|
* The first signal of each type wins — subsequent signals are ignored.
|
|
27
|
+
*
|
|
28
|
+
* `onSignal` is an optional callback fired when a DenySignal or
|
|
29
|
+
* RedirectSignal is captured. Consumers use it with Promise.race to
|
|
30
|
+
* react immediately instead of polling with setTimeout/queueMicrotask.
|
|
27
31
|
*/
|
|
28
32
|
export interface RenderSignals {
|
|
29
33
|
denySignal: DenySignal | null;
|
|
30
34
|
redirectSignal: RedirectSignal | null;
|
|
31
35
|
renderError: { error: unknown; status: number } | null;
|
|
36
|
+
/** Callback fired when a redirect or deny signal is captured in onError. */
|
|
37
|
+
onSignal?: () => void;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface RscStreamResult {
|
|
@@ -67,11 +73,13 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
67
73
|
if (isAbortError(error) || req.signal?.aborted) return;
|
|
68
74
|
if (error instanceof DenySignal) {
|
|
69
75
|
signals.denySignal = error;
|
|
76
|
+
signals.onSignal?.();
|
|
70
77
|
// Return structured digest for client-side error boundaries
|
|
71
78
|
return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
|
|
72
79
|
}
|
|
73
80
|
if (error instanceof RedirectSignal) {
|
|
74
81
|
signals.redirectSignal = error;
|
|
82
|
+
signals.onSignal?.();
|
|
75
83
|
return JSON.stringify({
|
|
76
84
|
type: 'redirect',
|
|
77
85
|
location: error.location,
|
|
@@ -98,11 +106,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
98
106
|
// directive isn't at the very top of the file, or the component is
|
|
99
107
|
// re-exported through a barrel file without 'use client'.
|
|
100
108
|
// See LOCAL-297.
|
|
101
|
-
if (
|
|
102
|
-
isDebug() &&
|
|
103
|
-
error instanceof Error &&
|
|
104
|
-
error.message.includes('Invalid hook call')
|
|
105
|
-
) {
|
|
109
|
+
if (isDebug() && error instanceof Error && error.message.includes('Invalid hook call')) {
|
|
106
110
|
console.error(
|
|
107
111
|
'[timber] A React hook was called during RSC rendering. This usually means a ' +
|
|
108
112
|
"'use client' component is being executed as a server component instead of " +
|