Bundling Extensions
The first reason to bundle your Visual Studio Code extension is to make sure it works for everyone using VS Code on any platform. Only bundled extensions can be used in VS Code for Web environments like github.dev and vscode.dev. When VS Code is running in the browser, it can only load one file for your extension so the extension code needs to be bundled into one single web-friendly JavaScript file. This also applies to Notebook Output Renderers, where VS Code will also only load one file for your renderer extension.
In addition, extensions can quickly grow in size and complexity. They may be authored in multiple source files and depend on modules from npm. Decomposition and reuse are development best practices but they come at a cost when installing and running extensions. Loading 100 small files is much slower than loading one large file. That's why we recommend bundling. Bundling is the process of combining multiple small source files into a single file.
For JavaScript, different bundlers are available. Popular ones are rollup.js, Parcel, esbuild, and webpack.
Using esbuild
esbuild
is a fast JavaScript bundler that's simple to configure. To acquire esbuild, open the terminal and type:
npm i --save-dev esbuild
Run esbuild
You can run esbuild from the command line but to reduce repetition and enable problem reporting, it is helpful to use a build script, esbuild.js
:
const esbuild = require('esbuild');
const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');
async function main() {
const ctx = await esbuild.context({
entryPoints: ['src/extension.ts'],
bundle: true,
format: 'cjs',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'node',
outfile: 'dist/extension.js',
external: ['vscode'],
logLevel: 'silent',
plugins: [
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
],
});
if (watch) {
await ctx.watch();
} else {
await ctx.rebuild();
await ctx.dispose();
}
}
/**
* @type {import('esbuild').Plugin}
*/
const esbuildProblemMatcherPlugin = {
name: 'esbuild-problem-matcher',
setup(build) {
build.onStart(() => {
console.log('[watch] build started');
});
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`);
console.error(` ${location.file}:${location.line}:${location.column}:`);
});
console.log('[watch] build finished');
});
},
};
main().catch((e) => {
console.error(e);
process.exit(1);
});
The build script does the following:
- It creates a build context with esbuild. The context is configured to:
- Bundle the code in
src/extension.ts
into a single filedist/extension.js
. - Minify the code if the
--production
flag was passed. - Generate source maps unless the
--production
flag was passed. - Exclude the 'vscode' module from the bundle (since it's provided by the VS Code runtime).
- Bundle the code in
- Use the esbuildProblemMatcherPlugin plugin to report errors that prevented the bundler to complete. This plugin emits the errors in a format that is detected by the
esbuild
problem matcher with also needs to be installed as an extension. - If the
--watch
flag was passed, it starts watching the source files for changes and rebuilds the bundle whenever a change is detected.
esbuild can work directly with TypeScript files. However, esbuild simply strips off all type declarations without doing any type checks. Only syntax errors are reported and can cause esbuild to fail.
For that reason, we separatly run the TypeScript compiler (tsc
) to check the types, but without emmiting any code (flag --noEmit
).
The scripts
section in package.json
now looks like that
"scripts": {
"compile": "npm run check-types && node esbuild.js",
"check-types": "tsc --noEmit",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"vscode:prepublish": "npm run package",
"package": "npm run check-types && node esbuild.js --production"
}
npm-run-all
is a node module that runs scripts in parallel whose name match a given prefix. For us, it runs the watch:esbuild
and watch:tsc
scripts. You need to add npm-run-all
to the devDependencies
section in package.json
.
The compile
and watch
scripts are for development and they produce the bundle file with source maps. The package
script is used by the vscode:prepublish
script which is used by vsce
, the VS Code packaging and publishing tool, and run before publishing an extension. Passing the --production
flag to the esbuild script will cause it to compress the code and create a small bundle, but also makes debugging hard, so other flags are used during development. To run above scripts, open a terminal and type npm run watch
or select Tasks: Run Task from the Command Palette (Ctrl+Shift+P
).
If you configure .vscode/tasks.json
the following way, you will get a separate terminal for each watch task.
{
"version": "2.0.0",
"tasks": [
{
"label": "watch",
"dependsOn": ["npm: watch:tsc", "npm: watch:esbuild"],
"presentation": {
"reveal": "never"
},
"group": {
"kind": "build",
"isDefault": true
}
},
{
"type": "npm",
"script": "watch:esbuild",
"group": "build",
"problemMatcher": "$esbuild-watch",
"isBackground": true,
"label": "npm: watch:esbuild",
"presentation": {
"group": "watch",
"reveal": "never"
}
},
{
"type": "npm",
"script": "watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"label": "npm: watch:tsc",
"presentation": {
"group": "watch",
"reveal": "never"
}
}
]
}
This watch tasks depends on the extension connor4312.esbuild-problem-matchers
for problem matching that you need to install for the task to report problems in the problems view. This extension needs to be installed for the launch to complete.
To not forget that, add a .vscode/extensions.json
file to the workspace:
{
"recommendations": ["connor4312.esbuild-problem-matchers"]
}
Finally, you will want to update your .vscodeignore
file so that compiled files are included in the published extension. Check out the Publishing section for more details.
Jump down to the Tests section to continue reading.
Using webpack
Webpack is a development tool that's available from npm. To acquire webpack and its command line interface, open the terminal and type:
npm i --save-dev webpack webpack-cli
This will install webpack and update your extension's package.json
file to include webpack in the devDependencies
.
Webpack is a JavaScript bundler but many VS Code extensions are written in TypeScript and only compiled to JavaScript. If your extension is using TypeScript, you can use the loader ts-loader
, so that webpack can understand TypeScript. Use the following to install ts-loader
:
npm i --save-dev ts-loader
All files are available in the webpack-extension sample.
Configure webpack
With all tools installed, webpack can now be configured. By convention, a webpack.config.js
file contains the configuration to instruct webpack to bundle your extension. The sample configuration below is for VS Code extensions and should provide a good starting point:
//@ts-check
'use strict';
const path = require('path');
const webpack = require('webpack');
/**@type {import('webpack').Configuration}*/
const config = {
target: 'webworker', // vscode extensions run in webworker context for VS Code web 📖 -> https://webpack.js.org/configuration/target/#target
entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/
output: {
// the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/
path: path.resolve(__dirname, 'dist'),
filename: 'extension.js',
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '../[resource-path]',
},
devtool: 'source-map',
externals: {
vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
},
resolve: {
// support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
mainFields: ['browser', 'module', 'main'], // look for `browser` entry point in imported node modules
extensions: ['.ts', '.js'],
alias: {
// provides alternate implementation for node module and source files
},
fallback: {
// Webpack 5 no longer polyfills Node.js core modules automatically.
// see https://webpack.js.org/configuration/resolve/#resolvefallback
// for the list of Node.js core module polyfills.
},
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
},
],
},
],
},
};
module.exports = config;
The file is available as part of the webpack-extension sample. Webpack configuration files are normal JavaScript modules that must export a configuration object.
In the sample above, the following are defined:
- The
target
indicates which context your extension will run. We recommend usingwebworker
so that your extension will work both in VS Code for web and VS Code desktop versions. - The entry point webpack should use. This is similar to the
main
property inpackage.json
except that you provide webpack with a "source" entry point, usuallysrc/extension.ts
, and not an "output" entry point. The webpack bundler understands TypeScript, so a separate TypeScript compile step is redundant. - The
output
configuration tells webpack where to place the generated bundle file. By convention, that is thedist
folder. In this sample, webpack will produce adist/extension.js
file. - The
resolve
andmodule/rules
configurations are there to support TypeScript and JavaScript input files. - The
externals
configuration is used to declare exclusions, for example files and modules that should not be included in the bundle. Thevscode
module should not be bundled because it doesn't exist on disk but is created by VS Code on-the-fly when required. Depending on the node modules that an extension uses, more exclusion may be necessary.
Finally, you will want to update your .vscodeignore
file so that compiled files are included in the published extension. Check out the Publishing section for more details.