@nadeshikon/plugin-nextjs
Version:
Run Next.js seamlessly on Netlify
455 lines (408 loc) • 16.4 kB
text/typescript
import { cpus } from 'os'
import type { NetlifyConfig } from '@netlify/build'
import { yellowBright } from 'chalk'
import { existsSync, readJson, move, copy, writeJson, readFile, writeFile, ensureDir, readFileSync } from 'fs-extra'
import globby from 'globby'
import { PrerenderManifest } from 'next/dist/build'
import { outdent } from 'outdent'
import pLimit from 'p-limit'
import { join, resolve, dirname } from 'pathe'
import slash from 'slash'
import { MINIMUM_REVALIDATE_SECONDS, DIVIDER } from '../constants'
import { NextConfig } from './config'
import { Rewrites, RoutesManifest } from './types'
import { findModuleFromBase } from './utils'
const TEST_ROUTE = /(|\/)\[[^/]+?](\/|\.html|$)/
const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
export const isDynamicRoute = (route) => TEST_ROUTE.test(route)
export const stripLocale = (rawPath: string, locales: Array<string> = []) => {
const [locale, ...segments] = rawPath.split('/')
if (locales.includes(locale)) {
return segments.join('/')
}
return rawPath
}
export const matchMiddleware = (middleware: Array<string>, filePath: string): string | boolean =>
middleware?.includes('') ||
middleware?.find(
(middlewarePath) =>
filePath === middlewarePath || filePath === `${middlewarePath}.html` || filePath.startsWith(`${middlewarePath}/`),
)
export const matchesRedirect = (file: string, redirects: Rewrites): boolean => {
if (!Array.isArray(redirects)) {
return false
}
return redirects.some((redirect) => {
if (!redirect.regex || redirect.internal) {
return false
}
// Strips the extension from the file path
return new RegExp(redirect.regex).test(`/${file.slice(0, -5)}`)
})
}
export const matchesRewrite = (file: string, rewrites: Rewrites): boolean => {
if (Array.isArray(rewrites)) {
return matchesRedirect(file, rewrites)
}
if (!Array.isArray(rewrites?.beforeFiles)) {
return false
}
return matchesRedirect(file, rewrites.beforeFiles)
}
export const getMiddleware = async (publish: string): Promise<Array<string>> => {
if (process.env.NEXT_DISABLE_NETLIFY_EDGE !== 'true' && process.env.NEXT_DISABLE_NETLIFY_EDGE !== '1') {
return []
}
const manifestPath = join(publish, 'server', 'middleware-manifest.json')
if (existsSync(manifestPath)) {
const manifest = await readJson(manifestPath, { throws: false })
return manifest?.sortedMiddleware ?? []
}
return []
}
// eslint-disable-next-line max-lines-per-function
export const moveStaticPages = async ({
netlifyConfig,
target,
i18n,
basePath,
}: {
netlifyConfig: NetlifyConfig
target: 'server' | 'serverless' | 'experimental-serverless-trace'
i18n: NextConfig['i18n']
basePath?: string
}): Promise<void> => {
console.log('Moving static page files to serve from CDN...')
const outputDir = join(netlifyConfig.build.publish, target === 'server' ? 'server' : 'serverless')
const buildId = readFileSync(join(netlifyConfig.build.publish, 'BUILD_ID'), 'utf8').trim()
const dataDir = join('_next', 'data', buildId)
await ensureDir(join(netlifyConfig.build.publish, dataDir))
// Load the middleware manifest so we can check if a file matches it before moving
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'),
)
const { redirects, rewrites }: RoutesManifest = await readJson(
join(netlifyConfig.build.publish, 'routes-manifest.json'),
)
const isrFiles = new Set<string>()
const shortRevalidateRoutes: Array<{ Route: string; Revalidate: number }> = []
Object.entries(prerenderManifest.routes).forEach(([route, { initialRevalidateSeconds }]) => {
if (initialRevalidateSeconds) {
// Find all files used by ISR routes
const trimmedPath = route === '/' ? 'index' : route.slice(1)
isrFiles.add(`${trimmedPath}.html`)
isrFiles.add(`${trimmedPath}.json`)
if (initialRevalidateSeconds < MINIMUM_REVALIDATE_SECONDS) {
shortRevalidateRoutes.push({ Route: route, Revalidate: initialRevalidateSeconds })
}
}
})
let fileCount = 0
const filesManifest: Record<string, string> = {}
const moveFile = async (file: string) => {
// Strip the initial 'app' or 'pages' directory from the output path
const pathname = file.split('/').slice(1).join('/')
// .rsc data files go next to the html file
const isData = file.endsWith('.json')
const source = join(outputDir, file)
const targetFile = isData ? join(dataDir, pathname) : pathname
const targetPath = basePath ? join(basePath, targetFile) : targetFile
fileCount += 1
filesManifest[file] = targetPath
const dest = join(netlifyConfig.build.publish, targetPath)
try {
await move(source, dest)
} catch (error) {
console.warn('Error moving file', source, error)
}
}
// Move all static files, except error documents and nft manifests
const pages = await globby(['{app,pages}/**/*.{html,json,rsc}', '!**/(500|404|*.js.nft).{html,json}'], {
cwd: outputDir,
dot: true,
})
const matchingMiddleware = new Set()
const matchedPages = new Set()
const matchedRedirects = new Set()
const matchedRewrites = new Set()
// Limit concurrent file moves to number of cpus or 2 if there is only 1
const limit = pLimit(Math.max(2, cpus().length))
const promises = pages.map((rawPath) => {
// Convert to POSIX path
const filePath = slash(rawPath)
// Remove the initial 'app' or 'pages' directory from the output path
const pagePath = filePath.split('/').slice(1).join('/')
// Don't move ISR files, as they're used for the first request
if (isrFiles.has(pagePath)) {
return
}
if (isDynamicRoute(pagePath)) {
return
}
if (matchesRedirect(pagePath, redirects)) {
matchedRedirects.add(pagePath)
return
}
if (matchesRewrite(pagePath, rewrites)) {
matchedRewrites.add(pagePath)
return
}
// Middleware matches against the unlocalised path
const unlocalizedPath = stripLocale(pagePath, i18n?.locales)
const middlewarePath = matchMiddleware(middleware, unlocalizedPath)
// If a file matches middleware it can't be offloaded to the CDN, and needs to stay at the origin to be served by next/server
if (middlewarePath) {
matchingMiddleware.add(middlewarePath)
matchedPages.add(filePath)
return
}
return limit(moveFile, filePath)
})
await Promise.all(promises)
console.log(`Moved ${fileCount} files`)
if (matchedPages.size !== 0) {
console.log(
yellowBright(outdent`
Skipped moving ${matchedPages.size} ${
matchedPages.size === 1 ? 'file because it matches' : 'files because they match'
} middleware, so cannot be deployed to the CDN and will be served from the origin instead.
This is fine, but we're letting you know because it may not be what you expect.
`),
)
console.log(
outdent`
The following middleware matched statically-rendered pages:
${yellowBright([...matchingMiddleware].map((mid) => `- /${mid}/_middleware`).join('\n'))}
${DIVIDER}
`,
)
// There could potentially be thousands of matching pages, so we don't want to spam the console with this
if (matchedPages.size < 50) {
console.log(
outdent`
The following files matched middleware and were not moved to the CDN:
${yellowBright([...matchedPages].map((mid) => `- ${mid}`).join('\n'))}
${DIVIDER}
`,
)
}
}
if (matchedRedirects.size !== 0 || matchedRewrites.size !== 0) {
console.log(
yellowBright(outdent`
Skipped moving ${
matchedRedirects.size + matchedRewrites.size
} files because they match redirects or beforeFiles rewrites, so cannot be deployed to the CDN and will be served from the origin instead.
`),
)
if (matchedRedirects.size < 50 && matchedRedirects.size !== 0) {
console.log(
outdent`
The following files matched redirects and were not moved to the CDN:
${yellowBright([...matchedRedirects].map((mid) => `- ${mid}`).join('\n'))}
${DIVIDER}
`,
)
}
if (matchedRewrites.size < 50 && matchedRewrites.size !== 0) {
console.log(
outdent`
The following files matched beforeFiles rewrites and were not moved to the CDN:
${yellowBright([...matchedRewrites].map((mid) => `- ${mid}`).join('\n'))}
${DIVIDER}
`,
)
}
}
// Write the manifest for use in the serverless functions
await writeJson(join(netlifyConfig.build.publish, 'static-manifest.json'), Object.entries(filesManifest))
if (i18n?.defaultLocale) {
const rootPath = basePath ? join(netlifyConfig.build.publish, basePath) : netlifyConfig.build.publish
// Copy the default locale into the root
const defaultLocaleDir = join(rootPath, i18n.defaultLocale)
if (existsSync(defaultLocaleDir)) {
await copy(defaultLocaleDir, `${rootPath}/`)
}
const defaultLocaleIndex = join(rootPath, `${i18n.defaultLocale}.html`)
const indexHtml = join(rootPath, 'index.html')
if (existsSync(defaultLocaleIndex) && !existsSync(indexHtml)) {
await copy(defaultLocaleIndex, indexHtml, { overwrite: false }).catch(() => {
/* ignore */
})
await copy(join(rootPath, `${i18n.defaultLocale}.json`), join(rootPath, 'index.json'), {
overwrite: false,
}).catch(() => {
/* ignore */
})
}
}
if (shortRevalidateRoutes.length !== 0) {
console.log(outdent`
The following routes use "revalidate" values of under ${MINIMUM_REVALIDATE_SECONDS} seconds, which is not supported.
They will use a revalidate time of ${MINIMUM_REVALIDATE_SECONDS} seconds instead.
`)
console.table(shortRevalidateRoutes)
// TODO: add these docs
// console.log(
// outdent`
// For more information, see https://ntl.fyi/next-revalidate-time
// ${DIVIDER}
// `,
// )
}
}
const PATCH_WARNING = `/* File patched by Netlify */`
/**
* Attempt to patch a source file, preserving a backup
*/
const patchFile = async ({
file,
replacements,
}: {
file: string
replacements: Array<[from: string, to: string]>
}): Promise<boolean> => {
if (!existsSync(file)) {
console.warn('File was not found')
return false
}
let content = await readFile(file, 'utf8')
// If the file has already been patched, patch the backed-up original instead
if (content.includes(PATCH_WARNING) && existsSync(`${file}.orig`)) {
content = await readFile(`${file}.orig`, 'utf8')
}
const newContent = replacements.reduce((acc, [from, to]) => {
if (acc.includes(to)) {
console.log('Already patched. Skipping.')
return acc
}
return acc.replace(from, to)
}, content)
if (newContent === content) {
console.warn('File was not changed')
return false
}
await writeFile(`${file}.orig`, content)
await writeFile(file, `${newContent}\n${PATCH_WARNING}`)
console.log('Done')
return true
}
/**
* The file we need has moved around a bit over the past few versions,
* so we iterate through the options until we find it
*/
const getServerFile = (root: string, includeBase = true) => {
const candidates = ['next/dist/server/next-server', 'next/dist/next-server/server/next-server']
if (includeBase) {
candidates.unshift('next/dist/server/base-server')
}
return findModuleFromBase({ candidates, paths: [root] })
}
/**
* Find the source file for a given page route
*/
export const getSourceFileForPage = (page: string, roots: string[]) => {
for (const root of roots) {
for (const extension of SOURCE_FILE_EXTENSIONS) {
const file = join(root, `${page}.${extension}`)
if (existsSync(file)) {
return file
}
}
}
console.log('Could not find source file for page', page)
}
/**
* Reads the node file trace file for a given file, and resolves the dependencies
*/
export const getDependenciesOfFile = async (file: string) => {
const nft = `${file}.nft.json`
if (!existsSync(nft)) {
return []
}
const dependencies = await readJson(nft, 'utf8')
return dependencies.files.map((dep) => resolve(dirname(file), dep))
}
const baseServerReplacements: Array<[string, string]> = [
// force manual revalidate during cache fetches
[
`checkIsManualRevalidate(req, this.renderOpts.previewProps)`,
`checkIsManualRevalidate(process.env._REVALIDATE_SSG ? { headers: { 'x-prerender-revalidate': this.renderOpts.previewProps.previewModeId } } : req, this.renderOpts.previewProps)`,
],
// ensure ISR 404 pages send the correct SWR cache headers
[`private: isPreviewMode || is404Page && cachedData`, `private: isPreviewMode && cachedData`],
]
const nextServerReplacements: Array<[string, string]> = [
[
`getMiddlewareManifest() {\n if (this.minimalMode) return null;`,
`getMiddlewareManifest() {\n if (this.minimalMode || (process.env.NEXT_DISABLE_NETLIFY_EDGE !== 'true' && process.env.NEXT_DISABLE_NETLIFY_EDGE !== '1')) return null;`,
],
[
`generateCatchAllMiddlewareRoute(devReady) {\n if (this.minimalMode) return []`,
`generateCatchAllMiddlewareRoute(devReady) {\n if (this.minimalMode || (process.env.NEXT_DISABLE_NETLIFY_EDGE !== 'true' && process.env.NEXT_DISABLE_NETLIFY_EDGE !== '1')) return [];`,
],
[
`generateCatchAllMiddlewareRoute() {\n if (this.minimalMode) return undefined;`,
`generateCatchAllMiddlewareRoute() {\n if (this.minimalMode || (process.env.NEXT_DISABLE_NETLIFY_EDGE !== 'true' && process.env.NEXT_DISABLE_NETLIFY_EDGE !== '1')) return undefined;`,
],
[
`getMiddlewareManifest() {\n if (this.minimalMode) {`,
`getMiddlewareManifest() {\n if (!this.minimalMode && (process.env.NEXT_DISABLE_NETLIFY_EDGE === 'true' || process.env.NEXT_DISABLE_NETLIFY_EDGE === '1')) {`,
],
]
export const patchNextFiles = async (root: string): Promise<void> => {
const baseServerFile = getServerFile(root)
console.log(`Patching ${baseServerFile}`)
if (baseServerFile) {
await patchFile({
file: baseServerFile,
replacements: baseServerReplacements,
})
}
const nextServerFile = getServerFile(root, false)
console.log(`Patching ${nextServerFile}`)
if (nextServerFile) {
await patchFile({
file: nextServerFile,
replacements: nextServerReplacements,
})
}
}
export const unpatchFile = async (file: string): Promise<void> => {
const origFile = `${file}.orig`
if (existsSync(origFile)) {
await move(origFile, file, { overwrite: true })
}
}
export const unpatchNextFiles = async (root: string): Promise<void> => {
const baseServerFile = getServerFile(root)
await unpatchFile(baseServerFile)
const nextServerFile = getServerFile(root, false)
if (nextServerFile !== baseServerFile) {
await unpatchFile(nextServerFile)
}
}
export const movePublicFiles = async ({
appDir,
outdir,
publish,
basePath,
}: {
appDir: string
outdir?: string
publish: string
basePath: string
}): Promise<void> => {
// `outdir` is a config property added when using Next.js with Nx. It's typically
// a relative path outside of the appDir, e.g. '../../dist/apps/<app-name>', and
// the parent directory of the .next directory.
// If it exists, copy the files from the public folder there in order to include
// any files that were generated during the build. Otherwise, copy the public
// directory from the original app directory.
const publicDir = outdir ? join(appDir, outdir, 'public') : join(appDir, 'public')
if (existsSync(publicDir)) {
await copy(publicDir, `${publish}${basePath}/`)
}
}