Skip to content

Commit aa3e043

Browse files
authored
Add unique search query for RSC requests to be cacable on CDN (#50970)
Adding a `_rsc` query for RSC payload requests so that they can be differentiated on resources level for CDN cache for the ones that didn't fully respect to VARY header. Also stripped them for node/edge servers so that they won't show up in the url x-ref: #49140 (comment) Closes #49140 Closes NEXT-1268
1 parent 109e6cb commit aa3e043

File tree

15 files changed

+110
-22
lines changed

15 files changed

+110
-22
lines changed

packages/next/src/client/components/app-router-headers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ export const FLIGHT_PARAMETERS = [
1414
[NEXT_ROUTER_STATE_TREE],
1515
[NEXT_ROUTER_PREFETCH],
1616
] as const
17+
18+
export const NEXT_RSC_UNION_QUERY = '_rsc' as const

packages/next/src/client/components/app-router.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { RedirectBoundary } from './redirect-boundary'
4949
import { NotFoundBoundary } from './not-found-boundary'
5050
import { findHeadInCache } from './router-reducer/reducers/find-head-in-cache'
5151
import { createInfinitePromise } from './infinite-promise'
52+
import { NEXT_RSC_UNION_QUERY } from './app-router-headers'
5253

5354
const isServer = typeof window === 'undefined'
5455

@@ -65,7 +66,7 @@ export function getServerActionDispatcher() {
6566

6667
export function urlToUrlWithoutFlightMarker(url: string): URL {
6768
const urlWithoutFlightParameters = new URL(url, location.origin)
68-
// TODO-APP: handle .rsc for static export case
69+
urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY)
6970
return urlWithoutFlightParameters
7071
}
7172

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import type {
1010
import {
1111
NEXT_ROUTER_PREFETCH,
1212
NEXT_ROUTER_STATE_TREE,
13+
NEXT_RSC_UNION_QUERY,
1314
NEXT_URL,
1415
RSC,
1516
RSC_CONTENT_TYPE_HEADER,
1617
} from '../app-router-headers'
1718
import { urlToUrlWithoutFlightMarker } from '../app-router'
1819
import { callServer } from '../../app-call-server'
1920
import { PrefetchKind } from './router-reducer-types'
21+
import { hexHash } from '../../../shared/lib/hash'
2022

2123
/**
2224
* Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side.
@@ -56,18 +58,28 @@ export async function fetchServerResponse(
5658
headers[NEXT_URL] = nextUrl
5759
}
5860

61+
const uniqueCacheQuery = hexHash(
62+
[
63+
headers[NEXT_ROUTER_PREFETCH] || '0',
64+
headers[NEXT_ROUTER_STATE_TREE],
65+
].join(',')
66+
)
67+
5968
try {
60-
let fetchUrl = url
69+
let fetchUrl = new URL(url)
6170
if (process.env.NODE_ENV === 'production') {
6271
if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
63-
fetchUrl = new URL(url) // clone
6472
if (fetchUrl.pathname.endsWith('/')) {
6573
fetchUrl.pathname += 'index.txt'
6674
} else {
6775
fetchUrl.pathname += '.txt'
6876
}
6977
}
7078
}
79+
80+
// Add unique cache query to avoid caching conflicts on CDN which don't respect to Vary header
81+
fetchUrl.searchParams.set(NEXT_RSC_UNION_QUERY, uniqueCacheQuery)
82+
7183
const res = await fetch(fetchUrl, {
7284
// Backwards compat for older browsers. `same-origin` is the default in modern browsers.
7385
credentials: 'same-origin',

packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '../router-reducer-types'
99
import { createRecordFromThenable } from '../create-record-from-thenable'
1010
import { prunePrefetchCache } from './prune-prefetch-cache'
11+
import { NEXT_RSC_UNION_QUERY } from '../../app-router-headers'
1112

1213
export function prefetchReducer(
1314
state: ReadonlyReducerState,
@@ -17,6 +18,8 @@ export function prefetchReducer(
1718
prunePrefetchCache(state.prefetchCache)
1819

1920
const { url } = action
21+
url.searchParams.delete(NEXT_RSC_UNION_QUERY)
22+
2023
const href = createHrefFromUrl(
2124
url,
2225
// Ensures the hash is not part of the cache key as it does not affect fetching the server

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
RSC,
8282
RSC_VARY_HEADER,
8383
FLIGHT_PARAMETERS,
84+
NEXT_RSC_UNION_QUERY,
8485
} from '../client/components/app-router-headers'
8586
import {
8687
MatchOptions,
@@ -2218,6 +2219,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
22182219
const { res, query, pathname } = ctx
22192220
let page = pathname
22202221
const bubbleNoFallback = !!query._nextBubbleNoFallback
2222+
delete query[NEXT_RSC_UNION_QUERY]
22212223
delete query._nextBubbleNoFallback
22222224

22232225
const options: MatchOptions = {
Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'
12
import type { NextParsedUrlQuery } from './request-meta'
23

34
const INTERNAL_QUERY_NAMES = [
@@ -6,29 +7,32 @@ const INTERNAL_QUERY_NAMES = [
67
'__nextInferredLocaleFromDefault',
78
'__nextDefaultLocale',
89
'__nextIsNotFound',
10+
NEXT_RSC_UNION_QUERY,
911
] as const
1012

11-
const EXTENDED_INTERNAL_QUERY_NAMES = ['__nextDataReq'] as const
13+
const EDGE_EXTENDED_INTERNAL_QUERY_NAMES = ['__nextDataReq'] as const
1214

1315
export function stripInternalQueries(query: NextParsedUrlQuery) {
1416
for (const name of INTERNAL_QUERY_NAMES) {
1517
delete query[name]
1618
}
1719
}
1820

19-
export function stripInternalSearchParams(
20-
searchParams: URLSearchParams,
21-
extended?: boolean
22-
) {
21+
export function stripInternalSearchParams<T extends string | URL>(
22+
url: T,
23+
isEdge: boolean
24+
): T {
25+
const isStringUrl = typeof url === 'string'
26+
const instance = isStringUrl ? new URL(url) : (url as URL)
2327
for (const name of INTERNAL_QUERY_NAMES) {
24-
searchParams.delete(name)
28+
instance.searchParams.delete(name)
2529
}
2630

27-
if (extended) {
28-
for (const name of EXTENDED_INTERNAL_QUERY_NAMES) {
29-
searchParams.delete(name)
31+
if (isEdge) {
32+
for (const name of EDGE_EXTENDED_INTERNAL_QUERY_NAMES) {
33+
instance.searchParams.delete(name)
3034
}
3135
}
3236

33-
return searchParams
37+
return (isStringUrl ? instance.toString() : instance) as T
3438
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { invokeRequest } from './lib/server-ipc/invoke-request'
110110
import { filterReqHeaders } from './lib/server-ipc/utils'
111111
import { createRequestResponseMocks } from './lib/mock-request'
112112
import chalk from 'next/dist/compiled/chalk'
113+
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'
113114

114115
export * from './base-server'
115116

@@ -1560,6 +1561,7 @@ export default class NextNodeServer extends BaseServer {
15601561
return { finished: true }
15611562
}
15621563
delete query._nextBubbleNoFallback
1564+
delete query[NEXT_RSC_UNION_QUERY]
15631565

15641566
const handledAsEdgeFunction = await this.runEdgeFunction({
15651567
req,

packages/next/src/server/request-meta.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { UrlWithParsedQuery } from 'url'
55
import type { BaseNextRequest } from './base-http'
66
import type { CloneableBody } from './body-streams'
77
import { RouteMatch } from './future/route-matches/route-match'
8+
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'
89

910
// FIXME: (wyattjoh) this is a temporary solution to allow us to pass data between bundled modules
1011
export const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta')
@@ -97,6 +98,7 @@ type NextQueryMetadata = {
9798
_nextBubbleNoFallback?: '1'
9899
__nextDataReq?: '1'
99100
__nextCustomErrorRender?: '1'
101+
[NEXT_RSC_UNION_QUERY]?: string
100102
}
101103

102104
export type NextParsedUrlQuery = ParsedUrlQuery &

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,14 @@ export async function adapter(
117117
}
118118
}
119119

120-
// Strip internal query parameters off the request.
121-
stripInternalSearchParams(requestUrl.searchParams, true)
120+
const normalizeUrl = process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE
121+
? new URL(params.request.url)
122+
: requestUrl
122123

123124
const request = new NextRequestHint({
124125
page: params.page,
125-
input: process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE
126-
? params.request.url
127-
: String(requestUrl),
126+
// Strip internal query parameters off the request.
127+
input: stripInternalSearchParams(normalizeUrl, true).toString(),
128128
init: {
129129
body: params.request.body,
130130
geo: params.request.geo,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getModuleContext } from './context'
44
import { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'
55
import { requestToBodyStream } from '../../body-streams'
66
import type { EdgeRuntime } from 'next/dist/compiled/edge-runtime'
7+
import { NEXT_RSC_UNION_QUERY } from '../../../client/components/app-router-headers'
78

89
export const ErrorSource = Symbol('SandboxError')
910

@@ -96,6 +97,10 @@ export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
9697
: undefined
9798

9899
const KUint8Array = runtime.evaluate('Uint8Array')
100+
const urlInstance = new URL(params.request.url)
101+
urlInstance.searchParams.delete(NEXT_RSC_UNION_QUERY)
102+
103+
params.request.url = urlInstance.toString()
99104

100105
try {
101106
const result = await edgeFunction({

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ export function djb2Hash(str: string) {
77
}
88
return Math.abs(hash)
99
}
10+
11+
export function hexHash(str: string) {
12+
return djb2Hash(str).toString(16).slice(0, 7)
13+
}

test/e2e/app-dir/app-prefetch/prefetching.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { createNextDescribe } from 'e2e-utils'
22
import { check, waitFor } from 'next-test-utils'
33

4+
// @ts-ignore
5+
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
6+
47
const browserConfigWithFixedTime = {
58
beforePageLoad: (page) => {
69
page.addInitScript(() => {
@@ -38,6 +41,10 @@ createNextDescribe(
3841
return
3942
}
4043

44+
it('NEXT_RSC_UNION_QUERY query name is _rsc', async () => {
45+
expect(NEXT_RSC_UNION_QUERY).toBe('_rsc')
46+
})
47+
4148
it('should show layout eagerly when prefetched with loading one level down', async () => {
4249
const browser = await next.browser('/', browserConfigWithFixedTime)
4350
// Ensure the page is prefetched
@@ -85,8 +92,12 @@ createNextDescribe(
8592
await browser.eval(
8693
'window.nd.router.prefetch("/static-page", {kind: "auto"})'
8794
)
95+
8896
await check(() => {
89-
return requests.some((req) => req.includes('static-page'))
97+
return requests.some(
98+
(req) =>
99+
req.includes('static-page') && !req.includes(NEXT_RSC_UNION_QUERY)
100+
)
90101
? 'success'
91102
: JSON.stringify(requests)
92103
}, 'success')
@@ -114,7 +125,10 @@ createNextDescribe(
114125
`window.nd.router.prefetch("/static-page", {kind: "auto"})`
115126
)
116127
await check(() => {
117-
return requests.some((req) => req.includes('static-page'))
128+
return requests.some(
129+
(req) =>
130+
req.includes('static-page') && !req.includes(NEXT_RSC_UNION_QUERY)
131+
)
118132
? 'success'
119133
: JSON.stringify(requests)
120134
}, 'success')
@@ -136,7 +150,10 @@ createNextDescribe(
136150
.waitForElementByCss('#static-page')
137151

138152
expect(
139-
requests.filter((request) => request === '/static-page').length
153+
requests.filter(
154+
(request) =>
155+
request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY)
156+
).length
140157
).toBe(1)
141158
})
142159

@@ -159,7 +176,11 @@ createNextDescribe(
159176
for (let i = 0; i < 5; i++) {
160177
await waitFor(500)
161178
expect(
162-
requests.filter((request) => request === '/static-page').length
179+
requests.filter(
180+
(request) =>
181+
request === '/static-page' ||
182+
request.includes(NEXT_RSC_UNION_QUERY)
183+
).length
163184
).toBe(0)
164185
}
165186
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { strict as assert } from 'node:assert'
2+
// @ts-ignore
3+
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
4+
5+
export default function Page({ searchParams }) {
6+
assert(searchParams[NEXT_RSC_UNION_QUERY] === undefined)
7+
8+
return <p>no rsc query page</p>
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { strict as assert } from 'node:assert'
2+
// @ts-ignore
3+
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
4+
5+
export function GET(request) {
6+
assert(request.nextUrl.searchParams.get(NEXT_RSC_UNION_QUERY) === null)
7+
return new Response('no rsc query route')
8+
}

test/e2e/app-dir/navigation/middleware.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
// @ts-check
22
import { NextResponse } from 'next/server'
3+
// @ts-ignore
4+
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
5+
6+
if (NEXT_RSC_UNION_QUERY !== '_rsc') {
7+
throw new Error(`NEXT_RSC_UNION_QUERY should be _rsc`)
8+
}
39

410
/**
511
* @param {import('next/server').NextRequest} request
612
* @returns {NextResponse | undefined}
713
*/
814
export function middleware(request) {
15+
const rscQuery = request.nextUrl.searchParams.get(NEXT_RSC_UNION_QUERY)
16+
17+
// Test that the RSC query is not present in the middleware
18+
if (rscQuery) {
19+
throw new Error('RSC query should not be present in the middleware')
20+
}
21+
922
if (request.nextUrl.pathname === '/redirect-middleware-to-dashboard') {
1023
return NextResponse.redirect(new URL('/redirect-dest', request.url))
1124
}

0 commit comments

Comments
 (0)