import type { NetlifyConfig } from '@netlify/build' import destr from 'destr' import { readJSON, writeJSON } from 'fs-extra' import type { Header } from 'next/dist/lib/load-custom-routes' import type { NextConfigComplete } from 'next/dist/server/config-shared' import { join, dirname, relative } from 'pathe' import slash from 'slash' import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME } from '../constants' import type { RoutesManifest } from './types' const ROUTES_MANIFEST_FILE = 'routes-manifest.json' type NetlifyHeaders = NetlifyConfig['headers'] export interface RequiredServerFiles { version?: number config?: NextConfigComplete appDir?: string files?: string[] ignore?: string[] } export type NextConfig = Pick & NextConfigComplete & { routesManifest?: RoutesManifest } const defaultFailBuild = (message: string, { error }): never => { throw new Error(`${message}\n${error && error.stack}`) } export const getNextConfig = async function getNextConfig({ publish, failBuild = defaultFailBuild, }): Promise { try { const { config, appDir, ignore }: RequiredServerFiles = await readJSON(join(publish, 'required-server-files.json')) if (!config) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return failBuild('Error loading your Next config') } const routesManifest: RoutesManifest = await readJSON(join(publish, ROUTES_MANIFEST_FILE)) // If you need access to other manifest files, you can add them here as well return { ...config, appDir, ignore, routesManifest } } catch (error: unknown) { return failBuild('Error loading your Next config', { error }) } } /** * Returns all of the NextJS configuration stored within 'required-server-files.json' * To update the configuration within this file, use the 'updateRequiredServerFiles' method. */ export const getRequiredServerFiles = async (publish: string): Promise => { const configFile = join(publish, 'required-server-files.json') return await readJSON(configFile) } /** * Writes a modified configuration object to 'required-server-files.json'. * To get the full configuration, use the 'getRequiredServerFiles' method. */ export const updateRequiredServerFiles = async (publish: string, modifiedConfig: RequiredServerFiles) => { const configFile = join(publish, 'required-server-files.json') await writeJSON(configFile, modifiedConfig) } const resolveModuleRoot = (moduleName) => { try { return dirname(relative(process.cwd(), require.resolve(`${moduleName}/package.json`, { paths: [process.cwd()] }))) } catch { return null } } const DEFAULT_EXCLUDED_MODULES = ['sharp', 'electron'] export const hasManuallyAddedModule = ({ netlifyConfig, moduleName, }: { netlifyConfig: NetlifyConfig moduleName: string }) => /* eslint-disable camelcase */ Object.values(netlifyConfig.functions).some(({ included_files = [] }) => included_files.some((inc) => inc.includes(`node_modules/${moduleName}`)), ) /* eslint-enable camelcase */ export const configureHandlerFunctions = async ({ netlifyConfig, publish, ignore = [], }: { netlifyConfig: NetlifyConfig publish: string ignore: Array }) => { const config = await getRequiredServerFiles(publish) const files = config.files || [] const cssFilesToInclude = files.filter((f) => f.startsWith(`${publish}/static/css/`)) /* eslint-disable no-underscore-dangle */ if (!destr(process.env.DISABLE_IPX)) { netlifyConfig.functions._ipx ||= {} netlifyConfig.functions._ipx.node_bundler = 'nft' } // If the user has manually added the module to included_files, then don't exclude it const excludedModules = DEFAULT_EXCLUDED_MODULES.filter( (moduleName) => !hasManuallyAddedModule({ netlifyConfig, moduleName }), ) /* eslint-enable no-underscore-dangle */ ;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*'].forEach((functionName) => { netlifyConfig.functions[functionName] ||= { included_files: [], external_node_modules: [] } netlifyConfig.functions[functionName].node_bundler = 'nft' netlifyConfig.functions[functionName].included_files ||= [] netlifyConfig.functions[functionName].included_files.push( '.env', '.env.local', '.env.production', '.env.production.local', './public/locales/**', './next-i18next.config.js', `${publish}/server/**`, `${publish}/serverless/**`, `${publish}/*.json`, `${publish}/BUILD_ID`, `${publish}/static/chunks/webpack-middleware*.js`, `!${publish}/server/**/*.js.nft.json`, `!${publish}/server/**/*.map`, '!**/node_modules/@next/swc*/**/*', ...cssFilesToInclude, ...ignore.map((path) => `!${slash(path)}`), ) const nextRoot = resolveModuleRoot('next') if (nextRoot) { netlifyConfig.functions[functionName].included_files.push( `!${nextRoot}/dist/server/lib/squoosh/**/*.wasm`, `!${nextRoot}/dist/next-server/server/lib/squoosh/**/*.wasm`, `!${nextRoot}/dist/compiled/webpack/bundle4.js`, `!${nextRoot}/dist/compiled/webpack/bundle5.js`, ) } excludedModules.forEach((moduleName) => { const moduleRoot = resolveModuleRoot(moduleName) if (moduleRoot) { netlifyConfig.functions[functionName].included_files.push(`!${moduleRoot}/**/*`) } }) }) } interface BuildHeaderParams { path: string headers: Header['headers'] locale?: string } const buildHeader = (buildHeaderParams: BuildHeaderParams) => { const { path, headers } = buildHeaderParams return { for: path, values: headers.reduce((builtHeaders, { key, value }) => { builtHeaders[key] = value return builtHeaders }, {}), } } // Replace the pattern :path* at the end of a path with * since it's a named splat which the Netlify // configuration does not support. const sanitizePath = (path: string) => path.replace(/:[^*/]+\*$/, '*') /** * Persist Next.js custom headers to the Netlify configuration so the headers work with static files * See {@link https://nextjs.org/docs/api-reference/next.config.js/headers} for more information on custom * headers in Next.js * * @param nextConfig - The Next.js configuration * @param netlifyHeaders - Existing headers that are already configured in the Netlify configuration */ export const generateCustomHeaders = (nextConfig: NextConfig, netlifyHeaders: NetlifyHeaders = []) => { // The routesManifest is the contents of the routes-manifest.json file which will already contain the generated // header paths which take locales and base path into account since this runs after the build. The routes-manifest.json // file is located at demos/default/.next/routes-manifest.json once you've build the demo site. const { routesManifest: { headers: customHeaders = [] }, i18n, } = nextConfig // Skip `has` based custom headers as they have more complex dynamic conditional header logic // that currently isn't supported by the Netlify configuration. // Also, this type of dynamic header logic is most likely not for SSG pages. for (const { source, headers, locale: localeEnabled } of customHeaders.filter((customHeader) => !customHeader.has)) { // Explicitly checking false to make the check simpler. // Locale specific paths are excluded only if localeEnabled is false. There is no true value for localeEnabled. It's either // false or undefined, where undefined means it's true. // // Again, the routesManifest has already been generated taking locales into account, but the check is required // so the paths can be properly set in the Netlify configuration. const useLocale = i18n?.locales?.length > 0 && localeEnabled !== false if (useLocale) { const { locales } = i18n const joinedLocales = locales.join('|') /** * converts e.g. * /:nextInternalLocale(en|fr)/some-path * to a path for each locale * /en/some-path and /fr/some-path as well as /some-path (default locale) */ const defaultLocalePath = sanitizePath(source).replace(`/:nextInternalLocale(${joinedLocales})`, '') netlifyHeaders.push(buildHeader({ path: defaultLocalePath, headers })) for (const locale of locales) { const path = sanitizePath(source).replace(`:nextInternalLocale(${joinedLocales})`, locale) netlifyHeaders.push(buildHeader({ path, headers })) } } else { const path = sanitizePath(source) netlifyHeaders.push(buildHeader({ path, headers })) } } }