@timber-js/app 0.2.0-alpha.1 → 0.2.0-alpha.10

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 (140) hide show
  1. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  3. package/dist/_chunks/debug-gwlJkDuf.js +108 -0
  4. package/dist/_chunks/debug-gwlJkDuf.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-DviM89f0.js} +3 -2
  6. package/dist/_chunks/format-DviM89f0.js.map +1 -0
  7. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  8. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  9. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  11. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-DIkVh_jG.js} +5 -4
  12. package/dist/_chunks/request-context-DIkVh_jG.js.map +1 -0
  13. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  15. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  16. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  17. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  21. package/dist/adapters/nitro.d.ts.map +1 -1
  22. package/dist/adapters/nitro.js.map +1 -1
  23. package/dist/cache/fast-hash.d.ts +22 -0
  24. package/dist/cache/fast-hash.d.ts.map +1 -0
  25. package/dist/cache/index.js +52 -10
  26. package/dist/cache/index.js.map +1 -1
  27. package/dist/cache/register-cached-function.d.ts.map +1 -1
  28. package/dist/cache/timber-cache.d.ts.map +1 -1
  29. package/dist/client/browser-entry.d.ts +1 -2
  30. package/dist/client/browser-entry.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +1 -1
  32. package/dist/client/index.d.ts +1 -1
  33. package/dist/client/index.d.ts.map +1 -1
  34. package/dist/client/index.js +136 -94
  35. package/dist/client/index.js.map +1 -1
  36. package/dist/client/link.d.ts +12 -9
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-context.d.ts +9 -3
  39. package/dist/client/navigation-context.d.ts.map +1 -1
  40. package/dist/client/router.d.ts.map +1 -1
  41. package/dist/client/segment-context.d.ts +1 -1
  42. package/dist/client/segment-context.d.ts.map +1 -1
  43. package/dist/client/segment-merger.d.ts.map +1 -1
  44. package/dist/client/stale-reload.d.ts.map +1 -1
  45. package/dist/client/top-loader.d.ts.map +1 -1
  46. package/dist/client/transition-root.d.ts +1 -1
  47. package/dist/client/transition-root.d.ts.map +1 -1
  48. package/dist/cookies/index.js +4 -4
  49. package/dist/fonts/local.d.ts +4 -2
  50. package/dist/fonts/local.d.ts.map +1 -1
  51. package/dist/index.d.ts +38 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +255 -21
  54. package/dist/index.js.map +1 -1
  55. package/dist/plugins/build-report.d.ts +11 -1
  56. package/dist/plugins/build-report.d.ts.map +1 -1
  57. package/dist/plugins/chunks.d.ts +9 -1
  58. package/dist/plugins/chunks.d.ts.map +1 -1
  59. package/dist/plugins/entries.d.ts +7 -0
  60. package/dist/plugins/entries.d.ts.map +1 -1
  61. package/dist/plugins/fonts.d.ts +9 -1
  62. package/dist/plugins/fonts.d.ts.map +1 -1
  63. package/dist/plugins/mdx.d.ts +6 -0
  64. package/dist/plugins/mdx.d.ts.map +1 -1
  65. package/dist/plugins/server-bundle.d.ts.map +1 -1
  66. package/dist/routing/index.js +1 -1
  67. package/dist/search-params/index.js +1 -1
  68. package/dist/server/access-gate.d.ts.map +1 -1
  69. package/dist/server/action-client.d.ts.map +1 -1
  70. package/dist/server/debug.d.ts +82 -0
  71. package/dist/server/debug.d.ts.map +1 -0
  72. package/dist/server/deny-renderer.d.ts.map +1 -1
  73. package/dist/server/dev-warnings.d.ts.map +1 -1
  74. package/dist/server/index.js +22 -13
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/logger.d.ts.map +1 -1
  77. package/dist/server/pipeline.d.ts.map +1 -1
  78. package/dist/server/primitives.d.ts.map +1 -1
  79. package/dist/server/request-context.d.ts.map +1 -1
  80. package/dist/server/response-cache.d.ts +54 -0
  81. package/dist/server/response-cache.d.ts.map +1 -0
  82. package/dist/server/route-element-builder.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  86. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  87. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  88. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  89. package/dist/server/tracing.d.ts +10 -0
  90. package/dist/server/tracing.d.ts.map +1 -1
  91. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/adapters/nitro.ts +6 -1
  94. package/src/cache/fast-hash.ts +34 -0
  95. package/src/cache/register-cached-function.ts +7 -3
  96. package/src/cache/timber-cache.ts +17 -10
  97. package/src/client/browser-entry.ts +16 -24
  98. package/src/client/index.ts +1 -1
  99. package/src/client/link.tsx +97 -34
  100. package/src/client/navigation-context.ts +54 -14
  101. package/src/client/router.ts +4 -6
  102. package/src/client/segment-context.ts +6 -1
  103. package/src/client/segment-merger.ts +2 -8
  104. package/src/client/stale-reload.ts +5 -7
  105. package/src/client/top-loader.tsx +23 -19
  106. package/src/client/transition-root.tsx +7 -1
  107. package/src/fonts/local.ts +7 -3
  108. package/src/index.ts +41 -0
  109. package/src/plugins/build-report.ts +23 -3
  110. package/src/plugins/chunks.ts +9 -1
  111. package/src/plugins/entries.ts +10 -4
  112. package/src/plugins/fonts.ts +171 -19
  113. package/src/plugins/mdx.ts +9 -5
  114. package/src/plugins/server-bundle.ts +4 -0
  115. package/src/server/access-gate.tsx +3 -2
  116. package/src/server/action-client.ts +15 -5
  117. package/src/server/debug.ts +137 -0
  118. package/src/server/deny-renderer.ts +3 -2
  119. package/src/server/dev-warnings.ts +2 -1
  120. package/src/server/logger.ts +4 -3
  121. package/src/server/pipeline.ts +7 -0
  122. package/src/server/primitives.ts +2 -1
  123. package/src/server/request-context.ts +3 -2
  124. package/src/server/response-cache.ts +410 -0
  125. package/src/server/route-element-builder.ts +1 -6
  126. package/src/server/rsc-entry/index.ts +61 -14
  127. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  128. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  129. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  130. package/src/server/rsc-prop-warnings.ts +3 -1
  131. package/src/server/tracing.ts +23 -0
  132. package/src/server/waituntil-bridge.ts +4 -1
  133. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  134. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
  135. package/dist/client/browser-links.d.ts +0 -32
  136. package/dist/client/browser-links.d.ts.map +0 -1
  137. package/dist/client/link-navigate-interceptor.d.ts +0 -28
  138. package/dist/client/link-navigate-interceptor.d.ts.map +0 -1
  139. package/src/client/browser-links.ts +0 -90
  140. package/src/client/link-navigate-interceptor.tsx +0 -62
@@ -13,6 +13,7 @@
13
13
  import { createHmac, timingSafeEqual } from 'node:crypto';
14
14
  import type { Routes } from '#/index.js';
15
15
  import { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';
16
+ import { isDebug } from './debug.js';
16
17
 
17
18
  // Re-export the ALS for framework-internal consumers that need direct access.
18
19
  export { requestContextAls };
@@ -117,7 +118,7 @@ export function cookies(): RequestCookies {
117
118
  set(name: string, value: string, options?: CookieOptions): void {
118
119
  assertMutable(store, 'set');
119
120
  if (store.flushed) {
120
- if (process.env.NODE_ENV !== 'production') {
121
+ if (isDebug()) {
121
122
  console.warn(
122
123
  `[timber] warn: cookies().set('${name}') called after response headers were committed.\n` +
123
124
  ` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\n` +
@@ -146,7 +147,7 @@ export function cookies(): RequestCookies {
146
147
  delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {
147
148
  assertMutable(store, 'delete');
148
149
  if (store.flushed) {
149
- if (process.env.NODE_ENV !== 'production') {
150
+ if (isDebug()) {
150
151
  console.warn(
151
152
  `[timber] warn: cookies().delete('${name}') called after response headers were committed.\n` +
152
153
  ` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\n` +
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Render-level response deduplication and short-TTL LRU cache.
3
+ *
4
+ * Two layers of optimization:
5
+ *
6
+ * 1. **Singleflight** — concurrent requests to the same URL share a single
7
+ * render. Uses createSingleflight() from cache/singleflight.ts.
8
+ *
9
+ * 2. **LRU cache** — recently rendered responses are reused without
10
+ * re-executing the RSC-to-SSR pipeline. Entries have a short TTL
11
+ * (default 5s) and the cache has a bounded size (default 150 entries).
12
+ *
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.
18
+ *
19
+ * See design/02-rendering-pipeline.md, design/31-benchmarking.md.
20
+ */
21
+
22
+ import { createSingleflight } from '#/cache/singleflight.js';
23
+
24
+ // ─── Configuration ─────────────────────────────────────────────────────────
25
+
26
+ export interface ResponseCacheConfig {
27
+ /** Maximum number of entries in the LRU cache. Default: 150. */
28
+ maxSize?: number;
29
+ /** TTL for cached entries in milliseconds. Default: 5000 (5s). */
30
+ ttlMs?: number;
31
+ /**
32
+ * When true (default), requests with Cookie or Authorization headers
33
+ * bypass the cache entirely. This prevents sharing user-specific
34
+ * responses across requests.
35
+ */
36
+ publicOnly?: boolean;
37
+ }
38
+
39
+ export interface ResolvedResponseCacheConfig {
40
+ maxSize: number;
41
+ ttlMs: number;
42
+ publicOnly: boolean;
43
+ }
44
+
45
+ export function resolveResponseCacheConfig(
46
+ config?: ResponseCacheConfig | false
47
+ ): ResolvedResponseCacheConfig | null {
48
+ // Explicitly disabled
49
+ if (config === false) return null;
50
+
51
+ return {
52
+ maxSize: config?.maxSize ?? 150,
53
+ ttlMs: config?.ttlMs ?? 5000,
54
+ publicOnly: config?.publicOnly ?? true,
55
+ };
56
+ }
57
+
58
+ // ─── Cache Entry ───────────────────────────────────────────────────────────
59
+
60
+ interface CacheEntry {
61
+ /** The cached response body as an ArrayBuffer (already consumed). */
62
+ body: ArrayBuffer;
63
+ /** Response status code. */
64
+ status: number;
65
+ /** Response headers (serialized). */
66
+ headers: [string, string][];
67
+ /** Timestamp when this entry was created. */
68
+ createdAt: number;
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;
74
+ }
75
+
76
+ // ─── LRU Cache ─────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Simple LRU cache backed by a Map (insertion order = access order).
80
+ * On get, we delete and re-insert to move the entry to the end (most recent).
81
+ * On eviction, we delete from the beginning (least recent).
82
+ */
83
+ class LruCache {
84
+ private readonly map = new Map<string, CacheEntry>();
85
+ private readonly maxSize: number;
86
+ private readonly ttlMs: number;
87
+
88
+ constructor(maxSize: number, ttlMs: number) {
89
+ this.maxSize = maxSize;
90
+ this.ttlMs = ttlMs;
91
+ }
92
+
93
+ get(key: string): CacheEntry | undefined {
94
+ const entry = this.map.get(key);
95
+ if (!entry) return undefined;
96
+
97
+ // Check TTL
98
+ if (Date.now() - entry.createdAt > this.ttlMs) {
99
+ this.map.delete(key);
100
+ return undefined;
101
+ }
102
+
103
+ // Move to end (most recently used)
104
+ this.map.delete(key);
105
+ this.map.set(key, entry);
106
+ return entry;
107
+ }
108
+
109
+ set(key: string, entry: CacheEntry): void {
110
+ // If key exists, remove to re-insert at end
111
+ this.map.delete(key);
112
+
113
+ // Evict oldest if at capacity
114
+ if (this.map.size >= this.maxSize) {
115
+ const oldest = this.map.keys().next().value;
116
+ if (oldest !== undefined) {
117
+ this.map.delete(oldest);
118
+ }
119
+ }
120
+
121
+ this.map.set(key, entry);
122
+ }
123
+
124
+ get size(): number {
125
+ return this.map.size;
126
+ }
127
+
128
+ clear(): void {
129
+ this.map.clear();
130
+ }
131
+ }
132
+
133
+ // ─── Response Cache ────────────────────────────────────────────────────────
134
+
135
+ export interface ResponseCache {
136
+ /**
137
+ * Wrap a render function with singleflight dedup + LRU caching.
138
+ * Returns the cached Response or executes the render function.
139
+ */
140
+ getOrRender(
141
+ req: Request,
142
+ isRscPayload: boolean,
143
+ renderFn: () => Promise<Response>
144
+ ): Promise<Response>;
145
+
146
+ /** Number of entries currently in the LRU cache. */
147
+ readonly size: number;
148
+
149
+ /** Clear all cached entries. */
150
+ clear(): void;
151
+ }
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
+
206
+ /**
207
+ * Create a response cache with singleflight deduplication and LRU caching.
208
+ */
209
+ export function createResponseCache(config: ResolvedResponseCacheConfig): ResponseCache {
210
+ const lru = new LruCache(config.maxSize, config.ttlMs);
211
+ const singleflight = createSingleflight();
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
+
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
+
229
+ // When publicOnly is true, skip caching for authenticated requests
230
+ if (config.publicOnly) {
231
+ if (req.headers.has('Cookie') || req.headers.has('Authorization')) {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ const url = new URL(req.url);
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;
255
+ }
256
+
257
+ /**
258
+ * Check if a response is cacheable.
259
+ * Responses with Set-Cookie headers, Cache-Control: no-store/private,
260
+ * error/redirect status codes, or Vary: * are never cached.
261
+ */
262
+ function isCacheable(response: Response): boolean {
263
+ // Don't cache error responses
264
+ if (response.status >= 400) return false;
265
+
266
+ // Don't cache redirects
267
+ if (response.status >= 300 && response.status < 400) return false;
268
+
269
+ // Don't cache responses with Set-Cookie (user-specific state)
270
+ if (response.headers.has('Set-Cookie')) return false;
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
+
280
+ // Only cache responses with a body
281
+ if (!response.body) return false;
282
+
283
+ return true;
284
+ }
285
+
286
+ /** Construct a fresh Response from a cache entry (each caller gets their own). */
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;
291
+ // slice(0) creates a copy so each caller owns their buffer
292
+ return new Response(isNullBody ? null : entry.body.slice(0), {
293
+ status: entry.status,
294
+ headers: entry.headers,
295
+ });
296
+ }
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
+
309
+ return {
310
+ async getOrRender(
311
+ req: Request,
312
+ isRscPayload: boolean,
313
+ renderFn: () => Promise<Response>
314
+ ): Promise<Response> {
315
+ const cacheKey = buildCacheKey(req, isRscPayload);
316
+
317
+ // No cache key = skip caching and singleflight entirely.
318
+ // This covers POST requests, authenticated requests (publicOnly),
319
+ // and Vary: * responses.
320
+ if (cacheKey === null) {
321
+ return renderFn();
322
+ }
323
+
324
+ // Check LRU cache first
325
+ const cached = lru.get(cacheKey);
326
+ if (cached) {
327
+ return responseFromEntry(cached);
328
+ }
329
+
330
+ // Singleflight: concurrent requests to the same key share one render.
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 () => {
336
+ const response = await renderFn();
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
+
343
+ if (!isCacheable(response)) {
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
+ };
363
+ }
364
+
365
+ // Buffer the response body for caching.
366
+ const body = await response.arrayBuffer();
367
+ const headers: [string, string][] = [];
368
+ response.headers.forEach((value, key) => {
369
+ headers.push([key, value]);
370
+ });
371
+
372
+ const entry: CacheEntry = {
373
+ body,
374
+ status: response.status,
375
+ headers,
376
+ createdAt: Date.now(),
377
+ vary: response.headers.get('Vary'),
378
+ };
379
+
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
+ }
388
+
389
+ return entry;
390
+ });
391
+
392
+ if (result === null) {
393
+ // Shouldn't happen — singleflight always returns a result.
394
+ // Defensive fallback: re-render.
395
+ return renderFn();
396
+ }
397
+
398
+ return responseFromEntry(result);
399
+ },
400
+
401
+ get size() {
402
+ return lru.size;
403
+ },
404
+
405
+ clear() {
406
+ lru.clear();
407
+ knownVaryHeaders.clear();
408
+ },
409
+ };
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
 
@@ -63,10 +63,16 @@ import {
63
63
  isRscPayloadRequest,
64
64
  } from './helpers.js';
65
65
  import { parseClientStateTree } from '#/server/state-tree-diff.js';
66
+ import {
67
+ createResponseCache,
68
+ resolveResponseCacheConfig,
69
+ type ResponseCache,
70
+ } from '#/server/response-cache.js';
66
71
  import { buildRscPayloadResponse } from './rsc-payload.js';
67
72
  import { renderRscStream } from './rsc-stream.js';
68
73
  import { renderSsrResponse } from './ssr-renderer.js';
69
74
  import { callSsr } from './ssr-bridge.js';
75
+ import { isDebug, isDevMode, setDebugFromConfig } from '#/server/debug.js';
70
76
 
71
77
  // Dev-only pipeline error handler, set by the dev server after import.
72
78
  // In production this is always undefined — no overhead.
@@ -121,13 +127,31 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
121
127
  buildManifest: buildManifest as BuildManifest,
122
128
  });
123
129
 
124
- // Dev logging — initialize OTEL-based dev tracing once at handler creation.
125
- // In production, isDev is false — no tracing, no overhead.
126
- // The DevSpanProcessor handles all formatting and stderr output.
127
- const isDev = process.env.NODE_ENV !== 'production';
130
+ // Initialize debug flag from config before anything else.
131
+ // This allows timber.config.ts `debug: true` to enable debug logging
132
+ // in production without the TIMBER_DEBUG env var.
133
+ if ((runtimeConfig as Record<string, unknown>).debug) {
134
+ setDebugFromConfig(true);
135
+ }
136
+
137
+ // Two separate flags for two different security levels:
138
+ //
139
+ // isDev (isDevMode) — gates client-visible behavior: dev error pages with
140
+ // stack traces, detailed Server-Timing headers, error messages in action
141
+ // payloads. Statically replaced in production → tree-shaken to zero.
142
+ // TIMBER_DEBUG cannot enable this.
143
+ //
144
+ // debugEnabled (isDebug) — gates server-side logging only: stderr warnings,
145
+ // OTEL dev tracing, console.error fallbacks. TIMBER_DEBUG enables this.
146
+ // Never affects what clients see.
147
+ const isDev = isDevMode();
148
+ const debugEnabled = isDebug();
128
149
  const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
129
150
 
130
- if (isDev) {
151
+ // Dev logging — initialize OTEL-based dev tracing once at handler creation.
152
+ // In production with TIMBER_DEBUG, this enables server-side tracing output
153
+ // without exposing anything to clients.
154
+ if (debugEnabled) {
131
155
  const devLogMode = resolveLogMode();
132
156
  if (devLogMode !== 'quiet') {
133
157
  await initDevTracing({ mode: devLogMode, slowPhaseMs });
@@ -140,6 +164,17 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
140
164
 
141
165
  const typedBuildManifest = buildManifest as BuildManifest;
142
166
 
167
+ // Initialize response-level caching and singleflight deduplication.
168
+ // See design/31-benchmarking.md for performance motivation.
169
+ const responseCacheRaw = (runtimeConfig as Record<string, unknown>).responseCache as
170
+ | { maxSize?: number; ttlMs?: number; publicOnly?: boolean }
171
+ | false
172
+ | undefined;
173
+ const responseCacheConfig = resolveResponseCacheConfig(responseCacheRaw);
174
+ const responseCache: ResponseCache | null = responseCacheConfig
175
+ ? createResponseCache(responseCacheConfig)
176
+ : null;
177
+
143
178
  const pipelineConfig: PipelineConfig = {
144
179
  proxyLoader: manifest.proxy?.load,
145
180
  matchRoute,
@@ -170,14 +205,17 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
170
205
  _requestHeaderOverlay: Headers,
171
206
  interception?: InterceptionContext
172
207
  ) => {
173
- return renderRoute(
174
- req,
175
- match,
176
- responseHeaders,
177
- clientBootstrap,
178
- clientJsDisabled,
179
- interception
180
- );
208
+ const doRender = () =>
209
+ renderRoute(req, match, responseHeaders, clientBootstrap, clientJsDisabled, interception);
210
+
211
+ // Response cache wraps the render with singleflight + LRU.
212
+ // Interception requests (modals) are excluded — they depend on
213
+ // X-Timber-URL which makes caching semantics ambiguous.
214
+ if (responseCache && !interception) {
215
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
216
+ return responseCache.getOrRender(req, isRsc, doRender);
217
+ }
218
+ return doRender();
181
219
  },
182
220
  renderNoMatch: async (req: Request, responseHeaders: Headers) => {
183
221
  return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
@@ -330,7 +368,8 @@ async function renderRoute(
330
368
  throw error;
331
369
  }
332
370
 
333
- const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } = routeResult;
371
+ const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
372
+ routeResult;
334
373
 
335
374
  // Build head HTML for injection into the SSR output.
336
375
  // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
@@ -347,6 +386,14 @@ async function renderRoute(
347
386
  headHtml += buildCssLinkTags(cssUrls);
348
387
  }
349
388
 
389
+ // Inline font CSS as a <style> tag — @font-face rules and scoped classes.
390
+ // The font CSS is set on globalThis by the transformed font file's
391
+ // side-effect import of virtual:timber-font-css-register.
392
+ const fontCss = (globalThis as Record<string, unknown>).__timber_font_css as string | undefined;
393
+ if (fontCss) {
394
+ headHtml += `<style data-timber-fonts>${fontCss}</style>`;
395
+ }
396
+
350
397
  const fontEntries = collectRouteFonts(segments, typedManifest);
351
398
  if (fontEntries.length > 0) {
352
399
  headHtml += buildFontPreloadTags(fontEntries);
@@ -45,15 +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 firstRead = await reader.read();
64
+ const signalDetected = new Promise<void>((resolve) => {
65
+ signals.onSignal = resolve;
66
+ });
67
+
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
+ ]);
50
76
 
51
- // Yield to the microtask queue so that async component rejections
52
- // (e.g. an async-wrapped page component that throws redirect())
53
- // propagate to the onError callback before we check the signals.
54
- // The rejected Promise from an async component resolves in the next
55
- // microtask after read(), so we need at least one tick.
56
- await new Promise<void>((r) => setTimeout(r, 0));
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;
57
87
 
58
88
  // Check for redirect/deny signals detected during initial rendering
59
89
  const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
@@ -72,6 +102,11 @@ export async function buildRscPayloadResponse(
72
102
  );
73
103
  }
74
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
+
75
110
  // Reconstruct the stream: prepend the buffered first chunk,
76
111
  // then continue piping from the original reader.
77
112
  const patchedStream = new ReadableStream<Uint8Array>({