Skip to content

fix: don't use ODB for routes that match middleware #1171

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

Merged
merged 7 commits into from
Feb 2, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions demos/default/local-plugin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useRouter } from 'next/router'
import Link from 'next/link'

const Show = ({ show }) => {
const router = useRouter()

// This is never shown on Netlify. We just need it for NextJS to be happy,
// because NextJS will render a fallback HTML page.
if (router.isFallback) {
return <div>Loading...</div>
}

return (
<div>
<p>
Check the network panel for the header <code>x-middleware-date</code> to ensure that it is running
</p>

<hr />

<h1>Show #{show.id}</h1>
<p>{show.name}</p>

<hr />

<Link href="/">
<a>Go back home</a>
</Link>
</div>
)
}

export async function getStaticPaths() {
// Set the paths we want to pre-render
const paths = [{ params: { slug: ['my', 'path', '1'] } }, { params: { slug: ['my', 'path', '2'] } }]

// We'll pre-render these paths at build time.
// { fallback: true } means other routes will be rendered at runtime.
return { paths, fallback: true }
}

export async function getStaticProps({ params }) {
// The ID to render
const { slug } = params
const id = slug[slug.length - 1]

const res = await fetch(`https://api.tvmaze.com/shows/${id}`)
const data = await res.json()

return {
props: {
show: data,
},
}
}

export default Show
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useRouter } from 'next/router'
import Link from 'next/link'

const Show = ({ show }) => {
const router = useRouter()

// This is never shown on Netlify. We just need it for NextJS to be happy,
// because NextJS will render a fallback HTML page.
if (router.isFallback) {
return <div>Loading...</div>
}

return (
<div>
<p>
Check the network panel for the header <code>x-middleware-date</code> to ensure that it is running
</p>
<hr />

<h1>Show #{show.id}</h1>
<p>{show.name}</p>

<hr />

<Link href="/">
<a>Go back home</a>
</Link>
</div>
)
}

export async function getStaticPaths() {
// Set the paths we want to pre-render
const paths = [{ params: { id: '3' } }, { params: { id: '4' } }]

// We'll pre-render these paths at build time.
// { fallback: true } means other routes will be rendered at runtime.
return { paths, fallback: true }
}

export async function getStaticProps({ params }) {
// The ID to render
const { id } = params

const res = await fetch(`https://api.tvmaze.com/shows/${id}`)
const data = await res.json()

return {
props: {
show: data,
},
}
}

export default Show
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'

export function middleware() {
const res = NextResponse.next()
res.headers.set('x-middleware-date', new Date().toISOString())
return res
}
18 changes: 14 additions & 4 deletions demos/default/pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ const Index = ({ shows, nodeEnv }) => {

return (
<div>
<img src="/next-on-netlify.png" alt="NextJS on Netlify Banner" className='self-center w-full max-h-80 max-w-5xl m-auto' />
<img
src="/next-on-netlify.png"
alt="NextJS on Netlify Banner"
className="self-center w-full max-h-80 max-w-5xl m-auto"
/>

<div>
<Header />
Expand All @@ -18,7 +22,8 @@ const Index = ({ shows, nodeEnv }) => {
<h2>Server-Side Rendering</h2>

<p>
This page is server-side rendered. It fetches a random list of five TV shows from the TVmaze REST API. Refresh this page to see it change.
This page is server-side rendered. It fetches a random list of five TV shows from the TVmaze REST API. Refresh
this page to see it change.
</p>
<code>NODE_ENV: {nodeEnv}</code>

Expand Down Expand Up @@ -86,8 +91,8 @@ const Index = ({ shows, nodeEnv }) => {

<h2>Localization</h2>
<p>
Localization (i18n) is supported! This demo uses <code>fr</code> with <code>en</code> as the default locale (at{' '}
<code>/</code>).
Localization (i18n) is supported! This demo uses <code>fr</code> with <code>en</code> as the default locale
(at <code>/</code>).
</p>
<strong>The current locale is {locale}</strong>
<p>Click on the links below to see the above text change</p>
Expand Down Expand Up @@ -175,6 +180,11 @@ const Index = ({ shows, nodeEnv }) => {
<a>Middleware</a>
</Link>
</li>
<li>
<Link href="/getStaticProps/withFallbackAndMiddleware/4">
<a>Middleware matching a pre-rendered dynamic route</a>
</Link>
</li>
</ul>
<h4>Preview mode</h4>
<p>Preview mode: </p>
Expand Down
19 changes: 11 additions & 8 deletions src/helpers/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export const matchesRewrite = (file: string, rewrites: Rewrites): boolean => {
return matchesRedirect(file, rewrites.beforeFiles)
}

export const getMiddleware = async (publish: string): Promise<Array<string>> => {
const manifestPath = join(publish, 'server', 'middleware-manifest.json')
if (existsSync(manifestPath)) {
const manifest = await readJson(manifestPath)
Copy link

Choose a reason for hiding this comment

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

No error handling? It looks like readJSON also allows you to pass it { throws: false } so it doesn't throw if it fails to read the JSON.

return manifest?.sortedMiddleware ?? []
}
return []
}

// eslint-disable-next-line max-lines-per-function
export const moveStaticPages = async ({
netlifyConfig,
Expand All @@ -75,14 +84,8 @@ export const moveStaticPages = async ({
const dataDir = join('_next', 'data', buildId)
await ensureDir(dataDir)
// Load the middleware manifest so we can check if a file matches it before moving
let middleware
const manifestPath = join(outputDir, 'middleware-manifest.json')
if (existsSync(manifestPath)) {
const manifest = await readJson(manifestPath)
if (manifest?.middleware) {
middleware = Object.keys(manifest.middleware).map((path) => path.slice(1))
}
}
const middlewarePaths = await getMiddleware(netlifyConfig.build.publish)
const middleware = middlewarePaths.map((path) => path.slice(1))

const prerenderManifest: PrerenderManifest = await readJson(
join(netlifyConfig.build.publish, 'prerender-manifest.json'),
Expand Down
73 changes: 66 additions & 7 deletions src/helpers/redirects.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
/* eslint-disable max-lines */
import { NetlifyConfig } from '@netlify/build'
import { yellowBright } from 'chalk'
import { readJSON } from 'fs-extra'
import { NextConfig } from 'next'
import { PrerenderManifest } from 'next/dist/build'
import { outdent } from 'outdent'
import { join } from 'pathe'

import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants'

import { getMiddleware } from './files'
import { RoutesManifest } from './types'
import {
getApiRewrites,
Expand All @@ -14,9 +18,11 @@ import {
redirectsForNextRoute,
redirectsForNextRouteWithData,
routeToDataRoute,
targetForFallback,
} from './utils'

const matchesMiddleware = (middleware: Array<string>, route: string): boolean =>
Copy link

Choose a reason for hiding this comment

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

Suggested change
const matchesMiddleware = (middleware: Array<string>, route: string): boolean =>
const matchesMiddleware = (middleware: Array<string> | undefined, route: string): boolean =>

[sand] Or optional? I see you check the existence in the line below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looks like middleware is always defined, because getMiddleware defaults to [], so I'll just remove the optional chain check

middleware?.some((middlewarePath) => route.startsWith(middlewarePath))

const generateLocaleRedirects = ({
i18n,
basePath,
Expand Down Expand Up @@ -65,6 +71,7 @@ export const generateStaticRedirects = ({
}
}

// eslint-disable-next-line max-lines-per-function
Copy link

Choose a reason for hiding this comment

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

[sand] This function is indeed starting to get pretty complex. Could you split it out?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'\ve split out the various bits

export const generateRedirects = async ({
netlifyConfig,
nextConfig: { i18n, basePath, trailingSlash, appDir },
Expand Down Expand Up @@ -102,6 +109,36 @@ export const generateRedirects = async ({
...(await getPreviewRewrites({ basePath, appDir })),
)

const middleware = await getMiddleware(netlifyConfig.build.publish)
const routesThatMatchMiddleware = new Set<string>()

const handlerRewrite = (from: string) => ({
from: `${basePath}${from}`,
to: HANDLER_FUNCTION_PATH,
status: 200,
})

// Routes that match middleware need to always use the SSR function
// This generates a rewrite for every middleware in every locale, both with and without a splat
netlifyConfig.redirects.push(
...middleware
.map((route) => {
const unlocalized = [handlerRewrite(`${route}`), handlerRewrite(`${route}/*`)]
if (i18n?.locales?.length > 0) {
Copy link

Choose a reason for hiding this comment

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

Suggested change
if (i18n?.locales?.length > 0) {
if (i18n?.locales?.length) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Explicitly testing against zero is in the linting rules

const localized = i18n?.locales?.map((locale) => [
Copy link

Choose a reason for hiding this comment

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

Suggested change
const localized = i18n?.locales?.map((locale) => [
const localized = i18n.locales.map((locale) => [

Since the above check prevents it, you don't need to have the optional check here

handlerRewrite(`/${locale}${route}`),
handlerRewrite(`/${locale}${route}/*`),
handlerRewrite(`/_next/data/${buildId}/${locale}${route}/*`),
])
// With i18n, all data routes are prefixed with the locale, but the HTML also has the unprefixed default
return [...unlocalized, ...localized]
}
return [...unlocalized, handlerRewrite(`/_next/data/${buildId}${route}/*`)]
})
// Flatten the array of arrays. Can't use flatMap as it might be 2 levels deep
.flat(2),
)

const staticRouteEntries = Object.entries(prerenderedStaticRoutes)

const staticRoutePaths = new Set<string>()
Expand All @@ -121,7 +158,9 @@ export const generateRedirects = async ({
if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) {
route = route.slice(i18n.defaultLocale.length + 1)
staticRoutePaths.add(route)

if (matchesMiddleware(middleware, route)) {
routesThatMatchMiddleware.add(route)
}
netlifyConfig.redirects.push(
...redirectsForNextRouteWithData({
route,
Expand All @@ -131,6 +170,9 @@ export const generateRedirects = async ({
force: true,
}),
)
} else if (matchesMiddleware(middleware, route)) {
// Routes that match middleware can't use the ODB
routesThatMatchMiddleware.add(route)
} else {
// ISR routes use the ODB handler
netlifyConfig.redirects.push(
Expand All @@ -155,11 +197,13 @@ export const generateRedirects = async ({
return
}
if (route.page in prerenderedDynamicRoutes) {
const { fallback } = prerenderedDynamicRoutes[route.page]

const { to, status } = targetForFallback(fallback)

netlifyConfig.redirects.push(...redirectsForNextRoute({ buildId, route: route.page, basePath, to, status, i18n }))
if (matchesMiddleware(middleware, route.page)) {
routesThatMatchMiddleware.add(route.page)
} else {
netlifyConfig.redirects.push(
...redirectsForNextRoute({ buildId, route: route.page, basePath, to: ODB_FUNCTION_PATH, status: 200, i18n }),
)
}
} else {
// If the route isn't prerendered, it's SSR
netlifyConfig.redirects.push(
Expand All @@ -174,4 +218,19 @@ export const generateRedirects = async ({
to: HANDLER_FUNCTION_PATH,
status: 200,
})

const middlewareMatches = routesThatMatchMiddleware.size
if (middlewareMatches > 0) {
console.log(
yellowBright(outdent`
There ${
middlewareMatches === 1
? `is one statically-generated or ISR route`
: `are ${middlewareMatches} statically-generated or ISR routes`
} that match a middleware function, which means they will always be served from the SSR function and will not use ISR or be served from the CDN.
If this was not intended, ensure that your middleware only matches routes that you intend to use SSR.
Copy link

Choose a reason for hiding this comment

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

cc @KyleBlankRollins for this message 😊

`),
)
}
}
/* eslint-enable max-lines */
18 changes: 1 addition & 17 deletions src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import { NetlifyConfig } from '@netlify/build'
import globby from 'globby'
import { join } from 'pathe'

import {
OPTIONAL_CATCH_ALL_REGEX,
CATCH_ALL_REGEX,
DYNAMIC_PARAMETER_REGEX,
ODB_FUNCTION_PATH,
HANDLER_FUNCTION_PATH,
} from '../constants'
import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants'

import { I18n } from './types'

Expand Down Expand Up @@ -77,16 +71,6 @@ const netlifyRoutesForNextRoute = (route: string, buildId: string, i18n?: I18n):

export const isApiRoute = (route: string) => route.startsWith('/api/') || route === '/api'

export const targetForFallback = (fallback: string | false) => {
if (fallback === null || fallback === false) {
// fallback = null mean "blocking", which uses ODB. For fallback=false then anything prerendered should 404.
// However i18n pages may not have been prerendered, so we still need to hit the origin
return { to: ODB_FUNCTION_PATH, status: 200 }
}
// fallback = true is also ODB
return { to: ODB_FUNCTION_PATH, status: 200 }
}

export const redirectsForNextRoute = ({
route,
buildId,
Expand Down
Loading