Source: index.js

#!/usr/bin/env node
import { resolve } from "node:path";
import {
  readdir,
  stat,
  readFile,
  appendFile,
  writeFile,
} from "node:fs/promises";
import { promisify } from "node:util";
import { exec as execSync } from "node:child_process";
import {
  gzip as gzipSync,
  brotliCompress as brotliCompressSync,
} from "node:zlib";

const exec = promisify(execSync);
const gzip = promisify(gzipSync);
const brotliCompress = promisify(brotliCompressSync);

/**
 * Format bytes to a human readable size.
 *
 * @since v2.1.0
 * @param {number} bytes - bytes to format
 * @param {number} [decimals] - decimal precision for rounding
 * @param {boolean} [binary] - binary or decimal unit conversion
 * @returns {string} human readable file size with units
 */
function formatBytes(bytes, decimals = 2, binary = false) {
  try {
    if (!bytes) {
      return "0 B";
    }

    const k = binary ? 1024 : 1000;
    const n = Math.floor(
      binary ? Math.log10(bytes) / 3 : Math.log2(bytes) / 10,
    );

    // I prefer human readable sizes, don't like it? byte me!
    const value = (bytes / Math.pow(k, n)).toFixed(decimals);
    const unit = `${"KMGTPEZY"[n - 1] || ""}B`;
    return `${value} ${unit}`;
  } catch (err) {
    help(
      err,
      "\n\nOccurred while formatting bytes. Double check the inputs:",
      "\n    bytes:",
      bytes,
      "\n    decimals:",
      decimals,
      "\n    binary:",
      binary,
    );
  }
}

/**
 * Find sizes of all files in a directory (recursive).
 *
 * @since v2.1.0
 * @param {string} parentDir - path to the directory containing the files
 * @returns {Promise<File[]>} all files in the directory and subdirectories
 */
async function getFiles(parentDir) {
  try {
    const files = [];
    const entries = await readdir(parentDir, {
      withFileTypes: true,
      recursive: true,
    });

    for (const dirent of entries) {
      if (dirent.isFile()) {
        const itemPath = resolve(dirent.path, dirent.name);
        const { size } = await stat(itemPath);
        files.push({ name: dirent.name, path: itemPath, size });
      }
    }

    return Promise.all(files);
  } catch (err) {
    if (err.code === "ENOENT") {
      help("Error: Could not find build at specified path:\n   ", parentDir);
    } else {
      help(
        err,
        "\n\nOccurred while finding build files.",
        "Double check the file path:\n   ",
        parentDir,
      );
    }
  }
}

/**
 * Filter files by filetype. Use {@link getFiles} to retrieve the build files.
 *
 * @since v2.2.0
 * @param {File[]} files - files to filter
 * @param {string} type - file type, e.g. "js", "tsx", etc.
 * @returns {File[]} files filtered by file type
 */
const filterFilesByType = (files, type) =>
  files.filter((file) => new RegExp(`.${type}$`, "i").test(file.name));

/**
 * Compress a file using gzip and return the size.
 *
 * @since v3.0.0
 * @param {string} filePath - path of the file to compress
 * @returns {Promise<number>} gzip-compressed byte size using the default
 *   [zlib]{@link https://nodejs.org/api/zlib.html#brotli-constants} options
 */
const getFileSizeGzip = (filePath) =>
  readFile(filePath)
    .then(gzip)
    .then((output) => output.length)
    .catch((err) =>
      help(
        err,
        "\n\nOccurred while getting gzipped file size.",
        "Double check the file path:\n   ",
        filePath,
      ),
    );

/**
 * Compress a file using brotli and return the size.
 *
 * @since v3.0.0
 * @param {string} filePath - path of the file to compress
 * @returns {Promise<number>} brotli-compressed byte size using the default
 *   [zlib]{@link https://nodejs.org/api/zlib.html#brotli-constants} options
 */
const getFileSizeBrotli = (filePath) =>
  readFile(filePath)
    .then(brotliCompress)
    .then((output) => output.length)
    .catch((err) =>
      help(
        err,
        "\n\nOccurred while getting brotli compressed file size.",
        "Double check the file path:\n   ",
        filePath,
      ),
    );

/**
 * Determine metrics related to an application's build size.
 *
 * @param {string} buildPath - path to the build directory
 * @param {string} [bundleFileType] - file type of bundle, e.g. "js", "css", etc.
 * @returns {Promise<BuildSizes>} build sizes
 */
async function getBuildSizes(buildPath, bundleFileType = "js") {
  try {
    const build = resolve(process.cwd(), buildPath);
    const buildFiles = await getFiles(build);
    const filteredBuildFiles = filterFilesByType(buildFiles, bundleFileType);

    // the file with the largest size by type
    const mainBundleFile = filteredBuildFiles.length
      ? filteredBuildFiles.reduce((max, file) =>
          max.size > file.size ? max : file,
        )
      : null;

    // the largest file size by type
    const mainBundleSize = mainBundleFile ? mainBundleFile.size : 0;
    const mainBundleName = mainBundleFile ? mainBundleFile.name : "Not found";

    // main bundle size compressed using gzip and brotli
    const mainBundleSizeGzip = mainBundleFile
      ? await getFileSizeGzip(mainBundleFile.path)
      : 0;
    const mainBundleSizeBrotli = mainBundleFile
      ? await getFileSizeBrotli(mainBundleFile.path)
      : 0;

    // sum of all file sizes
    const buildSize = buildFiles.reduce((count, file) => count + file.size, 0);

    // the `du` command is not available on windows
    const buildSizeOnDisk =
      process.platform !== "win32"
        ? Number((await exec(`du -sb ${build} | cut -f1`)).stdout.trim())
        : NaN;

    const buildFileCount = buildFiles.length;

    return {
      mainBundleName,
      mainBundleSize,
      mainBundleSizeGzip,
      mainBundleSizeBrotli,
      buildSize,
      buildSizeOnDisk,
      buildFileCount,
    };
  } catch (err) {
    help(
      err,
      "\n\nOccurred while getting build sizes.",
      "Double check the inputs:",
      "\n    build path:",
      resolve(buildPath),
      "\n    bundle filetype:",
      bundleFileType,
    );
  }
}

/**
 * Save the results from {@link getBuildSizes} to a CSV file. Useful for
 * tracking build sizes over time, e.g., in a CI/CD pipeline.
 *
 * @since v3.0.0
 * @param {BuildSizes} buildSizes - build sizes that will be saved to CSV
 * @param {string} outputPath - path of the output file, e.g. build/size.csv
 */
async function saveBuildSizes(buildSizes, outputPath) {
  try {
    const outfile = resolve(outputPath);
    let version = "";

    try {
      version = JSON.parse(await readFile("package.json", "utf8")).version;
    } catch (err) {
      if (err.code === "ENOENT" && err.path === "package.json") {
        console.warn(
          "No package.json file found in the current working directory.",
          "The package version will not be specified.\n",
        );
      }
    }

    const timestamp = new Intl.DateTimeFormat("default", {
      dateStyle: "short",
      timeStyle: "long",
    })
      .format(Date.now())
      .replace(",", " at");

    const header = [version ? "Version," : "", "Timestamp"];
    const row = [version ? `${version},` : "", timestamp];

    for (const [key, value] of Object.entries(buildSizes)) {
      header.push(key);
      row.push(value);
    }

    header.push("(File sizes in bytes)");
    const headerString = `${header.join(",")}\n`;

    try {
      // write csv header if outfile doesn't exist
      await writeFile(outfile, headerString, { flag: "wx" });
    } catch (err) {
      // don't throw error if outfile does exists
      if (err.code !== "EEXIST") {
        help(
          err,
          "\n\nOccurred while saving build sizes.",
          "Double check the output path:\n   ",
          resolve(outputPath),
        );
      }
    }

    // append build size info to csv
    const rowString = `${row.join(",")}\n`;
    await appendFile(outfile, rowString);
  } catch (err) {
    help(err, "\n\nError saving build sizes to CSV.");
  }
}

/**
 * Print a help message to stderr and exit with code 1.
 *
 * @private
 * @since v3.0.0
 * @param  {...any} messages - info for stderr
 */
function help(...messages) {
  messages && console.error(...messages);
  console.error(
    "\nAdd the -h or --help flag for usage information when on the CLI.\n",
    "\nRead the documentation for assistance with the exported functions:",
    "\nhttps://benelan.github.io/build-sizes/global.html",
  );
  process.exit(1);
}

/**
 * Information about a file.
 *
 * @typedef {object} File
 * @property {string} name - file name with type
 * @property {string} path - absolute file path
 * @property {string} size - uncompressed file size
 * @see {@link getFiles}
 * @see {@link getFileSizeBrotli}
 * @see {@link getFileSizeGzip}
 * @see {@link filterFilesByType}
 */

/**
 * Information about an application's build sizes.
 *
 * @typedef {object} BuildSizes
 * @property {string} mainBundleName - name of the largest bundle size by type
 * @property {number} mainBundleSize - byte size of the largest bundle file by type
 * @property {number} mainBundleSizeGzip - gzip-compressed byte size of the main bundle file
 * @property {number} mainBundleSizeBrotli - brotli-compressed byte size of the main bundle file
 * @property {number} buildSize - byte size of all files in the build directory
 * @property {number} buildSizeOnDisk -  on-disk size in bytes of the build directory. Not available on Windows.
 * @property {number} buildFileCount - count of all files in the build directory
 * @see {@link getBuildSizes}
 * @see {@link saveBuildSizes}
 */

export {
  getBuildSizes,
  saveBuildSizes,
  formatBytes,
  getFiles,
  getFileSizeGzip,
  getFileSizeBrotli,
  filterFilesByType,
  help,
};