This is a story of how we migrated Konfig from Bucklescript to Melange. I'll touch on some of the things we had to figure out along the way, as some things were not directly clear while migrating. Basically, this is the article I wish I could read before migrating.

Some lessons here may apply to someone trying make Melange work with an existing Javascript code-base.

Melange

The freshly updated website of Melange paints the best picture. In a nutshell, it allows you to compile OCaml to Javascript. In our case, we're using a different OCaml syntax called ReasonML, but that's equivalent to OCaml. Just more familiar for Javascript developers.

Historically, Konfig was built in ReasonML using BuckleScript. When Bucklescript branched off of OCaml to Rescript, the ecosystem was making a bit of a shift. We decided to wait this out a little, and see where things landed. Going to Rescript was always a possibility, but we ended up staying with ReasonML, using Melange. Mainly because:

  • Infix operators. While a double edged sword, they are amazing when using parser combinators, which we use to implement some of our price calculation logic
  • The OCamlLSP
  • Ecosystem PPX compatibility (PPX's are a way of metaprogramming in OCaml)
  • Upgraded compiler. Bucklescript was on an old version of the OCaml compiler, and with Melange, the upgrade enabled a lot of the newer OCaml features, like

Having said that, there have been some amazing updates in the Rescript ecosystem as well!

Monorepo Structure & Apps

The monorepo structure at Konfig looks a little bit like this:

/node_modules # ThirdParty
/packages
  /admin # ReasonML
    /bsconfig.json
  /viewer # ReasonML
    /bsconfig.json
  /shared # ReasonML
    /bsconfig.json
  /stdlib # ReasonML
    /bsconfig.json
  /... # ReasonML
/bsconfig.json

All the packages are ReasonML. In Bucklescript, this meant every single package had their own bsconfig.json, configuring the build. The monorepo meant we had to have one in the root as well.

In the end, at Konfig, we need to produce 2 apps. The admin , which is our CMS, and the viewer , which is used as an embed into our clients websites. While developing, we chose to continuously build the entire monorepo. The main reason is that when we change something in a shared package, we'd like to know anywhere it's been updated, and potentially breaks, but as a second reason - because we can. The compiler is really fast. It's not like Typescript, which, for a reasonably sized project had me waiting 30s for an incremental compile. For us, it's usually a sub-second update. A change higher in the dependency tree, for instance in the stdlib package, builds in less than 5 seconds.

Bucklescript uses a hand-rolled build system. It's basically a tiny OCaml application that writes Ninja rules for you, and then Ninja is the build-system that makes sure everything is done in order. With the move to Melange, we got a new build-system: Dune.

Dune & S-Expressions

Dune is the build system for OCaml projects. Melange has integrated more tightly with it, so the OCaml toolchain can be used with Melange as well. We can even have the code built for both native and web at the same time if we want, with just some minor configuration tweaks. Dune also always builds everything from source. This makes it somewhat slower to get up-and-running, but means builds are much faster, because the PPX's are combined, so there is way less overhead when processing files.

Coming from Javascript and friends, we are used to configuring stuff in JSON. To configure Dune though, we have to use S-Expressions. This felt new, but in essence not hard once you get used to it. From the wiki:

In the usual parenthesized syntax of Lisp, an S-expression is classically defined[1] as
- an atom of the form _x_, or
- an
expression of the form (_x_ . _y_) where x and y are S-expressions.

So for instance the following JSON:

{
  "string": "John",
  "list": [0, 1, 2, 3, 4],
  "nested": {
    "one": "foo",
    "two": "bar"
  }
}

Would be the same as the following S-Expression

(string John)
(list (0 1 2 3 4))
(nested
  (one foo)
  (two bar))

From bsconfig.json → dune

In Melange, and for our folder structure, this means we have to replace all the bsconfig.json files with dune files, like below.

Very high level, the individual packages define a dune library, while the root dune file defines an 'emit' for Melange to use.

/node_modules
/packages
  /admin
    /dune # library
  /viewer
    /dune # library
  /shared
    /dune # library
  /stdlib
    /dune # library
  /...
/dune # root

I'll outline below how these look like, roughly

Library

These are files that specify a Dune library. Every 'package' in /packages is a Dune library.

;Include the 'src' directory when looking for files to build
(dirs src)

;Also nest, go into all sub-directories, and look for files there
(include_subdirs unqualified)

;This describes the library `KonfigAdmin`
(library
 ;This namespaces the library
 (name KonfigAdmin)
 ;Here we describe the dependencies, like "bs-dependencies"
 (libraries melange-webapi
            melange-fetch
            decco.lib // Installed through OPAM
            KonfigShared // This is another package from the monorepo
            KonfigParser // This one too
            reason-react)
 ;There are two flags here. The first opens our standard library
 (flags (:standard -open KonfigShared__StdLib
                   // This second does:
                   // -warn-error == Make all warnings errors
                   // +A == Give me all warnings (so, errors)
                   // -3-9...-106 == Apart from these error numbers
                   -warn-error +A-3-9-26-27-32-44-102-103-106))
   ;Use the following PPX's
  (preprocess (pps melange.ppx 
                   reason-react-ppx 
                   decco.ppx ))
  ;Enable Melange mode (very important)
 (modes melange))

In essence these are straight copies from bsconfig with the exception of modes melange and the way we preprocess.

Emit

The root Dune file is the place we specify that Melange has to output some files.

;Look for things in /packages
(dirs packages)

;This tells Melange that it needs to output something
(melange.emit
 (alias build) ;build name
 (libraries KonfigAdmin KonfigViewer) ;Libraries to build
 (modules :standard) ;Which modules to build
 (target build) ;The output folder
 (promote (until-clean)) ;**
 (module_systems
  (es6 bs.js))) ;Output type (es6), and extension (.bs.js)

** On line 10 above, we see (promote (until-clean)) . This tells Melange to, in addition to using the _build folder, "promote" the files to the project source. We'll get into the reasoning of why this is use-full in the next section.

/node_modules
/build # Promoted build output. Same as /packages, but includes node_modules
  /node_modules
  /admin
  /viewer
  /...
/packages
  /admin
  /viewer
  /...

Build Location & Output

Bucklescript used to compile files right next to the source. So for every .re file, it would generate a .bs.js file right next to it. This made integrating it into an existing javascript codebase pretty easy, just add the compiler as a dependency, create your root bsconfig.json and you're up and running.

Dune however, uses a separate build folder. Just like when building assets for the web, where you have a 'public' or 'build' folder where your static assets live. This means that all dependencies that are not ReasonML files (css files, images, or other javascript files that we FFI with), need to be copied / co-located into the build folder. That's ok for a project that's built from scratch to use Melange, and doesn't have any existing Javascript. You're not convoluting your source folders with build artefacts. However, for existing projects that are migrating, and / or projects that use a lot of other build assets, Melange these to be explicitly set / copied over.

As we still have some old JS components lingering around, this is a bit cumbersome, so we need to work around that, and get the compiled assets back into the source folder...

The output in the /build folder reflects the folder / file structure of /packages exactly. This is really nice, because we basically just have to rsync one into the other to get our files:

rsync -a --inplace ./build/ .

With Dune, we can automate this process:

(rule
 (targets after_build) ;Dune needs some output target
 (deps
  (alias build)) ;We depend on the "build" / run this after every run
 (action
  (progn
   (bash "cd ../../ && ./promote.sh") ;Promote the files
   (write-file after_build "")))) ;Satisfy Dunes file output needs

This action is being ran from within the _build/default folder (the default profile for the build action).

For some reason, this was extremely fiddly to get right. It looks like you should be able to push the rsync command straight into the "progn", but we kept running into permission issues after the initial compile. Pushing it to a separate file was consistently working, so we stuck with that approach.

Node Modules & Vite Module Resolution

We now have our compiled assets right next to our source files, just like we had with Bucklescript. Unfortunately, there is one part of this that doesn't quite work, as the files that are generated by Melange are not installed as node_modules to begin with, they are not being resolved.

import * as Belt__Belt_Option from "melange.belt/belt_Option.bs.js";

Luckily Vite can help us out here using aliases. With the way yarn workspaces work, we can simply alias all *.bs.js files to go to node_modules , as it links our existing packages to other packages from that same node_modules folder.

import alias from '@rollup/plugin-alias';

// Get the path to the 'main' node_modules
const node_modules =
  path.resolve(__dirname).split(path.sep).slice(0, -2).join(path.sep) +
  '/node_modules';


//...plugins
alias({
  entries: [
    {
      find: /(.*).bs.js/,
      replacement: `${node_modules}/$1.bs.js`,
    },
  ],
}),

Other Small Changes / files

dune-project

Next to the dune files, we also have to specify a dune-project file. One per "package". As one dune-project can have multiple libraries, we can just have a single one at the root of the monorepo.

konfig.opam

In the OCaml Ecosystem, packages are managed by opam . An example opam file can be found in the Melange-Pancake repo.

makefile

To make working with this a little better, we can abstract some commands like we do in scripts for package.json . But instead of using package.json , we just put these commands into a Makefile.

To get set-up, one would simply run:

  • make init - installs the dependencies
  • make watch - start the watcher

Deploying

We deploy using Docker. The build process looks a bit more involved because it tries to build everything from scratch. Doing this for every single run, meant our CI would take much, much more time.

To mitigate this, we've split up the process into two.
1. Build a build-container that includes the opam / npm dependencies (essentially the make init from above).
2. Use that container as a source for our main container to build.

This could do with some more optimisation. Using the OCaml base container could for instance make it a bit smaller than a full Ubuntu base, but - it works. With this split, our deployments are down to less than 5 minutes per front-end application.

FROM ubuntu:24.04 as builder

RUN echo "Install Make, ASDF"
RUN apt-get update
RUN apt-get -y install make curl git
RUN git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.13.1

RUN echo "Install OPAM"
RUN apt-get -y install opam
RUN opam init -y

RUN echo "Install ASDF"
ENV PATH="$PATH:/root/.asdf/bin"
ENV PATH=$PATH:/root/.asdf/shims
RUN asdf plugin add nodejs

RUN echo "Copying Files"
RUN mkdir -p /opt
COPY . /opt

WORKDIR /opt/

RUN asdf install
RUN npm i -g yarn

RUN echo "Create Switch, install dependencies"
RUN make init
RUN yarn config set workspaces-experimental true
RUN yarn --frozen-lockfile --production=false --network-timeout 10000000

Conclusion

There are some idiomatic differences between developing for the web, and developing to distribute binaries. Since Dune was built with the lather in mind, there is a difference in the way we reason about the build process of our application.

While migrating, we hit some of those. And I personally had to get my head wrapped around some new things. I hope some of the things outlined can point some people in the right direction.

As a closing note, I will add that I'm extremely excited for the future of Melange. With version 3.0 just dropping as I was writing this article, we could not have been more back. Big thanks to the entire community.

Share this article: Link copied to clipboard!