From 2aeeea07d0374cd11c569741dd9aa42a51696724 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 23:53:51 +0000 Subject: [PATCH 01/12] fix(sdk): make isBrowserEnv tree-shakable Co-authored-by: Zack Jackson --- .../bridge/bridge-react/src/lazy/utils.ts | 2 +- packages/runtime-core/src/core.ts | 2 +- .../src/plugins/generate-preload-assets.ts | 2 +- .../src/plugins/snapshot/SnapshotHandler.ts | 2 +- .../src/plugins/snapshot/index.ts | 2 +- packages/runtime-core/src/remote/index.ts | 2 +- packages/runtime-core/src/utils/load.ts | 2 +- packages/runtime-core/src/utils/tool.ts | 2 +- packages/sdk/README.md | 6 +++--- packages/sdk/__tests__/utils.spec.ts | 19 ++++++++++++------- packages/sdk/src/env.ts | 14 ++++++++------ packages/sdk/src/utils.ts | 2 +- 12 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/bridge/bridge-react/src/lazy/utils.ts b/packages/bridge/bridge-react/src/lazy/utils.ts index 7f1025cbfc3..0d0b2c5618d 100644 --- a/packages/bridge/bridge-react/src/lazy/utils.ts +++ b/packages/bridge/bridge-react/src/lazy/utils.ts @@ -153,7 +153,7 @@ export async function fetchData( _id: id, }); }; - if (isBrowserEnv()) { + if (isBrowserEnv) { const dataFetchItem = getDataFetchItem(id); if (!dataFetchItem) { throw new Error(`dataFetchItem not found, id: ${id}`); diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 56ab22aabab..0a87e444a27 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -185,7 +185,7 @@ export class ModuleFederation { plugins, remotes: [], shared: {}, - inBrowser: isBrowserEnv(), + inBrowser: isBrowserEnv, }; 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..4c07fb7e605 100644 --- a/packages/runtime-core/src/plugins/generate-preload-assets.ts +++ b/packages/runtime-core/src/plugins/generate-preload-assets.ts @@ -324,7 +324,7 @@ export const generatePreloadAssetsPlugin: () => ModuleFederationRuntimePlugin = globalSnapshot, remoteSnapshot, } = args; - if (!isBrowserEnv()) { + if (!isBrowserEnv) { return { cssAssets: [], jsAssetsWithoutEntry: [], diff --git a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts index fe470edcf4d..40b226414df 100644 --- a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts +++ b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts @@ -187,7 +187,7 @@ export class SnapshotHandler { // global snapshot includes manifest or module info includes manifest if (globalRemoteSnapshot) { if (isManifestProvider(globalRemoteSnapshot)) { - const remoteEntry = isBrowserEnv() + const remoteEntry = isBrowserEnv ? 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 fde0f973576..5e3cab937ba 100644 --- a/packages/runtime-core/src/plugins/snapshot/index.ts +++ b/packages/runtime-core/src/plugins/snapshot/index.ts @@ -26,7 +26,7 @@ export function assignRemoteInfo( let entryUrl = getResourceUrl(remoteSnapshot, remoteEntryInfo.url); - if (!isBrowserEnv() && !entryUrl.startsWith('http')) { + if (!isBrowserEnv && !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 bb101f25450..de104dc18a4 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -426,7 +426,7 @@ export class RemoteHandler { } // Set the remote entry to a complete path if ('entry' in remote) { - if (isBrowserEnv() && !remote.entry.startsWith('http')) { + if (isBrowserEnv && !remote.entry.startsWith('http')) { remote.entry = new URL(remote.entry, window.location.origin).href; } } diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index 2c315935f82..7cfdbb8d1ef 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -269,7 +269,7 @@ export async function getRemoteEntry(params: { const isWebEnvironment = typeof ENV_TARGET !== 'undefined' ? ENV_TARGET === 'web' - : isBrowserEnv(); + : isBrowserEnv; return isWebEnvironment ? loadEntryDom({ diff --git a/packages/runtime-core/src/utils/tool.ts b/packages/runtime-core/src/utils/tool.ts index 96a92a1e4e6..42b8a93e85c 100644 --- a/packages/runtime-core/src/utils/tool.ts +++ b/packages/runtime-core/src/utils/tool.ts @@ -89,7 +89,7 @@ export function getRemoteEntryInfoFromSnapshot(snapshot: ModuleInfo): { type: 'global', globalName: '', }; - if (isBrowserEnv() || isReactNativeEnv() || !('ssrRemoteEntry' in snapshot)) { + if (isBrowserEnv || 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..173c8f7cc0c 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -32,7 +32,7 @@ generateShareFilename('packageName', true); const logger = createLogger('identifier'); // Check if the current environment is a browser -isBrowserEnv(); +const inBrowser = isBrowserEnv; // Check if the current environment is in debug mode isDebugMode(); @@ -76,8 +76,8 @@ generateSnapshotFromManifest(manifest, options); ### isBrowserEnv -- Type: `isBrowserEnv()` -- Checks if the current environment is a browser. +- Type: `isBrowserEnv: boolean` +- Indicates whether the current environment is a browser. ### isDebugMode diff --git a/packages/sdk/__tests__/utils.spec.ts b/packages/sdk/__tests__/utils.spec.ts index fbaf96d0cc7..61cbd417acc 100644 --- a/packages/sdk/__tests__/utils.spec.ts +++ b/packages/sdk/__tests__/utils.spec.ts @@ -1,20 +1,25 @@ 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(), + isBrowserEnv: false, isReactNativeEnv: jest.fn(), })); +const mockedEnv = env as unknown as { + isBrowserEnv: boolean; + isReactNativeEnv: jest.Mock; +}; + describe('getResourceUrl', () => { let module: ModuleInfo; let sourceUrl: string; beforeEach(() => { sourceUrl = 'test.js'; - (isBrowserEnv as jest.Mock).mockReset(); - (isReactNativeEnv as jest.Mock).mockReset(); + mockedEnv.isBrowserEnv = false; + mockedEnv.isReactNativeEnv.mockReset(); }); test('should return url with getPublicPath', () => { @@ -34,7 +39,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.isBrowserEnv = true; const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://public.com/test.js'); }); @@ -43,8 +48,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.isBrowserEnv = false; + mockedEnv.isReactNativeEnv.mockReturnValue(false); const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://ssr.com/test.js'); }); diff --git a/packages/sdk/src/env.ts b/packages/sdk/src/env.ts index bb054e58a61..195bd1b1e25 100644 --- a/packages/sdk/src/env.ts +++ b/packages/sdk/src/env.ts @@ -5,11 +5,13 @@ declare global { var FEDERATION_DEBUG: string | undefined; } -function isBrowserEnv(): boolean { - return ( - typeof window !== 'undefined' && typeof window.document !== 'undefined' - ); -} +// Declare the ENV_TARGET constant that will be defined by DefinePlugin +declare const ENV_TARGET: 'web' | 'node'; + +const isBrowserEnv = + typeof ENV_TARGET !== 'undefined' + ? ENV_TARGET === 'web' + : typeof window !== 'undefined' && typeof window.document !== 'undefined'; function isReactNativeEnv(): boolean { return ( @@ -19,7 +21,7 @@ function isReactNativeEnv(): boolean { function isBrowserDebug() { try { - if (isBrowserEnv() && window.localStorage) { + if (isBrowserEnv && window.localStorage) { return Boolean(localStorage.getItem(BROWSER_LOG_KEY)); } } catch (error) { diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 1a3888c5b6d..13d9040068a 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -189,7 +189,7 @@ const getResourceUrl = (module: ModuleInfo, sourceUrl: string): string => { return `${publicPath}${sourceUrl}`; } else if ('publicPath' in module) { - if (!isBrowserEnv() && !isReactNativeEnv() && 'ssrPublicPath' in module) { + if (!isBrowserEnv && !isReactNativeEnv() && 'ssrPublicPath' in module) { return `${module.ssrPublicPath}${sourceUrl}`; } return `${module.publicPath}${sourceUrl}`; From fea3d21a0a2926dc5c2e00c8bb44a1ff09227aa7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Feb 2026 00:20:19 +0000 Subject: [PATCH 02/12] chore: add changeset for isBrowserEnv --- .changeset/breezy-walls-burn.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/breezy-walls-burn.md diff --git a/.changeset/breezy-walls-burn.md b/.changeset/breezy-walls-burn.md new file mode 100644 index 00000000000..545c03ecd66 --- /dev/null +++ b/.changeset/breezy-walls-burn.md @@ -0,0 +1,8 @@ +--- +"@module-federation/sdk": major +"@module-federation/runtime-core": major +--- + +Change isBrowserEnv to a top-level boolean constant that honors ENV_TARGET, +so bundlers can tree-shake environment-specific branches. Update callers to +use `isBrowserEnv` instead of `isBrowserEnv()`. From 577e6ff7008e8b963efd01153e4c8013dc21e614 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Feb 2026 00:53:58 +0000 Subject: [PATCH 03/12] chore: expand bundle size metrics --- scripts/bundle-size-report.mjs | 300 ++++++++++++++++++++++++++++----- 1 file changed, 256 insertions(+), 44 deletions(-) diff --git a/scripts/bundle-size-report.mjs b/scripts/bundle-size-report.mjs index 6812082bd05..04fe61c3f0a 100755 --- a/scripts/bundle-size-report.mjs +++ b/scripts/bundle-size-report.mjs @@ -1,8 +1,13 @@ #!/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) +// - no tree-shake bundle (gzip) import { readFileSync, @@ -12,6 +17,7 @@ import { existsSync, } from 'fs'; import { join, resolve, relative } from 'path'; +import { builtinModules } from 'module'; import { gzipSync } from 'zlib'; const ROOT = resolve(import.meta.dirname, '..'); @@ -55,6 +61,24 @@ 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 esbuildPromise; + +async function loadEsbuild() { + if (!esbuildPromise) { + esbuildPromise = import('esbuild'); + } + return esbuildPromise; +} + /** Recursively sum all file sizes in a directory, excluding .map files */ function dirSize(dir) { let total = 0; @@ -75,11 +99,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 +144,17 @@ function findEsmEntry(pkgDir) { return null; } +function collectExternal(pkg) { + if (!pkg) return []; + const deps = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + ...Object.keys(pkg.optionalDependencies || {}), + ]; + const builtins = builtinModules.flatMap((item) => [item, `node:${item}`]); + return Array.from(new Set([...deps, ...builtins])); +} + /** Get gzip size of a file */ function gzipSize(filePath) { if (!filePath || !existsSync(filePath)) return 0; @@ -119,6 +162,46 @@ 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 loadEsbuild(); + const result = await build({ + entryPoints: [entryPath], + absWorkingDir: ROOT, + bundle: true, + write: false, + splitting: false, + format: 'esm', + platform: options.platform, + treeShaking: options.treeShaking, + minify: options.minify ?? true, + target: 'es2021', + define: options.define, + external: options.external, + logLevel: 'silent', + }); + + const output = result.outputFiles?.[0]; + if (!output) { + return { bytes: null, gzip: null, error: 'no output generated' }; + } + + const bytes = output.contents.length; + const gzip = gzipSync(output.contents, { level: 9 }).length; + return { bytes, gzip }; + } catch (error) { + return { + bytes: null, + gzip: null, + error: error?.message ? error.message : String(error), + }; + } +} + // ── Discovery ──────────────────────────────────────────────────────────────── /** Find all packages tagged with type:pkg */ @@ -167,20 +250,66 @@ 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); + const external = collectExternal(pkgJson); + + let webBundle = { bytes: null, gzip: null }; + let nodeBundle = { bytes: null, gzip: null }; + let noTreeBundle = { bytes: null, gzip: null }; + const bundleErrors = {}; + + if (esmEntry) { + const [webResult, nodeResult, noTreeResult] = await Promise.all([ + bundleEntry(esmEntry, { + platform: 'browser', + treeShaking: true, + define: { ENV_TARGET: JSON.stringify('web') }, + external, + }), + bundleEntry(esmEntry, { + platform: 'node', + treeShaking: true, + define: { ENV_TARGET: JSON.stringify('node') }, + external, + }), + bundleEntry(esmEntry, { + platform: 'neutral', + treeShaking: false, + minify: true, + external, + }), + ]); + + webBundle = webResult; + nodeBundle = nodeResult; + noTreeBundle = noTreeResult; + + if (webResult.error) bundleErrors.web = webResult.error; + if (nodeResult.error) bundleErrors.node = nodeResult.error; + if (noTreeResult.error) bundleErrors.noTree = noTreeResult.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, + noTreeBundleBytes: noTreeBundle.bytes, + noTreeBundleGzip: noTreeBundle.gzip, + bundleEntry: esmEntry ? relative(pkg.dir, esmEntry) : null, + bundleErrors: Object.keys(bundleErrors).length ? bundleErrors : null, }; } @@ -196,30 +325,87 @@ 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)' }, + { key: 'noTreeBundleGzip', label: 'No tree-shake 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 totalNoTreeBase = sumMetric(baseData, 'noTreeBundleGzip'); + const totalNoTreeCurrent = sumMetric(currentData, 'noTreeBundleGzip'); + + 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'); @@ -233,41 +419,52 @@ 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( + `**Total no tree-shake bundle (gzip):** ${formatBytes(totalNoTreeCurrent)} (${formatDelta(totalNoTreeCurrent, totalNoTreeBase)})`, + ); + lines.push(''); + lines.push( + '_Bundle sizes are generated with esbuild. Web/node bundles set ENV_TARGET and enable tree-shaking; the no tree-shake bundle disables tree-shaking and leaves ENV_TARGET undefined._', + ); 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) { @@ -298,7 +495,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'); @@ -307,17 +504,32 @@ function main() { // Print summary let totalDist = 0; let totalEsm = 0; + let totalWeb = 0; + let totalNode = 0; + let totalNoTree = 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; + if (typeof data.noTreeBundleGzip === 'number') + totalNoTree += data.noTreeBundleGzip; + 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)}, no-tree-gzip=${formatMaybe(data.noTreeBundleGzip)}${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)}, Total no-tree gzip: ${formatBytes(totalNoTree)}`, ); } } -main(); +main().catch((error) => { + console.error(error); + process.exit(1); +}); From 82b5e2e6683f5a6cef6ca1729cc038f5b6fe2982 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Feb 2026 01:16:20 +0000 Subject: [PATCH 04/12] fix(sdk): keep isBrowserEnv API --- .changeset/breezy-walls-burn.md | 10 ++++----- .../bridge/bridge-react/src/lazy/utils.ts | 7 ++++-- packages/runtime-core/src/core.ts | 4 ++-- .../src/plugins/generate-preload-assets.ts | 4 ++-- .../src/plugins/snapshot/SnapshotHandler.ts | 4 ++-- .../src/plugins/snapshot/index.ts | 4 ++-- packages/runtime-core/src/remote/index.ts | 4 ++-- packages/runtime-core/src/utils/env.ts | 6 ++++- packages/runtime-core/src/utils/load.ts | 6 ++--- packages/runtime-core/src/utils/tool.ts | 8 +++++-- packages/sdk/README.md | 14 ++++++++---- packages/sdk/__tests__/utils.spec.ts | 22 ++++++++++++------- packages/sdk/src/env.ts | 16 +++++++++++--- packages/sdk/src/utils.ts | 8 +++++-- 14 files changed, 77 insertions(+), 40 deletions(-) diff --git a/.changeset/breezy-walls-burn.md b/.changeset/breezy-walls-burn.md index 545c03ecd66..1901630b107 100644 --- a/.changeset/breezy-walls-burn.md +++ b/.changeset/breezy-walls-burn.md @@ -1,8 +1,8 @@ --- -"@module-federation/sdk": major -"@module-federation/runtime-core": major +"@module-federation/sdk": minor +"@module-federation/runtime-core": minor --- -Change isBrowserEnv to a top-level boolean constant that honors ENV_TARGET, -so bundlers can tree-shake environment-specific branches. Update callers to -use `isBrowserEnv` instead of `isBrowserEnv()`. +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 0d0b2c5618d..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/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 0a87e444a27..40938168498 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, @@ -185,7 +185,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 4c07fb7e605..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 40b226414df..b150ac4e656 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 { getShortErrorMsg, @@ -187,7 +187,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 5e3cab937ba..c8830cc3d9a 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 { @@ -26,7 +26,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 de104dc18a4..13bac9343b9 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, @@ -426,7 +426,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 7cfdbb8d1ef..3ce663374a6 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'; @@ -265,11 +265,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 42b8a93e85c..d191f3e94bc 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'; @@ -89,7 +89,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 173c8f7cc0c..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 -const inBrowser = isBrowserEnv; +const inBrowser = isBrowserEnv(); +const inBrowserStatic = isBrowserEnvValue; // Check if the current environment is in debug mode isDebugMode(); @@ -76,8 +77,13 @@ generateSnapshotFromManifest(manifest, options); ### isBrowserEnv -- Type: `isBrowserEnv: boolean` -- Indicates whether the current environment is a browser. +- 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 diff --git a/packages/sdk/__tests__/utils.spec.ts b/packages/sdk/__tests__/utils.spec.ts index 61cbd417acc..c90685ffb7c 100644 --- a/packages/sdk/__tests__/utils.spec.ts +++ b/packages/sdk/__tests__/utils.spec.ts @@ -2,13 +2,18 @@ import { getResourceUrl } from '../src/utils'; import { ModuleInfo } from '../src/types'; import * as env from '../src/env'; -jest.mock('../src/env', () => ({ - isBrowserEnv: false, - 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 { - isBrowserEnv: boolean; + isBrowserEnvValue: boolean; + isBrowserEnv: jest.Mock; isReactNativeEnv: jest.Mock; }; @@ -18,7 +23,8 @@ describe('getResourceUrl', () => { beforeEach(() => { sourceUrl = 'test.js'; - mockedEnv.isBrowserEnv = false; + mockedEnv.isBrowserEnvValue = false; + mockedEnv.isBrowserEnv.mockClear(); mockedEnv.isReactNativeEnv.mockReset(); }); @@ -39,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; - mockedEnv.isBrowserEnv = true; + mockedEnv.isBrowserEnvValue = true; const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://public.com/test.js'); }); @@ -48,7 +54,7 @@ describe('getResourceUrl', () => { const publicPath = 'https://public.com/'; const ssrPublicPath = 'https://ssr.com/'; module = { publicPath, ssrPublicPath } as ModuleInfo; - mockedEnv.isBrowserEnv = false; + mockedEnv.isBrowserEnvValue = false; mockedEnv.isReactNativeEnv.mockReturnValue(false); const result = getResourceUrl(module, sourceUrl); expect(result).toBe('https://ssr.com/test.js'); diff --git a/packages/sdk/src/env.ts b/packages/sdk/src/env.ts index 195bd1b1e25..af552f4ff84 100644 --- a/packages/sdk/src/env.ts +++ b/packages/sdk/src/env.ts @@ -8,11 +8,15 @@ declare global { // Declare the ENV_TARGET constant that will be defined by DefinePlugin declare const ENV_TARGET: 'web' | 'node'; -const isBrowserEnv = +const isBrowserEnvValue = typeof ENV_TARGET !== 'undefined' ? ENV_TARGET === 'web' : typeof window !== 'undefined' && typeof window.document !== 'undefined'; +function isBrowserEnv(): boolean { + return isBrowserEnvValue; +} + function isReactNativeEnv(): boolean { return ( typeof navigator !== 'undefined' && navigator?.product === 'ReactNative' @@ -21,7 +25,7 @@ function isReactNativeEnv(): boolean { function isBrowserDebug() { try { - if (isBrowserEnv && window.localStorage) { + if (isBrowserEnvValue && window.localStorage) { return Boolean(localStorage.getItem(BROWSER_LOG_KEY)); } } catch (error) { @@ -50,4 +54,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/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 13d9040068a..860047c0620 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -6,7 +6,7 @@ import { SEPARATOR, MANIFEST_EXT, } from './constant'; -import { getProcessEnv, isBrowserEnv, isReactNativeEnv } from './env'; +import { getProcessEnv, isBrowserEnvValue, isReactNativeEnv } from './env'; const LOG_CATEGORY = '[ Federation Runtime ]'; @@ -189,7 +189,11 @@ const getResourceUrl = (module: ModuleInfo, sourceUrl: string): string => { return `${publicPath}${sourceUrl}`; } else if ('publicPath' in module) { - if (!isBrowserEnv && !isReactNativeEnv() && 'ssrPublicPath' in module) { + if ( + !isBrowserEnvValue && + !isReactNativeEnv() && + 'ssrPublicPath' in module + ) { return `${module.ssrPublicPath}${sourceUrl}`; } return `${module.publicPath}${sourceUrl}`; From 106578dfb3de9d2b6201feb5e2ac3ceb7a299e17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Feb 2026 01:27:41 +0000 Subject: [PATCH 05/12] chore: tolerate bundle size assets --- scripts/bundle-size-report.mjs | 35 +++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/bundle-size-report.mjs b/scripts/bundle-size-report.mjs index 04fe61c3f0a..c5f412d5512 100755 --- a/scripts/bundle-size-report.mjs +++ b/scripts/bundle-size-report.mjs @@ -72,6 +72,25 @@ function formatDeltaMaybe(current, base) { let esbuildPromise; +const ASSET_LOADERS = { + '.css': 'empty', + '.scss': 'empty', + '.sass': 'empty', + '.less': 'empty', + '.styl': 'empty', + '.png': 'empty', + '.jpg': 'empty', + '.jpeg': 'empty', + '.gif': 'empty', + '.svg': 'empty', + '.webp': 'empty', + '.avif': 'empty', + '.woff': 'empty', + '.woff2': 'empty', + '.ttf': 'empty', + '.eot': 'empty', +}; + async function loadEsbuild() { if (!esbuildPromise) { esbuildPromise = import('esbuild'); @@ -79,6 +98,18 @@ async function loadEsbuild() { return esbuildPromise; } +function externalizeBareImports() { + return { + name: 'externalize-bare-imports', + setup(build) { + build.onResolve({ filter: /^[^./]/ }, (args) => ({ + path: args.path, + external: true, + })); + }, + }; +} + /** Recursively sum all file sizes in a directory, excluding .map files */ function dirSize(dir) { let total = 0; @@ -182,6 +213,8 @@ async function bundleEntry(entryPath, options) { target: 'es2021', define: options.define, external: options.external, + loader: ASSET_LOADERS, + plugins: [externalizeBareImports()], logLevel: 'silent', }); @@ -441,7 +474,7 @@ function compare(baseData, currentData) { ); lines.push(''); lines.push( - '_Bundle sizes are generated with esbuild. Web/node bundles set ENV_TARGET and enable tree-shaking; the no tree-shake bundle disables tree-shaking and leaves ENV_TARGET undefined._', + '_Bundle sizes are generated with esbuild. Web/node bundles set ENV_TARGET and enable tree-shaking; the no tree-shake bundle disables tree-shaking and leaves ENV_TARGET undefined. Asset imports are treated as empty and bare module imports are externalized for consistency._', ); lines.push(''); From 136700273d5f943d2365d6ecd13453a79fdfaf18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Feb 2026 01:31:25 +0000 Subject: [PATCH 06/12] chore: align bundle size ENV_TARGET --- scripts/bundle-size-report.mjs | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/scripts/bundle-size-report.mjs b/scripts/bundle-size-report.mjs index c5f412d5512..df5b0974892 100755 --- a/scripts/bundle-size-report.mjs +++ b/scripts/bundle-size-report.mjs @@ -7,7 +7,6 @@ // - package dist total (raw) // - ESM entry gzip // - web/node bundles (gzip, ENV_TARGET=web/node) -// - no tree-shake bundle (gzip) import { readFileSync, @@ -297,11 +296,10 @@ async function measure(packagesDir) { let webBundle = { bytes: null, gzip: null }; let nodeBundle = { bytes: null, gzip: null }; - let noTreeBundle = { bytes: null, gzip: null }; const bundleErrors = {}; if (esmEntry) { - const [webResult, nodeResult, noTreeResult] = await Promise.all([ + const [webResult, nodeResult] = await Promise.all([ bundleEntry(esmEntry, { platform: 'browser', treeShaking: true, @@ -314,21 +312,13 @@ async function measure(packagesDir) { define: { ENV_TARGET: JSON.stringify('node') }, external, }), - bundleEntry(esmEntry, { - platform: 'neutral', - treeShaking: false, - minify: true, - external, - }), ]); webBundle = webResult; nodeBundle = nodeResult; - noTreeBundle = noTreeResult; if (webResult.error) bundleErrors.web = webResult.error; if (nodeResult.error) bundleErrors.node = nodeResult.error; - if (noTreeResult.error) bundleErrors.noTree = noTreeResult.error; } results[pkg.name] = { @@ -339,8 +329,6 @@ async function measure(packagesDir) { webBundleGzip: webBundle.gzip, nodeBundleBytes: nodeBundle.bytes, nodeBundleGzip: nodeBundle.gzip, - noTreeBundleBytes: noTreeBundle.bytes, - noTreeBundleGzip: noTreeBundle.gzip, bundleEntry: esmEntry ? relative(pkg.dir, esmEntry) : null, bundleErrors: Object.keys(bundleErrors).length ? bundleErrors : null, }; @@ -367,7 +355,6 @@ function compare(baseData, currentData) { const bundleMetrics = [ { key: 'webBundleGzip', label: 'Web bundle (gzip)' }, { key: 'nodeBundleGzip', label: 'Node bundle (gzip)' }, - { key: 'noTreeBundleGzip', label: 'No tree-shake bundle (gzip)' }, ]; const allMetrics = [...distMetrics, ...bundleMetrics]; @@ -406,8 +393,6 @@ function compare(baseData, currentData) { const totalWebCurrent = sumMetric(currentData, 'webBundleGzip'); const totalNodeBase = sumMetric(baseData, 'nodeBundleGzip'); const totalNodeCurrent = sumMetric(currentData, 'nodeBundleGzip'); - const totalNoTreeBase = sumMetric(baseData, 'noTreeBundleGzip'); - const totalNoTreeCurrent = sumMetric(currentData, 'noTreeBundleGzip'); const buildTable = (title, metrics) => { if (changed.length === 0) return []; @@ -469,12 +454,9 @@ function compare(baseData, currentData) { lines.push( `**Total node bundle (gzip):** ${formatBytes(totalNodeCurrent)} (${formatDelta(totalNodeCurrent, totalNodeBase)})`, ); - lines.push( - `**Total no tree-shake bundle (gzip):** ${formatBytes(totalNoTreeCurrent)} (${formatDelta(totalNoTreeCurrent, totalNoTreeBase)})`, - ); lines.push(''); lines.push( - '_Bundle sizes are generated with esbuild. Web/node bundles set ENV_TARGET and enable tree-shaking; the no tree-shake bundle disables tree-shaking and leaves ENV_TARGET undefined. Asset imports are treated as empty and bare module imports are externalized for consistency._', + '_Bundle sizes are generated with esbuild. Web/node bundles set ENV_TARGET and enable tree-shaking. Asset imports are treated as empty and bare module imports are externalized for consistency._', ); lines.push(''); @@ -539,7 +521,6 @@ async function main() { let totalEsm = 0; let totalWeb = 0; let totalNode = 0; - let totalNoTree = 0; for (const [name, data] of Object.entries(results)) { totalDist += data.totalDist; totalEsm += data.esmGzip; @@ -547,17 +528,15 @@ async function main() { totalWeb += data.webBundleGzip; if (typeof data.nodeBundleGzip === 'number') totalNode += data.nodeBundleGzip; - if (typeof data.noTreeBundleGzip === 'number') - totalNoTree += data.noTreeBundleGzip; const bundleErrorNote = data.bundleErrors ? ` (bundle errors: ${Object.keys(data.bundleErrors).join(', ')})` : ''; console.log( - ` ${name}: dist=${formatBytes(data.totalDist)}, esm-gzip=${formatBytes(data.esmGzip)}, web-gzip=${formatMaybe(data.webBundleGzip)}, node-gzip=${formatMaybe(data.nodeBundleGzip)}, no-tree-gzip=${formatMaybe(data.noTreeBundleGzip)}${bundleErrorNote}`, + ` ${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 web gzip: ${formatBytes(totalWeb)}, Total node gzip: ${formatBytes(totalNode)}, Total no-tree gzip: ${formatBytes(totalNoTree)}`, + `Total dist: ${formatBytes(totalDist)}, Total ESM gzip: ${formatBytes(totalEsm)}, Total web gzip: ${formatBytes(totalWeb)}, Total node gzip: ${formatBytes(totalNode)}`, ); } } From 3c9770c84adf2335790b5d772c1a24b5c1a5699c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Feb 2026 01:46:11 +0000 Subject: [PATCH 07/12] chore: use rslib for bundle sizes --- scripts/bundle-size-report.mjs | 202 ++++++++++++++++++++------------- 1 file changed, 122 insertions(+), 80 deletions(-) diff --git a/scripts/bundle-size-report.mjs b/scripts/bundle-size-report.mjs index df5b0974892..fc581bcf658 100755 --- a/scripts/bundle-size-report.mjs +++ b/scripts/bundle-size-report.mjs @@ -14,9 +14,10 @@ import { readdirSync, statSync, existsSync, + mkdirSync, } from 'fs'; -import { join, resolve, relative } from 'path'; -import { builtinModules } from 'module'; +import { join, resolve, relative, extname } from 'path'; +import { tmpdir } from 'os'; import { gzipSync } from 'zlib'; const ROOT = resolve(import.meta.dirname, '..'); @@ -69,44 +70,23 @@ function formatDeltaMaybe(current, base) { return formatDelta(current, base); } -let esbuildPromise; - -const ASSET_LOADERS = { - '.css': 'empty', - '.scss': 'empty', - '.sass': 'empty', - '.less': 'empty', - '.styl': 'empty', - '.png': 'empty', - '.jpg': 'empty', - '.jpeg': 'empty', - '.gif': 'empty', - '.svg': 'empty', - '.webp': 'empty', - '.avif': 'empty', - '.woff': 'empty', - '.woff2': 'empty', - '.ttf': 'empty', - '.eot': 'empty', -}; - -async function loadEsbuild() { - if (!esbuildPromise) { - esbuildPromise = import('esbuild'); - } - return esbuildPromise; -} +let rslibPromise; -function externalizeBareImports() { - return { - name: 'externalize-bare-imports', - setup(build) { - build.onResolve({ filter: /^[^./]/ }, (args) => ({ - path: args.path, - external: true, - })); - }, - }; +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 */ @@ -174,15 +154,48 @@ function findEsmEntry(pkgDir, pkg) { return null; } -function collectExternal(pkg) { - if (!pkg) return []; - const deps = [ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.peerDependencies || {}), - ...Object.keys(pkg.optionalDependencies || {}), - ]; - const builtins = builtinModules.flatMap((item) => [item, `node:${item}`]); - return Array.from(new Set([...deps, ...builtins])); +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 */ @@ -198,32 +211,63 @@ async function bundleEntry(entryPath, options) { } try { - const { build } = await loadEsbuild(); - const result = await build({ - entryPoints: [entryPath], - absWorkingDir: ROOT, - bundle: true, - write: false, - splitting: false, - format: 'esm', - platform: options.platform, - treeShaking: options.treeShaking, - minify: options.minify ?? true, - target: 'es2021', - define: options.define, - external: options.external, - loader: ASSET_LOADERS, - plugins: [externalizeBareImports()], - logLevel: 'silent', - }); + const { build } = await loadRslib(); + const entryName = options.entryName || 'bundle'; + const distRoot = createTempDir( + options.packageName || 'pkg', + options.target, + ); - const output = result.outputFiles?.[0]; - if (!output) { - return { bytes: null, gzip: null, error: 'no output generated' }; + 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 = output.contents.length; - const gzip = gzipSync(output.contents, { level: 9 }).length; + const bytes = statSync(outputPath).size; + const gzip = gzipSize(outputPath); return { bytes, gzip }; } catch (error) { return { @@ -292,8 +336,6 @@ async function measure(packagesDir) { const totalSize = dirSize(distDir); const esmEntry = findEsmEntry(pkg.dir, pkgJson); const esmGzip = gzipSize(esmEntry); - const external = collectExternal(pkgJson); - let webBundle = { bytes: null, gzip: null }; let nodeBundle = { bytes: null, gzip: null }; const bundleErrors = {}; @@ -301,16 +343,16 @@ async function measure(packagesDir) { if (esmEntry) { const [webResult, nodeResult] = await Promise.all([ bundleEntry(esmEntry, { - platform: 'browser', - treeShaking: true, + target: 'web', + packageName: pkg.name, + entryName: 'bundle', define: { ENV_TARGET: JSON.stringify('web') }, - external, }), bundleEntry(esmEntry, { - platform: 'node', - treeShaking: true, + target: 'node', + packageName: pkg.name, + entryName: 'bundle', define: { ENV_TARGET: JSON.stringify('node') }, - external, }), ]); @@ -456,7 +498,7 @@ function compare(baseData, currentData) { ); lines.push(''); lines.push( - '_Bundle sizes are generated with esbuild. Web/node bundles set ENV_TARGET and enable tree-shaking. Asset imports are treated as empty and bare module imports are externalized for consistency._', + '_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(''); From a850bd1183594e28e2b79d4213f4b48e3252d280 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 24 Feb 2026 11:13:46 -0800 Subject: [PATCH 08/12] fix(sdk): recompute browser env at call time Restore dynamic browser detection in isBrowserEnv. Use it in browser-debug checks to avoid stale startup caching. Co-authored-by: Cursor --- packages/sdk/src/env.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/env.ts b/packages/sdk/src/env.ts index af552f4ff84..b8f21069b13 100644 --- a/packages/sdk/src/env.ts +++ b/packages/sdk/src/env.ts @@ -8,13 +8,15 @@ declare global { // Declare the ENV_TARGET constant that will be defined by DefinePlugin declare const ENV_TARGET: 'web' | 'node'; -const isBrowserEnvValue = +const detectBrowserEnv = () => typeof ENV_TARGET !== 'undefined' ? ENV_TARGET === 'web' : typeof window !== 'undefined' && typeof window.document !== 'undefined'; +const isBrowserEnvValue = detectBrowserEnv(); + function isBrowserEnv(): boolean { - return isBrowserEnvValue; + return detectBrowserEnv(); } function isReactNativeEnv(): boolean { @@ -25,7 +27,7 @@ function isReactNativeEnv(): boolean { function isBrowserDebug() { try { - if (isBrowserEnvValue && window.localStorage) { + if (isBrowserEnv() && window.localStorage) { return Boolean(localStorage.getItem(BROWSER_LOG_KEY)); } } catch (error) { From 193f7fedd2a28c03fec70fa683f0de6254f0a4d5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 24 Feb 2026 15:11:44 -0800 Subject: [PATCH 09/12] fix(dts-plugin): align workspace entrypoints and RawSource typing Resolve the dts-plugin TYPE-001 failure by correcting package entry paths for workspace dependencies and updating RawSource usage for webpack typings. Co-authored-by: Cursor --- .../dts-plugin/src/plugins/GenerateTypesPlugin.ts | 6 ++---- packages/error-codes/package.json | 8 ++++---- packages/sdk/package.json | 12 ++++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts index a8b4aaeed5f..d103436ff60 100644 --- a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts +++ b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts @@ -172,8 +172,7 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { compilation.emitAsset( zipName, new compiler.webpack.sources.RawSource( - fs.readFileSync(zipTypesPath), - false, + fs.readFileSync(zipTypesPath) as unknown as string, ), ); } @@ -186,8 +185,7 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { compilation.emitAsset( apiFileName, new compiler.webpack.sources.RawSource( - fs.readFileSync(apiTypesPath), - false, + fs.readFileSync(apiTypesPath) as unknown as string, ), ); } diff --git a/packages/error-codes/package.json b/packages/error-codes/package.json index 126b794b07c..352010e14a9 100644 --- a/packages/error-codes/package.json +++ b/packages/error-codes/package.json @@ -25,14 +25,14 @@ "browser": { "url": false }, - "main": "./dist/index.cjs.js", - "module": "./dist/index.esm.mjs", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.esm.mjs", - "require": "./dist/index.cjs.js" + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" } }, "typesVersions": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cc3f5bde8d4..85026bc4549 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,8 +23,8 @@ }, "author": "zhanghang ", "sideEffects": false, - "main": "./dist/index.cjs.cjs", - "module": "./dist/index.esm.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "browser": { "url": false @@ -33,21 +33,21 @@ ".": { "import": { "types": "./dist/index.d.ts", - "default": "./dist/index.esm.js" + "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.ts", - "default": "./dist/index.cjs.cjs" + "default": "./dist/index.cjs" } }, "./normalize-webpack-path": { "import": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.esm.js" + "default": "./dist/normalize-webpack-path.js" }, "require": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.cjs.cjs" + "default": "./dist/normalize-webpack-path.cjs" } } }, From e03ea523473f350c6e32056f2b2ad702229ef5fc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 24 Feb 2026 19:15:58 -0800 Subject: [PATCH 10/12] fix(sdk): align package entrypoints with emitted artifacts Restore sdk and error-codes export paths to the filenames emitted by the current build so CI package resolution no longer fails on these branches. Co-authored-by: Cursor --- packages/error-codes/package.json | 8 ++++---- packages/sdk/package.json | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/error-codes/package.json b/packages/error-codes/package.json index 352010e14a9..126b794b07c 100644 --- a/packages/error-codes/package.json +++ b/packages/error-codes/package.json @@ -25,14 +25,14 @@ "browser": { "url": false }, - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "main": "./dist/index.cjs.js", + "module": "./dist/index.esm.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" + "import": "./dist/index.esm.mjs", + "require": "./dist/index.cjs.js" } }, "typesVersions": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 85026bc4549..cc3f5bde8d4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,8 +23,8 @@ }, "author": "zhanghang ", "sideEffects": false, - "main": "./dist/index.cjs", - "module": "./dist/index.js", + "main": "./dist/index.cjs.cjs", + "module": "./dist/index.esm.js", "types": "./dist/index.d.ts", "browser": { "url": false @@ -33,21 +33,21 @@ ".": { "import": { "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "default": "./dist/index.esm.js" }, "require": { "types": "./dist/index.d.ts", - "default": "./dist/index.cjs" + "default": "./dist/index.cjs.cjs" } }, "./normalize-webpack-path": { "import": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.js" + "default": "./dist/normalize-webpack-path.esm.js" }, "require": { "types": "./dist/normalize-webpack-path.d.ts", - "default": "./dist/normalize-webpack-path.cjs" + "default": "./dist/normalize-webpack-path.cjs.cjs" } } }, From e953ab13d1c8a36119ee1810b2a0759f73d4a758 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 10 Mar 2026 15:25:11 -0700 Subject: [PATCH 11/12] fix(metro,sdk): stabilize type import and env test mocks --- packages/metro-core/src/types/runtime.d.ts | 4 +--- packages/sdk/__tests__/utils.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) 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/sdk/__tests__/utils.spec.ts b/packages/sdk/__tests__/utils.spec.ts index b52bc4955e7..812a9d6ffb4 100644 --- a/packages/sdk/__tests__/utils.spec.ts +++ b/packages/sdk/__tests__/utils.spec.ts @@ -63,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'); }); From f88ca31389024dfc0e9958e6a3735d75ba17cf0b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 10 Mar 2026 16:13:40 -0700 Subject: [PATCH 12/12] chore: refresh install state