Skip to content

Commit

Permalink
Add middleware prefetching config (#42936)
Browse files Browse the repository at this point in the history
This adds a new `experimental.middlewarePrefetch` config with two modes
with the default being the `flexible` config.

- `strict` only prefetches when the `href` explicitly matches an SSG
route (won't prefetch for middleware rewrite usage unless manual
`href`/`as` values are used)
- `flexible` always prefetches ensuring middleware rewrite usage is
handled and also prevents executing SSR routes during prefetch to avoid
unexpected invocations

x-ref: #39920
x-ref: [slack
thread](https://vercel.slack.com/archives/C047HMFN58X/p1668473101696689?thread_ts=1667856323.709179&cid=C047HMFN58X)

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
ijjk committed Nov 17, 2022
1 parent 07d3da1 commit 533c242
Show file tree
Hide file tree
Showing 11 changed files with 202 additions and 47 deletions.
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()
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,
},
}
}

0 comments on commit 533c242

Please sign in to comment.