import type { NetlifyConfig } from '@netlify/build' import { yellowBright } from 'chalk' import { readJSON } from 'fs-extra' import type { NextConfig } from 'next' import type { PrerenderManifest, SsgRoute } 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 { ApiRouteConfig } from './functions' import { RoutesManifest } from './types' import { getApiRewrites, getPreviewRewrites, is404Route, isApiRoute, redirectsForNextRoute, redirectsForNextRouteWithData, redirectsForNext404Route, routeToDataRoute, } from './utils' const matchesMiddleware = (middleware: Array, route: string): boolean => middleware.some((middlewarePath) => route.startsWith(middlewarePath)) const generateHiddenPathRedirects = ({ basePath }: Pick): NetlifyConfig['redirects'] => HIDDEN_PATHS.map((path) => ({ from: `${basePath}${path}`, to: '/404.html', status: 404, force: true, })) const generateLocaleRedirects = ({ i18n, basePath, trailingSlash, }: Pick): NetlifyConfig['redirects'] => { const redirects: NetlifyConfig['redirects'] = [] // If the cookie is set, we need to redirect at the origin redirects.push({ from: `${basePath}/`, to: HANDLER_FUNCTION_PATH, status: 200, force: true, conditions: { Cookie: ['NEXT_LOCALE'], }, }) i18n.locales.forEach((locale) => { if (locale === i18n.defaultLocale) { return } redirects.push({ from: `${basePath}/`, to: `${basePath}/${locale}${trailingSlash ? '/' : ''}`, status: 301, conditions: { Language: [locale], }, force: true, }) }) return redirects } export const generateStaticRedirects = ({ netlifyConfig, nextConfig: { i18n, basePath }, }: { netlifyConfig: NetlifyConfig nextConfig: Pick }) => { // Static files are in `static` netlifyConfig.redirects.push({ from: `${basePath}/_next/static/*`, to: `/static/:splat`, status: 200 }) if (i18n) { netlifyConfig.redirects.push({ from: `${basePath}/:locale/_next/static/*`, to: `/static/:splat`, 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 */ const generateMiddlewareRewrites = ({ basePath, middleware, i18n, buildId }) => { const handlerRewrite = (from: string) => ({ from: `${basePath}${from}`, to: HANDLER_FUNCTION_PATH, status: 200, }) return ( middleware .map((route) => { const unlocalized = [handlerRewrite(`${route}`), handlerRewrite(`${route}/*`)] if (i18n?.locales?.length > 0) { const localized = i18n.locales.map((locale) => [ 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 generateStaticIsrRewrites = ({ staticRouteEntries, basePath, i18n, buildId, middleware, }: { staticRouteEntries: Array<[string, SsgRoute]> basePath: string i18n: NextConfig['i18n'] buildId: string middleware: Array }): { staticRoutePaths: Set staticIsrRoutesThatMatchMiddleware: Array staticIsrRewrites: NetlifyConfig['redirects'] } => { const staticIsrRoutesThatMatchMiddleware: Array = [] const staticRoutePaths = new Set() const staticIsrRewrites: NetlifyConfig['redirects'] = [] staticRouteEntries.forEach(([route, { initialRevalidateSeconds }]) => { if (isApiRoute(route) || is404Route(route, i18n)) { return } staticRoutePaths.add(route) if (initialRevalidateSeconds === false) { // These can be ignored, as they're static files handled by the CDN return } // The default locale is served from the root, not the localised path if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { route = route.slice(i18n.defaultLocale.length + 1) staticRoutePaths.add(route) if (matchesMiddleware(middleware, route)) { staticIsrRoutesThatMatchMiddleware.push(route) } staticIsrRewrites.push( ...redirectsForNextRouteWithData({ route, dataRoute: routeToDataRoute(route, buildId, i18n.defaultLocale), basePath, to: ODB_FUNCTION_PATH, force: true, }), ) } else if (matchesMiddleware(middleware, route)) { // Routes that match middleware can't use the ODB staticIsrRoutesThatMatchMiddleware.push(route) } else { // ISR routes use the ODB handler staticIsrRewrites.push( // No i18n, because the route is already localized ...redirectsForNextRoute({ route, basePath, to: ODB_FUNCTION_PATH, force: true, buildId, i18n: null }), ) } }) return { staticRoutePaths, staticIsrRoutesThatMatchMiddleware, staticIsrRewrites, } } /** * Generate rewrites for all dynamic routes */ const generateDynamicRewrites = ({ dynamicRoutes, prerenderedDynamicRoutes, middleware, basePath, buildId, i18n, is404Isr, }: { dynamicRoutes: RoutesManifest['dynamicRoutes'] prerenderedDynamicRoutes: PrerenderManifest['dynamicRoutes'] basePath: string i18n: NextConfig['i18n'] buildId: string middleware: Array is404Isr: boolean }): { dynamicRoutesThatMatchMiddleware: Array dynamicRewrites: NetlifyConfig['redirects'] } => { const dynamicRewrites: NetlifyConfig['redirects'] = [] const dynamicRoutesThatMatchMiddleware: Array = [] dynamicRoutes.forEach((route) => { if (isApiRoute(route.page) || is404Route(route.page, i18n)) { return } if (route.page in prerenderedDynamicRoutes) { if (matchesMiddleware(middleware, route.page)) { dynamicRoutesThatMatchMiddleware.push(route.page) } else if (prerenderedDynamicRoutes[route.page].fallback === false && !is404Isr) { dynamicRewrites.push(...redirectsForNext404Route({ route: route.page, buildId, basePath, i18n })) } else { dynamicRewrites.push( ...redirectsForNextRoute({ route: route.page, buildId, basePath, to: ODB_FUNCTION_PATH, i18n }), ) } } else { // If the route isn't prerendered, it's SSR dynamicRewrites.push( ...redirectsForNextRoute({ route: route.page, buildId, basePath, to: HANDLER_FUNCTION_PATH, i18n }), ) } }) return { dynamicRoutesThatMatchMiddleware, dynamicRewrites, } } export const generateRedirects = async ({ netlifyConfig, nextConfig: { i18n, basePath, trailingSlash, appDir }, buildId, apiRoutes, }: { netlifyConfig: NetlifyConfig nextConfig: Pick buildId: string apiRoutes: Array }) => { const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest = await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json')) const { dynamicRoutes, staticRoutes }: RoutesManifest = await readJSON( join(netlifyConfig.build.publish, 'routes-manifest.json'), ) netlifyConfig.redirects.push(...generateHiddenPathRedirects({ basePath })) if (i18n && i18n.localeDetection !== false) { netlifyConfig.redirects.push(...generateLocaleRedirects({ i18n, basePath, trailingSlash })) } // This is only used in prod, so dev uses `next dev` directly netlifyConfig.redirects.push( // API routes always need to be served from the regular function ...getApiRewrites(basePath, apiRoutes), // Preview mode gets forced to the function, to bypass pre-rendered pages, but static files need to be skipped ...(await getPreviewRewrites({ basePath, appDir })), ) const middleware = await getMiddleware(netlifyConfig.build.publish) netlifyConfig.redirects.push(...generateMiddlewareRewrites({ basePath, i18n, middleware, buildId })) const staticRouteEntries = Object.entries(prerenderedStaticRoutes) const is404Isr = staticRouteEntries.some( ([route, { initialRevalidateSeconds }]) => is404Route(route, i18n) && initialRevalidateSeconds !== false, ) const routesThatMatchMiddleware: Array = [] const { staticRoutePaths, staticIsrRewrites, staticIsrRoutesThatMatchMiddleware } = generateStaticIsrRewrites({ staticRouteEntries, basePath, i18n, buildId, middleware, }) routesThatMatchMiddleware.push(...staticIsrRoutesThatMatchMiddleware) netlifyConfig.redirects.push(...staticIsrRewrites) // Add rewrites for all static SSR routes. This is Next 12+ staticRoutes?.forEach((route) => { if (staticRoutePaths.has(route.page) || isApiRoute(route.page) || is404Route(route.page)) { // Prerendered static routes are either handled by the CDN or are ISR return } netlifyConfig.redirects.push( ...redirectsForNextRoute({ route: route.page, buildId, basePath, to: HANDLER_FUNCTION_PATH, i18n }), ) }) // Add rewrites for all dynamic routes (both SSR and ISR) const { dynamicRewrites, dynamicRoutesThatMatchMiddleware } = generateDynamicRewrites({ dynamicRoutes, prerenderedDynamicRoutes, middleware, basePath, buildId, i18n, is404Isr, }) netlifyConfig.redirects.push(...dynamicRewrites) routesThatMatchMiddleware.push(...dynamicRoutesThatMatchMiddleware) // Final fallback netlifyConfig.redirects.push({ from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200, }) const middlewareMatches = new Set(routesThatMatchMiddleware).size if (middlewareMatches > 0) { console.log( yellowBright(outdent` There ${ middlewareMatches === 1 ? `is one statically-generated or ISR route that matches` : `are ${middlewareMatches} statically-generated or ISR routes that match` } a middleware function. Matched routes 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. `), ) } }