A few months ago, I announced that the GHC wasm backend added support for Template Haskell and ghci. Initially, the ghci feature only supported running code in nodejs and accessing the nodejs context, and I’ve been asked a few times when ghci was going to work in browsers in order to allow live-coding the frontend. Sure, why not? I promised it in the last blog post’s wishlist. After all, GHCJS used to support GHCJSi for browsers almost 10 years ago!
I was confident this could be done with moderate effort. Almost all the pieces are already in place: the external interpreter logic in GHC is there, and the wasm dynamic linker already works in nodejs. So just make it runnable in browsers as well, add a bit of logic for communicating with GHC and we’re done right? Well, it still took a few months for me to land it…but finally here it is!
To keep this post within reasonable length, I will only introduce the user-facing aspects of the wasm ghci browser mode and won’t cover the underlying implementation. The rest of the post is an example ghci session followed by a series of bite sized subsections, each covering one important tip about using this feature.
How to use it
The ghc-wasm-meta
repo provides user-facing
installation methods for the GHC wasm backend. Here we’ll go with the
simplest nix-based approach:
$ nix shell 'gitlab:haskell-wasm/ghc-wasm-meta?host=gitlab.haskell.org'
$ wasm32-wasi-ghc --interactive -fghci-browser
GHCi, version 9.12.2.20250327: https://www.haskell.org/ghc/ :? for help
Open http://127.0.0.1:38827/main.html or import http://127.0.0.1:38827/main.js to boot ghci
The -fghci-browser
flag enables the browser mode. There are a couple
of other related flags which you can read about in the user
manual, but for now, let’s open that page to
proceed. You’ll see a blank page, but you can press F12 to open the
devtools panel and check the network monitor tab to see that it’s
sending a lot of requests and downloading a bunch of wasm modules.
Within a few seconds, the initial loading process should be complete,
and the ghci prompt should appear in the terminal and accept user
commands.
Let’s start with the simplest:
ghci> putStrLn "hello firefox"
ghci>
The message is printed in the browser’s devtools console. That’s not impressive, so let’s try something that only works in a browser:
ghci> import GHC.Wasm.Prim
ghci> newtype JSButton = JSButton JSVal
ghci> foreign import javascript unsafe "document.createElement('button')" js_button_create :: IO JSButton
ghci> foreign import javascript unsafe "document.body.appendChild($1)" js_button_setup :: JSButton -> IO ()
ghci> btn <- js_button_create
ghci> js_button_setup btn
A fresh button just appeared on the page! It wouldn’t be useful if clicking it does nothing, so:
ghci> newtype Callback t = Callback JSVal
ghci> foreign import javascript "wrapper sync" syncCallback :: IO () -> IO (Callback (IO ()))
ghci> foreign import javascript unsafe "$1.addEventListener('click', $2)" js_button_on_click :: JSButton -> Callback (IO ()) -> IO ()
The above code implements logic to export a Haskell IO ()
function
to a JavaScript synchronous callback that can be attached as a
button’s client event listener. Synchronous callbacks always attempt
to run Haskell computations to completion, which works fine as long as
the exported Haskell function’s main thread does not block indefinitely,
like waiting for an async JSFFI import to resolve or be rejected. You
can read more about JSFFI in the user manual, but let’s
carry on with this example:
ghci> import Data.IORef
ghci> ref <- newIORef 0
ghci> :{
ghci| cb <- syncCallback $ do
ghci| print =<< readIORef ref
ghci| modifyIORef' ref succ
ghci| :}
ghci> js_button_on_click btn cb
Now, the button is attached to a simple counter in Haskell that prints an incrementing integer to the console each time the button is clicked. And that should be sufficient for a minimal demo! Now, there are still a couple of important tips to be mentioned before we wrap up this post:
Hot reloading
Just like native ghci, you can perform hot reloading:
ghci> :r
Ok, no modules to be reloaded.
ghci> btn
<interactive>:15:1: error: [GHC-88464]
Variable not in scope: btn
Reloading nukes all bindings in the current scope. But it doesn’t
magically undo all the side effects we’ve performed so far: if you
click on the button now, you’ll notice the counter is still working
and the exported Haskell function is still retained by the JavaScript
side! And this behavior is also consistent with native ghci:
hot-reloading does not actually wipe the Haskell heap, and there
exist tricks like foreign-store
to persist values
across ghci reloads.
For the wasm ghci, things like foreign-store
should work, though you
can allocate a stable pointer and print it, then reconstruct the
stable pointer and dereference it after a future reload. Since wasm
ghci runs in a JavaScript runtime after all, you can also cook your
global variable by assigning to globalThis
. Or locate the element
and fetch its event handler, it should be the same Haskell callback
exported earlier which can be freed by freeJSVal
.
So, when you do live-coding that involve some non-trivial back and forth calling between JavaScript and Haskell, don’t forget that hot reloads don’t kill old code and you need to implement your own logic to disable earlier callbacks to prevent inconsistent behavior.
Loading object code
The wasm ghci supports loading GHC bytecode and object code. All the
code you type into the interactive session is compiled to bytecode.
The code that you put in a .hs
source file and load via command line
or :l
commands can be compiled as object code if you pass
-fobject-code
to ghci.
I fixed the ghci debugger for all 32-bit cross targets
since the last blog post. Just like native ghci, debugger features
like breakpoints now work for bytecode. If you don’t use the ghci
debugger, it’s recommended that you use -fobject-code
to load
Haskell modules, since object code is faster and more robust at
run-time.
Interrupting via ^C
My GHC patch that landed the ghci browser
mode also fixed a previous bug in wasm ghci: ^C was not handled at all
and would kill the ghci session. Now, the behavior should be
consistent with native ghci. With or without -fghci-browser
, if
you’re running a long computation and you press ^C, an async exception
should interrupt the computation and unblock the ghci prompt.
Read the :doc
, Luke
Among the many changes I landed in GHC since last blog post, one of
them is adding proper haddock documentation to all user-facing things
exported by GHC.Wasm.Prim
. Apart from the GHC user manual, the
haddock documentation is also worth reading for users. I haven’t set
up a static site to serve the haddock pages yet, but they are already
accessible in ghci via the :doc
command. Just try import GHC.Wasm.Prim
and check :doc JSVal
or :doc freeJSVal
, then you
can read them in plain text.
As the Haskell wasm user community grows, so will the frustration with
lack of proper documentation. I’m slowly improving that. What you see
in :doc
will continue to be polished, same for the user manual.
Importing an npm
library in ghci
You can use JavaScript’s dynamic import()
function as an async JSFFI
import. If you want to import an npm
library in a ghci session, the
simplest approach is using a service like esm.run
which
serves pre-bundled npm
libraries as ES modules over a CDN.
If you have a local npm
project and want to use the code there, you
need to do your own bundling and start your own development server
that serves a page to make that code somehow accessible (e.g. via
globalThis
bindings). But how does that interact with the wasm ghci?
Read on.
Using ghci to debug other websites
The browser mode works by starting a local HTTP server that serves
some requests to be made from the browser side. For convenience, that
HTTP server accepts CORS requests from any origin, which means
it’s possible to inject the main.js
startup script into browser tabs
of other websites and use the wasm ghci session to debug those
websites! Once you fire up a ghci session, just open the devtools
console of another website and drop a
import("http://127.0.0.1:38827/main.js")
call, if that website
doesn’t actively block third-party scripts, then you can have more fun
than running it in the default blank page.
All JavaScript code for the GHC wasm backend consists of proper ES modules
that don’t pollute the globalThis
namespace. This principle has
been enforced since day one, which allows multiple Haskell wasm
modules or even wasm ghci sessions to co-exist in the same page! It
works fine as long as you respect their boundaries and don’t attempt
to do things like freeing a JSVal
allocated elsewhere, but even if
you only have one wasm module or ghci session, the “no global variable”
principle should also minimize the interference with the original page.
In my opinion, being able to interact with other websites is the most
exciting aspect of the browser mode. Sure, for Haskell developers that
want to experiment with frontend development, using ghci should
already be much easier than setting up a playground project and
manually handling linker flags, wrapper scripts, etc. But there’s even
greater potential: who said the website itself needs to be developed
in Haskell? Haskell can be used to test websites written in foreign
tech stacks, and testing backed by an advanced type system is
undoubtedly one of our core strengths! You can use libraries like
quickcheck-state-machine
or
quickcheck-dynamic
to perform state machine
property testing interactively, which has much greater potential of
finding bugs than just a few hard coded interactions in JavaScript.
No host file system in wasm
The default nodejs mode of wasm ghci has full access to the host file
system, so you can use Haskell APIs like readFile
to operate on any
host file path. This is no longer the case for browser mode: the only
handles available are stdout
/stderr
, which output to the devtools
console in a line-buffered manner, and there’s no file to read/write
in wasm otherwise. The same restriction also applies to Template
Haskell splices evaluated in a browser mode ghci session, so splices
like $(embedFile ...)
will fail.
This is a deliberate design choice. The dev environment backed by ghci browser mode should be as close as possible to the production environment used by statically linked wasm modules, and the production environment won’t have access to the host file system either. It would be possible to add extra plumbing to expose the host file system to ghci browser mode, but that is quite a bit of extra work and also makes the dev environment less realistic, so I’d like to keep the current design for a while.
If you need to read a local asset, you can serve the asset via another
local HTTP server and fetch it in ghci. If you have modules that use
splices like embedFile
, those modules should be pre-compiled to
object code and loaded later in ghci.
Don’t press F5
It’s very important that the browser page is never refreshed. The lifetime of the browser tab is supposed to be tied to the ghci session. Just exit ghci and close the tab when you’re done, but refreshing the page would completely break ghci! A lot of shared state between the browser side and host side is required to make it work, and refreshing would break the browser side of the state.
Likewise, currently the browser mode can’t recover from network glitches. It shouldn’t be a concern when you run GHC and the browser on the same machine, but in case you use SSH port forwarding or tailscale to establish the GHC/browser connection over an unstable network, once the WebSocket is broken then the game is over.
This is not ideal for sure, but supporting auto-recovery upon network issues or even page reloads is incredibly challenging, so let’s live with what is supported for now.
Doesn’t work on Safari yet
Currently the browser mode works fine for Firefox/Chrome, including
desktop/mobile versions and all the forks with different logos and
names. Sadly, Safari users are quite likely to see spurious crashes
with a call_indirect to a null table entry
error in the console.
Rest assured, normal statically-linked Haskell wasm modules still work
fine in Safari.
This is not my fault, but WebKit’s! I’ve filed a WebKit bug and if we’re lucky, this may be looked into on their side and get fixed eventually. If not, or if many people complain loudly, I can implement a workaround that seems to mitigate the WebKit bug to make the browser mode work in Safari too. That’ll be extra maintenance burden, so for now, if you’re on macOS, your best bet is installing Firefox/Chrome and using that for ghci.
Huge libraries don’t work yet
How large is “huge”? Well, you can check the source code of
V8, SpiderMonkey and
JavaScriptCore. In brief: there are limits agreed upon
among major browser engines that restrict a wasm module’s
import/export numbers, etc, and we do run into those limits
occasionally when the Haskell library is huge. For instance, the
monolithic ghc
library exceeds the limit, and so does the profiling way of
ghc-internal
. So cost-center profiling doesn’t work for the ghci
browser mode yet, though it does work for statically linked wasm
modules and ghci nodejs mode.
Unfortunately, this issue is definitely not a low hanging fruit even for me. I maintain a nodejs fork that patches the V8 limits so that the Template Haskell runner should still work for huge libraries, but I can’t do the same for browsers. A fundamental fix to sidestep the browser limits would be a huge amount of work. So I’ll be prioritizing other work first. If you need to load a huge library in the browser, you may need to split it into cabal sublibraries.
Wishlist, as usual
My past blog posts usually ends with a “what comes next” section. This one is no exception. The browser mode is in its early days, so it’s natural to find bugs and other rough edges, and there will be continuous improvement in the coming months. Another thing worth looking into is profiling: modern browsers have powerful profilers, and it would be nice to integrate our own profiling and event log mechanism with browser devtools to improve developer experience.
The next big thing I’ll be working on is threaded RTS support. Currently all Haskell wasm modules are single-threaded and runs in the browser main thread, but there may exist workloads that can benefit from multiple CPU cores. Once this is delivered, Haskell will also become the first functional language with multi-core support in wasm!
You’re welcome to join the Haskell wasm Matrix room to chat about the GHC wasm backend and get my quick updates on this project.
About the author
Cheng is a Software Engineer who specializes in the implementation of functional programming languages. He is the project lead and main developer of Tweag's Haskell-to-WebAssembly compiler project codenamed Asterius. He also maintains other Haskell projects and makes contributions to GHC(Glasgow Haskell Compiler). Outside of work, Cheng spends his time exploring Paris and watching anime.
If you enjoyed this article, you might be interested in joining the Tweag team.