Skip to content

Commit

Permalink
feat: added rewrite headers after user-supplied rewrites (#74776)
Browse files Browse the repository at this point in the history
Next.js's client router needs to know the pathname and query that the
request was rewritten to in order to facilitate reuse of static RSC
payloads generated from fallbacks. This takes the form of additional
headers being sent back on the response that includes the correct
rewritten pathname that later the client can take into account when
generating the client route key.
  • Loading branch information
wyattjoh authored Jan 13, 2025
1 parent 99c4dc3 commit 1c56ef6
Show file tree
Hide file tree
Showing 13 changed files with 576 additions and 14 deletions.
10 changes: 10 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ import {
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_DID_POSTPONE_HEADER,
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
NEXT_REWRITTEN_PATH_HEADER,
NEXT_REWRITTEN_QUERY_HEADER,
} from '../client/components/app-router-headers'
import { webpackBuild } from './webpack-build'
import { NextBuildContext, type MappedPages } from './build-context'
Expand Down Expand Up @@ -404,6 +406,10 @@ export type RoutesManifest = {
suffix: typeof RSC_SUFFIX
prefetchSuffix: typeof RSC_PREFETCH_SUFFIX
}
rewriteHeaders: {
pathHeader: typeof NEXT_REWRITTEN_PATH_HEADER
queryHeader: typeof NEXT_REWRITTEN_QUERY_HEADER
}
skipMiddlewareUrlNormalize?: boolean
caseSensitive?: boolean
/**
Expand Down Expand Up @@ -1261,6 +1267,10 @@ export default async function build(
suffix: RSC_SUFFIX,
prefetchSuffix: RSC_PREFETCH_SUFFIX,
},
rewriteHeaders: {
pathHeader: NEXT_REWRITTEN_PATH_HEADER,
queryHeader: NEXT_REWRITTEN_QUERY_HEADER,
},
skipMiddlewareUrlNormalize: config.skipMiddlewareUrlNormalize,
ppr: isAppPPREnabled
? {
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/client/components/app-router-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export const NEXT_RSC_UNION_QUERY = '_rsc' as const

export const NEXT_ROUTER_STALE_TIME_HEADER = 'x-nextjs-stale-time' as const
export const NEXT_DID_POSTPONE_HEADER = 'x-nextjs-postponed' as const
export const NEXT_REWRITTEN_PATH_HEADER = 'x-nextjs-rewritten-path' as const
export const NEXT_REWRITTEN_QUERY_HEADER = 'x-nextjs-rewritten-query' as const
export const NEXT_IS_PRERENDER_HEADER = 'x-nextjs-prerender' as const
4 changes: 1 addition & 3 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,9 +1430,7 @@ export default abstract class Server<
parsedUrl.pathname = normalizeResult.pathname

for (const key of Object.keys(parsedUrl.query)) {
if (!key.startsWith('__next') && !key.startsWith('_next')) {
delete parsedUrl.query[key]
}
delete parsedUrl.query[key]
}
const invokeQuery = getRequestMeta(req, 'invokeQuery')

Expand Down
72 changes: 61 additions & 11 deletions packages/next/src/server/lib/router-utils/resolve-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,16 @@ import {
prepareDestination,
} from '../../../shared/lib/router/utils/prepare-destination'
import type { TLSSocket } from 'tls'
import { NEXT_ROUTER_STATE_TREE_HEADER } from '../../../client/components/app-router-headers'
import {
NEXT_REWRITTEN_PATH_HEADER,
NEXT_REWRITTEN_QUERY_HEADER,
NEXT_ROUTER_STATE_TREE_HEADER,
RSC_HEADER,
} from '../../../client/components/app-router-headers'
import { getSelectedParams } from '../../../client/components/router-reducer/compute-changed-path'
import { isInterceptionRouteRewrite } from '../../../lib/generate-interception-routes-rewrites'
import { parseAndValidateFlightRouterState } from '../../app-render/parse-and-validate-flight-router-state'
import { parseUrl } from '../../../shared/lib/router/utils/parse-url'

const debug = setupDebug('next:router-server:resolve-routes')

Expand Down Expand Up @@ -599,27 +605,49 @@ export function getResolveRoutes(

if (middlewareHeaders['x-middleware-rewrite']) {
const value = middlewareHeaders['x-middleware-rewrite'] as string
const rel = relativizeURL(value, initUrl)
resHeaders['x-middleware-rewrite'] = rel
const destination = relativizeURL(value, initUrl)
resHeaders['x-middleware-rewrite'] = destination

const query = parsedUrl.query
parsedUrl = url.parse(rel, true)
const parsedDestination = url.parse(destination, true)

if (parsedDestination.protocol) {
// Assign the parsed destination to parsedUrl so that the next
// set of steps can use it.
parsedUrl = parsedDestination

if (parsedUrl.protocol) {
return {
parsedUrl,
parsedUrl: parsedDestination,
resHeaders,
finished: true,
}
}

// keep internal query state
for (const key of Object.keys(query)) {
if (key.startsWith('_next') || key.startsWith('__next')) {
parsedUrl.query[key] = query[key]
// Set the rewrite headers only if this is a RSC request.
if (req.headers[RSC_HEADER.toLowerCase()] === '1') {
// We set the rewritten path and query headers on the response now
// that we know that the it's not an external rewrite.
if (
parsedDestination.pathname &&
parsedUrl.pathname !== parsedDestination.pathname
) {
res.setHeader(
NEXT_REWRITTEN_PATH_HEADER,
parsedDestination.pathname
)
}
if (parsedDestination.search) {
res.setHeader(
NEXT_REWRITTEN_QUERY_HEADER,
// remove the leading ? from the search
parsedDestination.search.slice(1)
)
}
}

// Assign the parsed destination to parsedUrl so that the next
// set of steps can use it.
parsedUrl = parsedDestination

if (config.i18n) {
const curLocaleResult = normalizeLocalePath(
parsedUrl.pathname || '',
Expand Down Expand Up @@ -735,6 +763,12 @@ export function getResolveRoutes(
// so we'll just use the params from the route matcher
}

// We extract the search params of the destination so we can set it on
// the response headers. We don't want to use the following
// `parsedDestination` as the query object is mutated.
const { search: destinationSearch, pathname: destinationPathname } =
parseUrl(route.destination)

const { parsedDestination } = prepareDestination({
appendParamsToQuery: true,
destination: route.destination,
Expand All @@ -750,6 +784,22 @@ export function getResolveRoutes(
}
}

// Set the rewrite headers only if this is a RSC request.
if (req.headers[RSC_HEADER.toLowerCase()] === '1') {
// We set the rewritten path and query headers on the response now
// that we know that the it's not an external rewrite.
if (parsedUrl.pathname !== destinationPathname) {
res.setHeader(NEXT_REWRITTEN_PATH_HEADER, destinationPathname)
}
if (destinationSearch) {
res.setHeader(
NEXT_REWRITTEN_QUERY_HEADER,
// remove the leading ? from the search
destinationSearch.slice(1)
)
}
}

if (config.i18n) {
const curLocaleResult = normalizeLocalePath(
removePathPrefix(parsedDestination.pathname, config.basePath),
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/app-dir/rewrite-headers/app/hello/[name]/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default async function Page(props) {
const { name } = await props.params
return <div>Hello {name}</div>
}

export async function generateStaticParams() {
return [{ name: 'world' }, { name: 'wyatt' }, { name: 'admin' }]
}
11 changes: 11 additions & 0 deletions test/e2e/app-dir/rewrite-headers/app/layout.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Suspense } from 'react'

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Suspense>{children}</Suspense>
</body>
</html>
)
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/rewrite-headers/app/other/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>Other Page</div>
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/rewrite-headers/app/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Home() {
return <div>Hello World</div>
}
36 changes: 36 additions & 0 deletions test/e2e/app-dir/rewrite-headers/middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server'

export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
}

export default function middleware(req) {
const url = new URL(req.url)

if (url.pathname === '/hello/wyatt') {
return NextResponse.rewrite(new URL('/hello/admin?key=value', url))
}

if (url.pathname === '/hello/bob') {
return NextResponse.rewrite(new URL('/hello/bobby', url))
}

if (url.pathname === '/hello/john') {
return NextResponse.rewrite(new URL('/hello/john?key=value', url))
}

if (url.pathname === '/hello/vercel') {
return NextResponse.rewrite('https://www.vercel.com')
}

return NextResponse.next()
}
23 changes: 23 additions & 0 deletions test/e2e/app-dir/rewrite-headers/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
module.exports = {
rewrites: async () => {
return [
{
source: '/hello/sam',
destination: '/hello/samantha',
},
{
source: '/hello/other',
destination: '/other',
},
{
source: '/hello/fred',
destination: '/other?key=value',
},
{
source: '/hello/(.*)/google',
destination: 'https://www.google.$1/',
},
]
},
}
Loading

0 comments on commit 1c56ef6

Please sign in to comment.