@timber-js/app 0.1.0 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +43 -58
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Request Context — per-request ALS store for headers() and cookies().
3
+ *
4
+ * Follows the same pattern as tracing.ts: a module-level AsyncLocalStorage
5
+ * instance, public accessor functions that throw outside request scope,
6
+ * and a framework-internal `runWithRequestContext()` to establish scope.
7
+ *
8
+ * See design/04-authorization.md §"AccessContext does not include cookies or headers"
9
+ * and design/11-platform.md §"AsyncLocalStorage".
10
+ * See design/29-cookies.md for cookie mutation semantics.
11
+ */
12
+
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { createHmac, timingSafeEqual } from 'node:crypto';
15
+ import type { Routes } from '#/index.js';
16
+
17
+ // ─── ALS Store ────────────────────────────────────────────────────────────
18
+
19
+ interface RequestContextStore {
20
+ /** Incoming request headers (read-only view). */
21
+ headers: Headers;
22
+ /** Raw cookie header string, parsed lazily into a Map on first access. */
23
+ cookieHeader: string;
24
+ /** Lazily-parsed cookie map (mutable — reflects write-overlay from set()). */
25
+ parsedCookies?: Map<string, string>;
26
+ /** Original (pre-overlay) frozen headers, kept for overlay merging. */
27
+ originalHeaders: Headers;
28
+ /**
29
+ * Promise resolving to the route's typed search params (when search-params.ts
30
+ * exists) or to the raw URLSearchParams. Stored as a Promise so the framework
31
+ * can later support partial pre-rendering where param resolution is deferred.
32
+ */
33
+ searchParamsPromise: Promise<URLSearchParams | Record<string, unknown>>;
34
+ /** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */
35
+ cookieJar: Map<string, CookieEntry>;
36
+ /** Whether the response has flushed (headers committed). */
37
+ flushed: boolean;
38
+ /** Whether the current context allows cookie mutation. */
39
+ mutableContext: boolean;
40
+ }
41
+
42
+ /** A single outgoing cookie entry in the cookie jar. */
43
+ interface CookieEntry {
44
+ name: string;
45
+ value: string;
46
+ options: CookieOptions;
47
+ }
48
+
49
+ const requestContextAls = new AsyncLocalStorage<RequestContextStore>();
50
+
51
+ // ─── Cookie Signing Secrets ──────────────────────────────────────────────
52
+
53
+ /**
54
+ * Module-level cookie signing secrets. Index 0 is the newest (used for signing).
55
+ * All entries are tried for verification (key rotation support).
56
+ *
57
+ * Set by the framework at startup via `setCookieSecrets()`.
58
+ * See design/29-cookies.md §"Signed Cookies"
59
+ */
60
+ let _cookieSecrets: string[] = [];
61
+
62
+ /**
63
+ * Configure the cookie signing secrets.
64
+ *
65
+ * Called by the framework during server initialization with values from
66
+ * `cookies.secret` or `cookies.secrets` in timber.config.ts.
67
+ *
68
+ * The first secret (index 0) is used for signing new cookies.
69
+ * All secrets are tried for verification (supports key rotation).
70
+ */
71
+ export function setCookieSecrets(secrets: string[]): void {
72
+ _cookieSecrets = secrets.filter(Boolean);
73
+ }
74
+
75
+ // ─── Public API ───────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Returns a read-only view of the current request's headers.
79
+ *
80
+ * Available in middleware, access checks, server components, and server actions.
81
+ * Throws if called outside a request context (security principle #2: no global fallback).
82
+ */
83
+ export function headers(): ReadonlyHeaders {
84
+ const store = requestContextAls.getStore();
85
+ if (!store) {
86
+ throw new Error(
87
+ '[timber] headers() called outside of a request context. ' +
88
+ 'It can only be used in middleware, access checks, server components, and server actions.'
89
+ );
90
+ }
91
+ return store.headers;
92
+ }
93
+
94
+ /**
95
+ * Returns a cookie accessor for the current request.
96
+ *
97
+ * Available in middleware, access checks, server components, and server actions.
98
+ * Throws if called outside a request context (security principle #2: no global fallback).
99
+ *
100
+ * Read methods (.get, .has, .getAll) are always available and reflect
101
+ * read-your-own-writes from .set() calls in the same request.
102
+ *
103
+ * Mutation methods (.set, .delete, .clear) are only available in mutable
104
+ * contexts (middleware.ts, server actions, route.ts handlers). Calling them
105
+ * in read-only contexts (access.ts, server components) throws.
106
+ *
107
+ * See design/29-cookies.md
108
+ */
109
+ export function cookies(): RequestCookies {
110
+ const store = requestContextAls.getStore();
111
+ if (!store) {
112
+ throw new Error(
113
+ '[timber] cookies() called outside of a request context. ' +
114
+ 'It can only be used in middleware, access checks, server components, and server actions.'
115
+ );
116
+ }
117
+
118
+ // Parse cookies lazily on first access
119
+ if (!store.parsedCookies) {
120
+ store.parsedCookies = parseCookieHeader(store.cookieHeader);
121
+ }
122
+
123
+ const map = store.parsedCookies;
124
+ return {
125
+ get(name: string): string | undefined {
126
+ return map.get(name);
127
+ },
128
+ has(name: string): boolean {
129
+ return map.has(name);
130
+ },
131
+ getAll(): Array<{ name: string; value: string }> {
132
+ return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
133
+ },
134
+ get size(): number {
135
+ return map.size;
136
+ },
137
+
138
+ getSigned(name: string): string | undefined {
139
+ const raw = map.get(name);
140
+ if (!raw || _cookieSecrets.length === 0) return undefined;
141
+ return verifySignedCookie(raw, _cookieSecrets);
142
+ },
143
+
144
+ set(name: string, value: string, options?: CookieOptions): void {
145
+ assertMutable(store, 'set');
146
+ if (store.flushed) {
147
+ if (process.env.NODE_ENV !== 'production') {
148
+ console.warn(
149
+ `[timber] warn: cookies().set('${name}') called after response headers were committed.\n` +
150
+ ` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\n` +
151
+ ` or a route.ts handler.`
152
+ );
153
+ }
154
+ return;
155
+ }
156
+ let storedValue = value;
157
+ if (options?.signed) {
158
+ if (_cookieSecrets.length === 0) {
159
+ throw new Error(
160
+ `[timber] cookies().set('${name}', ..., { signed: true }) requires ` +
161
+ `cookies.secret or cookies.secrets in timber.config.ts.`
162
+ );
163
+ }
164
+ storedValue = signCookieValue(value, _cookieSecrets[0]);
165
+ }
166
+ const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };
167
+ store.cookieJar.set(name, { name, value: storedValue, options: opts });
168
+ // Read-your-own-writes: update the parsed cookies map with the signed value
169
+ // so getSigned() can verify it in the same request
170
+ map.set(name, storedValue);
171
+ },
172
+
173
+ delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {
174
+ assertMutable(store, 'delete');
175
+ if (store.flushed) {
176
+ if (process.env.NODE_ENV !== 'production') {
177
+ console.warn(
178
+ `[timber] warn: cookies().delete('${name}') called after response headers were committed.\n` +
179
+ ` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\n` +
180
+ ` or a route.ts handler.`
181
+ );
182
+ }
183
+ return;
184
+ }
185
+ const opts: CookieOptions = {
186
+ ...DEFAULT_COOKIE_OPTIONS,
187
+ ...options,
188
+ maxAge: 0,
189
+ expires: new Date(0),
190
+ };
191
+ store.cookieJar.set(name, { name, value: '', options: opts });
192
+ // Remove from read view
193
+ map.delete(name);
194
+ },
195
+
196
+ clear(): void {
197
+ assertMutable(store, 'clear');
198
+ if (store.flushed) return;
199
+ // Delete every incoming cookie
200
+ for (const name of Array.from(map.keys())) {
201
+ store.cookieJar.set(name, {
202
+ name,
203
+ value: '',
204
+ options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0, expires: new Date(0) },
205
+ });
206
+ }
207
+ map.clear();
208
+ },
209
+
210
+ toString(): string {
211
+ return Array.from(map.entries())
212
+ .map(([name, value]) => `${name}=${value}`)
213
+ .join('; ');
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Returns a Promise resolving to the current request's search params.
220
+ *
221
+ * In `page.tsx`, `middleware.ts`, and `access.ts` the framework pre-parses the
222
+ * route's `search-params.ts` definition and the Promise resolves to the typed
223
+ * object. In all other server component contexts it resolves to raw
224
+ * `URLSearchParams`.
225
+ *
226
+ * Returned as a Promise to match the `params` prop convention and to allow
227
+ * future partial pre-rendering support where param resolution may be deferred.
228
+ *
229
+ * Throws if called outside a request context.
230
+ */
231
+ export function searchParams<R extends keyof Routes>(): Promise<Routes[R]['searchParams']>;
232
+ export function searchParams(): Promise<URLSearchParams | Record<string, unknown>>;
233
+ export function searchParams(): Promise<URLSearchParams | Record<string, unknown>> {
234
+ const store = requestContextAls.getStore();
235
+ if (!store) {
236
+ throw new Error(
237
+ '[timber] searchParams() called outside of a request context. ' +
238
+ 'It can only be used in middleware, access checks, server components, and server actions.'
239
+ );
240
+ }
241
+ return store.searchParamsPromise;
242
+ }
243
+
244
+ /**
245
+ * Replace the search params Promise for the current request with one that
246
+ * resolves to the typed parsed result from the route's search-params.ts.
247
+ * Called by the framework before rendering the page — not for app code.
248
+ */
249
+ export function setParsedSearchParams(parsed: Record<string, unknown>): void {
250
+ const store = requestContextAls.getStore();
251
+ if (store) {
252
+ store.searchParamsPromise = Promise.resolve(parsed);
253
+ }
254
+ }
255
+
256
+ // ─── Types ────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Read-only Headers interface. The standard Headers class is mutable;
260
+ * this type narrows it to read-only methods. The underlying object is
261
+ * still a Headers instance, but user code should not mutate it.
262
+ */
263
+ export type ReadonlyHeaders = Pick<
264
+ Headers,
265
+ 'get' | 'has' | 'entries' | 'keys' | 'values' | 'forEach' | typeof Symbol.iterator
266
+ >;
267
+
268
+ /** Options for setting a cookie. See design/29-cookies.md. */
269
+ export interface CookieOptions {
270
+ /** Domain scope. Default: omitted (current domain only). */
271
+ domain?: string;
272
+ /** URL path scope. Default: '/'. */
273
+ path?: string;
274
+ /** Expiration date. Mutually exclusive with maxAge. */
275
+ expires?: Date;
276
+ /** Max age in seconds. Mutually exclusive with expires. */
277
+ maxAge?: number;
278
+ /** Prevent client-side JS access. Default: true. */
279
+ httpOnly?: boolean;
280
+ /** Only send over HTTPS. Default: true. */
281
+ secure?: boolean;
282
+ /** Cross-site request policy. Default: 'lax'. */
283
+ sameSite?: 'strict' | 'lax' | 'none';
284
+ /** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */
285
+ partitioned?: boolean;
286
+ /**
287
+ * Sign the cookie value with HMAC-SHA256 for integrity verification.
288
+ * Requires `cookies.secret` or `cookies.secrets` in timber.config.ts.
289
+ * See design/29-cookies.md §"Signed Cookies".
290
+ */
291
+ signed?: boolean;
292
+ }
293
+
294
+ const DEFAULT_COOKIE_OPTIONS: CookieOptions = {
295
+ path: '/',
296
+ httpOnly: true,
297
+ secure: true,
298
+ sameSite: 'lax',
299
+ };
300
+
301
+ /**
302
+ * Cookie accessor returned by `cookies()`.
303
+ *
304
+ * Read methods are always available. Mutation methods throw in read-only
305
+ * contexts (access.ts, server components).
306
+ */
307
+ export interface RequestCookies {
308
+ /** Get a cookie value by name. Returns undefined if not present. */
309
+ get(name: string): string | undefined;
310
+ /** Check if a cookie exists. */
311
+ has(name: string): boolean;
312
+ /** Get all cookies as an array of { name, value } pairs. */
313
+ getAll(): Array<{ name: string; value: string }>;
314
+ /** Number of cookies. */
315
+ readonly size: number;
316
+ /**
317
+ * Get a signed cookie value, verifying its HMAC-SHA256 signature.
318
+ * Returns undefined if the cookie is missing, the signature is invalid,
319
+ * or no secrets are configured. Never throws.
320
+ *
321
+ * See design/29-cookies.md §"Signed Cookies"
322
+ */
323
+ getSigned(name: string): string | undefined;
324
+ /** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */
325
+ set(name: string, value: string, options?: CookieOptions): void;
326
+ /** Delete a cookie. Only available in mutable contexts. */
327
+ delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void;
328
+ /** Delete all cookies. Only available in mutable contexts. */
329
+ clear(): void;
330
+ /** Serialize cookies as a Cookie header string. */
331
+ toString(): string;
332
+ }
333
+
334
+ // ─── Framework-Internal Helpers ───────────────────────────────────────────
335
+
336
+ /**
337
+ * Run a callback within a request context. Used by the pipeline to establish
338
+ * per-request ALS scope so that `headers()` and `cookies()` work.
339
+ *
340
+ * @param req - The incoming Request object.
341
+ * @param fn - The function to run within the request context.
342
+ */
343
+ export function runWithRequestContext<T>(req: Request, fn: () => T): T {
344
+ const originalCopy = new Headers(req.headers);
345
+ const store: RequestContextStore = {
346
+ headers: freezeHeaders(req.headers),
347
+ originalHeaders: originalCopy,
348
+ cookieHeader: req.headers.get('cookie') ?? '',
349
+ searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),
350
+ cookieJar: new Map(),
351
+ flushed: false,
352
+ mutableContext: false,
353
+ };
354
+ return requestContextAls.run(store, fn);
355
+ }
356
+
357
+ /**
358
+ * Enable cookie mutation for the current context. Called by the framework
359
+ * when entering middleware.ts, server actions, or route.ts handlers.
360
+ *
361
+ * See design/29-cookies.md §"Context Tracking"
362
+ */
363
+ export function setMutableCookieContext(mutable: boolean): void {
364
+ const store = requestContextAls.getStore();
365
+ if (store) {
366
+ store.mutableContext = mutable;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Mark the response as flushed (headers committed). After this point,
372
+ * cookie mutations log a warning instead of throwing.
373
+ *
374
+ * See design/29-cookies.md §"Streaming Constraint: Post-Flush Cookie Warning"
375
+ */
376
+ export function markResponseFlushed(): void {
377
+ const store = requestContextAls.getStore();
378
+ if (store) {
379
+ store.flushed = true;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Collect all Set-Cookie headers from the cookie jar.
385
+ * Called by the framework at flush time to apply cookies to the response.
386
+ *
387
+ * Returns an array of serialized Set-Cookie header values.
388
+ */
389
+ export function getSetCookieHeaders(): string[] {
390
+ const store = requestContextAls.getStore();
391
+ if (!store) return [];
392
+ return Array.from(store.cookieJar.values()).map(serializeCookieEntry);
393
+ }
394
+
395
+ /**
396
+ * Apply middleware-injected request headers to the current request context.
397
+ *
398
+ * Called by the pipeline after middleware.ts runs. Merges overlay headers
399
+ * on top of the original request headers so downstream code (access.ts,
400
+ * server components, server actions) sees them via `headers()`.
401
+ *
402
+ * The original request headers are never mutated — a new frozen Headers
403
+ * object is created with the overlay applied on top.
404
+ *
405
+ * See design/07-routing.md §"Request Header Injection"
406
+ */
407
+ export function applyRequestHeaderOverlay(overlay: Headers): void {
408
+ const store = requestContextAls.getStore();
409
+ if (!store) {
410
+ throw new Error('[timber] applyRequestHeaderOverlay() called outside of a request context.');
411
+ }
412
+
413
+ // Check if the overlay has any headers — skip if empty
414
+ let hasOverlay = false;
415
+ overlay.forEach(() => {
416
+ hasOverlay = true;
417
+ });
418
+ if (!hasOverlay) return;
419
+
420
+ // Merge: start with original headers, overlay on top
421
+ const merged = new Headers(store.originalHeaders);
422
+ overlay.forEach((value, key) => {
423
+ merged.set(key, value);
424
+ });
425
+ store.headers = freezeHeaders(merged);
426
+ }
427
+
428
+ // ─── Read-Only Headers ────────────────────────────────────────────────────
429
+
430
+ const MUTATING_METHODS = new Set(['set', 'append', 'delete']);
431
+
432
+ /**
433
+ * Wrap a Headers object in a Proxy that throws on mutating methods.
434
+ * Object.freeze doesn't work on Headers (native internal slots), so we
435
+ * intercept property access and reject set/append/delete at runtime.
436
+ *
437
+ * Read methods (get, has, entries, etc.) must be bound to the underlying
438
+ * Headers instance because they access private #headersList slots.
439
+ */
440
+ function freezeHeaders(source: Headers): Headers {
441
+ const copy = new Headers(source);
442
+ return new Proxy(copy, {
443
+ get(target, prop) {
444
+ if (typeof prop === 'string' && MUTATING_METHODS.has(prop)) {
445
+ return () => {
446
+ throw new Error(
447
+ `[timber] headers() returns a read-only Headers object. ` +
448
+ `Calling .${prop}() is not allowed. ` +
449
+ `Use ctx.requestHeaders in middleware to inject headers for downstream components.`
450
+ );
451
+ };
452
+ }
453
+ const value = Reflect.get(target, prop);
454
+ // Bind methods to the real Headers instance so private slot access works
455
+ if (typeof value === 'function') {
456
+ return value.bind(target);
457
+ }
458
+ return value;
459
+ },
460
+ });
461
+ }
462
+
463
+ // ─── Cookie Helpers ───────────────────────────────────────────────────────
464
+
465
+ /** Throw if cookie mutation is attempted in a read-only context. */
466
+ function assertMutable(store: RequestContextStore, method: string): void {
467
+ if (!store.mutableContext) {
468
+ throw new Error(
469
+ `[timber] cookies().${method}() cannot be called in this context.\n` +
470
+ ` Set cookies in middleware.ts, server actions, or route.ts handlers.`
471
+ );
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Parse a Cookie header string into a Map of name → value pairs.
477
+ * Follows RFC 6265 §4.2.1: cookies are semicolon-separated key=value pairs.
478
+ */
479
+ function parseCookieHeader(header: string): Map<string, string> {
480
+ const map = new Map<string, string>();
481
+ if (!header) return map;
482
+
483
+ for (const pair of header.split(';')) {
484
+ const eqIndex = pair.indexOf('=');
485
+ if (eqIndex === -1) continue;
486
+ const name = pair.slice(0, eqIndex).trim();
487
+ const value = pair.slice(eqIndex + 1).trim();
488
+ if (name) {
489
+ map.set(name, value);
490
+ }
491
+ }
492
+
493
+ return map;
494
+ }
495
+
496
+ // ─── Cookie Signing ──────────────────────────────────────────────────────
497
+
498
+ /**
499
+ * Sign a cookie value with HMAC-SHA256.
500
+ * Returns `value.hex_signature`.
501
+ */
502
+ function signCookieValue(value: string, secret: string): string {
503
+ const signature = createHmac('sha256', secret).update(value).digest('hex');
504
+ return `${value}.${signature}`;
505
+ }
506
+
507
+ /**
508
+ * Verify a signed cookie value against an array of secrets.
509
+ * Returns the original value if any secret produces a matching signature,
510
+ * or undefined if none match. Uses timing-safe comparison.
511
+ *
512
+ * The signed format is `value.hex_signature` — split at the last `.`.
513
+ */
514
+ function verifySignedCookie(raw: string, secrets: string[]): string | undefined {
515
+ const lastDot = raw.lastIndexOf('.');
516
+ if (lastDot <= 0 || lastDot === raw.length - 1) return undefined;
517
+
518
+ const value = raw.slice(0, lastDot);
519
+ const signature = raw.slice(lastDot + 1);
520
+
521
+ // Hex-encoded SHA-256 is always 64 chars
522
+ if (signature.length !== 64) return undefined;
523
+
524
+ const signatureBuffer = Buffer.from(signature, 'hex');
525
+ // If the hex decode produced fewer bytes, the signature was not valid hex
526
+ if (signatureBuffer.length !== 32) return undefined;
527
+
528
+ for (const secret of secrets) {
529
+ const expected = createHmac('sha256', secret).update(value).digest();
530
+ if (timingSafeEqual(expected, signatureBuffer)) {
531
+ return value;
532
+ }
533
+ }
534
+ return undefined;
535
+ }
536
+
537
+ /** Serialize a CookieEntry into a Set-Cookie header value. */
538
+ function serializeCookieEntry(entry: CookieEntry): string {
539
+ const parts = [`${entry.name}=${entry.value}`];
540
+ const opts = entry.options;
541
+
542
+ if (opts.domain) parts.push(`Domain=${opts.domain}`);
543
+ if (opts.path) parts.push(`Path=${opts.path}`);
544
+ if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
545
+ if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);
546
+ if (opts.httpOnly) parts.push('HttpOnly');
547
+ if (opts.secure) parts.push('Secure');
548
+ if (opts.sameSite) {
549
+ parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);
550
+ }
551
+ if (opts.partitioned) parts.push('Partitioned');
552
+
553
+ return parts.join('; ');
554
+ }