Skip to content

Commit bfbaed8

Browse files
committed
278 status code for redirects from RSC requests
1 parent fec157b commit bfbaed8

File tree

17 files changed

+242
-9
lines changed

17 files changed

+242
-9
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,5 +698,6 @@
698698
"697": "Next DevTools: Can't render in this environment. This is a bug in Next.js",
699699
"698": "Next DevTools: App Dev Overlay is already mounted. This is a bug in Next.js",
700700
"699": "Next DevTools: Pages Dev Overlay is already mounted. This is a bug in Next.js",
701-
"700": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload."
701+
"700": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload.",
702+
"701": "Next.js RSC redirect protocol error: Missing Location header. This may indicate either a Next.js bug or that a third-party proxy is not correctly implementing the RSC redirect protocol."
702703
}

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
NEXT_DID_POSTPONE_HEADER,
2929
NEXT_ROUTER_STALE_TIME_HEADER,
3030
} from '../app-router-headers'
31+
import { RSC_REDIRECT_STATUS_CODE } from '../../../shared/lib/constants'
3132
import { callServer } from '../../app-call-server'
3233
import { findSourceMapURL } from '../../app-find-source-map-url'
3334
import { PrefetchKind } from './router-reducer-types'
@@ -328,9 +329,6 @@ export async function createFetch(
328329
// again, but this time we'll append the cache busting search param to prevent
329330
// a mismatch.
330331
//
331-
// TODO: We can optimize Next.js's built-in middleware APIs by returning a
332-
// custom status code, to prevent the browser from automatically following it.
333-
//
334332
// This does not affect Server Action-based redirects; those are encoded
335333
// differently, as part of the Flight body. It only affects redirects that
336334
// occur in a middleware or a third-party proxy.
@@ -340,6 +338,25 @@ export async function createFetch(
340338
// This is to prevent a redirect loop. Same limit used by Chrome.
341339
const MAX_REDIRECTS = 20
342340
for (let n = 0; n < MAX_REDIRECTS; n++) {
341+
// This is a custom status code used to handle RSC redirects.
342+
// We use a custom status code to prevent the browser from automatically
343+
// following the redirect, which would require an additional round trip.
344+
// Instead, we can handle it on the client and immediately send a new RSC
345+
// request to the new location.
346+
if (browserResponse.status === RSC_REDIRECT_STATUS_CODE) {
347+
const location = browserResponse.headers.get('Location')
348+
if (!location) {
349+
throw new Error(
350+
'Next.js RSC redirect protocol error: Missing Location header. This may indicate either a Next.js bug or that a third-party proxy is not correctly implementing the RSC redirect protocol.'
351+
)
352+
}
353+
fetchUrl = new URL(location, fetchUrl)
354+
setCacheBustingSearchParam(fetchUrl, headers)
355+
browserResponse = await fetch(fetchUrl, fetchOptions)
356+
redirected = true
357+
continue
358+
}
359+
343360
if (!browserResponse.redirected) {
344361
// The server did not perform a redirect.
345362
break

packages/next/src/server/base-server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3051,7 +3051,9 @@ export default abstract class Server<
30513051
// the previous fallback cache entry. This preserves the previous
30523052
// behavior.
30533053
if (isProduction) {
3054-
return toResponseCacheEntry(previousFallbackCacheEntry)
3054+
return toResponseCacheEntry(previousFallbackCacheEntry, {
3055+
isRscRequest: isRSCRequest,
3056+
})
30553057
}
30563058

30573059
// We pass `undefined` and `null` as it doesn't apply to the pages

packages/next/src/server/config-shared.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1374,7 +1374,9 @@ export const defaultConfig = {
13741374
viewTransition: false,
13751375
routerBFCache: false,
13761376
removeUncaughtErrorAndRejectionListeners: false,
1377-
validateRSCRequestHeaders: false,
1377+
validateRSCRequestHeaders:
1378+
// TODO: remove once we've confirmed this mode is stable
1379+
!!process.env.__NEXT_TEST_MODE,
13781380
staleTimes: {
13791381
dynamic: 0,
13801382
static: 300,

packages/next/src/server/lib/router-server.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
PHASE_PRODUCTION_SERVER,
3333
PHASE_DEVELOPMENT_SERVER,
3434
UNDERSCORE_NOT_FOUND_ROUTE,
35+
RSC_REDIRECT_STATUS_CODE,
3536
} from '../../shared/lib/constants'
3637
import { RedirectStatusCode } from '../../client/components/redirect-status-code'
3738
import { DevBundlerService } from './dev-bundler-service'
@@ -59,6 +60,7 @@ import {
5960
handleChromeDevtoolsWorkspaceRequest,
6061
isChromeDevtoolsWorkspaceUrl,
6162
} from './chrome-devtools-workspace'
63+
import { RSC_HEADER } from '../../client/components/app-router-headers'
6264

6365
const debug = setupDebug('next:router-server:main')
6466
const isNextFont = (pathname: string | null) =>
@@ -375,6 +377,18 @@ export async function initialize(opts: {
375377
invokedOutputs,
376378
})
377379

380+
// If a previous redirect was already intercepted & updated to RSC status code,
381+
// e.g., in Middleware, we should short-circuit the request right away.
382+
if (config.experimental.validateRSCRequestHeaders) {
383+
if (statusCode === RSC_REDIRECT_STATUS_CODE) {
384+
res.statusCode = RSC_REDIRECT_STATUS_CODE
385+
const destination = url.format(parsedUrl)
386+
res.setHeader('Location', destination)
387+
res.end()
388+
return
389+
}
390+
}
391+
378392
if (res.closed || res.finished) {
379393
return
380394
}
@@ -425,9 +439,17 @@ export async function initialize(opts: {
425439
// handle redirect
426440
if (!bodyStream && statusCode && statusCode > 300 && statusCode < 400) {
427441
const destination = url.format(parsedUrl)
428-
res.statusCode = statusCode
429442
res.setHeader('location', destination)
430443

444+
// handle RSC redirect
445+
if (config.experimental.validateRSCRequestHeaders) {
446+
if (req.headers[RSC_HEADER.toLowerCase()] === '1') {
447+
res.statusCode = RSC_REDIRECT_STATUS_CODE
448+
return res.end(destination)
449+
}
450+
}
451+
452+
res.statusCode = statusCode
431453
if (statusCode === RedirectStatusCode.PermanentRedirect) {
432454
res.setHeader('Refresh', `0;url=${destination}`)
433455
}

packages/next/src/server/web/adapter.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { CloseController } from './web-on-close'
3434
import { getEdgePreviewProps } from './get-edge-preview-props'
3535
import { getBuiltinRequestContext } from '../after/builtin-request-context'
3636
import { getImplicitTags } from '../lib/implicit-tags'
37+
import { RSC_REDIRECT_STATUS_CODE } from '../../shared/lib/constants'
3738

3839
export class NextRequestHint extends NextRequest {
3940
sourcePage: string
@@ -96,6 +97,8 @@ function ensureTestApisIntercepted() {
9697
}
9798
}
9899

100+
const minimalMode = process.env.NODE_ENV !== 'development'
101+
99102
export async function adapter(
100103
params: AdapterOptions
101104
): Promise<FetchEventResult> {
@@ -196,7 +199,7 @@ export async function adapter(
196199
).IncrementalCache({
197200
appDir: true,
198201
fetchCache: true,
199-
minimalMode: process.env.NODE_ENV !== 'development',
202+
minimalMode: minimalMode,
200203
fetchCacheKeyPrefix: process.env.__NEXT_FETCH_CACHE_KEY_PREFIX,
201204
dev: process.env.NODE_ENV === 'development',
202205
requestHeaders: params.request.headers as any,
@@ -430,6 +433,22 @@ export async function adapter(
430433
}
431434
}
432435

436+
// Handles RSC redirects from Middleware.
437+
if (minimalMode && process.env.__NEXT_CLIENT_VALIDATE_RSC_REQUEST_HEADERS) {
438+
if (
439+
isRSCRequest &&
440+
response &&
441+
response.status >= 300 &&
442+
response.status < 400
443+
) {
444+
response = new Response(null, {
445+
...response,
446+
status: RSC_REDIRECT_STATUS_CODE,
447+
headers: response.headers,
448+
})
449+
}
450+
}
451+
433452
const finalResponse = response ? response : NextResponse.next()
434453

435454
// Flight headers are not overridable / removable so they are applied at the end.

packages/next/src/server/web/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export interface RequestData {
1515
basePath?: string
1616
i18n?: I18NConfig | null
1717
trailingSlash?: boolean
18-
experimental?: Pick<ExperimentalConfig, 'cacheLife' | 'authInterrupts'>
18+
experimental?: Pick<
19+
ExperimentalConfig,
20+
'cacheLife' | 'authInterrupts' | 'validateRSCRequestHeaders'
21+
>
1922
}
2023
page?: {
2124
name?: string

packages/next/src/shared/lib/constants.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,32 @@ export const SYSTEM_ENTRYPOINTS = new Set<string>([
149149
CLIENT_STATIC_FILES_RUNTIME_AMP,
150150
CLIENT_STATIC_FILES_RUNTIME_MAIN_APP,
151151
])
152+
153+
/**
154+
* Custom HTTP status code used to signal a redirect for React Server Component (RSC)
155+
* requests that should be handled by the client-side RSC router, rather than
156+
* being automatically followed by the browser like standard 307/308 redirects.
157+
*
158+
* Purpose:
159+
* - To bypass the browser's default redirect behavior, which typically forwards
160+
* original request headers and might not be ideal for RSC's stateful navigation.
161+
* - To empower the client-side RSC router to intercept this signal and initiate
162+
* a new, clean fetch or navigation to the target URL specified in the 'Location' header.
163+
* This allows for better control over the request lifecycle for the redirected route.
164+
*
165+
* How it's used:
166+
* - Server-side: When an RSC route intends to perform a redirect (originally as a
167+
* 307 or 308), the status code is transformed into this custom value.
168+
* The response must also include a 'Location' header with the redirect target URL.
169+
* - Client-side: The RSC router is programmed to recognize this specific status code.
170+
* Upon detection, it extracts the target URL from the 'Location' header and
171+
* handles the navigation programmatically.
172+
*
173+
* Choice of Value (e.g., 278):
174+
* - The specific value (like 278) is chosen from the 2xx range (indicating success)
175+
* but is not a standard HTTP status code with predefined browser actions for redirection.
176+
* This ensures browsers won't automatically follow it, while still signaling a
177+
* successful server determination that requires client action. It should be an
178+
* officially unassigned status code to minimize conflicts.
179+
*/
180+
export const RSC_REDIRECT_STATUS_CODE = 278
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react'
2+
3+
export default function Page() {
4+
return <div data-testid="about-page">About</div>
5+
}

test/e2e/rsc-redirect/app/layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ReactNode } from 'react'
2+
import React from 'react'
3+
4+
export default function RootLayout({ children }: { children: ReactNode }) {
5+
return (
6+
<html lang="en">
7+
<body>{children}</body>
8+
</html>
9+
)
10+
}

0 commit comments

Comments
 (0)