Package management
Melange can consume packages from both the npm registry and the opam repository.
- For Melange libraries and bindings (compile-time dependencies), use one of the package management alternatives described in Getting started. The rest of this guide assumes you're using opam.
- For JavaScript packages required by Melange bindings (runtime dependencies), use npm (or any of its alternatives).
Integrating with opam provides Melange projects with a native toolchain. Opam has been designed for the OCaml language, and it enables Melange projects to have first-class access to PPXs, compiler libraries, editor integration software and other tools.
In the following sections, we explain in detail how to use opam to define the dependencies of our application, as well as how to publish packages in the public opam repository. However, this documentation is not exhaustive and only covers what we believe are the most important parts for Melange developers. If you want to learn more about opam, please refer to the opam manual and FAQ page.
opam for Melange developers
Before diving into specifics about using opam, there are the two relevant differences between opam and npm that are worth mentioning.
1. One version of each package
At any given time, any opam switch can only install at most a single version of a package. This is known as a flat dependency graph, and some package managers (like Bower) follow a similar approach.
A flat dependency graph means that, for example, it is impossible to have two versions of reason-react
installed in the same project. This avoids some headaches when one inadvertently installs two versions of a dependency. Also, and specifically for Melange, it helps keep the resulting JavaScript bundle lean and reduce page load for browser-based applications.
On the other hand, upgrading your project dependencies to more recent versions might become tricky. Due to the restriction where only one version of a package can be installed, there is a higher chance for conflicts between the constraints of the transitive dependencies. If opam cannot find a solution, these conflicts need to be solved manually. This generally involves updating the conflicting dependency to make it compatible with a newer version of Melange or a transient dependency.
2. A source-based package manager for a compiled language
opam distributes just the source code of the packages and leaves the compilation step to a build phase that runs when consuming them, after they have been fetched. As a package manager for a compiled language like OCaml, opam has first-class support for this build step. Every package must tell opam how it should be built, and the way to do this is by using the build
field in the package .opam
file. This is different than how npm is used: most published packages in the npm registry don’t rely on a build step.
As Melange relies on OCaml packages for the compilation step (either PPXs, linters, instrumentation, or any other build-time package), it’s integrated with the native toolchain that OCaml programmers are familiar with, which relieves library authors of the burden of creating and distributing pre-built versions of their packages.
Let’s go now through the most common actions with opam when working on Melange projects. The following guide is based on the amazing opam for npm/yarn users guide by Louis (@khady).
Initial configuration
The first thing to do is to install opam. There is an official documentation page on installation. Most of the time, we can get it from your package manager. Otherwise, binaries are provided for every platform.
There is a necessary first step before using opam:
opam init -a
Here is what the documentation of the opam init
command says:
The init command initialises a local "opam root" (by default,
~/.opam/
) that holds opam’s data and packages. This is a necessary step for normal operation of opam. The initial software repositories are fetched, and an initial 'switch' can also be installed, according to the configuration and options. These can be afterwards configured using opam switch and opam repository.
Additionally, this command allows to customise some aspects of opam’s shell integration, when run initially (avoiding the interactive dialog), but also at any later time.
The interesting parts are:
- The opam root is at
~/.opam
- opam uses shell integration to make our life easier
- opam uses the concept of a switch
A switch is the equivalent of the node_modules
folder in npm’s world. It contains all the packages that are installed. There are local switches and global switches, in the same way we can have a node_modules
folder local to our project or install global dependencies using yarn global
or npm install -g
. Global switches can be handy sometimes, but to avoid confusion, the recommendation is to avoid them.
The default settings can be changed if the -a
option is omitted while calling opam init
.
Minimal app.opam
file
The equivalent to package.json
is an app.opam
file, where app
is the name of the package. It is possible to have multiple opam files in the same directory or project.
There is no opam command to manipulate the opam file. A command similar to npm init
or yarn add
does not exist in opam, so the updates in .opam
files have to be done by hand.
A minimal .opam
file looks like this:
opam-version: "2.0"
name: "my-app"
authors: "Louis"
homepage: "https://github.com/khady/example"
maintainer: "[email protected]"
dev-repo: "git+ssh://[email protected]:khady/example.git"
bug-reports: "https://github.com/khady/example/issues"
version: "0.1"
build: [
[ "dune" "subst" ] {pinned}
[ "dune" "build" "-p" name "-j" jobs ]
]
depends: [
"dune" {build}
]
build:
tells opam that dune
is needed only to build the project.
Installing packages
The first thing we need is a local switch in the current project. To verify if a switch exists already, we can look for a _opam
directory at the root of the project or use the opam switch
command to identify if a switch already exists in the project folder.
If it does not exist, we can create it with:
opam switch create . 5.2.0 --deps-only
If it exists, we can install the dependencies of the project with:
opam install . --deps-only
Add new packages
To add a new package to the opam switch, we can do:
opam install <package_name>
But opam will not modify the app.opam
file during the installation, this has to be done by hand, by adding the name of the package in the depends
field.
Linking packages for development
This can be achieved with opam pin
. For example, to pin a package to a specific commit on GitHub:
opam pin add reason-react.dev https://github.com/reasonml/reason-react.git#61bfbfaf8c971dec5152bce7e528d30552c70bc5
Branch names can also be used.
opam pin add reason-react.dev https://github.com/reasonml/reason-react.git#feature
For packages that are already published in the opam repository, a shortcut to pin to the latest version is to use the --dev-repo
flag, e.g.
opam pin add melange.dev --dev-repo
To remove the pinning for any package, use opam unpin <package_name>
or opam pin remove <package_name>
.
For other options, the command is well described in the official documentation.
Upgrading packages
There is one big difference compared to npm: opam stores a local copy of the opam repository, like apt-get
does in Debian. So before doing any upgrades, we might want to update this copy before:
opam update
Then, to upgrade the installed packages to the latest version, run:
opam upgrade <package_name>
opam upgrade
is also able to upgrade all the packages of the local switch if no package name is given.
Dev dependencies
You can use the with-dev-setup
field to define dependencies that are only required at development time. For example:
depends: [
"ocamlformat" {with-dev-setup}
]
This has to be combined with the --with-dev-setup
flag when installing dependencies, e.g. opam install --deps-only --with-dev-setup
.
Lock files
Lock files aren’t as used in the opam world as somewhere else, but they can be used as follows:
- Using
opam lock
to generate the lock file when needed (basically after eachopam install
oropam upgrade
). - Adding
--locked
to all theopam install --deps-only
andopam switch create .
commands.
Bindings and package management
When writing Melange libraries that bind to existing JavaScript packages, the users of the Melange library will have to make sure that those JavaScript packages are installed.
This is similar to how OCaml bindings to system libraries work, see examples like ocaml-mariadb
or ocurl
.
The advantage of this approach —as opposed to vendoring the JavaScript packages inside the bindings— is that it gives users of the bindings complete flexibility over the way these JavaScript packages are downloaded and bundled.
Melange provides a way to define dependencies from opam packages to npm packages inside the opam
files through the check-npm-deps opam plugin.
With this plugin, library authors can include constraints in the npm format inside the opam depexts
field, for example, the reason-react
opam file can include a section like this:
depexts: [
["react" "react-dom"] {npm-version = "^17.0.0 || ^18.0.0"}
]
This indicates that the version of reason-react
is compatible with ReactJS versions 17 and 18.
Users of Melange bindings can check that the constraints defined in the opam packages installed in their switch are fulfilled by the packages installed in node_modules
by using the check-npm-deps
plugin. For this, one just needs to install the plugin:
opam install opam-check-npm-deps
And then call it from the root folder of the project, where the opam switch and the node_modules
folder exist:
opam-check-npm-deps
Finding and using Melange compatible packages
opam packages
Melange packages are usually available on opam. Package search is available in the opam CLI via opam search <package-name>
, e.g. opam search reason-react
. You can run opam install <package-name>
to download, build and install opam packages in your switch. Remember that opam won't automatically add the dependency to <your-project>.opam
file, so it must be added manually:
...
depends: [
...
"reason-react" {>= "0.11.0"}
]
To use a library from the installed package, add the library name to the dune
file under the libraries
field. For example, if our project structure looks like:
project_name/
├── _opam
├── src
│ ├── dune
│ ├── ReactComponent1.ml
│ ├── ReactComponent2.ml
│ └── lib
│ ├── dune
│ └── data.ml
├── dune-project
├── dune
├── package.json
└── ...
project_name/
├── _opam
├── src
│ ├── dune
│ ├── ReactComponent1.re
│ ├── ReactComponent2.re
│ └── lib
│ ├── dune
│ └── data.re
├── dune-project
├── dune
├── package.json
└── ...
then reason-react
should be added to the dune
file under the src
folder:
(melange.emit
(target output)
(alias react)
(libraries lib reason-react)
(preprocess
(pps reason-react-ppx))
(module_systems es6))
Some libraries will only work after being processed by an accompanying PPX, e.g., reason-react
requires preprocessing with reason-react-ppx
. These preprocessors may be installed together with the library as part of the same package, or they might be part of a different package, in which case they need to be installed separately.
Unpublished opam packages
opam packages that have not yet been published may be installed with the opam pin
command. For example, opam pin add melange-fetch.dev git+https://github.com/melange-community/melange-fetch
will obtain melange-fetch
from its Git repository and install it on your switch. Your <your-project>.opam
file should then be updated in two places:
...
depends: [
...
"melange-fetch" {dev}
]
pin-depends: [
[ "melange-fetch.dev" "git+https://github.com/melange-community/melange-fetch" ]
]
Once installed, the libraries included in the package can be added to the dune
file:
(melange.emit
(target output)
(alias react)
(libraries lib reason-react melange-fetch)
(preprocess
(pps reason-react-ppx))
(module_systems es6))
npm packages
A number of Melange compatible packages can be found on npm. Many older, but still useful, compatible BuckleScript libraries can be found on npm, e.g., bs-json
. Run npm install @glennsl/bs-json
to add the dependency locally and record it in the package.json
file at the root of our project.
Dune needs to be made aware of the newly installed package. The subdir
stanza can be handy in these cases:
(subdir
node_modules
(dirs @glennsl)
(subdir
@glennsl/bs-json/src
(library
(name bs_json)
(wrapped false)
(modes melange))))
If the dune
file contains the line (dirs :standard \ node_modules)
, it should be removed, so that Dune can process the new Melange sources under the node_modules
folder.
In our project structure above we have the file data.ml
data.re
under the folder src/lib
. If we want use the bs-json
library from within the data.ml
data.re
file then we need to add the library name to the dune
file in the same folder, i.e., src/lib/dune
:
(library
(name data)
(libraries bs_json)
(modes melange))
Note that the library bs-json
was defined as bs_json
in the subdir
stanza and is referenced as bs_json
in the dune
file. This is necessary as Dune wrapped libraries will only expose a single top-level module named after the library, so the library name has to be a valid module name. This is why library names with characters like -
are not valid.
We can add new subdir
stanzas for every package we'd like to consume this way. See this dune file for a larger example that uses multiple npm packages.