diff --git a/BUILD_TIME.md b/BUILD_TIME.md deleted file mode 100644 index 6094141..0000000 --- a/BUILD_TIME.md +++ /dev/null @@ -1,55 +0,0 @@ -# Build Time (Compilation Time) - -Requires `agoda-devfeedback` version 1.0.0 or later. - -## Usage - -### Basic usage - -#### Webpack - -If you use **Webpack**, you can add the following to your `webpack.config.js` file: - -```javascript -const { WebpackBuildStatsPlugin } = require('agoda-devfeedback'); - -module.exports = { - // ... - plugins: [ - // ... - new WebpackBuildStatsPlugin(), - ], -}; -``` - -#### Vite - -If you use **Vite** you can add the following to your `vite.config.js` file: - -```javascript -import { viteBuildStatsPlugin } from 'agoda-devfeedback'; - -export default defineConfig({ - // ... - plugins: [ - // ... - viteBuildStatsPlugin(), - ], -}); -``` - -### Advanced usage - -Both Webpack and Vite plugins will not only send the build data but also send the command that you used to run the build like `yarn dev` or `yarn build` to be the build identifier which should work in most cases in order to help you distinguish between different build configurations. - -However, if you would like to define your own identifier, you can do so by passing it as a parameter to the plugin. - -```javascript -new WebpackBuildStatsPlugin('production'); -``` - -or - -```javascript -viteBuildStatsPlugin('production'); -``` diff --git a/README.md b/README.md index bebdedf..700fef9 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,68 @@ You might need to add this file to `.gitignore` to avoid committing it to the re ### Build Time (Compilation Time) This package supports collecting the build time (compilation time) of projects that are using Webpack (4.x or 5.x) or Vite (4.x). -Follow this [instruction](BUILD_TIME.md) to get started. + +### Bundle Size + +This package also supports collecting the bundle size of your project. This helps in understanding the size of the final output and optimizing it for better performance. + +## Usage + +### Configuration + +You can define an endpoint in the environment variable and the stats data will be sent there via HTTP POST Request + +| Environment Variable | Default Value | +| -------------------- | ------------------------------------ | +| WEBPACK_ENDPOINT | | +| VITE_ENDPOINT | | + +### Basic usage + +#### Webpack + +If you use **Webpack**, you can add the following to your `webpack.config.js` file: + +```javascript +const { WebpackBuildStatsPlugin } = require('agoda-devfeedback'); + +module.exports = { + // ... + plugins: [ + // ... + new WebpackBuildStatsPlugin(), + ], +}; +``` + +#### Vite + +If you use **Vite** you can add the following to your `vite.config.js` file: + +```javascript +import { viteBuildStatsPlugin } from 'agoda-devfeedback'; + +export default defineConfig({ + // ... + plugins: [ + // ... + viteBuildStatsPlugin(), + ], +}); +``` + +### Advanced usage + +Both Webpack and Vite plugins will not only send the build data but also send the command that you used to run the build like `yarn dev` or `yarn build` to be the build identifier which should work in most cases in order to help you distinguish between different build configurations. + +However, if you would like to define your own identifier, you can do so by passing it as a parameter to the plugin. + +```javascript +new WebpackBuildStatsPlugin('production'); +``` + +or + +```javascript +viteBuildStatsPlugin('production'); +``` diff --git a/src/WebpackBuildStatsPlugin.ts b/src/WebpackBuildStatsPlugin.ts index 9cb6502..7e848da 100644 --- a/src/WebpackBuildStatsPlugin.ts +++ b/src/WebpackBuildStatsPlugin.ts @@ -4,11 +4,26 @@ import type { Compiler, Stats, StatsCompilation } from 'webpack'; export class WebpackBuildStatsPlugin { private readonly customIdentifier: string | undefined; + private bundleFiles: Record = {}; + constructor(customIdentifier: string | undefined = process.env.npm_lifecycle_event) { this.customIdentifier = customIdentifier; } apply(compiler: Compiler) { + compiler.hooks.emit.tapAsync('AgodaBuildStatsPlugin', (compilation, callback) => { + this.bundleFiles = {}; + + for (const assetName in compilation.assets) { + if (compilation.assets.hasOwnProperty(assetName)) { + const asset = compilation.assets[assetName]; + this.bundleFiles[assetName] = asset.size(); + } + } + + callback(); + }); + compiler.hooks.done.tap('AgodaBuildStatsPlugin', async (stats: Stats) => { const jsonStats: StatsCompilation = stats.toJson(); @@ -19,6 +34,8 @@ export class WebpackBuildStatsPlugin { webpackVersion: jsonStats.version ?? null, nbrOfCachedModules: jsonStats.modules?.filter((m) => m.cached).length ?? 0, nbrOfRebuiltModules: jsonStats.modules?.filter((m) => m.built).length ?? 0, + bundleFiles: this.bundleFiles ?? {}, + bundleSize: Object.values(this.bundleFiles).reduce((total, size) => total + size, 0) ?? 0, }; sendBuildData(buildStats); diff --git a/src/common.ts b/src/common.ts index 8b3ef26..2dd9c65 100644 --- a/src/common.ts +++ b/src/common.ts @@ -56,11 +56,23 @@ export const getCommonMetadata = ( }; }; -const getEndpointFromType = (type: string) => { - return { - webpack: process.env.WEBPACK_ENDPOINT, - vite: process.env.VITE_ENDPOINT, - }[type]; +const getEndpointFromType = (type: WebpackBuildData['type'] | ViteBuildData['type']): string => { + const endpointsFromEnv: Record = { + webpack: process.env.WEBPACK_ENDPOINT ?? '', + vite: process.env.VITE_ENDPOINT ?? '', + }; + + const defaultEndpoints: Record = { + webpack: "http://compilation-metrics/webpack", + vite: "http://compilation-metrics/vite", + } + + if (endpointsFromEnv[type] === '') { + console.warn(`No endpoint found for type "${type}" from environment variable, using default: ${defaultEndpoints[type]}`); + return defaultEndpoints[type]; + } + + return endpointsFromEnv[type]; }; const LOG_FILE = 'devfeedback.log'; @@ -77,11 +89,6 @@ const sendData = async (endpoint: string, data: CommonMetadata): Promise { const endpoint = getEndpointFromType(buildStats.type); - if (!endpoint) { - console.log(`No endpoint found for type ${buildStats.type}. Please set the environment variable.`); - return; - } - console.log(`Your build time was ${buildStats.timeTaken.toFixed(2)}ms.`); const sent = await sendData(endpoint, buildStats); diff --git a/src/types.ts b/src/types.ts index 1ea73af..3525b55 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,9 +27,13 @@ export interface WebpackBuildData extends CommonMetadata { compilationHash: string | null; nbrOfCachedModules: number; nbrOfRebuiltModules: number; + bundleFiles: Record; + bundleSize: number; } export interface ViteBuildData extends CommonMetadata { type: 'vite'; viteVersion: string | null; + bundleFiles: Record; + bundleSize: number; } diff --git a/src/viteBuildStatsPlugin.ts b/src/viteBuildStatsPlugin.ts index 67b73d0..98077a1 100644 --- a/src/viteBuildStatsPlugin.ts +++ b/src/viteBuildStatsPlugin.ts @@ -1,6 +1,8 @@ import type { ViteBuildData } from './types'; import { type Plugin } from 'vite'; import { getCommonMetadata, sendBuildData } from './common'; +import { promises as fs } from 'fs'; +import path from 'path'; export function viteBuildStatsPlugin( customIdentifier: string | undefined = process.env.npm_lifecycle_event, @@ -8,6 +10,7 @@ export function viteBuildStatsPlugin( let buildStart: number; let buildEnd: number; let rollupVersion: string | undefined = undefined; + let bundleFiles: Record = {}; return { name: 'vite-plugin-agoda-build-reporter', @@ -18,11 +21,24 @@ export function viteBuildStatsPlugin( buildEnd: function () { buildEnd = Date.now(); }, + writeBundle: async function (options, bundle) { + for (const [fileName, assetInfo] of Object.entries(bundle)) { + const filePath = path.join(options.dir || '', fileName); + try { + const stats = await fs.stat(filePath); + bundleFiles[fileName] = stats.size; + } catch (err) { + console.error(`Error reading file size for ${fileName}:`, err); + } + } + }, closeBundle: async function () { const buildStats: ViteBuildData = { ...getCommonMetadata(buildEnd - buildStart, customIdentifier), type: 'vite', viteVersion: rollupVersion ?? null, + bundleFiles, + bundleSize: Object.values(bundleFiles).reduce((total, size) => total + size, 0), }; sendBuildData(buildStats); diff --git a/tests/WebpackBuildStatsPlugin.spec.ts b/tests/WebpackBuildStatsPlugin.spec.ts index 6e3b2a7..84a0ed1 100644 --- a/tests/WebpackBuildStatsPlugin.spec.ts +++ b/tests/WebpackBuildStatsPlugin.spec.ts @@ -15,6 +15,9 @@ const mockedSendBuildData = sendBuildData as jest.MockedFunction { compilationHash: 'blahblahblacksheep', nbrOfCachedModules: 1, nbrOfRebuiltModules: 1, - } as WebpackBuildData; + bundleFiles: { + 'file1.js': 1000, + 'file2.js': 2000, + }, + bundleSize: 3000, + } as unknown as WebpackBuildData; beforeEach(() => { jest.resetAllMocks(); @@ -52,9 +60,22 @@ describe('WebpackBuildStatsPlugin', () => { }), }; + // mock compilation + const mockedCompilation = { + assets: { + 'file1.js': { size: () => 1000 }, + 'file2.js': { size: () => 2000 }, + }, + }; + const plugin = new WebpackBuildStatsPlugin('my custom identifier'); plugin.apply(mockedCompiler as unknown as Compiler); + // simulate emit hook + const emitCallback = mockedCompiler.hooks.emit.tapAsync.mock.calls[0][1]; + await emitCallback(mockedCompilation, () => { }); + + // simulate done hook const callback = mockedCompiler.hooks.done.tap.mock.calls[0][1]; await callback(mockedStats as unknown as import('webpack').Stats); @@ -76,6 +97,14 @@ describe('WebpackBuildStatsPlugin', () => { }), }; + // mock compilation + const mockedCompilation = { + assets: { + 'file1.js': { size: () => 1000 }, + 'file2.js': { size: () => 2000 }, + }, + }; + // mock process object global.process = { env: { @@ -86,9 +115,22 @@ describe('WebpackBuildStatsPlugin', () => { const plugin = new WebpackBuildStatsPlugin(); plugin.apply(mockedCompiler as unknown as Compiler); + // simulate emit hook + const emitCallback = mockedCompiler.hooks.emit.tapAsync.mock.calls[0][1]; + await emitCallback(mockedCompilation, () => { }); + + // simulate done hook const callback = mockedCompiler.hooks.done.tap.mock.calls[0][1]; await callback(mockedStats as unknown as import('webpack').Stats); expect(mockedGetCommonMetadata).toBeCalledWith(123, 'default_value'); + expect(mockedSendBuildData).toBeCalledWith(expect.objectContaining({ + ...expected, + bundleFiles: { + 'file1.js': 1000, + 'file2.js': 2000, + }, + bundleSize: 3000, + })); }); }); diff --git a/tests/viteBuildStatsPlugin.spec.ts b/tests/viteBuildStatsPlugin.spec.ts index c7b1933..14c8f16 100644 --- a/tests/viteBuildStatsPlugin.spec.ts +++ b/tests/viteBuildStatsPlugin.spec.ts @@ -1,22 +1,36 @@ import type { CommonMetadata, ViteBuildData } from '../src/types'; import { viteBuildStatsPlugin } from '../src/viteBuildStatsPlugin'; import { getCommonMetadata, sendBuildData } from '../src/common'; +import { BigIntStats, PathLike, Stats, promises as fs } from 'fs'; +import path from 'path'; jest.mock('../src/common', () => ({ getCommonMetadata: jest.fn(), sendBuildData: jest.fn(), })); +jest.mock('fs', () => ({ + promises: { + stat: jest.fn(), + }, +})); + const mockedGetCommonMetadata = getCommonMetadata as jest.MockedFunction< typeof getCommonMetadata >; const mockedSendBuildData = sendBuildData as jest.MockedFunction; +const mockedFsStat = fs.stat as jest.MockedFunction; describe('viteBuildStatsPlugin', () => { const expected: ViteBuildData = { type: 'vite', viteVersion: '1.2.3', - } as ViteBuildData; + bundleFiles: { + 'file1.js': 1000, + 'file2.js': 2000, + }, + bundleSize: 3000, + } as unknown as ViteBuildData; beforeEach(() => { jest.resetAllMocks(); @@ -32,9 +46,21 @@ describe('viteBuildStatsPlugin', () => { mockedGetCommonMetadata.mockReturnValue({} as CommonMetadata); mockedSendBuildData.mockReturnValue(Promise.resolve()); + // mock fs.stat + mockedFsStat.mockImplementation((path: PathLike) => { + if (path.toString().endsWith('file1.js')) { + return Promise.resolve({ size: 1000 } as Stats); + } else if (path.toString().endsWith('file2.js')) { + return Promise.resolve({ size: 2000 } as Stats); + } else { + return Promise.reject(new Error('File not found')); + } + }); + const plugin = viteBuildStatsPlugin('my custom identifier'); (plugin.buildStart as () => void).bind({ meta: { rollupVersion: '1.2.3' } })(); (plugin.buildEnd as () => void)(); + await (plugin.writeBundle as any)({ dir: 'dist' }, { 'file1.js': {}, 'file2.js': {} }); await (plugin.closeBundle as () => Promise)(); expect(mockedGetCommonMetadata).toBeCalledWith(100, 'my custom identifier'); @@ -58,9 +84,21 @@ describe('viteBuildStatsPlugin', () => { }, } as unknown as typeof process; + // mock fs.stat + mockedFsStat.mockImplementation((path: PathLike) => { + if (path.toString().endsWith('file1.js')) { + return Promise.resolve({ size: 1000 } as Stats); + } else if (path.toString().endsWith('file2.js')) { + return Promise.resolve({ size: 2000 } as Stats); + } else { + return Promise.reject(new Error('File not found')); + } + }); + const plugin = viteBuildStatsPlugin(); (plugin.buildStart as () => void).bind({ meta: { rollupVersion: '1.2.3' } })(); (plugin.buildEnd as () => void)(); + await (plugin.writeBundle as any)({ dir: 'dist' }, { 'file1.js': {}, 'file2.js': {} }); await (plugin.closeBundle as () => Promise)(); expect(mockedGetCommonMetadata).toBeCalledWith(100, 'default_value');