import type { NetlifyConfig } from '@netlify/build' import type { Header } from '@netlify/build/types/config/netlify_config' import globby from 'globby' import type { ExperimentalConfig } from 'next/dist/server/config-shared' import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config' import { join } from 'pathe' import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants' import { ApiRouteType } from './analysis' import type { ApiRouteConfig } from './functions' import { I18n } from './types' const RESERVED_FILENAME = /[^\w_-]/g /** * Given a Next route, generates a valid Netlify function name. * If "background" is true then the function name will have `-background` * appended to it, meaning that it is executed as a background function. */ export const getFunctionNameForPage = (page: string, background = false) => `${page .replace(CATCH_ALL_REGEX, '_$1-SPLAT') .replace(OPTIONAL_CATCH_ALL_REGEX, '-SPLAT') .replace(DYNAMIC_PARAMETER_REGEX, '_$1-PARAM') .replace(RESERVED_FILENAME, '_')}-${background ? 'background' : 'handler'}` type ExperimentalConfigWithLegacy = ExperimentalConfig & { images?: Pick<ImageConfigComplete, 'remotePatterns'> } export const toNetlifyRoute = (nextRoute: string): Array<string> => { const netlifyRoutes = [nextRoute] // If the route is an optional catch-all route, we need to add a second // Netlify route for the base path (when no parameters are present). // The file ending must be present! if (OPTIONAL_CATCH_ALL_REGEX.test(nextRoute)) { let netlifyRoute = nextRoute.replace(OPTIONAL_CATCH_ALL_REGEX, '$2') // create an empty string, but actually needs to be a forward slash if (netlifyRoute === '') { netlifyRoute = '/' } // When optional catch-all route is at top-level, the regex on line 19 will // create an incorrect route for the data route. For example, it creates // /_next/data/%BUILDID%.json, but NextJS looks for // /_next/data/%BUILDID%/index.json netlifyRoute = netlifyRoute.replace(/(\/_next\/data\/[^/]+).json/, '$1/index.json') // Add second route to the front of the array netlifyRoutes.unshift(netlifyRoute) } return netlifyRoutes.map((route) => route // Replace catch-all, e.g., [...slug] .replace(CATCH_ALL_REGEX, '/:$1/*') // Replace optional catch-all, e.g., [[...slug]] .replace(OPTIONAL_CATCH_ALL_REGEX, '/*') // Replace dynamic parameters, e.g., [id] .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'), ) } export const generateNetlifyRoutes = ({ route, dataRoute, withData = true, }: { route: string dataRoute: string withData: boolean }) => [...(withData ? toNetlifyRoute(dataRoute) : []), ...toNetlifyRoute(route)] export const routeToDataRoute = (route: string, buildId: string, locale?: string) => `/_next/data/${buildId}${locale ? `/${locale}` : ''}${route === '/' ? (locale ? '' : '/index') : route}.json` // Default locale is served from root, not localized export const localizeRoute = (route: string, locale: string, defaultLocale: string) => locale === defaultLocale ? route : `/${locale}${route}` const netlifyRoutesForNextRoute = ({ route, buildId, i18n, withData = true, dataRoute, }: { route: string buildId: string i18n?: I18n withData?: boolean dataRoute?: string }): Array<{ redirect: string; locale: string | false; dataRoute?: string }> => { if (!i18n?.locales?.length) { return generateNetlifyRoutes({ route, dataRoute: dataRoute || routeToDataRoute(route, buildId), withData }).map( (redirect) => ({ redirect, locale: false, }), ) } const { locales, defaultLocale } = i18n const routes = [] locales.forEach((locale) => { // Data route is always localized, except for appDir const localizedDataRoute = dataRoute ? localizeRoute(dataRoute, locale, defaultLocale) : routeToDataRoute(route, buildId, locale) routes.push( ...generateNetlifyRoutes({ route: localizeRoute(route, locale, defaultLocale), dataRoute: localizedDataRoute, withData, }).map((redirect) => ({ redirect, locale, })), ) }) return routes } export const isApiRoute = (route: string) => route.startsWith('/api/') || route === '/api' export const is404Route = (route: string, i18n?: I18n) => i18n ? i18n.locales.some((locale) => route === `/${locale}/404`) : route === '/404' export const redirectsForNextRoute = ({ route, buildId, basePath, to, i18n, status = 200, force = false, withData = true, dataRoute, }: { route: string buildId: string basePath: string to: string i18n: I18n status?: number force?: boolean withData?: boolean dataRoute?: string }): NetlifyConfig['redirects'] => netlifyRoutesForNextRoute({ route, buildId, i18n, withData, dataRoute }).map(({ redirect }) => ({ from: `${basePath}${redirect}`, to, status, force, })) export const redirectsForNext404Route = ({ route, buildId, basePath, i18n, force = false, }: { route: string buildId: string basePath: string i18n: I18n force?: boolean }): NetlifyConfig['redirects'] => netlifyRoutesForNextRoute({ route, buildId, i18n }).map(({ redirect, locale }) => ({ from: `${basePath}${redirect}`, to: locale ? `${basePath}/server/pages/${locale}/404.html` : `${basePath}/server/pages/404.html`, status: 404, force, })) export const redirectsForNextRouteWithData = ({ route, dataRoute, basePath, to, status = 200, force = false, }: { route: string dataRoute: string basePath: string to: string status?: number force?: boolean }): NetlifyConfig['redirects'] => generateNetlifyRoutes({ route, dataRoute, withData: true }).map((redirect) => ({ from: `${basePath}${redirect}`, to, status, force, })) export const getApiRewrites = (basePath: string, apiRoutes: Array<ApiRouteConfig>) => { const apiRewrites = apiRoutes.map((apiRoute) => { const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`) // Scheduled functions can't be invoked directly, so we 404 them. if (apiRoute.config.type === ApiRouteType.SCHEDULED) { return { from, to: '/404.html', status: 404 } } return { from, to: `/.netlify/functions/${getFunctionNameForPage( apiRoute.route, apiRoute.config.type === ApiRouteType.BACKGROUND, )}`, status: 200, } }) return [ ...apiRewrites, { from: `${basePath}/api/*`, to: HANDLER_FUNCTION_PATH, status: 200, }, ] } export const getPreviewRewrites = async ({ basePath, appDir }) => { const publicFiles = await globby('**/*', { cwd: join(appDir, 'public') }) // Preview mode gets forced to the function, to bypass pre-rendered pages, but static files need to be skipped return [ ...publicFiles.map((file) => ({ from: `${basePath}/${file}`, // This is a no-op, but we do it to stop it matching the following rule to: `${basePath}/${file}`, conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] }, status: 200, })), { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200, conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] }, force: true, }, ] } export const shouldSkip = (): boolean => process.env.NEXT_PLUGIN_FORCE_RUN === 'false' || process.env.NEXT_PLUGIN_FORCE_RUN === '0' || process.env.NETLIFY_NEXT_PLUGIN_SKIP === 'true' || process.env.NETLIFY_NEXT_PLUGIN_SKIP === '1' /** * Given an array of base paths and candidate modules, return the first one that exists */ export const findModuleFromBase = ({ paths, candidates }): string | null => { for (const candidate of candidates) { try { const modulePath = require.resolve(candidate, { paths }) if (modulePath) { return modulePath } } catch { // Ignore the error } } return null } export const isNextAuthInstalled = (): boolean => { try { // eslint-disable-next-line import/no-unassigned-import, import/no-extraneous-dependencies, n/no-extraneous-require require('next-auth') return true } catch { // Ignore the MODULE_NOT_FOUND error return false } } export const getCustomImageResponseHeaders = (headers: Header[]): Record<string, string> | null => { const customImageResponseHeaders = headers.find((header) => header.for?.startsWith('/_next/image/')) if (customImageResponseHeaders) { return customImageResponseHeaders?.values as Record<string, string> } return null } export const isBundleSizeCheckDisabled = () => process.env.DISABLE_BUNDLE_ZIP_SIZE_CHECK === '1' || process.env.DISABLE_BUNDLE_ZIP_SIZE_CHECK === 'true' // In v12.2.6-canary.12 the types had not yet been updated. // Once this type is available from the next package, this should // be removed export type ImagesConfig = Partial<ImageConfigComplete> & Required<ImageConfigComplete> & { remotePatterns?: RemotePattern[] } export const getRemotePatterns = (experimental: ExperimentalConfigWithLegacy, images: ImagesConfig) => { // Where remote patterns is configured pre-v12.2.5 if (experimental.images?.remotePatterns) { return experimental.images.remotePatterns } // Where remote patterns is configured after v12.2.5 if (images.remotePatterns) { return images.remotePatterns || [] } return [] } // Taken from next/src/shared/lib/escape-regexp.ts const reHasRegExp = /[|\\{}()[\]^$+*?.-]/ const reReplaceRegExp = /[|\\{}()[\]^$+*?.-]/g export const escapeStringRegexp = (str: string) => (reHasRegExp.test(str) ? str.replace(reReplaceRegExp, '\\$&') : str)