Skip to the content

Capsid menu

bin/route-weight.mjs

168 lines and 24 functions in DrDustinEdwards/devkit, at commit 505cda4 on 2026-10-24. This file on GitHub.

Functions

The functions of route-weight.mjs
FunctionLinesComplexityCoveredCRAPMutation
parseManifest44 to 46133%OkCRAP 1.3No data: mutation testing has not run on this repo yet
routeWeights56 to 881100%OkCRAP 1No data: mutation testing has not run on this repo yet
file59 to 652100%OkCRAP 2No data: mutation testing has not run on this repo yet
sum66 to 661100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:66>66 to 661100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:70>70 to 702100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:71>71 to 8614100%WatchCRAP 14No data: mutation testing has not run on this repo yet
<anonymous:83>83 to 831100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:87>87 to 872100%OkCRAP 2No data: mutation testing has not run on this repo yet
check95 to 10997%CriticalCRAP 73.9No data: mutation testing has not run on this repo yet
baselineOf108 to 10820%WatchCRAP 6No data: mutation testing has not run on this repo yet
fail111 to 11410%OkCRAP 2No data: mutation testing has not run on this repo yet
shortHead116 to 12220%WatchCRAP 6No data: mutation testing has not run on this repo yet
main124 to 179120%CriticalCRAP 156No data: mutation testing has not run on this repo yet
opt126 to 12630%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:142>142 to 14210%OkCRAP 2No data: mutation testing has not run on this repo yet
read144 to 14820%WatchCRAP 6No data: mutation testing has not run on this repo yet
gzip149 to 14910%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:153>153 to 15310%OkCRAP 2No data: mutation testing has not run on this repo yet
all159 to 16210%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:160>160 to 16010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:161>161 to 16110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:172>172 to 17230%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:174>174 to 17410%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports no other module of this repo. No module of this repo imports it.

Source

#!/usr/bin/env node
// The route weight report. Reads a React Router client build's route manifest (build/client/assets/manifest-*.js) and
// reports, per route that renders a page, the gzip bytes of the JS and CSS a first visit downloads: the client entry and
// its imports, then every route from root down to this one with its module, imports and stylesheets, each file counted
// once and gzipped at level 9. Chunks loaded later through import() are not in the manifest and are not counted; nor are
// fonts and images. Warn-only: a route over the budget, or grown past the baseline, is a ::warning:: line and the run
// passes. A missing build, a manifest naming a file the build lacks, or a config that is not JSON fails (exit 1): a report
// that read nothing must not look like a clean one. The table goes to the job summary.
//
//   npx devkit-route-weight                     report against .route-weight.json (no file: the report alone)
//   npx devkit-route-weight --client <dir>      another client build (default build/client)
//   npx devkit-route-weight --config <file>     another config
//   npx devkit-route-weight --write-baseline    measure and record each route's total as the config's baseline
//
// The config, every field optional:
//
//   {
//     "linked_css": true,
//     "budget_bytes": 160000,
//     "growth": { "ratio": 0.05, "bytes": 1024 },
//     "baseline": { "measured_at": "abc1234", "routes": { "routes/home": 118230 } }
//   }
//
// `linked_css` also counts a stylesheet the counted JS names by URL (a root that links app.css through `?url`). A route
// grown past its baseline warns when it grew by more than `growth.ratio` of the baseline and more than `growth.bytes`
// (defaults 0.05 and 1024).

import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";

/**
 * @typedef {{ id: string, parentId?: string, path?: string, module?: string, imports?: string[], css?: string[],
 *   hasDefaultExport?: boolean }} ManifestRoute
 * @typedef {{ entry: { module: string, imports?: string[], css?: string[] }, routes: Record<string, ManifestRoute> }} Manifest
 * @typedef {{ id: string, path: string, js: number, css: number, total: number }} Row
 * @typedef {{ linked_css?: boolean, budget_bytes?: number, growth?: { ratio?: number, bytes?: number },
 *   baseline?: { measured_at?: string, routes?: Record<string, number> } }} Config
 */

/** The manifest object from the text of manifest-*.js (`window.__reactRouterManifest={...};`). */
export function parseManifest(/** @type {string} */ text) {
  return /** @type {Manifest} */ (JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)));
}

/**
 * One row per route with a default export (a resource route loads no page; root is in every row), heaviest first.
 * @param {Manifest} manifest
 * @param {(url: string) => Buffer} read the bytes of a file named as the manifest names it ("/assets/x.js")
 * @param {(bytes: Buffer) => number} gzip the compressed size
 * @param {boolean} [linkedCss] also count a stylesheet the counted JS names by URL
 * @returns {Row[]}
 */
export function routeWeights(manifest, read, gzip, linkedCss = false) {
  /** @type {Map<string, { bytes: Buffer, gzip: number }>} */
  const files = new Map();
  const file = (/** @type {string} */ url) => {
    if (!files.has(url)) {
      const bytes = read(url);
      files.set(url, { bytes, gzip: gzip(bytes) });
    }
    return /** @type {{ bytes: Buffer, gzip: number }} */ (files.get(url));
  };
  const sum = (/** @type {Set<string>} */ urls) => [...urls].reduce((n, url) => n + file(url).gzip, 0);
  const { entry, routes } = manifest;

  return Object.values(routes)
    .filter((route) => route.hasDefaultExport && route.id !== "root")
    .map((route) => {
      /** @type {ManifestRoute[]} */
      const chain = [];
      for (let at = /** @type {ManifestRoute | undefined} */ (route); at; at = at.parentId ? routes[at.parentId] : undefined) chain.unshift(at);
      const js = new Set([entry.module, ...(entry.imports ?? [])]);
      const css = new Set(entry.css ?? []);
      for (const step of chain) {
        if (step.module) js.add(step.module);
        for (const url of step.imports ?? []) js.add(url);
        for (const url of step.css ?? []) css.add(url);
      }
      if (linkedCss) for (const url of js) for (const m of file(url).bytes.toString("utf8").matchAll(/\/assets\/[\w.-]+\.css/g)) css.add(m[0]);
      const path = `/${chain.map((step) => step.path).filter(Boolean).join("/")}`;
      const [jsBytes, cssBytes] = [sum(js), sum(css)];
      return { id: route.id, path, js: jsBytes, css: cssBytes, total: jsBytes + cssBytes };
    })
    .sort((a, b) => b.total - a.total || a.id.localeCompare(b.id));
}

/**
 * A warning per route over the budget or grown past its baseline, and each row's baseline total (null when it has none).
 * @param {Row[]} rows
 * @param {Config} config
 */
export function check(rows, config) {
  const { ratio = 0.05, bytes = 1024 } = config.growth ?? {};
  const base = config.baseline?.routes ?? {};
  const warnings = [];
  for (const row of rows) {
    if (config.budget_bytes !== undefined && row.total > config.budget_bytes) {
      warnings.push(`${row.id} (${row.path}) loads ${row.total} B gzip, over the budget of ${config.budget_bytes} B.`);
    }
    const was = base[row.id];
    if (was !== undefined && row.total - was > bytes && row.total - was > was * ratio) {
      warnings.push(`${row.id} (${row.path}) loads ${row.total} B gzip, up ${row.total - was} B from the baseline ${was} B.`);
    }
  }
  return { warnings, baselineOf: (/** @type {string} */ id) => (id in base ? base[id] : null) };
}

function fail(/** @type {string} */ message) {
  console.log(`::error::devkit-route-weight: ${message}`);
  process.exit(1);
}

function shortHead(/** @type {string} */ root) {
  try {
    return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
  } catch {
    return new Date().toISOString().slice(0, 10);
  }
}

function main() {
  const argv = process.argv.slice(2);
  const opt = (/** @type {string} */ name, /** @type {string} */ fallback) => (argv.includes(name) && argv[argv.indexOf(name) + 1]) || fallback;
  const root = process.cwd();
  const client = resolve(root, opt("--client", join("build", "client")));
  const file = resolve(root, opt("--config", ".route-weight.json"));

  /** @type {Config} */
  let config = {};
  if (existsSync(file)) {
    try {
      config = JSON.parse(readFileSync(file, "utf8"));
    } catch (error) {
      fail(`${file} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  const assets = join(client, "assets");
  const manifestFile = existsSync(assets) ? readdirSync(assets).find((name) => /^manifest-[\w-]+\.js$/.test(name)) : undefined;
  if (!manifestFile) return fail(`no manifest-*.js in ${assets}. Build the app first.`);
  const read = (/** @type {string} */ url) => {
    const path = join(client, url.replace(/^\//, ""));
    if (!existsSync(path)) fail(`the manifest names ${url}, which is not in ${client}.`);
    return readFileSync(path);
  };
  const gzip = (/** @type {Buffer} */ bytes) => gzipSync(bytes, { level: 9 }).length;
  const rows = routeWeights(parseManifest(readFileSync(join(assets, manifestFile), "utf8")), read, gzip, config.linked_css);

  if (argv.includes("--write-baseline")) {
    config.baseline = { measured_at: shortHead(root), routes: Object.fromEntries(rows.map((row) => [row.id, row.total])) };
    writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
    console.log(`devkit-route-weight: wrote the baseline to ${file}`);
    return;
  }

  const all = (/** @type {string} */ ext) => {
    const names = readdirSync(assets).filter((name) => name.endsWith(ext));
    return `${names.length} ${ext.slice(1).toUpperCase()} files ${names.reduce((n, name) => n + gzip(readFileSync(join(assets, name))), 0)} B`;
  };
  const { warnings, baselineOf } = check(rows, config);
  const measuredAt = config.baseline?.measured_at;
  const table = [
    `### Route weight (gzip bytes, first visit${config.budget_bytes !== undefined ? `, budget ${config.budget_bytes} B` : ""}${measuredAt ? `, baseline ${measuredAt}` : ""})`,
    "",
    `Client build: ${all(".js")}, ${all(".css")} gzip.`,
    "",
    "| Route | Path | JS | CSS | Total | Baseline |",
    "|---|---|--:|--:|--:|--:|",
    ...rows.map((r) => `| ${r.id} | ${r.path} | ${r.js} | ${r.css} | ${r.total} | ${baselineOf(r.id) ?? (measuredAt ? "new" : "")} |`),
    "",
    warnings.length ? warnings.map((w) => `- ${w}`).join("\n") : "No route is over its budget or grown past its baseline.",
  ].join("\n");
  console.log(table);
  for (const w of warnings) console.log(`::warning title=Route weight::${w}`);
  if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${table}\n`);
}

// Run through node_modules/.bin, argv[1] is the link and import.meta.url the file it points to.
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) main();