Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 144 additions & 12 deletions packages/core/src/plugins/fileSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,76 @@ import type {
Rspack,
} from '../types';

interface FileSizeCache {
[environmentName: string]: {
[fileName: string]: {
size: number;
gzippedSize?: number;
};
};
}

const gzip = promisify(zlib.gzip);

async function gzipSize(input: Buffer) {
const data = await gzip(input);
return Buffer.byteLength(data);
}

/** Get the cache file path for storing previous build sizes */
function getCacheFilePath(cachePath: string): string {
return path.join(cachePath, 'file-sizes-cache.json');
}

/** Normalize file name by removing hash for comparison across builds */
export function normalizeFileName(fileName: string): string {
// Remove hash patterns like .a1b2c3d4. but keep the extension
return fileName.replace(/\.[a-f0-9]{8,}\./g, '.');
}

/** Load previous build file sizes from cache */
async function loadPreviousSizes(cachePath: string): Promise<FileSizeCache> {
const cacheFile = getCacheFilePath(cachePath);
try {
const content = await fs.promises.readFile(cacheFile, 'utf-8');
return JSON.parse(content);
} catch {
// Cache doesn't exist or is invalid, return empty cache
return {};
}
}

/** Save current build file sizes to cache */
async function saveSizes(
cachePath: string,
cache: FileSizeCache,
): Promise<void> {
const cacheFile = getCacheFilePath(cachePath);
const cacheDir = path.dirname(cacheFile);

try {
await fs.promises.mkdir(cacheDir, { recursive: true });
await fs.promises.writeFile(cacheFile, JSON.stringify(cache, null, 2));
} catch (err) {
// Fail silently - cache is not critical
logger.debug('Failed to save file size cache:', err);
}
}

const EXCLUDE_ASSET_REGEX = /\.(?:map|LICENSE\.txt|d\.ts)$/;

/** Exclude source map and license files by default */
export const excludeAsset = (asset: PrintFileSizeAsset): boolean =>
EXCLUDE_ASSET_REGEX.test(asset.name);

/** Format a size difference for inline display */
const formatDiff = (diff: number): string => {
const diffStr = calcFileSize(Math.abs(diff));
const sign = diff > 0 ? '+' : '-';
const colorFn = diff > 0 ? color.red : color.green;
return colorFn(`(${sign}${diffStr})`);
};

const getAssetColor = (size: number) => {
if (size > 300 * 1000) {
return color.red;
Expand All @@ -44,12 +101,19 @@ const getAssetColor = (size: number) => {
function getHeader(
maxFileLength: number,
maxSizeLength: number,
maxDiffLength: number,
fileHeader: string,
showDiffHeader: boolean,
showGzipHeader: boolean,
) {
const lengths = [maxFileLength, maxSizeLength];
const rowTypes = [fileHeader, 'Size'];

if (showDiffHeader) {
rowTypes.push('Diff');
lengths.push(maxDiffLength);
}

if (showGzipHeader) {
rowTypes.push('Gzip');
}
Expand Down Expand Up @@ -97,17 +161,22 @@ async function printFileSizes(
rootPath: string,
distPath: string,
environmentName: string,
previousSizes: FileSizeCache,
) {
const logs: string[] = [];
const showDetail = options.detail !== false;
let showTotal = options.total !== false;
const showDiff = options.showDiff === true;

if (!showTotal && !showDetail) {
return logs;
return { logs, currentSizes: {} };
}

const exclude = options.exclude ?? excludeAsset;
const relativeDistPath = path.relative(rootPath, distPath);
const currentSizes: {
[fileName: string]: { size: number; gzippedSize?: number };
} = {};

const formatAsset = async (asset: RsbuildAsset) => {
const fileName = asset.name.split('?')[0];
Expand All @@ -119,13 +188,37 @@ async function printFileSizes(
? getAssetColor(gzippedSize)(calcFileSize(gzippedSize))
: null;

// Normalize filename for comparison (remove hash)
const normalizedName = normalizeFileName(fileName);
const previousSizeData = previousSizes[environmentName]?.[normalizedName];
const previousSize = previousSizeData?.size;

// Calculate size differences for inline display
let sizeDiff: number | null = null;
let gzipDiff: number | null = null;
if (showDiff && previousSize !== undefined) {
sizeDiff = size - previousSize;
if (gzippedSize && previousSizeData?.gzippedSize !== undefined) {
gzipDiff = gzippedSize - previousSizeData.gzippedSize;
}
}

// Store current size for next build
currentSizes[normalizedName] = {
size,
gzippedSize: gzippedSize ?? undefined,
};

return {
size,
folder: path.join(relativeDistPath, path.dirname(fileName)),
name: path.basename(fileName),
gzippedSize,
sizeLabel: calcFileSize(size),
gzipSizeLabel,
sizeDiff,
gzipDiff,
isNew: showDiff && previousSize === undefined,
};
};

Expand All @@ -152,7 +245,7 @@ async function printFileSizes(
const assets = await getAssets();

if (assets.length === 0) {
return logs;
return { logs, currentSizes: {} };
}

logs.push('');
Expand Down Expand Up @@ -210,20 +303,34 @@ async function printFileSizes(
);

logs.push(
getHeader(maxFileLength, maxSizeLength, fileHeader, showGzipHeader),
getHeader(
maxFileLength,
maxSizeLength,
0,
fileHeader,
false,
showGzipHeader,
),
);

for (const asset of assets) {
let { sizeLabel } = asset;
const { name, folder, gzipSizeLabel } = asset;
const fileNameLength = (folder + path.sep + name).length;
const sizeLength = sizeLabel.length;
let { sizeLabel, gzipSizeLabel, sizeDiff, gzipDiff, isNew } = asset;
const { name, folder } = asset;

// Append inline diff to sizeLabel
if (isNew) {
sizeLabel += ` ${color.cyan('(NEW)')}`;
} else if (sizeDiff !== null && sizeDiff !== 0) {
sizeLabel += ` ${formatDiff(sizeDiff)}`;
}

if (sizeLength < maxSizeLength) {
const rightPadding = ' '.repeat(maxSizeLength - sizeLength);
sizeLabel += rightPadding;
// Append inline diff to gzipSizeLabel (only for existing files with changes)
if (gzipSizeLabel && !isNew && gzipDiff !== null && gzipDiff !== 0) {
gzipSizeLabel += ` ${formatDiff(gzipDiff)}`;
}

const fileNameLength = (folder + path.sep + name).length;

let fileNameLabel =
color.dim(asset.folder + path.sep) + coloringAssetName(asset.name);

Expand Down Expand Up @@ -283,7 +390,7 @@ async function printFileSizes(

logs.push('');

return logs;
return { logs, currentSizes };
}

export const pluginFileSize = (context: InternalContext): RsbuildPlugin => ({
Expand All @@ -297,6 +404,20 @@ export const pluginFileSize = (context: InternalContext): RsbuildPlugin => ({
return;
}

// Check if any environment has showDiff enabled
const hasShowDiff = Object.values(environments).some((environment) => {
const { printFileSize } = environment.config.performance;
if (printFileSize === false) return false;
if (printFileSize === true) return false; // uses default (false)
return printFileSize.showDiff === true;
});

// Load previous build sizes for comparison (only if showDiff is enabled)
const previousSizes = hasShowDiff
? await loadPreviousSizes(api.context.cachePath)
: {};
const newCache: FileSizeCache = {};

const logs: string[] = [];

await Promise.all(
Expand All @@ -312,6 +433,8 @@ export const pluginFileSize = (context: InternalContext): RsbuildPlugin => ({
detail: true,
// print compressed size for the browser targets by default
compressed: environment.config.output.target !== 'node',
// disable diff by default to avoid breaking existing output expectations
showDiff: false,
};

const mergedConfig =
Expand All @@ -323,22 +446,31 @@ export const pluginFileSize = (context: InternalContext): RsbuildPlugin => ({
};

const statsItem = 'stats' in stats ? stats.stats[index] : stats;
const statsLogs = await printFileSizes(
const { logs: statsLogs, currentSizes } = await printFileSizes(
mergedConfig,
statsItem,
api.context.rootPath,
environment.distPath,
environment.name,
previousSizes,
);

logs.push(...statsLogs);

// Store current sizes for this environment
newCache[environment.name] = currentSizes;
}),
).catch((err: unknown) => {
logger.warn('Failed to print file size.');
logger.warn(err);
});

logger.log(logs.join('\n'));

// Save current sizes for next build comparison (only if showDiff is enabled)
if (hasShowDiff) {
await saveSizes(api.context.cachePath, newCache);
}
});
},
});
5 changes: 5 additions & 0 deletions packages/core/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,11 @@ export type PrintFileSizeOptions = {
* @default (asset) => /\.(?:map|LICENSE\.txt)$/.test(asset.name)
*/
exclude?: (asset: PrintFileSizeAsset) => boolean;
/**
* Whether to show file size difference compared to the previous build.
* @default false
*/
showDiff?: boolean;
};

export interface PreconnectOption {
Expand Down
74 changes: 73 additions & 1 deletion packages/core/tests/fileSize.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { excludeAsset } from '../src/plugins/fileSize';
import { excludeAsset, normalizeFileName } from '../src/plugins/fileSize';

describe('plugin-file-size', () => {
it('#excludeAsset - should exclude asset correctly', () => {
Expand All @@ -13,5 +13,77 @@ describe('plugin-file-size', () => {
excludeAsset({ name: 'dist/b.css.LICENSE.txt', size: 1000 }),
).toBeTruthy();
expect(excludeAsset({ name: 'dist/a.png', size: 1000 })).toBeFalsy();
expect(excludeAsset({ name: 'dist/a.d.ts', size: 1000 })).toBeTruthy();
});

describe('#normalizeFileName', () => {
it('should remove 8-character hash from filename', () => {
expect(normalizeFileName('index.a1b2c3d4.js')).toBe('index.js');
expect(normalizeFileName('styles.12345678.css')).toBe('styles.css');
});

it('should remove longer hash patterns (16+ characters)', () => {
// Valid hex digits only (a-f, 0-9)
expect(normalizeFileName('main.1234567890abcdef.js')).toBe('main.js');
expect(normalizeFileName('bundle.abc123def456.js')).toBe('bundle.js');
});

it('should handle adjacent hashes (overlapping dots)', () => {
// Note: Due to overlapping match (shared dot), only first hash is removed
// This is fine - real build tools don't generate filenames like this
expect(normalizeFileName('chunk.abc12345.def67890.js')).toBe(
'chunk.def67890.js',
);

// Non-overlapping hashes work correctly
expect(normalizeFileName('chunk.abc12345.min.def67890.js')).toBe(
'chunk.min.js',
);
});

it('should not remove non-hex sequences', () => {
// Contains 'g' and 'h' which are not hex digits
expect(normalizeFileName('bundle.a1b2c3d4e5f6g7h8.js')).toBe(
'bundle.a1b2c3d4e5f6g7h8.js',
);
expect(normalizeFileName('file.xyz12345.js')).toBe('file.xyz12345.js');
});

it('should preserve filename without hash', () => {
expect(normalizeFileName('icon.png')).toBe('icon.png');
expect(normalizeFileName('index.html')).toBe('index.html');
expect(normalizeFileName('app.js')).toBe('app.js');
});

it('should handle filenames with path separators', () => {
expect(normalizeFileName('static/js/index.a1b2c3d4.js')).toBe(
'static/js/index.js',
);
expect(normalizeFileName('dist/css/main.12345678.css')).toBe(
'dist/css/main.css',
);
});

it('should not remove short sequences that look like hashes', () => {
// Less than 8 characters should not be removed
expect(normalizeFileName('file.abc123.js')).toBe('file.abc123.js');
expect(normalizeFileName('test.1234567.css')).toBe('test.1234567.css');
});

it('should handle uppercase hex digits', () => {
expect(normalizeFileName('bundle.A1B2C3D4.js')).toBe(
'bundle.A1B2C3D4.js',
);
// Only lowercase a-f are matched by the regex
Comment on lines +74 to +77
Copy link

Copilot AI Nov 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies that uppercase hex digits are NOT removed, but there's no test verifying that lowercase hex digits ARE removed in the same scenario. Add a test case with normalizeFileName('bundle.a1b2c3d4.js') to ensure the lowercase version is correctly normalized to 'bundle.js'.

Copilot uses AI. Check for mistakes.
});

it('should handle edge cases', () => {
// Hash at the beginning (unlikely but possible)
expect(normalizeFileName('12345678.main.js')).toBe('12345678.main.js');
// Multiple extensions
expect(normalizeFileName('app.min.a1b2c3d4.js')).toBe('app.min.js');
// No extension
expect(normalizeFileName('LICENSE')).toBe('LICENSE');
});
});
});
Loading