@timber-js/app 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +420 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- 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
|
+
}
|