diff --git a/.changeset/breezy-walls-burn.md b/.changeset/breezy-walls-burn.md new file mode 100644 index 00000000000..1901630b107 --- /dev/null +++ b/.changeset/breezy-walls-burn.md @@ -0,0 +1,8 @@ +--- +"@module-federation/sdk": minor +"@module-federation/runtime-core": minor +--- + +Add `isBrowserEnvValue` as a tree-shakable ENV_TARGET-aware constant while +preserving the `isBrowserEnv()` function. Internal callers use the constant to +enable bundler dead-code elimination without breaking the public API. diff --git a/packages/bridge/bridge-react/src/lazy/utils.ts b/packages/bridge/bridge-react/src/lazy/utils.ts index 7f1025cbfc3..40497c4adb8 100644 --- a/packages/bridge/bridge-react/src/lazy/utils.ts +++ b/packages/bridge/bridge-react/src/lazy/utils.ts @@ -1,4 +1,7 @@ -import { isBrowserEnv, composeKeyWithSeparator } from '@module-federation/sdk'; +import { + isBrowserEnvValue, + composeKeyWithSeparator, +} from '@module-federation/sdk'; import logger from './logger'; import { DOWNGRADE_KEY, @@ -153,7 +156,7 @@ export async function fetchData( _id: id, }); }; - if (isBrowserEnv()) { + if (isBrowserEnvValue) { const dataFetchItem = getDataFetchItem(id); if (!dataFetchItem) { throw new Error(`dataFetchItem not found, id: ${id}`); diff --git a/packages/metro-core/src/types/runtime.d.ts b/packages/metro-core/src/types/runtime.d.ts index 1724386a54f..7410f52546f 100644 --- a/packages/metro-core/src/types/runtime.d.ts +++ b/packages/metro-core/src/types/runtime.d.ts @@ -1,6 +1,4 @@ -import type runtimeCore from '@module-federation/runtime/core'; - -type RemoteEntryExports = runtimeCore.types.RemoteEntryExports; +import type { RemoteEntryExports } from '@module-federation/runtime-core/types'; declare module '@module-federation/runtime' { interface Federation { diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 411dbefce01..b1641295a37 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -1,4 +1,4 @@ -import { isBrowserEnv } from '@module-federation/sdk'; +import { isBrowserEnvValue } from '@module-federation/sdk'; import type { CreateScriptHookReturn, GlobalModuleInfo, @@ -190,7 +190,7 @@ export class ModuleFederation { plugins, remotes: [], shared: {}, - inBrowser: isBrowserEnv(), + inBrowser: isBrowserEnvValue, }; this.name = userOptions.name; diff --git a/packages/runtime-core/src/plugins/generate-preload-assets.ts b/packages/runtime-core/src/plugins/generate-preload-assets.ts index 4ac4ee56b0c..c23143d28b4 100644 --- a/packages/runtime-core/src/plugins/generate-preload-assets.ts +++ b/packages/runtime-core/src/plugins/generate-preload-assets.ts @@ -4,7 +4,7 @@ import { ProviderModuleInfo, isManifestProvider, getResourceUrl, - isBrowserEnv, + isBrowserEnvValue, } from '@module-federation/sdk'; import { EntryAssets, @@ -324,7 +324,7 @@ export const generatePreloadAssetsPlugin: () => ModuleFederationRuntimePlugin = globalSnapshot, remoteSnapshot, } = args; - if (!isBrowserEnv()) { + if (!isBrowserEnvValue) { return { cssAssets: [], jsAssetsWithoutEntry: [], diff --git a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts index 6bff9331640..f4715d4908b 100644 --- a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts +++ b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts @@ -4,7 +4,7 @@ import { ModuleInfo, generateSnapshotFromManifest, isManifestProvider, - isBrowserEnv, + isBrowserEnvValue, } from '@module-federation/sdk'; import { RUNTIME_003, @@ -186,7 +186,7 @@ export class SnapshotHandler { // global snapshot includes manifest or module info includes manifest if (globalRemoteSnapshot) { if (isManifestProvider(globalRemoteSnapshot)) { - const remoteEntry = isBrowserEnv() + const remoteEntry = isBrowserEnvValue ? globalRemoteSnapshot.remoteEntry : globalRemoteSnapshot.ssrRemoteEntry || globalRemoteSnapshot.remoteEntry || diff --git a/packages/runtime-core/src/plugins/snapshot/index.ts b/packages/runtime-core/src/plugins/snapshot/index.ts index b501c097c12..fa09668b674 100644 --- a/packages/runtime-core/src/plugins/snapshot/index.ts +++ b/packages/runtime-core/src/plugins/snapshot/index.ts @@ -1,7 +1,7 @@ import { ModuleInfo, getResourceUrl, - isBrowserEnv, + isBrowserEnvValue, } from '@module-federation/sdk'; import { ModuleFederationRuntimePlugin } from '../../type/plugin'; import { RUNTIME_011, runtimeDescMap } from '@module-federation/error-codes'; @@ -25,7 +25,7 @@ export function assignRemoteInfo( let entryUrl = getResourceUrl(remoteSnapshot, remoteEntryInfo.url); - if (!isBrowserEnv() && !entryUrl.startsWith('http')) { + if (!isBrowserEnvValue && !entryUrl.startsWith('http')) { entryUrl = `https:${entryUrl}`; } diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index 128016dc874..88c6d82ea13 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -1,5 +1,5 @@ import { - isBrowserEnv, + isBrowserEnvValue, warn, composeKeyWithSeparator, ModuleInfo, @@ -429,7 +429,7 @@ export class RemoteHandler { } // Set the remote entry to a complete path if ('entry' in remote) { - if (isBrowserEnv() && !remote.entry.startsWith('http')) { + if (isBrowserEnvValue && !remote.entry.startsWith('http')) { remote.entry = new URL(remote.entry, window.location.origin).href; } } diff --git a/packages/runtime-core/src/utils/env.ts b/packages/runtime-core/src/utils/env.ts index 94fa980ebb6..b80c05a22f0 100644 --- a/packages/runtime-core/src/utils/env.ts +++ b/packages/runtime-core/src/utils/env.ts @@ -1,4 +1,8 @@ -export { isBrowserEnv, isDebugMode } from '@module-federation/sdk'; +export { + isBrowserEnv, + isBrowserEnvValue, + isDebugMode, +} from '@module-federation/sdk'; export function isDevelopmentMode(): boolean { return true; diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index d3573139aa7..d949a87296f 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -2,7 +2,7 @@ import { loadScript, loadScriptNode, composeKeyWithSeparator, - isBrowserEnv, + isBrowserEnvValue, } from '@module-federation/sdk'; import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant'; import { ModuleFederation } from '../core'; @@ -258,11 +258,11 @@ export async function getRemoteEntry(params: { if (res) { return res; } - // Use ENV_TARGET if defined, otherwise fallback to isBrowserEnv, must keep this + // Use ENV_TARGET if defined, otherwise fallback to isBrowserEnvValue const isWebEnvironment = typeof ENV_TARGET !== 'undefined' ? ENV_TARGET === 'web' - : isBrowserEnv(); + : isBrowserEnvValue; return isWebEnvironment ? loadEntryDom({ diff --git a/packages/runtime-core/src/utils/tool.ts b/packages/runtime-core/src/utils/tool.ts index f96a2c70899..2fdd19b8e33 100644 --- a/packages/runtime-core/src/utils/tool.ts +++ b/packages/runtime-core/src/utils/tool.ts @@ -2,7 +2,7 @@ import { RemoteWithEntry, ModuleInfo, RemoteEntryType, - isBrowserEnv, + isBrowserEnvValue, isReactNativeEnv, } from '@module-federation/sdk'; import { Remote, RemoteInfoOptionalVersion } from '../type'; @@ -88,7 +88,11 @@ export function getRemoteEntryInfoFromSnapshot(snapshot: ModuleInfo): { type: 'global', globalName: '', }; - if (isBrowserEnv() || isReactNativeEnv() || !('ssrRemoteEntry' in snapshot)) { + if ( + isBrowserEnvValue || + isReactNativeEnv() || + !('ssrRemoteEntry' in snapshot) + ) { return 'remoteEntry' in snapshot ? { url: snapshot.remoteEntry, diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5ab9727c752..83fffe73c56 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -11,7 +11,7 @@ // The SDK can be used to parse entry strings, encode and decode module names, and generate filenames for exposed modules and shared packages. // It also includes a logger for debugging and environment detection utilities. // Additionally, it provides a function to generate a snapshot from a manifest and environment detection utilities. -import { parseEntry, encodeName, decodeName, generateExposeFilename, generateShareFilename, createLogger, isBrowserEnv, isDebugMode, getProcessEnv, generateSnapshotFromManifest } from '@module-federation/sdk'; +import { parseEntry, encodeName, decodeName, generateExposeFilename, generateShareFilename, createLogger, isBrowserEnv, isBrowserEnvValue, isDebugMode, getProcessEnv, generateSnapshotFromManifest } from '@module-federation/sdk'; // Parse an entry string into a RemoteEntryInfo object parseEntry('entryString'); @@ -32,7 +32,8 @@ generateShareFilename('packageName', true); const logger = createLogger('identifier'); // Check if the current environment is a browser -isBrowserEnv(); +const inBrowser = isBrowserEnv(); +const inBrowserStatic = isBrowserEnvValue; // Check if the current environment is in debug mode isDebugMode(); @@ -76,9 +77,14 @@ generateSnapshotFromManifest(manifest, options); ### isBrowserEnv -- Type: `isBrowserEnv()` +- Type: `isBrowserEnv(): boolean` - Checks if the current environment is a browser. +### isBrowserEnvValue + +- Type: `isBrowserEnvValue: boolean` +- Static browser environment flag (tree-shakable when ENV_TARGET is defined). + ### isDebugMode - Type: `isDebugMode()` diff --git a/packages/sdk/__tests__/utils.spec.ts b/packages/sdk/__tests__/utils.spec.ts index ed1c4e35c9c..812a9d6ffb4 100644 --- a/packages/sdk/__tests__/utils.spec.ts +++ b/packages/sdk/__tests__/utils.spec.ts @@ -1,11 +1,21 @@ import { getResourceUrl } from '../src/utils'; import { ModuleInfo } from '../src/types'; -import { isBrowserEnv, isReactNativeEnv } from '../src/env'; +import * as env from '../src/env'; -jest.mock('../src/env', () => ({ - isBrowserEnv: jest.fn(), - isReactNativeEnv: jest.fn(), -})); +jest.mock('../src/env', () => { + const mock = { + isBrowserEnvValue: false, + isBrowserEnv: jest.fn(() => mock.isBrowserEnvValue), + isReactNativeEnv: jest.fn(), + }; + return mock; +}); + +const mockedEnv = env as unknown as { + isBrowserEnvValue: boolean; + isBrowserEnv: jest.Mock; + isReactNativeEnv: jest.Mock; +}; describe('getResourceUrl', () => { let module: ModuleInfo; @@ -13,8 +23,9 @@ describe('getResourceUrl', () => { beforeEach(() => { sourceUrl = 'test.js'; - (isBrowserEnv as jest.Mock).mockReset(); - (isReactNativeEnv as jest.Mock).mockReset(); + mockedEnv.isBrowserEnvValue = false; + mockedEnv.isBrowserEnv.mockClear(); + mockedEnv.isReactNativeEnv.mockReset(); }); test('should return url with getPublicPath', () => { @@ -34,7 +45,7 @@ describe('getResourceUrl', () => { test('should return url with publicPath in browser or RN env', () => { const publicPath = 'https://public.com/'; module = { publicPath } as ModuleInfo; - (isBrowserEnv as jest.Mock).mockReturnValue(true); + mockedEnv.isBrowserEnvValue = true; const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://public.com/test.js'); }); @@ -43,8 +54,8 @@ describe('getResourceUrl', () => { const publicPath = 'https://public.com/'; const ssrPublicPath = 'https://ssr.com/'; module = { publicPath, ssrPublicPath } as ModuleInfo; - (isBrowserEnv as jest.Mock).mockReturnValue(false); - (isReactNativeEnv as jest.Mock).mockReturnValue(false); + mockedEnv.isBrowserEnvValue = false; + mockedEnv.isReactNativeEnv.mockReturnValue(false); const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://ssr.com/test.js'); }); @@ -52,8 +63,8 @@ describe('getResourceUrl', () => { test('should fallback to publicPath when ssrPublicPath is undefined', () => { const publicPath = 'https://public.com/'; module = { publicPath, ssrPublicPath: undefined } as ModuleInfo; - (isBrowserEnv as jest.Mock).mockReturnValue(false); - (isReactNativeEnv as jest.Mock).mockReturnValue(false); + mockedEnv.isBrowserEnv.mockReturnValue(false); + mockedEnv.isReactNativeEnv.mockReturnValue(false); const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://public.com/test.js'); }); diff --git a/packages/sdk/src/env.ts b/packages/sdk/src/env.ts index bb054e58a61..b8f21069b13 100644 --- a/packages/sdk/src/env.ts +++ b/packages/sdk/src/env.ts @@ -5,10 +5,18 @@ declare global { var FEDERATION_DEBUG: string | undefined; } +// Declare the ENV_TARGET constant that will be defined by DefinePlugin +declare const ENV_TARGET: 'web' | 'node'; + +const detectBrowserEnv = () => + typeof ENV_TARGET !== 'undefined' + ? ENV_TARGET === 'web' + : typeof window !== 'undefined' && typeof window.document !== 'undefined'; + +const isBrowserEnvValue = detectBrowserEnv(); + function isBrowserEnv(): boolean { - return ( - typeof window !== 'undefined' && typeof window.document !== 'undefined' - ); + return detectBrowserEnv(); } function isReactNativeEnv(): boolean { @@ -48,4 +56,10 @@ const getProcessEnv = function (): Record { return typeof process !== 'undefined' && process.env ? process.env : {}; }; -export { isBrowserEnv, isReactNativeEnv, isDebugMode, getProcessEnv }; +export { + isBrowserEnv, + isBrowserEnvValue, + isReactNativeEnv, + isDebugMode, + getProcessEnv, +}; diff --git a/scripts/bundle-size-report.mjs b/scripts/bundle-size-report.mjs index 9ee9c8ad33a..f9be4b8e5c4 100755 --- a/scripts/bundle-size-report.mjs +++ b/scripts/bundle-size-report.mjs @@ -1,8 +1,12 @@ #!/usr/bin/env node -// Bundle Size Report — zero-dependency measurement & comparison script +// Bundle Size Report — measurement & comparison script // Usage: // Measure: node scripts/bundle-size-report.mjs --output sizes.json [--packages-dir packages] // 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) import { readFileSync, @@ -10,8 +14,10 @@ import { readdirSync, statSync, existsSync, + mkdirSync, } from 'fs'; -import { join, resolve, relative } from 'path'; +import { join, resolve, relative, extname } from 'path'; +import { tmpdir } from 'os'; import { gzipSync } from 'zlib'; const ROOT = resolve(import.meta.dirname, '..'); @@ -55,6 +61,34 @@ function formatDelta(current, base) { return absPct > 5 ? `**${text}**` : text; } +function formatMaybe(bytes) { + return typeof bytes === 'number' ? formatBytes(bytes) : 'n/a'; +} + +function formatDeltaMaybe(current, base) { + if (typeof current !== 'number' || typeof base !== 'number') return 'n/a'; + return formatDelta(current, base); +} + +let rslibPromise; + +const ASSET_RULES = [ + { test: /\.(css|scss|sass|less|styl)$/i, type: 'asset/resource' }, + { + test: /\.(png|jpe?g|gif|svg|webp|avif|woff2?|ttf|eot)$/i, + type: 'asset/resource', + }, +]; + +const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); + +async function loadRslib() { + if (!rslibPromise) { + rslibPromise = import('@rslib/core'); + } + return rslibPromise; +} + /** Recursively sum all file sizes in a directory, excluding .map files */ function dirSize(dir) { let total = 0; @@ -75,11 +109,19 @@ function dirSize(dir) { } /** Detect the main ESM entry file from package.json */ -function findEsmEntry(pkgDir) { +function readPackageJson(pkgDir) { const pkgJsonPath = join(pkgDir, 'package.json'); if (!existsSync(pkgJsonPath)) return null; + try { + return JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + } catch { + return null; + } +} - const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); +/** Detect the main ESM entry file from package.json */ +function findEsmEntry(pkgDir, pkg) { + if (!pkg) return null; // Try module field first if (pkg.module) { @@ -112,6 +154,50 @@ function findEsmEntry(pkgDir) { return null; } +function createTempDir(pkgName, target) { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const dir = join(tmpdir(), 'mf-bundle-size', pkgName, target, stamp); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function findBundleOutput(distDir, entryName) { + if (!existsSync(distDir)) return null; + const matches = []; + const stack = [distDir]; + + while (stack.length) { + const dir = stack.pop(); + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (!entry.isFile()) continue; + if (entry.name.endsWith('.map') || entry.name.endsWith('.d.ts')) continue; + const ext = extname(entry.name); + if (!JS_EXTENSIONS.has(ext)) continue; + if (!entry.name.startsWith(entryName)) continue; + matches.push(fullPath); + } + } + + if (!matches.length) return null; + const extOrder = ['.mjs', '.js', '.cjs']; + matches.sort((a, b) => { + const aExt = extname(a); + const bExt = extname(b); + const aRank = extOrder.indexOf(aExt); + const bRank = extOrder.indexOf(bExt); + if (aRank !== bRank) return aRank - bRank; + return a.length - b.length; + }); + + return matches[0]; +} + /** Get gzip size of a file */ function gzipSize(filePath) { if (!filePath || !existsSync(filePath)) return 0; @@ -119,6 +205,79 @@ function gzipSize(filePath) { return gzipSync(content, { level: 9 }).length; } +async function bundleEntry(entryPath, options) { + if (!entryPath || !existsSync(entryPath)) { + return { bytes: null, gzip: null, error: 'entry not found' }; + } + + try { + const { build } = await loadRslib(); + const entryName = options.entryName || 'bundle'; + const distRoot = createTempDir( + options.packageName || 'pkg', + options.target, + ); + + await build( + { + lib: [ + { + id: `${entryName}-${options.target}`, + format: 'esm', + bundle: true, + dts: false, + syntax: 'es2021', + autoExternal: true, + }, + ], + source: { + entry: { + [entryName]: entryPath, + }, + define: options.define, + }, + output: { + target: options.target, + distPath: { + root: distRoot, + }, + cleanDistPath: true, + minify: true, + externals: [/^[^./]/], + externalsType: 'module', + }, + tools: { + rspack: (config) => { + config.module ??= {}; + config.module.rules = [ + ...(config.module.rules || []), + ...ASSET_RULES, + ]; + }, + }, + }, + { + root: ROOT, + }, + ); + + const outputPath = findBundleOutput(distRoot, entryName); + if (!outputPath) { + return { bytes: null, gzip: null, error: 'bundle output not found' }; + } + + const bytes = statSync(outputPath).size; + const gzip = gzipSize(outputPath); + return { bytes, gzip }; + } catch (error) { + return { + bytes: null, + gzip: null, + error: error?.message ? error.message : String(error), + }; + } +} + // ── Discovery ──────────────────────────────────────────────────────────────── /** Find package directories from workspace package manifests */ @@ -165,20 +324,53 @@ function discoverPackages(packagesDir) { // ── Measure ────────────────────────────────────────────────────────────────── -function measure(packagesDir) { +async function measure(packagesDir) { const packages = discoverPackages(packagesDir); const results = {}; for (const pkg of packages) { + const pkgJson = readPackageJson(pkg.dir); const distDir = join(pkg.dir, 'dist'); const totalSize = dirSize(distDir); - const esmEntry = findEsmEntry(pkg.dir); + const esmEntry = findEsmEntry(pkg.dir, pkgJson); const esmGzip = gzipSize(esmEntry); + let webBundle = { bytes: null, gzip: null }; + let nodeBundle = { bytes: null, gzip: null }; + const bundleErrors = {}; + + if (esmEntry) { + const [webResult, nodeResult] = await Promise.all([ + bundleEntry(esmEntry, { + target: 'web', + packageName: pkg.name, + entryName: 'bundle', + define: { ENV_TARGET: JSON.stringify('web') }, + }), + bundleEntry(esmEntry, { + target: 'node', + packageName: pkg.name, + entryName: 'bundle', + define: { ENV_TARGET: JSON.stringify('node') }, + }), + ]); + + webBundle = webResult; + nodeBundle = nodeResult; + + if (webResult.error) bundleErrors.web = webResult.error; + if (nodeResult.error) bundleErrors.node = nodeResult.error; + } results[pkg.name] = { totalDist: totalSize, esmGzip, esmEntry: esmEntry ? relative(pkg.dir, esmEntry) : null, + webBundleBytes: webBundle.bytes, + webBundleGzip: webBundle.gzip, + nodeBundleBytes: nodeBundle.bytes, + nodeBundleGzip: nodeBundle.gzip, + bundleEntry: esmEntry ? relative(pkg.dir, esmEntry) : null, + bundleErrors: Object.keys(bundleErrors).length ? bundleErrors : null, }; } @@ -194,30 +386,84 @@ function compare(baseData, currentData) { ]); const changed = []; let unchangedCount = 0; - let totalDistBase = 0; - let totalDistCurrent = 0; - let totalEsmBase = 0; - let totalEsmCurrent = 0; - for (const name of [...allPackages].sort()) { - const base = baseData[name] || { totalDist: 0, esmGzip: 0 }; - const current = currentData[name] || { totalDist: 0, esmGzip: 0 }; + const distMetrics = [ + { key: 'totalDist', label: 'Total dist (raw)' }, + { key: 'esmGzip', label: 'ESM gzip' }, + ]; - totalDistBase += base.totalDist; - totalDistCurrent += current.totalDist; - totalEsmBase += base.esmGzip; - totalEsmCurrent += current.esmGzip; + const bundleMetrics = [ + { key: 'webBundleGzip', label: 'Web bundle (gzip)' }, + { key: 'nodeBundleGzip', label: 'Node bundle (gzip)' }, + ]; - const distChanged = base.totalDist !== current.totalDist; - const esmChanged = base.esmGzip !== current.esmGzip; + const allMetrics = [...distMetrics, ...bundleMetrics]; - if (distChanged || esmChanged) { + for (const name of [...allPackages].sort()) { + const base = baseData[name] || {}; + const current = currentData[name] || {}; + + const hasChange = allMetrics.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 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 rows = []; + rows.push(`### ${title}`); + rows.push(''); + + const headers = ['Package']; + for (const metric of metrics) { + headers.push(metric.label, 'Delta'); + } + rows.push(`| ${headers.join(' | ')} |`); + rows.push(`| ${headers.map(() => '---').join(' | ')} |`); + + for (const { name, base, current } of changed) { + const cells = [`\`${name}\``]; + for (const metric of metrics) { + const currentValue = current[metric.key]; + const baseValue = base[metric.key]; + cells.push( + formatMaybe(currentValue), + formatDeltaMaybe(currentValue, baseValue), + ); + } + rows.push(`| ${cells.join(' | ')} |`); + } + + rows.push(''); + return rows; + }; + // Build markdown const lines = []; lines.push('## Bundle Size Report'); @@ -231,41 +477,49 @@ function compare(baseData, currentData) { `${changed.length} package(s) changed, ${unchangedCount} unchanged.`, ); lines.push(''); - lines.push('| Package | Total dist | Delta | ESM gzip | Delta |'); - lines.push('|---------|-----------|-------|----------|-------|'); - - // Sort by absolute dist delta descending - changed.sort((a, b) => { - const aDelta = Math.abs(a.current.totalDist - a.base.totalDist); - const bDelta = Math.abs(b.current.totalDist - b.base.totalDist); - return bDelta - aDelta; - }); - - for (const { name, base, current } of changed) { - const distDelta = formatDelta(current.totalDist, base.totalDist); - const esmDelta = formatDelta(current.esmGzip, base.esmGzip); - lines.push( - `| \`${name}\` | ${formatBytes(current.totalDist)} | ${distDelta} | ${formatBytes(current.esmGzip)} | ${esmDelta} |`, - ); - } - - lines.push(''); } + lines.push(...buildTable('Package dist + ESM entry', distMetrics)); + lines.push(...buildTable('Bundle targets', bundleMetrics)); + lines.push( - `**Total dist:** ${formatBytes(totalDistCurrent)} (${formatDelta(totalDistCurrent, totalDistBase)})`, + `**Total dist (raw):** ${formatBytes(totalDistCurrent)} (${formatDelta(totalDistCurrent, totalDistBase)})`, ); lines.push( `**Total ESM gzip:** ${formatBytes(totalEsmCurrent)} (${formatDelta(totalEsmCurrent, totalEsmBase)})`, ); + lines.push( + `**Total web bundle (gzip):** ${formatBytes(totalWebCurrent)} (${formatDelta(totalWebCurrent, totalWebBase)})`, + ); + lines.push( + `**Total node bundle (gzip):** ${formatBytes(totalNodeCurrent)} (${formatDelta(totalNodeCurrent, totalNodeBase)})`, + ); lines.push(''); + lines.push( + '_Bundle sizes are generated with rslib (Rspack). Web/node bundles set ENV_TARGET and enable tree-shaking. Bare imports are externalized to keep sizes consistent with prior reporting, and assets are emitted as resources._', + ); + lines.push(''); + + const errored = Object.entries(currentData).filter( + ([, data]) => data?.bundleErrors, + ); + if (errored.length) { + lines.push('### Bundle errors'); + for (const [name, data] of errored) { + const parts = Object.entries(data.bundleErrors).map( + ([target, error]) => `${target}: ${error}`, + ); + lines.push(`- \`${name}\`: ${parts.join('; ')}`); + } + lines.push(''); + } return lines.join('\n'); } // ── Main ───────────────────────────────────────────────────────────────────── -function main() { +async function main() { const args = parseArgs(process.argv); if (args.compare) { @@ -296,7 +550,7 @@ function main() { const outputPath = resolve(args.output || 'bundle-sizes.json'); console.log(`Scanning packages in ${packagesDir}...`); - const results = measure(packagesDir); + const results = await measure(packagesDir); const packageCount = Object.keys(results).length; writeFileSync(outputPath, JSON.stringify(results, null, 2), 'utf8'); @@ -305,17 +559,29 @@ function main() { // Print summary let totalDist = 0; let totalEsm = 0; + let totalWeb = 0; + let totalNode = 0; for (const [name, data] of Object.entries(results)) { totalDist += data.totalDist; totalEsm += data.esmGzip; + if (typeof data.webBundleGzip === 'number') + totalWeb += data.webBundleGzip; + if (typeof data.nodeBundleGzip === 'number') + totalNode += data.nodeBundleGzip; + const bundleErrorNote = data.bundleErrors + ? ` (bundle errors: ${Object.keys(data.bundleErrors).join(', ')})` + : ''; console.log( - ` ${name}: dist=${formatBytes(data.totalDist)}, esm-gzip=${formatBytes(data.esmGzip)}`, + ` ${name}: dist=${formatBytes(data.totalDist)}, esm-gzip=${formatBytes(data.esmGzip)}, web-gzip=${formatMaybe(data.webBundleGzip)}, node-gzip=${formatMaybe(data.nodeBundleGzip)}${bundleErrorNote}`, ); } console.log( - `Total dist: ${formatBytes(totalDist)}, Total ESM gzip: ${formatBytes(totalEsm)}`, + `Total dist: ${formatBytes(totalDist)}, Total ESM gzip: ${formatBytes(totalEsm)}, Total web gzip: ${formatBytes(totalWeb)}, Total node gzip: ${formatBytes(totalNode)}`, ); } } -main(); +main().catch((error) => { + console.error(error); + process.exit(1); +});