next.js/packages/next/src/build/analyze/index.ts
index.ts235 lines6.5 KB
import type { NextConfigComplete } from '../../server/config-shared'
import type { __ApiPreviewProps } from '../../server/api-utils'

import { setGlobal } from '../../trace'
import * as Log from '../output/log'
import * as path from 'node:path'
import loadConfig from '../../server/config'
import { PHASE_ANALYZE } from '../../shared/lib/constants'
import { turbopackAnalyze, type AnalyzeContext } from '../turbopack-analyze'
import { durationToString } from '../duration-to-string'
import { cp, writeFile, mkdir } from 'node:fs/promises'
import { discoverRoutes } from '../route-discovery'
import { findPagesDir } from '../../lib/find-pages-dir'
import loadCustomRoutes from '../../lib/load-custom-routes'
import { generateRoutesManifest } from '../generate-routes-manifest'
import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr'
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import http from 'node:http'

// @ts-expect-error types are in @types/serve-handler
import serveHandler from 'next/dist/compiled/serve-handler'
import { Telemetry } from '../../telemetry/storage'
import { eventAnalyzeCompleted } from '../../telemetry/events'
import { traceGlobals } from '../../trace/shared'
import type { RoutesManifest } from '..'
import { Bundler } from '../../lib/bundler'

export type AnalyzeOptions = {
  dir: string
  reactProductionProfiling?: boolean
  noMangling?: boolean
  appDirOnly?: boolean
  output?: boolean
  port?: number
}

export default async function analyze({
  dir,
  reactProductionProfiling = false,
  noMangling = false,
  appDirOnly = false,
  output = false,
  port = 4000,
}: AnalyzeOptions): Promise<void> {
  try {
    const config: NextConfigComplete = await loadConfig(PHASE_ANALYZE, dir, {
      silent: false,
      reactProductionProfiling,
      bundler: Bundler.Turbopack,
    })

    process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''

    const distDir = path.join(dir, '.next')
    const telemetry = new Telemetry({ distDir })
    setGlobal('phase', PHASE_ANALYZE)
    setGlobal('distDir', distDir)
    setGlobal('telemetry', telemetry)

    Log.info('Analyzing a production build...')

    const analyzeContext: AnalyzeContext = {
      config,
      dir,
      distDir,
      noMangling,
      appDirOnly,
    }

    const { duration: analyzeDuration, shutdownPromise } =
      await turbopackAnalyze(analyzeContext)

    const durationString = durationToString(analyzeDuration)
    const analyzeDir = path.join(distDir, 'diagnostics/analyze')

    await shutdownPromise

    const routes = await collectRoutesForAnalyze(dir, config, appDirOnly)

    await cp(path.join(__dirname, '../../bundle-analyzer'), analyzeDir, {
      recursive: true,
    })
    await mkdir(path.join(analyzeDir, 'data'), { recursive: true })
    await writeFile(
      path.join(analyzeDir, 'data', 'routes.json'),
      JSON.stringify(routes, null, 2)
    )

    let logMessage = `Analyze completed in ${durationString}.`
    if (output) {
      logMessage += ` Results written to ${analyzeDir}.\nTo explore the analyze results interactively, run \`next experimental-analyze\` without \`--output\`.`
    }
    Log.event(logMessage)

    telemetry.record(
      eventAnalyzeCompleted({
        success: true,
        durationInSeconds: Math.round(analyzeDuration),
        totalPageCount: routes.length,
      })
    )

    if (!output) {
      await startServer(analyzeDir, port)
    }
  } catch (e) {
    const telemetry = traceGlobals.get('telemetry') as Telemetry | undefined
    if (telemetry) {
      telemetry.record(
        eventAnalyzeCompleted({
          success: false,
        })
      )
    }

    throw e
  }
}

/**
 * Collects all routes from the project for the bundle analyzer.
 * Returns a list of route paths (both static and dynamic).
 */
async function collectRoutesForAnalyze(
  dir: string,
  config: NextConfigComplete,
  appDirOnly: boolean
): Promise<string[]> {
  const { pagesDir, appDir } = findPagesDir(dir)

  let appType: RoutesManifest['appType']
  if (pagesDir && appDir) {
    appType = 'hybrid'
  } else if (pagesDir) {
    appType = 'pages'
  } else if (appDir) {
    appType = 'app'
  } else {
    throw new Error('No pages or app directory found.')
  }

  const discovery = await discoverRoutes({
    appDir,
    pagesDir,
    pageExtensions: config.pageExtensions,
    isDev: false,
    baseDir: dir,
    isSrcDir: path.relative(dir, pagesDir || appDir || '').startsWith('src'),
    appDirOnly,
  })

  const pageKeys = {
    pages: Object.keys(discovery.mappedPages || {}),
    app: discovery.mappedAppPages
      ? Object.keys(discovery.mappedAppPages).map((key) =>
          normalizeAppPath(key)
        )
      : [],
  }

  // Load custom routes
  const { redirects, headers, onMatchHeaders, rewrites } =
    await loadCustomRoutes(config)

  // Compute restricted redirect paths
  const restrictedRedirectPaths = ['/_next'].map((pathPrefix) =>
    config.basePath ? `${config.basePath}${pathPrefix}` : pathPrefix
  )

  const isAppPPREnabled = checkIsAppPPREnabled(config.experimental.ppr)

  // Generate routes manifest
  const { routesManifest } = generateRoutesManifest({
    appType,
    pageKeys,
    config,
    redirects,
    headers,
    onMatchHeaders,
    rewrites,
    restrictedRedirectPaths,
    isAppPPREnabled,
  })

  return routesManifest.dynamicRoutes
    .map((r) => r.page)
    .concat(routesManifest.staticRoutes.map((r) => r.page))
}

function startServer(dir: string, port: number): Promise<void> {
  const server = http.createServer((req, res) => {
    return serveHandler(req, res, {
      public: dir,
    })
  })

  return new Promise((resolve, reject) => {
    function onError(err: Error) {
      server.close(() => {
        reject(err)
      })
    }

    server.on('error', onError)

    server.listen(port, 'localhost', () => {
      const address = server.address()
      if (address == null) {
        reject(new Error('Unable to get server address'))
        return
      }

      // No longer needed after startup
      server.removeListener('error', onError)

      let addressString
      if (typeof address === 'string') {
        addressString = address
      } else if (
        address.family === 'IPv6' &&
        (address.address === '::' || address.address === '::1')
      ) {
        addressString = `localhost:${address.port}`
      } else if (address.family === 'IPv6') {
        addressString = `[${address.address}]:${address.port}`
      } else {
        addressString = `${address.address}:${address.port}`
      }

      Log.info(`Bundle analyzer available at http://${addressString}`)
      resolve()
    })
  })
}
Quest for Codev2.0.0
/
SIGN IN