Skip to content

Commit

Permalink
Add unique search query for RSC requests to be cacable on CDN (#50970)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
huozhi committed Jun 12, 2023
1 parent 109e6cb commit aa3e043
Show file tree
Hide file tree
Showing 15 changed files with 110 additions and 22 deletions.
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 @@ -14,3 +14,5 @@ export const FLIGHT_PARAMETERS = [
[NEXT_ROUTER_STATE_TREE],
[NEXT_ROUTER_PREFETCH],
] as const

export const NEXT_RSC_UNION_QUERY = '_rsc' as const
3 changes: 2 additions & 1 deletion packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { RedirectBoundary } from './redirect-boundary'
import { NotFoundBoundary } from './not-found-boundary'
import { findHeadInCache } from './router-reducer/reducers/find-head-in-cache'
import { createInfinitePromise } from './infinite-promise'
import { NEXT_RSC_UNION_QUERY } from './app-router-headers'

const isServer = typeof window === 'undefined'

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import type {
import {
NEXT_ROUTER_PREFETCH,
NEXT_ROUTER_STATE_TREE,
NEXT_RSC_UNION_QUERY,
NEXT_URL,
RSC,
RSC_CONTENT_TYPE_HEADER,
} from '../app-router-headers'
import { urlToUrlWithoutFlightMarker } from '../app-router'
import { callServer } from '../../app-call-server'
import { PrefetchKind } from './router-reducer-types'
import { hexHash } from '../../../shared/lib/hash'

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

const uniqueCacheQuery = hexHash(
[
headers[NEXT_ROUTER_PREFETCH] || '0',
headers[NEXT_ROUTER_STATE_TREE],
].join(',')
)

try {
let fetchUrl = url
let fetchUrl = new URL(url)
if (process.env.NODE_ENV === 'production') {
if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
fetchUrl = new URL(url) // clone
if (fetchUrl.pathname.endsWith('/')) {
fetchUrl.pathname += 'index.txt'
} else {
fetchUrl.pathname += '.txt'
}
}
}

// Add unique cache query to avoid caching conflicts on CDN which don't respect to Vary header
fetchUrl.searchParams.set(NEXT_RSC_UNION_QUERY, uniqueCacheQuery)

const res = await fetch(fetchUrl, {
// Backwards compat for older browsers. `same-origin` is the default in modern browsers.
credentials: 'same-origin',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../router-reducer-types'
import { createRecordFromThenable } from '../create-record-from-thenable'
import { prunePrefetchCache } from './prune-prefetch-cache'
import { NEXT_RSC_UNION_QUERY } from '../../app-router-headers'

export function prefetchReducer(
state: ReadonlyReducerState,
Expand All @@ -17,6 +18,8 @@ export function prefetchReducer(
prunePrefetchCache(state.prefetchCache)

const { url } = action
url.searchParams.delete(NEXT_RSC_UNION_QUERY)

const href = createHrefFromUrl(
url,
// Ensures the hash is not part of the cache key as it does not affect fetching the server
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
RSC,
RSC_VARY_HEADER,
FLIGHT_PARAMETERS,
NEXT_RSC_UNION_QUERY,
} from '../client/components/app-router-headers'
import {
MatchOptions,
Expand Down Expand Up @@ -2218,6 +2219,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
const { res, query, pathname } = ctx
let page = pathname
const bubbleNoFallback = !!query._nextBubbleNoFallback
delete query[NEXT_RSC_UNION_QUERY]
delete query._nextBubbleNoFallback

const options: MatchOptions = {
Expand Down
24 changes: 14 additions & 10 deletions packages/next/src/server/internal-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'
import type { NextParsedUrlQuery } from './request-meta'

const INTERNAL_QUERY_NAMES = [
Expand All @@ -6,29 +7,32 @@ const INTERNAL_QUERY_NAMES = [
'__nextInferredLocaleFromDefault',
'__nextDefaultLocale',
'__nextIsNotFound',
NEXT_RSC_UNION_QUERY,
] as const

const EXTENDED_INTERNAL_QUERY_NAMES = ['__nextDataReq'] as const
const EDGE_EXTENDED_INTERNAL_QUERY_NAMES = ['__nextDataReq'] as const

export function stripInternalQueries(query: NextParsedUrlQuery) {
for (const name of INTERNAL_QUERY_NAMES) {
delete query[name]
}
}

export function stripInternalSearchParams(
searchParams: URLSearchParams,
extended?: boolean
) {
export function stripInternalSearchParams<T extends string | URL>(
url: T,
isEdge: boolean
): T {
const isStringUrl = typeof url === 'string'
const instance = isStringUrl ? new URL(url) : (url as URL)
for (const name of INTERNAL_QUERY_NAMES) {
searchParams.delete(name)
instance.searchParams.delete(name)
}

if (extended) {
for (const name of EXTENDED_INTERNAL_QUERY_NAMES) {
searchParams.delete(name)
if (isEdge) {
for (const name of EDGE_EXTENDED_INTERNAL_QUERY_NAMES) {
instance.searchParams.delete(name)
}
}

return searchParams
return (isStringUrl ? instance.toString() : instance) as T
}
2 changes: 2 additions & 0 deletions packages/next/src/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import { invokeRequest } from './lib/server-ipc/invoke-request'
import { filterReqHeaders } from './lib/server-ipc/utils'
import { createRequestResponseMocks } from './lib/mock-request'
import chalk from 'next/dist/compiled/chalk'
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'

export * from './base-server'

Expand Down Expand Up @@ -1560,6 +1561,7 @@ export default class NextNodeServer extends BaseServer {
return { finished: true }
}
delete query._nextBubbleNoFallback
delete query[NEXT_RSC_UNION_QUERY]

const handledAsEdgeFunction = await this.runEdgeFunction({
req,
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/request-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { UrlWithParsedQuery } from 'url'
import type { BaseNextRequest } from './base-http'
import type { CloneableBody } from './body-streams'
import { RouteMatch } from './future/route-matches/route-match'
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'

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

export type NextParsedUrlQuery = ParsedUrlQuery &
Expand Down
10 changes: 5 additions & 5 deletions packages/next/src/server/web/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,14 @@ export async function adapter(
}
}

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

const request = new NextRequestHint({
page: params.page,
input: process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE
? params.request.url
: String(requestUrl),
// Strip internal query parameters off the request.
input: stripInternalSearchParams(normalizeUrl, true).toString(),
init: {
body: params.request.body,
geo: params.request.geo,
Expand Down
5 changes: 5 additions & 0 deletions packages/next/src/server/web/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getModuleContext } from './context'
import { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'
import { requestToBodyStream } from '../../body-streams'
import type { EdgeRuntime } from 'next/dist/compiled/edge-runtime'
import { NEXT_RSC_UNION_QUERY } from '../../../client/components/app-router-headers'

export const ErrorSource = Symbol('SandboxError')

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

const KUint8Array = runtime.evaluate('Uint8Array')
const urlInstance = new URL(params.request.url)
urlInstance.searchParams.delete(NEXT_RSC_UNION_QUERY)

params.request.url = urlInstance.toString()

try {
const result = await edgeFunction({
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/shared/lib/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ export function djb2Hash(str: string) {
}
return Math.abs(hash)
}

export function hexHash(str: string) {
return djb2Hash(str).toString(16).slice(0, 7)
}
29 changes: 25 additions & 4 deletions test/e2e/app-dir/app-prefetch/prefetching.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { createNextDescribe } from 'e2e-utils'
import { check, waitFor } from 'next-test-utils'

// @ts-ignore
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'

const browserConfigWithFixedTime = {
beforePageLoad: (page) => {
page.addInitScript(() => {
Expand Down Expand Up @@ -38,6 +41,10 @@ createNextDescribe(
return
}

it('NEXT_RSC_UNION_QUERY query name is _rsc', async () => {
expect(NEXT_RSC_UNION_QUERY).toBe('_rsc')
})

it('should show layout eagerly when prefetched with loading one level down', async () => {
const browser = await next.browser('/', browserConfigWithFixedTime)
// Ensure the page is prefetched
Expand Down Expand Up @@ -85,8 +92,12 @@ createNextDescribe(
await browser.eval(
'window.nd.router.prefetch("/static-page", {kind: "auto"})'
)

await check(() => {
return requests.some((req) => req.includes('static-page'))
return requests.some(
(req) =>
req.includes('static-page') && !req.includes(NEXT_RSC_UNION_QUERY)
)
? 'success'
: JSON.stringify(requests)
}, 'success')
Expand Down Expand Up @@ -114,7 +125,10 @@ createNextDescribe(
`window.nd.router.prefetch("/static-page", {kind: "auto"})`
)
await check(() => {
return requests.some((req) => req.includes('static-page'))
return requests.some(
(req) =>
req.includes('static-page') && !req.includes(NEXT_RSC_UNION_QUERY)
)
? 'success'
: JSON.stringify(requests)
}, 'success')
Expand All @@ -136,7 +150,10 @@ createNextDescribe(
.waitForElementByCss('#static-page')

expect(
requests.filter((request) => request === '/static-page').length
requests.filter(
(request) =>
request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY)
).length
).toBe(1)
})

Expand All @@ -159,7 +176,11 @@ createNextDescribe(
for (let i = 0; i < 5; i++) {
await waitFor(500)
expect(
requests.filter((request) => request === '/static-page').length
requests.filter(
(request) =>
request === '/static-page' ||
request.includes(NEXT_RSC_UNION_QUERY)
).length
).toBe(0)
}
})
Expand Down
9 changes: 9 additions & 0 deletions test/e2e/app-dir/navigation/app/assertion/page/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { strict as assert } from 'node:assert'
// @ts-ignore
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'

export default function Page({ searchParams }) {
assert(searchParams[NEXT_RSC_UNION_QUERY] === undefined)

return <p>no rsc query page</p>
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/navigation/app/assertion/route/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { strict as assert } from 'node:assert'
// @ts-ignore
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'

export function GET(request) {
assert(request.nextUrl.searchParams.get(NEXT_RSC_UNION_QUERY) === null)
return new Response('no rsc query route')
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/navigation/middleware.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
// @ts-check
import { NextResponse } from 'next/server'
// @ts-ignore
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'

if (NEXT_RSC_UNION_QUERY !== '_rsc') {
throw new Error(`NEXT_RSC_UNION_QUERY should be _rsc`)
}

/**
* @param {import('next/server').NextRequest} request
* @returns {NextResponse | undefined}
*/
export function middleware(request) {
const rscQuery = request.nextUrl.searchParams.get(NEXT_RSC_UNION_QUERY)

// Test that the RSC query is not present in the middleware
if (rscQuery) {
throw new Error('RSC query should not be present in the middleware')
}

if (request.nextUrl.pathname === '/redirect-middleware-to-dashboard') {
return NextResponse.redirect(new URL('/redirect-dest', request.url))
}
Expand Down

0 comments on commit aa3e043

Please sign in to comment.