diff --git a/flow-libs/commander.js.flow b/flow-libs/commander.js.flow index 7173bf7b008..a89fd6db25a 100644 --- a/flow-libs/commander.js.flow +++ b/flow-libs/commander.js.flow @@ -244,7 +244,7 @@ declare module 'commander' { [key: string]: any; } - declare class commander$Command { + declare export class commander$Command { constructor(name?: string): commander$Command; args: string[]; diff --git a/packages/dev/bundle-stats-cli/README.md b/packages/dev/bundle-stats-cli/README.md new file mode 100644 index 00000000000..f7ee10a4279 --- /dev/null +++ b/packages/dev/bundle-stats-cli/README.md @@ -0,0 +1,4 @@ +# bundle-stats-cli + +- Run `yarn link` in `packages/dev/bundle-stats-cli` +- Run `parcel-bundle-stats` in the project root, a `parcel-bundle-reports` directory will be created with the stats file inside diff --git a/packages/dev/bundle-stats-cli/bin.js b/packages/dev/bundle-stats-cli/bin.js new file mode 100755 index 00000000000..24263e2c0de --- /dev/null +++ b/packages/dev/bundle-stats-cli/bin.js @@ -0,0 +1,12 @@ +#! /usr/bin/env node + +/* eslint-disable no-console */ +// @flow strict-local +'use strict'; + +// $FlowFixMe[untyped-import] +require('@parcel/babel-register'); + +const cli = require('./src/cli'); + +cli.command.parse(); diff --git a/packages/dev/bundle-stats-cli/package.json b/packages/dev/bundle-stats-cli/package.json new file mode 100644 index 00000000000..91a7eaa8470 --- /dev/null +++ b/packages/dev/bundle-stats-cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "@parcel/bundle-stats-cli", + "version": "2.9.3", + "private": true, + "main": "lib/cli.js", + "source": "src/cli.js", + "bin": { + "parcel-bundle-stats": "bin.js" + }, + "engines": { + "node": ">= 12.0.0", + "parcel": "^2.9.3" + }, + "dependencies": { + "@parcel/reporter-bundle-stats": "^2.9.3", + "@parcel/core": "^2.9.3", + "@parcel/utils": "^2.9.3", + "commander": "^7.0.0", + "parcel-query": "^2.9.3" + }, + "devDependencies": { + "@parcel/babel-register": "^2.9.3", + "@parcel/types": "^2.9.3" + } +} diff --git a/packages/dev/bundle-stats-cli/src/cli.js b/packages/dev/bundle-stats-cli/src/cli.js new file mode 100644 index 00000000000..c4deed1549c --- /dev/null +++ b/packages/dev/bundle-stats-cli/src/cli.js @@ -0,0 +1,84 @@ +/* eslint-disable no-console, monorepo/no-internal-import */ +// @flow strict-local + +import type {PackagedBundle} from '@parcel/types'; +import type {ParcelOptions} from '@parcel/core/src/types'; + +// $FlowFixMe[untyped-import] +import {version} from '../package.json'; + +import commander from 'commander'; +import fs from 'fs'; +import path from 'path'; + +import {DefaultMap} from '@parcel/utils'; + +import {loadGraphs} from 'parcel-query/src/index.js'; +import {getBundleStats} from '@parcel/reporter-bundle-stats/src/BundleStatsReporter'; +import {PackagedBundle as PackagedBundleClass} from '@parcel/core/src/public/Bundle'; +import type {commander$Command} from 'commander'; + +function run({cacheDir, outDir}) { + // 1. load bundle graph and info via parcel~query + let {bundleGraph, bundleInfo} = loadGraphs(cacheDir); + + if (bundleGraph == null) { + console.error('Bundle Graph could not be found'); + process.exit(1); + throw new Error(); + } + + if (bundleInfo == null) { + console.error('Bundle Info could not be found'); + process.exit(1); + throw new Error(); + } + + // 2. generate stats files for each target + fs.mkdirSync(outDir, {recursive: true}); + + let projectRoot = process.cwd(); + + // $FlowFixMe[unclear-type] + let parcelOptions: ParcelOptions = ({projectRoot}: any); + + let bundlesByTarget: DefaultMap< + string /* target name */, + Array, + > = new DefaultMap(() => []); + for (let bundle of bundleGraph.getBundles()) { + bundlesByTarget + .get(bundle.target.name) + .push( + PackagedBundleClass.getWithInfo( + bundle, + bundleGraph, + parcelOptions, + bundleInfo.get(bundle.id), + ), + ); + } + + for (let [targetName, bundles] of bundlesByTarget) { + fs.writeFileSync( + path.join(outDir, `${targetName}-stats.json`), + JSON.stringify(getBundleStats(bundles, parcelOptions), null, 2), + ); + } +} + +export const command: commander$Command = new commander.Command() + .version(version, '-V, --version') + .description('Generate a stats report for a Parcel build') + .option('-v, --verbose', 'Print verbose output') + .option( + '-c, --cache-dir ', + 'Directory to the parcel cache', + '.parcel-cache', + ) + .option( + '-o, --out-dir ', + 'Directory to write the stats to', + 'parcel-bundle-reports', + ) + .action(run); diff --git a/packages/reporters/bundle-stats/package.json b/packages/reporters/bundle-stats/package.json new file mode 100644 index 00000000000..2e4f0cce3c8 --- /dev/null +++ b/packages/reporters/bundle-stats/package.json @@ -0,0 +1,24 @@ +{ + "name": "@parcel/reporter-bundle-stats", + "version": "2.9.3", + "publishConfig": { + "access": "public" + }, + "main": "lib/BundleStatsReporter.js", + "source": "src/BundleStatsReporter.js", + "bin": { + "parcel-bundle-stats": "bin.js" + }, + "engines": { + "node": ">= 12.0.0", + "parcel": "^2.3.1" + }, + "dependencies": { + "@parcel/core": "^2.9.3", + "@parcel/plugin": "^2.9.3", + "@parcel/utils": "^2.9.3" + }, + "devDependencies": { + "@parcel/types": "^2.9.3" + } +} diff --git a/packages/reporters/bundle-stats/src/BundleStatsReporter.js b/packages/reporters/bundle-stats/src/BundleStatsReporter.js new file mode 100644 index 00000000000..41af0b2b968 --- /dev/null +++ b/packages/reporters/bundle-stats/src/BundleStatsReporter.js @@ -0,0 +1,99 @@ +// @flow strict-local + +import type {PackagedBundle, PluginOptions} from '@parcel/types'; + +import {Reporter} from '@parcel/plugin'; +import {DefaultMap} from '@parcel/utils'; + +import assert from 'assert'; +import path from 'path'; + +export type AssetStat = {| + size: number, + name: string, + bundles: Array, +|}; + +export type BundleStat = {| + size: number, + id: string, + assets: Array, +|}; + +export type BundleStats = {| + bundles: {[key: string]: BundleStat}, + assets: {[key: string]: AssetStat}, +|}; + +export default (new Reporter({ + async report({event, options}) { + if (event.type !== 'buildSuccess') { + return; + } + + let bundlesByTarget: DefaultMap< + string /* target name */, + Array, + > = new DefaultMap(() => []); + for (let bundle of event.bundleGraph.getBundles()) { + bundlesByTarget.get(bundle.target.name).push(bundle); + } + + let reportsDir = path.join(options.projectRoot, 'parcel-bundle-reports'); + await options.outputFS.mkdirp(reportsDir); + + await Promise.all( + [...bundlesByTarget.entries()].map(([targetName, bundles]) => + options.outputFS.writeFile( + path.join(reportsDir, `${targetName}-stats.json`), + JSON.stringify(getBundleStats(bundles, options), null, 2), + ), + ), + ); + }, +}): Reporter); + +export function getBundleStats( + bundles: Array, + options: PluginOptions, +): BundleStats { + let bundlesByName = new Map(); + let assetsById = new Map(); + + // let seen = new Map(); + + for (let bundle of bundles) { + let bundleName = path.relative(options.projectRoot, bundle.filePath); + + // If we've already seen this bundle, we can skip it... right? + if (bundlesByName.has(bundleName)) { + // Sanity check: this is the same bundle, right? + assert(bundlesByName.get(bundleName)?.size === bundle.stats.size); + continue; + } + + let assets = []; + bundle.traverseAssets(({id, filePath, stats: {size}}) => { + assets.push(id); + let assetName = path.relative(options.projectRoot, filePath); + if (assetsById.has(id)) { + assert(assetsById.get(id)?.name === assetName); + assert(assetsById.get(id)?.size === size); + assetsById.get(id)?.bundles.push(bundleName); + } else { + assetsById.set(id, {name: assetName, size, bundles: [bundleName]}); + } + }); + + bundlesByName.set(bundleName, { + id: bundle.id, + size: bundle.stats.size, + assets, + }); + } + + return { + bundles: Object.fromEntries(bundlesByName), + assets: Object.fromEntries(assetsById), + }; +}