@nadeshikon/plugin-nextjs
Version:
Run Next.js seamlessly on Netlify
1,494 lines (1,277 loc) • 56.9 kB
JavaScript
import { relative } from 'pathe'
import { getAllPageDependencies } from '../packages/runtime/src/templates/getPageResolver'
jest.mock('../packages/runtime/src/helpers/utils', () => {
return {
...jest.requireActual('../packages/runtime/src/helpers/utils'),
isNextAuthInstalled: jest.fn(),
}
})
const Chance = require('chance')
const { writeJSON, unlink, existsSync, readFileSync, copy, ensureDir, readJson, pathExists } = require('fs-extra')
const path = require('path')
const process = require('process')
const os = require('os')
const cpy = require('cpy')
const { dir: getTmpDir } = require('tmp-promise')
const { downloadFile } = require('../packages/runtime/src/templates/handlerUtils')
const { getExtendedApiRouteConfigs } = require('../packages/runtime/src/helpers/functions')
const nextRuntimeFactory = require('../packages/runtime/src')
const nextRuntime = nextRuntimeFactory({})
const { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME } = require('../packages/runtime/src/constants')
const { join } = require('pathe')
const {
matchMiddleware,
stripLocale,
matchesRedirect,
matchesRewrite,
patchNextFiles,
unpatchNextFiles,
} = require('../packages/runtime/src/helpers/files')
const {
getRequiredServerFiles,
updateRequiredServerFiles,
generateCustomHeaders,
} = require('../packages/runtime/src/helpers/config')
const { dirname } = require('path')
const { getProblematicUserRewrites } = require('../packages/runtime/src/helpers/verification')
const chance = new Chance()
const FIXTURES_DIR = `${__dirname}/fixtures`
const SAMPLE_PROJECT_DIR = `${__dirname}/../demos/default`
const constants = {
INTERNAL_FUNCTIONS_SRC: '.netlify/functions-internal',
PUBLISH_DIR: '.next',
FUNCTIONS_DIST: '.netlify/functions',
}
const utils = {
build: {
failBuild(message) {
throw new Error(message)
},
},
run: async () => void 0,
cache: {
save: jest.fn(),
restore: jest.fn(),
},
}
const REDIRECTS = [
{
source: '/:file((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/]+\\.\\w+)/',
destination: '/:file',
locale: false,
internal: true,
statusCode: 308,
regex: '^(?:/((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/]+\\.\\w+))/$',
},
{
source: '/:notfile((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/\\.]+)',
destination: '/:notfile/',
locale: false,
internal: true,
statusCode: 308,
regex: '^(?:/((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/\\.]+))$',
},
{
source: '/en/redirectme',
destination: '/',
statusCode: 308,
regex: '^(?!/_next)/en/redirectme(?:/)?$',
},
{
source: '/:nextInternalLocale(en|es|fr)/redirectme',
destination: '/:nextInternalLocale/',
statusCode: 308,
regex: '^(?!/_next)(?:/(en|es|fr))/redirectme(?:/)?$',
},
]
const REWRITES = [
{
source: '/:nextInternalLocale(en|es|fr)/old/:path*',
destination: '/:nextInternalLocale/:path*',
regex: '^(?:/(en|es|fr))/old(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$',
},
]
// Temporary switch cwd
const changeCwd = function (cwd) {
const originalCwd = process.cwd()
process.chdir(cwd)
return () => {
process.chdir(originalCwd)
}
}
const onBuildHasRun = (netlifyConfig) =>
Boolean(netlifyConfig.functions[HANDLER_FUNCTION_NAME]?.included_files?.some((file) => file.includes('BUILD_ID')))
const rewriteAppDir = async function (dir = '.next') {
const manifest = path.join(dir, 'required-server-files.json')
const manifestContent = await readJson(manifest)
manifestContent.appDir = process.cwd()
await writeJSON(manifest, manifestContent)
}
// Move .next from sample project to current directory
export const moveNextDist = async function (dir = '.next') {
await stubModules(['next', 'sharp'])
await ensureDir(dirname(dir))
await copy(path.join(SAMPLE_PROJECT_DIR, '.next'), path.join(process.cwd(), dir))
for (const file of ['pages', 'app', 'src', 'components', 'public', 'components', 'hello.txt', 'package.json']) {
const source = path.join(SAMPLE_PROJECT_DIR, file)
if (existsSync(source)) {
await copy(source, path.join(process.cwd(), file))
}
}
await rewriteAppDir(dir)
}
const stubModules = async function (modules) {
for (const mod of modules) {
const dir = path.join(process.cwd(), 'node_modules', mod)
await ensureDir(dir)
await writeJSON(path.join(dir, 'package.json'), { name: mod })
}
}
// Copy fixture files to the current directory
const useFixture = async function (fixtureName) {
const fixtureDir = `${FIXTURES_DIR}/${fixtureName}`
await cpy('**', process.cwd(), { cwd: fixtureDir, parents: true, overwrite: true, dot: true })
}
const netlifyConfig = { build: { command: 'npm run build' }, functions: {}, redirects: [], headers: [] }
const defaultArgs = {
netlifyConfig,
utils,
constants,
}
let restoreCwd
let cleanup
// In each test, we change cwd to a temporary directory.
// This allows us not to have to mock filesystem operations.
beforeEach(async () => {
const tmpDir = await getTmpDir({ unsafeCleanup: true })
restoreCwd = changeCwd(tmpDir.path)
cleanup = tmpDir.cleanup
netlifyConfig.build.publish = path.resolve('.next')
netlifyConfig.build.environment = {}
netlifyConfig.redirects = []
netlifyConfig.headers = []
netlifyConfig.functions[HANDLER_FUNCTION_NAME] && (netlifyConfig.functions[HANDLER_FUNCTION_NAME].included_files = [])
netlifyConfig.functions[ODB_FUNCTION_NAME] && (netlifyConfig.functions[ODB_FUNCTION_NAME].included_files = [])
netlifyConfig.functions['_api_*'] && (netlifyConfig.functions['_api_*'].included_files = [])
await useFixture('serverless_next_config')
})
afterEach(async () => {
jest.clearAllMocks()
jest.resetAllMocks()
// Cleans up the temporary directory from `getTmpDir()` and do not make it
// the current directory anymore
restoreCwd()
await cleanup()
})
describe('preBuild()', () => {
test('fails if publishing the root of the project', () => {
defaultArgs.netlifyConfig.build.publish = path.resolve('.')
expect(nextRuntime.onPreBuild(defaultArgs)).rejects.toThrowError(
/Your publish directory is pointing to the base directory of your site/,
)
})
test('fails if the build version is too old', () => {
expect(
nextRuntime.onPreBuild({
...defaultArgs,
constants: { IS_LOCAL: true, NETLIFY_BUILD_VERSION: '18.15.0' },
}),
).rejects.toThrow('This version of the Next Runtime requires netlify-cli')
})
test('passes if the build version is new enough', async () => {
expect(
nextRuntime.onPreBuild({
...defaultArgs,
constants: { IS_LOCAL: true, NETLIFY_BUILD_VERSION: '18.16.1' },
}),
).resolves.not.toThrow()
})
it('restores cache with right paths', async () => {
await useFixture('dist_dir_next_config')
const restore = jest.fn()
await nextRuntime.onPreBuild({
...defaultArgs,
utils: { ...utils, cache: { restore } },
})
expect(restore).toHaveBeenCalledWith(path.resolve('.next/cache'))
})
it('forces the target to "server"', async () => {
const netlifyConfig = { ...defaultArgs.netlifyConfig }
await nextRuntime.onPreBuild({ ...defaultArgs, netlifyConfig })
expect(netlifyConfig.build.environment.NEXT_PRIVATE_TARGET).toBe('server')
})
})
describe('onBuild()', () => {
const { isNextAuthInstalled } = require('../packages/runtime/src/helpers/utils')
beforeEach(() => {
isNextAuthInstalled.mockImplementation(() => {
return true
})
})
afterEach(() => {
delete process.env.DEPLOY_PRIME_URL
delete process.env.URL
delete process.env.CONTEXT
})
test('does not set NEXTAUTH_URL if value is already set', async () => {
const mockUserDefinedSiteUrl = chance.url()
process.env.DEPLOY_PRIME_URL = chance.url()
await moveNextDist()
const initialConfig = await getRequiredServerFiles(netlifyConfig.build.publish)
initialConfig.config.env.NEXTAUTH_URL = mockUserDefinedSiteUrl
await updateRequiredServerFiles(netlifyConfig.build.publish, initialConfig)
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toEqual(mockUserDefinedSiteUrl)
})
test("sets the NEXTAUTH_URL to the DEPLOY_PRIME_URL when CONTEXT env variable is not 'production'", async () => {
const mockUserDefinedSiteUrl = chance.url()
process.env.DEPLOY_PRIME_URL = mockUserDefinedSiteUrl
process.env.URL = chance.url()
// See https://docs.netlify.com/configure-builds/environment-variables/#build-metadata for all possible values
process.env.CONTEXT = 'deploy-preview'
await moveNextDist()
const initialConfig = await getRequiredServerFiles(netlifyConfig.build.publish)
initialConfig.config.env.NEXTAUTH_URL = mockUserDefinedSiteUrl
await updateRequiredServerFiles(netlifyConfig.build.publish, initialConfig)
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toEqual(mockUserDefinedSiteUrl)
})
test("sets the NEXTAUTH_URL to the user defined site URL when CONTEXT env variable is 'production'", async () => {
const mockUserDefinedSiteUrl = chance.url()
process.env.DEPLOY_PRIME_URL = chance.url()
process.env.URL = mockUserDefinedSiteUrl
// See https://docs.netlify.com/configure-builds/environment-variables/#build-metadata for all possible values
process.env.CONTEXT = 'production'
await moveNextDist()
const initialConfig = await getRequiredServerFiles(netlifyConfig.build.publish)
initialConfig.config.env.NEXTAUTH_URL = mockUserDefinedSiteUrl
await updateRequiredServerFiles(netlifyConfig.build.publish, initialConfig)
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toEqual(mockUserDefinedSiteUrl)
})
test('sets the NEXTAUTH_URL specified in the netlify.toml or in the Netlify UI', async () => {
const mockSiteUrl = chance.url()
process.env.NEXTAUTH_URL = mockSiteUrl
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toEqual(mockSiteUrl)
delete process.env.NEXTAUTH_URL
})
test('sets NEXTAUTH_URL when next-auth package is detected', async () => {
const mockSiteUrl = chance.url()
// Value represents the main address to the site and is either
// a Netlify subdomain or custom domain set by the user.
// See https://docs.netlify.com/configure-builds/environment-variables/#deploy-urls-and-metadata
process.env.DEPLOY_PRIME_URL = mockSiteUrl
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toEqual(mockSiteUrl)
})
test('includes the basePath on NEXTAUTH_URL when present', async () => {
const mockSiteUrl = chance.url()
process.env.DEPLOY_PRIME_URL = mockSiteUrl
await moveNextDist()
const initialConfig = await getRequiredServerFiles(netlifyConfig.build.publish)
initialConfig.config.basePath = '/foo'
await updateRequiredServerFiles(netlifyConfig.build.publish, initialConfig)
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toEqual(`${mockSiteUrl}/foo`)
})
test('skips setting NEXTAUTH_URL when next-auth package is not found', async () => {
isNextAuthInstalled.mockImplementation(() => {
return false
})
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const config = await getRequiredServerFiles(netlifyConfig.build.publish)
expect(config.config.env.NEXTAUTH_URL).toBeUndefined()
})
test('runs onBuild', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
})
test('skips if NETLIFY_NEXT_PLUGIN_SKIP is set', async () => {
process.env.NETLIFY_NEXT_PLUGIN_SKIP = 'true'
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(false)
delete process.env.NETLIFY_NEXT_PLUGIN_SKIP
})
test('skips if NEXT_PLUGIN_FORCE_RUN is "false"', async () => {
process.env.NEXT_PLUGIN_FORCE_RUN = 'false'
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(false)
delete process.env.NEXT_PLUGIN_FORCE_RUN
})
test("fails if BUILD_ID doesn't exist", async () => {
await moveNextDist()
await unlink(path.join(process.cwd(), '.next/BUILD_ID'))
const failBuild = jest.fn().mockImplementation((err) => {
throw new Error(err)
})
expect(() => nextRuntime.onBuild({ ...defaultArgs, utils: { ...utils, build: { failBuild } } })).rejects.toThrow(
`In most cases it should be set to ".next", unless you have chosen a custom "distDir" in your Next config.`,
)
expect(failBuild).toHaveBeenCalled()
})
test("fails with helpful warning if BUILD_ID doesn't exist and publish is 'out'", async () => {
await moveNextDist()
await unlink(path.join(process.cwd(), '.next/BUILD_ID'))
const failBuild = jest.fn().mockImplementation((err) => {
throw new Error(err)
})
netlifyConfig.build.publish = path.resolve('out')
expect(() => nextRuntime.onBuild({ ...defaultArgs, utils: { ...utils, build: { failBuild } } })).rejects.toThrow(
`Your publish directory is set to "out", but in most cases it should be ".next".`,
)
expect(failBuild).toHaveBeenCalled()
})
test('fails build if next export has run', async () => {
await moveNextDist()
await writeJSON(path.join(process.cwd(), '.next/export-detail.json'), {})
const failBuild = jest.fn()
await nextRuntime.onBuild({ ...defaultArgs, utils: { ...utils, build: { failBuild } } })
expect(failBuild).toHaveBeenCalled()
})
test('copy handlers to the internal functions directory', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(`.netlify/functions-internal/___netlify-handler/___netlify-handler.js`)).toBeTruthy()
expect(existsSync(`.netlify/functions-internal/___netlify-handler/bridge.js`)).toBeTruthy()
expect(existsSync(`.netlify/functions-internal/___netlify-handler/handlerUtils.js`)).toBeTruthy()
expect(existsSync(`.netlify/functions-internal/___netlify-odb-handler/___netlify-odb-handler.js`)).toBeTruthy()
expect(existsSync(`.netlify/functions-internal/___netlify-odb-handler/bridge.js`)).toBeTruthy()
expect(existsSync(`.netlify/functions-internal/___netlify-odb-handler/handlerUtils.js`)).toBeTruthy()
})
test('writes correct redirects to netlifyConfig', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
// Not ideal, because it doesn't test precedence, but unfortunately the exact order seems to
// be non-deterministic, as it depends on filesystem globbing across platforms.
const sorted = [...netlifyConfig.redirects].sort((a, b) => a.from.localeCompare(b.from))
expect(sorted).toMatchSnapshot()
})
test('publish dir is/has next dist', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.resolve('.next/BUILD_ID'))).toBeTruthy()
})
test('generates static files manifest', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const manifestPath = path.resolve('.next/static-manifest.json')
expect(existsSync(manifestPath)).toBeTruthy()
const data = (await readJson(manifestPath)).sort()
expect(data).toMatchSnapshot()
})
test('moves static files to root', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const data = JSON.parse(readFileSync(path.resolve('.next/static-manifest.json'), 'utf8'))
data.forEach(([_, file]) => {
expect(existsSync(path.resolve(path.join('.next', file)))).toBeTruthy()
expect(existsSync(path.resolve(path.join('.next', 'server', 'pages', file)))).toBeFalsy()
})
})
test('copies default locale files to top level', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const data = JSON.parse(readFileSync(path.resolve('.next/static-manifest.json'), 'utf8'))
const locale = 'en/'
data.forEach(([_, file]) => {
if (!file.startsWith(locale)) {
return
}
const trimmed = file.substring(locale.length)
expect(existsSync(path.resolve(path.join('.next', trimmed)))).toBeTruthy()
})
})
// TODO - TO BE MOVED TO TEST AGAINST A PROJECT WITH MIDDLEWARE IN ANOTHER PR
test.skip('skips static files that match middleware', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.resolve(path.join('.next', 'en', 'middle.html')))).toBeFalsy()
expect(existsSync(path.resolve(path.join('.next', 'server', 'pages', 'en', 'middle.html')))).toBeTruthy()
})
test('sets correct config', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const includes = [
'.env',
'.env.local',
'.env.production',
'.env.production.local',
'./public/locales/**',
'./next-i18next.config.js',
'.next/server/**',
'.next/serverless/**',
'.next/*.json',
'.next/BUILD_ID',
'.next/static/chunks/webpack-middleware*.js',
'!.next/server/**/*.js.nft.json',
'!.next/server/**/*.map',
'!**/node_modules/@next/swc*/**/*',
'!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*',
`!node_modules/next/dist/server/lib/squoosh/**/*.wasm`,
`!node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm`,
'!node_modules/next/dist/compiled/webpack/bundle4.js',
'!node_modules/next/dist/compiled/webpack/bundle5.js',
'!node_modules/sharp/**/*',
]
// Relative paths in Windows are different
if (os.platform() !== 'win32') {
expect(netlifyConfig.functions[HANDLER_FUNCTION_NAME].included_files).toEqual(includes)
expect(netlifyConfig.functions[ODB_FUNCTION_NAME].included_files).toEqual(includes)
}
expect(netlifyConfig.functions[HANDLER_FUNCTION_NAME].node_bundler).toEqual('nft')
expect(netlifyConfig.functions[ODB_FUNCTION_NAME].node_bundler).toEqual('nft')
})
const excludesSharp = (includedFiles) => includedFiles.some((file) => file.startsWith('!') && file.includes('sharp'))
it("doesn't exclude sharp if manually included", async () => {
await moveNextDist()
const functions = [HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*']
await nextRuntime.onBuild(defaultArgs)
// Should exclude by default
for (const func of functions) {
expect(excludesSharp(netlifyConfig.functions[func].included_files)).toBeTruthy()
}
// ...but if the user has added it, we shouldn't exclude it
for (const func of functions) {
netlifyConfig.functions[func].included_files = ['node_modules/sharp/**/*']
}
await nextRuntime.onBuild(defaultArgs)
for (const func of functions) {
expect(excludesSharp(netlifyConfig.functions[func].included_files)).toBeFalsy()
}
// ...even if it's in a subdirectory
for (const func of functions) {
netlifyConfig.functions[func].included_files = ['subdirectory/node_modules/sharp/**/*']
}
await nextRuntime.onBuild(defaultArgs)
for (const func of functions) {
expect(excludesSharp(netlifyConfig.functions[func].included_files)).toBeFalsy()
}
})
it('generates a file referencing all API route sources', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
for (const route of ['_api_hello-background-background', '_api_hello-scheduled-handler']) {
const expected = path.resolve(constants.INTERNAL_FUNCTIONS_SRC, route, 'pages.js')
expect(existsSync(expected)).toBeTruthy()
expect(readFileSync(expected, 'utf8')).toMatchSnapshot(`for ${route}`)
}
})
test('generates a file referencing all page sources', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const handlerPagesFile = path.join(constants.INTERNAL_FUNCTIONS_SRC, HANDLER_FUNCTION_NAME, 'pages.js')
const odbHandlerPagesFile = path.join(constants.INTERNAL_FUNCTIONS_SRC, ODB_FUNCTION_NAME, 'pages.js')
expect(existsSync(handlerPagesFile)).toBeTruthy()
expect(existsSync(odbHandlerPagesFile)).toBeTruthy()
expect(readFileSync(handlerPagesFile, 'utf8')).toMatchSnapshot()
expect(readFileSync(odbHandlerPagesFile, 'utf8')).toMatchSnapshot()
})
test('generates a file referencing all when publish dir is a subdirectory', async () => {
const dir = 'web/.next'
await moveNextDist(dir)
netlifyConfig.build.publish = path.resolve(dir)
const config = {
...defaultArgs,
netlifyConfig,
constants: { ...constants, PUBLISH_DIR: dir },
}
await nextRuntime.onBuild(config)
const handlerPagesFile = path.join(constants.INTERNAL_FUNCTIONS_SRC, HANDLER_FUNCTION_NAME, 'pages.js')
const odbHandlerPagesFile = path.join(constants.INTERNAL_FUNCTIONS_SRC, ODB_FUNCTION_NAME, 'pages.js')
expect(readFileSync(handlerPagesFile, 'utf8')).toMatchSnapshot()
expect(readFileSync(odbHandlerPagesFile, 'utf8')).toMatchSnapshot()
})
test('generates entrypoints with correct references', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const handlerFile = path.join(
constants.INTERNAL_FUNCTIONS_SRC,
HANDLER_FUNCTION_NAME,
`${HANDLER_FUNCTION_NAME}.js`,
)
const odbHandlerFile = path.join(constants.INTERNAL_FUNCTIONS_SRC, ODB_FUNCTION_NAME, `${ODB_FUNCTION_NAME}.js`)
expect(existsSync(handlerFile)).toBeTruthy()
expect(existsSync(odbHandlerFile)).toBeTruthy()
expect(readFileSync(handlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'ssr')`)
expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'odb')`)
expect(readFileSync(handlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`)
expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`)
})
test('handles empty routesManifest.staticRoutes', async () => {
await moveNextDist()
const manifestPath = path.resolve('.next/routes-manifest.json')
const routesManifest = await readJson(manifestPath)
delete routesManifest.staticRoutes
await writeJSON(manifestPath, routesManifest)
// The function is supposed to return undefined, but we want to check if it throws
expect(await nextRuntime.onBuild(defaultArgs)).toBeUndefined()
})
test('generates imageconfig file with entries for domains, remotePatterns, and custom response headers', async () => {
await moveNextDist()
const mockHeaderValue = chance.string()
const updatedArgs = {
...defaultArgs,
netlifyConfig: {
...defaultArgs.netlifyConfig,
headers: [
{
for: '/_next/image/',
values: {
'X-Foo': mockHeaderValue,
},
},
],
},
}
await nextRuntime.onBuild(updatedArgs)
const imageConfigPath = path.join(constants.INTERNAL_FUNCTIONS_SRC, IMAGE_FUNCTION_NAME, 'imageconfig.json')
const imageConfigJson = await readJson(imageConfigPath)
expect(imageConfigJson.domains.length).toBe(1)
expect(imageConfigJson.remotePatterns.length).toBe(1)
expect(imageConfigJson.responseHeaders).toStrictEqual({
'X-Foo': mockHeaderValue,
})
})
test('generates an ipx function by default', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.join('.netlify', 'functions-internal', '_ipx', '_ipx.js'))).toBeTruthy()
})
test('does not generate an ipx function when DISABLE_IPX is set', async () => {
process.env.DISABLE_IPX = '1'
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.join('.netlify', 'functions-internal', '_ipx', '_ipx.js'))).toBeFalsy()
delete process.env.DISABLE_IPX
})
test('creates 404 redirect when DISABLE_IPX is set', async () => {
process.env.DISABLE_IPX = '1'
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const nextImageRedirect = netlifyConfig.redirects.find((redirect) => redirect.from.includes('/_next/image'))
expect(nextImageRedirect).toBeDefined()
expect(nextImageRedirect.to).toEqual('/404.html')
expect(nextImageRedirect.status).toEqual(404)
expect(nextImageRedirect.force).toEqual(true)
delete process.env.DISABLE_IPX
})
test('generates an ipx edge function by default', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.join('.netlify', 'edge-functions', 'ipx', 'index.ts'))).toBeTruthy()
})
test('does not generate an ipx edge function if the feature is disabled', async () => {
process.env.NEXT_DISABLE_EDGE_IMAGES = '1'
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.join('.netlify', 'edge-functions', 'ipx', 'index.ts'))).toBeFalsy()
delete process.env.NEXT_DISABLE_EDGE_IMAGES
})
test('does not generate an ipx edge function if Netlify Edge is disabled', async () => {
process.env.NEXT_DISABLE_NETLIFY_EDGE = '1'
await moveNextDist()
// We need to pretend there's no edge API routes, because otherwise it'll fail
// when we try to disable edge runtime.
const manifest = path.join('.next', 'server', 'middleware-manifest.json')
const manifestContent = await readJson(manifest)
manifestContent.functions = {}
await writeJSON(manifest, manifestContent)
await nextRuntime.onBuild(defaultArgs)
expect(existsSync(path.join('.netlify', 'edge-functions', 'ipx', 'index.ts'))).toBeFalsy()
delete process.env.NEXT_DISABLE_NETLIFY_EDGE
})
test('moves static files to a subdirectory if basePath is set', async () => {
await moveNextDist()
const initialConfig = await getRequiredServerFiles(netlifyConfig.build.publish)
initialConfig.config.basePath = '/docs'
await updateRequiredServerFiles(netlifyConfig.build.publish, initialConfig)
await nextRuntime.onBuild(defaultArgs)
expect(onBuildHasRun(netlifyConfig)).toBe(true)
const publicFile = path.join(netlifyConfig.build.publish, 'docs', 'shows1.json')
expect(existsSync(publicFile)).toBe(true)
expect(await readJson(publicFile)).toMatchObject(expect.any(Array))
})
})
describe('onPostBuild', () => {
test('saves cache with right paths', async () => {
await moveNextDist()
const save = jest.fn()
await nextRuntime.onPostBuild({
...defaultArgs,
utils: { ...utils, cache: { save }, functions: { list: jest.fn().mockResolvedValue([]) } },
})
expect(save).toHaveBeenCalledWith(path.resolve('.next/cache'))
})
test('warns if old functions exist', async () => {
await moveNextDist()
const list = jest.fn().mockResolvedValue([
{
name: 'next_test',
mainFile: join(constants.INTERNAL_FUNCTIONS_SRC, 'next_test', 'next_test.js'),
runtime: 'js',
extension: '.js',
},
{
name: 'next_demo',
mainFile: join(constants.INTERNAL_FUNCTIONS_SRC, 'next_demo', 'next_demo.js'),
runtime: 'js',
extension: '.js',
},
])
const oldLog = console.log
const logMock = jest.fn()
console.log = logMock
await nextRuntime.onPostBuild({
...defaultArgs,
utils: { ...utils, cache: { save: jest.fn() }, functions: { list } },
})
expect(logMock).toHaveBeenCalledWith(
expect.stringContaining(
`We have found the following functions in your site that seem to be left over from the old Next.js plugin (v3). We have guessed this because the name starts with "next_".`,
),
)
console.log = oldLog
})
test('warns if NETLIFY_NEXT_PLUGIN_SKIP is set', async () => {
await moveNextDist()
process.env.NETLIFY_NEXT_PLUGIN_SKIP = 'true'
await moveNextDist()
const show = jest.fn()
await nextRuntime.onPostBuild({ ...defaultArgs, utils: { ...defaultArgs.utils, status: { show } } })
expect(show).toHaveBeenCalledWith({
summary: 'Next cache was stored, but all other functions were skipped because NETLIFY_NEXT_PLUGIN_SKIP is set',
title: 'Next Runtime did not run',
})
delete process.env.NETLIFY_NEXT_PLUGIN_SKIP
})
test('warns if NEXT_PLUGIN_FORCE_RUN is "false"', async () => {
await moveNextDist()
process.env.NEXT_PLUGIN_FORCE_RUN = 'false'
await moveNextDist()
const show = jest.fn()
await nextRuntime.onPostBuild({ ...defaultArgs, utils: { ...defaultArgs.utils, status: { show } } })
expect(show).toHaveBeenCalledWith({
summary:
'Next cache was stored, but all other functions were skipped because NEXT_PLUGIN_FORCE_RUN is set to false',
title: 'Next Runtime did not run',
})
delete process.env.NEXT_PLUGIN_FORCE_RUN
})
test('finds problematic user rewrites', async () => {
await moveNextDist()
const rewrites = getProblematicUserRewrites({
redirects: [
{ from: '/previous', to: '/rewrites-are-a-problem', status: 200 },
{ from: '/api', to: '/.netlify/functions/are-ok', status: 200 },
{ from: '/remote', to: 'http://example.com/proxying/is/ok', status: 200 },
{ from: '/old', to: '/redirects-are-fine' },
{ from: '/*', to: '/404-is-a-problem', status: 404 },
...netlifyConfig.redirects,
],
basePath: '',
})
expect(rewrites).toEqual([
{
from: '/previous',
status: 200,
to: '/rewrites-are-a-problem',
},
{
from: '/*',
status: 404,
to: '/404-is-a-problem',
},
])
})
test('adds headers to Netlify configuration', async () => {
await moveNextDist()
const show = jest.fn()
await nextRuntime.onPostBuild({
...defaultArgs,
utils: { ...defaultArgs.utils, status: { show }, functions: { list: jest.fn().mockResolvedValue([]) } },
})
expect(netlifyConfig.headers).toEqual([
{
for: '/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/en/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/es/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/fr/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/en/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/es/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/fr/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
{
for: '/en/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
{
for: '/es/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
{
for: '/fr/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
])
})
test('appends headers to existing headers in the Netlify configuration', async () => {
await moveNextDist()
netlifyConfig.headers = [
{
for: '/',
values: {
'x-existing-header-in-configuration': 'existing header in configuration value',
},
},
]
const show = jest.fn()
await nextRuntime.onPostBuild({
...defaultArgs,
utils: { ...defaultArgs.utils, status: { show }, functions: { list: jest.fn().mockResolvedValue([]) } },
})
expect(netlifyConfig.headers).toEqual([
{
for: '/',
values: {
'x-existing-header-in-configuration': 'existing header in configuration value',
},
},
{
for: '/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/en/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/es/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/fr/',
values: {
'x-custom-header': 'my custom header value',
},
},
{
for: '/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/en/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/es/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/fr/api/*',
values: {
'x-custom-api-header': 'my custom api header value',
},
},
{
for: '/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
{
for: '/en/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
{
for: '/es/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
{
for: '/fr/*',
values: {
'x-custom-header-for-everything': 'my custom header for everything value',
},
},
])
})
test('appends no additional headers in the Netlify configuration when none are in the routes manifest', async () => {
await moveNextDist()
netlifyConfig.headers = [
{
for: '/',
values: {
'x-existing-header-in-configuration': 'existing header in configuration value',
},
},
]
const show = jest.fn()
const manifestPath = path.resolve('.next/routes-manifest.json')
const routesManifest = await readJson(manifestPath)
delete routesManifest.headers
await writeJSON(manifestPath, routesManifest)
await nextRuntime.onPostBuild({
...defaultArgs,
utils: { ...defaultArgs.utils, status: { show }, functions: { list: jest.fn().mockResolvedValue([]) } },
})
expect(netlifyConfig.headers).toEqual([
{
for: '/',
values: {
'x-existing-header-in-configuration': 'existing header in configuration value',
},
},
])
})
})
describe('utility functions', () => {
test('middleware tester matches correct paths', () => {
const middleware = ['middle', 'sub/directory']
const paths = [
'middle.html',
'middle',
'middle/',
'middle/ware',
'sub/directory',
'sub/directory.html',
'sub/directory/child',
'sub/directory/child.html',
]
for (const path of paths) {
expect(matchMiddleware(middleware, path)).toBeTruthy()
}
})
test('middleware tester does not match incorrect paths', () => {
const middleware = ['middle', 'sub/directory']
const paths = [
'middl',
'',
'somethingelse',
'another.html',
'another/middle.html',
'sub/anotherdirectory.html',
'sub/directoryelse',
'sub/directoryelse.html',
]
for (const path of paths) {
expect(matchMiddleware(middleware, path)).toBeFalsy()
}
})
test('middleware tester matches root middleware', () => {
const middleware = ['']
const paths = [
'middl',
'',
'somethingelse',
'another.html',
'another/middle.html',
'sub/anotherdirectory.html',
'sub/directoryelse',
'sub/directoryelse.html',
]
for (const path of paths) {
expect(matchMiddleware(middleware, path)).toBeTruthy()
}
})
test('middleware tester matches root middleware', () => {
const paths = [
'middl',
'',
'somethingelse',
'another.html',
'another/middle.html',
'sub/anotherdirectory.html',
'sub/directoryelse',
'sub/directoryelse.html',
]
for (const path of paths) {
expect(matchMiddleware(undefined, path)).toBeFalsy()
}
})
test('stripLocale correctly strips matching locales', () => {
const locales = ['en', 'fr', 'en-GB']
const paths = [
['en/file.html', 'file.html'],
['fr/file.html', 'file.html'],
['en-GB/file.html', 'file.html'],
['file.html', 'file.html'],
]
for (const [path, expected] of paths) {
expect(stripLocale(path, locales)).toEqual(expected)
}
})
test('stripLocale does not touch non-matching matching locales', () => {
const locales = ['en', 'fr', 'en-GB']
const paths = ['de/file.html', 'enfile.html', 'en-US/file.html']
for (const path of paths) {
expect(stripLocale(path, locales)).toEqual(path)
}
})
test('matchesRedirect correctly matches paths with locales', () => {
const paths = ['en/redirectme.html', 'en/redirectme.json', 'fr/redirectme.html', 'fr/redirectme.json']
paths.forEach((path) => {
expect(matchesRedirect(path, REDIRECTS)).toBeTruthy()
})
})
test("matchesRedirect doesn't match paths with invalid locales", () => {
const paths = ['dk/redirectme.html', 'dk/redirectme.json', 'gr/redirectme.html', 'gr/redirectme.json']
paths.forEach((path) => {
expect(matchesRedirect(path, REDIRECTS)).toBeFalsy()
})
})
test("matchesRedirect doesn't match internal redirects", () => {
const paths = ['en/notrailingslash']
paths.forEach((path) => {
expect(matchesRedirect(path, REDIRECTS)).toBeFalsy()
})
})
it('matchesRewrite matches array of rewrites', () => {
expect(matchesRewrite('en/old/page.html', REWRITES)).toBeTruthy()
})
it('matchesRewrite matches beforeFiles rewrites', () => {
expect(matchesRewrite('en/old/page.html', { beforeFiles: REWRITES })).toBeTruthy()
})
it("matchesRewrite doesn't match afterFiles rewrites", () => {
expect(matchesRewrite('en/old/page.html', { afterFiles: REWRITES })).toBeFalsy()
})
it('matchesRewrite matches various paths', () => {
const paths = ['en/old/page.html', 'fr/old/page.html', 'en/old/deep/page.html', 'en/old.html']
paths.forEach((path) => {
expect(matchesRewrite(path, REWRITES)).toBeTruthy()
})
})
test('patches Next server files', async () => {
const root = path.resolve(dirname(__dirname))
await copy(join(root, 'package.json'), path.join(process.cwd(), 'package.json'))
await ensureDir(path.join(process.cwd(), 'node_modules'))
await copy(path.join(root, 'node_modules', 'next'), path.join(process.cwd(), 'node_modules', 'next'))
await patchNextFiles(process.cwd())
const serverFile = path.resolve(process.cwd(), 'node_modules', 'next', 'dist', 'server', 'base-server.js')
const patchedData = await readFileSync(serverFile, 'utf8')
expect(patchedData.includes('_REVALIDATE_SSG')).toBeTruthy()
expect(patchedData.includes('private: isPreviewMode && cachedData')).toBeTruthy()
await unpatchNextFiles(process.cwd())
const unPatchedData = await readFileSync(serverFile, 'utf8')
expect(unPatchedData.includes('_REVALIDATE_SSG')).toBeFalsy()
expect(unPatchedData.includes('private: isPreviewMode && cachedData')).toBeFalsy()
})
})
describe('function helpers', () => {
it('downloadFile can download a file', async () => {
const url =
'https://raw.githubusercontent.com/netlify/next-runtime/c2668af24a78eb69b33222913f44c1900a3bce23/manifest.yml'
const tmpFile = join(os.tmpdir(), 'next-test', 'downloadfile.txt')
await ensureDir(path.dirname(tmpFile))
await downloadFile(url, tmpFile)
expect(existsSync(tmpFile)).toBeTruthy()
expect(readFileSync(tmpFile, 'utf8')).toMatchInlineSnapshot(`
"name: netlify-plugin-nextjs-experimental
"
`)
await unlink(tmpFile)
})
it('downloadFile throws on bad domain', async () => {
const url = 'https://nonexistentdomain.example'
const tmpFile = join(os.tmpdir(), 'next-test', 'downloadfile.txt')
await ensureDir(path.dirname(tmpFile))
await expect(downloadFile(url, tmpFile)).rejects.toThrowErrorMatchingInlineSnapshot(
`"getaddrinfo ENOTFOUND nonexistentdomain.example"`,
)
})
it('downloadFile throws on 404', async () => {
const url = 'https://example.com/nonexistentfile'
const tmpFile = join(os.tmpdir(), 'next-test', 'downloadfile.txt')
await ensureDir(path.dirname(tmpFile))
await expect(downloadFile(url, tmpFile)).rejects.toThrowError(
'Failed to download https://example.com/nonexistentfile: 404 Not Found',
)
})
describe('config', () => {
describe('dependency tracing', () => {
it('extracts a list of all dependencies', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const dependencies = await getAllPageDependencies(constants.PUBLISH_DIR)
expect(dependencies.map((dep) => relative(process.cwd(), dep))).toMatchSnapshot()
})
it('extracts dependencies that exist', async () => {
await moveNextDist()
await nextRuntime.onBuild(defaultArgs)
const dependencies = await getAllPageDependencies(constants.PUBLISH_DIR)
const filesExist = await Promise.all(dependencies.map((dep) => pathExists(dep)))
expect(filesExist.every((exists) => exists)).toBeTruthy()
})
})
describe('generateCustomHeaders', () => {
// 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 which is why you'll see them in the paths already
// in test data.
it('sets custom headers in the Netlify configuration', () => {
const nextConfig = {
routesManifest: {
headers: [
// single header for a route
{
source: '/',
headers: [
{
key: 'X-Unit-Test',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
// multiple headers for a route
{
source: '/unit-test',
headers: [
{
key: 'X-Another-Unit-Test',
value: 'true',
},
{
key: 'X-Another-Unit-Test-Again',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
],
},
}
generateCustomHeaders(nextConfig, netlifyConfig.headers)
expect(netlifyConfig.headers).toEqual([
{
for: '/',
values: {
'X-Unit-Test': 'true',
},
},
{
for: '/unit-test',
values: {
'X-Another-Unit-Test': 'true',
'X-Another-Unit-Test-Again': 'true',
},
},
])
})
it('sets custom headers using a splat instead of a named splat in the Netlify configuration', () => {
netlifyConfig.headers = []
const nextConfig = {
routesManifest: {
headers: [
// single header for a route
{
source: '/:path*',
headers: [
{
key: 'X-Unit-Test',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
// multiple headers for a route
{
source: '/some-other-path/:path*',
headers: [
{
key: 'X-Another-Unit-Test',
value: 'true',
},
{
key: 'X-Another-Unit-Test-Again',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
{
source: '/some-other-path/yolo/:path*',
headers: [
{
key: 'X-Another-Unit-Test',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
],
},
}
generateCustomHeaders(nextConfig, netlifyConfig.headers)
expect(netlifyConfig.headers).toEqual([
{
for: '/*',
values: {
'X-Unit-Test': 'true',
},
},
{
for: '/some-other-path/*',
values: {
'X-Another-Unit-Test': 'true',
'X-Another-Unit-Test-Again': 'true',
},
},
{
for: '/some-other-path/yolo/*',
values: {
'X-Another-Unit-Test': 'true',
},
},
])
})
it('appends custom headers in the Netlify configuration', () => {
netlifyConfig.headers = [
{
for: '/',
values: {
'X-Existing-Header': 'true',
},
},
]
const nextConfig = {
routesManifest: {
headers: [
// single header for a route
{
source: '/',
headers: [
{
key: 'X-Unit-Test',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
// multiple headers for a route
{
source: '/unit-test',
headers: [
{
key: 'X-Another-Unit-Test',
value: 'true',
},
{
key: 'X-Another-Unit-Test-Again',
value: 'true',
},
],
regex: '^/(?:/)?$',
},
],
},
}
generateCustomHeaders(nextConfig, netlifyConfig.headers)
expect(netlifyConfig.headers).toEqual([
{
for: '/',
values: {
'X-Existing-Header': 'true',
},
},
{
for: '/',
values: {
'X-Unit-Test': 'true',
},
},
{
for: '/unit-test',
values: {
'X-Another-Unit-Test': 'true',
'X-Another-Unit-Test-Again': 'true',
},
},
])
})
it('sets custom headers using basePath in the Next.js configuration', () => {
netlifyConfig.headers = []