Compile and Bundle ESM Components#
The ESM components make it possible to load external libraries from a CDN, NPM or GitHub using one of two approaches:
Directly importing from
esm.sh
or another CDN or by defining a so called importmap.Bundling the resources using
npm
andesbuild
.
In this guide we will cover how and when to use each of these approaches.
Imports#
So called ECMA script modules or ESM modules for short, made it much simpler to build reusable modules that could easily import other libraries. Specifically they introduced import
and export
specifiers, which allow developers to import other libraries and export specific functions, objects and classes for the consumption of others.
These imports can reference modules directly on some CDN or you can define a so called importmap
, which allows you to specify where to load a library from. Let’s start with a simple example, we are going to build a ConfettiButton
.
Inline Imports#
Let us first specify the Python portion of our component, we are simply going to create a JSComponent
that loads confetti.js
:
import panel as pn
from panel.custom import JSComponent
pn.extension()
class ConfettiButton(JSComponent):
_esm = 'confetti.js'
ConfettiButton().servable()
Now that we have our Python component let’s build the Javascript (or TypeScript if you like):
/* confetti.js */
import confetti from "https://esm.sh/canvas-confetti@1.6.0";
export function render() {
const button = document.createElement('button')
button.addEventListener('click', () => confetti())
button.append('Click me!')
return button
}
Here we are importing the library directly from esm.sh, a fast and reliable CDN to fetch libraries compiled as modern ESM bundles from.
Note
esm.sh is very powerful and has many options for specifying shared dependencies or bundling dependencies together. Make sure to check out the docs.
Import Maps#
Once you move past initial development we recommend making use of import maps. To quote MDN:
An import map is a JSON object that allows developers to control how the browser resolves module specifiers when importing JavaScript modules. It provides a mapping between the text used as the module specifier in an import statement or import() operator, and the corresponding value that will replace the text when resolving the specifier.
The import map can be declared directly on the JSComponent
using the _importmap
attribute. A minimum it must contain some imports:
class ConfettiButton(JSComponent):
_importmap = {
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0",
}
}
_esm = 'confetti.js'
Now that we have separately declared the import we can update the import
line in the confetti.js
file:
/* confetti.js */
import confetti from "canvas-confetti";
This approach cleanly separates the definitions of the libraries and their versions from the actual code. If you are only importing a single library this is generally all you need to do, however once you have multiple inter-connected dependencies you may have to go beyond this.
Let’s say for instance you want to import libraries A
, B
and C
. Both B
and C
depend on A
, however because esm.sh rewrites imports you may end up with multiple different versions A
.
In order to avoid this we can ask esm.sh
not to rewrite the imports using the external
query parameter. This tells esm.sh that A
will be provided externally (i.e. by us), ensuring that libraries B
and C
both import the version of A
we declare:
{
"imports": {
"A": "https://esm.sh/A@1.0.0",
"B": "https://esm.sh/B?external=A",
"C": "https://esm.sh/C?external=A",
}
}
To give a real world example that esm.sh itself provides:
{
"imports": {
"preact": "https://esm.sh/preact@10.7.2",
"preact-render-to-string": "https://esm.sh/preact-render-to-string@5.2.0?external=preact"
}
}
Note
Import maps supports trailing slash that can not work with URL search params friendly. To fix this issue, esm.sh provides a special format for import URL that allows you to use query params with trailing slash: change the query prefix ? to & and put it after the package version.
{
"imports": {
"react-dom": "https://esm.sh/react-dom@18.2.0?pin=v135&dev",
"react-dom/": "https://esm.sh/react-dom@18.2.0&pin=v135&dev/"
}
}
Compile & Bundling#
Importing libraries directly from a CDN allows for quick experimentation and iteration but also means that the users of your components will have to have access to the internet to fetch the required modules. By compiling and bundling the component and external resources you can ship a self-contained and optimized ESM module that includes all the dependencies, while also ensuring that you only fetch the parts of the libraries that are actually needed. The panel compile
command provides a simple entrypoint to compile one or more components into a single component.
Setup#
The compilation and bundling workflow depends on two JavaScript tools: node.js
(or more specifically the node package manager npm
) and esbuild
. The most convenient way to install them is conda
but you can also set up your own node environment using something like asdf
, nvm
or volta
.
conda install esbuild npm
Once you have set up node.js
you can install esbuild
globally with:
npm install -g esbuild
and confirm the installation with:
esbuild --version
Panel Compile Command#
Panel provides the panel compile
command to automate the compilation of ESM components from the command line and bundle their resources. This functionality requires npm
and esbuild
to be installed globally on your system.
Example Usage#
Let’s consider a confetti.py module containing a custom JavaScript component:
# confetti.py
import panel as pn
from panel.custom import JSComponent
class ConfettiButton(JSComponent):
_esm = """
import confetti from "https://esm.sh/canvas-confetti@1.6.0";
export function render() {
const button = document.createElement('button')
button.addEventListener('click', () => confetti())
button.append('Click me!')
return button
}"""
To compile this component, you can use the following command:
panel compile confetti
Hint
panel compile
accepts file paths, e.g. my_components/custom.py
, and dotted module name, e.g. my_package.custom
. If you provide a module name it must be importable.
This will automatically discover the ConfettiButton
but you can also explicitly request a single component by adding the class name:
panel compile confetti:ConfettiButton
After running the command you should output that looks a like this, indicating the build succeeded:
Running command: npm install
npm output:
added 1 package, and audited 2 packages in 649ms
1 package is looking for funding
run `npm fund` for details
found 0 vulnerabilities
Running command: esbuild /var/folders/7c/ww31pmxj2j18w_mn_qy52gdh0000gq/T/tmp9yhyqo55/index.js --bundle --format=esm --outfile=<module-path>/ConfettiButton.bundle.js --minify
esbuild output:
.....<module-path>/ConfettiButton.bundle.js 10.5kb
âš¡ Done in 9ms
The compiled JavaScript file will be automatically loaded if it remains alongside the component. If you rename the component or modify its code or _importmap
, you must recompile the component. For ongoing development, consider using the --dev
option to ignore the compiled file and automatically reload the development version when it changes.
Compilation Steps#
The panel compile
command performs the compilation and bundling in several steps:
Identify Components: The first step is to discover the components in the provided module(s).
Extract External Dependencies: The command identifies external dependencies from the
_importmap
(if defined) or directly from the ESM code. These dependencies are written to apackage.json
file in a temporary build directory. The.js(x)
files corresponding to each component are also placed in this directory.Install Dependencies: The command runs
npm install
within the build directory to fetch all external dependencies specified inpackage.json
.Bundle and Minify: The command executes
esbuild index.js --bundle --format=esm --minify --outfile=<module-path>ConfettiButton.bundle.js
to bundle the ESM code into a single minified JavaScript file.Output the Compiled Bundle(s): The final output is one or more compiled JavaScript bundle (
ConfettiButton.bundle.js
).
Compiling Multiple Components#
If you intend to ship multiple components with shared dependencies, panel compile
can generate a combined bundle, which ensures that the dependencies are only loaded once. By default it will generate one bundle per module or per component, but if you declare a _bundle
attribute on the class, declared either as a string defining a relative path or a pathlib.Path
, you can generate shared bundles across modules. These bundles can include as many components as needed and will be automatically loaded when you use the component.
As an example, imagine you have a components declared across your package containing two distinct components. By declaring a path that resolves to the same location we can bundle them together:
# my_package/my_module.py
class ComponentA(JSComponent):
_bundle = './dist/custom.bundle.js'
# my_package/subpackage/other_module.py
class ComponentB(JSComponent):
_bundle = '../dist/custom.bundle.js'
when you compile it with:
panel compile my_package.my_module my_package.subpackage.other_module
you will end up with a single custom.bundle.js
file placed in the my_package/dist
directory.
Using the --build-dir
Option#
The --build-dir
option allows you to specify a custom directory where the package.json
and raw JavaScript/JSX modules will be written. This is useful if you need to manually modify the dependencies before the bundling process and/or debug issues while bundling. To use this feature, follow these steps:
Run the compile command with the
--build-dir
option to generate the directory:
panel compile confetti.py --build-dir ./custom_build_dir
Navigate to the specified build directory and manually edit the
package.json
file to adjust dependencies as needed.Once you’ve made your changes, you can manually run the
esbuild
command:
esbuild custom_build_dir/index.js --format=esm --bundle --minify
Here is a typical structure of the build_dir directory:
custom_build_dir/
├── index.js
├── package.json
├── <Component>.js
└── <OtherComponent>.js
The compiled JS file will now be loaded automatically as long as it remains alongside the component. If you rename the component you will have to delete and recompile the JS file. If you make changes to the code or _importmap
you also have to recompile. During development we recommend using --dev
, which ignores the compiled file.
Caution
The panel compile
CLI tool is still very new and experimental. In our testing it was able to compile and bundle most components but there are bound to be corner cases.
We will continue to improve the tool and eventually allow you to bundle multiple components into a single bundle to allow sharing of resources.
React Components#
React components automatically include react
and react-dom
in their bundles. The version of React
that is loaded can be specified the _react_version
attribute on the component class. We strongly suggest you pin a specific version on your component to ensure your component does not break should the version be bumped in Panel.
Manual Compilation#
If you have more complex requirements or the automatic compilation fails for whatever reason you can also manually compile the output. We generally strongly recommend that you start by generating the initial bundle structure by providing a --build-dir
and then tweaking the resulting output.
Configuration#
To run the bundling we will need one additional file, the package.json
, which, just like the import maps, determines the required packages and their versions. The package.json
is a complex file with tons of configuration options but all we will need are the dependencies.
To recap here are the three files that we need:
{
"name": "confetti-button",
"dependencies": {
"canvas-confetti": "^1.6.0"
}
}
import panel as pn
from panel.custom import JSComponent
pn.extension()
class ConfettiButton(JSComponent):
_esm = 'confetti.js'
ConfettiButton().servable()
import confetti from "canvas-confetti";
export function render() {
const button = document.createElement('button')
button.addEventListener('click', () => confetti())
button.append('Click me!')
return button
}
Once you have set up these three files you have to install the packages with npm
:
npm install
This will fetch the packages and install them into the local node_modules
directory. Once that is complete we can run the bundling:
esbuild confetti.js --bundle --format=esm --minify --outfile=ConfettiButton.bundle.js
This will create a new file called ConfettiButton.bundle.js
, which includes all the dependencies (even CSS, image files and other static assets if you have imported them).