diff --git a/packages/assemble-release-plan/src/determine-dependents.ts b/packages/assemble-release-plan/src/determine-dependents.ts index be917797fb5..9042af7ac5e 100644 --- a/packages/assemble-release-plan/src/determine-dependents.ts +++ b/packages/assemble-release-plan/src/determine-dependents.ts @@ -230,7 +230,8 @@ function shouldBumpMajor({ preInfo: PreInfo | undefined; onlyUpdatePeerDependentsWhenOutOfRange: boolean; }) { - //disable major bump due to peer dep + // This fork intentionally never escalates peer dependency changes into + // major dependent releases. Preserve that lock even when refactoring. if (depType === 'peerDependencies') { return false; } diff --git a/packages/assemble-release-plan/src/index.ts b/packages/assemble-release-plan/src/index.ts index 1aaaee42ce2..2eafb119178 100644 --- a/packages/assemble-release-plan/src/index.ts +++ b/packages/assemble-release-plan/src/index.ts @@ -1,475 +1,29 @@ import { InternalError } from '@changesets/errors'; import { getDependentsGraph } from '@changesets/get-dependents-graph'; import { shouldSkipPackage } from '@changesets/should-skip-package'; +import { + Config, + NewChangeset, + PackageGroup, + PreState, + ReleasePlan, +} from '@changesets/types'; +import { Package, Packages } from '@manypkg/get-packages'; import semverParse from 'semver/functions/parse'; -import semverGt from 'semver/functions/gt'; -import semverSatisfies from 'semver/functions/satisfies'; -import semverInc from 'semver/functions/inc'; - -function _toPrimitive(t, r) { - if ('object' != typeof t || !t) return t; - var e = t[Symbol.toPrimitive]; - if (void 0 !== e) { - var i = e.call(t, r || 'default'); - if ('object' != typeof i) return i; - throw new TypeError('@@toPrimitive must return a primitive value.'); - } - return ('string' === r ? String : Number)(t); -} -function _toPropertyKey(t) { - var i = _toPrimitive(t, 'string'); - return 'symbol' == typeof i ? i : i + ''; -} -function _defineProperty(e, r, t) { - return ( - (r = _toPropertyKey(r)) in e - ? Object.defineProperty(e, r, { - value: t, - enumerable: !0, - configurable: !0, - writable: !0, - }) - : (e[r] = t), - e - ); -} -function ownKeys(e, r) { - var t = Object.keys(e); - if (Object.getOwnPropertySymbols) { - var o = Object.getOwnPropertySymbols(e); - (r && - (o = o.filter(function (r) { - return Object.getOwnPropertyDescriptor(e, r).enumerable; - })), - t.push.apply(t, o)); - } - return t; -} -function _objectSpread2(e) { - for (var r = 1; r < arguments.length; r++) { - var t = null != arguments[r] ? arguments[r] : {}; - r % 2 - ? ownKeys(Object(t), !0).forEach(function (r) { - _defineProperty(e, r, t[r]); - }) - : Object.getOwnPropertyDescriptors - ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) - : ownKeys(Object(t)).forEach(function (r) { - Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); - }); - } - return e; -} -function getHighestReleaseType(releases) { - if (releases.length === 0) { - throw new Error( - `Large internal Changesets error when calculating highest release type in the set of releases. Please contact the maintainers`, - ); - } - let highestReleaseType = 'none'; - for (let release of releases) { - switch (release.type) { - case 'major': - return 'major'; - case 'minor': - highestReleaseType = 'minor'; - break; - case 'patch': - if (highestReleaseType === 'none') { - highestReleaseType = 'patch'; - } - break; - } - } - return highestReleaseType; -} -function getCurrentHighestVersion(packageGroup, packagesByName) { - let highestVersion; - for (let pkgName of packageGroup) { - let pkg = packagesByName.get(pkgName); - if (!pkg) { - console.error( - `FATAL ERROR IN CHANGESETS! We were unable to version for package group: ${pkgName} in package group: ${packageGroup.toString()}`, - ); - throw new Error(`fatal: could not resolve linked packages`); - } - if ( - highestVersion === undefined || - semverGt(pkg.packageJson.version, highestVersion) - ) { - highestVersion = pkg.packageJson.version; - } - } - return highestVersion; -} - -/* - WARNING: - Important note for understanding how this package works: - - We are doing some kind of wacky things with manipulating the objects within the - releases array, despite the fact that this was passed to us as an argument. We are - aware that this is generally bad practice, but have decided to to this here as - we control the entire flow of releases. - - We could solve this by inlining this function, or by returning a deep-cloned then - modified array, but we decided both of those are worse than this solution. -*/ -function applyLinks(releases, packagesByName, linked) { - let updated = false; - - // We do this for each set of linked packages - for (let linkedPackages of linked) { - // First we filter down to all the relevant releases for one set of linked packages - let releasingLinkedPackages = [...releases.values()].filter( - (release) => - linkedPackages.includes(release.name) && release.type !== 'none', - ); - - // If we proceed any further we do extra work with calculating highestVersion for things that might - // not need one, as they only have workspace based packages - if (releasingLinkedPackages.length === 0) continue; - let highestReleaseType = getHighestReleaseType(releasingLinkedPackages); - let highestVersion = getCurrentHighestVersion( - linkedPackages, - packagesByName, - ); - - // Finally, we update the packages so all of them are on the highest version - for (let linkedPackage of releasingLinkedPackages) { - if (linkedPackage.type !== highestReleaseType) { - updated = true; - linkedPackage.type = highestReleaseType; - } - if (linkedPackage.oldVersion !== highestVersion) { - updated = true; - linkedPackage.oldVersion = highestVersion; - } - } - } - return updated; -} -function incrementVersion(release, preInfo) { - if (release.type === 'none') { - return release.oldVersion; - } - let version = semverInc(release.oldVersion, release.type); - if (preInfo !== undefined && preInfo.state.mode !== 'exit') { - let preVersion = preInfo.preVersions.get(release.name); - if (preVersion === undefined) { - throw new InternalError( - `preVersion for ${release.name} does not exist when preState is defined`, - ); - } - // why are we adding this ourselves rather than passing 'pre' + versionType to semver.inc? - // because semver.inc with prereleases is confusing and this seems easier - version += `-${preInfo.state.tag}.${preVersion}`; - } - return version; -} - -/* - WARNING: - Important note for understanding how this package works: - - We are doing some kind of wacky things with manipulating the objects within the - releases array, despite the fact that this was passed to us as an argument. We are - aware that this is generally bad practice, but have decided to to this here as - we control the entire flow of releases. - - We could solve this by inlining this function, or by returning a deep-cloned then - modified array, but we decided both of those are worse than this solution. -*/ -function determineDependents({ - releases, - packagesByName, - dependencyGraph, - preInfo, - config, -}) { - let updated = false; - // NOTE this is intended to be called recursively - let pkgsToSearch = [...releases.values()]; - while (pkgsToSearch.length > 0) { - // nextRelease is our dependency, think of it as "avatar" - const nextRelease = pkgsToSearch.shift(); - if (!nextRelease) continue; - // pkgDependents will be a list of packages that depend on nextRelease ie. ['avatar-group', 'comment'] - const pkgDependents = dependencyGraph.get(nextRelease.name); - if (!pkgDependents) { - throw new Error( - `Error in determining dependents - could not find package in repository: ${nextRelease.name}`, - ); - } - pkgDependents - .map((dependent) => { - let type; - const dependentPackage = packagesByName.get(dependent); - if (!dependentPackage) throw new Error('Dependency map is incorrect'); - if ( - shouldSkipPackage(dependentPackage, { - ignore: config.ignore, - allowPrivatePackages: config.privatePackages.version, - }) - ) { - type = 'none'; - } else { - const dependencyVersionRanges = getDependencyVersionRanges( - dependentPackage.packageJson, - nextRelease, - ); - for (const { depType, versionRange } of dependencyVersionRanges) { - if (nextRelease.type === 'none') { - continue; - } else if ( - shouldBumpMajor({ - dependent, - depType, - versionRange, - releases, - nextRelease, - preInfo, - onlyUpdatePeerDependentsWhenOutOfRange: - config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH - .onlyUpdatePeerDependentsWhenOutOfRange, - }) - ) { - type = 'major'; - } else if ( - (!releases.has(dependent) || - releases.get(dependent).type === 'none') && - (config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH - .updateInternalDependents === 'always' || - !semverSatisfies( - incrementVersion(nextRelease, preInfo), - versionRange, - )) - ) { - switch (depType) { - case 'dependencies': - case 'optionalDependencies': - case 'peerDependencies': - if (type !== 'major' && type !== 'minor') { - type = 'patch'; - } - break; - case 'devDependencies': { - // We don't need a version bump if the package is only in the devDependencies of the dependent package - if ( - type !== 'major' && - type !== 'minor' && - type !== 'patch' - ) { - type = 'none'; - } - } - } - } - } - } - if (releases.has(dependent) && releases.get(dependent).type === type) { - type = undefined; - } - return { - name: dependent, - type, - pkgJSON: dependentPackage.packageJson, - }; - }) - .filter((dependentItem) => !!dependentItem.type) - .forEach(({ name, type, pkgJSON }) => { - // At this point, we know if we are making a change - updated = true; - const existing = releases.get(name); - // For things that are being given a major bump, we check if we have already - // added them here. If we have, we update the existing item instead of pushing it on to search. - // It is safe to not add it to pkgsToSearch because it should have already been searched at the - // largest possible bump type. - - if (existing && type === 'major' && existing.type !== 'major') { - existing.type = 'major'; - pkgsToSearch.push(existing); - } else { - let newDependent = { - name, - type, - oldVersion: pkgJSON.version, - changesets: [], - }; - pkgsToSearch.push(newDependent); - releases.set(name, newDependent); - } - }); - } - return updated; -} - -/* - Returns an array of objects in the shape { depType: DependencyType, versionRange: string } - The array can contain more than one elements in case a dependency appears in multiple - dependency lists. For example, a package that is both a peerDepenency and a devDependency. -*/ -function getDependencyVersionRanges(dependentPkgJSON, dependencyRelease) { - const DEPENDENCY_TYPES = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', - ]; - const dependencyVersionRanges = []; - for (const type of DEPENDENCY_TYPES) { - var _dependentPkgJSON$typ; - const versionRange = - (_dependentPkgJSON$typ = dependentPkgJSON[type]) === null || - _dependentPkgJSON$typ === void 0 - ? void 0 - : _dependentPkgJSON$typ[dependencyRelease.name]; - if (!versionRange) continue; - if (versionRange.startsWith('workspace:')) { - dependencyVersionRanges.push({ - depType: type, - versionRange: - // intentionally keep other workspace ranges untouched - // this has to be fixed but this should only be done when adding appropriate tests - versionRange === 'workspace:*' - ? // workspace:* actually means the current exact version, and not a wildcard similar to a reguler * range - dependencyRelease.oldVersion - : versionRange.replace(/^workspace:/, ''), - }); - } else { - dependencyVersionRanges.push({ - depType: type, - versionRange, - }); - } - } - return dependencyVersionRanges; -} -function shouldBumpMajor({ - dependent, - depType, - versionRange, - releases, - nextRelease, - preInfo, - onlyUpdatePeerDependentsWhenOutOfRange, -}) { - //disable major bump due to peer dep - if (depType === 'peerDependencies') { - return false; - } - // we check if it is a peerDependency because if it is, our dependent bump type might need to be major. - return ( - //@ts-ignore - depType === 'peerDependencies' && - nextRelease.type !== 'none' && - nextRelease.type !== 'patch' && - // 1. If onlyUpdatePeerDependentsWhenOutOfRange set to true, bump major if the version is leaving the range. - // 2. If onlyUpdatePeerDependentsWhenOutOfRange set to false, bump major regardless whether or not the version is leaving the range. - (!onlyUpdatePeerDependentsWhenOutOfRange || - !semverSatisfies(incrementVersion(nextRelease, preInfo), versionRange)) && - // bump major only if the dependent doesn't already has a major release. - (!releases.has(dependent) || - (releases.has(dependent) && releases.get(dependent).type !== 'major')) - ); -} +import applyLinks from './apply-links'; +import determineDependents from './determine-dependents'; +import flattenReleases from './flatten-releases'; +import { incrementVersion } from './increment'; +import matchFixedConstraint from './match-fixed-constraint'; +import { InternalRelease, PreInfo } from './types'; -// This function takes in changesets and returns one release per -function flattenReleases(changesets, packagesByName, config) { - let releases = new Map(); - changesets.forEach((changeset) => { - changeset.releases - // Filter out skipped packages because they should not trigger a release - // If their dependencies need updates, they will be added to releases by `determineDependents()` with release type `none` - .filter( - ({ name }) => - !shouldSkipPackage(packagesByName.get(name), { - ignore: config.ignore, - allowPrivatePackages: config.privatePackages.version, - }), - ) - .forEach(({ name, type }) => { - let release = releases.get(name); - let pkg = packagesByName.get(name); - if (!pkg) { - throw new Error( - `"${changeset.id}" changeset mentions a release for a package "${name}" but such a package could not be found.`, - ); - } - if (!release) { - release = { - name, - type, - oldVersion: pkg.packageJson.version, - changesets: [changeset.id], - }; - } else { - if ( - type === 'major' || - ((release.type === 'patch' || release.type === 'none') && - (type === 'minor' || type === 'patch')) - ) { - release.type = type; - } - // Check whether the bumpType will change - // If the bumpType has changed recalc newVersion - // push new changeset to releases - release.changesets.push(changeset.id); - } - releases.set(name, release); - }); - }); - return releases; -} -function matchFixedConstraint(releases, packagesByName, config) { - let updated = false; - for (let fixedPackages of config.fixed) { - let releasingFixedPackages = [...releases.values()].filter( - (release) => - fixedPackages.includes(release.name) && release.type !== 'none', - ); - if (releasingFixedPackages.length === 0) continue; - let highestReleaseType = getHighestReleaseType(releasingFixedPackages); - let highestVersion = getCurrentHighestVersion( - fixedPackages, - packagesByName, - ); +type SnapshotReleaseParameters = { + tag?: string | undefined; + commit?: string | undefined; +}; - // Finally, we update the packages so all of them are on the highest version - for (let pkgName of fixedPackages) { - if ( - shouldSkipPackage(packagesByName.get(pkgName), { - ignore: config.ignore, - allowPrivatePackages: config.privatePackages.version, - }) - ) { - continue; - } - let release = releases.get(pkgName); - if (!release) { - updated = true; - releases.set(pkgName, { - name: pkgName, - type: highestReleaseType, - oldVersion: highestVersion, - changesets: [], - }); - continue; - } - if (release.type !== highestReleaseType) { - updated = true; - release.type = highestReleaseType; - } - if (release.oldVersion !== highestVersion) { - updated = true; - release.oldVersion = highestVersion; - } - } - } - return updated; -} -function getPreVersion(version) { - let parsed = semverParse(version); +function getPreVersion(version: string) { + let parsed = semverParse(version)!; let preVersion = parsed.prerelease[1] === undefined ? -1 : parsed.prerelease[1]; if (typeof preVersion !== 'number') { @@ -478,8 +32,13 @@ function getPreVersion(version) { preVersion++; return preVersion; } -function getSnapshotSuffix(template, snapshotParameters) { + +function getSnapshotSuffix( + template: Config['snapshot']['prereleaseTemplate'], + snapshotParameters: SnapshotReleaseParameters, +): string { let snapshotRefDate = new Date(); + const placeholderValues = { commit: snapshotParameters.commit, tag: snapshotParameters.tag, @@ -497,12 +56,17 @@ function getSnapshotSuffix(template, snapshotParameters) { .filter(Boolean) .join('-'); } - const placeholders = Object.keys(placeholderValues); + + const placeholders = Object.keys(placeholderValues) as Array< + keyof typeof placeholderValues + >; + if (!template.includes(`{tag}`) && placeholderValues.tag !== undefined) { throw new Error( `Failed to compose snapshot version: "{tag}" placeholder is missing, but the snapshot parameter is defined (value: '${placeholderValues.tag}')`, ); } + return placeholders.reduce((prev, key) => { return prev.replace(new RegExp(`\\{${key}\\}`, 'g'), () => { const value = placeholderValues[key]; @@ -511,16 +75,18 @@ function getSnapshotSuffix(template, snapshotParameters) { `Failed to compose snapshot version: "{${key}}" placeholder is used without having a value defined!`, ); } + return value; }); }, template); } + function getSnapshotVersion( - release, - preInfo, - useCalculatedVersion, - snapshotSuffix, -) { + release: InternalRelease, + preInfo: PreInfo | undefined, + useCalculatedVersion: boolean, + snapshotSuffix: string, +): string { if (release.type === 'none') { return release.oldVersion; } @@ -537,60 +103,65 @@ function getSnapshotVersion( const baseVersion = useCalculatedVersion ? incrementVersion(release, preInfo) : `0.0.0`; + return `${baseVersion}-${snapshotSuffix}`; } -function getNewVersion(release, preInfo) { + +function getNewVersion( + release: InternalRelease, + preInfo: PreInfo | undefined, +): string { if (release.type === 'none') { return release.oldVersion; } + return incrementVersion(release, preInfo); } + +type OptionalProp = Omit & Partial>; + function assembleReleasePlan( - changesets, - packages, - config, + changesets: NewChangeset[], + packages: Packages, + config: OptionalProp, // intentionally not using an optional parameter here so the result of `readPreState` has to be passed in here - preState, + preState: PreState | undefined, // snapshot: undefined -> not using snaphot // snapshot: { tag: undefined } -> --snapshot (empty tag) // snapshot: { tag: "canary" } -> --snapshot canary - snapshot, -) { + snapshot?: SnapshotReleaseParameters | string | boolean, +): ReleasePlan { // TODO: remove `refined*` in the next major version of this package // just use `config` and `snapshot` parameters directly, typed as: `config: Config, snapshot?: SnapshotReleaseParameters` - const refinedConfig = config.snapshot - ? config - : _objectSpread2( - _objectSpread2({}, config), - {}, - { - snapshot: { - prereleaseTemplate: null, - useCalculatedVersion: - config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH - .useCalculatedVersionForSnapshots, - }, + const refinedConfig: Config = config.snapshot + ? (config as Config) + : { + ...config, + snapshot: { + prereleaseTemplate: null, + useCalculatedVersion: ( + config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH as any + ).useCalculatedVersionForSnapshots, }, - ); - const refinedSnapshot = + }; + const refinedSnapshot: SnapshotReleaseParameters | undefined = typeof snapshot === 'string' - ? { - tag: snapshot, - } + ? { tag: snapshot } : typeof snapshot === 'boolean' - ? { - tag: undefined, - } + ? { tag: undefined } : snapshot; + let packagesByName = new Map( packages.packages.map((x) => [x.packageJson.name, x]), ); + const relevantChangesets = getRelevantChangesets( changesets, packagesByName, refinedConfig, preState, ); + const preInfo = getPreInfo( changesets, packagesByName, @@ -606,10 +177,12 @@ function assembleReleasePlan( packagesByName, refinedConfig, ); + let dependencyGraph = getDependentsGraph(packages, { bumpVersionsWithWorkspaceProtocolOnly: refinedConfig.bumpVersionsWithWorkspaceProtocolOnly, }); + let releasesValidated = false; while (releasesValidated === false) { // The map passed in to determineDependents will be mutated @@ -632,13 +205,12 @@ function assembleReleasePlan( packagesByName, refinedConfig.linked, ); + releasesValidated = !linksUpdated && !dependentAdded && !fixedConstraintUpdated; } - if ( - (preInfo === null || preInfo === void 0 ? void 0 : preInfo.state.mode) === - 'exit' - ) { + + if (preInfo?.state.mode === 'exit') { for (let pkg of packages.packages) { // If a package had a prerelease, but didn't trigger a version bump in the regular release, // we want to give it a patch release. @@ -672,28 +244,32 @@ function assembleReleasePlan( refinedConfig.snapshot.prereleaseTemplate, refinedSnapshot, ); + return { changesets: relevantChangesets, releases: [...releases.values()].map((incompleteRelease) => { - return _objectSpread2( - _objectSpread2({}, incompleteRelease), - {}, - { - newVersion: snapshotSuffix - ? getSnapshotVersion( - incompleteRelease, - preInfo, - refinedConfig.snapshot.useCalculatedVersion, - snapshotSuffix, - ) - : getNewVersion(incompleteRelease, preInfo), - }, - ); + return { + ...incompleteRelease, + newVersion: snapshotSuffix + ? getSnapshotVersion( + incompleteRelease, + preInfo, + refinedConfig.snapshot.useCalculatedVersion, + snapshotSuffix, + ) + : getNewVersion(incompleteRelease, preInfo), + }; }), - preState: preInfo === null || preInfo === void 0 ? void 0 : preInfo.state, + preState: preInfo?.state, }; } -function getRelevantChangesets(changesets, packagesByName, config, preState) { + +function getRelevantChangesets( + changesets: NewChangeset[], + packagesByName: Map, + config: Config, + preState: PreState | undefined, +): NewChangeset[] { for (const changeset of changesets) { // Using the following 2 arrays to decide whether a changeset // contains both skipped and not skipped packages @@ -701,7 +277,7 @@ function getRelevantChangesets(changesets, packagesByName, config, preState) { const notSkippedPackages = []; for (const release of changeset.releases) { if ( - shouldSkipPackage(packagesByName.get(release.name), { + shouldSkipPackage(packagesByName.get(release.name)!, { ignore: config.ignore, allowPrivatePackages: config.privatePackages.version, }) @@ -711,6 +287,7 @@ function getRelevantChangesets(changesets, packagesByName, config, preState) { notSkippedPackages.push(release.name); } } + if (skippedPackages.length > 0 && notSkippedPackages.length > 0) { throw new Error( `Found mixed changeset ${changeset.id}\n` + @@ -720,36 +297,49 @@ function getRelevantChangesets(changesets, packagesByName, config, preState) { ); } } + if (preState && preState.mode !== 'exit') { let usedChangesetIds = new Set(preState.changesets); return changesets.filter( (changeset) => !usedChangesetIds.has(changeset.id), ); } + return changesets; } -function getHighestPreVersion(packageGroup, packagesByName) { + +function getHighestPreVersion( + packageGroup: PackageGroup, + packagesByName: Map, +): number { let highestPreVersion = 0; for (let pkg of packageGroup) { highestPreVersion = Math.max( - getPreVersion(packagesByName.get(pkg).packageJson.version), + getPreVersion(packagesByName.get(pkg)!.packageJson.version), highestPreVersion, ); } return highestPreVersion; } -function getPreInfo(changesets, packagesByName, config, preState) { + +function getPreInfo( + changesets: NewChangeset[], + packagesByName: Map, + config: Config, + preState: PreState | undefined, +): PreInfo | undefined { if (preState === undefined) { return; } - let updatedPreState = _objectSpread2( - _objectSpread2({}, preState), - {}, - { - changesets: changesets.map((changeset) => changeset.id), - initialVersions: _objectSpread2({}, preState.initialVersions), + + let updatedPreState = { + ...preState, + changesets: changesets.map((changeset) => changeset.id), + initialVersions: { + ...preState.initialVersions, }, - ); + }; + for (const [, pkg] of packagesByName) { if (updatedPreState.initialVersions[pkg.packageJson.name] === undefined) { updatedPreState.initialVersions[pkg.packageJson.name] = @@ -758,7 +348,7 @@ function getPreInfo(changesets, packagesByName, config, preState) { } // Populate preVersion // preVersion is the map between package name and its next pre version number. - let preVersions = new Map(); + let preVersions = new Map(); for (const [, pkg] of packagesByName) { preVersions.set( pkg.packageJson.name, @@ -777,10 +367,11 @@ function getPreInfo(changesets, packagesByName, config, preState) { preVersions.set(linkedPackage, highestPreVersion); } } + return { state: updatedPreState, preVersions, }; } -export { assembleReleasePlan as default }; +export default assembleReleasePlan; diff --git a/scripts/bundle-size-report.mjs b/scripts/bundle-size-report.mjs index 068a6790ceb..d2d1d82212f 100755 --- a/scripts/bundle-size-report.mjs +++ b/scripts/bundle-size-report.mjs @@ -5,8 +5,9 @@ // Compare: node scripts/bundle-size-report.mjs --compare base.json --current current.json --output stats.txt // Output includes: // - package dist total (raw) -// - ESM entry gzip -// - web/node bundles (gzip, ENV_TARGET=web/node) +// - root ESM entry gzip +// - web/node bundles for the root entry (gzip, ENV_TARGET=web/node) +// - tracked tree-shakable subpath entries like ./bundler import { readFileSync, @@ -15,13 +16,14 @@ import { statSync, existsSync, mkdirSync, - rmSync, } from 'fs'; -import { dirname, join, resolve, relative, extname } from 'path'; +import { join, resolve, relative, extname } from 'path'; +import { createRequire } from 'module'; import { tmpdir } from 'os'; import { gzipSync } from 'zlib'; const ROOT = resolve(import.meta.dirname, '..'); +const require = createRequire(import.meta.url); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -72,6 +74,7 @@ function formatDeltaMaybe(current, base) { } let rslibPromise; +let webpackPromise; const ASSET_RULES = [ { test: /\.(css|scss|sass|less|styl)$/i, type: 'asset/resource' }, @@ -82,7 +85,8 @@ const ASSET_RULES = [ ]; const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); -const EXPORT_CONDITION_SKIP_KEYS = new Set(['types', 'require', 'node']); +const TRACKED_EXPORT_SUBPATHS = ['./bundler']; +const EXCLUDED_PACKAGE_NAMES = new Set(['@changesets/assemble-release-plan']); async function loadRslib() { if (!rslibPromise) { @@ -91,6 +95,13 @@ async function loadRslib() { return rslibPromise; } +async function loadWebpack() { + if (!webpackPromise) { + webpackPromise = Promise.resolve(require('webpack')); + } + return webpackPromise; +} + /** Recursively sum all file sizes in a directory, excluding .map files */ function dirSize(dir) { let total = 0; @@ -121,90 +132,106 @@ function readPackageJson(pkgDir) { } } -function resolvePackageEntry(pkgDir, entry) { - if (typeof entry !== 'string') return null; - const resolved = join(pkgDir, entry); - if (!existsSync(resolved)) return null; - if (!JS_EXTENSIONS.has(extname(resolved))) return null; - return resolved; +/** Detect the main ESM entry file from package.json */ +function findEsmEntry(pkgDir, pkg) { + if (!pkg) return null; + + // Try module field first + if (pkg.module) { + const resolved = join(pkgDir, pkg.module); + if (existsSync(resolved)) return resolved; + } + + // Try exports["."].import + if (pkg.exports && pkg.exports['.']) { + const dot = pkg.exports['.']; + const importPath = typeof dot === 'string' ? dot : dot.import; + if (importPath) { + const entry = + typeof importPath === 'string' + ? importPath + : importPath.default || importPath; + if (typeof entry === 'string') { + const resolved = join(pkgDir, entry); + if (existsSync(resolved)) return resolved; + } + } + } + + // Fall back to main + if (pkg.main) { + const resolved = join(pkgDir, pkg.main); + if (existsSync(resolved)) return resolved; + } + + return null; } -function resolveExportImportPath(target) { +function resolveExportTarget(pkgDir, target) { if (!target) return null; - if (typeof target === 'string') return target; - if (Array.isArray(target)) { - for (const item of target) { - const resolved = resolveExportImportPath(item); - if (resolved) return resolved; - } - return null; + if (typeof target === 'string') { + const resolved = join(pkgDir, target); + return existsSync(resolved) ? resolved : null; } if (typeof target !== 'object') { return null; } - for (const key of ['module', 'import', 'browser', 'default']) { + const preferredKeys = [ + 'import', + 'default', + 'module', + 'browser', + 'node', + 'require', + ]; + for (const key of preferredKeys) { if (!(key in target)) continue; - const resolved = resolveExportImportPath(target[key]); + const resolved = resolveExportTarget(pkgDir, target[key]); if (resolved) return resolved; } - for (const [key, value] of Object.entries(target)) { - if (EXPORT_CONDITION_SKIP_KEYS.has(key)) continue; - const resolved = resolveExportImportPath(value); + for (const value of Object.values(target)) { + const resolved = resolveExportTarget(pkgDir, value); if (resolved) return resolved; } return null; } -function addEsmEntry(entries, pkgDir, entry) { - const resolved = resolvePackageEntry(pkgDir, entry); - if (resolved) { - entries.add(resolved); - } -} - -/** Detect explicit public ESM entry files from package.json */ -function findEsmEntries(pkgDir, pkg) { - if (!pkg) return []; +function findTrackedEntries(pkgDir, pkg) { + const entries = []; + const rootEntry = findEsmEntry(pkgDir, pkg); - const entries = new Set(); + if (rootEntry) { + entries.push({ + id: '.', + label: 'Package root', + path: rootEntry, + }); + } - if (pkg.module) { - addEsmEntry(entries, pkgDir, pkg.module); + if (!pkg?.exports || typeof pkg.exports !== 'object') { + return entries; } - if (pkg.exports) { - if (typeof pkg.exports === 'string' || Array.isArray(pkg.exports)) { - addEsmEntry(entries, pkgDir, resolveExportImportPath(pkg.exports)); - } else if (typeof pkg.exports === 'object') { - const exportEntries = Object.entries(pkg.exports); - const hasSubpaths = exportEntries.some(([key]) => key.startsWith('.')); + for (const subpath of TRACKED_EXPORT_SUBPATHS) { + if (!(subpath in pkg.exports)) continue; - if (!hasSubpaths) { - addEsmEntry(entries, pkgDir, resolveExportImportPath(pkg.exports)); - } else { - for (const [subpath, target] of exportEntries) { - if ( - subpath !== '.' && - (!subpath.startsWith('./') || subpath.includes('*')) - ) { - continue; - } - addEsmEntry(entries, pkgDir, resolveExportImportPath(target)); - } - } - } - } + const resolved = resolveExportTarget(pkgDir, pkg.exports[subpath]); + if (!resolved) continue; + if (entries.some((entry) => entry.path === resolved)) continue; - if (!entries.size && pkg.main) { - addEsmEntry(entries, pkgDir, pkg.main); + entries.push({ + id: subpath, + label: `${subpath} export`, + path: resolved, + }); } - return [...entries]; + return entries; } function createTempDir(pkgName, target) { @@ -258,45 +285,6 @@ function gzipSize(filePath) { return gzipSync(content, { level: 9 }).length; } -function sumGzipSize(filePaths) { - return filePaths.reduce((sum, filePath) => sum + gzipSize(filePath), 0); -} - -function toImportSpecifier(fromDir, targetPath) { - let specifier = relative(fromDir, targetPath).replace(/\\/g, '/'); - if (!specifier.startsWith('.')) { - specifier = `./${specifier}`; - } - return specifier; -} - -function createAggregateEntry(entryPaths, options) { - const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const safePackageName = (options.packageName || 'pkg').replace( - /[\\/]/g, - '__', - ); - const dir = join( - ROOT, - '.bundle-size-tmp', - safePackageName, - options.target, - stamp, - ); - mkdirSync(dir, { recursive: true }); - const entryPath = join(dir, 'aggregate-entry.mjs'); - const lines = entryPaths.flatMap((targetPath, index) => { - const importPath = toImportSpecifier(dir, targetPath); - return [ - `import * as entry${index} from ${JSON.stringify(importPath)};`, - `export const __bundle_size_entry_${index} = entry${index};`, - ]; - }); - - writeFileSync(entryPath, `${lines.join('\n')}\n`, 'utf8'); - return entryPath; -} - async function bundleEntry(entryPath, options) { if (!entryPath || !existsSync(entryPath)) { return { bytes: null, gzip: null, error: 'entry not found' }; @@ -370,26 +358,231 @@ async function bundleEntry(entryPath, options) { } } -async function bundleEntries(entryPaths, options) { - if (!entryPaths.length) { - return { bytes: null, gzip: null, error: 'entry not found' }; +async function measureEntry(entry, pkgName, pkgDir) { + const entryGzip = gzipSize(entry.path); + const [webResult, nodeResult] = await Promise.all([ + bundleEntry(entry.path, { + target: 'web', + packageName: pkgName, + entryName: entry.id === '.' ? 'bundle' : sanitizeEntryId(entry.id), + define: { ENV_TARGET: JSON.stringify('web') }, + }), + bundleEntry(entry.path, { + target: 'node', + packageName: pkgName, + entryName: entry.id === '.' ? 'bundle' : sanitizeEntryId(entry.id), + define: { ENV_TARGET: JSON.stringify('node') }, + }), + ]); + + const bundleErrors = {}; + if (webResult.error) bundleErrors.web = webResult.error; + if (nodeResult.error) bundleErrors.node = nodeResult.error; + + return { + label: entry.label, + entry: relative(pkgDir, entry.path), + gzip: entryGzip, + webBundleBytes: webResult.bytes, + webBundleGzip: webResult.gzip, + nodeBundleBytes: nodeResult.bytes, + nodeBundleGzip: nodeResult.gzip, + bundleErrors: Object.keys(bundleErrors).length ? bundleErrors : null, + }; +} + +function sanitizeEntryId(entryId) { + return ( + entryId.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '') || 'entry' + ); +} + +function findPackage(packages, name) { + return packages.find((pkg) => pkg.name === name) || null; +} + +function createScenarioResult(label, data) { + return { + label, + webBytes: data.webBytes ?? null, + webGzip: data.webGzip ?? null, + nodeBytes: data.nodeBytes ?? null, + nodeGzip: data.nodeGzip ?? null, + details: data.details ?? null, + errors: data.errors ?? null, + }; +} + +async function runWebpackBuild(config) { + const webpack = await loadWebpack(); + const compiler = webpack(config); + + return new Promise((resolveBuild, rejectBuild) => { + compiler.run((error, stats) => { + const done = (closeError) => { + if (error) { + rejectBuild(error); + return; + } + if (closeError) { + rejectBuild(closeError); + return; + } + if (!stats) { + rejectBuild(new Error('webpack returned no stats')); + return; + } + if (stats.hasErrors()) { + rejectBuild( + new Error( + stats.toString({ + all: false, + errors: true, + errorDetails: true, + }), + ), + ); + return; + } + resolveBuild(stats); + }; + + if (typeof compiler.close === 'function') { + compiler.close(done); + } else { + done(); + } + }); + }); +} + +async function measureEnhancedRemoteScenario(packagesDir, packages) { + const enhancedPkg = findPackage(packages, '@module-federation/enhanced'); + if (!enhancedPkg) { + return createScenarioResult('Enhanced remoteEntry', { + errors: { scenario: 'package not found' }, + }); } - if (entryPaths.length === 1) { - return bundleEntry(entryPaths[0], options); + const enhancedEntry = join(enhancedPkg.dir, 'dist/src/index.js'); + if (!existsSync(enhancedEntry)) { + return createScenarioResult('Enhanced remoteEntry', { + errors: { scenario: 'built enhanced entry not found' }, + }); } - const aggregateEntry = createAggregateEntry(entryPaths, options); + const { ModuleFederationPlugin } = require(enhancedEntry); + const scenarioRoot = createTempDir('scenario-enhanced-remote', 'matrix'); + const entryPath = join(scenarioRoot, 'index.js'); + const exposePath = join(scenarioRoot, 'noop.js'); + writeFileSync(entryPath, 'module.exports = 1;\n', 'utf8'); + writeFileSync(exposePath, 'module.exports = () => "noop";\n', 'utf8'); + + const buildTarget = async ({ + optimizationTarget, + disableSnapshot, + suffix, + }) => { + const outputPath = join(scenarioRoot, `dist-${suffix}`); + mkdirSync(outputPath, { recursive: true }); + const filename = `remoteEntry-${suffix}.js`; + + await runWebpackBuild({ + mode: 'production', + context: scenarioRoot, + entry: './index.js', + target: 'async-node', + infrastructureLogging: { level: 'error' }, + stats: 'errors-only', + output: { + path: outputPath, + filename: '[name].js', + chunkFilename: '[name].js', + publicPath: '/', + uniqueName: `bundle-size-enhanced-${suffix}`, + clean: true, + }, + optimization: { + minimize: true, + chunkIds: 'named', + moduleIds: 'named', + }, + plugins: [ + new ModuleFederationPlugin({ + name: `bundle_size_${suffix}`, + filename, + library: { + type: 'commonjs-module', + name: `bundle_size_${suffix}`, + }, + exposes: { + './noop': './noop.js', + }, + remotes: { + remote: 'remote@http://localhost:3001/remoteEntry.js', + }, + manifest: false, + experiments: { + optimization: { + target: optimizationTarget, + disableSnapshot, + }, + }, + }), + ], + }); + + const outputFile = join(outputPath, filename); + return { + bytes: statSync(outputFile).size, + gzip: gzipSize(outputFile), + file: outputFile, + }; + }; + try { - return await bundleEntry(aggregateEntry, options); - } finally { - const aggregateDir = dirname(aggregateEntry); - if (existsSync(aggregateDir)) { - rmSync(aggregateDir, { recursive: true, force: true }); - } + const [webResult, nodeResult] = await Promise.all([ + buildTarget({ + optimizationTarget: 'web', + disableSnapshot: true, + suffix: 'web', + }), + buildTarget({ + optimizationTarget: 'node', + disableSnapshot: false, + suffix: 'node', + }), + ]); + + return createScenarioResult('Enhanced remoteEntry', { + webBytes: webResult.bytes, + webGzip: webResult.gzip, + nodeBytes: nodeResult.bytes, + nodeGzip: nodeResult.gzip, + details: { + webFile: relative(ROOT, webResult.file), + nodeFile: relative(ROOT, nodeResult.file), + packagesDir: relative(ROOT, packagesDir), + }, + }); + } catch (error) { + return createScenarioResult('Enhanced remoteEntry', { + errors: { + scenario: error?.message ? error.message : String(error), + }, + }); } } +async function measureScenarios(packagesDir, packages) { + const scenarios = {}; + scenarios.enhancedRemoteEntry = await measureEnhancedRemoteScenario( + packagesDir, + packages, + ); + return scenarios; +} + // ── Discovery ──────────────────────────────────────────────────────────────── /** Find package directories from workspace package manifests */ @@ -421,6 +614,7 @@ function discoverPackages(packagesDir) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); if (!packageJson?.name) continue; + if (EXCLUDED_PACKAGE_NAMES.has(packageJson.name)) continue; packages.push({ name: packageJson.name, dir: pkgDir, @@ -444,63 +638,75 @@ async function measure(packagesDir) { const pkgJson = readPackageJson(pkg.dir); const distDir = join(pkg.dir, 'dist'); const totalSize = dirSize(distDir); - const esmEntries = findEsmEntries(pkg.dir, pkgJson); - const esmGzip = sumGzipSize(esmEntries); - let webBundle = { bytes: null, gzip: null }; - let nodeBundle = { bytes: null, gzip: null }; - const bundleErrors = {}; - - if (esmEntries.length) { - const [webResult, nodeResult] = await Promise.all([ - bundleEntries(esmEntries, { - target: 'web', - packageName: pkg.name, - entryName: 'bundle', - define: { ENV_TARGET: JSON.stringify('web') }, - }), - bundleEntries(esmEntries, { - target: 'node', - packageName: pkg.name, - entryName: 'bundle', - define: { ENV_TARGET: JSON.stringify('node') }, - }), - ]); - - webBundle = webResult; - nodeBundle = nodeResult; + const measuredEntries = findTrackedEntries(pkg.dir, pkgJson); + const entrypoints = {}; - if (webResult.error) bundleErrors.web = webResult.error; - if (nodeResult.error) bundleErrors.node = nodeResult.error; + for (const entry of measuredEntries) { + entrypoints[entry.id] = await measureEntry(entry, pkg.name, pkg.dir); } + const rootMetrics = entrypoints['.'] || { + entry: null, + gzip: 0, + webBundleBytes: null, + webBundleGzip: null, + nodeBundleBytes: null, + nodeBundleGzip: null, + bundleErrors: null, + }; + results[pkg.name] = { totalDist: totalSize, - esmGzip, - esmEntry: esmEntries[0] ? relative(pkg.dir, esmEntries[0]) : null, - esmEntries: esmEntries.map((entryPath) => relative(pkg.dir, entryPath)), - webBundleBytes: webBundle.bytes, - webBundleGzip: webBundle.gzip, - nodeBundleBytes: nodeBundle.bytes, - nodeBundleGzip: nodeBundle.gzip, - bundleEntry: esmEntries[0] ? relative(pkg.dir, esmEntries[0]) : null, - bundleEntries: esmEntries.map((entryPath) => - relative(pkg.dir, entryPath), - ), - bundleErrors: Object.keys(bundleErrors).length ? bundleErrors : null, + esmGzip: rootMetrics.gzip, + esmEntry: rootMetrics.entry, + webBundleBytes: rootMetrics.webBundleBytes, + webBundleGzip: rootMetrics.webBundleGzip, + nodeBundleBytes: rootMetrics.nodeBundleBytes, + nodeBundleGzip: rootMetrics.nodeBundleGzip, + bundleEntry: rootMetrics.entry, + bundleErrors: rootMetrics.bundleErrors, + entrypoints, }; } - return results; + return { + packages: results, + scenarios: await measureScenarios(packagesDir, packages), + }; } // ── Compare ────────────────────────────────────────────────────────────────── +function normalizeMeasuredData(data) { + if ( + data && + typeof data === 'object' && + 'packages' in data && + 'scenarios' in data + ) { + return { + packages: data.packages || {}, + scenarios: data.scenarios || {}, + }; + } + + return { + packages: data || {}, + scenarios: {}, + }; +} + function compare(baseData, currentData) { + const normalizedBase = normalizeMeasuredData(baseData); + const normalizedCurrent = normalizeMeasuredData(currentData); + const basePackages = normalizedBase.packages; + const currentPackages = normalizedCurrent.packages; + const baseScenarios = normalizedBase.scenarios; + const currentScenarios = normalizedCurrent.scenarios; const allPackages = new Set([ - ...Object.keys(baseData), - ...Object.keys(currentData), + ...Object.keys(basePackages), + ...Object.keys(currentPackages), ]); - const changed = []; let unchangedCount = 0; const emptyPackageMetrics = { totalDist: 0, @@ -511,7 +717,7 @@ function compare(baseData, currentData) { const distMetrics = [ { key: 'totalDist', label: 'Total dist (raw)' }, - { key: 'esmGzip', label: 'Public ESM gzip' }, + { key: 'esmGzip', label: 'ESM gzip' }, ]; const bundleMetrics = [ @@ -520,44 +726,133 @@ function compare(baseData, currentData) { ]; const allMetrics = [...distMetrics, ...bundleMetrics]; - - for (const name of [...allPackages].sort()) { - const base = baseData[name] || emptyPackageMetrics; - const current = currentData[name] || emptyPackageMetrics; - - const hasChange = allMetrics.some(({ key }) => { - const baseValue = base[key]; - const currentValue = current[key]; + const changedRootRows = []; + const changedEntrypointRows = []; + const changedPackages = new Set(); + + const hasMetricChange = (base, current, metrics) => + metrics.some(({ key }) => { + const baseValue = base?.[key]; + const currentValue = current?.[key]; if (typeof baseValue === 'number' && typeof currentValue === 'number') { return baseValue !== currentValue; } return typeof baseValue === 'number' || typeof currentValue === 'number'; }); - if (hasChange) { - changed.push({ name, base, current }); - } else { - unchangedCount++; + const getEntrypoints = (pkg) => { + const entrypoints = { ...(pkg?.entrypoints || {}) }; + if (!entrypoints['.']) { + entrypoints['.'] = { + label: 'Package root', + entry: pkg?.bundleEntry ?? pkg?.esmEntry ?? null, + gzip: pkg?.esmGzip ?? 0, + webBundleBytes: pkg?.webBundleBytes ?? null, + webBundleGzip: pkg?.webBundleGzip ?? null, + nodeBundleBytes: pkg?.nodeBundleBytes ?? null, + nodeBundleGzip: pkg?.nodeBundleGzip ?? null, + bundleErrors: pkg?.bundleErrors ?? null, + }; + } + return entrypoints; + }; + + for (const name of [...allPackages].sort()) { + const base = basePackages[name] || emptyPackageMetrics; + const current = currentPackages[name] || emptyPackageMetrics; + const baseEntrypoints = getEntrypoints(base); + const currentEntrypoints = getEntrypoints(current); + + if (hasMetricChange(base, current, allMetrics)) { + changedRootRows.push({ name, base, current }); + changedPackages.add(name); + } + + const trackedEntrypoints = new Set([ + ...Object.keys(baseEntrypoints), + ...Object.keys(currentEntrypoints), + ]); + trackedEntrypoints.delete('.'); + + for (const entryId of [...trackedEntrypoints].sort()) { + const baseEntry = baseEntrypoints[entryId] || {}; + const currentEntry = currentEntrypoints[entryId] || {}; + const entryMetrics = [ + { key: 'gzip' }, + { key: 'webBundleGzip' }, + { key: 'nodeBundleGzip' }, + ]; + + if (!hasMetricChange(baseEntry, currentEntry, entryMetrics)) { + continue; + } + + changedEntrypointRows.push({ + name, + entryId, + base: baseEntry, + current: currentEntry, + }); + changedPackages.add(name); } } + const changed = [...changedPackages].sort(); + unchangedCount = allPackages.size - changed.length; + const sumMetric = (data, key) => Object.values(data).reduce((sum, item) => { const value = item?.[key]; return typeof value === 'number' ? sum + value : sum; }, 0); - const totalDistBase = sumMetric(baseData, 'totalDist'); - const totalDistCurrent = sumMetric(currentData, 'totalDist'); - const totalEsmBase = sumMetric(baseData, 'esmGzip'); - const totalEsmCurrent = sumMetric(currentData, 'esmGzip'); - const totalWebBase = sumMetric(baseData, 'webBundleGzip'); - const totalWebCurrent = sumMetric(currentData, 'webBundleGzip'); - const totalNodeBase = sumMetric(baseData, 'nodeBundleGzip'); - const totalNodeCurrent = sumMetric(currentData, 'nodeBundleGzip'); - - const buildTable = (title, metrics) => { - if (changed.length === 0) return []; + const sumEntrypointMetric = (data, entryId, key) => + Object.values(data).reduce((sum, item) => { + const value = item?.entrypoints?.[entryId]?.[key]; + return typeof value === 'number' ? sum + value : sum; + }, 0); + + const totalDistBase = sumMetric(basePackages, 'totalDist'); + const totalDistCurrent = sumMetric(currentPackages, 'totalDist'); + const totalEsmBase = sumMetric(basePackages, 'esmGzip'); + const totalEsmCurrent = sumMetric(currentPackages, 'esmGzip'); + const totalWebBase = sumMetric(basePackages, 'webBundleGzip'); + const totalWebCurrent = sumMetric(currentPackages, 'webBundleGzip'); + const totalNodeBase = sumMetric(basePackages, 'nodeBundleGzip'); + const totalNodeCurrent = sumMetric(currentPackages, 'nodeBundleGzip'); + const totalBundlerEntryBase = sumEntrypointMetric( + basePackages, + './bundler', + 'gzip', + ); + const totalBundlerEntryCurrent = sumEntrypointMetric( + currentPackages, + './bundler', + 'gzip', + ); + const totalBundlerWebBase = sumEntrypointMetric( + basePackages, + './bundler', + 'webBundleGzip', + ); + const totalBundlerWebCurrent = sumEntrypointMetric( + currentPackages, + './bundler', + 'webBundleGzip', + ); + const totalBundlerNodeBase = sumEntrypointMetric( + basePackages, + './bundler', + 'nodeBundleGzip', + ); + const totalBundlerNodeCurrent = sumEntrypointMetric( + currentPackages, + './bundler', + 'nodeBundleGzip', + ); + + const buildTable = (title, metrics, rowsData) => { + if (rowsData.length === 0) return []; const rows = []; rows.push(`### ${title}`); rows.push(''); @@ -569,7 +864,7 @@ function compare(baseData, currentData) { rows.push(`| ${headers.join(' | ')} |`); rows.push(`| ${headers.map(() => '---').join(' | ')} |`); - for (const { name, base, current } of changed) { + for (const { name, base, current } of rowsData) { const cells = [`\`${name}\``]; for (const metric of metrics) { const currentValue = current[metric.key]; @@ -586,6 +881,94 @@ function compare(baseData, currentData) { return rows; }; + const formatSignedBytes = (bytes) => { + if (typeof bytes !== 'number' || bytes === 0) return '0 B'; + return `${bytes > 0 ? '+' : '-'}${formatBytes(Math.abs(bytes))}`; + }; + + const buildEntrypointTable = () => { + if (changedEntrypointRows.length === 0) return []; + + const rows = []; + rows.push('### Tree-shakable entrypoints'); + rows.push(''); + rows.push( + '| Package | Export | Entry gzip | Delta | Web bundle (gzip) | Delta | Node bundle (gzip) | Delta | Gap (node-web) | Delta |', + ); + rows.push('| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |'); + + for (const { name, entryId, base, current } of changedEntrypointRows) { + const currentGap = + typeof current.nodeBundleGzip === 'number' && + typeof current.webBundleGzip === 'number' + ? current.nodeBundleGzip - current.webBundleGzip + : null; + const baseGap = + typeof base.nodeBundleGzip === 'number' && + typeof base.webBundleGzip === 'number' + ? base.nodeBundleGzip - base.webBundleGzip + : null; + + rows.push( + [ + `\`${name}\``, + `\`${entryId}\``, + formatMaybe(current.gzip), + formatDeltaMaybe(current.gzip, base.gzip), + formatMaybe(current.webBundleGzip), + formatDeltaMaybe(current.webBundleGzip, base.webBundleGzip), + formatMaybe(current.nodeBundleGzip), + formatDeltaMaybe(current.nodeBundleGzip, base.nodeBundleGzip), + currentGap === null ? 'n/a' : formatSignedBytes(currentGap), + currentGap === null || baseGap === null + ? 'n/a' + : formatSignedBytes(currentGap - baseGap), + ].join(' | '), + ); + rows[rows.length - 1] = `| ${rows[rows.length - 1]} |`; + } + + rows.push(''); + return rows; + }; + + const buildScenarioTable = () => { + const scenarioNames = new Set([ + ...Object.keys(baseScenarios), + ...Object.keys(currentScenarios), + ]); + if (scenarioNames.size === 0) return []; + + const rows = []; + rows.push('### Consumer scenarios'); + rows.push(''); + rows.push( + '| Scenario | Web output (gzip) | Delta | Node output (gzip) | Delta | Gap (node-web) | Delta |', + ); + rows.push('| --- | --- | --- | --- | --- | --- | --- |'); + + for (const name of [...scenarioNames].sort()) { + const base = baseScenarios[name] || {}; + const current = currentScenarios[name] || {}; + const currentGap = + typeof current.nodeGzip === 'number' && + typeof current.webGzip === 'number' + ? current.nodeGzip - current.webGzip + : null; + const baseGap = + typeof base.nodeGzip === 'number' && typeof base.webGzip === 'number' + ? base.nodeGzip - base.webGzip + : null; + + rows.push( + `| ${current.label || base.label || `\`${name}\``} | ${formatMaybe(current.webGzip)} | ${formatDeltaMaybe(current.webGzip, base.webGzip)} | ${formatMaybe(current.nodeGzip)} | ${formatDeltaMaybe(current.nodeGzip, base.nodeGzip)} | ${currentGap === null ? 'n/a' : formatSignedBytes(currentGap)} | ${currentGap === null || baseGap === null ? 'n/a' : formatSignedBytes(currentGap - baseGap)} |`, + ); + } + + rows.push(''); + return rows; + }; + // Build markdown const lines = []; lines.push('## Bundle Size Report'); @@ -601,8 +984,12 @@ function compare(baseData, currentData) { lines.push(''); } - lines.push(...buildTable('Package dist + public ESM exports', distMetrics)); - lines.push(...buildTable('Bundle targets', bundleMetrics)); + lines.push( + ...buildTable('Package dist + ESM entry', distMetrics, changedRootRows), + ); + lines.push(...buildTable('Bundle targets', bundleMetrics, changedRootRows)); + lines.push(...buildEntrypointTable()); + lines.push(...buildScenarioTable()); lines.push( `**Total dist (raw):** ${formatBytes(totalDistCurrent)} (${formatDelta(totalDistCurrent, totalDistBase)})`, @@ -616,22 +1003,65 @@ function compare(baseData, currentData) { lines.push( `**Total node bundle (gzip):** ${formatBytes(totalNodeCurrent)} (${formatDelta(totalNodeCurrent, totalNodeBase)})`, ); + if ( + totalBundlerEntryBase > 0 || + totalBundlerEntryCurrent > 0 || + totalBundlerWebBase > 0 || + totalBundlerWebCurrent > 0 || + totalBundlerNodeBase > 0 || + totalBundlerNodeCurrent > 0 + ) { + lines.push( + `**Tracked ./bundler entry gzip:** ${formatBytes(totalBundlerEntryCurrent)} (${formatDelta(totalBundlerEntryCurrent, totalBundlerEntryBase)})`, + ); + lines.push( + `**Tracked ./bundler web bundle (gzip):** ${formatBytes(totalBundlerWebCurrent)} (${formatDelta(totalBundlerWebCurrent, totalBundlerWebBase)})`, + ); + lines.push( + `**Tracked ./bundler node bundle (gzip):** ${formatBytes(totalBundlerNodeCurrent)} (${formatDelta(totalBundlerNodeCurrent, totalBundlerNodeBase)})`, + ); + } lines.push(''); lines.push( - '_Bundle sizes are generated with rslib (Rspack). Public ESM gzip aggregates explicit package export entry files (wildcard exports are ignored). Web/node bundles synthesize a single entry that imports those public ESM exports, set ENV_TARGET, and enable tree-shaking. Bare imports are externalized to keep sizes consistent with prior reporting, and assets are emitted as resources._', + '_Bundle sizes are generated with rslib (Rspack). Package-root metrics preserve the historical report. Tracked subpath exports such as `./bundler` are measured separately so ENV_TARGET-driven tree-shaking is visible. Bare imports are externalized to keep package-level sizes consistent, and assets are emitted as resources._', ); lines.push(''); - const errored = Object.entries(currentData).filter( - ([, data]) => data?.bundleErrors, - ); + const errored = []; + for (const [name, data] of Object.entries(currentPackages)) { + const entrypoints = getEntrypoints(data); + for (const [entryId, entry] of Object.entries(entrypoints)) { + if (!entry?.bundleErrors) continue; + errored.push({ + name, + entryId, + bundleErrors: entry.bundleErrors, + }); + } + } + if (errored.length) { lines.push('### Bundle errors'); - for (const [name, data] of errored) { - const parts = Object.entries(data.bundleErrors).map( + for (const item of errored) { + const parts = Object.entries(item.bundleErrors).map( + ([target, error]) => `${target}: ${error}`, + ); + const entryLabel = item.entryId === '.' ? '' : ` ${item.entryId}`; + lines.push(`- \`${item.name}${entryLabel}\`: ${parts.join('; ')}`); + } + lines.push(''); + } + + const erroredScenarios = Object.values(currentScenarios).filter( + (scenario) => scenario?.errors, + ); + if (erroredScenarios.length) { + lines.push('### Scenario errors'); + for (const scenario of erroredScenarios) { + const parts = Object.entries(scenario.errors).map( ([target, error]) => `${target}: ${error}`, ); - lines.push(`- \`${name}\`: ${parts.join('; ')}`); + lines.push(`- ${scenario.label || 'Scenario'}: ${parts.join('; ')}`); } lines.push(''); } @@ -673,7 +1103,7 @@ async function main() { console.log(`Scanning packages in ${packagesDir}...`); const results = await measure(packagesDir); - const packageCount = Object.keys(results).length; + const packageCount = Object.keys(results.packages).length; writeFileSync(outputPath, JSON.stringify(results, null, 2), 'utf8'); console.log(`Measured ${packageCount} packages → ${outputPath}`); @@ -683,7 +1113,7 @@ async function main() { let totalEsm = 0; let totalWeb = 0; let totalNode = 0; - for (const [name, data] of Object.entries(results)) { + for (const [name, data] of Object.entries(results.packages)) { totalDist += data.totalDist; totalEsm += data.esmGzip; if (typeof data.webBundleGzip === 'number') @@ -693,11 +1123,25 @@ async function main() { const bundleErrorNote = data.bundleErrors ? ` (bundle errors: ${Object.keys(data.bundleErrors).join(', ')})` : ''; - const entryNote = data.esmEntries?.length - ? `, esm-entries=${data.esmEntries.length}` + console.log( + ` ${name}: dist=${formatBytes(data.totalDist)}, esm-gzip=${formatBytes(data.esmGzip)}, web-gzip=${formatMaybe(data.webBundleGzip)}, node-gzip=${formatMaybe(data.nodeBundleGzip)}${bundleErrorNote}`, + ); + for (const [entryId, entry] of Object.entries(data.entrypoints || {})) { + if (entryId === '.') continue; + const entryBundleErrorNote = entry.bundleErrors + ? ` (bundle errors: ${Object.keys(entry.bundleErrors).join(', ')})` + : ''; + console.log( + ` ${entryId}: entry-gzip=${formatMaybe(entry.gzip)}, web-gzip=${formatMaybe(entry.webBundleGzip)}, node-gzip=${formatMaybe(entry.nodeBundleGzip)}${entryBundleErrorNote}`, + ); + } + } + for (const scenario of Object.values(results.scenarios || {})) { + const scenarioErrorNote = scenario.errors + ? ` (errors: ${Object.keys(scenario.errors).join(', ')})` : ''; console.log( - ` ${name}: dist=${formatBytes(data.totalDist)}, esm-gzip=${formatBytes(data.esmGzip)}, web-gzip=${formatMaybe(data.webBundleGzip)}, node-gzip=${formatMaybe(data.nodeBundleGzip)}${entryNote}${bundleErrorNote}`, + ` scenario ${scenario.label}: web-gzip=${formatMaybe(scenario.webGzip)}, node-gzip=${formatMaybe(scenario.nodeGzip)}${scenarioErrorNote}`, ); } console.log(