Skip to content

Commit 21e8168

Browse files
authored
Add --analyze flag to enable bundle analyzer to CLI (#3075)
1 parent 9898fcd commit 21e8168

File tree

19 files changed

+662
-38
lines changed

19 files changed

+662
-38
lines changed

packages/snaps-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"util": "^0.12.5",
113113
"vm-browserify": "^1.1.2",
114114
"webpack": "^5.88.0",
115+
"webpack-bundle-analyzer": "^4.10.2",
115116
"webpack-merge": "^5.9.0",
116117
"yargs": "^17.7.1"
117118
},

packages/snaps-cli/src/builders.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ export enum TranspilationModes {
66
None = 'none',
77
}
88

9-
const builders: Record<string, Readonly<Options>> = {
10-
// eslint-disable-next-line @typescript-eslint/naming-convention
9+
const builders = {
10+
analyze: {
11+
describe: 'Analyze the Snap bundle',
12+
type: 'boolean',
13+
},
14+
1115
config: {
1216
alias: 'c',
1317
describe: 'Path to config file',
@@ -146,6 +150,6 @@ const builders: Record<string, Readonly<Options>> = {
146150
type: 'boolean',
147151
deprecated: true,
148152
},
149-
};
153+
} as const satisfies Record<string, Readonly<Options>>;
150154

151155
export default builders;

packages/snaps-cli/src/commands/build/build.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { DEFAULT_SNAP_BUNDLE } from '@metamask/snaps-utils/test-utils';
22
import fs from 'fs';
3+
import type { Compiler } from 'webpack';
4+
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
35

46
import { getMockConfig } from '../../test-utils';
57
import { evaluate } from '../eval';
@@ -10,6 +12,10 @@ jest.mock('fs');
1012
jest.mock('../eval');
1113
jest.mock('./implementation');
1214

15+
jest.mock('webpack-bundle-analyzer', () => ({
16+
BundleAnalyzerPlugin: jest.fn(),
17+
}));
18+
1319
describe('buildHandler', () => {
1420
it('builds a snap', async () => {
1521
await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE);
@@ -27,6 +33,7 @@ describe('buildHandler', () => {
2733

2834
expect(process.exitCode).not.toBe(1);
2935
expect(build).toHaveBeenCalledWith(config, {
36+
analyze: false,
3037
evaluate: false,
3138
spinner: expect.any(Object),
3239
});
@@ -36,7 +43,54 @@ describe('buildHandler', () => {
3643
);
3744
});
3845

39-
it('does note evaluate if the evaluate option is set to false', async () => {
46+
it('analyzes a snap bundle', async () => {
47+
await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE);
48+
49+
jest.spyOn(console, 'log').mockImplementation();
50+
const config = getMockConfig('webpack', {
51+
input: '/input.js',
52+
output: {
53+
path: '/foo',
54+
filename: 'output.js',
55+
},
56+
});
57+
58+
const compiler: Compiler = {
59+
// @ts-expect-error: Mock `Compiler` object.
60+
options: {
61+
plugins: [new BundleAnalyzerPlugin()],
62+
},
63+
};
64+
65+
const plugin = jest.mocked(BundleAnalyzerPlugin);
66+
const instance = plugin.mock.instances[0];
67+
68+
// @ts-expect-error: Partial `server` mock.
69+
instance.server = Promise.resolve({
70+
http: {
71+
address: () => 'http://localhost:8888',
72+
},
73+
});
74+
75+
jest.mocked(build).mockResolvedValueOnce(compiler);
76+
77+
await buildHandler(config, true);
78+
79+
expect(process.exitCode).not.toBe(1);
80+
expect(build).toHaveBeenCalledWith(config, {
81+
analyze: true,
82+
evaluate: false,
83+
spinner: expect.any(Object),
84+
});
85+
86+
expect(console.log).toHaveBeenCalledWith(
87+
expect.stringContaining(
88+
'Bundle analyzer running at http://localhost:8888.',
89+
),
90+
);
91+
});
92+
93+
it('does not evaluate if the evaluate option is set to false', async () => {
4094
await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE);
4195

4296
jest.spyOn(console, 'log').mockImplementation();

packages/snaps-cli/src/commands/build/build.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { isFile } from '@metamask/snaps-utils/node';
2+
import { assert } from '@metamask/utils';
23
import { resolve as pathResolve } from 'path';
34

45
import type { ProcessedConfig, ProcessedWebpackConfig } from '../../config';
56
import { CommandError } from '../../errors';
67
import type { Steps } from '../../utils';
7-
import { executeSteps, info } from '../../utils';
8+
import { success, executeSteps, info } from '../../utils';
89
import { evaluate } from '../eval';
910
import { build } from './implementation';
11+
import { getBundleAnalyzerPort } from './utils';
1012

1113
type BuildContext = {
14+
analyze: boolean;
1215
config: ProcessedWebpackConfig;
16+
port?: number;
1317
};
1418

1519
const steps: Steps<BuildContext> = [
@@ -27,10 +31,25 @@ const steps: Steps<BuildContext> = [
2731
},
2832
{
2933
name: 'Building the snap bundle.',
30-
task: async ({ config, spinner }) => {
34+
task: async ({ analyze, config, spinner }) => {
3135
// We don't evaluate the bundle here, because it's done in a separate
3236
// step.
33-
return await build(config, { evaluate: false, spinner });
37+
const compiler = await build(config, {
38+
analyze,
39+
evaluate: false,
40+
spinner,
41+
});
42+
43+
if (analyze) {
44+
return {
45+
analyze,
46+
config,
47+
spinner,
48+
port: await getBundleAnalyzerPort(compiler),
49+
};
50+
}
51+
52+
return undefined;
3453
},
3554
},
3655
{
@@ -48,6 +67,16 @@ const steps: Steps<BuildContext> = [
4867
info(`Snap bundle evaluated successfully.`, spinner);
4968
},
5069
},
70+
{
71+
name: 'Running analyser.',
72+
condition: ({ analyze }) => analyze,
73+
task: async ({ spinner, port }) => {
74+
assert(port, 'Port is not defined.');
75+
success(`Bundle analyzer running at http://localhost:${port}.`, spinner);
76+
77+
spinner.stop();
78+
},
79+
},
5180
] as const;
5281

5382
/**
@@ -57,10 +86,15 @@ const steps: Steps<BuildContext> = [
5786
* This creates the destination directory if it doesn't exist.
5887
*
5988
* @param config - The config object.
89+
* @param analyze - Whether to analyze the bundle.
6090
* @returns Nothing.
6191
*/
62-
export async function buildHandler(config: ProcessedConfig): Promise<void> {
92+
export async function buildHandler(
93+
config: ProcessedConfig,
94+
analyze = false,
95+
): Promise<void> {
6396
return await executeSteps(steps, {
6497
config,
98+
analyze,
6599
});
66100
}

packages/snaps-cli/src/commands/build/implementation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Compiler } from 'webpack';
2+
13
import type { ProcessedWebpackConfig } from '../../config';
24
import type { WebpackOptions } from '../../webpack';
35
import { getCompiler } from '../../webpack';
@@ -14,7 +16,7 @@ export async function build(
1416
options?: WebpackOptions,
1517
) {
1618
const compiler = await getCompiler(config, options);
17-
return await new Promise<void>((resolve, reject) => {
19+
return await new Promise<Compiler>((resolve, reject) => {
1820
compiler.run((runError) => {
1921
if (runError) {
2022
reject(runError);
@@ -27,7 +29,7 @@ export async function build(
2729
return;
2830
}
2931

30-
resolve();
32+
resolve(compiler);
3133
});
3234
});
3335
});

packages/snaps-cli/src/commands/build/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ describe('build command', () => {
1010
const config = getMockConfig('webpack');
1111

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

15-
expect(buildHandler).toHaveBeenCalledWith(config);
15+
expect(buildHandler).toHaveBeenCalledWith(config, false);
1616
});
1717
});

packages/snaps-cli/src/commands/build/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const command = {
99
desc: 'Build snap from source',
1010
builder: (yarg: yargs.Argv) => {
1111
yarg
12+
.option('analyze', builders.analyze)
1213
.option('dist', builders.dist)
1314
.option('eval', builders.eval)
1415
.option('manifest', builders.manifest)
@@ -22,7 +23,8 @@ const command = {
2223
.implies('writeManifest', 'manifest')
2324
.implies('depsToTranspile', 'transpilationMode');
2425
},
25-
handler: async (argv: YargsArgs) => buildHandler(argv.context.config),
26+
handler: async (argv: YargsArgs) =>
27+
buildHandler(argv.context.config, argv.analyze),
2628
};
2729

2830
export * from './implementation';
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Compiler } from 'webpack';
2+
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
3+
4+
import { getBundleAnalyzerPort } from './utils';
5+
6+
jest.mock('webpack-bundle-analyzer', () => ({
7+
BundleAnalyzerPlugin: jest.fn(),
8+
}));
9+
10+
describe('getBundleAnalyzerPort', () => {
11+
it('returns the port of the bundle analyzer server', async () => {
12+
const compiler: Compiler = {
13+
// @ts-expect-error: Mock `Compiler` object.
14+
options: {
15+
plugins: [new BundleAnalyzerPlugin()],
16+
},
17+
};
18+
19+
const plugin = jest.mocked(BundleAnalyzerPlugin);
20+
const instance = plugin.mock.instances[0];
21+
22+
// @ts-expect-error: Partial `server` mock.
23+
instance.server = Promise.resolve({
24+
http: {
25+
address: () => 'http://localhost:8888',
26+
},
27+
});
28+
29+
const port = await getBundleAnalyzerPort(compiler);
30+
expect(port).toBe(8888);
31+
});
32+
33+
it('returns the port of the bundle analyzer server that returns an object', async () => {
34+
const compiler: Compiler = {
35+
// @ts-expect-error: Mock `Compiler` object.
36+
options: {
37+
plugins: [new BundleAnalyzerPlugin()],
38+
},
39+
};
40+
41+
const plugin = jest.mocked(BundleAnalyzerPlugin);
42+
const instance = plugin.mock.instances[0];
43+
44+
// @ts-expect-error: Partial `server` mock.
45+
instance.server = Promise.resolve({
46+
http: {
47+
address: () => {
48+
return {
49+
port: 8888,
50+
};
51+
},
52+
},
53+
});
54+
55+
const port = await getBundleAnalyzerPort(compiler);
56+
expect(port).toBe(8888);
57+
});
58+
59+
it('returns undefined if the bundle analyzer server is not available', async () => {
60+
const compiler: Compiler = {
61+
// @ts-expect-error: Mock `Compiler` object.
62+
options: {
63+
plugins: [new BundleAnalyzerPlugin()],
64+
},
65+
};
66+
67+
const port = await getBundleAnalyzerPort(compiler);
68+
expect(port).toBeUndefined();
69+
});
70+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Compiler } from 'webpack';
2+
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
3+
4+
/**
5+
* Get the port of the bundle analyzer server.
6+
*
7+
* @param compiler - The Webpack compiler.
8+
* @returns The port of the bundle analyzer server.
9+
*/
10+
export async function getBundleAnalyzerPort(compiler: Compiler) {
11+
const analyzerPlugin = compiler.options.plugins.find(
12+
(plugin): plugin is BundleAnalyzerPlugin =>
13+
plugin instanceof BundleAnalyzerPlugin,
14+
);
15+
16+
if (analyzerPlugin?.server) {
17+
const { http } = await analyzerPlugin.server;
18+
19+
const address = http.address();
20+
if (typeof address === 'string') {
21+
const { port } = new URL(address);
22+
return parseInt(port, 10);
23+
}
24+
25+
return address?.port;
26+
}
27+
28+
return undefined;
29+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
declare module 'webpack-bundle-analyzer' {
2+
import type { Server } from 'http';
3+
import type { Compiler, WebpackPluginInstance } from 'webpack';
4+
5+
export type BundleAnalyzerPluginOptions = {
6+
analyzerPort?: number | undefined;
7+
logLevel?: 'info' | 'warn' | 'error' | 'silent' | undefined;
8+
openAnalyzer?: boolean | undefined;
9+
};
10+
11+
export class BundleAnalyzerPlugin implements WebpackPluginInstance {
12+
readonly opts: BundleAnalyzerPluginOptions;
13+
14+
server?: Promise<{
15+
http: Server;
16+
}>;
17+
18+
constructor(options?: BundleAnalyzerPluginOptions);
19+
20+
apply(compiler: Compiler): void;
21+
}
22+
}

0 commit comments

Comments
 (0)