next.js/packages/next/src/build/route-bundle-stats.ts
route-bundle-stats.ts222 lines6.3 KB
import path from 'path'
import { promises as fs, statSync } from 'fs'
import {
  APP_PATHS_MANIFEST,
  CLIENT_REFERENCE_MANIFEST,
  SERVER_DIRECTORY,
} from '../shared/lib/constants'
import type { BuildManifest } from '../server/get-page-files'
import { filterAndSortList } from './utils'

const ROUTE_BUNDLE_STATS_FILE = 'route-bundle-stats.json'

type RouteBundleStat = {
  route: string
  firstLoadUncompressedJsBytes: number
  firstLoadChunkPaths: string[]
}

function sumFileSizes(
  distDir: string,
  files: string[],
  cache: Map<string, number>
): number {
  let total = 0
  for (const relPath of files) {
    const cached = cache.get(relPath)
    if (cached !== undefined) {
      total += cached
      continue
    }
    try {
      const size = statSync(path.join(distDir, relPath)).size
      cache.set(relPath, size)
      total += size
    } catch {
      // ignore missing files
    }
  }
  return total
}

function toProjectRelativePaths(
  dir: string,
  distDir: string,
  relPaths: string[]
): string[] {
  return relPaths.map((f) => path.relative(dir, path.join(distDir, f)))
}

function buildRouteToAppPathsMap(
  appPathsManifest: Record<string, string>
): Map<string, string[]> {
  const { normalizeAppPath } =
    require('../shared/lib/router/utils/app-paths') as typeof import('../shared/lib/router/utils/app-paths')
  // Keys in appPathsManifest are app paths like /blog/[slug]/page;
  // values are server bundle file paths. Normalize the key to get the route.
  const routeToAppPaths = new Map<string, string[]>()
  for (const appPath of Object.keys(appPathsManifest)) {
    const route = normalizeAppPath(appPath)
    const existing = routeToAppPaths.get(route)
    if (existing) {
      existing.push(appPath)
    } else {
      routeToAppPaths.set(route, [appPath])
    }
  }
  return routeToAppPaths
}

// Reads the manfiest file and gets the entry JS files. The manifest file is
// a JavaScript file that sets a global variable (__RSC_MANIFEST). We require()
// it with a save/restore of the global
function readEntryJSFiles(
  distDir: string,
  pagePath: string,
  appRoute: string
): Record<string, string[]> | undefined {
  const manifestFile = path.join(
    distDir,
    SERVER_DIRECTORY,
    'app',
    `${pagePath}_${CLIENT_REFERENCE_MANIFEST}.js`
  )
  try {
    const g = global as Record<string, unknown>
    const prev = g.__RSC_MANIFEST
    g.__RSC_MANIFEST = undefined
    require(manifestFile)
    const rscManifest = g.__RSC_MANIFEST as
      | Record<string, { entryJSFiles?: Record<string, string[]> }>
      | undefined
    g.__RSC_MANIFEST = prev

    // The key in __RSC_MANIFEST is the app path (e.g. /blog/[slug]/page)
    const manifestEntry = rscManifest?.[pagePath] ?? rscManifest?.[appRoute]
    return manifestEntry?.entryJSFiles
  } catch {
    return undefined
  }
}

function collectPagesRouterStats(
  pages: ReadonlyArray<string>,
  buildManifest: BuildManifest,
  distDir: string,
  dir: string,
  cache: Map<string, number>
): RouteBundleStat[] {
  const rows: RouteBundleStat[] = []
  const sharedFiles = buildManifest.pages['/_app'] ?? []
  for (const page of filterAndSortList(pages, 'pages', false)) {
    if (page === '/_app' || page === '/_document' || page === '/_error')
      // Don't report on layouts directly
      continue

    const allFiles = (buildManifest.pages[page] ?? []).filter((f) =>
      f.endsWith('.js')
    )
    const sharedJs = sharedFiles.filter((f) => f.endsWith('.js'))
    const chunks = [...new Set([...allFiles, ...sharedJs])]
    const firstLoadUncompressedJsBytes = sumFileSizes(distDir, chunks, cache)
    rows.push({
      route: page,
      firstLoadUncompressedJsBytes,
      firstLoadChunkPaths: toProjectRelativePaths(dir, distDir, chunks),
    })
  }
  return rows
}

async function collectAppRouterStats(
  appRoutes: ReadonlyArray<string>,
  buildManifest: BuildManifest,
  distDir: string,
  dir: string,
  cache: Map<string, number>
): Promise<RouteBundleStat[]> {
  let appPathsManifest: Record<string, string> = {}
  try {
    const manifestPath = path.join(
      distDir,
      SERVER_DIRECTORY,
      APP_PATHS_MANIFEST
    )
    appPathsManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'))
  } catch {
    // App paths manifest not available; skip app router sizes
    return []
  }

  const routeToAppPaths = buildRouteToAppPathsMap(appPathsManifest)
  const sharedFiles = buildManifest.rootMainFiles ?? []
  const rows: RouteBundleStat[] = []

  for (const appRoute of filterAndSortList(appRoutes, 'app', false)) {
    const appPaths = routeToAppPaths.get(appRoute) ?? []
    // Find the /page entry (most specific, has the most chunks)
    const pagePath = appPaths.find((p) => p.endsWith('/page'))
    if (!pagePath) continue

    const entryJSFiles = readEntryJSFiles(distDir, pagePath, appRoute)
    if (!entryJSFiles) continue

    // Union JS files across all segments (page, layout, etc.) so that
    // layout code's contribution is included in the First Load JS total.
    const allFiles = [
      ...new Set(
        Object.values(entryJSFiles)
          .flat()
          .filter((f) => f.endsWith('.js'))
      ),
    ]
    const sharedJs = sharedFiles.filter((f) => f.endsWith('.js'))
    const chunks = [...new Set([...allFiles, ...sharedJs])]
    const firstLoadUncompressedJsBytes = sumFileSizes(distDir, chunks, cache)
    rows.push({
      route: appRoute,
      firstLoadUncompressedJsBytes,
      firstLoadChunkPaths: toProjectRelativePaths(dir, distDir, chunks),
    })
  }
  return rows
}

export async function writeRouteBundleStats(
  lists: {
    pages: ReadonlyArray<string>
    app: ReadonlyArray<string> | undefined
  },
  buildManifest: BuildManifest,
  distDir: string,
  dir: string
): Promise<void> {
  const cache = new Map<string, number>()

  const rows = [
    ...(lists.pages.length > 0
      ? collectPagesRouterStats(lists.pages, buildManifest, distDir, dir, cache)
      : []),
    ...(lists.app && lists.app.length > 0
      ? await collectAppRouterStats(
          lists.app,
          buildManifest,
          distDir,
          dir,
          cache
        )
      : []),
  ]

  rows.sort(
    (a, b) => b.firstLoadUncompressedJsBytes - a.firstLoadUncompressedJsBytes
  )

  const diagnosticsDir = path.join(distDir, 'diagnostics')
  await fs.mkdir(diagnosticsDir, { recursive: true })
  await fs.writeFile(
    path.join(diagnosticsDir, ROUTE_BUNDLE_STATS_FILE),
    JSON.stringify(rows, null, 2)
  )
}
Quest for Codev2.0.0
/
SIGN IN