@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.31

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 (142) 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-B3Gypr3D.js +108 -0
  4. package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
  6. package/dist/_chunks/format-RyoGQL74.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-BQUC8PHn.js} +5 -4
  12. package/dist/_chunks/request-context-BQUC8PHn.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/compress-module.d.ts.map +1 -1
  22. package/dist/adapters/nitro.d.ts +17 -1
  23. package/dist/adapters/nitro.d.ts.map +1 -1
  24. package/dist/adapters/nitro.js +26 -9
  25. package/dist/adapters/nitro.js.map +1 -1
  26. package/dist/cache/fast-hash.d.ts +22 -0
  27. package/dist/cache/fast-hash.d.ts.map +1 -0
  28. package/dist/cache/index.js +52 -10
  29. package/dist/cache/index.js.map +1 -1
  30. package/dist/cache/register-cached-function.d.ts.map +1 -1
  31. package/dist/cache/timber-cache.d.ts.map +1 -1
  32. package/dist/client/error-boundary.js +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/link.d.ts.map +1 -1
  36. package/dist/client/router.d.ts.map +1 -1
  37. package/dist/client/segment-context.d.ts +1 -1
  38. package/dist/client/segment-context.d.ts.map +1 -1
  39. package/dist/client/segment-merger.d.ts.map +1 -1
  40. package/dist/client/stale-reload.d.ts.map +1 -1
  41. package/dist/client/top-loader.d.ts.map +1 -1
  42. package/dist/client/transition-root.d.ts +1 -1
  43. package/dist/client/transition-root.d.ts.map +1 -1
  44. package/dist/cookies/index.js +4 -4
  45. package/dist/fonts/css.d.ts +1 -0
  46. package/dist/fonts/css.d.ts.map +1 -1
  47. package/dist/fonts/local.d.ts +4 -2
  48. package/dist/fonts/local.d.ts.map +1 -1
  49. package/dist/index.d.ts +28 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +249 -21
  52. package/dist/index.js.map +1 -1
  53. package/dist/plugins/build-report.d.ts +11 -1
  54. package/dist/plugins/build-report.d.ts.map +1 -1
  55. package/dist/plugins/entries.d.ts +7 -0
  56. package/dist/plugins/entries.d.ts.map +1 -1
  57. package/dist/plugins/fonts.d.ts +9 -1
  58. package/dist/plugins/fonts.d.ts.map +1 -1
  59. package/dist/plugins/mdx.d.ts +6 -0
  60. package/dist/plugins/mdx.d.ts.map +1 -1
  61. package/dist/plugins/server-bundle.d.ts.map +1 -1
  62. package/dist/routing/index.js +1 -1
  63. package/dist/rsc-runtime/ssr.d.ts +12 -0
  64. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  65. package/dist/search-params/index.js +1 -1
  66. package/dist/server/access-gate.d.ts.map +1 -1
  67. package/dist/server/action-client.d.ts.map +1 -1
  68. package/dist/server/debug.d.ts +82 -0
  69. package/dist/server/debug.d.ts.map +1 -0
  70. package/dist/server/deny-renderer.d.ts.map +1 -1
  71. package/dist/server/dev-warnings.d.ts.map +1 -1
  72. package/dist/server/html-injectors.d.ts.map +1 -1
  73. package/dist/server/index.js +32 -23
  74. package/dist/server/index.js.map +1 -1
  75. package/dist/server/logger.d.ts.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +65 -0
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  78. package/dist/server/pipeline.d.ts +7 -4
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts.map +1 -1
  81. package/dist/server/request-context.d.ts.map +1 -1
  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/ssr-entry.d.ts.map +1 -1
  90. package/dist/server/ssr-render.d.ts +34 -21
  91. package/dist/server/ssr-render.d.ts.map +1 -1
  92. package/dist/server/tracing.d.ts +10 -0
  93. package/dist/server/tracing.d.ts.map +1 -1
  94. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  95. package/dist/shims/image.d.ts +15 -15
  96. package/package.json +1 -1
  97. package/src/adapters/compress-module.ts +21 -4
  98. package/src/adapters/nitro.ts +31 -5
  99. package/src/cache/fast-hash.ts +34 -0
  100. package/src/cache/register-cached-function.ts +7 -3
  101. package/src/cache/timber-cache.ts +17 -10
  102. package/src/client/browser-entry.ts +10 -6
  103. package/src/client/link.tsx +14 -9
  104. package/src/client/router.ts +4 -6
  105. package/src/client/segment-context.ts +6 -1
  106. package/src/client/segment-merger.ts +2 -8
  107. package/src/client/stale-reload.ts +5 -7
  108. package/src/client/top-loader.tsx +8 -7
  109. package/src/client/transition-root.tsx +7 -1
  110. package/src/fonts/css.ts +2 -1
  111. package/src/fonts/local.ts +7 -3
  112. package/src/index.ts +35 -2
  113. package/src/plugins/build-report.ts +23 -3
  114. package/src/plugins/entries.ts +9 -4
  115. package/src/plugins/fonts.ts +171 -19
  116. package/src/plugins/mdx.ts +9 -5
  117. package/src/plugins/server-bundle.ts +4 -0
  118. package/src/rsc-runtime/ssr.ts +50 -0
  119. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  120. package/src/server/access-gate.tsx +3 -2
  121. package/src/server/action-client.ts +15 -5
  122. package/src/server/debug.ts +137 -0
  123. package/src/server/deny-renderer.ts +3 -2
  124. package/src/server/dev-warnings.ts +2 -1
  125. package/src/server/html-injectors.ts +30 -10
  126. package/src/server/logger.ts +4 -3
  127. package/src/server/node-stream-transforms.ts +315 -0
  128. package/src/server/pipeline.ts +34 -20
  129. package/src/server/primitives.ts +2 -1
  130. package/src/server/request-context.ts +3 -2
  131. package/src/server/route-element-builder.ts +1 -6
  132. package/src/server/rsc-entry/index.ts +50 -7
  133. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  134. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  135. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  136. package/src/server/rsc-prop-warnings.ts +3 -1
  137. package/src/server/ssr-entry.ts +130 -8
  138. package/src/server/ssr-render.ts +168 -57
  139. package/src/server/tracing.ts +23 -0
  140. package/src/server/waituntil-bridge.ts +4 -1
  141. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  142. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -5,6 +5,17 @@
5
5
  * independently of the Vite RSC plugin runtime (which provides
6
6
  * createFromReadableStream for decoding RSC streams).
7
7
  *
8
+ * Uses a platform-adaptive rendering strategy:
9
+ * - **Node.js / Bun**: `renderToPipeableStream` — React pipes HTML chunks
10
+ * through Node.js native streams (C++ implementation). Each chunk flows
11
+ * through libuv buffers with zero Promise overhead.
12
+ * - **Cloudflare Workers / Edge**: `renderToReadableStream` — React outputs
13
+ * to Web Streams which are V8-native C++ built-ins on these platforms.
14
+ *
15
+ * The detection is automatic at runtime. Both paths produce a Web
16
+ * `ReadableStream<Uint8Array>` so downstream transforms (injectHead,
17
+ * injectRscPayload, compression) work identically regardless of platform.
18
+ *
8
19
  * Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
9
20
  * 18-build-system.md §"Entry Files"
10
21
  */
@@ -28,60 +39,187 @@ import { formatSsrError } from './error-formatter.js';
28
39
  const NOINDEX_SCRIPT =
29
40
  '<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
30
41
 
42
+ // ─── Platform Detection ──────────────────────────────────────────────────────
43
+ //
44
+ // Detect whether we're running on a platform with native Node.js streams.
45
+ // On Node.js/Bun, `node:stream` is backed by C++ (libuv). On Cloudflare
46
+ // Workers, `node:stream` via nodejs_compat is a JS polyfill — Web Streams
47
+ // are the faster path there (V8-native C++ built-ins).
48
+ //
49
+ // We detect once at module load to avoid per-request overhead.
50
+ // The check: process.versions.node exists AND we can import node:stream.
51
+ // Cloudflare Workers with nodejs_compat may polyfill process.versions but
52
+ // the streams won't be native. The Readable.toWeb check confirms native support.
53
+
54
+ let _useNodeStreams = false;
55
+ let _PassThrough: typeof import('node:stream').PassThrough | null = null;
56
+ let _ReadableToWeb: ((readable: import('node:stream').Readable) => ReadableStream) | null = null;
57
+ let _renderToPipeableStream: typeof import('react-dom/server').renderToPipeableStream | null = null;
58
+
59
+ try {
60
+ // Dynamic imports to avoid bundling node:stream and the Node.js-specific
61
+ // react-dom/server entry for CF Workers builds.
62
+ // On Node.js/Bun this resolves to native C++ streams.
63
+ // On CF Workers this either fails or returns a JS polyfill.
64
+ const nodeStream = await import('node:stream');
65
+ if (
66
+ typeof nodeStream.PassThrough === 'function' &&
67
+ typeof nodeStream.Readable.toWeb === 'function' &&
68
+ // Real Node.js — not a polyfill. Polyfills typically don't set
69
+ // process.release.name to 'node'.
70
+ typeof process !== 'undefined' &&
71
+ process.release?.name === 'node'
72
+ ) {
73
+ // Dynamically import renderToPipeableStream from the Node.js-specific
74
+ // entry point. The SSR bundle resolves react-dom/server to the edge/browser
75
+ // export (which lacks renderToPipeableStream), so we import the .node file
76
+ // directly. This is safe because we're inside a process.release.name === 'node'
77
+ // guard — this code only runs on real Node.js.
78
+ const reactDomServer = await import('react-dom/server.node');
79
+ if (typeof reactDomServer.renderToPipeableStream === 'function') {
80
+ _useNodeStreams = true;
81
+ _PassThrough = nodeStream.PassThrough;
82
+ _ReadableToWeb = nodeStream.Readable.toWeb as (
83
+ readable: import('node:stream').Readable
84
+ ) => ReadableStream;
85
+ _renderToPipeableStream = reactDomServer.renderToPipeableStream;
86
+ }
87
+ }
88
+ } catch {
89
+ // node:stream or renderToPipeableStream not available — use Web Streams path
90
+ }
91
+
31
92
  /**
32
93
  * Render a React element tree to a ReadableStream of HTML.
33
94
  *
34
- * Uses renderToReadableStream (NOT renderToString) for streaming SSR.
95
+ * Automatically selects the optimal rendering path for the platform:
96
+ * - Node.js/Bun: `renderToPipeableStream` → Node.js native streams → `Readable.toWeb()`
97
+ * - CF Workers/Edge: `renderToReadableStream` → native Web Streams
98
+ *
35
99
  * The returned stream begins yielding after onShellReady — everything
36
100
  * outside <Suspense> boundaries is in the shell.
37
101
  *
38
- * With progressive streaming, the RSC stream is piped directly to SSR
39
- * without buffering. If deny() was called outside a Suspense boundary,
40
- * the RSC stream encodes an error in the shell — renderToReadableStream
41
- * rejects, and the RSC entry catches this to render a deny page with
42
- * the correct HTTP status code. If deny() was inside Suspense, the shell
43
- * succeeds (200 committed) and the error streams as an error boundary.
44
- *
45
102
  * @param element - The React element tree decoded from the RSC stream
46
103
  * @param options - Optional configuration
47
- * @param options.bootstrapScriptContent - Inline JS injected by React as a
48
- * non-deferred `<script>` in the shell HTML. Executes immediately during
49
- * parsing — even while Suspense boundaries are still streaming. Used to
50
- * kick off module loading via dynamic `import()` so hydration can start
51
- * before the HTML stream closes.
52
104
  * @returns A ReadableStream of HTML bytes with hydration markers
53
105
  */
54
106
  export async function renderSsrStream(
55
107
  element: ReactNode,
56
108
  options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
109
+ ): Promise<ReadableStream<Uint8Array>> {
110
+ return renderViaReadableStream(element, options);
111
+ }
112
+
113
+ /** Whether the current platform uses native Node.js streams for SSR. */
114
+ export const useNodeStreams = _useNodeStreams;
115
+
116
+ // ─── Node.js Path: renderToPipeableStream ────────────────────────────────────
117
+ //
118
+ // Uses React's Node.js-native API. HTML chunks flow through C++ stream
119
+ // buffers with zero Promise allocations per chunk. Returns a Node.js
120
+ // Readable — the caller (ssr-entry.ts) pipes through Node.js Transform
121
+ // streams for injectHead/injectRscPayload before converting to Web
122
+ // ReadableStream at the Response boundary.
123
+
124
+ /**
125
+ * Render via renderToPipeableStream, returning a Node.js Readable.
126
+ *
127
+ * The entire HTML rendering + post-processing pipeline stays in native
128
+ * Node.js streams (C++ backed). Only converted to Web ReadableStream
129
+ * at the very end for the Response body.
130
+ */
131
+ export async function renderSsrNodeStream(
132
+ element: ReactNode,
133
+ options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
134
+ ): Promise<import('node:stream').Readable> {
135
+ const signal = options?.signal;
136
+ const deferMs = options?.deferSuspenseFor;
137
+
138
+ return new Promise<import('node:stream').Readable>((resolve, reject) => {
139
+ const _startTime = performance.now();
140
+ const passthrough = new _PassThrough!();
141
+
142
+ let allReadyResolve: (() => void) | null = null;
143
+ const allReady = new Promise<void>((r) => {
144
+ allReadyResolve = r;
145
+ });
146
+ allReady.catch(() => {});
147
+
148
+ const { pipe, abort } = _renderToPipeableStream!(element, {
149
+ bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
150
+
151
+ onShellReady() {
152
+ const _shellReady = performance.now();
153
+ // eslint-disable-next-line no-console
154
+ console.log(`[ssr-perf] onShellReady in ${(_shellReady - _startTime).toFixed(1)}ms`);
155
+ if (deferMs && deferMs > 0) {
156
+ Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
157
+ pipe(passthrough);
158
+ resolve(passthrough);
159
+ });
160
+ } else {
161
+ pipe(passthrough);
162
+ resolve(passthrough);
163
+ }
164
+ },
165
+
166
+ onAllReady() {
167
+ allReadyResolve?.();
168
+ },
169
+
170
+ onShellError(error: unknown) {
171
+ reject(error);
172
+ },
173
+
174
+ onError(error: unknown) {
175
+ if (isAbortError(error) || signal?.aborted) return;
176
+ console.error('[timber] SSR render error:', formatSsrError(error));
177
+ },
178
+ });
179
+
180
+ if (signal) {
181
+ if (signal.aborted) {
182
+ abort();
183
+ } else {
184
+ signal.addEventListener('abort', () => abort(), { once: true });
185
+ }
186
+ }
187
+ });
188
+ }
189
+
190
+ /** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
191
+ export function nodeReadableToWeb(
192
+ readable: import('node:stream').Readable
193
+ ): ReadableStream<Uint8Array> {
194
+ return _ReadableToWeb!(readable) as ReadableStream<Uint8Array>;
195
+ }
196
+
197
+ // ─── Web Streams Path: renderToReadableStream ────────────────────────────────
198
+ //
199
+ // Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a
200
+ // V8-native C++ built-in, making this the fastest path for that platform.
201
+ // On Node.js, Web Streams are a JS reimplementation — slower, but this path
202
+ // is only used as a fallback when Node.js native streams aren't available.
203
+
204
+ async function renderViaReadableStream(
205
+ element: ReactNode,
206
+ options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
57
207
  ): Promise<ReadableStream<Uint8Array>> {
58
208
  const signal = options?.signal;
59
209
  const stream = await renderToReadableStream(element, {
60
210
  bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
61
211
  signal,
62
212
  onError(error: unknown) {
63
- // Suppress logging for connection aborts — the user refreshed or
64
- // navigated away, not an application error.
65
213
  if (isAbortError(error) || signal?.aborted) return;
66
214
  console.error('[timber] SSR render error:', formatSsrError(error));
67
215
  },
68
216
  });
69
217
 
70
218
  // Prevent unhandled promise rejection from streaming-phase errors.
71
- // React DOM Server exposes `allReady` — a promise that resolves when
72
- // ALL content (including Suspense boundaries) has been rendered. If a
73
- // streaming-phase error occurs (e.g. React boundary flush failure),
74
- // `allReady` rejects independently of the stream. Without this catch,
75
- // the rejection becomes an unhandled promise rejection that crashes
76
- // the Node.js process.
77
219
  stream.allReady.catch(() => {});
78
220
 
79
221
  // deferSuspenseFor hold: delay the first read so React can resolve
80
222
  // fast-completing Suspense boundaries before we read the shell HTML.
81
- // renderToReadableStream generates HTML lazily on pull — if we wait
82
- // before reading, React resolves pending boundaries and inlines their
83
- // content instead of serializing fallbacks. Race allReady against
84
- // deferSuspenseFor so we don't wait longer than necessary.
85
223
  // See design/05-streaming.md §"deferSuspenseFor"
86
224
  const deferMs = options?.deferSuspenseFor;
87
225
  if (deferMs && deferMs > 0) {
@@ -91,30 +229,19 @@ export async function renderSsrStream(
91
229
  ]);
92
230
  }
93
231
 
94
- // renderToReadableStream resolves after onShellReady by default.
95
- // The stream is ready to read — the shell (everything outside
96
- // Suspense boundaries) is available. Suspense content streams
97
- // into the open connection as it resolves.
98
- //
99
- // Wrap the stream in an error-resilient transform. With progressive
100
- // streaming, errors inside Suspense boundaries (e.g. deny() or throws
101
- // in async components) cause React's stream to error during the flush
102
- // phase. The onError callback logs the error, but the stream error
103
- // would become an unhandled promise rejection and crash the process.
104
- // The transform catches these post-shell streaming errors and closes
105
- // the stream cleanly — the shell (with correct status code) has
106
- // already been sent.
107
232
  return wrapStreamWithErrorHandling(stream, signal);
108
233
  }
109
234
 
235
+ // ─── Shared Utilities ────────────────────────────────────────────────────────
236
+
110
237
  /**
111
238
  * Wrap an HTML stream with error handling for the streaming phase.
112
239
  *
113
240
  * During progressive RSC→SSR streaming, errors in Suspense boundaries
114
241
  * (e.g. deny() inside Suspense, throws in async components) cause
115
- * React DOM's renderToReadableStream to error after the shell has been
116
- * flushed. Without this wrapper, the stream error becomes an unhandled
117
- * promise rejection that crashes the process.
242
+ * React DOM's stream to error after the shell has been flushed. Without
243
+ * this wrapper, the stream error becomes an unhandled promise rejection
244
+ * that crashes the process.
118
245
  *
119
246
  * The wrapper catches streaming-phase errors, logs them, and closes
120
247
  * the output stream cleanly. The shell (headers, status code, content
@@ -138,17 +265,10 @@ export function wrapStreamWithErrorHandling(
138
265
  }
139
266
  controller.enqueue(value);
140
267
  } catch (error) {
141
- // Connection abort (user refreshed or navigated away) — close
142
- // silently without logging. This is not an application error.
143
268
  if (isAbortError(error) || signal?.aborted) {
144
269
  controller.close();
145
270
  return;
146
271
  }
147
- // Streaming-phase error (e.g. React boundary flush failure,
148
- // deny() or throw inside Suspense after flush).
149
- // The shell has already been sent with status 200. Inject a
150
- // noindex meta tag so search engines don't index this error page,
151
- // then close cleanly. See design/05-streaming.md.
152
272
  console.error('[timber] SSR streaming error (post-shell):', formatSsrError(error));
153
273
  controller.enqueue(encoder.encode(NOINDEX_SCRIPT));
154
274
  controller.close();
@@ -162,10 +282,6 @@ export function wrapStreamWithErrorHandling(
162
282
 
163
283
  /**
164
284
  * Check if an error is an abort error (connection closed by client).
165
- *
166
- * When the browser aborts a request (page refresh, navigation away),
167
- * the AbortSignal fires and React/streams throw an AbortError. This
168
- * is not an application error — suppress it from error boundaries and logs.
169
285
  */
170
286
  function isAbortError(error: unknown): boolean {
171
287
  if (error instanceof DOMException && error.name === 'AbortError') return true;
@@ -178,11 +294,6 @@ function isAbortError(error: unknown): boolean {
178
294
  * status code and headers from the navigation context.
179
295
  *
180
296
  * Sets content-type to text/html if not already set by middleware.
181
- *
182
- * @param htmlStream - The HTML stream from renderSsrStream
183
- * @param statusCode - The committed HTTP status code from RSC
184
- * @param responseHeaders - Response headers from middleware/proxy
185
- * @returns A Response ready to send to the client
186
297
  */
187
298
  export function buildSsrResponse(
188
299
  htmlStream: ReadableStream<Uint8Array>,
@@ -252,6 +252,29 @@ export async function addSpanEvent(
252
252
  }
253
253
  }
254
254
 
255
+ /**
256
+ * Fire-and-forget span event — no await, no microtask overhead.
257
+ *
258
+ * Used on the cache hot path where awaiting addSpanEvent creates an
259
+ * unnecessary microtask per cache operation. If OTEL is not loaded yet,
260
+ * the event is silently dropped (acceptable for diagnostics).
261
+ *
262
+ * See TIM-370 for perf motivation.
263
+ */
264
+ export function addSpanEventSync(
265
+ name: string,
266
+ attributes?: Record<string, string | number | boolean>
267
+ ): void {
268
+ // Fast path: if OTEL API hasn't been loaded yet, skip entirely.
269
+ // _otelApi is undefined (not yet loaded), null (failed to load), or the module.
270
+ if (!_otelApi) return;
271
+
272
+ const activeSpan = _otelApi.trace.getActiveSpan();
273
+ if (activeSpan) {
274
+ activeSpan.addEvent(name, attributes);
275
+ }
276
+ }
277
+
255
278
  /**
256
279
  * Try to extract the OTEL trace ID from the current active span context.
257
280
  * Returns undefined if OTEL is not active or no span exists.
@@ -18,7 +18,10 @@ import { waitUntilAls } from './als-registry.js';
18
18
  * Called by generated entry points (Nitro node-server/bun, Cloudflare)
19
19
  * to bind the platform's lifecycle extension for the request duration.
20
20
  */
21
- export function runWithWaitUntil<T>(waitUntilFn: (promise: Promise<unknown>) => void, fn: () => T): T {
21
+ export function runWithWaitUntil<T>(
22
+ waitUntilFn: (promise: Promise<unknown>) => void,
23
+ fn: () => T
24
+ ): T {
22
25
  return waitUntilAls.run(waitUntilFn, fn);
23
26
  }
24
27
 
@@ -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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"}