diff --git a/packages/runtime/src/helpers/files.ts b/packages/runtime/src/helpers/files.ts index 483f045597..400e9ee246 100644 --- a/packages/runtime/src/helpers/files.ts +++ b/packages/runtime/src/helpers/files.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ 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' @@ -59,11 +60,11 @@ export const matchesRewrite = (file: string, rewrites: Rewrites): boolean => { return matchesRedirect(file, rewrites.beforeFiles) } -export const getMiddleware = async (distDir: string): Promise> => { +export const getMiddleware = async (publish: string): Promise> => { if (process.env.NEXT_DISABLE_NETLIFY_EDGE !== 'true' && process.env.NEXT_DISABLE_NETLIFY_EDGE !== '1') { return [] } - const manifestPath = join(distDir, 'server', 'middleware-manifest.json') + const manifestPath = join(publish, 'server', 'middleware-manifest.json') if (existsSync(manifestPath)) { const manifest = await readJson(manifestPath, { throws: false }) return manifest?.sortedMiddleware ?? [] @@ -73,28 +74,32 @@ export const getMiddleware = async (distDir: string): Promise> => // eslint-disable-next-line max-lines-per-function export const moveStaticPages = async ({ - distDir, + netlifyConfig, + target, i18n, basePath, - publishDir, }: { - distDir: string + netlifyConfig: NetlifyConfig + target: 'server' | 'serverless' | 'experimental-serverless-trace' i18n: NextConfig['i18n'] basePath?: string - publishDir }): Promise => { console.log('Moving static page files to serve from CDN...') - const outputDir = join(distDir, 'server') + const outputDir = join(netlifyConfig.build.publish, target === 'server' ? 'server' : 'serverless') const root = join(outputDir, 'pages') - const buildId = readFileSync(join(distDir, 'BUILD_ID'), 'utf8').trim() + const buildId = readFileSync(join(netlifyConfig.build.publish, 'BUILD_ID'), 'utf8').trim() const dataDir = join('_next', 'data', buildId) - await ensureDir(join(publishDir, dataDir)) + 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(distDir) + const middlewarePaths = await getMiddleware(netlifyConfig.build.publish) const middleware = middlewarePaths.map((path) => path.slice(1)) - const prerenderManifest: PrerenderManifest = await readJson(join(distDir, 'prerender-manifest.json')) - const { redirects, rewrites }: RoutesManifest = await readJson(join(distDir, 'routes-manifest.json')) + 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() @@ -123,7 +128,7 @@ export const moveStaticPages = async ({ files.push(file) filesManifest[file] = targetPath - const dest = join(publishDir, targetPath) + const dest = join(netlifyConfig.build.publish, targetPath) try { await move(source, dest) @@ -237,10 +242,10 @@ export const moveStaticPages = async ({ } // Write the manifest for use in the serverless functions - await writeJson(join(distDir, 'static-manifest.json'), Object.entries(filesManifest)) + await writeJson(join(netlifyConfig.build.publish, 'static-manifest.json'), Object.entries(filesManifest)) if (i18n?.defaultLocale) { - const rootPath = basePath ? join(publishDir, basePath) : publishDir + 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)) { @@ -422,13 +427,12 @@ export const unpatchNextFiles = async (root: string): Promise => { export const movePublicFiles = async ({ appDir, outdir, - publishDir, + publish, }: { appDir: string outdir?: string - publishDir: string + publish: string }): Promise => { - await ensureDir(publishDir) // `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/', and // the parent directory of the .next directory. @@ -437,7 +441,7 @@ export const movePublicFiles = async ({ // directory from the original app directory. const publicDir = outdir ? join(appDir, outdir, 'public') : join(appDir, 'public') if (existsSync(publicDir)) { - await copy(publicDir, `${publishDir}/`) + await copy(publicDir, `${publish}/`) } } /* eslint-enable max-lines */ diff --git a/packages/runtime/src/helpers/redirects.ts b/packages/runtime/src/helpers/redirects.ts index bf8b14e7be..764117596e 100644 --- a/packages/runtime/src/helpers/redirects.ts +++ b/packages/runtime/src/helpers/redirects.ts @@ -7,7 +7,7 @@ import type { PrerenderManifest, SsgRoute } from 'next/dist/build' import { outdent } from 'outdent' import { join } from 'pathe' -import { HANDLER_FUNCTION_PATH, ODB_FUNCTION_PATH } from '../constants' +import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants' import { getMiddleware } from './files' import { ApiRouteConfig } from './functions' @@ -25,6 +25,14 @@ import { 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, @@ -58,6 +66,21 @@ const generateLocaleRedirects = ({ 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 @@ -220,6 +243,8 @@ export const generateRedirects = async ({ join(netlifyConfig.build.publish, 'routes-manifest.json'), ) + netlifyConfig.redirects.push(...generateHiddenPathRedirects({ basePath })) + if (i18n && i18n.localeDetection !== false) { netlifyConfig.redirects.push(...generateLocaleRedirects({ i18n, basePath, trailingSlash })) } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1cacd5b9dd..90ffcb43b6 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -4,7 +4,7 @@ import { join, relative } from 'path' import type { NetlifyPlugin } from '@netlify/build' import { bold, redBright } from 'chalk' import destr from 'destr' -import { copy, ensureDir, existsSync, readFileSync } from 'fs-extra' +import { existsSync, readFileSync } from 'fs-extra' import { outdent } from 'outdent' import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME } from './constants' @@ -26,7 +26,7 @@ import { getExtendedApiRouteConfigs, warnOnApiRoutes, } from './helpers/functions' -import { generateRedirects } from './helpers/redirects' +import { generateRedirects, generateStaticRedirects } from './helpers/redirects' import { shouldSkip, isNextAuthInstalled, getCustomImageResponseHeaders, getRemotePatterns } from './helpers/utils' import { verifyNetlifyBuildVersion, @@ -80,18 +80,12 @@ const plugin: NetlifyPlugin = { checkNextSiteHasBuilt({ publish, failBuild }) - const { appDir, basePath, i18n, images, target, ignore, trailingSlash, outdir, experimental, distDir } = - await getNextConfig({ + const { appDir, basePath, i18n, images, target, ignore, trailingSlash, outdir, experimental } = await getNextConfig( + { publish, failBuild, - }) - - const dotNextDir = join(appDir, distDir) - - // This is the *generated* publish dir. The user specifies .next, be we actually use this subdirectory - const publishDir = join(dotNextDir, 'dist') - await ensureDir(publishDir) - + }, + ) await cleanupEdgeFunctions(constants) const middlewareManifest = await loadMiddlewareManifest(netlifyConfig) @@ -123,7 +117,7 @@ const plugin: NetlifyPlugin = { } if (isNextAuthInstalled()) { - const config = await getRequiredServerFiles(dotNextDir) + const config = await getRequiredServerFiles(publish) const userDefinedNextAuthUrl = config.config.env.NEXTAUTH_URL @@ -140,7 +134,7 @@ const plugin: NetlifyPlugin = { ) config.config.env.NEXTAUTH_URL = nextAuthUrl - await updateRequiredServerFiles(dotNextDir, config) + await updateRequiredServerFiles(publish, config) } else { // Using the deploy prime url in production leads to issues because the unique deploy ID is part of the generated URL // and will not match the expected URL in the callback URL of an OAuth application. @@ -151,27 +145,30 @@ const plugin: NetlifyPlugin = { console.log(`NextAuth package detected, setting NEXTAUTH_URL environment variable to ${nextAuthUrl}`) config.config.env.NEXTAUTH_URL = nextAuthUrl - await updateRequiredServerFiles(dotNextDir, config) + await updateRequiredServerFiles(publish, config) } } - const buildId = readFileSync(join(dotNextDir, 'BUILD_ID'), 'utf8').trim() + const buildId = readFileSync(join(publish, 'BUILD_ID'), 'utf8').trim() - await configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), dotNextDir) }) - const apiRoutes = await getExtendedApiRouteConfigs(dotNextDir, appDir) + await configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), publish) }) + const apiRoutes = await getExtendedApiRouteConfigs(publish, appDir) await generateFunctions(constants, appDir, apiRoutes) await generatePagesResolver({ target, constants }) - await movePublicFiles({ appDir, outdir, publishDir }) + await movePublicFiles({ appDir, outdir, publish }) await patchNextFiles(appDir) if (!destr(process.env.SERVE_STATIC_FILES_FROM_ORIGIN)) { - await moveStaticPages({ distDir: dotNextDir, i18n, basePath, publishDir }) + await moveStaticPages({ target, netlifyConfig, i18n, basePath }) } - await copy(join(dotNextDir, 'static'), join(publishDir, '_next', 'static')) + await generateStaticRedirects({ + netlifyConfig, + nextConfig: { basePath, i18n }, + }) await setupImageFunction({ constants, @@ -193,16 +190,20 @@ const plugin: NetlifyPlugin = { }, async onPostBuild({ - netlifyConfig, + netlifyConfig: { + build: { publish }, + redirects, + headers, + }, utils: { status, cache, functions, build: { failBuild }, }, - constants: { FUNCTIONS_DIST, PUBLISH_DIR }, + constants: { FUNCTIONS_DIST }, }) { - await saveCache({ cache, publish: netlifyConfig.build.publish }) + await saveCache({ cache, publish }) if (shouldSkip()) { status.show({ @@ -218,16 +219,15 @@ const plugin: NetlifyPlugin = { await checkForOldFunctions({ functions }) await checkZipSize(join(FUNCTIONS_DIST, `${ODB_FUNCTION_NAME}.zip`)) - const nextConfig = await getNextConfig({ publish: netlifyConfig.build.publish, failBuild }) + const nextConfig = await getNextConfig({ publish, failBuild }) const { basePath, appDir } = nextConfig - generateCustomHeaders(nextConfig, netlifyConfig.headers) + generateCustomHeaders(nextConfig, headers) - warnForProblematicUserRewrites({ basePath, redirects: netlifyConfig.redirects }) + warnForProblematicUserRewrites({ basePath, redirects }) warnForRootRedirects({ appDir }) await warnOnApiRoutes({ FUNCTIONS_DIST }) - netlifyConfig.build.publish = join(PUBLISH_DIR, 'dist') }, } // The types haven't been updated yet diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index da9a561c1a..3e0a8fc8ec 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -1086,6 +1086,16 @@ Array [ "status": 301, "to": "/_ipx/w_:width,q_:quality/:url", }, + Object { + "from": "/_next/static/*", + "status": 200, + "to": "/static/:splat", + }, + Object { + "from": "/:locale/_next/static/*", + "status": 200, + "to": "/static/:splat", + }, Object { "conditions": Object { "Cookie": Array [ @@ -1130,6 +1140,24 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/BUILD_ID", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/build-manifest.json", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/cache/*", + "status": 404, + "to": "/404.html", + }, Object { "force": false, "from": "/css", @@ -1682,24 +1710,54 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/prerender-manifest.json", + "status": 404, + "to": "/404.html", + }, Object { "force": false, "from": "/previewTest", "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/react-loadable-manifest.json", + "status": 404, + "to": "/404.html", + }, Object { "force": false, "from": "/redirectme", "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/routes-manifest.json", + "status": 404, + "to": "/404.html", + }, Object { "force": false, "from": "/script", "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/server/*", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/serverless/*", + "status": 404, + "to": "/404.html", + }, Object { "force": false, "from": "/shows/:id", @@ -1724,5 +1782,17 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "force": true, + "from": "/trace", + "status": 404, + "to": "/404.html", + }, + Object { + "force": true, + "from": "/traces", + "status": 404, + "to": "/404.html", + }, ] `; diff --git a/test/index.js b/test/index.js index b85deadaaa..5d0f634660 100644 --- a/test/index.js +++ b/test/index.js @@ -107,22 +107,21 @@ const changeCwd = function (cwd) { const onBuildHasRun = (netlifyConfig) => Boolean(netlifyConfig.functions[HANDLER_FUNCTION_NAME]?.included_files?.some((file) => file.includes('BUILD_ID'))) -const rewriteAppDir = async function (dir = '.next', appDir) { - const manifest = path.join(appDir, dir, 'required-server-files.json') +const rewriteAppDir = async function (dir = '.next') { + const manifest = path.join(dir, 'required-server-files.json') const manifestContent = await readJson(manifest) - manifestContent.appDir = appDir + manifestContent.appDir = process.cwd() await writeJSON(manifest, manifestContent) } // Move .next from sample project to current directory -export const moveNextDist = async function (dotNext = '.next', app = '.') { - const appDir = path.join(process.cwd(), app) +export const moveNextDist = async function (dir = '.next') { await stubModules(['next', 'sharp']) - await ensureDir(dirname(dotNext)) - await copy(path.join(SAMPLE_PROJECT_DIR, '.next'), path.join(appDir, dotNext)) - await copy(path.join(SAMPLE_PROJECT_DIR, 'pages'), path.join(appDir, 'pages')) - await rewriteAppDir(dotNext, appDir) + await ensureDir(dirname(dir)) + await copy(path.join(SAMPLE_PROJECT_DIR, '.next'), path.join(process.cwd(), dir)) + await copy(path.join(SAMPLE_PROJECT_DIR, 'pages'), path.join(process.cwd(), 'pages')) + await rewriteAppDir(dir) } const stubModules = async function (modules) { @@ -467,17 +466,17 @@ describe('onBuild()', () => { expect(data).toMatchSnapshot() }) - test('moves static files to dist', async () => { + 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', 'dist', file)))).toBeTruthy() + 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 in dist', async () => { + 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')) @@ -489,7 +488,7 @@ describe('onBuild()', () => { return } const trimmed = file.substring(locale.length) - expect(existsSync(path.resolve(path.join('.next', 'dist', trimmed)))).toBeTruthy() + expect(existsSync(path.resolve(path.join('.next', trimmed)))).toBeTruthy() }) }) @@ -597,14 +596,13 @@ describe('onBuild()', () => { }) test('generates a file referencing all when publish dir is a subdirectory', async () => { - const dotNext = '.next' - const app = 'web' - await moveNextDist(dotNext, app) - netlifyConfig.build.publish = path.resolve(app, dotNext) + const dir = 'web/.next' + await moveNextDist(dir) + netlifyConfig.build.publish = path.resolve(dir) const config = { ...defaultArgs, netlifyConfig, - constants: { ...constants, PUBLISH_DIR: path.join(app, dotNext) }, + constants: { ...constants, PUBLISH_DIR: dir }, } await nextRuntime.onBuild(config) const handlerPagesFile = path.join(constants.INTERNAL_FUNCTIONS_SRC, HANDLER_FUNCTION_NAME, 'pages.js')