Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add middleware prefetching config #42936

Merged
merged 4 commits into from Nov 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -229,6 +229,9 @@ export function getDefineEnv({
'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify(
config.experimental.optimisticClientCache
),
'process.env.__NEXT_MIDDLEWARE_PREFETCH': JSON.stringify(
config.experimental.middlewarePrefetch
),
'process.env.__NEXT_CROSS_ORIGIN': JSON.stringify(config.crossOrigin),
'process.browser': JSON.stringify(isClient),
'process.env.__NEXT_TEST_MODE': JSON.stringify(
Expand Down
9 changes: 9 additions & 0 deletions packages/next/server/base-server.ts
Expand Up @@ -1054,6 +1054,15 @@ export default abstract class Server<ServerOptions extends Options = Options> {
) &&
(isSSG || hasServerProps)

// when we are handling a middleware prefetch and it doesn't
// resolve to a static data route we bail early to avoid
// unexpected SSR invocations
if (!isSSG && req.headers['x-middleware-prefetch']) {
res.setHeader('x-middleware-skip', '1')
res.body('{}').send()
return null
}

if (isAppPath) {
res.setHeader('vary', RSC_VARY_HEADER)

Expand Down
5 changes: 5 additions & 0 deletions packages/next/server/config-schema.ts
Expand Up @@ -304,6 +304,11 @@ const configSchema = {
manualClientBasePath: {
type: 'boolean',
},
middlewarePrefetch: {
// automatic typing doesn't like enum
enum: ['strict', 'flexible'] as any,
type: 'string',
},
modularizeImports: {
type: 'object',
},
Expand Down
2 changes: 2 additions & 0 deletions packages/next/server/config-shared.ts
Expand Up @@ -83,6 +83,7 @@ export interface ExperimentalConfig {
skipMiddlewareUrlNormalize?: boolean
skipTrailingSlashRedirect?: boolean
optimisticClientCache?: boolean
middlewarePrefetch?: 'strict' | 'flexible'
legacyBrowsers?: boolean
manualClientBasePath?: boolean
newNextLinkBehavior?: boolean
Expand Down Expand Up @@ -563,6 +564,7 @@ export const defaultConfig: NextConfig = {
swcMinify: true,
output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined,
experimental: {
middlewarePrefetch: 'flexible',
optimisticClientCache: true,
runtime: undefined,
manualClientBasePath: false,
Expand Down
8 changes: 8 additions & 0 deletions packages/next/server/next-server.ts
Expand Up @@ -1975,6 +1975,14 @@ export default class NextNodeServer extends BaseServer {
? `${parsedDestination.hostname}:${parsedDestination.port}`
: parsedDestination.hostname) !== req.headers.host
) {
// when we are handling a middleware prefetch and it doesn't
// resolve to a static data route we bail early to avoid
// unexpected SSR invocations
if (req.headers['x-middleware-prefetch']) {
res.setHeader('x-middleware-skip', '1')
res.body('{}').send()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is an intended or unintended consequence, but this causes issues with CDNs that are configured to cache the routes https://nextjs.org/_next/data/...

With this change, the server would return an empty body {} for requests with the x-middleware-prefetch header which may be incidentally cached by the CDN.

For CDNs that do not allow configuring cache keys by custom headers such as Cloudflare, this empty body will be cached.

And if the user will then try to navigate to the "prefetched page", the Next.js application will throw an exception because it is then trying to load the same https://nextjs.org/_next/data/... URL without the x-middleware-prefetch header expecting the server-side props to be returned, but instead gets back {}.

return { finished: true }
}
return this.proxyRequest(
req as NodeNextRequest,
res as NodeNextResponse,
Expand Down
136 changes: 106 additions & 30 deletions packages/next/shared/lib/router/router.ts
Expand Up @@ -635,8 +635,6 @@ function fetchRetry(
})
}

const backgroundCache: Record<string, Promise<any>> = {}

interface FetchDataOutput {
dataHref: string
json: Record<string, any> | null
Expand Down Expand Up @@ -687,7 +685,11 @@ function fetchNextData({
const { href: cacheKey } = new URL(dataHref, window.location.href)
const getData = (params?: { method?: 'HEAD' | 'GET' }) =>
fetchRetry(dataHref, isServerRender ? 3 : 1, {
headers: isPrefetch ? { purpose: 'prefetch' } : {},
headers: Object.assign(
{} as HeadersInit,
isPrefetch ? { purpose: 'prefetch' } : {},
isPrefetch && hasMiddleware ? { 'x-middleware-prefetch': '1' } : {}
),
method: params?.method ?? 'GET',
})
.then((response) => {
Expand Down Expand Up @@ -756,7 +758,12 @@ function fetchNextData({
return data
})
.catch((err) => {
delete inflightCache[cacheKey]
if (!unstable_skipClientCache) {
delete inflightCache[cacheKey]
}
if (err.message === 'Failed to fetch') {
markAssetError(err)
}
throw err
})

Expand Down Expand Up @@ -839,8 +846,10 @@ export default class Router implements BaseRouter {
* Map of all components loaded in `Router`
*/
components: { [pathname: string]: PrivateRouteInfo }
// Server Data Cache
// Server Data Cache (full data requests)
sdc: NextDataCache = {}
// Server Background Cache (HEAD requests)
sbc: NextDataCache = {}

sub: Subscription
clc: ComponentLoadCancel
Expand Down Expand Up @@ -1966,6 +1975,7 @@ export default class Router implements BaseRouter {
? existingInfo
: undefined

const isBackground = isQueryUpdating
const fetchNextDataParams: FetchNextDataParams = {
dataHref: this.pageLoader.getDataHref({
href: formatWithValidation({ pathname, query }),
Expand All @@ -1976,11 +1986,11 @@ export default class Router implements BaseRouter {
hasMiddleware: true,
isServerRender: this.isSsr,
parseJSON: true,
inflightCache: this.sdc,
inflightCache: isBackground ? this.sbc : this.sdc,
persistCache: !isPreview,
isPrefetch: false,
unstable_skipClientCache,
isBackground: isQueryUpdating,
isBackground,
}

const data =
Expand Down Expand Up @@ -2071,26 +2081,36 @@ export default class Router implements BaseRouter {
)
}
}
const wasBailedPrefetch = data?.response?.headers.get('x-middleware-skip')

const shouldFetchData = routeInfo.__N_SSG || routeInfo.__N_SSP

// For non-SSG prefetches that bailed before sending data
// we clear the cache to fetch full response
if (wasBailedPrefetch) {
delete this.sdc[data?.dataHref]
}

const { props, cacheKey } = await this._getData(async () => {
if (shouldFetchData) {
const { json, cacheKey: _cacheKey } = data?.json
? data
: await fetchNextData({
dataHref: this.pageLoader.getDataHref({
href: formatWithValidation({ pathname, query }),
asPath: resolvedAs,
locale,
}),
isServerRender: this.isSsr,
parseJSON: true,
inflightCache: this.sdc,
persistCache: !isPreview,
isPrefetch: false,
unstable_skipClientCache,
})
const { json, cacheKey: _cacheKey } =
data?.json && !wasBailedPrefetch
? data
: await fetchNextData({
dataHref:
data?.dataHref ||
this.pageLoader.getDataHref({
href: formatWithValidation({ pathname, query }),
asPath: resolvedAs,
locale,
}),
isServerRender: this.isSsr,
parseJSON: true,
inflightCache: wasBailedPrefetch ? {} : this.sdc,
persistCache: !isPreview,
isPrefetch: false,
unstable_skipClientCache,
})

return {
cacheKey: _cacheKey,
Expand Down Expand Up @@ -2135,7 +2155,7 @@ export default class Router implements BaseRouter {
Object.assign({}, fetchNextDataParams, {
isBackground: true,
persistCache: false,
inflightCache: backgroundCache,
inflightCache: this.sbc,
})
).catch(() => {})
}
Expand Down Expand Up @@ -2278,6 +2298,12 @@ export default class Router implements BaseRouter {
? options.locale || undefined
: this.locale

const isMiddlewareMatch = await matchesMiddleware({
asPath: asPath,
locale: locale,
router: this,
})

if (process.env.__NEXT_HAS_REWRITES && asPath.startsWith('/')) {
let rewrites: any
;({ __rewrites: rewrites } = await getClientBuildManifest())
Expand Down Expand Up @@ -2305,7 +2331,9 @@ export default class Router implements BaseRouter {
pathname = rewritesResult.resolvedHref
parsed.pathname = pathname

url = formatWithValidation(parsed)
if (!isMiddlewareMatch) {
url = formatWithValidation(parsed)
}
}
}
parsed.pathname = resolveDynamicRoute(parsed.pathname, pages)
Expand All @@ -2320,25 +2348,73 @@ export default class Router implements BaseRouter {
) || {}
)

url = formatWithValidation(parsed)
if (!isMiddlewareMatch) {
url = formatWithValidation(parsed)
}
}

// Prefetch is not supported in development mode because it would trigger on-demand-entries
if (process.env.NODE_ENV !== 'production') {
return
}

const data =
process.env.__NEXT_MIDDLEWARE_PREFETCH === 'strict'
? ({} as any)
: await withMiddlewareEffects({
fetchData: () =>
fetchNextData({
dataHref: this.pageLoader.getDataHref({
href: formatWithValidation({ pathname, query }),
skipInterpolation: true,
asPath: resolvedAs,
locale,
}),
hasMiddleware: true,
isServerRender: this.isSsr,
parseJSON: true,
inflightCache: this.sdc,
persistCache: !this.isPreview,
isPrefetch: true,
}),
asPath: asPath,
locale: locale,
router: this,
})

/**
* If there was a rewrite we apply the effects of the rewrite on the
* current parameters for the prefetch.
*/
if (data?.effect.type === 'rewrite') {
parsed.pathname = data.effect.resolvedHref
pathname = data.effect.resolvedHref
query = { ...query, ...data.effect.parsedAs.query }
resolvedAs = data.effect.parsedAs.pathname
url = formatWithValidation(parsed)
}

/**
* If there is a redirect to an external destination then we don't have
* to prefetch content as it will be unused.
*/
if (data?.effect.type === 'redirect-external') {
return
}

const route = removeTrailingSlash(pathname)

await Promise.all([
this.pageLoader._isSsg(route).then((isSsg) => {
return isSsg
? fetchNextData({
dataHref: this.pageLoader.getDataHref({
href: url,
asPath: resolvedAs,
locale: locale,
}),
dataHref:
data?.dataHref ||
this.pageLoader.getDataHref({
href: url,
asPath: resolvedAs,
locale: locale,
}),
isServerRender: false,
parseJSON: true,
inflightCache: this.sdc,
Expand Down
4 changes: 3 additions & 1 deletion test/e2e/middleware-rewrites/app/pages/about.js
@@ -1,15 +1,17 @@
export default function Main({ message, middleware }) {
export default function Main({ message, middleware, now }) {
return (
<div>
<h1 className="title">About Page</h1>
<p className={message}>{message}</p>
<p className="middleware">{middleware}</p>
<p className="now">{now}</p>
</div>
)
}

export const getServerSideProps = ({ query }) => ({
props: {
now: Date.now(),
middleware: query.middleware || '',
message: query.message || '',
},
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/middleware-rewrites/app/pages/index.js
Expand Up @@ -51,6 +51,14 @@ export default function Home() {
Rewrite me to internal path
</Link>
<div />
<Link href="/rewrite-to-static" id="rewrite-to-static">
Rewrite me to static
</Link>
<div />
<Link href="/fallback-true-blog/rewritten" id="rewrite-to-ssr">
Rewrite me to /about (SSR)
</Link>
<div />
<Link href="/ssg" id="normal-ssg-link">
normal SSG link
</Link>
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/middleware-rewrites/app/pages/static-ssg/[slug].js
Expand Up @@ -12,3 +12,19 @@ export default function Page() {
</>
)
}

export function getStaticPaths() {
return {
paths: ['/static-ssg/first'],
fallback: 'blocking',
}
}

export function getStaticProps({ params }) {
return {
props: {
now: Date.now(),
params,
},
}
}