Rust has recently gotten a compiler flag which enables Rust+C WASM compilation, and here is a repository of examples of how it would be done. I know that both Haskell and Rust have C-FFI and both can target WASM (although for Haskell its only WASI now??) so my hope was that there could be a way to replicate what is done in the example repo, except instead of C binaries, Haskell is linked instead.
So the end goal would be a Rust crate which wraps some Haskell library (I am particularly interested in HaTeX since I haven’t found a popular crate like this in Rust) which has a build-script that invokes GHC WASM, and links (statically) whatever the output of that is. Then other Rust crates which target WASM can depend on the wrapper-crate to access said Haskell-library functionality, and when the final build of a whole host of these WASM-targeting crates is done, the Haskell-compiled WASM is included in the final output.
What I have tried so far
Executable approach
I followed the Haskell WASM GHC Repo to set up (using Nix-shell) a basic Haskell-WASM with the following structure:
cbits\
wizerinit.c <-- Near-verbatim copy of the Wizer/Preinitialization code from the
GHC WASM Repo's README documentation
src\
Lib.hs <-- The ForeignFunctionInterface module, exports `hs_dummy_init :: IO ()`
and `hs_fib :: CInt -> CInt` foreign functions to test everything
OtherModule.hs <-- An empty dummy module to make sure multi-module projects also work
latex-preprocessor.cabal
and then finally the contents of my latex-preprocessor.cabal
are
cabal-version: 3.12
name: latex-preprocessor
version: 0
executable latex-preprocessor
main-is: Lib.hs
other-modules: OtherModule
c-sources: cbits/wizerinit.c
hs-source-dirs: src
default-language: GHC2021
build-depends:
, base
ghc-options:
-Wall
-no-hs-main
-optl-mexec-model=reactor
"-optl-Wl,--export=hs_dummy_init,--export=hs_fib"
and then running the following sequence of commands:
wasm32-wasi-cabal build exe:latex-preprocessor
LATEX_PREPROCESSOR_WASM="$(wasm32-wasi-cabal list-bin exe:latex-preprocessor)"
wizer --allow-wasi --wasm-bulk-memory true \
"$LATEX_PREPROCESSOR_WASM" -o "dist-newstyle/haskell.wasm"
it all seems to work fine, no errors or anything, and I even get the final output in dist-newstyle/haskell.wasm
.
With that, I tried to get the Rust side of things working. I chose to basically follow the first example from that Rust+C repository; where I compile both the Rust and Haskell separately, and then link them with wasm-ld
. I set the toolchain to nightly
and passed the wasm_c_abi
flag in config files, so my version of the script only needed to call cargo build --release
:
# Build Rust
cargo build --release
# Create a static library from the Rust build
cp ./../target/wasm32-unknown-unknown/release/liblatex_preprocessor.rlib ./build/rust.wasm
# Build Haskell
cd ./haskell
wasm32-wasi-cabal build exe:latex-preprocessor
LATEX_PREPROCESSOR_WASM="$(wasm32-wasi-cabal list-bin exe:latex-preprocessor)"
wizer --allow-wasi --wasm-bulk-memory true \
"$LATEX_PREPROCESSOR_WASM" -o "dist-newstyle/haskell.wasm"
cd ./..
# Create a static library from the Haskell build
cp ./haskell/dist-newstyle/haskell.wasm ./build/haskell.wasm
# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld \
--error-limit=0 \
--no-entry \
--export-all \
-o ./build/latex_preprocessor.wasm \
./build/rust.wasm \
./build/haskell.wasm
# you can use wasm2wat to disassemble it and see exported symbols
wasm2wat ./build/latex_preprocessor.wasm > ./build/latex_preprocessor.wat
and just to make sure the output wasn’t completely missing in the final WASM module, I added the following (only) function to the lib.rs
file:
#[no_mangle]
pub extern "C" fn add(left: usize, right: usize) -> usize {
left + right
}
Running the whole thing yielded an error, where wasm-ld
was complaining that wasm-ld: error: ./build/haskell.wasm: not a relocatable wasm file
. Not sure what this means, but I’m assuming it has something to do with the fact that I just built an executable rather than library?
Library approach
All the examples I’ve come across online for GHC+Wasm are building executables, rather than libraries. So I have no idea if building a library is the appropriate thing to even do, but I tried. I changed the executable stanza in my cabal file to a library stanza like this:
library
exposed-modules: Lib
other-modules: OtherModule
c-sources: cbits/wizerinit.c
hs-source-dirs: src
default-language: GHC2021
build-depends:
, base
ghc-options:
-Wall
-no-hs-main
-optl-mexec-model=reactor
"-optl-Wl,--export=hs_dummy_init,--export=hs_fib"
and running wasm32-wasi-cabal build lib:latex-preprocessor
now produced a bunch of object files corresponding to each of the source files, and finally two library files .a
and .so
. I assume the .a
file would be the one that has the web-assembly? So I copied it directly to where the old output used to be:
wasm32-wasi-cabal build lib:latex-preprocessor
LATEX_PREPROCESSOR_WASM=./dist-newstyle/build/wasm32-wasi/ghc-9.10.1.20241209/latex-preprocessor-0/build/libHSlatex-preprocessor-0-inplace.a
cp "$LATEX_PREPROCESSOR_WASM" ./dist-newstyle/haskell.wasm
wizer --allow-wasi --wasm-bulk-memory true \
"./dist-newstyle/haskell.wasm" -o "dist-newstyle/haskell.wasm"
Running this, I got the library to build, but the wizer
step failed, with the error message being about a magic-number miss-match. I’m guessing this is because it only works with executable WASM builds, and this is a library?? Anyways, I omitted that last wizer
step so that my new top-level build script looks like this now:
# Build Rust
cargo build --release
# Create a static library from the Rust build
cp ./../target/wasm32-unknown-unknown/release/liblatex_preprocessor.rlib ./build/rust.wasm
# Build Haskell
cd ./haskell
wasm32-wasi-cabal build lib:latex-preprocessor
LATEX_PREPROCESSOR_WASM=./dist-newstyle/build/wasm32-wasi/ghc-9.10.1.20241209/latex-preprocessor-0/build/libHSlatex-preprocessor-0-inplace.a
cp "$LATEX_PREPROCESSOR_WASM" ./dist-newstyle/haskell.wasm
cd ./..
# Create a static library from the Haskell build
cp ./haskell/dist-newstyle/haskell.wasm ./build/haskell.wasm
# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld \
--error-limit=0 \
--no-entry \
--export-all \
-o ./build/latex_preprocessor.wasm \
./build/rust.wasm \
./build/haskell.wasm
# you can use wasm2wat to disassemble it and see exported symbols
wasm2wat ./build/latex_preprocessor.wasm > ./build/latex_preprocessor.wat
Running this now works without errors, however the output .wat
file is almost entirely empty (and only 500 bytes) with even the add
function (the one I added to lib.rs
for troubleshooting) is missing from the exports. I read online that you need the --whole-archive
flag for wasm-ld
to include all the contents that were in the archives. I only first tried it on rust.wasm
to make sure it works:
# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld --error-limit=0 --no-entry --export-all -o ./build/latex_preprocessor.wasm \
--whole-archive ./build/rust.wasm --no-whole-archive \
./build/haskell.wasm
and sure enough the new .wat
file now had the add
function in the exports:
(module $latex_preprocessor.wasm
...
(func $add (type 1) (param i32 i32) (result i32)
local.get 1
local.get 0
i32.add)
...
(export "__wasm_call_ctors" (func $__wasm_call_ctors))
(export "add" (func $add))
...
however when I extended it to haskell.wasm
also, I started having a whole bunch of errors. Running this:
# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld --error-limit=0 --no-entry --export-all -o ./build/latex_preprocessor.wasm \
--whole-archive ./build/rust.wasm \
./build/haskell.wasm --no-whole-archive
yielded the following errors:
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __Sp
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: ghczmprim_GHCziTuple_Z0T_closure
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __stg_gc_fun
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __R2
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: ghczmprim_GHCziTypes_True_closure
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __R4
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_apply
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_inCall
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_checkSchedStatus
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_unlock
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_lock
...
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: hs_perform_gc
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: hs_perform_gc
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: rts_clearMemory
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: malloc_inspect_all
from this, I can see that a whole bunch of symbols are missing (from Haskell’s RTS and also their patched version of libc
which exposes malloc_inspect_all
.) I’m assuming this is because GHC built a library/archive which dynamically links to all these in instead of statically linking them??
Speculations + Questions
I started scouring for resources related to GHC+static linking, found out that there is a foreign-library
stanza for cabal, that there are all sorts of GHC flags like shared
, static
, -fPIC
and so on, aswell as linker flags like -optl-Wl,--relocatable
, but I’m not exactly sure how to use any of them, or how they interact with specifically the WASM backend.
Also, assuming that the wizer
stuff only really works on WASM executables, then I guess I should expose the contents of cbits/wizerinit.c
in my Rust wrapper crate, which will then continue to propagate up the chain of crates that use it (they can optionally tack-on extra initialization functionality if they need it and continue to re-expose) until the final crate which compiles to WASM executable can also expose it, and only THEN I can run wizer
on the final output?
Another thing which I just ignored hoping it wasn’t a big deal: the GHC compiler targets wasm32-wasi
while the Rust crates I’m hoping to build target wasm32-unknown-unknown
(I want to use wasm-bindgen
and this requires I target the -unknown-unknown triple.) My hope is that I CAN infact link/bundle these together, and all it means is that I have to provide browser-side WASI polyfill when running the final WASM module, without any issues. But this is purely wishful thinking, I have no idea what its actually going to mean.
So the final question I have is, does anyone know if this is even possible? And if so, are there any resources, examples, articles, documentation, and so on, regarding how to do this stuff? In regards to the unique blend of GHC+WASM+C-FFI in particular I mean.
EDIT: forgot to mention that in my mad quest for figuring this out, I came across multiple resources stating that WASM doesn’t have multiple-memory yet and something about importing linear_memory
from the env
module?? I have no concrete idea what any of that means, but I’m assuming that this means you have to do very special handling when making WASM modules so the individual component’s memory layouts don’t conflict, or something? Honestly this bit of research only added to my overall confusion.