From 90ad2cbc44f5e807d7507c1eecc73ceb5445b6dc Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 10 Jan 2021 02:12:13 +0100 Subject: [PATCH] Update profiling approach to cover webpack runs (#20900) --- bench/instrument.js | 30 +- packages/next/build/index.ts | 1253 +++++++++-------- packages/next/build/utils.ts | 231 +-- packages/next/build/webpack-config.ts | 7 +- .../webpack/loaders/babel-loader/src/index.js | 261 ++-- .../loaders/next-client-pages-loader.ts | 24 +- .../webpack/plugins/build-manifest-plugin.ts | 243 ++-- .../webpack/plugins/css-minimizer-plugin.ts | 133 +- .../build/webpack/plugins/profiling-plugin.ts | 272 +--- .../terser-webpack-plugin/src/index.js | 316 +++-- packages/next/export/index.ts | 833 +++++------ packages/next/export/worker.ts | 666 ++++----- 12 files changed, 2112 insertions(+), 2157 deletions(-) diff --git a/bench/instrument.js b/bench/instrument.js index 1032533d54a2c82..38176de31cbebe0 100644 --- a/bench/instrument.js +++ b/bench/instrument.js @@ -1,11 +1,35 @@ +// Disable automatic instrumentation +process.env.OTEL_NO_PATCH_MODULES = '*' + const { NodeTracerProvider } = require('@opentelemetry/node') -const { BatchSpanProcessor } = require('@opentelemetry/tracing') +const { SimpleSpanProcessor } = require('@opentelemetry/tracing') const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin') -const tracerProvider = new NodeTracerProvider() +const tracerProvider = new NodeTracerProvider({ + // All automatic instrumentation plugins have to be disabled as it affects worker_thread/child_process bootup performance + plugins: { + mongodb: { enabled: false, path: '@opentelemetry/plugin-mongodb' }, + grpc: { enabled: false, path: '@opentelemetry/plugin-grpc' }, + '@grpc/grpc-js': { enabled: false, path: '@opentelemetry/plugin-grpc-js' }, + http: { enabled: false, path: '@opentelemetry/plugin-http' }, + https: { enabled: false, path: '@opentelemetry/plugin-https' }, + mysql: { enabled: false, path: '@opentelemetry/plugin-mysql' }, + pg: { enabled: false, path: '@opentelemetry/plugin-pg' }, + redis: { enabled: false, path: '@opentelemetry/plugin-redis' }, + ioredis: { enabled: false, path: '@opentelemetry/plugin-ioredis' }, + 'pg-pool': { enabled: false, path: '@opentelemetry/plugin-pg-pool' }, + express: { enabled: false, path: '@opentelemetry/plugin-express' }, + '@hapi/hapi': { + enabled: false, + path: '@opentelemetry/hapi-instrumentation', + }, + koa: { enabled: false, path: '@opentelemetry/koa-instrumentation' }, + dns: { enabled: false, path: '@opentelemetry/plugin-dns' }, + }, +}) tracerProvider.addSpanProcessor( - new BatchSpanProcessor( + new SimpleSpanProcessor( new ZipkinExporter({ serviceName: 'next-js', }) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index f0052a59da7de38..ece28d8e4bc3703 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -69,7 +69,7 @@ import { generateBuildId } from './generate-build-id' import { isWriteable } from './is-writeable' import * as Log from './output/log' import createSpinner from './spinner' -import { traceAsyncFn, tracer } from './tracer' +import { traceAsyncFn, traceFn, tracer } from './tracer' import { collectPages, getJsPageSizeInKb, @@ -83,6 +83,7 @@ import { import getBaseWebpackConfig from './webpack-config' import { PagesManifest } from './webpack/plugins/pages-manifest-plugin' import { writeBuildId } from './write-build-id' +import opentelemetryApi from '@opentelemetry/api' const staticCheckWorker = require.resolve('./utils') @@ -113,7 +114,7 @@ export default async function build( reactProductionProfiling = false, debugOutput = false ): Promise { - const span = tracer.startSpan('build') + const span = tracer.startSpan('next-build') return traceAsyncFn(span, async () => { if (!(await isWriteable(dir))) { throw new Error( @@ -122,14 +123,27 @@ export default async function build( } // attempt to load global env values so they are available in next.config.js - const { loadedEnvFiles } = loadEnvConfig(dir, false, Log) + const { loadedEnvFiles } = traceFn(tracer.startSpan('load-dotenv'), () => + loadEnvConfig(dir, false, Log) + ) - const config = loadConfig(PHASE_PRODUCTION_BUILD, dir, conf) + const config = traceFn(tracer.startSpan('load-next-config'), () => + loadConfig(PHASE_PRODUCTION_BUILD, dir, conf) + ) const { target } = config - const buildId = await generateBuildId(config.generateBuildId, nanoid) + const buildId = await traceAsyncFn( + tracer.startSpan('generate-buildid'), + () => generateBuildId(config.generateBuildId, nanoid) + ) const distDir = path.join(dir, config.distDir) - const { headers, rewrites, redirects } = await loadCustomRoutes(config) + const { + headers, + rewrites, + redirects, + } = await traceAsyncFn(tracer.startSpan('load-custom-routes'), () => + loadCustomRoutes(config) + ) if (ciEnvironment.isCI && !ciEnvironment.hasNextSupport) { const cacheDir = path.join(distDir, 'cache') @@ -168,20 +182,15 @@ export default async function build( ) const ignoreTypeScriptErrors = Boolean(config.typescript?.ignoreBuildErrors) - await verifyTypeScriptSetup(dir, pagesDir, !ignoreTypeScriptErrors) - - let chromeProfiler: any = null - if (config.experimental.profiling) { - const { createTrace } = require('./profiler/profiler.js') - chromeProfiler = createTrace(path.join(distDir, `profile-events.json`)) - chromeProfiler.profiler.startProfiling() - } + await traceAsyncFn(tracer.startSpan('verify-typescript-setup'), () => + verifyTypeScriptSetup(dir, pagesDir, !ignoreTypeScriptErrors) + ) const isLikeServerless = isTargetLikeServerless(target) - const pagePaths: string[] = await collectPages( - pagesDir, - config.pageExtensions + const pagePaths: string[] = await traceAsyncFn( + tracer.startSpan('collect-pages'), + () => collectPages(pagesDir, config.pageExtensions) ) // needed for static exporting since we want to replace with HTML @@ -195,14 +204,18 @@ export default async function build( previewModeEncryptionKey: crypto.randomBytes(32).toString('hex'), } - const mappedPages = createPagesMapping(pagePaths, config.pageExtensions) - const entrypoints = createEntrypoints( - mappedPages, - target, - buildId, - previewProps, - config, - loadedEnvFiles + const mappedPages = traceFn(tracer.startSpan('create-pages-mapping'), () => + createPagesMapping(pagePaths, config.pageExtensions) + ) + const entrypoints = traceFn(tracer.startSpan('create-entrypoints'), () => + createEntrypoints( + mappedPages, + target, + buildId, + previewProps, + config, + loadedEnvFiles + ) ) const pageKeys = Object.keys(mappedPages) const conflictingPublicFiles: string[] = [] @@ -213,7 +226,6 @@ export default async function build( mappedPages['/404'] && mappedPages['/404'].startsWith('private-next-pages') ) - let hasNonStaticErrorPage: boolean if (hasPublicDir) { const hasPublicUnderScoreNextDir = await fileExists( @@ -224,29 +236,34 @@ export default async function build( } } - // Check if pages conflict with files in `public` - // Only a page of public file can be served, not both. - for (const page in mappedPages) { - const hasPublicPageFile = await fileExists( - path.join(publicDir, page === '/' ? '/index' : page), - 'file' - ) - if (hasPublicPageFile) { - conflictingPublicFiles.push(page) - } - } + await traceAsyncFn( + tracer.startSpan('public-dir-conflict-check'), + async () => { + // Check if pages conflict with files in `public` + // Only a page of public file can be served, not both. + for (const page in mappedPages) { + const hasPublicPageFile = await fileExists( + path.join(publicDir, page === '/' ? '/index' : page), + 'file' + ) + if (hasPublicPageFile) { + conflictingPublicFiles.push(page) + } + } - const numConflicting = conflictingPublicFiles.length + const numConflicting = conflictingPublicFiles.length - if (numConflicting) { - throw new Error( - `Conflicting public and page file${ - numConflicting === 1 ? ' was' : 's were' - } found. https://err.sh/vercel/next.js/conflicting-public-file-page\n${conflictingPublicFiles.join( - '\n' - )}` - ) - } + if (numConflicting) { + throw new Error( + `Conflicting public and page file${ + numConflicting === 1 ? ' was' : 's were' + } found. https://err.sh/vercel/next.js/conflicting-public-file-page\n${conflictingPublicFiles.join( + '\n' + )}` + ) + } + } + ) const nestedReservedPages = pageKeys.filter((page) => { return ( @@ -323,7 +340,7 @@ export default async function build( defaultLocale: string localeDetection?: false } - } = { + } = traceFn(tracer.startSpan('generate-routes-manifest'), () => ({ version: 3, pages404: true, basePath: config.basePath, @@ -343,15 +360,19 @@ export default async function build( }), dataRoutes: [], i18n: config.i18n || undefined, - } + })) - await promises.mkdir(distDir, { recursive: true }) + await traceAsyncFn(tracer.startSpan('create-distdir'), () => + promises.mkdir(distDir, { recursive: true }) + ) // We need to write the manifest with rewrites before build // so serverless can import the manifest - await promises.writeFile( - routesManifestPath, - JSON.stringify(routesManifest), - 'utf8' + await traceAsyncFn(tracer.startSpan('write-routes-manifest'), () => + promises.writeFile( + routesManifestPath, + JSON.stringify(routesManifest), + 'utf8' + ) ) const manifestPath = path.join( @@ -360,61 +381,66 @@ export default async function build( PAGES_MANIFEST ) - const requiredServerFiles = { - version: 1, - config: { - ...config, - compress: false, - configFile: undefined, - }, - files: [ - ROUTES_MANIFEST, - path.relative(distDir, manifestPath), - BUILD_MANIFEST, - PRERENDER_MANIFEST, - REACT_LOADABLE_MANIFEST, - config.experimental.optimizeFonts - ? path.join( - isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, - FONT_MANIFEST - ) - : null, - BUILD_ID_FILE, - ] - .filter(nonNullable) - .map((file) => path.join(config.distDir, file)), - ignore: [ - path.relative( - dir, - path.join(path.dirname(require.resolve('sharp')), '**/*') - ), - ], - } + const requiredServerFiles = traceFn( + tracer.startSpan('generate-required-server-files'), + () => ({ + version: 1, + config: { + ...config, + compress: false, + configFile: undefined, + }, + files: [ + ROUTES_MANIFEST, + path.relative(distDir, manifestPath), + BUILD_MANIFEST, + PRERENDER_MANIFEST, + REACT_LOADABLE_MANIFEST, + config.experimental.optimizeFonts + ? path.join( + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, + FONT_MANIFEST + ) + : null, + BUILD_ID_FILE, + ] + .filter(nonNullable) + .map((file) => path.join(config.distDir, file)), + ignore: [ + path.relative( + dir, + path.join(path.dirname(require.resolve('sharp')), '**/*') + ), + ], + }) + ) - const configs = await Promise.all([ - getBaseWebpackConfig(dir, { - tracer: chromeProfiler, - buildId, - reactProductionProfiling, - isServer: false, - config, - target, - pagesDir, - entrypoints: entrypoints.client, - rewrites, - }), - getBaseWebpackConfig(dir, { - tracer: chromeProfiler, - buildId, - reactProductionProfiling, - isServer: true, - config, - target, - pagesDir, - entrypoints: entrypoints.server, - rewrites, - }), - ]) + const configs = await traceAsyncFn( + tracer.startSpan('generate-webpack-config'), + () => + Promise.all([ + getBaseWebpackConfig(dir, { + buildId, + reactProductionProfiling, + isServer: false, + config, + target, + pagesDir, + entrypoints: entrypoints.client, + rewrites, + }), + getBaseWebpackConfig(dir, { + buildId, + reactProductionProfiling, + isServer: true, + config, + target, + pagesDir, + entrypoints: entrypoints.server, + rewrites, + }), + ]) + ) const clientConfig = configs[0] @@ -449,7 +475,10 @@ export default async function build( } } } else { - result = await runCompiler(configs) + result = await traceAsyncFn( + tracer.startSpan('run-webpack-compiler'), + () => runCompiler(configs) + ) } const webpackBuildEnd = process.hrtime(webpackBuildStart) @@ -457,7 +486,9 @@ export default async function build( buildSpinner.stopAndPersist() } - result = formatWebpackMessages(result) + result = traceFn(tracer.startSpan('format-webpack-messages'), () => + formatWebpackMessages(result) + ) if (result.errors.length > 0) { // Only keep the first error. Others are often indicative @@ -535,167 +566,207 @@ export default async function build( let customAppGetInitialProps: boolean | undefined let namedExports: Array | undefined let isNextImageImported: boolean | undefined - - process.env.NEXT_PHASE = PHASE_PRODUCTION_BUILD - - const staticCheckWorkers = new Worker(staticCheckWorker, { - numWorkers: config.experimental.cpus, - enableWorkerThreads: config.experimental.workerThreads, - }) as Worker & { isPageStatic: typeof isPageStatic } - - staticCheckWorkers.getStdout().pipe(process.stdout) - staticCheckWorkers.getStderr().pipe(process.stderr) - - const runtimeEnvConfig = { - publicRuntimeConfig: config.publicRuntimeConfig, - serverRuntimeConfig: config.serverRuntimeConfig, - } - - hasNonStaticErrorPage = - hasCustomErrorPage && - (await hasCustomGetInitialProps( - getPagePath('/_error', distDir, isLikeServerless), - runtimeEnvConfig, - false - )) - - let hasSsrAmpPages = false - const analysisBegin = process.hrtime() - await Promise.all( - pageKeys.map(async (page) => { - const actualPage = normalizePagePath(page) - const [selfSize, allSize] = await getJsPageSizeInKb( - actualPage, - distDir, - buildManifest - ) + let hasSsrAmpPages = false - let isSsg = false - let isStatic = false - let isHybridAmp = false - let ssgPageRoutes: string[] | null = null + const staticCheckSpan = tracer.startSpan('static-check') + const { hasNonStaticErrorPage } = await traceAsyncFn( + staticCheckSpan, + async () => { + process.env.NEXT_PHASE = PHASE_PRODUCTION_BUILD - const nonReservedPage = !page.match( - /^\/(_app|_error|_document|api(\/|$))/ - ) + const staticCheckWorkers = new Worker(staticCheckWorker, { + numWorkers: config.experimental.cpus, + enableWorkerThreads: config.experimental.workerThreads, + }) as Worker & { isPageStatic: typeof isPageStatic } - if (nonReservedPage) { - const serverBundle = getPagePath(page, distDir, isLikeServerless) + staticCheckWorkers.getStdout().pipe(process.stdout) + staticCheckWorkers.getStderr().pipe(process.stderr) - if (customAppGetInitialProps === undefined) { - customAppGetInitialProps = await hasCustomGetInitialProps( - isLikeServerless - ? serverBundle - : getPagePath('/_app', distDir, isLikeServerless), - runtimeEnvConfig, - true - ) - - namedExports = getNamedExports( - isLikeServerless - ? serverBundle - : getPagePath('/_app', distDir, isLikeServerless), - runtimeEnvConfig - ) - - if (customAppGetInitialProps) { - console.warn( - chalk.bold.yellow(`Warning: `) + - chalk.yellow( - `You have opted-out of Automatic Static Optimization due to \`getInitialProps\` in \`pages/_app\`. This does not opt-out pages with \`getStaticProps\`` - ) - ) - console.warn( - 'Read more: https://err.sh/next.js/opt-out-auto-static-optimization\n' - ) - } - } + const runtimeEnvConfig = { + publicRuntimeConfig: config.publicRuntimeConfig, + serverRuntimeConfig: config.serverRuntimeConfig, + } - try { - let workerResult = await staticCheckWorkers.isPageStatic( - page, - serverBundle, + const nonStaticErrorPage = await traceAsyncFn( + tracer.startSpan('check-static-error-page'), + async () => + hasCustomErrorPage && + (await hasCustomGetInitialProps( + getPagePath('/_error', distDir, isLikeServerless), runtimeEnvConfig, - config.i18n?.locales, - config.i18n?.defaultLocale - ) + false + )) + ) - if ( - workerResult.isStatic === false && - (workerResult.isHybridAmp || workerResult.isAmpOnly) - ) { - hasSsrAmpPages = true - } + await Promise.all( + pageKeys.map(async (page) => { + return traceAsyncFn( + tracer.startSpan('check-page', { attributes: { page } }), + async () => { + const actualPage = normalizePagePath(page) + const [selfSize, allSize] = await getJsPageSizeInKb( + actualPage, + distDir, + buildManifest + ) - if (workerResult.isHybridAmp) { - isHybridAmp = true - hybridAmpPages.add(page) - } + let isSsg = false + let isStatic = false + let isHybridAmp = false + let ssgPageRoutes: string[] | null = null - if (workerResult.isNextImageImported) { - isNextImageImported = true - } + const nonReservedPage = !page.match( + /^\/(_app|_error|_document|api(\/|$))/ + ) - if (workerResult.hasStaticProps) { - ssgPages.add(page) - isSsg = true + if (nonReservedPage) { + const serverBundle = getPagePath( + page, + distDir, + isLikeServerless + ) - if ( - workerResult.prerenderRoutes && - workerResult.encodedPrerenderRoutes - ) { - additionalSsgPaths.set(page, workerResult.prerenderRoutes) - additionalSsgPathsEncoded.set( - page, - workerResult.encodedPrerenderRoutes - ) - ssgPageRoutes = workerResult.prerenderRoutes - } + if (customAppGetInitialProps === undefined) { + customAppGetInitialProps = await hasCustomGetInitialProps( + isLikeServerless + ? serverBundle + : getPagePath('/_app', distDir, isLikeServerless), + runtimeEnvConfig, + true + ) + + namedExports = getNamedExports( + isLikeServerless + ? serverBundle + : getPagePath('/_app', distDir, isLikeServerless), + runtimeEnvConfig + ) + + if (customAppGetInitialProps) { + console.warn( + chalk.bold.yellow(`Warning: `) + + chalk.yellow( + `You have opted-out of Automatic Static Optimization due to \`getInitialProps\` in \`pages/_app\`. This does not opt-out pages with \`getStaticProps\`` + ) + ) + console.warn( + 'Read more: https://err.sh/next.js/opt-out-auto-static-optimization\n' + ) + } + } - if (workerResult.prerenderFallback === 'blocking') { - ssgBlockingFallbackPages.add(page) - } else if (workerResult.prerenderFallback === true) { - ssgStaticFallbackPages.add(page) - } - } else if (workerResult.hasServerProps) { - serverPropsPages.add(page) - } else if ( - workerResult.isStatic && - customAppGetInitialProps === false - ) { - staticPages.add(page) - isStatic = true - } + try { + let workerResult = await traceAsyncFn( + tracer.startSpan('is-page-static'), + () => { + const spanContext = {} + + opentelemetryApi.propagation.inject( + opentelemetryApi.context.active(), + spanContext + ) + return staticCheckWorkers.isPageStatic( + page, + serverBundle, + runtimeEnvConfig, + config.i18n?.locales, + config.i18n?.defaultLocale, + spanContext + ) + } + ) + + if ( + workerResult.isStatic === false && + (workerResult.isHybridAmp || workerResult.isAmpOnly) + ) { + hasSsrAmpPages = true + } + + if (workerResult.isHybridAmp) { + isHybridAmp = true + hybridAmpPages.add(page) + } + + if (workerResult.isNextImageImported) { + isNextImageImported = true + } + + if (workerResult.hasStaticProps) { + ssgPages.add(page) + isSsg = true + + if ( + workerResult.prerenderRoutes && + workerResult.encodedPrerenderRoutes + ) { + additionalSsgPaths.set( + page, + workerResult.prerenderRoutes + ) + additionalSsgPathsEncoded.set( + page, + workerResult.encodedPrerenderRoutes + ) + ssgPageRoutes = workerResult.prerenderRoutes + } + + if (workerResult.prerenderFallback === 'blocking') { + ssgBlockingFallbackPages.add(page) + } else if (workerResult.prerenderFallback === true) { + ssgStaticFallbackPages.add(page) + } + } else if (workerResult.hasServerProps) { + serverPropsPages.add(page) + } else if ( + workerResult.isStatic && + customAppGetInitialProps === false + ) { + staticPages.add(page) + isStatic = true + } + + if (hasPages404 && page === '/404') { + if ( + !workerResult.isStatic && + !workerResult.hasStaticProps + ) { + throw new Error(PAGES_404_GET_INITIAL_PROPS_ERROR) + } + // we need to ensure the 404 lambda is present since we use + // it when _app has getInitialProps + if ( + customAppGetInitialProps && + !workerResult.hasStaticProps + ) { + staticPages.delete(page) + } + } + } catch (err) { + if (err.message !== 'INVALID_DEFAULT_EXPORT') throw err + invalidPages.add(page) + } + } - if (hasPages404 && page === '/404') { - if (!workerResult.isStatic && !workerResult.hasStaticProps) { - throw new Error(PAGES_404_GET_INITIAL_PROPS_ERROR) + pageInfos.set(page, { + size: selfSize, + totalSize: allSize, + static: isStatic, + isSsg, + isHybridAmp, + ssgPageRoutes, + initialRevalidateSeconds: false, + }) } - // we need to ensure the 404 lambda is present since we use - // it when _app has getInitialProps - if (customAppGetInitialProps && !workerResult.hasStaticProps) { - staticPages.delete(page) - } - } - } catch (err) { - if (err.message !== 'INVALID_DEFAULT_EXPORT') throw err - invalidPages.add(page) - } - } + ) + }) + ) + staticCheckWorkers.end() - pageInfos.set(page, { - size: selfSize, - totalSize: allSize, - static: isStatic, - isSsg, - isHybridAmp, - ssgPageRoutes, - initialRevalidateSeconds: false, - }) - }) + return { hasNonStaticErrorPage: nonStaticErrorPage } + } ) - staticCheckWorkers.end() if (!hasSsrAmpPages) { requiredServerFiles.ignore.push( @@ -796,350 +867,377 @@ export default async function build( if (postCompileSpinner) postCompileSpinner.stopAndPersist() - if (staticPages.size > 0 || ssgPages.size > 0 || useStatic404) { - const combinedPages = [...staticPages, ...ssgPages] - 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) => { - const { i18n } = config - - // 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 }, + await traceAsyncFn(tracer.startSpan('static-generation'), async () => { + if (staticPages.size > 0 || ssgPages.size > 0 || useStatic404) { + const combinedPages = [...staticPages, ...ssgPages] + 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) => { + const { i18n } = config + + // 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 } } } } else { - defaultMap[page] = { page, query: { __nextFallback: true } } + // Remove dynamically routed pages from the default path map when + // fallback behavior is disabled. + delete defaultMap[page] } - } 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) - if (useStatic404) { - defaultMap['/404'] = { - page: hasPages404 ? '/404' : '/_error', + routes.forEach((route, routeIdx) => { + defaultMap[route] = { + page, + query: { __nextSsgPath: encodedRoutes?.[routeIdx] }, + } + }) + }) + + 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 }, - } + 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 }, + } - if (isFallback) { - defaultMap[outputPath].query.__nextFallback = true + if (isFallback) { + defaultMap[outputPath].query.__nextFallback = true + } } - } - if (isSsg) { - // remove non-locale prefixed variant from defaultMap - delete defaultMap[page] + if (isSsg) { + // remove non-locale prefixed variant from defaultMap + delete defaultMap[page] + } } } - } - - return defaultMap - }, - trailingSlash: false, - } - await exportApp(dir, exportOptions, exportConfig) - - const postBuildSpinner = createSpinner({ - prefixText: `${Log.prefixes.info} Finalizing page optimization`, - }) - ssgNotFoundPaths = exportConfig.ssgNotFoundPaths + return defaultMap + }, + trailingSlash: false, + } - // 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 - ) + await exportApp(dir, exportOptions, exportConfig) - const moveExportedPage = async ( - originPage: string, - page: string, - file: string, - isSsg: boolean, - ext: 'html' | 'json', - additionalSsgFile = false - ) => { - 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( - 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 postBuildSpinner = createSpinner({ + prefixText: `${Log.prefixes.info} Finalizing page optimization`, + }) + ssgNotFoundPaths = exportConfig.ssgNotFoundPaths - const dest = path.join( + // 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, - relativeDest + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY ) - if (!isSsg) { - pagesManifest[page] = relativeDest - } + 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 { i18n } = config - 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] - } + const relativeDest = path + .relative( + serverOutputDir, + 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 + ) + ) + .replace(/\\/g, '/') - if (i18n) { - if (additionalSsgFile) return + const dest = path.join( + distDir, + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, + relativeDest + ) - for (const locale of i18n.locales) { - const curPath = `/${locale}${page === '/' ? '' : page}` - const localeExt = page === '/' ? path.extname(file) : '' - const relativeDestNoPages = relativeDest.substr('pages/'.length) + if (!isSsg) { + pagesManifest[page] = relativeDest + } - if (isSsg && ssgNotFoundPaths.includes(curPath)) { - continue - } + const { i18n } = config + 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] + } - 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 (i18n) { + if (additionalSsgFile) return - const updatedOrig = path.join( - exportOptions.outdir, - locale + localeExt, - page === '/' ? '' : file - ) - const updatedDest = path.join( - distDir, - isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, - updatedRelativeDest - ) + for (const locale of i18n.locales) { + const curPath = `/${locale}${page === '/' ? '' : page}` + const localeExt = page === '/' ? path.extname(file) : '' + const relativeDestNoPages = relativeDest.substr( + 'pages/'.length + ) - if (!isSsg) { - pagesManifest[curPath] = updatedRelativeDest + if (isSsg && ssgNotFoundPaths.includes(curPath)) { + continue + } + + 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, '/') + + 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) + } + } } - 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') - } - 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) - - // The dynamic version of SSG pages are only prerendered if the fallback - // is enabled. Below, we handle the specific prerenders of these. - if (!(isSsg && isDynamic && !isStaticSsgFallback)) { - await moveExportedPage(page, page, file, isSsg, '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') } - if (hasAmp && (!isSsg || (isSsg && !isDynamic))) { - const ampPage = `${file}.amp` - await moveExportedPage(page, ampPage, ampPage, isSsg, '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) + + // The dynamic version of SSG pages are only prerendered if the fallback + // is enabled. Below, we handle the specific prerenders of these. + if (!(isSsg && isDynamic && !isStaticSsgFallback)) { + 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) { + await moveExportedPage(page, ampPage, ampPage, isSsg, 'json') + } } - } - if (isSsg) { - const { i18n } = config + if (isSsg) { + const { i18n } = config - // For a non-dynamic SSG page, we must copy its data file from export. - if (!isDynamic) { - await moveExportedPage(page, page, file, true, 'json') + // For a non-dynamic SSG page, we must copy its data file from export. + if (!isDynamic) { + await moveExportedPage(page, page, file, true, 'json') - const revalidationMapPath = i18n - ? `/${i18n.defaultLocale}${page === '/' ? '' : page}` - : page + const revalidationMapPath = i18n + ? `/${i18n.defaultLocale}${page === '/' ? '' : page}` + : page - finalPrerenderRoutes[page] = { - initialRevalidateSeconds: - exportConfig.initialPageRevalidationMap[revalidationMapPath], - 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[revalidationMapPath] - 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, true, 'html', true) - await moveExportedPage(page, route, pageFile, true, 'json', true) - - if (hasAmp) { - const ampPage = `${pageFile}.amp` + finalPrerenderRoutes[page] = { + initialRevalidateSeconds: + exportConfig.initialPageRevalidationMap[revalidationMapPath], + 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[revalidationMapPath] + 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, - ampPage, - ampPage, + route, + pageFile, true, 'html', true ) await moveExportedPage( page, - ampPage, - ampPage, + route, + pageFile, true, 'json', true ) - } - finalPrerenderRoutes[route] = { - initialRevalidateSeconds: - exportConfig.initialPageRevalidationMap[route], - srcRoute: page, - dataRoute: path.posix.join( - '/_next/data', - buildId, - `${normalizePagePath(route)}.json` - ), - } + if (hasAmp) { + const ampPage = `${pageFile}.amp` + await moveExportedPage( + page, + ampPage, + ampPage, + true, + 'html', + true + ) + await moveExportedPage( + page, + ampPage, + ampPage, + true, + 'json', + true + ) + } - // Set route Revalidation Interval - const pageInfo = pageInfos.get(route) - if (pageInfo) { - pageInfo.initialRevalidateSeconds = - exportConfig.initialPageRevalidationMap[route] - pageInfos.set(route, pageInfo) + 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) + } } } } } - } - // 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) telemetry.record( @@ -1252,22 +1350,21 @@ export default async function build( allPageInfos.set(key, info) }) - await printTreeView( - Object.keys(mappedPages), - allPageInfos, - isLikeServerless, - { + await traceAsyncFn(tracer.startSpan('print-tree-view'), () => + printTreeView(Object.keys(mappedPages), allPageInfos, isLikeServerless, { distPath: distDir, buildId: buildId, pagesDir, useStatic404, pageExtensions: config.pageExtensions, buildManifest, - } + }) ) if (debugOutput) { - printCustomRoutes({ redirects, rewrites, headers }) + traceFn(tracer.startSpan('print-custom-routes'), () => + printCustomRoutes({ redirects, rewrites, headers }) + ) } if (config.analyticsId) { @@ -1279,65 +1376,9 @@ export default async function build( console.log('') } - if (chromeProfiler) { - const parsedResults = await chromeProfiler.profiler.stopProfiling() - await new Promise((resolve) => { - if (parsedResults === undefined) { - chromeProfiler.profiler.destroy() - chromeProfiler.trace.flush() - chromeProfiler.end(resolve) - return - } - - const cpuStartTime = parsedResults.profile.startTime - const cpuEndTime = parsedResults.profile.endTime - - chromeProfiler.trace.completeEvent({ - name: 'TaskQueueManager::ProcessTaskFromWorkQueue', - id: ++chromeProfiler.counter, - cat: ['toplevel'], - ts: cpuStartTime, - args: { - src_file: '../../ipc/ipc_moji_bootstrap.cc', - src_func: 'Accept', - }, - }) - - chromeProfiler.trace.completeEvent({ - name: 'EvaluateScript', - id: ++chromeProfiler.counter, - cat: ['devtools.timeline'], - ts: cpuStartTime, - dur: cpuEndTime - cpuStartTime, - args: { - data: { - url: 'webpack', - lineNumber: 1, - columnNumber: 1, - frame: '0xFFF', - }, - }, - }) - - chromeProfiler.trace.instantEvent({ - name: 'CpuProfile', - id: ++chromeProfiler.counter, - cat: ['disabled-by-default-devtools.timeline'], - ts: cpuEndTime, - args: { - data: { - cpuProfile: parsedResults.profile, - }, - }, - }) - - chromeProfiler.profiler.destroy() - chromeProfiler.trace.flush() - chromeProfiler.end(resolve) - }) - } - - await telemetry.flush() + await traceAsyncFn(tracer.startSpan('telemetry-flush'), () => + telemetry.flush() + ) }) } diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 33c877c9e4665fe..c3fdfdee521f55b 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -28,6 +28,8 @@ import { BuildManifest } from '../next-server/server/get-page-files' import { removePathTrailingSlash } from '../client/normalize-trailing-slash' import { UnwrapPromise } from '../lib/coalesced-function' import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' +import opentelemetryApi from '@opentelemetry/api' +import { tracer, traceAsyncFn } from './tracer' const fileGzipStats: { [k: string]: Promise } = {} const fsStatGzip = (file: string) => { @@ -713,7 +715,8 @@ export async function isPageStatic( serverBundle: string, runtimeEnvConfig: any, locales?: string[], - defaultLocale?: string + defaultLocale?: string, + spanContext?: any ): Promise<{ isStatic?: boolean isAmpOnly?: boolean @@ -725,111 +728,131 @@ export async function isPageStatic( prerenderFallback?: boolean | 'blocking' isNextImageImported?: boolean }> { - try { - require('../next-server/lib/runtime-config').setConfig(runtimeEnvConfig) - const mod = await require(serverBundle) - const Comp = await (mod.default || mod) - - if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { - throw new Error('INVALID_DEFAULT_EXPORT') - } - - const hasGetInitialProps = !!(Comp as any).getInitialProps - const hasStaticProps = !!(await mod.getStaticProps) - const hasStaticPaths = !!(await mod.getStaticPaths) - const hasServerProps = !!(await mod.getServerSideProps) - const hasLegacyServerProps = !!(await mod.unstable_getServerProps) - const hasLegacyStaticProps = !!(await mod.unstable_getStaticProps) - const hasLegacyStaticPaths = !!(await mod.unstable_getStaticPaths) - const hasLegacyStaticParams = !!(await mod.unstable_getStaticParams) - - if (hasLegacyStaticParams) { - throw new Error( - `unstable_getStaticParams was replaced with getStaticPaths. Please update your code.` - ) - } - - if (hasLegacyStaticPaths) { - throw new Error( - `unstable_getStaticPaths was replaced with getStaticPaths. Please update your code.` - ) - } - - if (hasLegacyStaticProps) { - throw new Error( - `unstable_getStaticProps was replaced with getStaticProps. Please update your code.` - ) - } - - if (hasLegacyServerProps) { - throw new Error( - `unstable_getServerProps was replaced with getServerSideProps. Please update your code.` - ) - } - - // A page cannot be prerendered _and_ define a data requirement. That's - // contradictory! - if (hasGetInitialProps && hasStaticProps) { - throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT) - } - - if (hasGetInitialProps && hasServerProps) { - throw new Error(SERVER_PROPS_GET_INIT_PROPS_CONFLICT) - } - - if (hasStaticProps && hasServerProps) { - throw new Error(SERVER_PROPS_SSG_CONFLICT) - } - - const pageIsDynamic = isDynamicRoute(page) - // A page cannot have static parameters if it is not a dynamic page. - if (hasStaticProps && hasStaticPaths && !pageIsDynamic) { - throw new Error( - `getStaticPaths can only be used with dynamic pages, not '${page}'.` + - `\nLearn more: https://nextjs.org/docs/routing/dynamic-routes` - ) - } - - if (hasStaticProps && pageIsDynamic && !hasStaticPaths) { - throw new Error( - `getStaticPaths is required for dynamic SSG pages and is missing for '${page}'.` + - `\nRead more: https://err.sh/next.js/invalid-getstaticpaths-value` + return opentelemetryApi.context.with( + opentelemetryApi.propagation.extract( + opentelemetryApi.context.active(), + spanContext + ), + () => { + return traceAsyncFn( + tracer.startSpan('is-page-static-utils'), + async () => { + try { + require('../next-server/lib/runtime-config').setConfig( + runtimeEnvConfig + ) + const mod = await require(serverBundle) + const Comp = await (mod.default || mod) + + if ( + !Comp || + !isValidElementType(Comp) || + typeof Comp === 'string' + ) { + throw new Error('INVALID_DEFAULT_EXPORT') + } + + const hasGetInitialProps = !!(Comp as any).getInitialProps + const hasStaticProps = !!(await mod.getStaticProps) + const hasStaticPaths = !!(await mod.getStaticPaths) + const hasServerProps = !!(await mod.getServerSideProps) + const hasLegacyServerProps = !!(await mod.unstable_getServerProps) + const hasLegacyStaticProps = !!(await mod.unstable_getStaticProps) + const hasLegacyStaticPaths = !!(await mod.unstable_getStaticPaths) + const hasLegacyStaticParams = !!(await mod.unstable_getStaticParams) + + if (hasLegacyStaticParams) { + throw new Error( + `unstable_getStaticParams was replaced with getStaticPaths. Please update your code.` + ) + } + + if (hasLegacyStaticPaths) { + throw new Error( + `unstable_getStaticPaths was replaced with getStaticPaths. Please update your code.` + ) + } + + if (hasLegacyStaticProps) { + throw new Error( + `unstable_getStaticProps was replaced with getStaticProps. Please update your code.` + ) + } + + if (hasLegacyServerProps) { + throw new Error( + `unstable_getServerProps was replaced with getServerSideProps. Please update your code.` + ) + } + + // A page cannot be prerendered _and_ define a data requirement. That's + // contradictory! + if (hasGetInitialProps && hasStaticProps) { + throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT) + } + + if (hasGetInitialProps && hasServerProps) { + throw new Error(SERVER_PROPS_GET_INIT_PROPS_CONFLICT) + } + + if (hasStaticProps && hasServerProps) { + throw new Error(SERVER_PROPS_SSG_CONFLICT) + } + + const pageIsDynamic = isDynamicRoute(page) + // A page cannot have static parameters if it is not a dynamic page. + if (hasStaticProps && hasStaticPaths && !pageIsDynamic) { + throw new Error( + `getStaticPaths can only be used with dynamic pages, not '${page}'.` + + `\nLearn more: https://nextjs.org/docs/routing/dynamic-routes` + ) + } + + if (hasStaticProps && pageIsDynamic && !hasStaticPaths) { + throw new Error( + `getStaticPaths is required for dynamic SSG pages and is missing for '${page}'.` + + `\nRead more: https://err.sh/next.js/invalid-getstaticpaths-value` + ) + } + + let prerenderRoutes: Array | undefined + let encodedPrerenderRoutes: Array | undefined + let prerenderFallback: boolean | 'blocking' | undefined + if (hasStaticProps && hasStaticPaths) { + ;({ + paths: prerenderRoutes, + fallback: prerenderFallback, + encodedPaths: encodedPrerenderRoutes, + } = await buildStaticPaths( + page, + mod.getStaticPaths, + locales, + defaultLocale + )) + } + + const isNextImageImported = (global as any).__NEXT_IMAGE_IMPORTED + const config = mod.config || {} + return { + isStatic: + !hasStaticProps && !hasGetInitialProps && !hasServerProps, + isHybridAmp: config.amp === 'hybrid', + isAmpOnly: config.amp === true, + prerenderRoutes, + prerenderFallback, + encodedPrerenderRoutes, + hasStaticProps, + hasServerProps, + isNextImageImported, + } + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') return {} + throw err + } + } ) } - - let prerenderRoutes: Array | undefined - let encodedPrerenderRoutes: Array | undefined - let prerenderFallback: boolean | 'blocking' | undefined - if (hasStaticProps && hasStaticPaths) { - ;({ - paths: prerenderRoutes, - fallback: prerenderFallback, - encodedPaths: encodedPrerenderRoutes, - } = await buildStaticPaths( - page, - mod.getStaticPaths, - locales, - defaultLocale - )) - } - - const isNextImageImported = (global as any).__NEXT_IMAGE_IMPORTED - const config = mod.config || {} - return { - isStatic: !hasStaticProps && !hasGetInitialProps && !hasServerProps, - isHybridAmp: config.amp === 'hybrid', - isAmpOnly: config.amp === true, - prerenderRoutes, - prerenderFallback, - encodedPrerenderRoutes, - hasStaticProps, - hasServerProps, - isNextImageImported, - } - } catch (err) { - if (err.code === 'MODULE_NOT_FOUND') return {} - throw err - } + ) } export async function hasCustomGetInitialProps( diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 94f49a1b1cc851b..ef8c4f06a4e0d1c 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -193,7 +193,6 @@ export default async function getBaseWebpackConfig( dev = false, isServer = false, pagesDir, - tracer, target = 'server', reactProductionProfiling = false, entrypoints, @@ -205,7 +204,6 @@ export default async function getBaseWebpackConfig( isServer?: boolean pagesDir: string target?: string - tracer?: any reactProductionProfiling?: boolean entrypoints: WebpackEntrypoints rewrites: Rewrite[] @@ -1078,10 +1076,7 @@ export default async function getBaseWebpackConfig( buildId, rewrites, }), - tracer && - new ProfilingPlugin({ - tracer, - }), + new ProfilingPlugin(), config.experimental.optimizeFonts && !dev && isServer && diff --git a/packages/next/build/webpack/loaders/babel-loader/src/index.js b/packages/next/build/webpack/loaders/babel-loader/src/index.js index 4aabed3cfc45f57..4e41b72e190bca6 100644 --- a/packages/next/build/webpack/loaders/babel-loader/src/index.js +++ b/packages/next/build/webpack/loaders/babel-loader/src/index.js @@ -22,147 +22,150 @@ export default function makeLoader(callback) { } async function loader(source, inputSourceMap, overrides) { - const span = tracer.startSpan('babel-loader') - return traceAsyncFn(span, async () => { - const filename = this.resourcePath - span.setAttribute('filename', filename) - - let loaderOptions = loaderUtils.getOptions(this) || {} - - let customOptions - if (overrides && overrides.customOptions) { - const result = await traceAsyncFn( - tracer.startSpan('loader-overrides-customoptions'), - async () => - await overrides.customOptions.call(this, loaderOptions, { - source, - map: inputSourceMap, - }) - ) - customOptions = result.custom - loaderOptions = result.loader - } - - // Standardize on 'sourceMaps' as the key passed through to Webpack, so that - // users may safely use either one alongside our default use of - // 'this.sourceMap' below without getting error about conflicting aliases. - if ( - Object.prototype.hasOwnProperty.call(loaderOptions, 'sourceMap') && - !Object.prototype.hasOwnProperty.call(loaderOptions, 'sourceMaps') - ) { - loaderOptions = Object.assign({}, loaderOptions, { - sourceMaps: loaderOptions.sourceMap, - }) - delete loaderOptions.sourceMap - } - - const programmaticOptions = Object.assign({}, loaderOptions, { - filename, - inputSourceMap: inputSourceMap || undefined, - - // Set the default sourcemap behavior based on Webpack's mapping flag, - // but allow users to override if they want. - sourceMaps: - loaderOptions.sourceMaps === undefined - ? this.sourceMap - : loaderOptions.sourceMaps, - - // Ensure that Webpack will get a full absolute path in the sourcemap - // so that it can properly map the module back to its internal cached - // modules. - sourceFileName: filename, - caller: { - name: 'babel-loader', - - // Provide plugins with insight into webpack target. - // https://github.com/babel/babel-loader/issues/787 - target: this.target, - - // Webpack >= 2 supports ESM and dynamic import. - supportsStaticESM: true, - supportsDynamicImport: true, - - // Webpack 5 supports TLA behind a flag. We enable it by default - // for Babel, and then webpack will throw an error if the experimental - // flag isn't enabled. - supportsTopLevelAwait: true, - ...loaderOptions.caller, - }, - }) - // Remove loader related options - delete programmaticOptions.cacheDirectory - delete programmaticOptions.cacheIdentifier - - const config = traceFn( - tracer.startSpan('babel-load-partial-config-async'), - () => { - return babel.loadPartialConfig(programmaticOptions) - } - ) - - if (config) { - let options = config.options - if (overrides && overrides.config) { - options = await traceAsyncFn( - tracer.startSpan('loader-overrides-config'), + // Provided by profiling-plugin.ts + return tracer.withSpan(this.currentTraceSpan, () => { + const span = tracer.startSpan('babel-loader') + return traceAsyncFn(span, async () => { + const filename = this.resourcePath + span.setAttribute('filename', filename) + + let loaderOptions = loaderUtils.getOptions(this) || {} + + let customOptions + if (overrides && overrides.customOptions) { + const result = await traceAsyncFn( + tracer.startSpan('loader-overrides-customoptions'), async () => - await overrides.config.call(this, config, { + await overrides.customOptions.call(this, loaderOptions, { source, map: inputSourceMap, - customOptions, }) ) + customOptions = result.custom + loaderOptions = result.loader } - if (options.sourceMaps === 'inline') { - // Babel has this weird behavior where if you set "inline", we - // inline the sourcemap, and set 'result.map = null'. This results - // in bad behavior from Babel since the maps get put into the code, - // which Webpack does not expect, and because the map we return to - // Webpack is null, which is also bad. To avoid that, we override the - // behavior here so "inline" just behaves like 'true'. - options.sourceMaps = true - } - - const { cacheDirectory, cacheIdentifier } = loaderOptions - - let result - if (cacheDirectory) { - result = await cache({ - source, - options, - cacheDirectory, - cacheIdentifier, - cacheCompression: false, + // Standardize on 'sourceMaps' as the key passed through to Webpack, so that + // users may safely use either one alongside our default use of + // 'this.sourceMap' below without getting error about conflicting aliases. + if ( + Object.prototype.hasOwnProperty.call(loaderOptions, 'sourceMap') && + !Object.prototype.hasOwnProperty.call(loaderOptions, 'sourceMaps') + ) { + loaderOptions = Object.assign({}, loaderOptions, { + sourceMaps: loaderOptions.sourceMap, }) - } else { - result = await traceAsyncFn( - tracer.startSpan('transform', { - attributes: { - filename, - cache: 'DISABLED', - }, - }), - async () => { - return transform(source, options) - } - ) + delete loaderOptions.sourceMap } - // TODO: Babel should really provide the full list of config files that - // were used so that this can also handle files loaded with 'extends'. - if (typeof config.babelrc === 'string') { - this.addDependency(config.babelrc) - } - - if (result) { - const { code, map } = result + const programmaticOptions = Object.assign({}, loaderOptions, { + filename, + inputSourceMap: inputSourceMap || undefined, + + // Set the default sourcemap behavior based on Webpack's mapping flag, + // but allow users to override if they want. + sourceMaps: + loaderOptions.sourceMaps === undefined + ? this.sourceMap + : loaderOptions.sourceMaps, + + // Ensure that Webpack will get a full absolute path in the sourcemap + // so that it can properly map the module back to its internal cached + // modules. + sourceFileName: filename, + caller: { + name: 'babel-loader', + + // Provide plugins with insight into webpack target. + // https://github.com/babel/babel-loader/issues/787 + target: this.target, + + // Webpack >= 2 supports ESM and dynamic import. + supportsStaticESM: true, + supportsDynamicImport: true, + + // Webpack 5 supports TLA behind a flag. We enable it by default + // for Babel, and then webpack will throw an error if the experimental + // flag isn't enabled. + supportsTopLevelAwait: true, + ...loaderOptions.caller, + }, + }) + // Remove loader related options + delete programmaticOptions.cacheDirectory + delete programmaticOptions.cacheIdentifier + + const config = traceFn( + tracer.startSpan('babel-load-partial-config-async'), + () => { + return babel.loadPartialConfig(programmaticOptions) + } + ) - return [code, map] + if (config) { + let options = config.options + if (overrides && overrides.config) { + options = await traceAsyncFn( + tracer.startSpan('loader-overrides-config'), + async () => + await overrides.config.call(this, config, { + source, + map: inputSourceMap, + customOptions, + }) + ) + } + + if (options.sourceMaps === 'inline') { + // Babel has this weird behavior where if you set "inline", we + // inline the sourcemap, and set 'result.map = null'. This results + // in bad behavior from Babel since the maps get put into the code, + // which Webpack does not expect, and because the map we return to + // Webpack is null, which is also bad. To avoid that, we override the + // behavior here so "inline" just behaves like 'true'. + options.sourceMaps = true + } + + const { cacheDirectory, cacheIdentifier } = loaderOptions + + let result + if (cacheDirectory) { + result = await cache({ + source, + options, + cacheDirectory, + cacheIdentifier, + cacheCompression: false, + }) + } else { + result = await traceAsyncFn( + tracer.startSpan('transform', { + attributes: { + filename, + cache: 'DISABLED', + }, + }), + async () => { + return transform(source, options) + } + ) + } + + // TODO: Babel should really provide the full list of config files that + // were used so that this can also handle files loaded with 'extends'. + if (typeof config.babelrc === 'string') { + this.addDependency(config.babelrc) + } + + if (result) { + const { code, map } = result + + return [code, map] + } } - } - // If the file was ignored, pass through the original content. - return [source, inputSourceMap] + // If the file was ignored, pass through the original content. + return [source, inputSourceMap] + }) }) } diff --git a/packages/next/build/webpack/loaders/next-client-pages-loader.ts b/packages/next/build/webpack/loaders/next-client-pages-loader.ts index 5fe4a77d9811c42..d836e309e9e033b 100644 --- a/packages/next/build/webpack/loaders/next-client-pages-loader.ts +++ b/packages/next/build/webpack/loaders/next-client-pages-loader.ts @@ -1,4 +1,3 @@ -import { loader } from 'webpack' import loaderUtils from 'loader-utils' import { tracer, traceFn } from '../../tracer' @@ -7,19 +6,21 @@ export type ClientPagesLoaderOptions = { page: string } -const nextClientPagesLoader: loader.Loader = function () { - const span = tracer.startSpan('next-client-pages-loader') - return traceFn(span, () => { - const { absolutePagePath, page } = loaderUtils.getOptions( - this - ) as ClientPagesLoaderOptions +// this parameter: https://www.typescriptlang.org/docs/handbook/functions.html#this-parameters +function nextClientPagesLoader(this: any) { + return tracer.withSpan(this.currentTraceSpan, () => { + const span = tracer.startSpan('next-client-pages-loader') + return traceFn(span, () => { + const { absolutePagePath, page } = loaderUtils.getOptions( + this + ) as ClientPagesLoaderOptions - span.setAttribute('absolutePagePath', absolutePagePath) + span.setAttribute('absolutePagePath', absolutePagePath) - const stringifiedAbsolutePagePath = JSON.stringify(absolutePagePath) - const stringifiedPage = JSON.stringify(page) + const stringifiedAbsolutePagePath = JSON.stringify(absolutePagePath) + const stringifiedPage = JSON.stringify(page) - return ` + return ` (window.__NEXT_P = window.__NEXT_P || []).push([ ${stringifiedPage}, function () { @@ -27,6 +28,7 @@ const nextClientPagesLoader: loader.Loader = function () { } ]); ` + }) }) } diff --git a/packages/next/build/webpack/plugins/build-manifest-plugin.ts b/packages/next/build/webpack/plugins/build-manifest-plugin.ts index dfe28783c1a0be3..ddd11fbd12693b9 100644 --- a/packages/next/build/webpack/plugins/build-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/build-manifest-plugin.ts @@ -15,7 +15,7 @@ import { ampFirstEntryNamesMap } from './next-drop-client-page-plugin' import { Rewrite } from '../../../lib/load-custom-routes' import { getSortedRoutes } from '../../../next-server/lib/router/utils' import { tracer, traceFn } from '../../tracer' - +import { spans } from './profiling-plugin' // @ts-ignore: TODO: remove ignore when webpack 5 is stable const { RawSource } = webpack.sources || sources @@ -28,38 +28,41 @@ export type ClientBuildManifest = Record // This function takes the asset map generated in BuildManifestPlugin and creates a // reduced version to send to the client. function generateClientManifest( + compiler: any, assetMap: BuildManifest, rewrites: Rewrite[] ): string { - const span = tracer.startSpan('NextJsBuildManifest-generateClientManifest') - return traceFn(span, () => { - const clientManifest: ClientBuildManifest = { - // TODO: update manifest type to include rewrites - __rewrites: rewrites as any, - } - const appDependencies = new Set(assetMap.pages['/_app']) - const sortedPageKeys = getSortedRoutes(Object.keys(assetMap.pages)) - - sortedPageKeys.forEach((page) => { - const dependencies = assetMap.pages[page] - - if (page === '/_app') return - // Filter out dependencies in the _app entry, because those will have already - // been loaded by the client prior to a navigation event - const filteredDeps = dependencies.filter( - (dep) => !appDependencies.has(dep) - ) - - // The manifest can omit the page if it has no requirements - if (filteredDeps.length) { - clientManifest[page] = filteredDeps + return tracer.withSpan(spans.get(compiler), () => { + const span = tracer.startSpan('NextJsBuildManifest-generateClientManifest') + return traceFn(span, () => { + const clientManifest: ClientBuildManifest = { + // TODO: update manifest type to include rewrites + __rewrites: rewrites as any, } - }) - // provide the sorted pages as an array so we don't rely on the object's keys - // being in order and we don't slow down look-up time for page assets - clientManifest.sortedPages = sortedPageKeys + const appDependencies = new Set(assetMap.pages['/_app']) + const sortedPageKeys = getSortedRoutes(Object.keys(assetMap.pages)) + + sortedPageKeys.forEach((page) => { + const dependencies = assetMap.pages[page] + + if (page === '/_app') return + // Filter out dependencies in the _app entry, because those will have already + // been loaded by the client prior to a navigation event + const filteredDeps = dependencies.filter( + (dep) => !appDependencies.has(dep) + ) + + // The manifest can omit the page if it has no requirements + if (filteredDeps.length) { + clientManifest[page] = filteredDeps + } + }) + // provide the sorted pages as an array so we don't rely on the object's keys + // being in order and we don't slow down look-up time for page assets + clientManifest.sortedPages = sortedPageKeys - return devalue(clientManifest) + return devalue(clientManifest) + }) }) } @@ -100,120 +103,126 @@ export default class BuildManifestPlugin { }) } - createAssets(compilation: any, assets: any) { - const span = tracer.startSpan('NextJsBuildManifest-createassets') - return traceFn(span, () => { - const namedChunks: Map = - compilation.namedChunks - const assetMap: DeepMutable = { - polyfillFiles: [], - devFiles: [], - ampDevFiles: [], - lowPriorityFiles: [], - pages: { '/_app': [] }, - ampFirstPages: [], - } + createAssets(compiler: any, compilation: any, assets: any) { + return tracer.withSpan(spans.get(compiler), () => { + const span = tracer.startSpan('NextJsBuildManifest-createassets') + return traceFn(span, () => { + const namedChunks: Map = + compilation.namedChunks + const assetMap: DeepMutable = { + polyfillFiles: [], + devFiles: [], + ampDevFiles: [], + lowPriorityFiles: [], + pages: { '/_app': [] }, + ampFirstPages: [], + } - const ampFirstEntryNames = ampFirstEntryNamesMap.get(compilation) - if (ampFirstEntryNames) { - for (const entryName of ampFirstEntryNames) { - const pagePath = getRouteFromEntrypoint(entryName) - if (!pagePath) { - continue - } + const ampFirstEntryNames = ampFirstEntryNamesMap.get(compilation) + if (ampFirstEntryNames) { + for (const entryName of ampFirstEntryNames) { + const pagePath = getRouteFromEntrypoint(entryName) + if (!pagePath) { + continue + } - assetMap.ampFirstPages.push(pagePath) + assetMap.ampFirstPages.push(pagePath) + } } - } - const mainJsChunk = namedChunks.get(CLIENT_STATIC_FILES_RUNTIME_MAIN) + const mainJsChunk = namedChunks.get(CLIENT_STATIC_FILES_RUNTIME_MAIN) - const mainJsFiles: string[] = getFilesArray(mainJsChunk?.files).filter( - isJsFile - ) + const mainJsFiles: string[] = getFilesArray(mainJsChunk?.files).filter( + isJsFile + ) - const polyfillChunk = namedChunks.get( - CLIENT_STATIC_FILES_RUNTIME_POLYFILLS - ) + const polyfillChunk = namedChunks.get( + CLIENT_STATIC_FILES_RUNTIME_POLYFILLS + ) - // Create a separate entry for polyfills - assetMap.polyfillFiles = getFilesArray(polyfillChunk?.files).filter( - isJsFile - ) + // Create a separate entry for polyfills + assetMap.polyfillFiles = getFilesArray(polyfillChunk?.files).filter( + isJsFile + ) - const reactRefreshChunk = namedChunks.get( - CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH - ) - assetMap.devFiles = getFilesArray(reactRefreshChunk?.files).filter( - isJsFile - ) + const reactRefreshChunk = namedChunks.get( + CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH + ) + assetMap.devFiles = getFilesArray(reactRefreshChunk?.files).filter( + isJsFile + ) - for (const entrypoint of compilation.entrypoints.values()) { - const isAmpRuntime = entrypoint.name === CLIENT_STATIC_FILES_RUNTIME_AMP + for (const entrypoint of compilation.entrypoints.values()) { + const isAmpRuntime = + entrypoint.name === CLIENT_STATIC_FILES_RUNTIME_AMP - if (isAmpRuntime) { - for (const file of entrypoint.getFiles()) { - if (!(isJsFile(file) || file.endsWith('.css'))) { - continue + if (isAmpRuntime) { + for (const file of entrypoint.getFiles()) { + if (!(isJsFile(file) || file.endsWith('.css'))) { + continue + } + + assetMap.ampDevFiles.push(file.replace(/\\/g, '/')) } + continue + } + const pagePath = getRouteFromEntrypoint(entrypoint.name) - assetMap.ampDevFiles.push(file.replace(/\\/g, '/')) + if (!pagePath) { + continue } - continue - } - const pagePath = getRouteFromEntrypoint(entrypoint.name) - if (!pagePath) { - continue - } + const filesForEntry: string[] = [] - const filesForEntry: string[] = [] + // getFiles() - helper function to read the files for an entrypoint from stats object + for (const file of entrypoint.getFiles()) { + if (!(isJsFile(file) || file.endsWith('.css'))) { + continue + } - // getFiles() - helper function to read the files for an entrypoint from stats object - for (const file of entrypoint.getFiles()) { - if (!(isJsFile(file) || file.endsWith('.css'))) { - continue + filesForEntry.push(file.replace(/\\/g, '/')) } - filesForEntry.push(file.replace(/\\/g, '/')) + assetMap.pages[pagePath] = [...mainJsFiles, ...filesForEntry] } - assetMap.pages[pagePath] = [...mainJsFiles, ...filesForEntry] - } - - // Add the runtime build manifest file (generated later in this file) - // as a dependency for the app. If the flag is false, the file won't be - // downloaded by the client. - assetMap.lowPriorityFiles.push( - `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` - ) + // Add the runtime build manifest file (generated later in this file) + // as a dependency for the app. If the flag is false, the file won't be + // downloaded by the client. + assetMap.lowPriorityFiles.push( + `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` + ) - // Add the runtime ssg manifest file as a lazy-loaded file dependency. - // We also stub this file out for development mode (when it is not - // generated). - const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` + // Add the runtime ssg manifest file as a lazy-loaded file dependency. + // We also stub this file out for development mode (when it is not + // generated). + const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` - const ssgManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.js` - assetMap.lowPriorityFiles.push(ssgManifestPath) - assets[ssgManifestPath] = new RawSource(srcEmptySsgManifest) + const ssgManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.js` + assetMap.lowPriorityFiles.push(ssgManifestPath) + assets[ssgManifestPath] = new RawSource(srcEmptySsgManifest) - assetMap.pages = Object.keys(assetMap.pages) - .sort() - // eslint-disable-next-line - .reduce((a, c) => ((a[c] = assetMap.pages[c]), a), {} as any) + assetMap.pages = Object.keys(assetMap.pages) + .sort() + // eslint-disable-next-line + .reduce((a, c) => ((a[c] = assetMap.pages[c]), a), {} as any) - assets[BUILD_MANIFEST] = new RawSource(JSON.stringify(assetMap, null, 2)) + assets[BUILD_MANIFEST] = new RawSource( + JSON.stringify(assetMap, null, 2) + ) - const clientManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` + const clientManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` - assets[clientManifestPath] = new RawSource( - `self.__BUILD_MANIFEST = ${generateClientManifest( - assetMap, - this.rewrites - )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` - ) + assets[clientManifestPath] = new RawSource( + `self.__BUILD_MANIFEST = ${generateClientManifest( + compiler, + assetMap, + this.rewrites + )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` + ) - return assets + return assets + }) }) } @@ -228,7 +237,7 @@ export default class BuildManifestPlugin { stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, (assets: any) => { - this.createAssets(compilation, assets) + this.createAssets(compiler, compilation, assets) } ) }) @@ -236,7 +245,7 @@ export default class BuildManifestPlugin { } compiler.hooks.emit.tap('NextJsBuildManifest', (compilation: any) => { - this.createAssets(compilation, compilation.assets) + this.createAssets(compiler, compilation, compilation.assets) }) } } diff --git a/packages/next/build/webpack/plugins/css-minimizer-plugin.ts b/packages/next/build/webpack/plugins/css-minimizer-plugin.ts index 84af2bbaa9e33e6..6f6a18b26fb87e8 100644 --- a/packages/next/build/webpack/plugins/css-minimizer-plugin.ts +++ b/packages/next/build/webpack/plugins/css-minimizer-plugin.ts @@ -4,6 +4,7 @@ import postcss, { Parser } from 'postcss' import webpack from 'webpack' import sources from 'webpack-sources' import { tracer, traceAsyncFn } from '../../tracer' +import { spans } from './profiling-plugin' // @ts-ignore: TODO: remove ignore when webpack 5 is stable const { RawSource, SourceMapSource } = webpack.sources || sources @@ -71,87 +72,91 @@ export class CssMinimizerPlugin { stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, }, async (assets: any) => { + return tracer.withSpan(spans.get(compiler), () => { + const span = tracer.startSpan('css-minimizer-plugin', { + attributes: { + webpackVersion: 5, + }, + }) + + return traceAsyncFn(span, async () => { + const files = Object.keys(assets) + await Promise.all( + files + .filter((file) => CSS_REGEX.test(file)) + .map(async (file) => { + const assetSpan = tracer.startSpan('minify-css', { + attributes: { + file, + }, + }) + return traceAsyncFn(span, async () => { + const asset = assets[file] + + const etag = cache.getLazyHashedEtag(asset) + + const cachedResult = await cache.getPromise(file, etag) + + assetSpan.setAttribute( + 'cache', + cachedResult ? 'HIT' : 'MISS' + ) + if (cachedResult) { + assets[file] = cachedResult + return + } + + const result = await this.optimizeAsset(file, asset) + await cache.storePromise(file, etag, result) + assets[file] = result + }) + }) + ) + }) + }) + } + ) + return + } + compilation.hooks.optimizeChunkAssets.tapPromise( + 'CssMinimizerPlugin', + (chunks: webpack.compilation.Chunk[]) => { + return tracer.withSpan(spans.get(compiler), () => { const span = tracer.startSpan('css-minimizer-plugin', { attributes: { - webpackVersion: 5, + webpackVersion: 4, + compilationName: compilation.name, }, }) return traceAsyncFn(span, async () => { - const files = Object.keys(assets) - await Promise.all( - files - .filter((file) => CSS_REGEX.test(file)) + const res = await Promise.all( + chunks + .reduce( + (acc, chunk) => acc.concat(chunk.files || []), + [] as string[] + ) + .filter((entry) => CSS_REGEX.test(entry)) .map(async (file) => { const assetSpan = tracer.startSpan('minify-css', { attributes: { file, }, }) - return traceAsyncFn(span, async () => { - const asset = assets[file] - - const etag = cache.getLazyHashedEtag(asset) - - const cachedResult = await cache.getPromise(file, etag) - - assetSpan.setAttribute( - 'cache', - cachedResult ? 'HIT' : 'MISS' + return traceAsyncFn(assetSpan, async () => { + const asset = compilation.assets[file] + // Makes trace attributes the same as webpack 5 + // When using webpack 4 the result is not cached + assetSpan.setAttribute('cache', 'MISS') + compilation.assets[file] = await this.optimizeAsset( + file, + asset ) - if (cachedResult) { - assets[file] = cachedResult - return - } - - const result = await this.optimizeAsset(file, asset) - await cache.storePromise(file, etag, result) - assets[file] = result }) }) ) + return res }) - } - ) - return - } - compilation.hooks.optimizeChunkAssets.tapPromise( - 'CssMinimizerPlugin', - (chunks: webpack.compilation.Chunk[]) => { - const span = tracer.startSpan('css-minimizer-plugin', { - attributes: { - webpackVersion: 4, - compilationName: compilation.name, - }, - }) - - return traceAsyncFn(span, async () => { - const res = await Promise.all( - chunks - .reduce( - (acc, chunk) => acc.concat(chunk.files || []), - [] as string[] - ) - .filter((entry) => CSS_REGEX.test(entry)) - .map(async (file) => { - const assetSpan = tracer.startSpan('minify-css', { - attributes: { - file, - }, - }) - return traceAsyncFn(assetSpan, async () => { - const asset = compilation.assets[file] - // Makes trace attributes the same as webpack 5 - // When using webpack 4 the result is not cached - assetSpan.setAttribute('cache', 'MISS') - compilation.assets[file] = await this.optimizeAsset( - file, - asset - ) - }) - }) - ) - return res }) } ) diff --git a/packages/next/build/webpack/plugins/profiling-plugin.ts b/packages/next/build/webpack/plugins/profiling-plugin.ts index 80bd99feed255f2..23c9272c363f69f 100644 --- a/packages/next/build/webpack/plugins/profiling-plugin.ts +++ b/packages/next/build/webpack/plugins/profiling-plugin.ts @@ -1,257 +1,45 @@ -// Copy of https://github.com/webpack/webpack/blob/master/lib/debug/ProfilingPlugin.js -// License: -/* -Copyright JS Foundation and other contributors +import { tracer } from '../../tracer' -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -// Includes fix https://github.com/webpack/webpack/pull/9566 -// Includes support for custom tracer that can capture across multiple builds const pluginName = 'ProfilingPlugin' -export class ProfilingPlugin { - tracer: any - /** - * @param {ProfilingPluginOptions=} opts options object - */ - constructor(opts: { tracer: any }) { - this.tracer = opts.tracer - } +export const spans = new WeakMap() +export class ProfilingPlugin { apply(compiler: any) { - const tracer = this.tracer - - // Compiler Hooks - Object.keys(compiler.hooks).forEach((hookName) => { - compiler.hooks[hookName].intercept( - makeInterceptorFor('Compiler', tracer)(hookName) - ) - }) + // Only enabled when instrumentation is loaded + if (!tracer.getCurrentSpan()) { + return + } - Object.keys(compiler.resolverFactory.hooks).forEach((hookName) => { - compiler.resolverFactory.hooks[hookName].intercept( - makeInterceptorFor('Resolver', tracer)(hookName) - ) + compiler.hooks.compile.tap(pluginName, () => { + const span = tracer.startSpan('webpack-compile', { + attributes: { name: compiler.name }, + }) + spans.set(compiler, span) }) - - compiler.hooks.compilation.tap( - pluginName, - ( - compilation: any, - { normalModuleFactory, contextModuleFactory }: any - ) => { - interceptAllHooksFor(compilation, tracer, 'Compilation') - interceptAllHooksFor( - normalModuleFactory, - tracer, - 'Normal Module Factory' - ) - interceptAllHooksFor( - contextModuleFactory, - tracer, - 'Context Module Factory' - ) - interceptAllParserHooks(normalModuleFactory, tracer) - interceptTemplateInstancesFrom(compilation, tracer) - } - ) - } -} - -const interceptTemplateInstancesFrom = (compilation: any, tracer: any) => { - const { - mainTemplate, - chunkTemplate, - hotUpdateChunkTemplate, - moduleTemplates, - } = compilation - - const { javascript, webassembly } = moduleTemplates - ;[ - { - instance: mainTemplate, - name: 'MainTemplate', - }, - { - instance: chunkTemplate, - name: 'ChunkTemplate', - }, - { - instance: hotUpdateChunkTemplate, - name: 'HotUpdateChunkTemplate', - }, - { - instance: javascript, - name: 'JavaScriptModuleTemplate', - }, - { - instance: webassembly, - name: 'WebAssemblyModuleTemplate', - }, - ].forEach((templateObject) => { - Object.keys(templateObject.instance.hooks).forEach((hookName) => { - templateObject.instance.hooks[hookName].intercept( - makeInterceptorFor(templateObject.name, tracer)(hookName) - ) + compiler.hooks.done.tap(pluginName, () => { + spans.get(compiler).end() }) - }) -} + compiler.hooks.compilation.tap(pluginName, (compilation: any) => { + compilation.hooks.buildModule.tap(pluginName, (module: any) => { + tracer.withSpan(spans.get(compiler), () => { + const span = tracer.startSpan('build-module') + span.setAttribute('name', module.userRequest) + spans.set(module, span) + }) + }) -const interceptAllHooksFor = (instance: any, tracer: any, logLabel: any) => { - if (Reflect.has(instance, 'hooks')) { - Object.keys(instance.hooks).forEach((hookName) => { - instance.hooks[hookName].intercept( - makeInterceptorFor(logLabel, tracer)(hookName) + compilation.hooks.normalModuleLoader.tap( + pluginName, + (loaderContext: any, module: any) => { + const parentSpan = spans.get(module) + loaderContext.currentTraceSpan = parentSpan + } ) - }) - } -} - -const interceptAllParserHooks = (moduleFactory: any, tracer: any) => { - const moduleTypes = [ - 'javascript/auto', - 'javascript/dynamic', - 'javascript/esm', - 'json', - 'webassembly/experimental', - ] - moduleTypes.forEach((moduleType) => { - moduleFactory.hooks.parser - .for(moduleType) - .tap('ProfilingPlugin', (parser: any) => { - interceptAllHooksFor(parser, tracer, 'Parser') + compilation.hooks.succeedModule.tap(pluginName, (module: any) => { + spans.get(module).end() }) - }) -} - -const makeInterceptorFor = (_instance: any, tracer: any) => ( - hookName: any -) => ({ - register: ({ name, type, context, fn }: any) => { - const newFn = makeNewProfiledTapFn(hookName, tracer, { - name, - type, - fn, }) - return { - name, - type, - context, - fn: newFn, - } - }, -}) - -// TODO improve typing -/** @typedef {(...args: TODO[]) => void | Promise} PluginFunction */ - -/** - * @param {string} hookName Name of the hook to profile. - * @param {Trace} tracer The trace object. - * @param {object} options Options for the profiled fn. - * @param {string} options.name Plugin name - * @param {string} options.type Plugin type (sync | async | promise) - * @param {PluginFunction} options.fn Plugin function - * @returns {PluginFunction} Chainable hooked function. - */ -const makeNewProfiledTapFn = ( - _hookName: any, - tracer: any, - { name, type, fn }: any -) => { - const defaultCategory = ['blink.user_timing'] - - switch (type) { - case 'promise': - return (...args: any) => { - const id = ++tracer.counter - tracer.trace.begin({ - name, - id, - cat: defaultCategory, - }) - const promise = /** @type {Promise<*>} */ fn(...args) - return promise.then((r: any) => { - tracer.trace.end({ - name, - id, - cat: defaultCategory, - }) - return r - }) - } - case 'async': - return (...args: any) => { - const id = ++tracer.counter - tracer.trace.begin({ - name, - id, - cat: defaultCategory, - }) - const callback = args.pop() - /* eslint-disable */ - fn(...args, (...r: any) => { - tracer.trace.end({ - name, - id, - cat: defaultCategory, - }) - callback(...r) - }) - /* eslint-enable */ - } - case 'sync': - return (...args: any) => { - const id = ++tracer.counter - // Do not instrument ourself due to the CPU - // profile needing to be the last event in the trace. - if (name === pluginName) { - return fn(...args) - } - - tracer.trace.begin({ - name, - id, - cat: defaultCategory, - }) - let r - try { - r = fn(...args) - } catch (error) { - tracer.trace.end({ - name, - id, - cat: defaultCategory, - }) - throw error - } - tracer.trace.end({ - name, - id, - cat: defaultCategory, - }) - return r - } - default: - break } } diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js index 0d9273ba4cf1181..5c20c71d32bc6bd 100644 --- a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js @@ -8,6 +8,7 @@ import jestWorker from 'jest-worker' import crypto from 'crypto' import cacache from 'next/dist/compiled/cacache' import { tracer, traceAsyncFn } from '../../../../tracer' +import { spans } from '../../profiling-plugin' const isWebpack5 = parseInt(webpack.version) === 5 @@ -113,196 +114,201 @@ class TerserPlugin { } async optimize( + compiler, compilation, assets, optimizeOptions, cache, { SourceMapSource, RawSource } ) { - const span = tracer.startSpan('terser-webpack-plugin-optimize', { - attributes: { - webpackVersion: isWebpack5 ? 5 : 4, - compilationName: compilation.name, - }, - }) - - return traceAsyncFn(span, async () => { - let numberOfAssetsForMinify = 0 - const assetsList = isWebpack5 - ? Object.keys(assets) - : [ - ...Array.from(compilation.additionalChunkAssets || []), - ...Array.from(assets).reduce((acc, chunk) => { - return acc.concat(Array.from(chunk.files || [])) - }, []), - ] - - const assetsForMinify = await Promise.all( - assetsList - .filter((name) => { - if ( - !ModuleFilenameHelpers.matchObject.bind( - // eslint-disable-next-line no-undefined - undefined, - { test: /\.[cm]?js(\?.*)?$/i } - )(name) - ) { - return false - } - - const res = compilation.getAsset(name) - if (!res) { - console.log(name) - return false - } + const compilerSpan = spans.get(compiler) + + return tracer.withSpan(compilerSpan, () => { + const span = tracer.startSpan('terser-webpack-plugin-optimize', { + attributes: { + webpackVersion: isWebpack5 ? 5 : 4, + compilationName: compilation.name, + }, + }) + + return traceAsyncFn(span, async () => { + let numberOfAssetsForMinify = 0 + const assetsList = isWebpack5 + ? Object.keys(assets) + : [ + ...Array.from(compilation.additionalChunkAssets || []), + ...Array.from(assets).reduce((acc, chunk) => { + return acc.concat(Array.from(chunk.files || [])) + }, []), + ] + + const assetsForMinify = await Promise.all( + assetsList + .filter((name) => { + if ( + !ModuleFilenameHelpers.matchObject.bind( + // eslint-disable-next-line no-undefined + undefined, + { test: /\.[cm]?js(\?.*)?$/i } + )(name) + ) { + return false + } - const { info } = res + const res = compilation.getAsset(name) + if (!res) { + console.log(name) + return false + } - // Skip double minimize assets from child compilation - if (info.minimized) { - return false - } + const { info } = res - return true - }) - .map(async (name) => { - const { info, source } = compilation.getAsset(name) + // Skip double minimize assets from child compilation + if (info.minimized) { + return false + } - const eTag = cache.getLazyHashedEtag(source) - const output = await cache.getPromise(name, eTag) + return true + }) + .map(async (name) => { + const { info, source } = compilation.getAsset(name) - if (!output) { - numberOfAssetsForMinify += 1 - } + const eTag = cache.getLazyHashedEtag(source) + const output = await cache.getPromise(name, eTag) - return { name, info, inputSource: source, output, eTag } - }) - ) + if (!output) { + numberOfAssetsForMinify += 1 + } - const numberOfWorkers = Math.min( - numberOfAssetsForMinify, - optimizeOptions.availableNumberOfCores - ) + return { name, info, inputSource: source, output, eTag } + }) + ) - let initializedWorker + const numberOfWorkers = Math.min( + numberOfAssetsForMinify, + optimizeOptions.availableNumberOfCores + ) - // eslint-disable-next-line consistent-return - const getWorker = () => { - if (initializedWorker) { - return initializedWorker - } + let initializedWorker - initializedWorker = new jestWorker( - path.join(__dirname, './minify.js'), - { - numWorkers: numberOfWorkers, - enableWorkerThreads: true, + // eslint-disable-next-line consistent-return + const getWorker = () => { + if (initializedWorker) { + return initializedWorker } - ) - initializedWorker.getStdout().pipe(process.stdout) - initializedWorker.getStderr().pipe(process.stderr) + initializedWorker = new jestWorker( + path.join(__dirname, './minify.js'), + { + numWorkers: numberOfWorkers, + enableWorkerThreads: true, + } + ) - return initializedWorker - } + initializedWorker.getStdout().pipe(process.stdout) + initializedWorker.getStderr().pipe(process.stderr) - const limit = pLimit( - numberOfAssetsForMinify > 0 ? numberOfWorkers : Infinity - ) - const scheduledTasks = [] - - for (const asset of assetsForMinify) { - scheduledTasks.push( - limit(async () => { - const { name, inputSource, info, eTag } = asset - let { output } = asset - - const assetSpan = tracer.startSpan('minify-js', { - attributes: { - name, - cache: typeof output === 'undefined' ? 'MISS' : 'HIT', - }, - }) + return initializedWorker + } - return traceAsyncFn(assetSpan, async () => { - if (!output) { - const { - source: sourceFromInputSource, - map: inputSourceMap, - } = inputSource.sourceAndMap() + const limit = pLimit( + numberOfAssetsForMinify > 0 ? numberOfWorkers : Infinity + ) + const scheduledTasks = [] - const input = Buffer.isBuffer(sourceFromInputSource) - ? sourceFromInputSource.toString() - : sourceFromInputSource + for (const asset of assetsForMinify) { + scheduledTasks.push( + limit(async () => { + const { name, inputSource, info, eTag } = asset + let { output } = asset - const options = { + const assetSpan = tracer.startSpan('minify-js', { + attributes: { name, - input, - inputSourceMap, - terserOptions: { ...this.options.terserOptions }, - } + cache: typeof output === 'undefined' ? 'MISS' : 'HIT', + }, + }) + + return traceAsyncFn(assetSpan, async () => { + if (!output) { + const { + source: sourceFromInputSource, + map: inputSourceMap, + } = inputSource.sourceAndMap() + + const input = Buffer.isBuffer(sourceFromInputSource) + ? sourceFromInputSource.toString() + : sourceFromInputSource + + const options = { + name, + input, + inputSourceMap, + terserOptions: { ...this.options.terserOptions }, + } - if (typeof options.terserOptions.module === 'undefined') { - if (typeof info.javascriptModule !== 'undefined') { - options.terserOptions.module = info.javascriptModule - } else if (/\.mjs(\?.*)?$/i.test(name)) { - options.terserOptions.module = true - } else if (/\.cjs(\?.*)?$/i.test(name)) { - options.terserOptions.module = false + if (typeof options.terserOptions.module === 'undefined') { + if (typeof info.javascriptModule !== 'undefined') { + options.terserOptions.module = info.javascriptModule + } else if (/\.mjs(\?.*)?$/i.test(name)) { + options.terserOptions.module = true + } else if (/\.cjs(\?.*)?$/i.test(name)) { + options.terserOptions.module = false + } } - } - try { - output = await getWorker().minify(options) - } catch (error) { - compilation.errors.push(buildError(error, name)) + try { + output = await getWorker().minify(options) + } catch (error) { + compilation.errors.push(buildError(error, name)) - return - } + return + } - if (output.map) { - output.source = new SourceMapSource( - output.code, - name, - output.map, - input, - /** @type {SourceMapRawSourceMap} */ (inputSourceMap), - true - ) - } else { - output.source = new RawSource(output.code) - } + if (output.map) { + output.source = new SourceMapSource( + output.code, + name, + output.map, + input, + /** @type {SourceMapRawSourceMap} */ (inputSourceMap), + true + ) + } else { + output.source = new RawSource(output.code) + } - if (isWebpack5) { - await cache.storePromise(name, eTag, { - source: output.source, - }) - } else { - await cache.storePromise(name, eTag, { - code: output.code, - map: output.map, - name, - input, - inputSourceMap, - }) + if (isWebpack5) { + await cache.storePromise(name, eTag, { + source: output.source, + }) + } else { + await cache.storePromise(name, eTag, { + code: output.code, + map: output.map, + name, + input, + inputSourceMap, + }) + } } - } - /** @type {AssetInfo} */ - const newInfo = { minimized: true } - const { source } = output + /** @type {AssetInfo} */ + const newInfo = { minimized: true } + const { source } = output - compilation.updateAsset(name, source, newInfo) + compilation.updateAsset(name, source, newInfo) + }) }) - }) - ) - } + ) + } - await Promise.all(scheduledTasks) + await Promise.all(scheduledTasks) - if (initializedWorker) { - await initializedWorker.end() - } + if (initializedWorker) { + await initializedWorker.end() + } + }) }) } @@ -355,6 +361,7 @@ class TerserPlugin { }, (assets) => this.optimize( + compiler, compilation, assets, { @@ -387,6 +394,7 @@ class TerserPlugin { pluginName, async (assets) => { return await this.optimize( + compiler, compilation, assets, { diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 814db195b8d9ea7..fc611fccb91a5d5 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -46,6 +46,8 @@ import { PrerenderManifest } from '../build' import exportPage from './worker' import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import { getPagePath } from '../next-server/server/require' +import { tracer, traceFn, traceAsyncFn } from '../build/tracer' +import opentelemetryApi from '@opentelemetry/api' const exists = promisify(existsOrig) @@ -135,465 +137,498 @@ export default async function exportApp( options: ExportOptions, configuration?: NextConfig ): Promise { - dir = resolve(dir) + const nextExportSpan = tracer.startSpan('next-export') + return traceAsyncFn(nextExportSpan, async () => { + dir = resolve(dir) - // attempt to load global env values so they are available in next.config.js - loadEnvConfig(dir, false, Log) - - const nextConfig = configuration || loadConfig(PHASE_EXPORT, dir) - const threads = options.threads || Math.max(cpus().length - 1, 1) - const distDir = join(dir, nextConfig.distDir) - - const telemetry = options.buildExport ? null : new Telemetry({ distDir }) - - if (telemetry) { - telemetry.record( - eventCliSession(PHASE_EXPORT, distDir, { - cliCommand: 'export', - isSrcDir: null, - hasNowJson: !!(await findUp('now.json', { cwd: dir })), - isCustomServer: null, - }) + // attempt to load global env values so they are available in next.config.js + traceFn(tracer.startSpan('load-dotenv'), () => + loadEnvConfig(dir, false, Log) ) - } - const subFolders = nextConfig.trailingSlash - const isLikeServerless = nextConfig.target !== 'server' - - if (!options.silent && !options.buildExport) { - Log.info(`using build directory: ${distDir}`) - } - - const buildIdFile = join(distDir, BUILD_ID_FILE) - - if (!existsSync(buildIdFile)) { - throw new Error( - `Could not find a production build in the '${distDir}' directory. Try building your app with 'next build' before starting the static export. https://err.sh/vercel/next.js/next-export-no-build-id` - ) - } - - const customRoutesDetected = ['rewrites', 'redirects', 'headers'].filter( - (config) => typeof nextConfig[config] === 'function' - ) - - if ( - !hasNextSupport && - !options.buildExport && - customRoutesDetected.length > 0 - ) { - Log.warn( - `rewrites, redirects, and headers are not applied when exporting your application, detected (${customRoutesDetected.join( - ', ' - )}). See more info here: https://err.sh/next.js/export-no-custom-routes` - ) - } - - const buildId = readFileSync(buildIdFile, 'utf8') - const pagesManifest = - !options.pages && - (require(join( - distDir, - isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, - PAGES_MANIFEST - )) as PagesManifest) - - let prerenderManifest: PrerenderManifest | undefined = undefined - try { - prerenderManifest = require(join(distDir, PRERENDER_MANIFEST)) - } catch (_) {} - - const excludedPrerenderRoutes = new Set() - const pages = options.pages || Object.keys(pagesManifest) - const defaultPathMap: ExportPathMap = {} - let hasApiRoutes = false - - for (const page of pages) { - // _document and _app are not real pages - // _error is exported as 404.html later on - // API Routes are Node.js functions - - if (page.match(API_ROUTE)) { - hasApiRoutes = true - continue + const nextConfig = + configuration || + traceFn(tracer.startSpan('load-next-config'), () => + loadConfig(PHASE_EXPORT, dir) + ) + const threads = options.threads || Math.max(cpus().length - 1, 1) + const distDir = join(dir, nextConfig.distDir) + + const telemetry = options.buildExport ? null : new Telemetry({ distDir }) + + if (telemetry) { + telemetry.record( + eventCliSession(PHASE_EXPORT, distDir, { + cliCommand: 'export', + isSrcDir: null, + hasNowJson: !!(await findUp('now.json', { cwd: dir })), + isCustomServer: null, + }) + ) } - if (page === '/_document' || page === '/_app' || page === '/_error') { - continue - } + const subFolders = nextConfig.trailingSlash + const isLikeServerless = nextConfig.target !== 'server' - // iSSG pages that are dynamic should not export templated version by - // default. In most cases, this would never work. There is no server that - // could run `getStaticProps`. If users make their page work lazily, they - // can manually add it to the `exportPathMap`. - if (prerenderManifest?.dynamicRoutes[page]) { - excludedPrerenderRoutes.add(page) - continue + if (!options.silent && !options.buildExport) { + Log.info(`using build directory: ${distDir}`) } - defaultPathMap[page] = { page } - } - - // Initialize the output directory - const outDir = options.outdir + const buildIdFile = join(distDir, BUILD_ID_FILE) - if (outDir === join(dir, 'public')) { - throw new Error( - `The 'public' directory is reserved in Next.js and can not be used as the export out directory. https://err.sh/vercel/next.js/can-not-output-to-public` - ) - } - - await recursiveDelete(join(outDir)) - await promises.mkdir(join(outDir, '_next', buildId), { recursive: true }) - - writeFileSync( - join(distDir, EXPORT_DETAIL), - JSON.stringify({ - version: 1, - outDirectory: outDir, - success: false, - }), - 'utf8' - ) - - // Copy static directory - if (!options.buildExport && existsSync(join(dir, 'static'))) { - if (!options.silent) { - Log.info('Copying "static" directory') + if (!existsSync(buildIdFile)) { + throw new Error( + `Could not find a production build in the '${distDir}' directory. Try building your app with 'next build' before starting the static export. https://err.sh/vercel/next.js/next-export-no-build-id` + ) } - await recursiveCopy(join(dir, 'static'), join(outDir, 'static')) - } - // Copy .next/static directory - if ( - !options.buildExport && - existsSync(join(distDir, CLIENT_STATIC_FILES_PATH)) - ) { - if (!options.silent) { - Log.info('Copying "static build" directory') - } - await recursiveCopy( - join(distDir, CLIENT_STATIC_FILES_PATH), - join(outDir, '_next', CLIENT_STATIC_FILES_PATH) + const customRoutesDetected = ['rewrites', 'redirects', 'headers'].filter( + (config) => typeof nextConfig[config] === 'function' ) - } - // Get the exportPathMap from the config file - if (typeof nextConfig.exportPathMap !== 'function') { - if (!options.silent) { - Log.info( - `No "exportPathMap" found in "${CONFIG_FILE}". Generating map from "./pages"` + if ( + !hasNextSupport && + !options.buildExport && + customRoutesDetected.length > 0 + ) { + Log.warn( + `rewrites, redirects, and headers are not applied when exporting your application, detected (${customRoutesDetected.join( + ', ' + )}). See more info here: https://err.sh/next.js/export-no-custom-routes` ) } - nextConfig.exportPathMap = async (defaultMap: ExportPathMap) => { - return defaultMap - } - } - const { - i18n, - images: { loader = 'default' }, - } = nextConfig - - if (i18n && !options.buildExport) { - throw new Error( - `i18n support is not compatible with next export. See here for more info on deploying: https://nextjs.org/docs/deployment` - ) - } + const buildId = readFileSync(buildIdFile, 'utf8') + const pagesManifest = + !options.pages && + (require(join( + distDir, + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, + PAGES_MANIFEST + )) as PagesManifest) + + let prerenderManifest: PrerenderManifest | undefined = undefined + try { + prerenderManifest = require(join(distDir, PRERENDER_MANIFEST)) + } catch (_) {} + + const excludedPrerenderRoutes = new Set() + const pages = options.pages || Object.keys(pagesManifest) + const defaultPathMap: ExportPathMap = {} + let hasApiRoutes = false + + for (const page of pages) { + // _document and _app are not real pages + // _error is exported as 404.html later on + // API Routes are Node.js functions + + if (page.match(API_ROUTE)) { + hasApiRoutes = true + continue + } - const { isNextImageImported } = await promises - .readFile(join(distDir, EXPORT_MARKER), 'utf8') - .then((text) => JSON.parse(text)) - .catch(() => ({})) - - if ( - isNextImageImported && - loader === 'default' && - !options.buildExport && - !hasNextSupport - ) { - throw new Error( - `Image Optimization using Next.js' default loader is not compatible with \`next export\`. -Possible solutions: - - Use \`next start\`, which starts the Image Optimization API. - - Use Vercel to deploy, which supports Image Optimization. - - Configure a third-party loader in \`next.config.js\`. -Read more: https://err.sh/next.js/export-image-api` - ) - } + if (page === '/_document' || page === '/_app' || page === '/_error') { + continue + } - // Start the rendering process - const renderOpts = { - dir, - buildId, - nextExport: true, - assetPrefix: nextConfig.assetPrefix.replace(/\/$/, ''), - distDir, - dev: false, - hotReloader: null, - basePath: nextConfig.basePath, - canonicalBase: nextConfig.amp?.canonicalBase || '', - ampValidatorPath: nextConfig.experimental.amp?.validator || undefined, - ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false, - ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined, - locales: i18n?.locales, - locale: i18n?.defaultLocale, - defaultLocale: i18n?.defaultLocale, - domainLocales: i18n?.domains, - } + // iSSG pages that are dynamic should not export templated version by + // default. In most cases, this would never work. There is no server that + // could run `getStaticProps`. If users make their page work lazily, they + // can manually add it to the `exportPathMap`. + if (prerenderManifest?.dynamicRoutes[page]) { + excludedPrerenderRoutes.add(page) + continue + } - const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig + defaultPathMap[page] = { page } + } - if (Object.keys(publicRuntimeConfig).length > 0) { - ;(renderOpts as any).runtimeConfig = publicRuntimeConfig - } + // Initialize the output directory + const outDir = options.outdir - // We need this for server rendering the Link component. - ;(global as any).__NEXT_DATA__ = { - nextExport: true, - } + if (outDir === join(dir, 'public')) { + throw new Error( + `The 'public' directory is reserved in Next.js and can not be used as the export out directory. https://err.sh/vercel/next.js/can-not-output-to-public` + ) + } - if (!options.silent && !options.buildExport) { - Log.info(`Launching ${threads} workers`) - } - const exportPathMap = await nextConfig.exportPathMap(defaultPathMap, { - dev: false, - dir, - outDir, - distDir, - buildId, - }) + await recursiveDelete(join(outDir)) + await promises.mkdir(join(outDir, '_next', buildId), { recursive: true }) + + writeFileSync( + join(distDir, EXPORT_DETAIL), + JSON.stringify({ + version: 1, + outDirectory: outDir, + success: false, + }), + 'utf8' + ) - if (!exportPathMap['/404'] && !exportPathMap['/404.html']) { - exportPathMap['/404'] = exportPathMap['/404.html'] = { - page: '/_error', + // Copy static directory + if (!options.buildExport && existsSync(join(dir, 'static'))) { + if (!options.silent) { + Log.info('Copying "static" directory') + } + await traceAsyncFn(tracer.startSpan('copy-static-directory'), () => + recursiveCopy(join(dir, 'static'), join(outDir, 'static')) + ) } - } - // make sure to prevent duplicates - const exportPaths = [ - ...new Set( - Object.keys(exportPathMap).map((path) => - denormalizePagePath(normalizePagePath(path)) + // Copy .next/static directory + if ( + !options.buildExport && + existsSync(join(distDir, CLIENT_STATIC_FILES_PATH)) + ) { + if (!options.silent) { + Log.info('Copying "static build" directory') + } + await traceAsyncFn(tracer.startSpan('copy-next-static-directory'), () => + recursiveCopy( + join(distDir, CLIENT_STATIC_FILES_PATH), + join(outDir, '_next', CLIENT_STATIC_FILES_PATH) + ) ) - ), - ] - - const filteredPaths = exportPaths.filter( - // Remove API routes - (route) => !exportPathMap[route].page.match(API_ROUTE) - ) - - if (filteredPaths.length !== exportPaths.length) { - hasApiRoutes = true - } - - if (prerenderManifest && !options.buildExport) { - const fallbackEnabledPages = new Set() + } - for (const key of Object.keys(prerenderManifest.dynamicRoutes)) { - // only error if page is included in path map - if (!exportPathMap[key] && !excludedPrerenderRoutes.has(key)) { - continue + // Get the exportPathMap from the config file + if (typeof nextConfig.exportPathMap !== 'function') { + if (!options.silent) { + Log.info( + `No "exportPathMap" found in "${CONFIG_FILE}". Generating map from "./pages"` + ) } - - if (prerenderManifest.dynamicRoutes[key].fallback !== false) { - fallbackEnabledPages.add(key) + nextConfig.exportPathMap = async (defaultMap: ExportPathMap) => { + return defaultMap } } - if (fallbackEnabledPages.size) { + const { + i18n, + images: { loader = 'default' }, + } = nextConfig + + if (i18n && !options.buildExport) { throw new Error( - `Found pages with \`fallback\` enabled:\n${[ - ...fallbackEnabledPages, - ].join('\n')}\n${SSG_FALLBACK_EXPORT_ERROR}\n` + `i18n support is not compatible with next export. See here for more info on deploying: https://nextjs.org/docs/deployment` ) } - } - // Warn if the user defines a path for an API page - if (hasApiRoutes) { - if (!options.silent) { - Log.warn( - chalk.yellow( - `Statically exporting a Next.js application via \`next export\` disables API routes.` - ) + - `\n` + - chalk.yellow( - `This command is meant for static-only hosts, and is` + - ' ' + - chalk.bold(`not necessary to make your application static.`) - ) + - `\n` + - chalk.yellow( - `Pages in your application without server-side data dependencies will be automatically statically exported by \`next build\`, including pages powered by \`getStaticProps\`.` - ) + - `\n` + - chalk.yellow( - `Learn more: https://err.sh/vercel/next.js/api-routes-static-export` - ) + if (!options.buildExport) { + const { isNextImageImported } = await traceAsyncFn( + tracer.startSpan('is-next-image-imported'), + () => + promises + .readFile(join(distDir, EXPORT_MARKER), 'utf8') + .then((text) => JSON.parse(text)) + .catch(() => ({})) ) + + if (isNextImageImported && loader === 'default' && !hasNextSupport) { + throw new Error( + `Image Optimization using Next.js' default loader is not compatible with \`next export\`. + Possible solutions: + - Use \`next start\`, which starts the Image Optimization API. + - Use Vercel to deploy, which supports Image Optimization. + - Configure a third-party loader in \`next.config.js\`. + Read more: https://err.sh/next.js/export-image-api` + ) + } } - } - const progress = - !options.silent && - createProgress( - filteredPaths.length, - `${Log.prefixes.info} ${options.statusMessage || 'Exporting'}` - ) - const pagesDataDir = options.buildExport - ? outDir - : join(outDir, '_next/data', buildId) - - const ampValidations: AmpPageStatus = {} - let hadValidationError = false - - const publicDir = join(dir, CLIENT_PUBLIC_FILES_PATH) - // Copy public directory - if (!options.buildExport && existsSync(publicDir)) { - if (!options.silent) { - Log.info('Copying "public" directory') + // Start the rendering process + const renderOpts = { + dir, + buildId, + nextExport: true, + assetPrefix: nextConfig.assetPrefix.replace(/\/$/, ''), + distDir, + dev: false, + hotReloader: null, + basePath: nextConfig.basePath, + canonicalBase: nextConfig.amp?.canonicalBase || '', + ampValidatorPath: nextConfig.experimental.amp?.validator || undefined, + ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false, + ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined, + locales: i18n?.locales, + locale: i18n?.defaultLocale, + defaultLocale: i18n?.defaultLocale, + domainLocales: i18n?.domains, } - await recursiveCopy(publicDir, outDir, { - filter(path) { - // Exclude paths used by pages - return !exportPathMap[path] - }, - }) - } - const worker = new Worker(require.resolve('./worker'), { - maxRetries: 0, - numWorkers: threads, - enableWorkerThreads: nextConfig.experimental.workerThreads, - exposedMethods: ['default'], - }) as Worker & { default: typeof exportPage } + const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig - worker.getStdout().pipe(process.stdout) - worker.getStderr().pipe(process.stderr) + if (Object.keys(publicRuntimeConfig).length > 0) { + ;(renderOpts as any).runtimeConfig = publicRuntimeConfig + } - let renderError = false - const errorPaths: string[] = [] + // We need this for server rendering the Link component. + ;(global as any).__NEXT_DATA__ = { + nextExport: true, + } - await Promise.all( - filteredPaths.map(async (path) => { - const result = await worker.default({ - path, - pathMap: exportPathMap[path], - distDir, - outDir, - pagesDataDir, - renderOpts, - serverRuntimeConfig, - subFolders, - buildExport: options.buildExport, - serverless: isTargetLikeServerless(nextConfig.target), - optimizeFonts: nextConfig.experimental.optimizeFonts, - optimizeImages: nextConfig.experimental.optimizeImages, - optimizeCss: nextConfig.experimental.optimizeCss, - }) + if (!options.silent && !options.buildExport) { + Log.info(`Launching ${threads} workers`) + } + const exportPathMap = await traceAsyncFn( + tracer.startSpan('run-export-path-map'), + () => + nextConfig.exportPathMap(defaultPathMap, { + dev: false, + dir, + outDir, + distDir, + buildId, + }) + ) - for (const validation of result.ampValidations || []) { - const { page, result: ampValidationResult } = validation - ampValidations[page] = ampValidationResult - hadValidationError = - hadValidationError || - (Array.isArray(ampValidationResult?.errors) && - ampValidationResult.errors.length > 0) + if (!exportPathMap['/404'] && !exportPathMap['/404.html']) { + exportPathMap['/404'] = exportPathMap['/404.html'] = { + page: '/_error', } - renderError = renderError || !!result.error - if (!!result.error) errorPaths.push(path) + } + + // make sure to prevent duplicates + const exportPaths = [ + ...new Set( + Object.keys(exportPathMap).map((path) => + denormalizePagePath(normalizePagePath(path)) + ) + ), + ] + + const filteredPaths = exportPaths.filter( + // Remove API routes + (route) => !exportPathMap[route].page.match(API_ROUTE) + ) + + if (filteredPaths.length !== exportPaths.length) { + hasApiRoutes = true + } + + if (prerenderManifest && !options.buildExport) { + const fallbackEnabledPages = new Set() - if (options.buildExport && configuration) { - if (typeof result.fromBuildExportRevalidate !== 'undefined') { - configuration.initialPageRevalidationMap[path] = - result.fromBuildExportRevalidate + for (const key of Object.keys(prerenderManifest.dynamicRoutes)) { + // only error if page is included in path map + if (!exportPathMap[key] && !excludedPrerenderRoutes.has(key)) { + continue } - if (result.ssgNotFound === true) { - configuration.ssgNotFoundPaths.push(path) + if (prerenderManifest.dynamicRoutes[key].fallback !== false) { + fallbackEnabledPages.add(key) } } - if (progress) progress() - }) - ) - - worker.end() - - // copy prerendered routes to outDir - if (!options.buildExport && prerenderManifest) { - await Promise.all( - Object.keys(prerenderManifest.routes).map(async (route) => { - const { srcRoute } = prerenderManifest!.routes[route] - const pageName = srcRoute || route - const pagePath = getPagePath(pageName, distDir, isLikeServerless) - const distPagesDir = join( - pagePath, - // strip leading / and then recurse number of nested dirs - // to place from base folder - pageName - .substr(1) - .split('/') - .map(() => '..') - .join('/') + if (fallbackEnabledPages.size) { + throw new Error( + `Found pages with \`fallback\` enabled:\n${[ + ...fallbackEnabledPages, + ].join('\n')}\n${SSG_FALLBACK_EXPORT_ERROR}\n` ) - route = normalizePagePath(route) + } + } - const orig = join(distPagesDir, route) - const htmlDest = join( - outDir, - `${route}${ - subFolders && route !== '/index' ? `${sep}index` : '' - }.html` - ) - const ampHtmlDest = join( - outDir, - `${route}.amp${subFolders ? `${sep}index` : ''}.html` + // Warn if the user defines a path for an API page + if (hasApiRoutes) { + if (!options.silent) { + Log.warn( + chalk.yellow( + `Statically exporting a Next.js application via \`next export\` disables API routes.` + ) + + `\n` + + chalk.yellow( + `This command is meant for static-only hosts, and is` + + ' ' + + chalk.bold(`not necessary to make your application static.`) + ) + + `\n` + + chalk.yellow( + `Pages in your application without server-side data dependencies will be automatically statically exported by \`next build\`, including pages powered by \`getStaticProps\`.` + ) + + `\n` + + chalk.yellow( + `Learn more: https://err.sh/vercel/next.js/api-routes-static-export` + ) ) - const jsonDest = join(pagesDataDir, `${route}.json`) + } + } - await promises.mkdir(dirname(htmlDest), { recursive: true }) - await promises.mkdir(dirname(jsonDest), { recursive: true }) - await promises.copyFile(`${orig}.html`, htmlDest) - await promises.copyFile(`${orig}.json`, jsonDest) + const progress = + !options.silent && + createProgress( + filteredPaths.length, + `${Log.prefixes.info} ${options.statusMessage || 'Exporting'}` + ) + const pagesDataDir = options.buildExport + ? outDir + : join(outDir, '_next/data', buildId) + + const ampValidations: AmpPageStatus = {} + let hadValidationError = false + + const publicDir = join(dir, CLIENT_PUBLIC_FILES_PATH) + // Copy public directory + if (!options.buildExport && existsSync(publicDir)) { + if (!options.silent) { + Log.info('Copying "public" directory') + } + await traceAsyncFn(tracer.startSpan('copy-public-directory'), () => + recursiveCopy(publicDir, outDir, { + filter(path) { + // Exclude paths used by pages + return !exportPathMap[path] + }, + }) + ) + } - if (await exists(`${orig}.amp.html`)) { - await promises.mkdir(dirname(ampHtmlDest), { recursive: true }) - await promises.copyFile(`${orig}.amp.html`, ampHtmlDest) - } + const worker = new Worker(require.resolve('./worker'), { + maxRetries: 0, + numWorkers: threads, + enableWorkerThreads: true, + exposedMethods: ['default'], + }) as Worker & { default: typeof exportPage } + + worker.getStdout().pipe(process.stdout) + worker.getStderr().pipe(process.stderr) + + let renderError = false + const errorPaths: string[] = [] + + await Promise.all( + filteredPaths.map(async (path) => { + const pageExportSpan = tracer.startSpan('export-page', { + attributes: { path }, + }) + return traceAsyncFn(pageExportSpan, async () => { + const spanContext = {} + + opentelemetryApi.propagation.inject( + opentelemetryApi.context.active(), + spanContext + ) + + const result = await worker.default({ + path, + pathMap: exportPathMap[path], + distDir, + outDir, + pagesDataDir, + renderOpts, + serverRuntimeConfig, + subFolders, + buildExport: options.buildExport, + serverless: isTargetLikeServerless(nextConfig.target), + optimizeFonts: nextConfig.experimental.optimizeFonts, + optimizeImages: nextConfig.experimental.optimizeImages, + optimizeCss: nextConfig.experimental.optimizeCss, + spanContext, + }) + + for (const validation of result.ampValidations || []) { + const { page, result: ampValidationResult } = validation + ampValidations[page] = ampValidationResult + hadValidationError = + hadValidationError || + (Array.isArray(ampValidationResult?.errors) && + ampValidationResult.errors.length > 0) + } + renderError = renderError || !!result.error + if (!!result.error) errorPaths.push(path) + + if (options.buildExport && configuration) { + if (typeof result.fromBuildExportRevalidate !== 'undefined') { + configuration.initialPageRevalidationMap[path] = + result.fromBuildExportRevalidate + } + + if (result.ssgNotFound === true) { + configuration.ssgNotFoundPaths.push(path) + } + } + + if (progress) progress() + }) }) ) - } - if (Object.keys(ampValidations).length) { - console.log(formatAmpMessages(ampValidations)) - } - if (hadValidationError) { - throw new Error( - `AMP Validation caused the export to fail. https://err.sh/vercel/next.js/amp-export-validation` - ) - } + worker.end() + + // copy prerendered routes to outDir + if (!options.buildExport && prerenderManifest) { + await Promise.all( + Object.keys(prerenderManifest.routes).map(async (route) => { + const { srcRoute } = prerenderManifest!.routes[route] + const pageName = srcRoute || route + const pagePath = getPagePath(pageName, distDir, isLikeServerless) + const distPagesDir = join( + pagePath, + // strip leading / and then recurse number of nested dirs + // to place from base folder + pageName + .substr(1) + .split('/') + .map(() => '..') + .join('/') + ) + route = normalizePagePath(route) + + const orig = join(distPagesDir, route) + const htmlDest = join( + outDir, + `${route}${ + subFolders && route !== '/index' ? `${sep}index` : '' + }.html` + ) + const ampHtmlDest = join( + outDir, + `${route}.amp${subFolders ? `${sep}index` : ''}.html` + ) + const jsonDest = join(pagesDataDir, `${route}.json`) + + await promises.mkdir(dirname(htmlDest), { recursive: true }) + await promises.mkdir(dirname(jsonDest), { recursive: true }) + await promises.copyFile(`${orig}.html`, htmlDest) + await promises.copyFile(`${orig}.json`, jsonDest) + + if (await exists(`${orig}.amp.html`)) { + await promises.mkdir(dirname(ampHtmlDest), { recursive: true }) + await promises.copyFile(`${orig}.amp.html`, ampHtmlDest) + } + }) + ) + } + + if (Object.keys(ampValidations).length) { + console.log(formatAmpMessages(ampValidations)) + } + if (hadValidationError) { + throw new Error( + `AMP Validation caused the export to fail. https://err.sh/vercel/next.js/amp-export-validation` + ) + } + + if (renderError) { + throw new Error( + `Export encountered errors on following paths:\n\t${errorPaths + .sort() + .join('\n\t')}` + ) + } - if (renderError) { - throw new Error( - `Export encountered errors on following paths:\n\t${errorPaths - .sort() - .join('\n\t')}` + writeFileSync( + join(distDir, EXPORT_DETAIL), + JSON.stringify({ + version: 1, + outDirectory: outDir, + success: true, + }), + 'utf8' ) - } - writeFileSync( - join(distDir, EXPORT_DETAIL), - JSON.stringify({ - version: 1, - outDirectory: outDir, - success: true, - }), - 'utf8' - ) - - if (telemetry) { - await telemetry.flush() - } + if (telemetry) { + await telemetry.flush() + } + }) } diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index 7704512c463540a..c50547bc142ba1f 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -16,6 +16,8 @@ import { GetStaticProps } from '../types' import { requireFontManifest } from '../next-server/server/require' import { FontManifest } from '../next-server/server/font-utils' import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' +import { tracer, traceAsyncFn } from '../build/tracer' +import opentelemetryApi from '@opentelemetry/api' const envConfig = require('../next-server/lib/runtime-config') @@ -50,6 +52,7 @@ interface ExportPageInput { optimizeFonts: boolean optimizeImages: boolean optimizeCss: any + spanContext: any } interface ExportPageResults { @@ -82,6 +85,7 @@ type ComponentModule = ComponentType<{}> & { } export default async function exportPage({ + spanContext, path, pathMap, distDir, @@ -96,344 +100,362 @@ export default async function exportPage({ optimizeImages, optimizeCss, }: ExportPageInput): Promise { - let results: ExportPageResults = { - ampValidations: [], - } - - try { - const { query: originalQuery = {} } = pathMap - const { page } = pathMap - const filePath = normalizePagePath(path) - const isDynamic = isDynamicRoute(page) - const ampPath = `${filePath}.amp` - let renderAmpPath = ampPath - let query = { ...originalQuery } - let params: { [key: string]: string | string[] } | undefined - - let updatedPath = (query.__nextSsgPath as string) || path - let locale = query.__nextLocale || renderOpts.locale - delete query.__nextLocale - delete query.__nextSsgPath - - if (renderOpts.locale) { - const localePathResult = normalizeLocalePath(path, renderOpts.locales) - - if (localePathResult.detectedLocale) { - updatedPath = localePathResult.pathname - locale = localePathResult.detectedLocale - - if (locale === renderOpts.defaultLocale) { - renderAmpPath = `${normalizePagePath(updatedPath)}.amp` + return opentelemetryApi.context.with( + opentelemetryApi.propagation.extract( + opentelemetryApi.context.active(), + spanContext + ), + () => { + return traceAsyncFn(tracer.startSpan('export-page-worker'), async () => { + let results: ExportPageResults = { + ampValidations: [], } - } - } - // We need to show a warning if they try to provide query values - // for an auto-exported page since they won't be available - const hasOrigQueryValues = Object.keys(originalQuery).length > 0 - const queryWithAutoExportWarn = () => { - if (hasOrigQueryValues) { - throw new Error( - `\nError: you provided query values for ${path} which is an auto-exported page. These can not be applied since the page can no longer be re-rendered on the server. To disable auto-export for this page add \`getInitialProps\`\n` - ) - } - } + try { + const { query: originalQuery = {} } = pathMap + const { page } = pathMap + const filePath = normalizePagePath(path) + const isDynamic = isDynamicRoute(page) + const ampPath = `${filePath}.amp` + let renderAmpPath = ampPath + let query = { ...originalQuery } + let params: { [key: string]: string | string[] } | undefined + + let updatedPath = (query.__nextSsgPath as string) || path + let locale = query.__nextLocale || renderOpts.locale + delete query.__nextLocale + delete query.__nextSsgPath + + if (renderOpts.locale) { + const localePathResult = normalizeLocalePath( + path, + renderOpts.locales + ) + + if (localePathResult.detectedLocale) { + updatedPath = localePathResult.pathname + locale = localePathResult.detectedLocale - // Check if the page is a specified dynamic route - if (isDynamic && page !== path) { - params = getRouteMatcher(getRouteRegex(page))(updatedPath) || undefined - if (params) { - // we have to pass these separately for serverless - if (!serverless) { - query = { - ...query, - ...params, + if (locale === renderOpts.defaultLocale) { + renderAmpPath = `${normalizePagePath(updatedPath)}.amp` + } + } } - } - } else { - throw new Error( - `The provided export path '${updatedPath}' doesn't match the '${page}' page.\nRead more: https://err.sh/vercel/next.js/export-path-mismatch` - ) - } - } - const headerMocks = { - headers: {}, - getHeader: () => ({}), - setHeader: () => {}, - hasHeader: () => false, - removeHeader: () => {}, - getHeaderNames: () => [], - } + // We need to show a warning if they try to provide query values + // for an auto-exported page since they won't be available + const hasOrigQueryValues = Object.keys(originalQuery).length > 0 + const queryWithAutoExportWarn = () => { + if (hasOrigQueryValues) { + throw new Error( + `\nError: you provided query values for ${path} which is an auto-exported page. These can not be applied since the page can no longer be re-rendered on the server. To disable auto-export for this page add \`getInitialProps\`\n` + ) + } + } - const req = ({ - url: updatedPath, - ...headerMocks, - } as unknown) as IncomingMessage - const res = ({ - ...headerMocks, - } as unknown) as ServerResponse - - envConfig.setConfig({ - serverRuntimeConfig, - publicRuntimeConfig: renderOpts.runtimeConfig, - }) - - let htmlFilename = `${filePath}${sep}index.html` - if (!subFolders) htmlFilename = `${filePath}.html` - - const pageExt = extname(page) - const pathExt = extname(path) - // Make sure page isn't a folder with a dot in the name e.g. `v1.2` - if (pageExt !== pathExt && pathExt !== '') { - // If the path has an extension, use that as the filename instead - htmlFilename = path - } else if (path === '/') { - // If the path is the root, just use index.html - htmlFilename = 'index.html' - } + // Check if the page is a specified dynamic route + if (isDynamic && page !== path) { + params = + getRouteMatcher(getRouteRegex(page))(updatedPath) || undefined + if (params) { + // we have to pass these separately for serverless + if (!serverless) { + query = { + ...query, + ...params, + } + } + } else { + throw new Error( + `The provided export path '${updatedPath}' doesn't match the '${page}' page.\nRead more: https://err.sh/vercel/next.js/export-path-mismatch` + ) + } + } - const baseDir = join(outDir, dirname(htmlFilename)) - let htmlFilepath = join(outDir, htmlFilename) + const headerMocks = { + headers: {}, + getHeader: () => ({}), + setHeader: () => {}, + hasHeader: () => false, + removeHeader: () => {}, + getHeaderNames: () => [], + } - await promises.mkdir(baseDir, { recursive: true }) - let html - let curRenderOpts: RenderOpts = {} - let renderMethod = renderToHTML + const req = ({ + url: updatedPath, + ...headerMocks, + } as unknown) as IncomingMessage + const res = ({ + ...headerMocks, + } as unknown) as ServerResponse + + envConfig.setConfig({ + serverRuntimeConfig, + publicRuntimeConfig: renderOpts.runtimeConfig, + }) + + let htmlFilename = `${filePath}${sep}index.html` + if (!subFolders) htmlFilename = `${filePath}.html` + + const pageExt = extname(page) + const pathExt = extname(path) + // Make sure page isn't a folder with a dot in the name e.g. `v1.2` + if (pageExt !== pathExt && pathExt !== '') { + // If the path has an extension, use that as the filename instead + htmlFilename = path + } else if (path === '/') { + // If the path is the root, just use index.html + htmlFilename = 'index.html' + } - const renderedDuringBuild = (getStaticProps: any) => { - return !buildExport && getStaticProps && !isDynamicRoute(path) - } + const baseDir = join(outDir, dirname(htmlFilename)) + let htmlFilepath = join(outDir, htmlFilename) - if (serverless) { - const curUrl = url.parse(req.url!, true) - req.url = url.format({ - ...curUrl, - query: { - ...curUrl.query, - ...query, - }, - }) - const { Component: mod, getServerSideProps } = await loadComponents( - distDir, - page, - serverless - ) - - if (getServerSideProps) { - throw new Error(`Error for page ${page}: ${SERVER_PROPS_EXPORT_ERROR}`) - } - - // if it was auto-exported the HTML is loaded here - if (typeof mod === 'string') { - html = mod - queryWithAutoExportWarn() - } else { - // for non-dynamic SSG pages we should have already - // prerendered the file - if (renderedDuringBuild((mod as ComponentModule).getStaticProps)) - return results + await promises.mkdir(baseDir, { recursive: true }) + let html + let curRenderOpts: RenderOpts = {} + let renderMethod = renderToHTML - if ( - (mod as ComponentModule).getStaticProps && - !htmlFilepath.endsWith('.html') - ) { - // make sure it ends with .html if the name contains a dot - htmlFilename += '.html' - htmlFilepath += '.html' - } + const renderedDuringBuild = (getStaticProps: any) => { + return !buildExport && getStaticProps && !isDynamicRoute(path) + } - renderMethod = (mod as ComponentModule).renderReqToHTML - const result = await renderMethod( - req, - res, - 'export', - { - ampPath: renderAmpPath, - /// @ts-ignore - optimizeFonts, - /// @ts-ignore - optimizeImages, - /// @ts-ignore - optimizeCss, - fontManifest: optimizeFonts - ? requireFontManifest(distDir, serverless) - : null, - locale: locale!, - locales: renderOpts.locales!, - }, - // @ts-ignore - params - ) - curRenderOpts = (result as any).renderOpts || {} - html = (result as any).html - } - - if (!html && !(curRenderOpts as any).isNotFound) { - throw new Error(`Failed to render serverless page`) - } - } else { - const components = await loadComponents(distDir, page, serverless) - - if (components.getServerSideProps) { - throw new Error(`Error for page ${page}: ${SERVER_PROPS_EXPORT_ERROR}`) - } - - // for non-dynamic SSG pages we should have already - // prerendered the file - if (renderedDuringBuild(components.getStaticProps)) { - return results - } - - // TODO: de-dupe the logic here between serverless and server mode - if (components.getStaticProps && !htmlFilepath.endsWith('.html')) { - // make sure it ends with .html if the name contains a dot - htmlFilepath += '.html' - htmlFilename += '.html' - } - - if (typeof components.Component === 'string') { - html = components.Component - queryWithAutoExportWarn() - } else { - /** - * This sets environment variable to be used at the time of static export by head.tsx. - * Using this from process.env allows targeting both serverless and SSR by calling - * `process.env.__NEXT_OPTIMIZE_FONTS`. - * TODO(prateekbh@): Remove this when experimental.optimizeFonts are being cleaned up. - */ - if (optimizeFonts) { - process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true) - } - if (optimizeImages) { - process.env.__NEXT_OPTIMIZE_IMAGES = JSON.stringify(true) - } - if (optimizeCss) { - process.env.__NEXT_OPTIMIZE_CSS = JSON.stringify(true) - } - curRenderOpts = { - ...components, - ...renderOpts, - ampPath: renderAmpPath, - params, - optimizeFonts, - optimizeImages, - optimizeCss, - fontManifest: optimizeFonts - ? requireFontManifest(distDir, serverless) - : null, - locale: locale as string, - } - // @ts-ignore - html = await renderMethod(req, res, page, query, curRenderOpts) - } - } - results.ssgNotFound = (curRenderOpts as any).isNotFound - - const validateAmp = async ( - rawAmpHtml: string, - ampPageName: string, - validatorPath?: string - ) => { - const validator = await AmpHtmlValidator.getInstance(validatorPath) - const result = validator.validateString(rawAmpHtml) - const errors = result.errors.filter((e) => e.severity === 'ERROR') - const warnings = result.errors.filter((e) => e.severity !== 'ERROR') - - if (warnings.length || errors.length) { - results.ampValidations.push({ - page: ampPageName, - result: { - errors, - warnings, - }, - }) - } - } + if (serverless) { + const curUrl = url.parse(req.url!, true) + req.url = url.format({ + ...curUrl, + query: { + ...curUrl.query, + ...query, + }, + }) + const { Component: mod, getServerSideProps } = await loadComponents( + distDir, + page, + serverless + ) - if (curRenderOpts.inAmpMode && !curRenderOpts.ampSkipValidation) { - if (!results.ssgNotFound) { - await validateAmp(html, path, curRenderOpts.ampValidatorPath) - } - } else if (curRenderOpts.hybridAmp) { - // we need to render the AMP version - let ampHtmlFilename = `${ampPath}${sep}index.html` - if (!subFolders) { - ampHtmlFilename = `${ampPath}.html` - } - const ampBaseDir = join(outDir, dirname(ampHtmlFilename)) - const ampHtmlFilepath = join(outDir, ampHtmlFilename) - - try { - await promises.access(ampHtmlFilepath) - } catch (_) { - // make sure it doesn't exist from manual mapping - let ampHtml - if (serverless) { - req.url += (req.url!.includes('?') ? '&' : '?') + 'amp=1' - // @ts-ignore - ampHtml = ( - await (renderMethod as any)( - req, - res, - 'export', - curRenderOpts, - params + if (getServerSideProps) { + throw new Error( + `Error for page ${page}: ${SERVER_PROPS_EXPORT_ERROR}` + ) + } + + // if it was auto-exported the HTML is loaded here + if (typeof mod === 'string') { + html = mod + queryWithAutoExportWarn() + } else { + // for non-dynamic SSG pages we should have already + // prerendered the file + if (renderedDuringBuild((mod as ComponentModule).getStaticProps)) + return results + + if ( + (mod as ComponentModule).getStaticProps && + !htmlFilepath.endsWith('.html') + ) { + // make sure it ends with .html if the name contains a dot + htmlFilename += '.html' + htmlFilepath += '.html' + } + + renderMethod = (mod as ComponentModule).renderReqToHTML + const result = await renderMethod( + req, + res, + 'export', + { + ampPath: renderAmpPath, + /// @ts-ignore + optimizeFonts, + /// @ts-ignore + optimizeImages, + /// @ts-ignore + optimizeCss, + fontManifest: optimizeFonts + ? requireFontManifest(distDir, serverless) + : null, + locale: locale!, + locales: renderOpts.locales!, + }, + // @ts-ignore + params + ) + curRenderOpts = (result as any).renderOpts || {} + html = (result as any).html + } + + if (!html && !(curRenderOpts as any).isNotFound) { + throw new Error(`Failed to render serverless page`) + } + } else { + const components = await loadComponents(distDir, page, serverless) + + if (components.getServerSideProps) { + throw new Error( + `Error for page ${page}: ${SERVER_PROPS_EXPORT_ERROR}` + ) + } + + // for non-dynamic SSG pages we should have already + // prerendered the file + if (renderedDuringBuild(components.getStaticProps)) { + return results + } + + // TODO: de-dupe the logic here between serverless and server mode + if (components.getStaticProps && !htmlFilepath.endsWith('.html')) { + // make sure it ends with .html if the name contains a dot + htmlFilepath += '.html' + htmlFilename += '.html' + } + + if (typeof components.Component === 'string') { + html = components.Component + queryWithAutoExportWarn() + } else { + /** + * This sets environment variable to be used at the time of static export by head.tsx. + * Using this from process.env allows targeting both serverless and SSR by calling + * `process.env.__NEXT_OPTIMIZE_FONTS`. + * TODO(prateekbh@): Remove this when experimental.optimizeFonts are being cleaned up. + */ + if (optimizeFonts) { + process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true) + } + if (optimizeImages) { + process.env.__NEXT_OPTIMIZE_IMAGES = JSON.stringify(true) + } + if (optimizeCss) { + process.env.__NEXT_OPTIMIZE_CSS = JSON.stringify(true) + } + curRenderOpts = { + ...components, + ...renderOpts, + ampPath: renderAmpPath, + params, + optimizeFonts, + optimizeImages, + optimizeCss, + fontManifest: optimizeFonts + ? requireFontManifest(distDir, serverless) + : null, + locale: locale as string, + } + // @ts-ignore + html = await renderMethod(req, res, page, query, curRenderOpts) + } + } + results.ssgNotFound = (curRenderOpts as any).isNotFound + + const validateAmp = async ( + rawAmpHtml: string, + ampPageName: string, + validatorPath?: string + ) => { + const validator = await AmpHtmlValidator.getInstance(validatorPath) + const result = validator.validateString(rawAmpHtml) + const errors = result.errors.filter((e) => e.severity === 'ERROR') + const warnings = result.errors.filter((e) => e.severity !== 'ERROR') + + if (warnings.length || errors.length) { + results.ampValidations.push({ + page: ampPageName, + result: { + errors, + warnings, + }, + }) + } + } + + if (curRenderOpts.inAmpMode && !curRenderOpts.ampSkipValidation) { + if (!results.ssgNotFound) { + await validateAmp(html, path, curRenderOpts.ampValidatorPath) + } + } else if (curRenderOpts.hybridAmp) { + // we need to render the AMP version + let ampHtmlFilename = `${ampPath}${sep}index.html` + if (!subFolders) { + ampHtmlFilename = `${ampPath}.html` + } + const ampBaseDir = join(outDir, dirname(ampHtmlFilename)) + const ampHtmlFilepath = join(outDir, ampHtmlFilename) + + try { + await promises.access(ampHtmlFilepath) + } catch (_) { + // make sure it doesn't exist from manual mapping + let ampHtml + if (serverless) { + req.url += (req.url!.includes('?') ? '&' : '?') + 'amp=1' + // @ts-ignore + ampHtml = ( + await (renderMethod as any)( + req, + res, + 'export', + curRenderOpts, + params + ) + ).html + } else { + ampHtml = await renderMethod( + req, + res, + page, + // @ts-ignore + { ...query, amp: '1' }, + curRenderOpts as any + ) + } + + if (!curRenderOpts.ampSkipValidation) { + await validateAmp(ampHtml, page + '?amp=1') + } + await promises.mkdir(ampBaseDir, { recursive: true }) + await promises.writeFile(ampHtmlFilepath, ampHtml, 'utf8') + } + } + + if ((curRenderOpts as any).pageData) { + const dataFile = join( + pagesDataDir, + htmlFilename.replace(/\.html$/, '.json') ) - ).html - } else { - ampHtml = await renderMethod( - req, - res, - page, - // @ts-ignore - { ...query, amp: '1' }, - curRenderOpts as any - ) - } - if (!curRenderOpts.ampSkipValidation) { - await validateAmp(ampHtml, page + '?amp=1') - } - await promises.mkdir(ampBaseDir, { recursive: true }) - await promises.writeFile(ampHtmlFilepath, ampHtml, 'utf8') - } - } + await promises.mkdir(dirname(dataFile), { recursive: true }) + await promises.writeFile( + dataFile, + JSON.stringify((curRenderOpts as any).pageData), + 'utf8' + ) - if ((curRenderOpts as any).pageData) { - const dataFile = join( - pagesDataDir, - htmlFilename.replace(/\.html$/, '.json') - ) - - await promises.mkdir(dirname(dataFile), { recursive: true }) - await promises.writeFile( - dataFile, - JSON.stringify((curRenderOpts as any).pageData), - 'utf8' - ) - - if (curRenderOpts.hybridAmp) { - await promises.writeFile( - dataFile.replace(/\.json$/, '.amp.json'), - JSON.stringify((curRenderOpts as any).pageData), - 'utf8' - ) - } - } - results.fromBuildExportRevalidate = (curRenderOpts as any).revalidate + if (curRenderOpts.hybridAmp) { + await promises.writeFile( + dataFile.replace(/\.json$/, '.amp.json'), + JSON.stringify((curRenderOpts as any).pageData), + 'utf8' + ) + } + } + results.fromBuildExportRevalidate = (curRenderOpts as any).revalidate - if (results.ssgNotFound) { - // don't attempt writing to disk if getStaticProps returned not found - return results + if (results.ssgNotFound) { + // don't attempt writing to disk if getStaticProps returned not found + return results + } + await promises.writeFile(htmlFilepath, html, 'utf8') + return results + } catch (error) { + console.error( + `\nError occurred prerendering page "${path}". Read more: https://err.sh/next.js/prerender-error\n` + + error.stack + ) + return { ...results, error: true } + } + }) } - await promises.writeFile(htmlFilepath, html, 'utf8') - return results - } catch (error) { - console.error( - `\nError occurred prerendering page "${path}". Read more: https://err.sh/next.js/prerender-error\n` + - error.stack - ) - return { ...results, error: true } - } + ) }