diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 7bf978f869691..a0dc62462b8ac 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -11,7 +11,7 @@ import { pathToRegexp } from 'next/dist/compiled/path-to-regexp' import path from 'path' import formatWebpackMessages from '../client/dev/error-overlay/format-webpack-messages' import { - PAGES_404_GET_INITIAL_PROPS_ERROR, + STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR, PUBLIC_DIR_MIDDLEWARE_CONFLICT, } from '../lib/constants' import { fileExists } from '../lib/file-exists' @@ -41,6 +41,7 @@ import { SERVERLESS_DIRECTORY, SERVER_DIRECTORY, SERVER_FILES_MANIFEST, + STATIC_STATUS_PAGES, } from '../next-server/lib/constants' import { getRouteRegex, @@ -742,7 +743,9 @@ export default async function build( !workerResult.isStatic && !workerResult.hasStaticProps ) { - throw new Error(PAGES_404_GET_INITIAL_PROPS_ERROR) + throw new Error( + `\`pages/404\` ${STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR}` + ) } // we need to ensure the 404 lambda is present since we use // it when _app has getInitialProps @@ -753,6 +756,16 @@ export default async function build( staticPages.delete(page) } } + + if ( + STATIC_STATUS_PAGES.includes(page) && + !workerResult.isStatic && + !workerResult.hasStaticProps + ) { + throw new Error( + `\`pages${page}\` ${STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR}` + ) + } } catch (err) { if (err.message !== 'INVALID_DEFAULT_EXPORT') throw err invalidPages.add(page) @@ -891,400 +904,412 @@ export default async function build( const { i18n } = config + const usedStaticStatusPages = STATIC_STATUS_PAGES.filter( + (page) => + mappedPages[page] && mappedPages[page].startsWith('private-next-pages') + ) + usedStaticStatusPages.forEach((page) => { + if (!ssgPages.has(page)) { + staticPages.add(page) + } + }) + + const hasPages500 = usedStaticStatusPages.includes('/500') + await traceAsyncFn(tracer.startSpan('static-generation'), async () => { - if (staticPages.size > 0 || ssgPages.size > 0 || useStatic404) { - const combinedPages = [...staticPages, ...ssgPages] - - detectConflictingPaths( - [ - ...combinedPages, - ...pageKeys.filter((page) => !combinedPages.includes(page)), - ], - ssgPages, - additionalSsgPaths - ) - const exportApp = require('../export').default - const exportOptions = { - silent: false, - buildExport: true, - threads: config.experimental.cpus, - pages: combinedPages, - outdir: path.join(distDir, 'export'), - statusMessage: 'Generating static pages', - } - const exportConfig: any = { - ...config, - initialPageRevalidationMap: {}, - ssgNotFoundPaths: [] as string[], - // Default map will be the collection of automatic statically exported - // pages and incremental pages. - // n.b. we cannot handle this above in combinedPages because the dynamic - // page must be in the `pages` array, but not in the mapping. - exportPathMap: (defaultMap: any) => { - // Dynamically routed pages should be prerendered to be used as - // a client-side skeleton (fallback) while data is being fetched. - // This ensures the end-user never sees a 500 or slow response from the - // server. - // - // Note: prerendering disables automatic static optimization. - ssgPages.forEach((page) => { - if (isDynamicRoute(page)) { - tbdPrerenderRoutes.push(page) - - if (ssgStaticFallbackPages.has(page)) { - // Override the rendering for the dynamic page to be treated as a - // fallback render. - if (i18n) { - defaultMap[`/${i18n.defaultLocale}${page}`] = { - page, - query: { __nextFallback: true }, - } - } else { - defaultMap[page] = { page, query: { __nextFallback: true } } + const combinedPages = [...staticPages, ...ssgPages] + + detectConflictingPaths( + [ + ...combinedPages, + ...pageKeys.filter((page) => !combinedPages.includes(page)), + ], + ssgPages, + additionalSsgPaths + ) + const exportApp = require('../export').default + const exportOptions = { + silent: false, + buildExport: true, + threads: config.experimental.cpus, + pages: combinedPages, + outdir: path.join(distDir, 'export'), + statusMessage: 'Generating static pages', + } + const exportConfig: any = { + ...config, + initialPageRevalidationMap: {}, + ssgNotFoundPaths: [] as string[], + // Default map will be the collection of automatic statically exported + // pages and incremental pages. + // n.b. we cannot handle this above in combinedPages because the dynamic + // page must be in the `pages` array, but not in the mapping. + exportPathMap: (defaultMap: any) => { + // Dynamically routed pages should be prerendered to be used as + // a client-side skeleton (fallback) while data is being fetched. + // This ensures the end-user never sees a 500 or slow response from the + // server. + // + // Note: prerendering disables automatic static optimization. + ssgPages.forEach((page) => { + if (isDynamicRoute(page)) { + tbdPrerenderRoutes.push(page) + + if (ssgStaticFallbackPages.has(page)) { + // Override the rendering for the dynamic page to be treated as a + // fallback render. + if (i18n) { + defaultMap[`/${i18n.defaultLocale}${page}`] = { + page, + query: { __nextFallback: true }, } } else { - // Remove dynamically routed pages from the default path map when - // fallback behavior is disabled. - delete defaultMap[page] + defaultMap[page] = { page, query: { __nextFallback: true } } } + } else { + // Remove dynamically routed pages from the default path map when + // fallback behavior is disabled. + delete defaultMap[page] + } + } + }) + // Append the "well-known" routes we should prerender for, e.g. blog + // post slugs. + additionalSsgPaths.forEach((routes, page) => { + const encodedRoutes = additionalSsgPathsEncoded.get(page) + + routes.forEach((route, routeIdx) => { + defaultMap[route] = { + page, + query: { __nextSsgPath: encodedRoutes?.[routeIdx] }, } }) - // Append the "well-known" routes we should prerender for, e.g. blog - // post slugs. - additionalSsgPaths.forEach((routes, page) => { - const encodedRoutes = additionalSsgPathsEncoded.get(page) - - routes.forEach((route, routeIdx) => { - defaultMap[route] = { - page, - query: { __nextSsgPath: encodedRoutes?.[routeIdx] }, - } - }) - }) + }) - if (useStatic404) { - defaultMap['/404'] = { - page: hasPages404 ? '/404' : '/_error', - } + if (useStatic404) { + defaultMap['/404'] = { + page: hasPages404 ? '/404' : '/_error', } + } - if (i18n) { - for (const page of [ - ...staticPages, - ...ssgPages, - ...(useStatic404 ? ['/404'] : []), - ]) { - const isSsg = ssgPages.has(page) - const isDynamic = isDynamicRoute(page) - const isFallback = isSsg && ssgStaticFallbackPages.has(page) - - for (const locale of i18n.locales) { - // skip fallback generation for SSG pages without fallback mode - if (isSsg && isDynamic && !isFallback) continue - const outputPath = `/${locale}${page === '/' ? '' : page}` - - defaultMap[outputPath] = { - page: defaultMap[page]?.page || page, - query: { __nextLocale: locale }, - } + // ensure 500.html is always generated even if pages/500.html + // doesn't exist + if (!hasPages500) { + defaultMap['/500'] = { + page: '/_error', + } + } - if (isFallback) { - defaultMap[outputPath].query.__nextFallback = true - } + if (i18n) { + for (const page of [ + ...staticPages, + ...ssgPages, + ...(useStatic404 ? ['/404'] : []), + ...(!hasPages500 ? ['/500'] : []), + ]) { + const isSsg = ssgPages.has(page) + const isDynamic = isDynamicRoute(page) + const isFallback = isSsg && ssgStaticFallbackPages.has(page) + + for (const locale of i18n.locales) { + // skip fallback generation for SSG pages without fallback mode + if (isSsg && isDynamic && !isFallback) continue + const outputPath = `/${locale}${page === '/' ? '' : page}` + + defaultMap[outputPath] = { + page: defaultMap[page]?.page || page, + query: { __nextLocale: locale }, } - if (isSsg) { - // remove non-locale prefixed variant from defaultMap - delete defaultMap[page] + if (isFallback) { + defaultMap[outputPath].query.__nextFallback = true } } - } - return defaultMap - }, - trailingSlash: false, - } - - await exportApp(dir, exportOptions, exportConfig) + if (isSsg) { + // remove non-locale prefixed variant from defaultMap + delete defaultMap[page] + } + } + } + return defaultMap + }, + trailingSlash: false, + } - const postBuildSpinner = createSpinner({ - prefixText: `${Log.prefixes.info} Finalizing page optimization`, - }) - ssgNotFoundPaths = exportConfig.ssgNotFoundPaths + await exportApp(dir, exportOptions, exportConfig) - // remove server bundles that were exported - for (const page of staticPages) { - const serverBundle = getPagePath(page, distDir, isLikeServerless) - await promises.unlink(serverBundle) - } - const serverOutputDir = path.join( - distDir, - isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY - ) + const postBuildSpinner = createSpinner({ + prefixText: `${Log.prefixes.info} Finalizing page optimization`, + }) + ssgNotFoundPaths = exportConfig.ssgNotFoundPaths - const moveExportedPage = async ( - originPage: string, - page: string, - file: string, - isSsg: boolean, - ext: 'html' | 'json', - additionalSsgFile = false - ) => { - return traceAsyncFn( - tracer.startSpan('move-exported-page'), - async () => { - file = `${file}.${ext}` - const orig = path.join(exportOptions.outdir, file) - const pagePath = getPagePath( - originPage, - distDir, - isLikeServerless - ) + // remove server bundles that were exported + for (const page of staticPages) { + const serverBundle = getPagePath(page, distDir, isLikeServerless) + await promises.unlink(serverBundle) + } + const serverOutputDir = path.join( + distDir, + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY + ) - const relativeDest = path - .relative( - serverOutputDir, + const moveExportedPage = async ( + originPage: string, + page: string, + file: string, + isSsg: boolean, + ext: 'html' | 'json', + additionalSsgFile = false + ) => { + return traceAsyncFn( + tracer.startSpan('move-exported-page'), + async () => { + file = `${file}.${ext}` + const orig = path.join(exportOptions.outdir, file) + const pagePath = getPagePath(originPage, distDir, isLikeServerless) + + const relativeDest = path + .relative( + serverOutputDir, + path.join( path.join( - path.join( - pagePath, - // strip leading / and then recurse number of nested dirs - // to place from base folder - originPage - .substr(1) - .split('/') - .map(() => '..') - .join('/') - ), - file - ) + pagePath, + // strip leading / and then recurse number of nested dirs + // to place from base folder + originPage + .substr(1) + .split('/') + .map(() => '..') + .join('/') + ), + file ) - .replace(/\\/g, '/') - - const dest = path.join( - distDir, - isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, - relativeDest ) + .replace(/\\/g, '/') - if (!isSsg) { - pagesManifest[page] = relativeDest - } + const dest = path.join( + distDir, + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, + relativeDest + ) - const isNotFound = ssgNotFoundPaths.includes(page) - - // for SSG files with i18n the non-prerendered variants are - // output with the locale prefixed so don't attempt moving - // without the prefix - if ((!i18n || additionalSsgFile) && !isNotFound) { - await promises.mkdir(path.dirname(dest), { recursive: true }) - await promises.rename(orig, dest) - } else if (i18n && !isSsg) { - // this will be updated with the locale prefixed variant - // since all files are output with the locale prefix - delete pagesManifest[page] - } + if ( + !isSsg && + !( + // don't add static status page to manifest if it's + // the default generated version e.g. no pages/500 + ( + STATIC_STATUS_PAGES.includes(page) && + !usedStaticStatusPages.includes(page) + ) + ) + ) { + pagesManifest[page] = relativeDest + } - if (i18n) { - if (additionalSsgFile) return + const isNotFound = ssgNotFoundPaths.includes(page) + + // for SSG files with i18n the non-prerendered variants are + // output with the locale prefixed so don't attempt moving + // without the prefix + if ((!i18n || additionalSsgFile) && !isNotFound) { + await promises.mkdir(path.dirname(dest), { recursive: true }) + await promises.rename(orig, dest) + } else if (i18n && !isSsg) { + // this will be updated with the locale prefixed variant + // since all files are output with the locale prefix + delete pagesManifest[page] + } - for (const locale of i18n.locales) { - const curPath = `/${locale}${page === '/' ? '' : page}` - const localeExt = page === '/' ? path.extname(file) : '' - const relativeDestNoPages = relativeDest.substr( - 'pages/'.length - ) + if (i18n) { + if (additionalSsgFile) return - if (isSsg && ssgNotFoundPaths.includes(curPath)) { - continue - } + for (const locale of i18n.locales) { + const curPath = `/${locale}${page === '/' ? '' : page}` + const localeExt = page === '/' ? path.extname(file) : '' + const relativeDestNoPages = relativeDest.substr('pages/'.length) - const updatedRelativeDest = path - .join( - 'pages', - locale + localeExt, - // if it's the top-most index page we want it to be locale.EXT - // instead of locale/index.html - page === '/' ? '' : relativeDestNoPages - ) - .replace(/\\/g, '/') + if (isSsg && ssgNotFoundPaths.includes(curPath)) { + continue + } - const updatedOrig = path.join( - exportOptions.outdir, + const updatedRelativeDest = path + .join( + 'pages', locale + localeExt, - page === '/' ? '' : file - ) - const updatedDest = path.join( - distDir, - isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, - updatedRelativeDest + // if it's the top-most index page we want it to be locale.EXT + // instead of locale/index.html + page === '/' ? '' : relativeDestNoPages ) + .replace(/\\/g, '/') - if (!isSsg) { - pagesManifest[curPath] = updatedRelativeDest - } - await promises.mkdir(path.dirname(updatedDest), { - recursive: true, - }) - await promises.rename(updatedOrig, updatedDest) + const updatedOrig = path.join( + exportOptions.outdir, + locale + localeExt, + page === '/' ? '' : file + ) + const updatedDest = path.join( + distDir, + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, + updatedRelativeDest + ) + + if (!isSsg) { + pagesManifest[curPath] = updatedRelativeDest } + await promises.mkdir(path.dirname(updatedDest), { + recursive: true, + }) + await promises.rename(updatedOrig, updatedDest) } } - ) - } + } + ) + } - // Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page - if (!hasPages404 && useStatic404) { - await moveExportedPage('/_error', '/404', '/404', false, 'html') - } + // Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page + if (!hasPages404 && useStatic404) { + await moveExportedPage('/_error', '/404', '/404', false, 'html') + } - for (const page of combinedPages) { - const isSsg = ssgPages.has(page) - const isStaticSsgFallback = ssgStaticFallbackPages.has(page) - const isDynamic = isDynamicRoute(page) - const hasAmp = hybridAmpPages.has(page) - const file = normalizePagePath(page) + if (!hasPages500) { + await moveExportedPage('/_error', '/500', '/500', false, 'html') + } - // The dynamic version of SSG pages are only prerendered if the - // fallback is enabled. Below, we handle the specific prerenders - // of these. - const hasHtmlOutput = !(isSsg && isDynamic && !isStaticSsgFallback) + for (const page of combinedPages) { + const isSsg = ssgPages.has(page) + const isStaticSsgFallback = ssgStaticFallbackPages.has(page) + const isDynamic = isDynamicRoute(page) + const hasAmp = hybridAmpPages.has(page) + const file = normalizePagePath(page) - if (hasHtmlOutput) { - await moveExportedPage(page, page, file, isSsg, 'html') - } + // The dynamic version of SSG pages are only prerendered if the + // fallback is enabled. Below, we handle the specific prerenders + // of these. + const hasHtmlOutput = !(isSsg && isDynamic && !isStaticSsgFallback) - if (hasAmp && (!isSsg || (isSsg && !isDynamic))) { - const ampPage = `${file}.amp` - await moveExportedPage(page, ampPage, ampPage, isSsg, 'html') + if (hasHtmlOutput) { + await moveExportedPage(page, page, file, isSsg, 'html') + } - if (isSsg) { - await moveExportedPage(page, ampPage, ampPage, isSsg, 'json') - } - } + if (hasAmp && (!isSsg || (isSsg && !isDynamic))) { + const ampPage = `${file}.amp` + await moveExportedPage(page, ampPage, ampPage, isSsg, 'html') if (isSsg) { - // For a non-dynamic SSG page, we must copy its data file - // from export, we already moved the HTML file above - if (!isDynamic) { - await moveExportedPage(page, page, file, isSsg, 'json') - - if (i18n) { - // TODO: do we want to show all locale variants in build output - for (const locale of i18n.locales) { - const localePage = `/${locale}${page === '/' ? '' : page}` - - if (!ssgNotFoundPaths.includes(localePage)) { - finalPrerenderRoutes[localePage] = { - initialRevalidateSeconds: - exportConfig.initialPageRevalidationMap[localePage], - srcRoute: null, - dataRoute: path.posix.join( - '/_next/data', - buildId, - `${file}.json` - ), - } + await moveExportedPage(page, ampPage, ampPage, isSsg, 'json') + } + } + + if (isSsg) { + // For a non-dynamic SSG page, we must copy its data file + // from export, we already moved the HTML file above + if (!isDynamic) { + await moveExportedPage(page, page, file, isSsg, 'json') + + if (i18n) { + // TODO: do we want to show all locale variants in build output + for (const locale of i18n.locales) { + const localePage = `/${locale}${page === '/' ? '' : page}` + + if (!ssgNotFoundPaths.includes(localePage)) { + finalPrerenderRoutes[localePage] = { + initialRevalidateSeconds: + exportConfig.initialPageRevalidationMap[localePage], + srcRoute: null, + dataRoute: path.posix.join( + '/_next/data', + buildId, + `${file}.json` + ), } } - } else { - finalPrerenderRoutes[page] = { - initialRevalidateSeconds: - exportConfig.initialPageRevalidationMap[page], - srcRoute: null, - dataRoute: path.posix.join( - '/_next/data', - buildId, - `${file}.json` - ), - } - } - // Set Page Revalidation Interval - const pageInfo = pageInfos.get(page) - if (pageInfo) { - pageInfo.initialRevalidateSeconds = - exportConfig.initialPageRevalidationMap[page] - pageInfos.set(page, pageInfo) } } else { - // For a dynamic SSG page, we did not copy its data exports and only - // copy the fallback HTML file (if present). - // We must also copy specific versions of this page as defined by - // `getStaticPaths` (additionalSsgPaths). - const extraRoutes = additionalSsgPaths.get(page) || [] - for (const route of extraRoutes) { - const pageFile = normalizePagePath(route) + finalPrerenderRoutes[page] = { + initialRevalidateSeconds: + exportConfig.initialPageRevalidationMap[page], + srcRoute: null, + dataRoute: path.posix.join( + '/_next/data', + buildId, + `${file}.json` + ), + } + } + // Set Page Revalidation Interval + const pageInfo = pageInfos.get(page) + if (pageInfo) { + pageInfo.initialRevalidateSeconds = + exportConfig.initialPageRevalidationMap[page] + pageInfos.set(page, pageInfo) + } + } else { + // For a dynamic SSG page, we did not copy its data exports and only + // copy the fallback HTML file (if present). + // We must also copy specific versions of this page as defined by + // `getStaticPaths` (additionalSsgPaths). + const extraRoutes = additionalSsgPaths.get(page) || [] + for (const route of extraRoutes) { + const pageFile = normalizePagePath(route) + await moveExportedPage(page, route, pageFile, isSsg, 'html', true) + await moveExportedPage(page, route, pageFile, isSsg, 'json', true) + + if (hasAmp) { + const ampPage = `${pageFile}.amp` await moveExportedPage( page, - route, - pageFile, + ampPage, + ampPage, isSsg, 'html', true ) await moveExportedPage( page, - route, - pageFile, + ampPage, + ampPage, isSsg, 'json', true ) + } - if (hasAmp) { - const ampPage = `${pageFile}.amp` - await moveExportedPage( - page, - ampPage, - ampPage, - isSsg, - 'html', - true - ) - await moveExportedPage( - page, - ampPage, - ampPage, - isSsg, - 'json', - true - ) - } - - finalPrerenderRoutes[route] = { - initialRevalidateSeconds: - exportConfig.initialPageRevalidationMap[route], - srcRoute: page, - dataRoute: path.posix.join( - '/_next/data', - buildId, - `${normalizePagePath(route)}.json` - ), - } + finalPrerenderRoutes[route] = { + initialRevalidateSeconds: + exportConfig.initialPageRevalidationMap[route], + srcRoute: page, + dataRoute: path.posix.join( + '/_next/data', + buildId, + `${normalizePagePath(route)}.json` + ), + } - // Set route Revalidation Interval - const pageInfo = pageInfos.get(route) - if (pageInfo) { - pageInfo.initialRevalidateSeconds = - exportConfig.initialPageRevalidationMap[route] - pageInfos.set(route, pageInfo) - } + // Set route Revalidation Interval + const pageInfo = pageInfos.get(route) + if (pageInfo) { + pageInfo.initialRevalidateSeconds = + exportConfig.initialPageRevalidationMap[route] + pageInfos.set(route, pageInfo) } } } } + } - // remove temporary export folder - await recursiveDelete(exportOptions.outdir) - await promises.rmdir(exportOptions.outdir) - await promises.writeFile( - manifestPath, - JSON.stringify(pagesManifest, null, 2), - 'utf8' - ) + // remove temporary export folder + await recursiveDelete(exportOptions.outdir) + await promises.rmdir(exportOptions.outdir) + await promises.writeFile( + manifestPath, + JSON.stringify(pagesManifest, null, 2), + 'utf8' + ) - if (postBuildSpinner) postBuildSpinner.stopAndPersist() - console.log() - } + if (postBuildSpinner) postBuildSpinner.stopAndPersist() + console.log() }) const analysisEnd = process.hrtime(analysisBegin) diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index 01e17ade79006..ca2eaa5734db9 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -189,6 +189,10 @@ export default async function exportPage({ ...headerMocks, } as unknown) as ServerResponse + if (path === '/500' && page === '/_error') { + res.statusCode = 500 + } + envConfig.setConfig({ serverRuntimeConfig, publicRuntimeConfig: renderOpts.runtimeConfig, diff --git a/packages/next/lib/constants.ts b/packages/next/lib/constants.ts index 9d202591121d7..878779267890f 100644 --- a/packages/next/lib/constants.ts +++ b/packages/next/lib/constants.ts @@ -30,7 +30,7 @@ export const SERVER_PROPS_GET_INIT_PROPS_CONFLICT = `You can not use getInitialP export const SERVER_PROPS_SSG_CONFLICT = `You can not use getStaticProps or getStaticPaths with getServerSideProps. To use SSG, please remove getServerSideProps` -export const PAGES_404_GET_INITIAL_PROPS_ERROR = `\`pages/404\` can not have getInitialProps/getServerSideProps, https://err.sh/next.js/404-get-initial-props` +export const STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR = `can not have getInitialProps/getServerSideProps, https://err.sh/next.js/404-get-initial-props` export const SERVER_PROPS_EXPORT_ERROR = `pages with \`getServerSideProps\` can not be exported. See more info here: https://err.sh/next.js/gssp-export` diff --git a/packages/next/next-server/lib/constants.ts b/packages/next/next-server/lib/constants.ts index 03a4fc100705b..c07b817533ce2 100644 --- a/packages/next/next-server/lib/constants.ts +++ b/packages/next/next-server/lib/constants.ts @@ -38,3 +38,4 @@ export const PERMANENT_REDIRECT_STATUS = 308 export const STATIC_PROPS_ID = '__N_SSG' export const SERVER_PROPS_ID = '__N_SSP' export const OPTIMIZED_FONT_PROVIDERS = ['https://fonts.googleapis.com/css'] +export const STATIC_STATUS_PAGES = ['/500'] diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 970ab91d2510b..0b957150e62e2 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -32,6 +32,7 @@ import { ROUTES_MANIFEST, SERVERLESS_DIRECTORY, SERVER_DIRECTORY, + STATIC_STATUS_PAGES, TEMPORARY_REDIRECT_STATUS, } from '../lib/constants' import { @@ -1364,6 +1365,12 @@ export default class Server { res.statusCode = 404 } + // ensure correct status is set when visiting a status page + // directly e.g. /500 + if (STATIC_STATUS_PAGES.includes(pathname)) { + res.statusCode = parseInt(pathname.substr(1), 10) + } + // handle static page if (typeof components.Component === 'string') { return components.Component @@ -1908,9 +1915,15 @@ export default class Server { result = await this.findPageComponents('/404', query) using404Page = result !== null } + let statusPage = `/${res.statusCode}` + + if (!result && STATIC_STATUS_PAGES.includes(statusPage)) { + result = await this.findPageComponents(statusPage, query) + } if (!result) { result = await this.findPageComponents('/_error', query) + statusPage = '/_error' } if ( @@ -1928,7 +1941,7 @@ export default class Server { html = await this.renderToHTMLWithComponents( req, res, - using404Page ? '/404' : '/_error', + statusPage, result!, { ...this.renderOpts, diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index c688e78e10e7c..8830769d9c418 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -8,7 +8,7 @@ import { GSP_NO_RETURNED_VALUE, GSSP_COMPONENT_MEMBER_ERROR, GSSP_NO_RETURNED_VALUE, - PAGES_404_GET_INITIAL_PROPS_ERROR, + STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR, SERVER_PROPS_GET_INIT_PROPS_CONFLICT, SERVER_PROPS_SSG_CONFLICT, SSG_GET_INITIAL_PROPS_CONFLICT, @@ -22,6 +22,7 @@ import { AMP_RENDER_TARGET, SERVER_PROPS_ID, STATIC_PROPS_ID, + STATIC_STATUS_PAGES, } from '../lib/constants' import { defaultHead } from '../lib/head' import { HeadManagerContext } from '../lib/head-manager-context' @@ -536,7 +537,17 @@ export async function renderToHTML( } if (pathname === '/404' && (hasPageGetInitialProps || getServerSideProps)) { - throw new Error(PAGES_404_GET_INITIAL_PROPS_ERROR) + throw new Error( + `\`pages/404\` ${STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR}` + ) + } + if ( + STATIC_STATUS_PAGES.includes(pathname) && + (hasPageGetInitialProps || getServerSideProps) + ) { + throw new Error( + `\`pages${pathname}\` ${STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR}` + ) } } if (isAutoExport) renderOpts.autoExport = true diff --git a/packages/next/server/next-dev-server.ts b/packages/next/server/next-dev-server.ts index 4ef111da02d47..b45bfc71a5ba3 100644 --- a/packages/next/server/next-dev-server.ts +++ b/packages/next/server/next-dev-server.ts @@ -20,6 +20,7 @@ import { PHASE_DEVELOPMENT_SERVER, CLIENT_STATIC_FILES_PATH, DEV_CLIENT_PAGES_MANIFEST, + STATIC_STATUS_PAGES, } from '../next-server/lib/constants' import { getRouteMatcher, @@ -627,6 +628,11 @@ export default class DevServer extends Server { await this.devReady if (res.statusCode === 404 && (await this.hasPage('/404'))) { await this.hotReloader!.ensurePage('/404') + } else if ( + STATIC_STATUS_PAGES.includes(`/${res.statusCode}`) && + (await this.hasPage(`/${res.statusCode}`)) + ) { + await this.hotReloader!.ensurePage(`/${res.statusCode}`) } else { await this.hotReloader!.ensurePage('/_error') } diff --git a/test/integration/500-page/next.config.js b/test/integration/500-page/next.config.js new file mode 100644 index 0000000000000..4ba52ba2c8df6 --- /dev/null +++ b/test/integration/500-page/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/integration/500-page/pages/500.js b/test/integration/500-page/pages/500.js new file mode 100644 index 0000000000000..aef5a27d3360e --- /dev/null +++ b/test/integration/500-page/pages/500.js @@ -0,0 +1,5 @@ +const page = () => { + console.log('rendered 500') + return 'custom 500 page' +} +export default page diff --git a/test/integration/500-page/pages/err.js b/test/integration/500-page/pages/err.js new file mode 100644 index 0000000000000..61c7136f7ba7e --- /dev/null +++ b/test/integration/500-page/pages/err.js @@ -0,0 +1,5 @@ +const page = () => 'page with err' +page.getInitialProps = () => { + throw new Error('oops') +} +export default page diff --git a/test/integration/500-page/pages/index.js b/test/integration/500-page/pages/index.js new file mode 100644 index 0000000000000..f6c15d1f66e8a --- /dev/null +++ b/test/integration/500-page/pages/index.js @@ -0,0 +1 @@ +export default () => 'hello from index' diff --git a/test/integration/500-page/test/index.test.js b/test/integration/500-page/test/index.test.js new file mode 100644 index 0000000000000..7dc3984d0c280 --- /dev/null +++ b/test/integration/500-page/test/index.test.js @@ -0,0 +1,340 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import { + killApp, + findPort, + launchApp, + nextStart, + nextBuild, + renderViaHTTP, + fetchViaHTTP, + waitFor, + getPageFileFromPagesManifest, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const appDir = join(__dirname, '../') +const pages500 = join(appDir, 'pages/500.js') +const pagesApp = join(appDir, 'pages/_app.js') +const pagesError = join(appDir, 'pages/_error.js') +const nextConfig = join(appDir, 'next.config.js') +const gip500Err = /`pages\/500` can not have getInitialProps\/getServerSideProps/ + +let nextConfigContent +let appPort +let app + +const runTests = (mode = 'server') => { + it('should use pages/500', async () => { + const html = await renderViaHTTP(appPort, '/500') + expect(html).toContain('custom 500 page') + }) + + it('should set correct status code with pages/500', async () => { + const res = await fetchViaHTTP(appPort, '/500') + expect(res.status).toBe(500) + }) + + it('should not error when visited directly', async () => { + const res = await fetchViaHTTP(appPort, '/500') + expect(res.status).toBe(500) + expect(await res.text()).toContain('custom 500 page') + }) + + if (mode !== 'dev') { + it('should output 500.html during build', async () => { + const page = getPageFileFromPagesManifest(appDir, '/500') + expect(page.endsWith('.html')).toBe(true) + }) + + it('should add /500 to pages-manifest correctly', async () => { + const manifest = await fs.readJSON( + join(appDir, '.next', mode, 'pages-manifest.json') + ) + expect('/500' in manifest).toBe(true) + }) + } +} + +describe('500 Page Support', () => { + describe('dev mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(() => killApp(app)) + + runTests('dev') + }) + + describe('server mode', () => { + beforeAll(async () => { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(app)) + + runTests('server') + }) + + describe('serverless mode', () => { + beforeAll(async () => { + nextConfigContent = await fs.readFile(nextConfig, 'utf8') + await fs.writeFile( + nextConfig, + ` + module.exports = { + target: 'serverless' + } + ` + ) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await fs.writeFile(nextConfig, nextConfigContent) + await killApp(app) + }) + + runTests('serverless') + }) + + it('still builds 500 statically with getInitialProps in _app', async () => { + await fs.writeFile( + pagesApp, + ` + import App from 'next/app' + + const page = ({ Component, pageProps }) => + page.getInitialProps = (ctx) => App.getInitialProps(ctx) + export default page + ` + ) + const { stderr, stdout: buildStdout, code } = await nextBuild(appDir, [], { + stderr: true, + stdout: true, + }) + await fs.remove(pagesApp) + + expect(stderr).not.toMatch(gip500Err) + expect(buildStdout).toContain('rendered 500') + expect(code).toBe(0) + expect( + await fs.pathExists(join(appDir, '.next/server/pages/500.html')) + ).toBe(true) + + let appStdout = '' + const appPort = await findPort() + const app = await nextStart(appDir, appPort, { + onStderr(msg) { + appStdout += msg || '' + }, + }) + + await renderViaHTTP(appPort, '/err') + await killApp(app) + + expect(appStdout).not.toContain('rendered 500') + }) + + it('builds 500 statically by default with no pages/500', async () => { + await fs.rename(pages500, `${pages500}.bak`) + const { stderr, code } = await nextBuild(appDir, [], { stderr: true }) + await fs.rename(`${pages500}.bak`, pages500) + + expect(stderr).not.toMatch(gip500Err) + expect(code).toBe(0) + expect( + await fs.pathExists(join(appDir, '.next/server/pages/500.html')) + ).toBe(true) + }) + + it('builds 500 statically by default with no pages/500 and custom _error', async () => { + await fs.rename(pages500, `${pages500}.bak`) + await fs.writeFile( + pagesError, + ` + function Error({ statusCode }) { + return

Error status: {statusCode}

+ } + Error.getInitialProps = ({ res, err }) => { + console.error('called _error.getInitialProps') + return { + statusCode: res && res.statusCode ? res.statusCode : err ? err.statusCode : 404 + } + } + export default Error + ` + ) + const { stderr: buildStderr, code } = await nextBuild(appDir, [], { + stderr: true, + }) + await fs.rename(`${pages500}.bak`, pages500) + await fs.remove(pagesError) + console.log(buildStderr) + expect(buildStderr).not.toMatch(gip500Err) + expect(code).toBe(0) + expect( + await fs.pathExists(join(appDir, '.next/server/pages/500.html')) + ).toBe(true) + + let appStderr = '' + const appPort = await findPort() + const app = await nextStart(appDir, appPort, { + onStderr(msg) { + appStderr += msg || '' + }, + }) + + await renderViaHTTP(appPort, '/err') + await killApp(app) + + expect(appStderr).toContain('called _error.getInitialProps') + }) + + it('shows error with getInitialProps in pages/500 build', async () => { + await fs.move(pages500, `${pages500}.bak`) + await fs.writeFile( + pages500, + ` + const page = () => 'custom 500 page' + page.getInitialProps = () => ({ a: 'b' }) + export default page + ` + ) + const { stderr, code } = await nextBuild(appDir, [], { stderr: true }) + await fs.remove(pages500) + await fs.move(`${pages500}.bak`, pages500) + + expect(stderr).toMatch(gip500Err) + expect(code).toBe(1) + }) + + it('shows error with getInitialProps in pages/500 dev', async () => { + await fs.move(pages500, `${pages500}.bak`) + await fs.writeFile( + pages500, + ` + const page = () => 'custom 500 page' + page.getInitialProps = () => ({ a: 'b' }) + export default page + ` + ) + + let stderr = '' + appPort = await findPort() + app = await launchApp(appDir, appPort, { + onStderr(msg) { + stderr += msg || '' + }, + }) + await renderViaHTTP(appPort, '/500') + await waitFor(1000) + + await killApp(app) + + await fs.remove(pages500) + await fs.move(`${pages500}.bak`, pages500) + + expect(stderr).toMatch(gip500Err) + }) + + it('does not show error with getStaticProps in pages/500 build', async () => { + await fs.move(pages500, `${pages500}.bak`) + await fs.writeFile( + pages500, + ` + const page = () => 'custom 500 page' + export const getStaticProps = () => ({ props: { a: 'b' } }) + export default page + ` + ) + const { stderr, code } = await nextBuild(appDir, [], { stderr: true }) + await fs.remove(pages500) + await fs.move(`${pages500}.bak`, pages500) + + expect(stderr).not.toMatch(gip500Err) + expect(code).toBe(0) + }) + + it('does not show error with getStaticProps in pages/500 dev', async () => { + await fs.move(pages500, `${pages500}.bak`) + await fs.writeFile( + pages500, + ` + const page = () => 'custom 500 page' + export const getStaticProps = () => ({ props: { a: 'b' } }) + export default page + ` + ) + + let stderr = '' + appPort = await findPort() + app = await launchApp(appDir, appPort, { + onStderr(msg) { + stderr += msg || '' + }, + }) + await renderViaHTTP(appPort, '/abc') + await waitFor(1000) + + await killApp(app) + + await fs.remove(pages500) + await fs.move(`${pages500}.bak`, pages500) + + expect(stderr).not.toMatch(gip500Err) + }) + + it('shows error with getServerSideProps in pages/500 build', async () => { + await fs.move(pages500, `${pages500}.bak`) + await fs.writeFile( + pages500, + ` + const page = () => 'custom 500 page' + export const getServerSideProps = () => ({ props: { a: 'b' } }) + export default page + ` + ) + const { stderr, code } = await nextBuild(appDir, [], { stderr: true }) + await fs.remove(pages500) + await fs.move(`${pages500}.bak`, pages500) + + expect(stderr).toMatch(gip500Err) + expect(code).toBe(1) + }) + + it('shows error with getServerSideProps in pages/500 dev', async () => { + await fs.move(pages500, `${pages500}.bak`) + await fs.writeFile( + pages500, + ` + const page = () => 'custom 500 page' + export const getServerSideProps = () => ({ props: { a: 'b' } }) + export default page + ` + ) + + let stderr = '' + appPort = await findPort() + app = await launchApp(appDir, appPort, { + onStderr(msg) { + stderr += msg || '' + }, + }) + await renderViaHTTP(appPort, '/500') + await waitFor(1000) + + await killApp(app) + + await fs.remove(pages500) + await fs.move(`${pages500}.bak`, pages500) + + expect(stderr).toMatch(gip500Err) + }) +}) diff --git a/test/integration/error-in-error/pages/_error.js b/test/integration/error-in-error/pages/_error.js index 4c8c07002213f..825ef429db067 100644 --- a/test/integration/error-in-error/pages/_error.js +++ b/test/integration/error-in-error/pages/_error.js @@ -2,7 +2,7 @@ import React from 'react' class Error extends React.Component { static async getInitialProps({ req, res, err }) { - if (req.url !== '/404.html') { + if (!req.url.startsWith('/404') && !req.url.startsWith('/500')) { await Promise.reject(new Error('an error in error')) } const statusCode = res ? res.statusCode : err ? err.statusCode : null diff --git a/test/integration/polyfills/test/index.test.js b/test/integration/polyfills/test/index.test.js index 1a0b62a3973df..cbcaf257c4ef4 100644 --- a/test/integration/polyfills/test/index.test.js +++ b/test/integration/polyfills/test/index.test.js @@ -39,9 +39,9 @@ describe('Polyfills', () => { }) it('should contain generated page count in output', async () => { - expect(output).toContain('Generating static pages (0/3)') - expect(output).toContain('Generating static pages (3/3)') + expect(output).toContain('Generating static pages (0/4)') + expect(output).toContain('Generating static pages (4/4)') // we should only have 1 segment and the initial message logged out - expect(output.match(/Generating static pages/g).length).toBe(2) + expect(output.match(/Generating static pages/g).length).toBe(5) }) }) diff --git a/test/integration/production/test/index.test.js b/test/integration/production/test/index.test.js index 186e478c3fbb4..90ca62b5e7299 100644 --- a/test/integration/production/test/index.test.js +++ b/test/integration/production/test/index.test.js @@ -58,8 +58,8 @@ describe('Production Usage', () => { afterAll(() => stopApp(server)) it('should contain generated page count in output', async () => { - expect(output).toContain('Generating static pages (0/36)') - expect(output).toContain('Generating static pages (36/36)') + expect(output).toContain('Generating static pages (0/37)') + expect(output).toContain('Generating static pages (37/37)') // we should only have 4 segments and the initial message logged out expect(output.match(/Generating static pages/g).length).toBe(5) })