Skip to content

Add --analyze flag to enable bundle analyzer to CLI #3075

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 7, 2025
Merged
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
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
Loading