@timber-js/app 0.2.0-alpha.34 → 0.2.0-alpha.35
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-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
- package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
- package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
- package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
- package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
- package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
- package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
- package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
- package/dist/_chunks/wrappers-C1SN725w.js +331 -0
- package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +193 -90
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +8 -8
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +23 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +2 -2
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +33 -12
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -81
- package/dist/index.d.ts +87 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +346 -210
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +76 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +104 -0
- package/dist/params/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +32 -0
- package/dist/plugins/client-chunks.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +6 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +153 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +4 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +78 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +29 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1819 -1629
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +28 -40
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +2 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +12 -8
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/package.json +12 -8
- package/src/client/browser-entry.ts +55 -13
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/index.ts +9 -1
- package/src/client/link.tsx +9 -9
- package/src/client/navigation-context.ts +2 -2
- package/src/client/router.ts +102 -55
- package/src/client/rsc-fetch.ts +63 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/stale-reload.ts +28 -0
- package/src/client/top-loader.tsx +2 -2
- package/src/client/use-params.ts +3 -3
- package/src/client/use-query-states.ts +1 -1
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +69 -18
- package/src/index.ts +255 -65
- package/src/params/define.ts +260 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +6 -0
- package/src/plugins/build-manifest.ts +11 -0
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/entries.ts +3 -6
- package/src/plugins/routing.ts +40 -14
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +1 -1
- package/src/plugins/static-build.ts +8 -4
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +55 -6
- package/src/routing/status-file-lint.ts +2 -1
- package/src/routing/types.ts +7 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +504 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +38 -8
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +16 -0
- package/src/server/als-registry.ts +4 -4
- package/src/server/build-manifest.ts +4 -4
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +57 -14
- package/src/server/flight-injection-state.ts +152 -0
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +42 -26
- package/src/server/index.ts +2 -4
- package/src/server/node-stream-transforms.ts +68 -41
- package/src/server/pipeline.ts +98 -26
- package/src/server/request-context.ts +49 -124
- package/src/server/route-element-builder.ts +102 -99
- package/src/server/route-matcher.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +3 -2
- package/src/server/rsc-entry/index.ts +26 -11
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +4 -4
- package/src/server/slot-resolver.ts +204 -206
- package/src/server/ssr-entry.ts +3 -1
- package/src/server/ssr-render.ts +3 -0
- package/src/server/tree-builder.ts +84 -48
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +1 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/create.ts +0 -321
- package/src/server/prerender.ts +0 -139
package/src/server/pipeline.ts
CHANGED
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
} from './logger.js';
|
|
43
43
|
import { callOnRequestError } from './instrumentation.js';
|
|
44
44
|
import { RedirectSignal, DenySignal } from './primitives.js';
|
|
45
|
+
import { ParamCoercionError } from './route-element-builder.js';
|
|
46
|
+
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
45
47
|
import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
|
|
46
48
|
import { findInterceptionMatch } from './pipeline-interception.js';
|
|
47
49
|
import type { MiddlewareContext } from './types.js';
|
|
@@ -152,6 +154,42 @@ export interface PipelineConfig {
|
|
|
152
154
|
) => Response | Promise<Response>;
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// ─── Param Coercion ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run segment param coercion on the matched route's segments.
|
|
161
|
+
*
|
|
162
|
+
* Loads params.ts modules from segments that have them, extracts the
|
|
163
|
+
* segmentParams definition, and coerces raw string params through codecs.
|
|
164
|
+
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
165
|
+
*
|
|
166
|
+
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
167
|
+
* See design/07-routing.md §"Where Coercion Runs"
|
|
168
|
+
*/
|
|
169
|
+
async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
170
|
+
const segments = match.segments as unknown as import('./route-matcher.js').ManifestSegmentNode[];
|
|
171
|
+
|
|
172
|
+
for (const segment of segments) {
|
|
173
|
+
// Only process segments that have a params.ts convention file
|
|
174
|
+
if (!segment.params) continue;
|
|
175
|
+
|
|
176
|
+
const mod = (await segment.params.load()) as Record<string, unknown>;
|
|
177
|
+
const segmentParamsDef = mod.segmentParams as
|
|
178
|
+
| { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
|
|
179
|
+
| undefined;
|
|
180
|
+
|
|
181
|
+
if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const coerced = segmentParamsDef.parse(match.params);
|
|
185
|
+
// Merge coerced values back into match.params
|
|
186
|
+
Object.assign(match.params, coerced);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
// ─── Pipeline ──────────────────────────────────────────────────────────────
|
|
156
194
|
|
|
157
195
|
/**
|
|
@@ -286,6 +324,24 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
286
324
|
}
|
|
287
325
|
}
|
|
288
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Build a redirect Response from a RedirectSignal.
|
|
329
|
+
*
|
|
330
|
+
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
331
|
+
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
332
|
+
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
333
|
+
* createFromFetch. See design/19-client-navigation.md.
|
|
334
|
+
*/
|
|
335
|
+
function buildRedirectResponse(signal: RedirectSignal, req: Request, headers: Headers): Response {
|
|
336
|
+
const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
337
|
+
if (isRsc) {
|
|
338
|
+
headers.set('X-Timber-Redirect', signal.location);
|
|
339
|
+
return new Response(null, { status: 204, headers });
|
|
340
|
+
}
|
|
341
|
+
headers.set('Location', signal.location);
|
|
342
|
+
return new Response(null, { status: signal.status, headers });
|
|
343
|
+
}
|
|
344
|
+
|
|
289
345
|
async function handleRequest(req: Request, method: string, path: string): Promise<Response> {
|
|
290
346
|
// Stage 1: URL canonicalization
|
|
291
347
|
const url = new URL(req.url);
|
|
@@ -342,6 +398,21 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
342
398
|
}
|
|
343
399
|
}
|
|
344
400
|
|
|
401
|
+
// Stage 1c: Version skew detection (TIM-446).
|
|
402
|
+
// For RSC payload requests (client navigation), check if the client's
|
|
403
|
+
// deployment ID matches the current build. On mismatch, signal the
|
|
404
|
+
// client to do a full page reload instead of returning an RSC payload
|
|
405
|
+
// that references mismatched module IDs.
|
|
406
|
+
const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
407
|
+
if (isRscRequest) {
|
|
408
|
+
const skewCheck = checkVersionSkew(req);
|
|
409
|
+
if (!skewCheck.ok) {
|
|
410
|
+
const reloadHeaders = new Headers();
|
|
411
|
+
applyReloadHeaders(reloadHeaders);
|
|
412
|
+
return new Response(null, { status: 204, headers: reloadHeaders });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
345
416
|
// Stage 2: Route matching
|
|
346
417
|
let match = matchRoute(canonicalPathname);
|
|
347
418
|
let interception: InterceptionContext | undefined;
|
|
@@ -400,18 +471,37 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
400
471
|
}
|
|
401
472
|
}
|
|
402
473
|
|
|
474
|
+
// Stage 2c: Param coercion (before middleware)
|
|
475
|
+
// Load params.ts modules from matched segments and coerce raw string
|
|
476
|
+
// params through defineSegmentParams codecs. Coercion failure → 404
|
|
477
|
+
// (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
|
|
478
|
+
try {
|
|
479
|
+
await coerceSegmentParams(match);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof ParamCoercionError) {
|
|
482
|
+
return new Response(null, { status: 404 });
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
|
|
403
487
|
// Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
|
|
404
488
|
if (match.middleware) {
|
|
405
489
|
const ctx: MiddlewareContext = {
|
|
406
490
|
req,
|
|
407
491
|
requestHeaders: requestHeaderOverlay,
|
|
408
492
|
headers: responseHeaders,
|
|
409
|
-
|
|
410
|
-
searchParams: new URL(req.url).searchParams,
|
|
493
|
+
segmentParams: match.params,
|
|
411
494
|
earlyHints: (hints) => {
|
|
412
495
|
for (const hint of hints) {
|
|
413
|
-
|
|
414
|
-
|
|
496
|
+
// Match Cloudflare's cached Early Hints attribute order: `as` before `rel`.
|
|
497
|
+
// Cloudflare caches Link headers and re-emits them on subsequent 200s.
|
|
498
|
+
// If our order differs, the browser sees duplicate preloads and warns.
|
|
499
|
+
let value: string;
|
|
500
|
+
if (hint.as !== undefined) {
|
|
501
|
+
value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
502
|
+
} else {
|
|
503
|
+
value = `<${hint.href}>; rel=${hint.rel}`;
|
|
504
|
+
}
|
|
415
505
|
if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
|
|
416
506
|
if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
417
507
|
responseHeaders.append('Link', value);
|
|
@@ -443,20 +533,10 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
443
533
|
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
444
534
|
} catch (error) {
|
|
445
535
|
setMutableCookieContext(false);
|
|
446
|
-
// RedirectSignal from middleware → HTTP redirect (not an error)
|
|
447
|
-
// For RSC payload requests (client navigation), return 204 + X-Timber-Redirect
|
|
448
|
-
// so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
449
|
-
// turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
450
|
-
// createFromFetch. See design/19-client-navigation.md.
|
|
536
|
+
// RedirectSignal from middleware → HTTP redirect (not an error)
|
|
451
537
|
if (error instanceof RedirectSignal) {
|
|
452
538
|
applyCookieJar(responseHeaders);
|
|
453
|
-
|
|
454
|
-
if (isRsc) {
|
|
455
|
-
responseHeaders.set('X-Timber-Redirect', error.location);
|
|
456
|
-
return new Response(null, { status: 204, headers: responseHeaders });
|
|
457
|
-
}
|
|
458
|
-
responseHeaders.set('Location', error.location);
|
|
459
|
-
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
539
|
+
return buildRedirectResponse(error, req, responseHeaders);
|
|
460
540
|
}
|
|
461
541
|
// DenySignal from middleware → HTTP deny status
|
|
462
542
|
if (error instanceof DenySignal) {
|
|
@@ -493,17 +573,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
493
573
|
if (error instanceof DenySignal) {
|
|
494
574
|
return new Response(null, { status: error.status });
|
|
495
575
|
}
|
|
496
|
-
// RedirectSignal leaked from render — honour the redirect
|
|
497
|
-
// For RSC payload requests, return 204 + X-Timber-Redirect so the
|
|
498
|
-
// client router can perform a soft SPA redirect (same as middleware path).
|
|
576
|
+
// RedirectSignal leaked from render — honour the redirect
|
|
499
577
|
if (error instanceof RedirectSignal) {
|
|
500
|
-
|
|
501
|
-
if (isRsc) {
|
|
502
|
-
responseHeaders.set('X-Timber-Redirect', error.location);
|
|
503
|
-
return new Response(null, { status: 204, headers: responseHeaders });
|
|
504
|
-
}
|
|
505
|
-
responseHeaders.set('Location', error.location);
|
|
506
|
-
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
578
|
+
return buildRedirectResponse(error, req, responseHeaders);
|
|
507
579
|
}
|
|
508
580
|
logRenderError({ method, path, error });
|
|
509
581
|
await fireOnRequestError(error, req, 'render');
|
|
@@ -10,8 +10,6 @@
|
|
|
10
10
|
* See design/29-cookies.md for cookie mutation semantics.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
14
|
-
import type { Routes } from '#/index.js';
|
|
15
13
|
import { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';
|
|
16
14
|
import { isDebug } from './debug.js';
|
|
17
15
|
|
|
@@ -22,30 +20,6 @@ export { requestContextAls };
|
|
|
22
20
|
// the ALS context persists for the entire request lifecycle including
|
|
23
21
|
// async stream consumption by React's renderToReadableStream.
|
|
24
22
|
|
|
25
|
-
// ─── Cookie Signing Secrets ──────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Module-level cookie signing secrets. Index 0 is the newest (used for signing).
|
|
29
|
-
* All entries are tried for verification (key rotation support).
|
|
30
|
-
*
|
|
31
|
-
* Set by the framework at startup via `setCookieSecrets()`.
|
|
32
|
-
* See design/29-cookies.md §"Signed Cookies"
|
|
33
|
-
*/
|
|
34
|
-
let _cookieSecrets: string[] = [];
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Configure the cookie signing secrets.
|
|
38
|
-
*
|
|
39
|
-
* Called by the framework during server initialization with values from
|
|
40
|
-
* `cookies.secret` or `cookies.secrets` in timber.config.ts.
|
|
41
|
-
*
|
|
42
|
-
* The first secret (index 0) is used for signing new cookies.
|
|
43
|
-
* All secrets are tried for verification (supports key rotation).
|
|
44
|
-
*/
|
|
45
|
-
export function setCookieSecrets(secrets: string[]): void {
|
|
46
|
-
_cookieSecrets = secrets.filter(Boolean);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
23
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
50
24
|
|
|
51
25
|
/**
|
|
@@ -109,12 +83,6 @@ export function cookies(): RequestCookies {
|
|
|
109
83
|
return map.size;
|
|
110
84
|
},
|
|
111
85
|
|
|
112
|
-
getSigned(name: string): string | undefined {
|
|
113
|
-
const raw = map.get(name);
|
|
114
|
-
if (!raw || _cookieSecrets.length === 0) return undefined;
|
|
115
|
-
return verifySignedCookie(raw, _cookieSecrets);
|
|
116
|
-
},
|
|
117
|
-
|
|
118
86
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
119
87
|
assertMutable(store, 'set');
|
|
120
88
|
if (store.flushed) {
|
|
@@ -127,21 +95,10 @@ export function cookies(): RequestCookies {
|
|
|
127
95
|
}
|
|
128
96
|
return;
|
|
129
97
|
}
|
|
130
|
-
let storedValue = value;
|
|
131
|
-
if (options?.signed) {
|
|
132
|
-
if (_cookieSecrets.length === 0) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
`[timber] cookies().set('${name}', ..., { signed: true }) requires ` +
|
|
135
|
-
`cookies.secret or cookies.secrets in timber.config.ts.`
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
storedValue = signCookieValue(value, _cookieSecrets[0]);
|
|
139
|
-
}
|
|
140
98
|
const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };
|
|
141
|
-
store.cookieJar.set(name, { name, value
|
|
142
|
-
// Read-your-own-writes: update the parsed cookies map
|
|
143
|
-
|
|
144
|
-
map.set(name, storedValue);
|
|
99
|
+
store.cookieJar.set(name, { name, value, options: opts });
|
|
100
|
+
// Read-your-own-writes: update the parsed cookies map
|
|
101
|
+
map.set(name, value);
|
|
145
102
|
},
|
|
146
103
|
|
|
147
104
|
delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {
|
|
@@ -190,43 +147,37 @@ export function cookies(): RequestCookies {
|
|
|
190
147
|
}
|
|
191
148
|
|
|
192
149
|
/**
|
|
193
|
-
* Returns a Promise resolving to the current request's
|
|
150
|
+
* Returns a Promise resolving to the current request's raw URLSearchParams.
|
|
151
|
+
*
|
|
152
|
+
* For typed, parsed search params, import the definition from params.ts
|
|
153
|
+
* and call `.load()` or `.parse()`:
|
|
194
154
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
155
|
+
* ```ts
|
|
156
|
+
* import { searchParams } from './params'
|
|
157
|
+
* const parsed = await searchParams.load()
|
|
158
|
+
* ```
|
|
199
159
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
160
|
+
* Or explicitly:
|
|
161
|
+
*
|
|
162
|
+
* ```ts
|
|
163
|
+
* import { rawSearchParams } from '@timber-js/app/server'
|
|
164
|
+
* import { searchParams } from './params'
|
|
165
|
+
* const parsed = searchParams.parse(await rawSearchParams())
|
|
166
|
+
* ```
|
|
202
167
|
*
|
|
203
168
|
* Throws if called outside a request context.
|
|
204
169
|
*/
|
|
205
|
-
export function
|
|
206
|
-
export function searchParams(): Promise<URLSearchParams | Record<string, unknown>>;
|
|
207
|
-
export function searchParams(): Promise<URLSearchParams | Record<string, unknown>> {
|
|
170
|
+
export function rawSearchParams(): Promise<URLSearchParams> {
|
|
208
171
|
const store = requestContextAls.getStore();
|
|
209
172
|
if (!store) {
|
|
210
173
|
throw new Error(
|
|
211
|
-
'[timber]
|
|
174
|
+
'[timber] rawSearchParams() called outside of a request context. ' +
|
|
212
175
|
'It can only be used in middleware, access checks, server components, and server actions.'
|
|
213
176
|
);
|
|
214
177
|
}
|
|
215
178
|
return store.searchParamsPromise;
|
|
216
179
|
}
|
|
217
180
|
|
|
218
|
-
/**
|
|
219
|
-
* Replace the search params Promise for the current request with one that
|
|
220
|
-
* resolves to the typed parsed result from the route's search-params.ts.
|
|
221
|
-
* Called by the framework before rendering the page — not for app code.
|
|
222
|
-
*/
|
|
223
|
-
export function setParsedSearchParams(parsed: Record<string, unknown>): void {
|
|
224
|
-
const store = requestContextAls.getStore();
|
|
225
|
-
if (store) {
|
|
226
|
-
store.searchParamsPromise = Promise.resolve(parsed);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
181
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
231
182
|
|
|
232
183
|
/**
|
|
@@ -257,12 +208,6 @@ export interface CookieOptions {
|
|
|
257
208
|
sameSite?: 'strict' | 'lax' | 'none';
|
|
258
209
|
/** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */
|
|
259
210
|
partitioned?: boolean;
|
|
260
|
-
/**
|
|
261
|
-
* Sign the cookie value with HMAC-SHA256 for integrity verification.
|
|
262
|
-
* Requires `cookies.secret` or `cookies.secrets` in timber.config.ts.
|
|
263
|
-
* See design/29-cookies.md §"Signed Cookies".
|
|
264
|
-
*/
|
|
265
|
-
signed?: boolean;
|
|
266
211
|
}
|
|
267
212
|
|
|
268
213
|
const DEFAULT_COOKIE_OPTIONS: CookieOptions = {
|
|
@@ -287,14 +232,6 @@ export interface RequestCookies {
|
|
|
287
232
|
getAll(): Array<{ name: string; value: string }>;
|
|
288
233
|
/** Number of cookies. */
|
|
289
234
|
readonly size: number;
|
|
290
|
-
/**
|
|
291
|
-
* Get a signed cookie value, verifying its HMAC-SHA256 signature.
|
|
292
|
-
* Returns undefined if the cookie is missing, the signature is invalid,
|
|
293
|
-
* or no secrets are configured. Never throws.
|
|
294
|
-
*
|
|
295
|
-
* See design/29-cookies.md §"Signed Cookies"
|
|
296
|
-
*/
|
|
297
|
-
getSigned(name: string): string | undefined;
|
|
298
235
|
/** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */
|
|
299
236
|
set(name: string, value: string, options?: CookieOptions): void;
|
|
300
237
|
/** Delete a cookie. Only available in mutable contexts. */
|
|
@@ -354,6 +291,35 @@ export function markResponseFlushed(): void {
|
|
|
354
291
|
}
|
|
355
292
|
}
|
|
356
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Build a Map of cookie name → value reflecting the current request's
|
|
296
|
+
* read-your-own-writes state. Includes incoming cookies plus any
|
|
297
|
+
* mutations from cookies().set() / cookies().delete() in the same request.
|
|
298
|
+
*
|
|
299
|
+
* Used by SSR renderers to populate NavContext.cookies so that
|
|
300
|
+
* useCookie()'s server snapshot matches the actual response state.
|
|
301
|
+
*
|
|
302
|
+
* See design/29-cookies.md §"Read-Your-Own-Writes"
|
|
303
|
+
* See design/triage/TIM-441-cookie-api-triage.md §4
|
|
304
|
+
*/
|
|
305
|
+
export function getCookiesForSsr(): Map<string, string> {
|
|
306
|
+
const store = requestContextAls.getStore();
|
|
307
|
+
if (!store) {
|
|
308
|
+
throw new Error('[timber] getCookiesForSsr() called outside of a request context.');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Trigger lazy parsing if not yet done
|
|
312
|
+
if (!store.parsedCookies) {
|
|
313
|
+
store.parsedCookies = parseCookieHeader(store.cookieHeader);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The parsedCookies map already reflects read-your-own-writes:
|
|
317
|
+
// - cookies().set() updates the map via map.set(name, value)
|
|
318
|
+
// - cookies().delete() removes from the map via map.delete(name)
|
|
319
|
+
// Return a copy so callers can't mutate the internal map.
|
|
320
|
+
return new Map(store.parsedCookies);
|
|
321
|
+
}
|
|
322
|
+
|
|
357
323
|
/**
|
|
358
324
|
* Collect all Set-Cookie headers from the cookie jar.
|
|
359
325
|
* Called by the framework at flush time to apply cookies to the response.
|
|
@@ -467,47 +433,6 @@ function parseCookieHeader(header: string): Map<string, string> {
|
|
|
467
433
|
return map;
|
|
468
434
|
}
|
|
469
435
|
|
|
470
|
-
// ─── Cookie Signing ──────────────────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Sign a cookie value with HMAC-SHA256.
|
|
474
|
-
* Returns `value.hex_signature`.
|
|
475
|
-
*/
|
|
476
|
-
function signCookieValue(value: string, secret: string): string {
|
|
477
|
-
const signature = createHmac('sha256', secret).update(value).digest('hex');
|
|
478
|
-
return `${value}.${signature}`;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Verify a signed cookie value against an array of secrets.
|
|
483
|
-
* Returns the original value if any secret produces a matching signature,
|
|
484
|
-
* or undefined if none match. Uses timing-safe comparison.
|
|
485
|
-
*
|
|
486
|
-
* The signed format is `value.hex_signature` — split at the last `.`.
|
|
487
|
-
*/
|
|
488
|
-
function verifySignedCookie(raw: string, secrets: string[]): string | undefined {
|
|
489
|
-
const lastDot = raw.lastIndexOf('.');
|
|
490
|
-
if (lastDot <= 0 || lastDot === raw.length - 1) return undefined;
|
|
491
|
-
|
|
492
|
-
const value = raw.slice(0, lastDot);
|
|
493
|
-
const signature = raw.slice(lastDot + 1);
|
|
494
|
-
|
|
495
|
-
// Hex-encoded SHA-256 is always 64 chars
|
|
496
|
-
if (signature.length !== 64) return undefined;
|
|
497
|
-
|
|
498
|
-
const signatureBuffer = Buffer.from(signature, 'hex');
|
|
499
|
-
// If the hex decode produced fewer bytes, the signature was not valid hex
|
|
500
|
-
if (signatureBuffer.length !== 32) return undefined;
|
|
501
|
-
|
|
502
|
-
for (const secret of secrets) {
|
|
503
|
-
const expected = createHmac('sha256', secret).update(value).digest();
|
|
504
|
-
if (timingSafeEqual(expected, signatureBuffer)) {
|
|
505
|
-
return value;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
return undefined;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
436
|
/** Serialize a CookieEntry into a Set-Cookie header value. */
|
|
512
437
|
function serializeCookieEntry(entry: CookieEntry): string {
|
|
513
438
|
const parts = [`${entry.name}=${entry.value}`];
|