Tweag
Technical groups
Dropdown arrow
Open source
Careers
Research
Blog
Contact
Consulting services
Technical groups
Dropdown arrow
Open source
Careers
Research
Blog
Contact
Consulting services

Frontend live-coding via ghci

17 April 2025 — by Cheng Shao

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 Shao

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.

This article is licensed under a Creative Commons Attribution 4.0 International license.

Company

AboutOpen SourceCareersContact Us

Connect with us

© 2024 Modus Create, LLC

Privacy PolicySitemap