@timber-js/app 0.1.48 → 0.1.49
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/_chunks/{als-registry-c0AGnbqS.js → als-registry-k-AtAQ9R.js} +4 -2
- package/dist/_chunks/{als-registry-c0AGnbqS.js.map → als-registry-k-AtAQ9R.js.map} +1 -1
- package/dist/_chunks/interception-DGDIjDbR.js.map +1 -1
- package/dist/_chunks/{request-context-C69VW4xS.js → request-context-CRj2Zh1E.js} +2 -2
- package/dist/_chunks/request-context-CRj2Zh1E.js.map +1 -0
- package/dist/_chunks/{tracing-tIvqStk8.js → tracing-DF0G3FB7.js} +2 -2
- package/dist/_chunks/{tracing-tIvqStk8.js.map → tracing-DF0G3FB7.js.map} +1 -1
- package/dist/_chunks/use-query-states-DAhgj8Gx.js.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +32 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +2 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +1 -1
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- package/dist/index.d.ts +8 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -10
- package/dist/index.js.map +1 -1
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +10 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +4 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/index.js +39 -7
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-platform.d.ts.map +1 -1
- package/dist/server/metadata-social.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +8 -4
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
- package/dist/server/waituntil-bridge.d.ts +27 -0
- package/dist/server/waituntil-bridge.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/adapters/nitro.ts +44 -8
- package/src/client/browser-entry.ts +80 -12
- package/src/client/navigation-context.ts +4 -1
- package/src/client/router.ts +5 -7
- package/src/client/transition-root.tsx +5 -11
- package/src/client/use-query-states.ts +4 -1
- package/src/client/use-router.ts +3 -1
- package/src/index.ts +8 -25
- package/src/plugins/chunks.ts +2 -1
- package/src/plugins/entries.ts +66 -2
- package/src/routing/scanner.ts +1 -4
- package/src/server/als-registry.ts +10 -0
- package/src/server/compress.ts +0 -1
- package/src/server/metadata-platform.ts +4 -1
- package/src/server/metadata-social.ts +4 -1
- package/src/server/pipeline.ts +6 -23
- package/src/server/primitives.ts +19 -9
- package/src/server/request-context.ts +1 -5
- package/src/server/rsc-entry/index.ts +16 -0
- package/src/server/rsc-entry/rsc-stream.ts +1 -4
- package/src/server/rsc-entry/ssr-renderer.ts +1 -3
- package/src/server/rsc-prop-warnings.ts +7 -17
- package/src/server/waituntil-bridge.ts +34 -0
- package/dist/_chunks/request-context-C69VW4xS.js.map +0 -1
package/src/plugins/entries.ts
CHANGED
|
@@ -33,6 +33,7 @@ const VIRTUAL_IDS = {
|
|
|
33
33
|
ssrEntry: 'virtual:timber-ssr-entry',
|
|
34
34
|
browserEntry: 'virtual:timber-browser-entry',
|
|
35
35
|
config: 'virtual:timber-config',
|
|
36
|
+
instrumentation: 'virtual:timber-instrumentation',
|
|
36
37
|
} as const;
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -53,6 +54,9 @@ const ENTRY_FILE_MAP: Record<string, string> = {
|
|
|
53
54
|
/** The \0-prefixed resolved ID for virtual:timber-config */
|
|
54
55
|
const RESOLVED_CONFIG_ID = `\0${VIRTUAL_IDS.config}`;
|
|
55
56
|
|
|
57
|
+
/** The \0-prefixed resolved ID for virtual:timber-instrumentation */
|
|
58
|
+
const RESOLVED_INSTRUMENTATION_ID = `\0${VIRTUAL_IDS.instrumentation}`;
|
|
59
|
+
|
|
56
60
|
/**
|
|
57
61
|
* Strip the \0 prefix from a module ID.
|
|
58
62
|
*
|
|
@@ -103,6 +107,7 @@ function generateConfigModule(ctx: PluginContext): string {
|
|
|
103
107
|
clientJavascript: ctx.clientJavascript,
|
|
104
108
|
dev: ctx.dev ?? false,
|
|
105
109
|
slowPhaseMs: ctx.config.dev?.slowPhaseMs ?? 200,
|
|
110
|
+
slowRequestMs: ctx.config.slowRequestMs ?? 3000,
|
|
106
111
|
cookieSecrets,
|
|
107
112
|
};
|
|
108
113
|
|
|
@@ -116,6 +121,56 @@ function generateConfigModule(ctx: PluginContext): string {
|
|
|
116
121
|
].join('\n');
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Detect the user's instrumentation.ts file at the project root.
|
|
126
|
+
*
|
|
127
|
+
* Checks for instrumentation.ts, .js, and .mjs — matching the same
|
|
128
|
+
* extensions as timber.config.ts detection.
|
|
129
|
+
*/
|
|
130
|
+
function detectInstrumentationFile(root: string): string | null {
|
|
131
|
+
const extensions = ['.ts', '.js', '.mjs'];
|
|
132
|
+
for (const ext of extensions) {
|
|
133
|
+
const candidate = resolve(root, `instrumentation${ext}`);
|
|
134
|
+
if (existsSync(candidate)) return candidate;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate the virtual:timber-instrumentation module source.
|
|
141
|
+
*
|
|
142
|
+
* When the user's instrumentation.ts exists, generates a module that
|
|
143
|
+
* dynamically imports it. When it doesn't exist, generates a module
|
|
144
|
+
* that returns null. The RSC entry calls this loader at startup time.
|
|
145
|
+
*
|
|
146
|
+
* See design/17-logging.md §"instrumentation.ts — The Entry Point"
|
|
147
|
+
*/
|
|
148
|
+
export function generateInstrumentationModule(instrumentationPath: string | null): string {
|
|
149
|
+
if (instrumentationPath) {
|
|
150
|
+
// Use the absolute path so the bundler can resolve and include it.
|
|
151
|
+
// The dynamic import ensures the file is loaded lazily and errors
|
|
152
|
+
// in the user's code don't prevent the module from being parsed.
|
|
153
|
+
return [
|
|
154
|
+
'// Auto-generated instrumentation loader — do not edit.',
|
|
155
|
+
'// Generated by timber-entries plugin.',
|
|
156
|
+
'',
|
|
157
|
+
`export default async function loadUserInstrumentation() {`,
|
|
158
|
+
` return import(${JSON.stringify(instrumentationPath)});`,
|
|
159
|
+
`}`,
|
|
160
|
+
].join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [
|
|
164
|
+
'// Auto-generated instrumentation loader — do not edit.',
|
|
165
|
+
'// Generated by timber-entries plugin.',
|
|
166
|
+
'// No instrumentation.ts found at project root.',
|
|
167
|
+
'',
|
|
168
|
+
`export default async function loadUserInstrumentation() {`,
|
|
169
|
+
` return null;`,
|
|
170
|
+
`}`,
|
|
171
|
+
].join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
119
174
|
/**
|
|
120
175
|
* Create the timber-entries Vite plugin.
|
|
121
176
|
*
|
|
@@ -153,19 +208,28 @@ export function timberEntries(ctx: PluginContext): Plugin {
|
|
|
153
208
|
return RESOLVED_CONFIG_ID;
|
|
154
209
|
}
|
|
155
210
|
|
|
211
|
+
// Check instrumentation virtual module
|
|
212
|
+
if (cleanId === VIRTUAL_IDS.instrumentation) {
|
|
213
|
+
return RESOLVED_INSTRUMENTATION_ID;
|
|
214
|
+
}
|
|
215
|
+
|
|
156
216
|
return null;
|
|
157
217
|
},
|
|
158
218
|
|
|
159
219
|
/**
|
|
160
|
-
* Load
|
|
220
|
+
* Load virtual modules: virtual:timber-config and virtual:timber-instrumentation.
|
|
161
221
|
*
|
|
162
222
|
* Entry files (rsc/ssr/browser) are real TypeScript files that Vite
|
|
163
|
-
* processes normally. Only
|
|
223
|
+
* processes normally. Only config and instrumentation need generated code.
|
|
164
224
|
*/
|
|
165
225
|
load(id: string) {
|
|
166
226
|
if (id === RESOLVED_CONFIG_ID) {
|
|
167
227
|
return generateConfigModule(ctx);
|
|
168
228
|
}
|
|
229
|
+
if (id === RESOLVED_INSTRUMENTATION_ID) {
|
|
230
|
+
const instrumentationPath = detectInstrumentationFile(ctx.root);
|
|
231
|
+
return generateInstrumentationModule(instrumentationPath);
|
|
232
|
+
}
|
|
169
233
|
return null;
|
|
170
234
|
},
|
|
171
235
|
|
package/src/routing/scanner.ts
CHANGED
|
@@ -19,10 +19,7 @@ import type {
|
|
|
19
19
|
InterceptionMarker,
|
|
20
20
|
} from './types.js';
|
|
21
21
|
import { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
|
|
22
|
-
import {
|
|
23
|
-
classifyMetadataRoute,
|
|
24
|
-
isDynamicMetadataExtension,
|
|
25
|
-
} from '#/server/metadata-routes.js';
|
|
22
|
+
import { classifyMetadataRoute, isDynamicMetadataExtension } from '#/server/metadata-routes.js';
|
|
26
23
|
|
|
27
24
|
/**
|
|
28
25
|
* Pattern matching encoded path delimiters that must be rejected during route discovery.
|
|
@@ -114,3 +114,13 @@ export type EarlyHintsSenderFn = (links: string[]) => void;
|
|
|
114
114
|
|
|
115
115
|
/** @internal — import via early-hints-sender.ts public API */
|
|
116
116
|
export const earlyHintsSenderAls = new AsyncLocalStorage<EarlyHintsSenderFn>();
|
|
117
|
+
|
|
118
|
+
// ─── waitUntil Bridge ────────────────────────────────────────────────────
|
|
119
|
+
// Used by: waituntil-bridge.ts (waitUntil())
|
|
120
|
+
// Design doc: design/11-platform.md §"waitUntil()"
|
|
121
|
+
|
|
122
|
+
/** Function that extends the request lifecycle with a background promise. */
|
|
123
|
+
export type WaitUntilFn = (promise: Promise<unknown>) => void;
|
|
124
|
+
|
|
125
|
+
/** @internal — import via waituntil-bridge.ts public API */
|
|
126
|
+
export const waitUntilAls = new AsyncLocalStorage<WaitUntilFn>();
|
package/src/server/compress.ts
CHANGED
|
@@ -153,4 +153,3 @@ function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint
|
|
|
153
153
|
// than ReadableStream's Uint8Array, but Uint8Array is a valid BufferSource.
|
|
154
154
|
return body.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
|
|
155
155
|
}
|
|
156
|
-
|
|
@@ -218,7 +218,10 @@ export function renderAppLinks(
|
|
|
218
218
|
/**
|
|
219
219
|
* Render Apple iTunes smart banner meta tag.
|
|
220
220
|
*/
|
|
221
|
-
export function renderItunes(
|
|
221
|
+
export function renderItunes(
|
|
222
|
+
itunes: NonNullable<Metadata['itunes']>,
|
|
223
|
+
elements: HeadElement[]
|
|
224
|
+
): void {
|
|
222
225
|
const parts = [`app-id=${itunes.appId}`];
|
|
223
226
|
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
224
227
|
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
@@ -15,7 +15,10 @@ import type { HeadElement } from './metadata.js';
|
|
|
15
15
|
* Handles og:title, og:description, og:image (with dimensions/alt),
|
|
16
16
|
* og:video, og:audio, og:article:author, and other OG properties.
|
|
17
17
|
*/
|
|
18
|
-
export function renderOpenGraph(
|
|
18
|
+
export function renderOpenGraph(
|
|
19
|
+
og: NonNullable<Metadata['openGraph']>,
|
|
20
|
+
elements: HeadElement[]
|
|
21
|
+
): void {
|
|
19
22
|
const simpleProps: Array<[string, string | undefined]> = [
|
|
20
23
|
['og:title', og.title],
|
|
21
24
|
['og:description', og.description],
|
package/src/server/pipeline.ts
CHANGED
|
@@ -14,11 +14,7 @@
|
|
|
14
14
|
import { canonicalize } from './canonicalize.js';
|
|
15
15
|
import { runProxy, type ProxyExport } from './proxy.js';
|
|
16
16
|
import { runMiddleware, type MiddlewareFn } from './middleware-runner.js';
|
|
17
|
-
import {
|
|
18
|
-
runWithTimingCollector,
|
|
19
|
-
withTiming,
|
|
20
|
-
getServerTimingHeader,
|
|
21
|
-
} from './server-timing.js';
|
|
17
|
+
import { runWithTimingCollector, withTiming, getServerTimingHeader } from './server-timing.js';
|
|
22
18
|
import {
|
|
23
19
|
runWithRequestContext,
|
|
24
20
|
applyRequestHeaderOverlay,
|
|
@@ -258,9 +254,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
258
254
|
return response;
|
|
259
255
|
};
|
|
260
256
|
|
|
261
|
-
return enableServerTiming
|
|
262
|
-
? runWithTimingCollector(runRequest)
|
|
263
|
-
: runRequest();
|
|
257
|
+
return enableServerTiming ? runWithTimingCollector(runRequest) : runRequest();
|
|
264
258
|
});
|
|
265
259
|
});
|
|
266
260
|
};
|
|
@@ -276,12 +270,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
276
270
|
} else {
|
|
277
271
|
proxyExport = config.proxy!;
|
|
278
272
|
}
|
|
279
|
-
const proxyFn = () =>
|
|
280
|
-
runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
273
|
+
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
281
274
|
return await withSpan('timber.proxy', {}, () =>
|
|
282
|
-
enableServerTiming
|
|
283
|
-
? withTiming('proxy', 'proxy.ts', proxyFn)
|
|
284
|
-
: proxyFn()
|
|
275
|
+
enableServerTiming ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
|
|
285
276
|
);
|
|
286
277
|
} catch (error) {
|
|
287
278
|
// Uncaught proxy.ts error → bare HTTP 500
|
|
@@ -430,9 +421,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
430
421
|
setMutableCookieContext(true);
|
|
431
422
|
const middlewareFn = () => runMiddleware(match.middleware!, ctx);
|
|
432
423
|
const middlewareResponse = await withSpan('timber.middleware', {}, () =>
|
|
433
|
-
enableServerTiming
|
|
434
|
-
? withTiming('mw', 'middleware.ts', middlewareFn)
|
|
435
|
-
: middlewareFn()
|
|
424
|
+
enableServerTiming ? withTiming('mw', 'middleware.ts', middlewareFn) : middlewareFn()
|
|
436
425
|
);
|
|
437
426
|
setMutableCookieContext(false);
|
|
438
427
|
if (middlewareResponse) {
|
|
@@ -487,9 +476,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
487
476
|
const renderFn = () =>
|
|
488
477
|
render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
489
478
|
const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
|
|
490
|
-
enableServerTiming
|
|
491
|
-
? withTiming('render', 'RSC + SSR render', renderFn)
|
|
492
|
-
: renderFn()
|
|
479
|
+
enableServerTiming ? withTiming('render', 'RSC + SSR render', renderFn) : renderFn()
|
|
493
480
|
);
|
|
494
481
|
markResponseFlushed();
|
|
495
482
|
return response;
|
|
@@ -542,8 +529,6 @@ async function fireOnRequestError(
|
|
|
542
529
|
);
|
|
543
530
|
}
|
|
544
531
|
|
|
545
|
-
|
|
546
|
-
|
|
547
532
|
// ─── Cookie Helpers ──────────────────────────────────────────────────────
|
|
548
533
|
|
|
549
534
|
/**
|
|
@@ -585,5 +570,3 @@ function ensureMutableResponse(response: Response): Response {
|
|
|
585
570
|
});
|
|
586
571
|
}
|
|
587
572
|
}
|
|
588
|
-
|
|
589
|
-
|
package/src/server/primitives.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// use to control request flow. See design/10-error-handling.md.
|
|
5
5
|
|
|
6
6
|
import type { JsonSerializable } from './types.js';
|
|
7
|
+
import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
|
|
7
8
|
|
|
8
9
|
// ─── Dev-mode validation ────────────────────────────────────────────────────
|
|
9
10
|
|
|
@@ -71,10 +72,7 @@ export function findNonSerializable(value: unknown, path = 'data'): string | nul
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
74
|
-
const result = findNonSerializable(
|
|
75
|
-
(value as Record<string, unknown>)[key],
|
|
76
|
-
`${path}.${key}`
|
|
77
|
-
);
|
|
75
|
+
const result = findNonSerializable((value as Record<string, unknown>)[key], `${path}.${key}`);
|
|
78
76
|
if (result) return result;
|
|
79
77
|
}
|
|
80
78
|
return null;
|
|
@@ -338,14 +336,26 @@ let _waitUntilWarned = false;
|
|
|
338
336
|
* Register a promise to be kept alive after the response is sent.
|
|
339
337
|
* Maps to `ctx.waitUntil()` on Cloudflare Workers and similar platforms.
|
|
340
338
|
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
339
|
+
* In production, the platform adapter installs a per-request waitUntil
|
|
340
|
+
* function via ALS (see waituntil-bridge.ts). This function checks the
|
|
341
|
+
* ALS bridge first, then falls back to the legacy adapter argument.
|
|
342
|
+
*
|
|
343
|
+
* If neither is available, a warning is logged once and the promise is
|
|
344
|
+
* left to resolve (or reject) without being tracked.
|
|
343
345
|
*
|
|
344
346
|
* @param promise - The background work to keep alive.
|
|
345
|
-
* @param adapter -
|
|
347
|
+
* @param adapter - Optional legacy adapter (prefer ALS bridge in production).
|
|
346
348
|
*/
|
|
347
|
-
export function waitUntil(promise: Promise<unknown>, adapter
|
|
348
|
-
|
|
349
|
+
export function waitUntil(promise: Promise<unknown>, adapter?: WaitUntilAdapter): void {
|
|
350
|
+
// Check ALS bridge first (installed by generated entry points)
|
|
351
|
+
const alsFn = _getWaitUntil();
|
|
352
|
+
if (alsFn) {
|
|
353
|
+
alsFn(promise);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Fall back to legacy adapter argument
|
|
358
|
+
if (adapter && typeof adapter.waitUntil === 'function') {
|
|
349
359
|
adapter.waitUntil(promise);
|
|
350
360
|
return;
|
|
351
361
|
}
|
|
@@ -12,11 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
14
14
|
import type { Routes } from '#/index.js';
|
|
15
|
-
import {
|
|
16
|
-
requestContextAls,
|
|
17
|
-
type RequestContextStore,
|
|
18
|
-
type CookieEntry,
|
|
19
|
-
} from './als-registry.js';
|
|
15
|
+
import { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';
|
|
20
16
|
|
|
21
17
|
// Re-export the ALS for framework-internal consumers that need direct access.
|
|
22
18
|
export { requestContextAls };
|
|
@@ -21,6 +21,8 @@ import routeManifest from 'virtual:timber-route-manifest';
|
|
|
21
21
|
import config from 'virtual:timber-config';
|
|
22
22
|
// @ts-expect-error — virtual module provided by timber-build-manifest plugin
|
|
23
23
|
import buildManifest from 'virtual:timber-build-manifest';
|
|
24
|
+
// @ts-expect-error — virtual module provided by timber-entries plugin
|
|
25
|
+
import loadUserInstrumentation from 'virtual:timber-instrumentation';
|
|
24
26
|
|
|
25
27
|
import type { FormRerender } from '#/server/action-handler.js';
|
|
26
28
|
import { handleActionRequest, isActionRequest } from '#/server/action-handler.js';
|
|
@@ -51,6 +53,7 @@ import { createMetadataRouteMatcher, createRouteMatcher } from '#/server/route-m
|
|
|
51
53
|
import { initDevTracing } from '#/server/tracing.js';
|
|
52
54
|
|
|
53
55
|
import { renderFallbackError as renderFallback } from '#/server/fallback-error.js';
|
|
56
|
+
import { loadInstrumentation } from '#/server/instrumentation.js';
|
|
54
57
|
import { handleApiRoute } from './api-handler.js';
|
|
55
58
|
import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
|
|
56
59
|
import {
|
|
@@ -86,6 +89,12 @@ export function setDevPipelineErrorHandler(handler: (error: Error, phase: string
|
|
|
86
89
|
* 103 Early Hints → middleware.ts → render (RSC → SSR → HTML).
|
|
87
90
|
*/
|
|
88
91
|
async function createRequestHandler(manifest: typeof routeManifest, runtimeConfig: typeof config) {
|
|
92
|
+
// Load the user's instrumentation.ts — register() is awaited before the
|
|
93
|
+
// server accepts any requests. The logger and onRequestError hooks are
|
|
94
|
+
// wired into the framework. This runs once at startup.
|
|
95
|
+
// See design/17-logging.md §"register() — Server Startup"
|
|
96
|
+
await loadInstrumentation(loadUserInstrumentation);
|
|
97
|
+
|
|
89
98
|
// Initialize cookie signing secrets from config (design/29-cookies.md §"Signed Cookies")
|
|
90
99
|
const cookieSecrets = (runtimeConfig as Record<string, unknown>).cookieSecrets as
|
|
91
100
|
| string[]
|
|
@@ -174,6 +183,9 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
174
183
|
return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
|
|
175
184
|
},
|
|
176
185
|
interceptionRewrites: manifest.interceptionRewrites,
|
|
186
|
+
// Slow request threshold from timber.config.ts. Default 3000ms, 0 to disable.
|
|
187
|
+
// See design/17-logging.md §"slowRequestMs"
|
|
188
|
+
slowRequestMs: (runtimeConfig as Record<string, unknown>).slowRequestMs as number | undefined,
|
|
177
189
|
enableServerTiming: isDev,
|
|
178
190
|
onPipelineError: isDev
|
|
179
191
|
? (error: Error, phase: string) => {
|
|
@@ -446,4 +458,8 @@ async function renderRoute(
|
|
|
446
458
|
// the handler with per-request 103 Early Hints sender via ALS.
|
|
447
459
|
export { runWithEarlyHintsSender } from '#/server/early-hints-sender.js';
|
|
448
460
|
|
|
461
|
+
// Re-export for generated entry points to wrap the handler with per-request
|
|
462
|
+
// waitUntil support via ALS. See design/11-platform.md §"waitUntil()".
|
|
463
|
+
export { runWithWaitUntil } from '#/server/waituntil-bridge.js';
|
|
464
|
+
|
|
449
465
|
export default await createRequestHandler(routeManifest, config);
|
|
@@ -46,10 +46,7 @@ export interface RscStreamResult {
|
|
|
46
46
|
* fires onError during stream consumption. Signals are captured in the
|
|
47
47
|
* returned `signals` object for the caller to handle.
|
|
48
48
|
*/
|
|
49
|
-
export function renderRscStream(
|
|
50
|
-
element: React.ReactElement,
|
|
51
|
-
req: Request
|
|
52
|
-
): RscStreamResult {
|
|
49
|
+
export function renderRscStream(element: React.ReactElement, req: Request): RscStreamResult {
|
|
53
50
|
const signals: RenderSignals = {
|
|
54
51
|
denySignal: null,
|
|
55
52
|
redirectSignal: null,
|
|
@@ -121,9 +121,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
121
121
|
// promotion if the denial was already handled by a TimberErrorBoundary
|
|
122
122
|
// (e.g., slot error boundary). The boundary sets navContext._denyHandledByBoundary
|
|
123
123
|
// during SSR rendering. See LOCAL-298.
|
|
124
|
-
function checkCapturedSignals(
|
|
125
|
-
skipHandledDeny = false
|
|
126
|
-
): Response | Promise<Response> | null {
|
|
124
|
+
function checkCapturedSignals(skipHandledDeny = false): Response | Promise<Response> | null {
|
|
127
125
|
if (signals.redirectSignal) {
|
|
128
126
|
return buildRedirectResponse(req, signals.redirectSignal, responseHeaders);
|
|
129
127
|
}
|
|
@@ -42,8 +42,7 @@ const DETECTION_RULES: Array<{
|
|
|
42
42
|
pattern: /RegExp/i,
|
|
43
43
|
info: {
|
|
44
44
|
type: 'RegExp',
|
|
45
|
-
suggestion:
|
|
46
|
-
'Use .toString() to serialize, and new RegExp() to reconstruct on the client.',
|
|
45
|
+
suggestion: 'Use .toString() to serialize, and new RegExp() to reconstruct on the client.',
|
|
47
46
|
},
|
|
48
47
|
},
|
|
49
48
|
{
|
|
@@ -58,24 +57,21 @@ const DETECTION_RULES: Array<{
|
|
|
58
57
|
pattern: /URLSearchParams/,
|
|
59
58
|
info: {
|
|
60
59
|
type: 'URLSearchParams',
|
|
61
|
-
suggestion:
|
|
62
|
-
'Pass .toString() to serialize, or spread entries: Object.fromEntries(params).',
|
|
60
|
+
suggestion: 'Pass .toString() to serialize, or spread entries: Object.fromEntries(params).',
|
|
63
61
|
},
|
|
64
62
|
},
|
|
65
63
|
{
|
|
66
64
|
pattern: /Headers/,
|
|
67
65
|
info: {
|
|
68
66
|
type: 'Headers',
|
|
69
|
-
suggestion:
|
|
70
|
-
'Convert to a plain object: Object.fromEntries(headers.entries()).',
|
|
67
|
+
suggestion: 'Convert to a plain object: Object.fromEntries(headers.entries()).',
|
|
71
68
|
},
|
|
72
69
|
},
|
|
73
70
|
{
|
|
74
71
|
pattern: /Symbol/i,
|
|
75
72
|
info: {
|
|
76
73
|
type: 'Symbol',
|
|
77
|
-
suggestion:
|
|
78
|
-
'Symbols cannot be serialized. Use a string identifier instead.',
|
|
74
|
+
suggestion: 'Symbols cannot be serialized. Use a string identifier instead.',
|
|
79
75
|
},
|
|
80
76
|
},
|
|
81
77
|
{
|
|
@@ -91,8 +87,7 @@ const DETECTION_RULES: Array<{
|
|
|
91
87
|
pattern: /Classes or null prototypes/i,
|
|
92
88
|
info: {
|
|
93
89
|
type: 'class instance',
|
|
94
|
-
suggestion:
|
|
95
|
-
'Spread to a plain object: { ...instance } or extract the needed properties.',
|
|
90
|
+
suggestion: 'Spread to a plain object: { ...instance } or extract the needed properties.',
|
|
96
91
|
},
|
|
97
92
|
},
|
|
98
93
|
{
|
|
@@ -116,9 +111,7 @@ const DETECTION_RULES: Array<{
|
|
|
116
111
|
* Returns type info with an actionable fix, or null if the error
|
|
117
112
|
* is not related to RSC prop serialization.
|
|
118
113
|
*/
|
|
119
|
-
export function detectNonSerializableType(
|
|
120
|
-
errorMessage: string
|
|
121
|
-
): NonSerializableTypeInfo | null {
|
|
114
|
+
export function detectNonSerializableType(errorMessage: string): NonSerializableTypeInfo | null {
|
|
122
115
|
if (!errorMessage) return null;
|
|
123
116
|
|
|
124
117
|
for (const rule of DETECTION_RULES) {
|
|
@@ -171,10 +164,7 @@ export function formatRscPropWarning(
|
|
|
171
164
|
* @param requestPath - The request pathname for context
|
|
172
165
|
* @returns true if a warning was emitted
|
|
173
166
|
*/
|
|
174
|
-
export function checkAndWarnRscPropError(
|
|
175
|
-
error: unknown,
|
|
176
|
-
requestPath: string
|
|
177
|
-
): boolean {
|
|
167
|
+
export function checkAndWarnRscPropError(error: unknown, requestPath: string): boolean {
|
|
178
168
|
if (process.env.NODE_ENV === 'production') return false;
|
|
179
169
|
if (!(error instanceof Error)) return false;
|
|
180
170
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request waitUntil bridge — ALS bridge for platform adapters.
|
|
3
|
+
*
|
|
4
|
+
* The generated entry point (Nitro, Cloudflare) wraps the handler with
|
|
5
|
+
* `runWithWaitUntil`, binding the platform's lifecycle extension function
|
|
6
|
+
* (e.g., h3's `event.waitUntil()` or CF's `ctx.waitUntil()`) for the
|
|
7
|
+
* request duration. The `waitUntil()` primitive reads from this ALS to
|
|
8
|
+
* dispatch background work to the correct platform API.
|
|
9
|
+
*
|
|
10
|
+
* Design doc: design/11-platform.md §"waitUntil()"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { waitUntilAls } from './als-registry.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run a function with a per-request waitUntil handler installed.
|
|
17
|
+
*
|
|
18
|
+
* Called by generated entry points (Nitro node-server/bun, Cloudflare)
|
|
19
|
+
* to bind the platform's lifecycle extension for the request duration.
|
|
20
|
+
*/
|
|
21
|
+
export function runWithWaitUntil<T>(waitUntilFn: (promise: Promise<unknown>) => void, fn: () => T): T {
|
|
22
|
+
return waitUntilAls.run(waitUntilFn, fn);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the current request's waitUntil function, if available.
|
|
27
|
+
*
|
|
28
|
+
* Returns undefined when no platform adapter has installed a waitUntil
|
|
29
|
+
* handler for the current request (e.g., on platforms that don't support
|
|
30
|
+
* lifecycle extension, or outside a request context).
|
|
31
|
+
*/
|
|
32
|
+
export function getWaitUntil(): ((promise: Promise<unknown>) => void) | undefined {
|
|
33
|
+
return waitUntilAls.getStore();
|
|
34
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"request-context-C69VW4xS.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 {\n requestContextAls,\n type RequestContextStore,\n type CookieEntry,\n} 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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"}
|