@timber-js/app 0.2.0-alpha.133 → 0.2.0-alpha.135
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/{actions-BNhpxrnV.js → actions-CHFblsdT.js} +62 -19
- package/dist/_chunks/actions-CHFblsdT.js.map +1 -0
- package/dist/_chunks/{build-output-helper-K7oUCRqU.js → build-output-helper-CfBfOU9m.js} +1 -1
- package/dist/_chunks/{build-output-helper-K7oUCRqU.js.map → build-output-helper-CfBfOU9m.js.map} +1 -1
- package/dist/_chunks/cli-schema-sync-q-Ce6-iG.js.map +1 -1
- package/dist/_chunks/{codec-registry-DqkeAydt.js → codec-registry-G9hD5LOq.js} +1 -1
- package/dist/_chunks/{codec-registry-DqkeAydt.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
- package/dist/_chunks/{define-jeHDXZGq.js → define-DXWUJb-K.js} +3 -3
- package/dist/_chunks/{define-jeHDXZGq.js.map → define-DXWUJb-K.js.map} +1 -1
- package/dist/_chunks/{define-DMq-Yjp7.js → define-Dp-qu3ql.js} +3 -3
- package/dist/_chunks/{define-DMq-Yjp7.js.map → define-Dp-qu3ql.js.map} +1 -1
- package/dist/_chunks/fast-hash-DXZ7S2D5.js +36 -0
- package/dist/_chunks/fast-hash-DXZ7S2D5.js.map +1 -0
- package/dist/_chunks/{format-BjN9HLu9.js → format-CR6v1pWE.js} +1 -1
- package/dist/_chunks/{format-BjN9HLu9.js.map → format-CR6v1pWE.js.map} +1 -1
- package/dist/_chunks/{handler-store-bIAstrxQ.js → handler-store-aQMpqPe7.js} +1 -1
- package/dist/_chunks/{handler-store-bIAstrxQ.js.map → handler-store-aQMpqPe7.js.map} +1 -1
- package/dist/_chunks/href-validation-2G-HZaQ8.js +124 -0
- package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
- package/dist/_chunks/logger-CMd9P1xQ.js.map +1 -1
- package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -1
- package/dist/_chunks/{navigation-root-B5-RmsPi.js → navigation-root-BQqFQI-n.js} +3 -3
- package/dist/_chunks/{navigation-root-B5-RmsPi.js.map → navigation-root-BQqFQI-n.js.map} +1 -1
- package/dist/_chunks/{registry-BI9cgCt7.js → registry-Dp_hJL4s.js} +1 -1
- package/dist/_chunks/{registry-BI9cgCt7.js.map → registry-Dp_hJL4s.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-DU2nbVOi.js → resolve-schema-D3CbcdCW.js} +2 -2
- package/dist/_chunks/{resolve-schema-DU2nbVOi.js.map → resolve-schema-D3CbcdCW.js.map} +1 -1
- package/dist/_chunks/{router-ref-B2-2kIIC.js → router-ref-B50j42zB.js} +2 -2
- package/dist/_chunks/{router-ref-B2-2kIIC.js.map → router-ref-B50j42zB.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-o0SwFtXW.js → schema-bridge-Cuy8BaYu.js} +2 -2
- package/dist/_chunks/{schema-bridge-o0SwFtXW.js.map → schema-bridge-Cuy8BaYu.js.map} +1 -1
- package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
- package/dist/_chunks/{ssr-data-DVX8S7vj.js → ssr-data-DxXyoRrz.js} +1 -1
- package/dist/_chunks/{ssr-data-DVX8S7vj.js.map → ssr-data-DxXyoRrz.js.map} +1 -1
- package/dist/_chunks/{stale-reload-CCqb62Z3.js → stale-reload-DIIIVGh_.js} +1 -1
- package/dist/_chunks/{stale-reload-CCqb62Z3.js.map → stale-reload-DIIIVGh_.js.map} +1 -1
- package/dist/_chunks/tracing-DP5djRq5.js.map +1 -1
- package/dist/_chunks/{use-query-states-C9G6LP0F.js → use-query-states-D9SBlAfp.js} +2 -2
- package/dist/_chunks/{use-query-states-C9G6LP0F.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CIeinIx2.js → use-segment-params-x2poKmk8.js} +2 -2
- package/dist/_chunks/use-segment-params-x2poKmk8.js.map +1 -0
- package/dist/_chunks/{validation-AfEFM5ze.js → validation-BoWM_qI1.js} +31 -2
- package/dist/_chunks/validation-BoWM_qI1.js.map +1 -0
- package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
- package/dist/_chunks/warnings-CAzunaGo.js.map +1 -1
- package/dist/adapters/cloudflare-dev.js.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/cache-api.d.ts +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.js +17 -39
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/timber-cache.d.ts +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +14 -26
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +8 -8
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +2 -4
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/cookies/index.js +3 -3
- package/dist/cookies/index.js.map +1 -1
- package/dist/cookies/validation.d.ts +11 -23
- package/dist/cookies/validation.d.ts.map +1 -1
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +259 -3
- package/dist/index.js.map +1 -1
- package/dist/params/define-schema.d.ts.map +1 -1
- package/dist/params/index.js +2 -2
- package/dist/params/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +31 -0
- package/dist/plugins/cache.d.ts.map +1 -0
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -3
- package/dist/search-params/index.js.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts +2 -2
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/csrf.d.ts +3 -2
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +17 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +69 -21
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts +5 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +5 -2
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/route-element-builder.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/rsc-entry/revalidate-renderer.d.ts +108 -0
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +15 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/sitemap-handler.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/shared/href-validation.d.ts +38 -0
- package/dist/shared/href-validation.d.ts.map +1 -0
- package/dist/shims/font-google.js.map +1 -1
- package/dist/shims/font-local.js.map +1 -1
- package/package.json +5 -5
- package/src/cache/cache-api.ts +3 -2
- package/src/cache/timber-cache.ts +27 -4
- package/src/client/index.ts +1 -0
- package/src/client/link.tsx +13 -37
- package/src/client/navigation-context.ts +2 -0
- package/src/client/use-pathname.ts +1 -0
- package/src/client/use-router.ts +3 -0
- package/src/client/use-search-params.ts +1 -0
- package/src/client/use-segment-params.ts +1 -0
- package/src/config-validation.ts +1 -1
- package/src/cookies/validation.ts +58 -0
- package/src/dev-tools/error-page.ts +1 -0
- package/src/fonts/virtual-modules.ts +2 -0
- package/src/index.ts +5 -1
- package/src/params/define-schema.ts +1 -0
- package/src/plugins/cache.ts +180 -0
- package/src/routing/link-codegen.ts +5 -3
- package/src/schema-bridge.ts +3 -2
- package/src/server/action-client.ts +31 -13
- package/src/server/actions.ts +20 -3
- package/src/server/body-limits.ts +10 -2
- package/src/server/cookie-context.ts +7 -2
- package/src/server/cookie-parsing.ts +1 -1
- package/src/server/csrf.ts +39 -6
- package/src/server/deny-renderer.ts +0 -1
- package/src/server/html-injector-core.ts +6 -5
- package/src/server/html-injectors.ts +8 -5
- package/src/server/pipeline-interception.ts +1 -0
- package/src/server/pipeline-phases.ts +30 -19
- package/src/server/pipeline.ts +27 -1
- package/src/server/primitives.ts +19 -18
- package/src/server/route-element-builder.ts +2 -0
- package/src/server/rsc-entry/error-renderer.ts +4 -3
- package/src/server/rsc-entry/index.ts +16 -36
- package/src/server/rsc-entry/revalidate-renderer.ts +344 -0
- package/src/server/rsc-entry/rsc-payload.ts +0 -4
- package/src/server/rsc-entry/ssr-renderer.ts +76 -0
- package/src/server/sitemap-handler.ts +24 -2
- package/src/server/slot-resolver.ts +8 -2
- package/src/server/ssr-wrappers.tsx +2 -0
- package/src/server/tree-builder.ts +2 -0
- package/src/server/types.ts +2 -2
- package/src/shared/href-validation.ts +127 -0
- package/dist/_chunks/actions-BNhpxrnV.js.map +0 -1
- package/dist/_chunks/merge-search-params-Cm1j99HC.js +0 -41
- package/dist/_chunks/merge-search-params-Cm1j99HC.js.map +0 -1
- package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
- package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
|
@@ -24,6 +24,7 @@ export interface InterceptionMatchResult {
|
|
|
24
24
|
* followed by '/' or be an exact match.
|
|
25
25
|
*/
|
|
26
26
|
function hasSegmentPrefix(pathname: string, prefix: string): boolean {
|
|
27
|
+
if (prefix === '/') return true;
|
|
27
28
|
if (!pathname.startsWith(prefix)) return false;
|
|
28
29
|
return pathname.length === prefix.length || pathname[prefix.length] === '/';
|
|
29
30
|
}
|
|
@@ -82,7 +82,7 @@ export async function runProxyPhase(
|
|
|
82
82
|
try {
|
|
83
83
|
const proxyExport = await getProxy();
|
|
84
84
|
const proxyFn = () =>
|
|
85
|
-
runProxy(proxyExport, req, () => handleRequest(config, req, method, path));
|
|
85
|
+
runProxy(proxyExport, req, () => handleRequest(config, req, method, path, true));
|
|
86
86
|
const response = await withSpan('timber.proxy', {}, () =>
|
|
87
87
|
detailed ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
|
|
88
88
|
);
|
|
@@ -214,33 +214,44 @@ export async function runRenderPhase(
|
|
|
214
214
|
*
|
|
215
215
|
* Used both as the top-level entry (when no proxy.ts is configured) and as
|
|
216
216
|
* the `next()` continuation passed to `runProxy()`.
|
|
217
|
+
*
|
|
218
|
+
* @param pathIsCanonical When true, `path` has already been canonicalized by
|
|
219
|
+
* `createPipeline` — skip re-canonicalization to prevent double-decode.
|
|
220
|
+
* When false (default), runs canonicalize as a safety net for direct callers.
|
|
217
221
|
*/
|
|
218
222
|
export async function handleRequest(
|
|
219
223
|
config: PipelineConfig,
|
|
220
224
|
req: Request,
|
|
221
225
|
method: string,
|
|
222
|
-
path: string
|
|
226
|
+
path: string,
|
|
227
|
+
pathIsCanonical?: boolean
|
|
223
228
|
): Promise<Response> {
|
|
224
229
|
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
225
230
|
|
|
226
|
-
// Stage 1: URL canonicalization
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
)
|
|
231
|
+
// Stage 1: URL canonicalization.
|
|
232
|
+
// When pathIsCanonical is true, createPipeline has already run canonicalize()
|
|
233
|
+
// and passed the result as `path`. Re-canonicalizing would double-decode
|
|
234
|
+
// percent-encoded characters (e.g., %61dmin → admin). See TIM-1004.
|
|
235
|
+
//
|
|
236
|
+
// When pathIsCanonical is false, this is a safety net for direct callers
|
|
237
|
+
// (tests, non-pipeline usage) that haven't pre-canonicalized.
|
|
238
|
+
let canonicalPathname: string;
|
|
239
|
+
if (pathIsCanonical) {
|
|
240
|
+
canonicalPathname = path;
|
|
241
|
+
} else {
|
|
242
|
+
const result = canonicalize(path, stripTrailingSlash);
|
|
243
|
+
if (!result.ok) {
|
|
244
|
+
if (isDebug()) {
|
|
245
|
+
console.warn(
|
|
246
|
+
`[timber] URL canonicalization rejected ${method} ${path} — responding with ${result.status}\n` +
|
|
247
|
+
` This usually means the URL contains encoded separators (%2f, %5c),\n` +
|
|
248
|
+
` null bytes (%00), path traversal (..), or malformed percent-encoding.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return new Response(null, { status: result.status });
|
|
240
252
|
}
|
|
241
|
-
|
|
253
|
+
canonicalPathname = result.pathname;
|
|
242
254
|
}
|
|
243
|
-
const canonicalPathname = result.pathname;
|
|
244
255
|
|
|
245
256
|
// Stage 1b: Metadata route matching — runs before regular route matching.
|
|
246
257
|
// Metadata routes skip middleware.ts and access.ts (public endpoints for crawlers).
|
|
@@ -377,7 +388,7 @@ export async function handleRequest(
|
|
|
377
388
|
if (isDebug()) {
|
|
378
389
|
console.warn(
|
|
379
390
|
`[timber] No route matched for pathname: ${canonicalPathname}\n` +
|
|
380
|
-
`
|
|
391
|
+
` Input path: ${path}\n` +
|
|
381
392
|
` Method: ${method}`
|
|
382
393
|
);
|
|
383
394
|
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -327,13 +327,39 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
327
327
|
path: canonicalPath,
|
|
328
328
|
});
|
|
329
329
|
} else {
|
|
330
|
-
result = await handleRequest(config, canonicalReq, method, canonicalPath);
|
|
330
|
+
result = await handleRequest(config, canonicalReq, method, canonicalPath, true);
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
// Set response status on the root span before it ends —
|
|
334
334
|
// DevSpanProcessor reads this for tree/summary output.
|
|
335
335
|
await setSpanAttribute('http.response.status_code', result.status);
|
|
336
336
|
|
|
337
|
+
// Vary: Accept — CDNs must cache HTML and RSC payload responses
|
|
338
|
+
// separately for the same URL. Without this, a shared cache may
|
|
339
|
+
// serve an RSC Flight payload to a browser expecting HTML (or
|
|
340
|
+
// vice versa). See GHSA-wfc6-r584-vfw7, design/13-security.md.
|
|
341
|
+
//
|
|
342
|
+
// Vary: X-Timber-URL — intercepting route responses depend on
|
|
343
|
+
// the source URL, so caches must store them separately.
|
|
344
|
+
const varyTokens = ['Accept'];
|
|
345
|
+
if (config.interceptionRewrites?.length) {
|
|
346
|
+
varyTokens.push('X-Timber-URL');
|
|
347
|
+
}
|
|
348
|
+
const existingVary = result.headers.get('Vary');
|
|
349
|
+
const existingTokens = existingVary
|
|
350
|
+
? existingVary
|
|
351
|
+
.toLowerCase()
|
|
352
|
+
.split(',')
|
|
353
|
+
.map((t) => t.trim())
|
|
354
|
+
: [];
|
|
355
|
+
const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
|
|
356
|
+
if (newTokens.length > 0) {
|
|
357
|
+
result.headers.set(
|
|
358
|
+
'Vary',
|
|
359
|
+
existingVary ? `${existingVary}, ${newTokens.join(', ')}` : newTokens.join(', ')
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
337
363
|
// Append Server-Timing header based on configured mode.
|
|
338
364
|
// Header mutability is guaranteed by the producer-side clone
|
|
339
365
|
// in `outcomeToResponse` and the metadata-route / auto-sitemap
|
package/src/server/primitives.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
|
|
|
8
8
|
import { isDebug } from './debug.js';
|
|
9
9
|
import { getRequestSearchString } from './request-context.js';
|
|
10
10
|
import { mergePreservedSearchParams } from '../shared/merge-search-params.js';
|
|
11
|
+
import { validateExternalRedirectUrl } from '../shared/href-validation.js';
|
|
11
12
|
|
|
12
13
|
// ─── Dev-mode validation ────────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -239,6 +240,9 @@ export class RedirectSignal extends Error {
|
|
|
239
240
|
/** Pattern matching absolute URLs: http(s):// or protocol-relative // */
|
|
240
241
|
const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
241
242
|
|
|
243
|
+
// eslint-disable-next-line no-control-regex -- must match all C0 controls to prevent bypass
|
|
244
|
+
const REDIRECT_CONTROL_CHARS_RE = /[\x00-\x1f]/g;
|
|
245
|
+
|
|
242
246
|
/**
|
|
243
247
|
* Options for redirect() — alternative to passing a bare status code.
|
|
244
248
|
*/
|
|
@@ -299,7 +303,14 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
|
|
|
299
303
|
if (status < 300 || status > 399) {
|
|
300
304
|
throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
|
|
301
305
|
}
|
|
302
|
-
|
|
306
|
+
// Strip C0 control characters and trim whitespace before checking — browsers
|
|
307
|
+
// do this during URL resolution, so " \thttp://evil.com" resolves as absolute.
|
|
308
|
+
// Normalize for validation only: strip C0 controls, trim, then replace backslashes
|
|
309
|
+
// with forward slashes. Browsers normalize \ → / during URL resolution, so
|
|
310
|
+
// "/\evil.com" becomes "//evil.com" (protocol-relative open redirect).
|
|
311
|
+
const cleaned = path.replace(REDIRECT_CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
|
|
312
|
+
|
|
313
|
+
if (ABSOLUTE_URL_RE.test(cleaned)) {
|
|
303
314
|
throw new Error(
|
|
304
315
|
`redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
|
|
305
316
|
'Use redirectExternal(url, allowList) for external redirects.'
|
|
@@ -325,10 +336,13 @@ export function permanentRedirect(path: string, options?: Omit<RedirectOptions,
|
|
|
325
336
|
}
|
|
326
337
|
|
|
327
338
|
/**
|
|
328
|
-
* Redirect to an external URL. The
|
|
339
|
+
* Redirect to an external URL. The origin must be in the provided allow-list.
|
|
340
|
+
*
|
|
341
|
+
* Only http: and https: schemes are permitted. The allow-list is matched
|
|
342
|
+
* against full origins (scheme + host + port), not bare hostnames.
|
|
329
343
|
*
|
|
330
344
|
* @param url - Absolute URL to redirect to.
|
|
331
|
-
* @param allowList - Array of allowed
|
|
345
|
+
* @param allowList - Array of allowed origins (e.g. ['https://example.com', 'https://auth.example.com']).
|
|
332
346
|
* @param status - HTTP redirect status code (3xx). Defaults to 302.
|
|
333
347
|
*/
|
|
334
348
|
export function redirectExternal(url: string, allowList: string[], status: number = 302): never {
|
|
@@ -336,21 +350,8 @@ export function redirectExternal(url: string, allowList: string[], status: numbe
|
|
|
336
350
|
throw new Error(`redirectExternal() requires a 3xx status code, got ${status}.`);
|
|
337
351
|
}
|
|
338
352
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
hostname = new URL(url).hostname;
|
|
342
|
-
} catch {
|
|
343
|
-
throw new Error(`redirectExternal() received an invalid URL: "${url}"`);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if (!allowList.includes(hostname)) {
|
|
347
|
-
throw new Error(
|
|
348
|
-
`redirectExternal() target "${hostname}" is not in the allow-list. ` +
|
|
349
|
-
`Allowed: [${allowList.join(', ')}]`
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
throw new RedirectSignal(url, status);
|
|
353
|
+
const canonicalUrl = validateExternalRedirectUrl(url, allowList);
|
|
354
|
+
throw new RedirectSignal(canonicalUrl, status);
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
// ─── RenderError ────────────────────────────────────────────────────────────
|
|
@@ -67,10 +67,12 @@ function replaceOutermostSegmentOutlet(
|
|
|
67
67
|
const props = element.props as Record<string, unknown>;
|
|
68
68
|
if (element.type === AccessGate && props.children) {
|
|
69
69
|
const inner = replaceOutermostSegmentOutlet(props.children as React.ReactElement, replacement);
|
|
70
|
+
/* eslint-disable react/no-children-prop -- createElement API */
|
|
70
71
|
return createElement(element.type as React.FunctionComponent<Record<string, unknown>>, {
|
|
71
72
|
...props,
|
|
72
73
|
children: inner,
|
|
73
74
|
});
|
|
75
|
+
/* eslint-enable react/no-children-prop */
|
|
74
76
|
}
|
|
75
77
|
return element;
|
|
76
78
|
}
|
|
@@ -152,10 +152,11 @@ function getLayoutsForErrorFile(
|
|
|
152
152
|
*/
|
|
153
153
|
function toSerializableError(error: unknown): SerializableError {
|
|
154
154
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
155
|
+
const devMode = isDevMode();
|
|
155
156
|
return {
|
|
156
|
-
message: err.message,
|
|
157
|
-
name: err.name,
|
|
158
|
-
...(
|
|
157
|
+
message: devMode ? err.message : 'An unexpected error occurred.',
|
|
158
|
+
name: devMode ? err.name : 'Error',
|
|
159
|
+
...(devMode && err.stack ? { stack: err.stack } : {}),
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -45,11 +45,10 @@ import { buildClientScripts } from '../html-injectors.js';
|
|
|
45
45
|
import type { InterceptionContext, PipelineConfig, RouteMatch } from '../pipeline.js';
|
|
46
46
|
import { createPipeline } from '../pipeline.js';
|
|
47
47
|
import { coerceSegmentParams, setGlobalSchemaCodecs } from '../param-coercion.js';
|
|
48
|
-
import { setSegmentParams } from '../request-context.js';
|
|
49
|
-
import { buildRouteElement } from '../route-element-builder.js';
|
|
50
48
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
51
49
|
import { createMetadataRouteMatcher, createRouteMatcher } from '../route-matcher.js';
|
|
52
50
|
import { initDevTracing } from '../tracing.js';
|
|
51
|
+
import { createRevalidateRendererFactory } from './revalidate-renderer.js';
|
|
53
52
|
|
|
54
53
|
import { renderFallbackError as renderFallback } from '../fallback-error.js';
|
|
55
54
|
import { loadInstrumentation } from '../instrumentation.js';
|
|
@@ -422,40 +421,21 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
422
421
|
// action POSTs) matches the page pipeline exactly. Defaults to `true`
|
|
423
422
|
// both here and inside the wrapper.
|
|
424
423
|
stripTrailingSlash: pipelineConfig.stripTrailingSlash,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// Without this, components receive raw strings instead of typed values.
|
|
441
|
-
await coerceSegmentParams(revalidateMatch);
|
|
442
|
-
// Set coerced params in ALS so getSegmentParams() works during the
|
|
443
|
-
// revalidation render (AccessGate reads params via ALS). Without this,
|
|
444
|
-
// AccessGate → getSegmentParams() throws because segmentParams
|
|
445
|
-
// is never set. See TIM-667.
|
|
446
|
-
setSegmentParams(revalidateMatch.segmentParams);
|
|
447
|
-
const routeResult = await buildRouteElement(
|
|
448
|
-
revalidateReq,
|
|
449
|
-
revalidateMatch,
|
|
450
|
-
undefined,
|
|
451
|
-
null,
|
|
452
|
-
(buildManifest as BuildManifest).metadataRouteHashes
|
|
453
|
-
);
|
|
454
|
-
return {
|
|
455
|
-
element: routeResult.element,
|
|
456
|
-
headElements: routeResult.headElements,
|
|
457
|
-
};
|
|
458
|
-
},
|
|
424
|
+
// Revalidation renderer — runs the target route's `middleware.ts`
|
|
425
|
+
// chain BEFORE building the element tree so a server action that
|
|
426
|
+
// revalidates a protected path honors that path's auth, tenant
|
|
427
|
+
// isolation, and request-header injection. Closes the silent-bypass
|
|
428
|
+
// bug where `revalidatePath('/admin')` from a public action would
|
|
429
|
+
// serialize the admin RSC tree without ever consulting admin's
|
|
430
|
+
// middleware. Also validates the user-supplied `path` as a local
|
|
431
|
+
// root-relative URL to reject `revalidatePath('//evil.com/admin')`
|
|
432
|
+
// and similar scheme/authority injections. See TIM-996 and
|
|
433
|
+
// design/08-forms-and-actions.md §"revalidatePath()".
|
|
434
|
+
buildRevalidateRenderer: createRevalidateRendererFactory({
|
|
435
|
+
matchRoute,
|
|
436
|
+
coerceSegmentParams,
|
|
437
|
+
buildManifest: buildManifest as BuildManifest,
|
|
438
|
+
}),
|
|
459
439
|
});
|
|
460
440
|
|
|
461
441
|
return { handler, pipelineConfig };
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revalidation renderer — builds the React element tree for the target
|
|
3
|
+
* path requested by `revalidatePath()` from inside a server action, with
|
|
4
|
+
* the same security guarantees as a normal route render.
|
|
5
|
+
*
|
|
6
|
+
* Before this module existed, `revalidatePath()` matched the route, coerced
|
|
7
|
+
* params, and called `buildRouteElement()` directly. That bypassed the
|
|
8
|
+
* target route's `middleware.ts` chain entirely. An action that revalidated
|
|
9
|
+
* a higher-privilege or user-controlled path could leak an authenticated
|
|
10
|
+
* RSC tree to the action submitter even when middleware on that path
|
|
11
|
+
* would have denied them.
|
|
12
|
+
*
|
|
13
|
+
* The fix (TIM-996) is to run the target route's middleware chain inside
|
|
14
|
+
* a fresh `runWithRequestContext` scope BEFORE building the element tree,
|
|
15
|
+
* propagate `DenySignal` and `RedirectSignal` to `executeAction`, drop the
|
|
16
|
+
* revalidation when middleware returns a short-circuit `Response`, and
|
|
17
|
+
* lift the request-header overlay onto the action's ALS store so the
|
|
18
|
+
* revalidated component render sees middleware-injected headers via
|
|
19
|
+
* `getHeaders()`.
|
|
20
|
+
*
|
|
21
|
+
* The input `path` is also validated as a local root-relative pathname so
|
|
22
|
+
* `revalidatePath('//evil.com/admin')` and similar scheme/authority
|
|
23
|
+
* injections cannot smuggle a foreign host through `new URL(path, base)`.
|
|
24
|
+
*
|
|
25
|
+
* See design/08-forms-and-actions.md §"revalidatePath()" and
|
|
26
|
+
* design/13-security.md §"Server actions silently bypass middleware".
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { BuildManifest } from '../build-manifest.js';
|
|
30
|
+
import { canonicalize } from '../canonicalize.js';
|
|
31
|
+
import { getCookiesForSsr, seedRequestCookies } from '../cookie-context.js';
|
|
32
|
+
import { logRenderError } from '../logger.js';
|
|
33
|
+
import type { RouteMatch, RouteMatcher } from '../pipeline.js';
|
|
34
|
+
import { DenySignal, RedirectSignal } from '../primitives.js';
|
|
35
|
+
import { applyRequestHeaderOverlay, setSegmentParams } from '../request-context.js';
|
|
36
|
+
import { requestContextAls } from '../als-registry.js';
|
|
37
|
+
import { buildRouteElement } from '../route-element-builder.js';
|
|
38
|
+
import type { RevalidateRenderer, RevalidationResult } from '../actions.js';
|
|
39
|
+
|
|
40
|
+
import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sentinel thrown when revalidation cannot proceed because middleware
|
|
44
|
+
* returned a short-circuit `Response` (a non-redirect HTTP response that
|
|
45
|
+
* we cannot translate back to an element tree). `executeAction` catches
|
|
46
|
+
* this and drops the revalidation — the action result is still delivered,
|
|
47
|
+
* but no `_tree` is piggybacked onto the response. The matching name
|
|
48
|
+
* `RevalidationDropped` is exported so callers can identify it via
|
|
49
|
+
* `instanceof` without leaking the message text to clients.
|
|
50
|
+
*/
|
|
51
|
+
export class RevalidationDropped extends Error {
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'RevalidationDropped';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of validating a `revalidatePath()` argument. */
|
|
59
|
+
type PathValidation =
|
|
60
|
+
| { ok: true; pathname: string; search: string }
|
|
61
|
+
| { ok: false; reason: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a `revalidatePath()` argument.
|
|
65
|
+
*
|
|
66
|
+
* Accepts local root-relative pathnames only. Rejects anything that
|
|
67
|
+
* `new URL(path, base)` would resolve to a different host or scheme:
|
|
68
|
+
*
|
|
69
|
+
* - Empty / non-string input
|
|
70
|
+
* - Anything not starting with `/` (relative, scheme-bearing, etc.)
|
|
71
|
+
* - Protocol-relative `//evil.com/admin` — resolves to scheme://evil.com
|
|
72
|
+
* - Backslash-prefixed `/\evil.com` — some clients fold `\` into `/`
|
|
73
|
+
* - Control characters and null bytes
|
|
74
|
+
* - Paths that fail canonicalization (encoded separators, `..` escaping
|
|
75
|
+
* root, malformed percent-encoding)
|
|
76
|
+
*
|
|
77
|
+
* The pathname is canonicalized so the rest of the renderer matches the
|
|
78
|
+
* same shape the page pipeline matches.
|
|
79
|
+
*/
|
|
80
|
+
export function validateRevalidatePath(path: unknown): PathValidation {
|
|
81
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
82
|
+
return { ok: false, reason: 'must be a non-empty string' };
|
|
83
|
+
}
|
|
84
|
+
if (!path.startsWith('/')) {
|
|
85
|
+
return { ok: false, reason: 'must start with "/" (root-relative path)' };
|
|
86
|
+
}
|
|
87
|
+
if (path.startsWith('//')) {
|
|
88
|
+
return { ok: false, reason: 'must not start with "//" (protocol-relative URL)' };
|
|
89
|
+
}
|
|
90
|
+
if (path.startsWith('/\\')) {
|
|
91
|
+
return { ok: false, reason: 'must not start with "/\\" (backslash injection)' };
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < path.length; i++) {
|
|
94
|
+
const code = path.charCodeAt(i);
|
|
95
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
96
|
+
return { ok: false, reason: 'must not contain control characters' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const hashIndex = path.indexOf('#');
|
|
100
|
+
// Fragments are meaningless server-side and would not be sent back to
|
|
101
|
+
// the client on revalidation anyway. Drop them silently before splitting
|
|
102
|
+
// the query so a `#` cannot smuggle additional path content past the
|
|
103
|
+
// canonicalizer.
|
|
104
|
+
const noFragment = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
|
105
|
+
const queryIndex = noFragment.indexOf('?');
|
|
106
|
+
const pathnameOnly = queryIndex >= 0 ? noFragment.slice(0, queryIndex) : noFragment;
|
|
107
|
+
const search = queryIndex >= 0 ? noFragment.slice(queryIndex) : '';
|
|
108
|
+
const canonical = canonicalize(pathnameOnly, true);
|
|
109
|
+
if (!canonical.ok) {
|
|
110
|
+
return { ok: false, reason: `failed URL canonicalization (status ${canonical.status})` };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, pathname: canonical.pathname, search };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Serialize a cookie map into a `Cookie:` request header value
|
|
117
|
+
* (`name1=value1; name2=value2`).
|
|
118
|
+
*
|
|
119
|
+
* Cookie values are RFC 6265 cookie-octet (no `;`, no `,`, no whitespace,
|
|
120
|
+
* no `"` / `\`) by construction — `assertValidCookieValue()` rejects
|
|
121
|
+
* anything else at write time. Names are validated similarly. Joining
|
|
122
|
+
* with `; ` is therefore safe and cannot be used to smuggle additional
|
|
123
|
+
* cookies via a malformed value.
|
|
124
|
+
*/
|
|
125
|
+
function serializeCookieMap(cookies: Map<string, string>): string {
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
cookies.forEach((value, name) => {
|
|
128
|
+
parts.push(`${name}=${value}`);
|
|
129
|
+
});
|
|
130
|
+
return parts.join('; ');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Dependencies for the revalidate renderer factory. */
|
|
134
|
+
export interface RevalidateRendererDeps {
|
|
135
|
+
matchRoute: RouteMatcher;
|
|
136
|
+
coerceSegmentParams: CoerceSegmentParamsFn;
|
|
137
|
+
buildManifest: BuildManifest;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a per-request `RevalidateRenderer`.
|
|
142
|
+
*
|
|
143
|
+
* The returned renderer:
|
|
144
|
+
* 1. Validates the requested path.
|
|
145
|
+
* 2. Matches the canonicalized path against the route tree.
|
|
146
|
+
* 3. Coerces segment params via the matched chain's `params.ts` codecs.
|
|
147
|
+
* 4. Runs the matched route's `middleware.ts` chain inside a fresh
|
|
148
|
+
* `runWithRequestContext` scope (via `runMiddlewareForAction`).
|
|
149
|
+
* Deny/redirect signals are re-thrown so `executeAction` can apply
|
|
150
|
+
* its existing redirect handling and the new deny-drop behavior.
|
|
151
|
+
* A short-circuit `Response` becomes `RevalidationDropped`.
|
|
152
|
+
* 5. Lifts the request-header overlay onto the OUTER action's ALS store
|
|
153
|
+
* so the revalidated component render (which happens later inside
|
|
154
|
+
* `renderToReadableStream` in the action's scope) observes
|
|
155
|
+
* middleware-injected headers via `getHeaders()`. Acceptable because
|
|
156
|
+
* revalidation runs after the action body has returned — the only
|
|
157
|
+
* remaining ALS readers are render-phase code that should see the
|
|
158
|
+
* same overlay a normal page render would see.
|
|
159
|
+
* 6. Builds and returns the route element tree, ready for the action
|
|
160
|
+
* handler to serialize alongside the action result.
|
|
161
|
+
*
|
|
162
|
+
* Cookies set inside the revalidation middleware are intentionally NOT
|
|
163
|
+
* propagated to the action response. The middleware on a *different* path
|
|
164
|
+
* setting cookies via the action's response is surprising semantics; the
|
|
165
|
+
* scoped cookie jar on the inner ALS run is discarded with the scope.
|
|
166
|
+
* The primary security boundary closed here is auth — `deny()` from the
|
|
167
|
+
* target's middleware now prevents the revalidated tree from leaking.
|
|
168
|
+
*/
|
|
169
|
+
export function createRevalidateRendererFactory(
|
|
170
|
+
deps: RevalidateRendererDeps
|
|
171
|
+
): (req: Request) => RevalidateRenderer {
|
|
172
|
+
return (req: Request): RevalidateRenderer => {
|
|
173
|
+
return async (path: string): Promise<RevalidationResult> => {
|
|
174
|
+
// ── 1. Validate the path ────────────────────────────────────────
|
|
175
|
+
const validation = validateRevalidatePath(path);
|
|
176
|
+
if (!validation.ok) {
|
|
177
|
+
// Throwing a typed error here drops the revalidation. We do not
|
|
178
|
+
// surface the message to the client (logRenderError sinks it).
|
|
179
|
+
const err = new RevalidationDropped(
|
|
180
|
+
`revalidatePath(${JSON.stringify(path)}) rejected: ${validation.reason}`
|
|
181
|
+
);
|
|
182
|
+
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: err });
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── 2. Build the revalidation Request on the canonical pathname ─
|
|
187
|
+
//
|
|
188
|
+
// Use the canonical pathname (not the raw input) so the rebuilt
|
|
189
|
+
// URL cannot carry a hostile authority — `new URL('//evil.com/x',
|
|
190
|
+
// base)` would otherwise resolve to scheme://evil.com/x. Forward
|
|
191
|
+
// the original action request's headers (session IDs, accept-
|
|
192
|
+
// language, etc.) so middleware on the target route sees the same
|
|
193
|
+
// identity the action did.
|
|
194
|
+
//
|
|
195
|
+
// The `Cookie:` header is REBUILT from the action's
|
|
196
|
+
// read-your-own-writes cookie state rather than copied from the
|
|
197
|
+
// inbound request. An action that rotates the session via
|
|
198
|
+
// `getCookieJar().set('session', newToken)` and then calls
|
|
199
|
+
// `revalidatePath('/dashboard')` should have the target route's
|
|
200
|
+
// middleware see the NEW session — otherwise a session-rotation
|
|
201
|
+
// action would succeed only to have the piggybacked revalidation
|
|
202
|
+
// redirect to /login. `getCookieJar()` writes only update the ALS
|
|
203
|
+
// jar, not the immutable `Request.headers`, so without this step
|
|
204
|
+
// middleware reading `ctx.req.headers.get('cookie')` sees stale
|
|
205
|
+
// values. Codex P2.
|
|
206
|
+
const baseUrl = new URL(req.url);
|
|
207
|
+
const revalidateUrl = new URL(baseUrl.origin);
|
|
208
|
+
revalidateUrl.pathname = validation.pathname;
|
|
209
|
+
revalidateUrl.search = validation.search;
|
|
210
|
+
const revalidateHeaders = new Headers(req.headers);
|
|
211
|
+
revalidateHeaders.set('Accept', 'text/x-component');
|
|
212
|
+
// Snapshot the action's post-mutation cookie map (deletions
|
|
213
|
+
// included) and serialize it into a `Cookie:` header value the
|
|
214
|
+
// target middleware can read off `ctx.req.headers.get('cookie')`.
|
|
215
|
+
// Only attempt this inside a request context — `getCookiesForSsr`
|
|
216
|
+
// throws otherwise — and bail out gracefully if no context is
|
|
217
|
+
// active (the legacy call-site contract permitted this).
|
|
218
|
+
const inRequestContext = Boolean(requestContextAls.getStore());
|
|
219
|
+
const ryw = inRequestContext ? getCookiesForSsr() : new Map<string, string>();
|
|
220
|
+
if (inRequestContext) {
|
|
221
|
+
if (ryw.size === 0) {
|
|
222
|
+
revalidateHeaders.delete('cookie');
|
|
223
|
+
} else {
|
|
224
|
+
revalidateHeaders.set('cookie', serializeCookieMap(ryw));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const revalidateReq = new Request(revalidateUrl.toString(), {
|
|
228
|
+
headers: revalidateHeaders,
|
|
229
|
+
});
|
|
230
|
+
// Seed the parsed map directly so the child `runWithRequestContext`
|
|
231
|
+
// inside `runMiddlewareForAction` skips `parseCookieHeader` — the
|
|
232
|
+
// same H-3 smuggling primitive mitigated by TIM-868. The Cookie
|
|
233
|
+
// header above remains in place for code that reads
|
|
234
|
+
// `ctx.req.headers.get('cookie')` directly.
|
|
235
|
+
if (inRequestContext) {
|
|
236
|
+
seedRequestCookies(revalidateReq, new Map(ryw));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── 3. Match the canonical path ─────────────────────────────────
|
|
240
|
+
const match = deps.matchRoute(validation.pathname);
|
|
241
|
+
if (!match) {
|
|
242
|
+
throw new Error(`revalidatePath('${path}') — no matching route`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── 4. Run middleware in a fresh ALS scope ──────────────────────
|
|
246
|
+
//
|
|
247
|
+
// We only run middleware when the matched chain has at least one
|
|
248
|
+
// entry. Routes with no middleware skip this step (no scope, no
|
|
249
|
+
// overlay) — matching the page pipeline's behavior in
|
|
250
|
+
// `handleRequest` where `runMiddlewarePhase` is gated on
|
|
251
|
+
// `middlewareChain.length > 0`.
|
|
252
|
+
let overlay: Headers | undefined;
|
|
253
|
+
if (match.middlewareChain.length > 0) {
|
|
254
|
+
const outcome = await runMiddlewareForAction(
|
|
255
|
+
revalidateReq,
|
|
256
|
+
match,
|
|
257
|
+
deps.coerceSegmentParams
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (outcome.kind === 'param-coercion-error') {
|
|
261
|
+
// Bad params on the target path — drop revalidation. The
|
|
262
|
+
// action's own response still goes back to the client.
|
|
263
|
+
throw new RevalidationDropped(
|
|
264
|
+
`revalidatePath('${path}') — segment param coercion failed`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (outcome.kind === 'error') {
|
|
268
|
+
// Re-throw unhandled middleware errors so `executeAction`'s
|
|
269
|
+
// existing `console.error` fallback logs them. Not surfaced
|
|
270
|
+
// to the action client (revalidation is best-effort).
|
|
271
|
+
throw outcome.error;
|
|
272
|
+
}
|
|
273
|
+
if (outcome.kind === 'redirect') {
|
|
274
|
+
// Propagate the redirect — `executeAction` catches
|
|
275
|
+
// RedirectSignal and converts it to an action-level redirect.
|
|
276
|
+
// See actions.ts §"Process path revalidation".
|
|
277
|
+
throw outcome.signal;
|
|
278
|
+
}
|
|
279
|
+
if (outcome.kind === 'deny') {
|
|
280
|
+
// Propagate the deny — `executeAction` catches DenySignal
|
|
281
|
+
// (added in this PR) and drops the revalidation.
|
|
282
|
+
throw outcome.signal;
|
|
283
|
+
}
|
|
284
|
+
if (outcome.kind === 'short-circuit') {
|
|
285
|
+
// Middleware returned a Response directly. A redirect Response
|
|
286
|
+
// (`return Response.redirect('/login')` or a 3xx + Location)
|
|
287
|
+
// is the same outcome as throwing `redirect()` — common for
|
|
288
|
+
// session-expiry / feature-flag gates — and the action client
|
|
289
|
+
// should follow it. Translate to a `RedirectSignal` so
|
|
290
|
+
// `executeAction` surfaces it as the action redirect, matching
|
|
291
|
+
// the normal page-pipeline behavior promised in
|
|
292
|
+
// design/08-forms-and-actions.md §"revalidatePath()". Codex P2.
|
|
293
|
+
const status = outcome.response.status;
|
|
294
|
+
const location = outcome.response.headers.get('location');
|
|
295
|
+
if (status >= 300 && status < 400 && location) {
|
|
296
|
+
throw new RedirectSignal(location, status);
|
|
297
|
+
}
|
|
298
|
+
// Any other short-circuit Response (200/403/418/etc.) cannot
|
|
299
|
+
// be translated to a piggy-backed element tree. Drop the
|
|
300
|
+
// revalidation; the action result still ships.
|
|
301
|
+
throw new RevalidationDropped(
|
|
302
|
+
`revalidatePath('${path}') — middleware returned a short-circuit Response (status ${status})`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// `continue` — capture the overlay so we can apply it to the
|
|
307
|
+
// action's request-context store below. We deliberately do NOT
|
|
308
|
+
// forward `outcome.cookies` or `outcome.setCookieHeaders` here;
|
|
309
|
+
// see the module docstring for why.
|
|
310
|
+
overlay = outcome.overlay;
|
|
311
|
+
} else {
|
|
312
|
+
// No middleware on the target — still coerce params so segment
|
|
313
|
+
// params reach `setSegmentParams` typed. `runMiddlewareForAction`
|
|
314
|
+
// would have done this for us; do it manually on the bypass path.
|
|
315
|
+
await deps.coerceSegmentParams(match);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── 5. Lift state onto the action's ALS store ───────────────────
|
|
319
|
+
//
|
|
320
|
+
// Segment params are set unconditionally so `getSegmentParams()`
|
|
321
|
+
// inside the revalidated tree returns the target path's coerced
|
|
322
|
+
// params, not the action route's. The overlay is applied only
|
|
323
|
+
// when middleware ran and produced headers — otherwise we leave
|
|
324
|
+
// the action's `headers()` view untouched.
|
|
325
|
+
setSegmentParams(match.segmentParams);
|
|
326
|
+
if (overlay) {
|
|
327
|
+
applyRequestHeaderOverlay(overlay);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 6. Build the route element tree ─────────────────────────────
|
|
331
|
+
const routeResult = await buildRouteElement(
|
|
332
|
+
revalidateReq,
|
|
333
|
+
match,
|
|
334
|
+
undefined,
|
|
335
|
+
null,
|
|
336
|
+
deps.buildManifest.metadataRouteHashes
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
element: routeResult.element,
|
|
340
|
+
headElements: routeResult.headElements,
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -172,10 +172,6 @@ export async function buildRscPayloadResponse(
|
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
|
|
175
|
-
// Vary on Accept so CDNs cache HTML and RSC responses separately
|
|
176
|
-
// for the same URL. The client appends ?_rsc=<id> as a cache-bust,
|
|
177
|
-
// but Vary ensures correct behavior even without the query param.
|
|
178
|
-
responseHeaders.set('Vary', 'Accept');
|
|
179
175
|
|
|
180
176
|
// Send resolved head elements so the client can update document.title
|
|
181
177
|
// and <meta> tags after SPA navigation. See design/16-metadata.md.
|