@timber-js/app 0.2.0-alpha.2 → 0.2.0-alpha.4

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 (77) 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-B4WUeqJ-.js +75 -0
  4. package/dist/_chunks/debug-B4WUeqJ-.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-CwdaB0_2.js} +3 -2
  6. package/dist/_chunks/format-CwdaB0_2.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-CZJi4CuK.js} +5 -4
  12. package/dist/_chunks/request-context-CZJi4CuK.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-Cwn7697K.js} +2 -2
  16. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-Cwn7697K.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/cache/index.js +2 -2
  22. package/dist/client/error-boundary.js +1 -1
  23. package/dist/client/index.js +3 -3
  24. package/dist/client/top-loader.d.ts.map +1 -1
  25. package/dist/cookies/index.js +4 -4
  26. package/dist/index.d.ts +38 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +22 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/plugins/build-report.d.ts +11 -1
  31. package/dist/plugins/build-report.d.ts.map +1 -1
  32. package/dist/plugins/entries.d.ts.map +1 -1
  33. package/dist/plugins/server-bundle.d.ts.map +1 -1
  34. package/dist/routing/index.js +1 -1
  35. package/dist/search-params/index.js +1 -1
  36. package/dist/server/access-gate.d.ts.map +1 -1
  37. package/dist/server/action-client.d.ts.map +1 -1
  38. package/dist/server/debug.d.ts +51 -0
  39. package/dist/server/debug.d.ts.map +1 -0
  40. package/dist/server/deny-renderer.d.ts.map +1 -1
  41. package/dist/server/dev-warnings.d.ts.map +1 -1
  42. package/dist/server/index.js +14 -13
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/logger.d.ts.map +1 -1
  45. package/dist/server/primitives.d.ts.map +1 -1
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/response-cache.d.ts +53 -0
  48. package/dist/server/response-cache.d.ts.map +1 -0
  49. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  50. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  53. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  54. package/dist/shims/image.d.ts +15 -15
  55. package/package.json +1 -1
  56. package/src/client/stale-reload.ts +1 -1
  57. package/src/client/top-loader.tsx +18 -15
  58. package/src/index.ts +40 -0
  59. package/src/plugins/build-report.ts +23 -3
  60. package/src/plugins/entries.ts +2 -0
  61. package/src/plugins/server-bundle.ts +4 -0
  62. package/src/server/access-gate.tsx +3 -2
  63. package/src/server/action-client.ts +3 -2
  64. package/src/server/debug.ts +99 -0
  65. package/src/server/deny-renderer.ts +3 -2
  66. package/src/server/dev-warnings.ts +2 -1
  67. package/src/server/logger.ts +4 -3
  68. package/src/server/primitives.ts +2 -1
  69. package/src/server/request-context.ts +3 -2
  70. package/src/server/response-cache.ts +277 -0
  71. package/src/server/rsc-entry/index.ts +36 -9
  72. package/src/server/rsc-entry/rsc-payload.ts +4 -1
  73. package/src/server/rsc-entry/rsc-stream.ts +2 -1
  74. package/src/server/rsc-entry/ssr-renderer.ts +6 -2
  75. package/src/server/rsc-prop-warnings.ts +3 -1
  76. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  77. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { getTraceStore } from './tracing.js';
12
12
  import { formatSsrError } from './error-formatter.js';
13
+ import { isDebug } from './debug.js';
13
14
 
14
15
  // ─── Logger Interface ─────────────────────────────────────────────────────
15
16
 
@@ -103,7 +104,7 @@ export function logMiddlewareShortCircuit(data: {
103
104
  export function logMiddlewareError(data: { method: string; path: string; error: unknown }): void {
104
105
  if (_logger) {
105
106
  _logger.error('unhandled error in middleware phase', withTraceContext(data));
106
- } else if (process.env.NODE_ENV !== 'production') {
107
+ } else if (isDebug()) {
107
108
  console.error('[timber] middleware error', data.error);
108
109
  }
109
110
  }
@@ -112,7 +113,7 @@ export function logMiddlewareError(data: { method: string; path: string; error:
112
113
  export function logRenderError(data: { method: string; path: string; error: unknown }): void {
113
114
  if (_logger) {
114
115
  _logger.error('unhandled render-phase error', withTraceContext(data));
115
- } else if (process.env.NODE_ENV !== 'production') {
116
+ } else if (isDebug()) {
116
117
  // No logger configured — fall back to console.error in dev with
117
118
  // cleaned-up error messages (vendor paths rewritten, hints added).
118
119
  console.error('[timber] render error:', formatSsrError(data.error));
@@ -123,7 +124,7 @@ export function logRenderError(data: { method: string; path: string; error: unkn
123
124
  export function logProxyError(data: { error: unknown }): void {
124
125
  if (_logger) {
125
126
  _logger.error('proxy.ts threw uncaught error', withTraceContext(data));
126
- } else if (process.env.NODE_ENV !== 'production') {
127
+ } else if (isDebug()) {
127
128
  console.error('[timber] proxy error', data.error);
128
129
  }
129
130
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { JsonSerializable } from './types.js';
7
7
  import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
8
+ import { isDebug } from './debug.js';
8
9
 
9
10
  // ─── Dev-mode validation ────────────────────────────────────────────────────
10
11
 
@@ -83,7 +84,7 @@ export function findNonSerializable(value: unknown, path = 'data'): string | nul
83
84
  * No-op in production.
84
85
  */
85
86
  function warnIfNotSerializable(data: unknown, callerName: string): void {
86
- if (process.env.NODE_ENV === 'production') return;
87
+ if (!isDebug()) return;
87
88
  if (data === undefined) return;
88
89
 
89
90
  const issue = findNonSerializable(data);
@@ -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,277 @@
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: method + pathname + isRscPayload. Responses
14
+ * with Set-Cookie headers are never cached (they contain user-specific
15
+ * state). When `publicOnly` is true (default), requests with Cookie or
16
+ * Authorization headers bypass the cache entirely.
17
+ *
18
+ * See design/02-rendering-pipeline.md, design/31-benchmarking.md.
19
+ */
20
+
21
+ import { createSingleflight } from '#/cache/singleflight.js';
22
+
23
+ // ─── Configuration ─────────────────────────────────────────────────────────
24
+
25
+ export interface ResponseCacheConfig {
26
+ /** Maximum number of entries in the LRU cache. Default: 150. */
27
+ maxSize?: number;
28
+ /** TTL for cached entries in milliseconds. Default: 5000 (5s). */
29
+ ttlMs?: number;
30
+ /**
31
+ * When true (default), requests with Cookie or Authorization headers
32
+ * bypass the cache entirely. This prevents sharing user-specific
33
+ * responses across requests.
34
+ */
35
+ publicOnly?: boolean;
36
+ }
37
+
38
+ export interface ResolvedResponseCacheConfig {
39
+ maxSize: number;
40
+ ttlMs: number;
41
+ publicOnly: boolean;
42
+ }
43
+
44
+ export function resolveResponseCacheConfig(
45
+ config?: ResponseCacheConfig | false
46
+ ): ResolvedResponseCacheConfig | null {
47
+ // Explicitly disabled
48
+ if (config === false) return null;
49
+
50
+ return {
51
+ maxSize: config?.maxSize ?? 150,
52
+ ttlMs: config?.ttlMs ?? 5000,
53
+ publicOnly: config?.publicOnly ?? true,
54
+ };
55
+ }
56
+
57
+ // ─── Cache Entry ───────────────────────────────────────────────────────────
58
+
59
+ interface CacheEntry {
60
+ /** The cached response body as an ArrayBuffer (already consumed). */
61
+ body: ArrayBuffer;
62
+ /** Response status code. */
63
+ status: number;
64
+ /** Response headers (serialized). */
65
+ headers: [string, string][];
66
+ /** Timestamp when this entry was created. */
67
+ createdAt: number;
68
+ }
69
+
70
+ // ─── Singleflight Result ───────────────────────────────────────────────────
71
+
72
+ /** Internal type: singleflight returns either a raw response or a cache entry. */
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;
78
+ }
79
+
80
+ // ─── LRU Cache ─────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Simple LRU cache backed by a Map (insertion order = access order).
84
+ * On get, we delete and re-insert to move the entry to the end (most recent).
85
+ * On eviction, we delete from the beginning (least recent).
86
+ */
87
+ class LruCache {
88
+ private readonly map = new Map<string, CacheEntry>();
89
+ private readonly maxSize: number;
90
+ private readonly ttlMs: number;
91
+
92
+ constructor(maxSize: number, ttlMs: number) {
93
+ this.maxSize = maxSize;
94
+ this.ttlMs = ttlMs;
95
+ }
96
+
97
+ get(key: string): CacheEntry | undefined {
98
+ const entry = this.map.get(key);
99
+ if (!entry) return undefined;
100
+
101
+ // Check TTL
102
+ if (Date.now() - entry.createdAt > this.ttlMs) {
103
+ this.map.delete(key);
104
+ return undefined;
105
+ }
106
+
107
+ // Move to end (most recently used)
108
+ this.map.delete(key);
109
+ this.map.set(key, entry);
110
+ return entry;
111
+ }
112
+
113
+ set(key: string, entry: CacheEntry): void {
114
+ // If key exists, remove to re-insert at end
115
+ this.map.delete(key);
116
+
117
+ // Evict oldest if at capacity
118
+ if (this.map.size >= this.maxSize) {
119
+ const oldest = this.map.keys().next().value;
120
+ if (oldest !== undefined) {
121
+ this.map.delete(oldest);
122
+ }
123
+ }
124
+
125
+ this.map.set(key, entry);
126
+ }
127
+
128
+ get size(): number {
129
+ return this.map.size;
130
+ }
131
+
132
+ clear(): void {
133
+ this.map.clear();
134
+ }
135
+ }
136
+
137
+ // ─── Response Cache ────────────────────────────────────────────────────────
138
+
139
+ export interface ResponseCache {
140
+ /**
141
+ * Wrap a render function with singleflight dedup + LRU caching.
142
+ * Returns the cached Response or executes the render function.
143
+ */
144
+ getOrRender(
145
+ req: Request,
146
+ isRscPayload: boolean,
147
+ renderFn: () => Promise<Response>
148
+ ): Promise<Response>;
149
+
150
+ /** Number of entries currently in the LRU cache. */
151
+ readonly size: number;
152
+
153
+ /** Clear all cached entries. */
154
+ clear(): void;
155
+ }
156
+
157
+ /**
158
+ * Create a response cache with singleflight deduplication and LRU caching.
159
+ */
160
+ export function createResponseCache(config: ResolvedResponseCacheConfig): ResponseCache {
161
+ const lru = new LruCache(config.maxSize, config.ttlMs);
162
+ const singleflight = createSingleflight();
163
+
164
+ function buildCacheKey(req: Request, isRscPayload: boolean): string | null {
165
+ // When publicOnly is true, skip caching for authenticated requests
166
+ if (config.publicOnly) {
167
+ if (req.headers.has('Cookie') || req.headers.has('Authorization')) {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ const url = new URL(req.url);
173
+ return `${req.method}:${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
174
+ }
175
+
176
+ /**
177
+ * Check if a response is cacheable.
178
+ * Responses with Set-Cookie headers are never cached — they contain
179
+ * user-specific state that must not be shared across requests.
180
+ */
181
+ function isCacheable(response: Response): boolean {
182
+ // Don't cache error responses
183
+ if (response.status >= 400) return false;
184
+
185
+ // Don't cache redirects
186
+ if (response.status >= 300 && response.status < 400) return false;
187
+
188
+ // Don't cache responses with Set-Cookie (user-specific state)
189
+ if (response.headers.has('Set-Cookie')) return false;
190
+
191
+ // Only cache responses with a body
192
+ if (!response.body) return false;
193
+
194
+ return true;
195
+ }
196
+
197
+ /** Construct a fresh Response from a cache entry (each caller gets their own). */
198
+ function responseFromEntry(entry: CacheEntry): Response {
199
+ // slice(0) creates a copy so each caller owns their buffer
200
+ return new Response(entry.body.slice(0), {
201
+ status: entry.status,
202
+ headers: entry.headers,
203
+ });
204
+ }
205
+
206
+ return {
207
+ async getOrRender(
208
+ req: Request,
209
+ isRscPayload: boolean,
210
+ renderFn: () => Promise<Response>
211
+ ): Promise<Response> {
212
+ const cacheKey = buildCacheKey(req, isRscPayload);
213
+
214
+ // No cache key = skip caching entirely
215
+ if (cacheKey === null) {
216
+ return renderFn();
217
+ }
218
+
219
+ // Check LRU cache first
220
+ const cached = lru.get(cacheKey);
221
+ if (cached) {
222
+ return responseFromEntry(cached);
223
+ }
224
+
225
+ // Singleflight: concurrent requests to the same key share one render.
226
+ // The singleflight returns a SingleflightResult so all waiters
227
+ // can construct their own Response from the same cached data.
228
+ const result: SingleflightResult = await singleflight.do(cacheKey, async () => {
229
+ const response = await renderFn();
230
+
231
+ if (!isCacheable(response)) {
232
+ return { response, entry: null };
233
+ }
234
+
235
+ // Buffer the response body for caching.
236
+ // The original Response body is consumed here — callers get copies
237
+ // from the cached ArrayBuffer.
238
+ const body = await response.arrayBuffer();
239
+ const headers: [string, string][] = [];
240
+ response.headers.forEach((value, key) => {
241
+ headers.push([key, value]);
242
+ });
243
+
244
+ const entry: CacheEntry = {
245
+ body,
246
+ status: response.status,
247
+ headers,
248
+ createdAt: Date.now(),
249
+ };
250
+
251
+ lru.set(cacheKey, entry);
252
+
253
+ return { response: null, entry };
254
+ });
255
+
256
+ // Non-cacheable response — only the first caller gets the original.
257
+ // For singleflight, this means concurrent waiters get the same promise
258
+ // result. The first caller already consumed the body, so subsequent
259
+ // callers would get an empty body. This is acceptable: non-cacheable
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;
264
+ }
265
+
266
+ return responseFromEntry(result.entry!);
267
+ },
268
+
269
+ get size() {
270
+ return lru.size;
271
+ },
272
+
273
+ clear() {
274
+ lru.clear();
275
+ },
276
+ };
277
+ }
@@ -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, 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.
@@ -124,7 +130,14 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
124
130
  // Dev logging — initialize OTEL-based dev tracing once at handler creation.
125
131
  // In production, isDev is false — no tracing, no overhead.
126
132
  // The DevSpanProcessor handles all formatting and stderr output.
127
- const isDev = process.env.NODE_ENV !== 'production';
133
+ // Initialize debug flag from config before anything else.
134
+ // This allows timber.config.ts `debug: true` to enable debug logging
135
+ // in production without the TIMBER_DEBUG env var.
136
+ if ((runtimeConfig as Record<string, unknown>).debug) {
137
+ setDebugFromConfig(true);
138
+ }
139
+
140
+ const isDev = isDebug();
128
141
  const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
129
142
 
130
143
  if (isDev) {
@@ -140,6 +153,17 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
140
153
 
141
154
  const typedBuildManifest = buildManifest as BuildManifest;
142
155
 
156
+ // Initialize response-level caching and singleflight deduplication.
157
+ // See design/31-benchmarking.md for performance motivation.
158
+ const responseCacheRaw = (runtimeConfig as Record<string, unknown>).responseCache as
159
+ | { maxSize?: number; ttlMs?: number; publicOnly?: boolean }
160
+ | false
161
+ | undefined;
162
+ const responseCacheConfig = resolveResponseCacheConfig(responseCacheRaw);
163
+ const responseCache: ResponseCache | null = responseCacheConfig
164
+ ? createResponseCache(responseCacheConfig)
165
+ : null;
166
+
143
167
  const pipelineConfig: PipelineConfig = {
144
168
  proxyLoader: manifest.proxy?.load,
145
169
  matchRoute,
@@ -170,14 +194,17 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
170
194
  _requestHeaderOverlay: Headers,
171
195
  interception?: InterceptionContext
172
196
  ) => {
173
- return renderRoute(
174
- req,
175
- match,
176
- responseHeaders,
177
- clientBootstrap,
178
- clientJsDisabled,
179
- interception
180
- );
197
+ const doRender = () =>
198
+ renderRoute(req, match, responseHeaders, clientBootstrap, clientJsDisabled, interception);
199
+
200
+ // Response cache wraps the render with singleflight + LRU.
201
+ // Interception requests (modals) are excluded — they depend on
202
+ // X-Timber-URL which makes caching semantics ambiguous.
203
+ if (responseCache && !interception) {
204
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
205
+ return responseCache.getOrRender(req, isRsc, doRender);
206
+ }
207
+ return doRender();
181
208
  },
182
209
  renderNoMatch: async (req: Request, responseHeaders: Headers) => {
183
210
  return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
@@ -53,7 +53,10 @@ export async function buildRscPayloadResponse(
53
53
  // propagate to the onError callback before we check the signals.
54
54
  // The rejected Promise from an async component resolves in the next
55
55
  // microtask after read(), so we need at least one tick.
56
- await new Promise<void>((r) => setTimeout(r, 0));
56
+ //
57
+ // Uses queueMicrotask instead of setTimeout(0) to stay within the
58
+ // same tick — no full event loop round-trip needed.
59
+ await new Promise<void>((r) => queueMicrotask(r));
57
60
 
58
61
  // Check for redirect/deny signals detected during initial rendering
59
62
  const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
@@ -17,6 +17,7 @@ import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js'
17
17
  import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
18
18
 
19
19
  import { createDebugChannelSink, isAbortError } from './helpers.js';
20
+ import { isDebug } from '#/server/debug.js';
20
21
 
21
22
  /**
22
23
  * Mutable signal state captured during RSC rendering.
@@ -98,7 +99,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
98
99
  // re-exported through a barrel file without 'use client'.
99
100
  // See LOCAL-297.
100
101
  if (
101
- process.env.NODE_ENV !== 'production' &&
102
+ isDebug() &&
102
103
  error instanceof Error &&
103
104
  error.message.includes('Invalid hook call')
104
105
  ) {
@@ -156,12 +156,16 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
156
156
  try {
157
157
  const ssrResponse = await callSsr(ssrStream, navContext);
158
158
 
159
- // Signal promotion: yield one tick so async component rejections
159
+ // Signal promotion: yield one microtask so async component rejections
160
160
  // propagate to the RSC onError callback, then check if any signals
161
161
  // were captured during rendering inside Suspense boundaries.
162
162
  // The Response hasn't been sent yet — it's an unconsumed stream.
163
+ //
164
+ // Uses queueMicrotask instead of setTimeout(0) to avoid yielding to
165
+ // the full event loop (timers phase). Microtask resolution happens
166
+ // within the same tick, eliminating per-request idle time under load.
163
167
  // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
164
- await new Promise<void>((r) => setTimeout(r, 0));
168
+ await new Promise<void>((r) => queueMicrotask(r));
165
169
 
166
170
  const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
167
171
  if (promoted) {
@@ -16,6 +16,8 @@
16
16
  * Task: TIM-358
17
17
  */
18
18
 
19
+ import { isDebug } from './debug.js';
20
+
19
21
  // ─── Types ────────────────────────────────────────────────────────────────
20
22
 
21
23
  export interface NonSerializableTypeInfo {
@@ -165,7 +167,7 @@ export function formatRscPropWarning(
165
167
  * @returns true if a warning was emitted
166
168
  */
167
169
  export function checkAndWarnRscPropError(error: unknown, requestPath: string): boolean {
168
- if (process.env.NODE_ENV === 'production') return false;
170
+ if (!isDebug()) return false;
169
171
  if (!(error instanceof Error)) return false;
170
172
 
171
173
  const info = detectNonSerializableType(error.message);
@@ -1 +0,0 @@
1
- {"version":3,"file":"format-DNt20Kt8.js","names":[],"sources":["../../src/server/dev-warnings.ts","../../src/utils/format.ts"],"sourcesContent":["/**\n * Dev-mode warnings for common timber.js misuse patterns.\n *\n * These fire in development only and are stripped from production builds.\n * Each warning targets a specific misuse identified during design review.\n *\n * Warnings are deduplicated by warningId:filePath:line so the same warning\n * is only emitted once per dev session (per unique source location).\n *\n * Warnings are written to stderr and, when a Vite dev server is available,\n * forwarded to the browser console via Vite's WebSocket.\n *\n * See design/21-dev-server.md §\"Dev-Mode Warnings\"\n * See design/11-platform.md §\"Dev Mode\"\n */\n\nimport type { ViteDevServer } from 'vite';\n\n// ─── Warning IDs ───────────────────────────────────────────────────────────\n\nexport const WarningId = {\n SUSPENSE_WRAPS_CHILDREN: 'SUSPENSE_WRAPS_CHILDREN',\n DENY_IN_SUSPENSE: 'DENY_IN_SUSPENSE',\n REDIRECT_IN_SUSPENSE: 'REDIRECT_IN_SUSPENSE',\n REDIRECT_IN_ACCESS: 'REDIRECT_IN_ACCESS',\n STATIC_REQUEST_API: 'STATIC_REQUEST_API',\n CACHE_REQUEST_PROPS: 'CACHE_REQUEST_PROPS',\n SLOW_SLOT_NO_SUSPENSE: 'SLOW_SLOT_NO_SUSPENSE',\n} as const;\n\nexport type WarningId = (typeof WarningId)[keyof typeof WarningId];\n\n// ─── Configuration ──────────────────────────────────────────────────────────\n\n/** Configuration for dev warning behavior. */\nexport interface DevWarningConfig {\n /** Threshold in ms for \"slow slot\" warnings. Default: 200. */\n slowSlotThresholdMs?: number;\n}\n\n// ─── Deduplication & Server ─────────────────────────────────────────────────\n\nconst _emitted = new Set<string>();\n\n/** Vite dev server for forwarding warnings to browser console. */\nlet _viteServer: ViteDevServer | null = null;\n\n/**\n * Register the Vite dev server for browser console forwarding.\n * Called by timber-dev-server during configureServer.\n */\nexport function setViteServer(server: ViteDevServer | null): void {\n _viteServer = server;\n}\n\nfunction isDev(): boolean {\n return process.env.NODE_ENV !== 'production';\n}\n\n/**\n * Emit a warning only once per dedup key.\n *\n * Writes to stderr and forwards to browser console via Vite WebSocket.\n * Returns true if emitted (not deduplicated).\n */\nfunction emitOnce(\n warningId: WarningId,\n location: string,\n level: 'warn' | 'error',\n message: string\n): boolean {\n if (!isDev()) return false;\n\n const dedupKey = `${warningId}:${location}`;\n if (_emitted.has(dedupKey)) return false;\n _emitted.add(dedupKey);\n\n // Write to stderr\n const prefix = level === 'error' ? '\\x1b[31m[timber]\\x1b[0m' : '\\x1b[33m[timber]\\x1b[0m';\n process.stderr.write(`${prefix} ${message}\\n`);\n\n // Forward to browser console via Vite WebSocket\n if (_viteServer?.hot) {\n _viteServer.hot.send('timber:dev-warning', {\n warningId,\n level,\n message: `[timber] ${message}`,\n });\n }\n\n return true;\n}\n\n// ─── Warning Functions ──────────────────────────────────────────────────────\n\n/**\n * Warn when a layout wraps {children} in <Suspense>.\n *\n * This defers the page content — the primary resource — behind a fallback.\n * The page's data fetches won't affect the HTTP status code because they\n * resolve after onShellReady. If the page calls deny(404), the status code\n * is already committed as 200.\n *\n * @param layoutFile - Relative path to the layout file (e.g., \"app/(dashboard)/layout.tsx\")\n */\nexport function warnSuspenseWrappingChildren(layoutFile: string): void {\n emitOnce(\n WarningId.SUSPENSE_WRAPS_CHILDREN,\n layoutFile,\n 'warn',\n `Layout at ${layoutFile} wraps {children} in <Suspense>. ` +\n 'This prevents child pages from setting HTTP status codes. ' +\n 'Use useNavigationPending() for loading states instead.'\n );\n}\n\n/**\n * Warn when deny() is called inside a Suspense boundary.\n *\n * After the shell has flushed and the status code is committed, deny()\n * cannot change the HTTP response. The signal will be caught by the nearest\n * error boundary instead of producing a correct status code.\n *\n * @param file - Relative path to the file\n * @param line - Line number where deny() was called\n */\nexport function warnDenyInSuspense(file: string, line?: number): void {\n const location = line ? `${file}:${line}` : file;\n emitOnce(\n WarningId.DENY_IN_SUSPENSE,\n location,\n 'error',\n `deny() called inside <Suspense> at ${location}. ` +\n 'The HTTP status is already committed — this will trigger an error boundary with a 200 status. ' +\n 'Move deny() outside <Suspense> for correct HTTP semantics.'\n );\n}\n\n/**\n * Warn when redirect() is called inside a Suspense boundary.\n *\n * This will perform a client-side navigation instead of an HTTP redirect.\n *\n * @param file - Relative path to the file\n * @param line - Line number where redirect() was called\n */\nexport function warnRedirectInSuspense(file: string, line?: number): void {\n const location = line ? `${file}:${line}` : file;\n emitOnce(\n WarningId.REDIRECT_IN_SUSPENSE,\n location,\n 'error',\n `redirect() called inside <Suspense> at ${location}. ` +\n 'This will perform a client-side navigation instead of an HTTP redirect.'\n );\n}\n\n/**\n * Warn when redirect() is called in a slot's access.ts.\n *\n * Slots use deny() for graceful degradation. Redirecting from a slot would\n * redirect the entire page, breaking the contract that slot failure is\n * isolated to the slot.\n *\n * @param accessFile - Relative path to the access.ts file\n * @param line - Line number where redirect() was called\n */\nexport function warnRedirectInAccess(accessFile: string, line?: number): void {\n const location = line ? `${accessFile}:${line}` : accessFile;\n emitOnce(\n WarningId.REDIRECT_IN_ACCESS,\n location,\n 'error',\n `redirect() called in access.ts at ${location}. ` +\n 'Only deny() is valid in slot access checks. ' +\n 'Use deny() to block access or move redirect() to middleware.ts.'\n );\n}\n\n/**\n * Warn when cookies() or headers() is called during a static build.\n *\n * In output: 'static' mode, there is no per-request context — these APIs\n * read build-time values only. This is almost always a mistake.\n *\n * @param api - The dynamic API name (\"cookies\" or \"headers\")\n * @param file - Relative path to the file calling the API\n */\nexport function warnStaticRequestApi(api: 'cookies' | 'headers', file: string): void {\n emitOnce(\n WarningId.STATIC_REQUEST_API,\n `${api}:${file}`,\n 'error',\n `${api}() called during static generation of ${file}. ` +\n 'Dynamic request APIs are not available during prerendering.'\n );\n}\n\n/**\n * Warn when a \"use cache\" component receives request-specific props.\n *\n * Cached components should not depend on per-request data — a userId or\n * sessionId in the props means the cache will either be ineffective\n * (key per user) or dangerous (serve one user's data to another).\n *\n * @param componentName - Name of the cached component\n * @param propName - Name of the suspicious prop\n * @param file - Relative path to the component file\n * @param line - Line number\n */\nexport function warnCacheRequestProps(\n componentName: string,\n propName: string,\n file: string,\n line?: number\n): void {\n const location = line ? `${file}:${line}` : file;\n emitOnce(\n WarningId.CACHE_REQUEST_PROPS,\n `${componentName}:${propName}:${location}`,\n 'warn',\n `Cached component ${componentName} receives prop \"${propName}\" which appears request-specific. ` +\n 'Cached components should not depend on per-request data.'\n );\n}\n\n/**\n * Warn when a parallel slot resolves slowly without a <Suspense> wrapper.\n *\n * A slow slot without Suspense blocks onShellReady — and therefore the\n * status code commit — for the entire page. Wrapping it in <Suspense>\n * lets the shell flush without waiting for the slot.\n *\n * @param slotName - The slot name (e.g., \"@admin\")\n * @param durationMs - How long the slot took to resolve\n */\nexport function warnSlowSlotWithoutSuspense(slotName: string, durationMs: number): void {\n emitOnce(\n WarningId.SLOW_SLOT_NO_SUSPENSE,\n slotName,\n 'warn',\n `Slot ${slotName} resolved in ${durationMs}ms and is not wrapped in <Suspense>. ` +\n 'Consider wrapping to avoid blocking the flush.'\n );\n}\n\n// ─── Legacy aliases ─────────────────────────────────────────────────────────\n\n/** @deprecated Use warnStaticRequestApi instead */\nexport const warnDynamicApiInStaticBuild = warnStaticRequestApi;\n\n/** @deprecated Use warnRedirectInAccess instead */\nexport function warnRedirectInSlotAccess(slotName: string): void {\n warnRedirectInAccess(`${slotName}/access.ts`);\n}\n\n/** @deprecated Use warnDenyInSuspense / warnRedirectInSuspense instead */\nexport function warnDenyAfterFlush(signal: 'deny' | 'redirect'): void {\n if (signal === 'deny') {\n warnDenyInSuspense('unknown');\n } else {\n warnRedirectInSuspense('unknown');\n }\n}\n\n// ─── Testing ────────────────────────────────────────────────────────────────\n\n/**\n * Reset emitted warnings. For testing only.\n * @internal\n */\nexport function _resetWarnings(): void {\n _emitted.clear();\n}\n\n/**\n * Get the set of emitted dedup keys. For testing only.\n * @internal\n */\nexport function _getEmitted(): ReadonlySet<string> {\n return _emitted;\n}\n","/**\n * Shared formatting utilities.\n */\n\n/** Format a byte count as a human-readable string (e.g. \"1.50 kB\"). */\nexport function formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} kB`;\n return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n}\n"],"mappings":";AAoBA,IAAa,YAAY;CACvB,yBAAyB;CACzB,kBAAkB;CAClB,sBAAsB;CACtB,oBAAoB;CACpB,oBAAoB;CACpB,qBAAqB;CACrB,uBAAuB;CACxB;AAcD,IAAM,2BAAW,IAAI,KAAa;;AAGlC,IAAI,cAAoC;;;;;AAMxC,SAAgB,cAAc,QAAoC;AAChE,eAAc;;AAGhB,SAAS,QAAiB;AACxB,QAAA,QAAA,IAAA,aAAgC;;;;;;;;AASlC,SAAS,SACP,WACA,UACA,OACA,SACS;AACT,KAAI,CAAC,OAAO,CAAE,QAAO;CAErB,MAAM,WAAW,GAAG,UAAU,GAAG;AACjC,KAAI,SAAS,IAAI,SAAS,CAAE,QAAO;AACnC,UAAS,IAAI,SAAS;CAGtB,MAAM,SAAS,UAAU,UAAU,4BAA4B;AAC/D,SAAQ,OAAO,MAAM,GAAG,OAAO,GAAG,QAAQ,IAAI;AAG9C,KAAI,aAAa,IACf,aAAY,IAAI,KAAK,sBAAsB;EACzC;EACA;EACA,SAAS,YAAY;EACtB,CAAC;AAGJ,QAAO;;;;;;;;;;;;AAeT,SAAgB,6BAA6B,YAA0B;AACrE,UACE,UAAU,yBACV,YACA,QACA,aAAa,WAAW,mJAGzB;;;;;;;;;;;;AAaH,SAAgB,mBAAmB,MAAc,MAAqB;CACpE,MAAM,WAAW,OAAO,GAAG,KAAK,GAAG,SAAS;AAC5C,UACE,UAAU,kBACV,UACA,SACA,sCAAsC,SAAS,4JAGhD;;;;;;;;;;AAWH,SAAgB,uBAAuB,MAAc,MAAqB;CACxE,MAAM,WAAW,OAAO,GAAG,KAAK,GAAG,SAAS;AAC5C,UACE,UAAU,sBACV,UACA,SACA,0CAA0C,SAAS,2EAEpD;;;;;;;;;;;;AAaH,SAAgB,qBAAqB,YAAoB,MAAqB;CAC5E,MAAM,WAAW,OAAO,GAAG,WAAW,GAAG,SAAS;AAClD,UACE,UAAU,oBACV,UACA,SACA,qCAAqC,SAAS,+GAG/C;;;;;;;;;;;AAYH,SAAgB,qBAAqB,KAA4B,MAAoB;AACnF,UACE,UAAU,oBACV,GAAG,IAAI,GAAG,QACV,SACA,GAAG,IAAI,wCAAwC,KAAK,+DAErD;;;;;;;;;;;;;;AAeH,SAAgB,sBACd,eACA,UACA,MACA,MACM;CACN,MAAM,WAAW,OAAO,GAAG,KAAK,GAAG,SAAS;AAC5C,UACE,UAAU,qBACV,GAAG,cAAc,GAAG,SAAS,GAAG,YAChC,QACA,oBAAoB,cAAc,kBAAkB,SAAS,4FAE9D;;;;;;;;;;;;AAaH,SAAgB,4BAA4B,UAAkB,YAA0B;AACtF,UACE,UAAU,uBACV,UACA,QACA,QAAQ,SAAS,eAAe,WAAW,qFAE5C;;;AAMH,IAAa,8BAA8B;;AAG3C,SAAgB,yBAAyB,UAAwB;AAC/D,sBAAqB,GAAG,SAAS,YAAY;;;AAI/C,SAAgB,mBAAmB,QAAmC;AACpE,KAAI,WAAW,OACb,oBAAmB,UAAU;KAE7B,wBAAuB,UAAU;;;;;;;;AChQrC,SAAgB,WAAW,OAAuB;AAChD,KAAI,QAAQ,KAAM,QAAO,GAAG,MAAM;AAClC,KAAI,QAAQ,OAAO,KAAM,QAAO,IAAI,QAAQ,MAAM,QAAQ,EAAE,CAAC;AAC7D,QAAO,IAAI,SAAS,OAAO,OAAO,QAAQ,EAAE,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"request-context-CRj2Zh1E.js","names":[],"sources":["../../src/server/request-context.ts"],"sourcesContent":["/**\n * Request Context — per-request ALS store for headers() and cookies().\n *\n * Follows the same pattern as tracing.ts: a module-level AsyncLocalStorage\n * instance, public accessor functions that throw outside request scope,\n * and a framework-internal `runWithRequestContext()` to establish scope.\n *\n * See design/04-authorization.md §\"AccessContext does not include cookies or headers\"\n * and design/11-platform.md §\"AsyncLocalStorage\".\n * See design/29-cookies.md for cookie mutation semantics.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\nimport type { Routes } from '#/index.js';\nimport { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';\n\n// Re-export the ALS for framework-internal consumers that need direct access.\nexport { requestContextAls };\n\n// No fallback needed — we use enterWith() instead of run() to ensure\n// the ALS context persists for the entire request lifecycle including\n// async stream consumption by React's renderToReadableStream.\n\n// ─── Cookie Signing Secrets ──────────────────────────────────────────────\n\n/**\n * Module-level cookie signing secrets. Index 0 is the newest (used for signing).\n * All entries are tried for verification (key rotation support).\n *\n * Set by the framework at startup via `setCookieSecrets()`.\n * See design/29-cookies.md §\"Signed Cookies\"\n */\nlet _cookieSecrets: string[] = [];\n\n/**\n * Configure the cookie signing secrets.\n *\n * Called by the framework during server initialization with values from\n * `cookies.secret` or `cookies.secrets` in timber.config.ts.\n *\n * The first secret (index 0) is used for signing new cookies.\n * All secrets are tried for verification (supports key rotation).\n */\nexport function setCookieSecrets(secrets: string[]): void {\n _cookieSecrets = secrets.filter(Boolean);\n}\n\n// ─── Public API ───────────────────────────────────────────────────────────\n\n/**\n * Returns a read-only view of the current request's headers.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n */\nexport function headers(): ReadonlyHeaders {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] headers() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.headers;\n}\n\n/**\n * Returns a cookie accessor for the current request.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n *\n * Read methods (.get, .has, .getAll) are always available and reflect\n * read-your-own-writes from .set() calls in the same request.\n *\n * Mutation methods (.set, .delete, .clear) are only available in mutable\n * contexts (middleware.ts, server actions, route.ts handlers). Calling them\n * in read-only contexts (access.ts, server components) throws.\n *\n * See design/29-cookies.md\n */\nexport function cookies(): RequestCookies {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] cookies() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n\n // Parse cookies lazily on first access\n if (!store.parsedCookies) {\n store.parsedCookies = parseCookieHeader(store.cookieHeader);\n }\n\n const map = store.parsedCookies;\n return {\n get(name: string): string | undefined {\n return map.get(name);\n },\n has(name: string): boolean {\n return map.has(name);\n },\n getAll(): Array<{ name: string; value: string }> {\n return Array.from(map.entries()).map(([name, value]) => ({ name, value }));\n },\n get size(): number {\n return map.size;\n },\n\n getSigned(name: string): string | undefined {\n const raw = map.get(name);\n if (!raw || _cookieSecrets.length === 0) return undefined;\n return verifySignedCookie(raw, _cookieSecrets);\n },\n\n set(name: string, value: string, options?: CookieOptions): void {\n assertMutable(store, 'set');\n if (store.flushed) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[timber] warn: cookies().set('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n let storedValue = value;\n if (options?.signed) {\n if (_cookieSecrets.length === 0) {\n throw new Error(\n `[timber] cookies().set('${name}', ..., { signed: true }) requires ` +\n `cookies.secret or cookies.secrets in timber.config.ts.`\n );\n }\n storedValue = signCookieValue(value, _cookieSecrets[0]);\n }\n const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };\n store.cookieJar.set(name, { name, value: storedValue, options: opts });\n // Read-your-own-writes: update the parsed cookies map with the signed value\n // so getSigned() can verify it in the same request\n map.set(name, storedValue);\n },\n\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {\n assertMutable(store, 'delete');\n if (store.flushed) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[timber] warn: cookies().delete('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n const opts: CookieOptions = {\n ...DEFAULT_COOKIE_OPTIONS,\n ...options,\n maxAge: 0,\n expires: new Date(0),\n };\n store.cookieJar.set(name, { name, value: '', options: opts });\n // Remove from read view\n map.delete(name);\n },\n\n clear(): void {\n assertMutable(store, 'clear');\n if (store.flushed) return;\n // Delete every incoming cookie\n for (const name of Array.from(map.keys())) {\n store.cookieJar.set(name, {\n name,\n value: '',\n options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0, expires: new Date(0) },\n });\n }\n map.clear();\n },\n\n toString(): string {\n return Array.from(map.entries())\n .map(([name, value]) => `${name}=${value}`)\n .join('; ');\n },\n };\n}\n\n/**\n * Returns a Promise resolving to the current request's search params.\n *\n * In `page.tsx`, `middleware.ts`, and `access.ts` the framework pre-parses the\n * route's `search-params.ts` definition and the Promise resolves to the typed\n * object. In all other server component contexts it resolves to raw\n * `URLSearchParams`.\n *\n * Returned as a Promise to match the `params` prop convention and to allow\n * future partial pre-rendering support where param resolution may be deferred.\n *\n * Throws if called outside a request context.\n */\nexport function searchParams<R extends keyof Routes>(): Promise<Routes[R]['searchParams']>;\nexport function searchParams(): Promise<URLSearchParams | Record<string, unknown>>;\nexport function searchParams(): Promise<URLSearchParams | Record<string, unknown>> {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] searchParams() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.searchParamsPromise;\n}\n\n/**\n * Replace the search params Promise for the current request with one that\n * resolves to the typed parsed result from the route's search-params.ts.\n * Called by the framework before rendering the page — not for app code.\n */\nexport function setParsedSearchParams(parsed: Record<string, unknown>): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.searchParamsPromise = Promise.resolve(parsed);\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────────\n\n/**\n * Read-only Headers interface. The standard Headers class is mutable;\n * this type narrows it to read-only methods. The underlying object is\n * still a Headers instance, but user code should not mutate it.\n */\nexport type ReadonlyHeaders = Pick<\n Headers,\n 'get' | 'has' | 'entries' | 'keys' | 'values' | 'forEach' | typeof Symbol.iterator\n>;\n\n/** Options for setting a cookie. See design/29-cookies.md. */\nexport interface CookieOptions {\n /** Domain scope. Default: omitted (current domain only). */\n domain?: string;\n /** URL path scope. Default: '/'. */\n path?: string;\n /** Expiration date. Mutually exclusive with maxAge. */\n expires?: Date;\n /** Max age in seconds. Mutually exclusive with expires. */\n maxAge?: number;\n /** Prevent client-side JS access. Default: true. */\n httpOnly?: boolean;\n /** Only send over HTTPS. Default: true. */\n secure?: boolean;\n /** Cross-site request policy. Default: 'lax'. */\n sameSite?: 'strict' | 'lax' | 'none';\n /** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */\n partitioned?: boolean;\n /**\n * Sign the cookie value with HMAC-SHA256 for integrity verification.\n * Requires `cookies.secret` or `cookies.secrets` in timber.config.ts.\n * See design/29-cookies.md §\"Signed Cookies\".\n */\n signed?: boolean;\n}\n\nconst DEFAULT_COOKIE_OPTIONS: CookieOptions = {\n path: '/',\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n};\n\n/**\n * Cookie accessor returned by `cookies()`.\n *\n * Read methods are always available. Mutation methods throw in read-only\n * contexts (access.ts, server components).\n */\nexport interface RequestCookies {\n /** Get a cookie value by name. Returns undefined if not present. */\n get(name: string): string | undefined;\n /** Check if a cookie exists. */\n has(name: string): boolean;\n /** Get all cookies as an array of { name, value } pairs. */\n getAll(): Array<{ name: string; value: string }>;\n /** Number of cookies. */\n readonly size: number;\n /**\n * Get a signed cookie value, verifying its HMAC-SHA256 signature.\n * Returns undefined if the cookie is missing, the signature is invalid,\n * or no secrets are configured. Never throws.\n *\n * See design/29-cookies.md §\"Signed Cookies\"\n */\n getSigned(name: string): string | undefined;\n /** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */\n set(name: string, value: string, options?: CookieOptions): void;\n /** Delete a cookie. Only available in mutable contexts. */\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void;\n /** Delete all cookies. Only available in mutable contexts. */\n clear(): void;\n /** Serialize cookies as a Cookie header string. */\n toString(): string;\n}\n\n// ─── Framework-Internal Helpers ───────────────────────────────────────────\n\n/**\n * Run a callback within a request context. Used by the pipeline to establish\n * per-request ALS scope so that `headers()` and `cookies()` work.\n *\n * @param req - The incoming Request object.\n * @param fn - The function to run within the request context.\n */\nexport function runWithRequestContext<T>(req: Request, fn: () => T): T {\n const originalCopy = new Headers(req.headers);\n const store: RequestContextStore = {\n headers: freezeHeaders(req.headers),\n originalHeaders: originalCopy,\n cookieHeader: req.headers.get('cookie') ?? '',\n searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),\n cookieJar: new Map(),\n flushed: false,\n mutableContext: false,\n };\n return requestContextAls.run(store, fn);\n}\n\n/**\n * Enable cookie mutation for the current context. Called by the framework\n * when entering middleware.ts, server actions, or route.ts handlers.\n *\n * See design/29-cookies.md §\"Context Tracking\"\n */\nexport function setMutableCookieContext(mutable: boolean): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.mutableContext = mutable;\n }\n}\n\n/**\n * Mark the response as flushed (headers committed). After this point,\n * cookie mutations log a warning instead of throwing.\n *\n * See design/29-cookies.md §\"Streaming Constraint: Post-Flush Cookie Warning\"\n */\nexport function markResponseFlushed(): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.flushed = true;\n }\n}\n\n/**\n * Collect all Set-Cookie headers from the cookie jar.\n * Called by the framework at flush time to apply cookies to the response.\n *\n * Returns an array of serialized Set-Cookie header values.\n */\nexport function getSetCookieHeaders(): string[] {\n const store = requestContextAls.getStore();\n if (!store) return [];\n return Array.from(store.cookieJar.values()).map(serializeCookieEntry);\n}\n\n/**\n * Apply middleware-injected request headers to the current request context.\n *\n * Called by the pipeline after middleware.ts runs. Merges overlay headers\n * on top of the original request headers so downstream code (access.ts,\n * server components, server actions) sees them via `headers()`.\n *\n * The original request headers are never mutated — a new frozen Headers\n * object is created with the overlay applied on top.\n *\n * See design/07-routing.md §\"Request Header Injection\"\n */\nexport function applyRequestHeaderOverlay(overlay: Headers): void {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error('[timber] applyRequestHeaderOverlay() called outside of a request context.');\n }\n\n // Check if the overlay has any headers — skip if empty\n let hasOverlay = false;\n overlay.forEach(() => {\n hasOverlay = true;\n });\n if (!hasOverlay) return;\n\n // Merge: start with original headers, overlay on top\n const merged = new Headers(store.originalHeaders);\n overlay.forEach((value, key) => {\n merged.set(key, value);\n });\n store.headers = freezeHeaders(merged);\n}\n\n// ─── Read-Only Headers ────────────────────────────────────────────────────\n\nconst MUTATING_METHODS = new Set(['set', 'append', 'delete']);\n\n/**\n * Wrap a Headers object in a Proxy that throws on mutating methods.\n * Object.freeze doesn't work on Headers (native internal slots), so we\n * intercept property access and reject set/append/delete at runtime.\n *\n * Read methods (get, has, entries, etc.) must be bound to the underlying\n * Headers instance because they access private #headersList slots.\n */\nfunction freezeHeaders(source: Headers): Headers {\n const copy = new Headers(source);\n return new Proxy(copy, {\n get(target, prop) {\n if (typeof prop === 'string' && MUTATING_METHODS.has(prop)) {\n return () => {\n throw new Error(\n `[timber] headers() returns a read-only Headers object. ` +\n `Calling .${prop}() is not allowed. ` +\n `Use ctx.requestHeaders in middleware to inject headers for downstream components.`\n );\n };\n }\n const value = Reflect.get(target, prop);\n // Bind methods to the real Headers instance so private slot access works\n if (typeof value === 'function') {\n return value.bind(target);\n }\n return value;\n },\n });\n}\n\n// ─── Cookie Helpers ───────────────────────────────────────────────────────\n\n/** Throw if cookie mutation is attempted in a read-only context. */\nfunction assertMutable(store: RequestContextStore, method: string): void {\n if (!store.mutableContext) {\n throw new Error(\n `[timber] cookies().${method}() cannot be called in this context.\\n` +\n ` Set cookies in middleware.ts, server actions, or route.ts handlers.`\n );\n }\n}\n\n/**\n * Parse a Cookie header string into a Map of name → value pairs.\n * Follows RFC 6265 §4.2.1: cookies are semicolon-separated key=value pairs.\n */\nfunction parseCookieHeader(header: string): Map<string, string> {\n const map = new Map<string, string>();\n if (!header) return map;\n\n for (const pair of header.split(';')) {\n const eqIndex = pair.indexOf('=');\n if (eqIndex === -1) continue;\n const name = pair.slice(0, eqIndex).trim();\n const value = pair.slice(eqIndex + 1).trim();\n if (name) {\n map.set(name, value);\n }\n }\n\n return map;\n}\n\n// ─── Cookie Signing ──────────────────────────────────────────────────────\n\n/**\n * Sign a cookie value with HMAC-SHA256.\n * Returns `value.hex_signature`.\n */\nfunction signCookieValue(value: string, secret: string): string {\n const signature = createHmac('sha256', secret).update(value).digest('hex');\n return `${value}.${signature}`;\n}\n\n/**\n * Verify a signed cookie value against an array of secrets.\n * Returns the original value if any secret produces a matching signature,\n * or undefined if none match. Uses timing-safe comparison.\n *\n * The signed format is `value.hex_signature` — split at the last `.`.\n */\nfunction verifySignedCookie(raw: string, secrets: string[]): string | undefined {\n const lastDot = raw.lastIndexOf('.');\n if (lastDot <= 0 || lastDot === raw.length - 1) return undefined;\n\n const value = raw.slice(0, lastDot);\n const signature = raw.slice(lastDot + 1);\n\n // Hex-encoded SHA-256 is always 64 chars\n if (signature.length !== 64) return undefined;\n\n const signatureBuffer = Buffer.from(signature, 'hex');\n // If the hex decode produced fewer bytes, the signature was not valid hex\n if (signatureBuffer.length !== 32) return undefined;\n\n for (const secret of secrets) {\n const expected = createHmac('sha256', secret).update(value).digest();\n if (timingSafeEqual(expected, signatureBuffer)) {\n return value;\n }\n }\n return undefined;\n}\n\n/** Serialize a CookieEntry into a Set-Cookie header value. */\nfunction serializeCookieEntry(entry: CookieEntry): string {\n const parts = [`${entry.name}=${entry.value}`];\n const opts = entry.options;\n\n if (opts.domain) parts.push(`Domain=${opts.domain}`);\n if (opts.path) parts.push(`Path=${opts.path}`);\n if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);\n if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);\n if (opts.httpOnly) parts.push('HttpOnly');\n if (opts.secure) parts.push('Secure');\n if (opts.sameSite) {\n parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);\n }\n if (opts.partitioned) parts.push('Partitioned');\n\n return parts.join('; ');\n}\n"],"mappings":"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"}