Skip to content

Commit

Permalink
Add --analyze flag to enable bundle analyzer to CLI (#3075)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mrtenz authored Feb 7, 2025
1 parent 9898fcd commit 21e8168
Show file tree
Hide file tree
Showing 19 changed files with 662 additions and 38 deletions.
1 change: 1 addition & 0 deletions packages/snaps-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"util": "^0.12.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.88.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-merge": "^5.9.0",
"yargs": "^17.7.1"
},
Expand Down
10 changes: 7 additions & 3 deletions packages/snaps-cli/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ export enum TranspilationModes {
None = 'none',
}

const builders: Record<string, Readonly<Options>> = {
// eslint-disable-next-line @typescript-eslint/naming-convention
const builders = {
analyze: {
describe: 'Analyze the Snap bundle',
type: 'boolean',
},

config: {
alias: 'c',
describe: 'Path to config file',
Expand Down Expand Up @@ -146,6 +150,6 @@ const builders: Record<string, Readonly<Options>> = {
type: 'boolean',
deprecated: true,
},
};
} as const satisfies Record<string, Readonly<Options>>;

export default builders;
56 changes: 55 additions & 1 deletion packages/snaps-cli/src/commands/build/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { DEFAULT_SNAP_BUNDLE } from '@metamask/snaps-utils/test-utils';
import fs from 'fs';
import type { Compiler } from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';

import { getMockConfig } from '../../test-utils';
import { evaluate } from '../eval';
Expand All @@ -10,6 +12,10 @@ jest.mock('fs');
jest.mock('../eval');
jest.mock('./implementation');

jest.mock('webpack-bundle-analyzer', () => ({
BundleAnalyzerPlugin: jest.fn(),
}));

describe('buildHandler', () => {
it('builds a snap', async () => {
await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE);
Expand All @@ -27,6 +33,7 @@ describe('buildHandler', () => {

expect(process.exitCode).not.toBe(1);
expect(build).toHaveBeenCalledWith(config, {
analyze: false,
evaluate: false,
spinner: expect.any(Object),
});
Expand All @@ -36,7 +43,54 @@ describe('buildHandler', () => {
);
});

it('does note evaluate if the evaluate option is set to false', async () => {
it('analyzes a snap bundle', async () => {
await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE);

jest.spyOn(console, 'log').mockImplementation();
const config = getMockConfig('webpack', {
input: '/input.js',
output: {
path: '/foo',
filename: 'output.js',
},
});

const compiler: Compiler = {
// @ts-expect-error: Mock `Compiler` object.
options: {
plugins: [new BundleAnalyzerPlugin()],
},
};

const plugin = jest.mocked(BundleAnalyzerPlugin);
const instance = plugin.mock.instances[0];

// @ts-expect-error: Partial `server` mock.
instance.server = Promise.resolve({
http: {
address: () => 'http://localhost:8888',
},
});

jest.mocked(build).mockResolvedValueOnce(compiler);

await buildHandler(config, true);

expect(process.exitCode).not.toBe(1);
expect(build).toHaveBeenCalledWith(config, {
analyze: true,
evaluate: false,
spinner: expect.any(Object),
});

expect(console.log).toHaveBeenCalledWith(
expect.stringContaining(
'Bundle analyzer running at http://localhost:8888.',
),
);
});

it('does not evaluate if the evaluate option is set to false', async () => {
await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE);

jest.spyOn(console, 'log').mockImplementation();
Expand Down
42 changes: 38 additions & 4 deletions packages/snaps-cli/src/commands/build/build.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { isFile } from '@metamask/snaps-utils/node';
import { assert } from '@metamask/utils';
import { resolve as pathResolve } from 'path';

import type { ProcessedConfig, ProcessedWebpackConfig } from '../../config';
import { CommandError } from '../../errors';
import type { Steps } from '../../utils';
import { executeSteps, info } from '../../utils';
import { success, executeSteps, info } from '../../utils';
import { evaluate } from '../eval';
import { build } from './implementation';
import { getBundleAnalyzerPort } from './utils';

type BuildContext = {
analyze: boolean;
config: ProcessedWebpackConfig;
port?: number;
};

const steps: Steps<BuildContext> = [
Expand All @@ -27,10 +31,25 @@ const steps: Steps<BuildContext> = [
},
{
name: 'Building the snap bundle.',
task: async ({ config, spinner }) => {
task: async ({ analyze, config, spinner }) => {
// We don't evaluate the bundle here, because it's done in a separate
// step.
return await build(config, { evaluate: false, spinner });
const compiler = await build(config, {
analyze,
evaluate: false,
spinner,
});

if (analyze) {
return {
analyze,
config,
spinner,
port: await getBundleAnalyzerPort(compiler),
};
}

return undefined;
},
},
{
Expand All @@ -48,6 +67,16 @@ const steps: Steps<BuildContext> = [
info(`Snap bundle evaluated successfully.`, spinner);
},
},
{
name: 'Running analyser.',
condition: ({ analyze }) => analyze,
task: async ({ spinner, port }) => {
assert(port, 'Port is not defined.');
success(`Bundle analyzer running at http://localhost:${port}.`, spinner);

spinner.stop();
},
},
] as const;

/**
Expand All @@ -57,10 +86,15 @@ const steps: Steps<BuildContext> = [
* This creates the destination directory if it doesn't exist.
*
* @param config - The config object.
* @param analyze - Whether to analyze the bundle.
* @returns Nothing.
*/
export async function buildHandler(config: ProcessedConfig): Promise<void> {
export async function buildHandler(
config: ProcessedConfig,
analyze = false,
): Promise<void> {
return await executeSteps(steps, {
config,
analyze,
});
}
6 changes: 4 additions & 2 deletions packages/snaps-cli/src/commands/build/implementation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Compiler } from 'webpack';

import type { ProcessedWebpackConfig } from '../../config';
import type { WebpackOptions } from '../../webpack';
import { getCompiler } from '../../webpack';
Expand All @@ -14,7 +16,7 @@ export async function build(
options?: WebpackOptions,
) {
const compiler = await getCompiler(config, options);
return await new Promise<void>((resolve, reject) => {
return await new Promise<Compiler>((resolve, reject) => {
compiler.run((runError) => {
if (runError) {
reject(runError);
Expand All @@ -27,7 +29,7 @@ export async function build(
return;
}

resolve();
resolve(compiler);
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions packages/snaps-cli/src/commands/build/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ describe('build command', () => {
const config = getMockConfig('webpack');

// @ts-expect-error - Partial `YargsArgs` is fine for testing.
await command.handler({ context: { config } });
await command.handler({ analyze: false, context: { config } });

expect(buildHandler).toHaveBeenCalledWith(config);
expect(buildHandler).toHaveBeenCalledWith(config, false);
});
});
4 changes: 3 additions & 1 deletion packages/snaps-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const command = {
desc: 'Build snap from source',
builder: (yarg: yargs.Argv) => {
yarg
.option('analyze', builders.analyze)
.option('dist', builders.dist)
.option('eval', builders.eval)
.option('manifest', builders.manifest)
Expand All @@ -22,7 +23,8 @@ const command = {
.implies('writeManifest', 'manifest')
.implies('depsToTranspile', 'transpilationMode');
},
handler: async (argv: YargsArgs) => buildHandler(argv.context.config),
handler: async (argv: YargsArgs) =>
buildHandler(argv.context.config, argv.analyze),
};

export * from './implementation';
Expand Down
70 changes: 70 additions & 0 deletions packages/snaps-cli/src/commands/build/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Compiler } from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';

import { getBundleAnalyzerPort } from './utils';

jest.mock('webpack-bundle-analyzer', () => ({
BundleAnalyzerPlugin: jest.fn(),
}));

describe('getBundleAnalyzerPort', () => {
it('returns the port of the bundle analyzer server', async () => {
const compiler: Compiler = {
// @ts-expect-error: Mock `Compiler` object.
options: {
plugins: [new BundleAnalyzerPlugin()],
},
};

const plugin = jest.mocked(BundleAnalyzerPlugin);
const instance = plugin.mock.instances[0];

// @ts-expect-error: Partial `server` mock.
instance.server = Promise.resolve({
http: {
address: () => 'http://localhost:8888',
},
});

const port = await getBundleAnalyzerPort(compiler);
expect(port).toBe(8888);
});

it('returns the port of the bundle analyzer server that returns an object', async () => {
const compiler: Compiler = {
// @ts-expect-error: Mock `Compiler` object.
options: {
plugins: [new BundleAnalyzerPlugin()],
},
};

const plugin = jest.mocked(BundleAnalyzerPlugin);
const instance = plugin.mock.instances[0];

// @ts-expect-error: Partial `server` mock.
instance.server = Promise.resolve({
http: {
address: () => {
return {
port: 8888,
};
},
},
});

const port = await getBundleAnalyzerPort(compiler);
expect(port).toBe(8888);
});

it('returns undefined if the bundle analyzer server is not available', async () => {
const compiler: Compiler = {
// @ts-expect-error: Mock `Compiler` object.
options: {
plugins: [new BundleAnalyzerPlugin()],
},
};

const port = await getBundleAnalyzerPort(compiler);
expect(port).toBeUndefined();
});
});
29 changes: 29 additions & 0 deletions packages/snaps-cli/src/commands/build/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Compiler } from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';

/**
* Get the port of the bundle analyzer server.
*
* @param compiler - The Webpack compiler.
* @returns The port of the bundle analyzer server.
*/
export async function getBundleAnalyzerPort(compiler: Compiler) {
const analyzerPlugin = compiler.options.plugins.find(
(plugin): plugin is BundleAnalyzerPlugin =>
plugin instanceof BundleAnalyzerPlugin,
);

if (analyzerPlugin?.server) {
const { http } = await analyzerPlugin.server;

const address = http.address();
if (typeof address === 'string') {
const { port } = new URL(address);
return parseInt(port, 10);
}

return address?.port;
}

return undefined;
}
22 changes: 22 additions & 0 deletions packages/snaps-cli/src/types/webpack-bundle-analyzer.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
declare module 'webpack-bundle-analyzer' {
import type { Server } from 'http';
import type { Compiler, WebpackPluginInstance } from 'webpack';

export type BundleAnalyzerPluginOptions = {
analyzerPort?: number | undefined;
logLevel?: 'info' | 'warn' | 'error' | 'silent' | undefined;
openAnalyzer?: boolean | undefined;
};

export class BundleAnalyzerPlugin implements WebpackPluginInstance {
readonly opts: BundleAnalyzerPluginOptions;

server?: Promise<{
http: Server;
}>;

constructor(options?: BundleAnalyzerPluginOptions);

apply(compiler: Compiler): void;
}
}
1 change: 1 addition & 0 deletions packages/snaps-cli/src/types/yargs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type YargsArgs = {
config: ProcessedConfig;
};

analyze?: boolean;
fix?: boolean;
input?: string;

Expand Down
Loading

0 comments on commit 21e8168

Please sign in to comment.