Haskell WebAssembly for Browser Interaction

1 hour ago 1

The Goal

I wanted to test out the WASM support that GHC now offers in the very recent versions. Since this blog has very little JS itself, I thought it would serve as a good testbed by building a little theme toggle with the following requirements:

  • The logic should be written in Haskell, it can’t just be a value-less shim that just wraps around some JavaScript logic.
  • If possible it would make use of at least one dependency that isn’t part of the standard library, because it would be nice to use helpful libraries and not have to reimplement them badly.

The Setup

From what I found (at the time of writing) this appeared to be the best starting point to work from: ghc-wasm-meta. As it’s a nix flake and my blog code includes some nix to provide the tooling that drives it, then I should be hopefully off to a good start. It’s worth pointing out however that this is using a bleeding edge version of GHC 9.14, so the usual Danger Will Robinson warning applies with something like that.

The Build

This was a little fiddly in the end and a fair chunk of the options originated from my assistant Claude.

wasm32-wasi-cabal update wasm32-wasi-cabal configure \ --ghc-options="-no-hs-main -O2 -optl-mexec-model=reactor -optl-Wl,--export=hs_init,--export=hs_initializeThemeToggle -optl-Wl,--strip-all -optl-Wl,--gc-sections" \ --enable-library-vanilla \ --disable-library-profiling \ --disable-shared wasm32-wasi-cabal build cp $(wasm32-wasi-cabal list-bin exe:haskell-wasm) output/haskell-wasm.wasm $(wasm32-wasi-ghc --print-libdir)/post-link.mjs -i output/haskell-wasm.wasm -o output/ghc_wasm_jsffi.js

The highlights are the following:

  • -optl-mexec-model=reactor - This is a WASM option which makes it setup so that the exports can be invoked multiple times after it is instantiated.
  • --export=hs_init,--export=hs_initializeThemeToggle - Export the general Haskell initialisation and the function which actually does the part we’re interested in.
  • post-link.mjs - This produces a special file used when hooking the WASM into the JavaScript that fires it up.

Most of the rest are oriented around getting the size of the compiled WASM bundle down.

Along with this there’s some additional use of wasm-opt to shrink the WASM even further but it’s irrelevant to getting things actually working.

The JavaScript

This is the part which took the longest in the end, I wish this was just a one-liner and really should be but this appears to be the state of the art right now:

// Import the FFI bindings and WASI shim import createGhcWasmJsffi from '/js/ghc_wasm_jsffi.js'; import { WASI, File, OpenFile, ConsoleStdout } from 'https://cdn.jsdelivr.net/npm/@bjorn3/[email protected]/dist/index.js'; let wasmInstance = null; let wasmExports = null; // Fetch the WASM file const wasmResponse = await fetch('/js/haskell-wasm.wasm'); if (!wasmResponse.ok) { throw new Error('Failed to fetch WASM: ' + wasmResponse.status + ' ' + wasmResponse.statusText); } const wasmBuffer = await wasmResponse.arrayBuffer(); // Set up exports proxy for FFI let exportedFunctions = null; const exportsProxy = new Proxy({}, { get: (_, property) => { if (!exportedFunctions) { throw new Error('WASM exports not initialized when accessing ' + String(property)); } return exportedFunctions[property]; } }); // Create GHC WASM FFI bindings const ghcWasmJsffi = createGhcWasmJsffi(exportsProxy); // Create WASI instance with console stdout const fds = [ new OpenFile(new File([])), // stdin (fd 0) ConsoleStdout.lineBuffered(msg => console.log(msg)), // stdout (fd 1) ConsoleStdout.lineBuffered(msg => console.error(msg)), // stderr (fd 2) ]; const wasi = new WASI([], [], fds); const wasiImports = wasi.wasiImport; // Instantiate the WASM module const wasmInstantiation = await WebAssembly.instantiate(wasmBuffer, { "wasi_snapshot_preview1": wasiImports, "ghc_wasm_jsffi": ghcWasmJsffi }); wasmInstance = wasmInstantiation.instance; exportedFunctions = wasmInstance.exports; wasmExports = wasmInstance.exports; // Initialize WASI with the instantiated module wasi.inst = wasmInstance; // Initialize the Haskell runtime wasmExports.hs_init(); // Initialize the theme toggle wasmExports.hs_initializeThemeToggle();

Even with a shim for the WASI side of things this feels like a lot of needless yak shaving. Getting any of this wrong often resulted in weird errors that aren’t at all very clear, especially when you’re only 2 hours into actually looking at WebAssembly.

The long and the short of this is that it’s loading and instantiating the WASM, somewhat akin to a weird linker and then via the exports property of the instance makes available to the caller the exported API.

The Haskell

Here’s the interesting bit:

{-# LANGUAGE ForeignFunctionInterface #-} module HaskellWasm ( initializeThemeToggle ) where import GHC.Wasm.Prim import Data.Enum.Circular -- Create a JavaScript callback from a Haskell IO action foreign import javascript "wrapper" makeCallback :: IO () -> IO JSVal -- DOM element access foreign import javascript unsafe "document.getElementById($1)" js_getElementById :: JSString -> IO JSVal -- Element property access foreign import javascript unsafe "$1.classList" js_getClassList :: JSVal -> IO JSVal foreign import javascript unsafe "$1.querySelector($2)" js_elementQuerySelector :: JSVal -> JSString -> IO JSVal -- Element property setters foreign import javascript unsafe "$1.textContent = $2" js_setTextContent :: JSVal -> JSString -> IO () -- ClassList operations foreign import javascript unsafe "$1.remove($2)" js_classListRemove :: JSVal -> JSString -> IO () foreign import javascript unsafe "$1.add($2)" js_classListAdd :: JSVal -> JSString -> IO () -- Event handling foreign import javascript unsafe "$1.addEventListener($2, $3)" js_elementAddEventListener :: JSVal -> JSString -> JSVal -> IO () -- LocalStorage operations foreign import javascript unsafe "localStorage.getItem($1)" js_localStorageGetItem :: JSString -> IO JSString foreign import javascript unsafe "localStorage.setItem($1, $2)" js_localStorageSetItem :: JSString -> JSString -> IO () -- Media query foreign import javascript unsafe "window.matchMedia($1).matches" js_windowMatchMediaMatches :: JSString -> IO Bool -- Document operations foreign import javascript unsafe "document.documentElement" js_getDocumentElement :: IO JSVal foreign import javascript unsafe "$1.setAttribute($2, $3)" js_elementSetAttribute :: JSVal -> JSString -> JSString -> IO () foreign import javascript unsafe "$1 === null" js_stringIsNull :: JSString -> Bool -- Theme data type data Theme = Light | Dark deriving (Show, Eq, Enum, Bounded) -- Convert theme to string for storage/attributes themeToString :: Theme -> JSString themeToString Light = toJSString "light" themeToString Dark = toJSString "dark" -- Convert string to theme stringToTheme :: JSString -> Maybe Theme stringToTheme s | js_stringIsNull s = Nothing | fromJSString s == "light" = Just Light | fromJSString s == "dark" = Just Dark | otherwise = Nothing -- Convert theme to icon themeToIcon :: Theme -> JSString themeToIcon Light = toJSString "🌞" themeToIcon Dark = toJSString "🌙" -- Apply theme to the page applyTheme :: Theme -> IO () applyTheme theme = do -- Set data-theme attribute on document element docElement <- js_getDocumentElement js_elementSetAttribute docElement (toJSString "data-theme") (themeToString theme) -- Update button icon to show the NEXT theme (what you'll switch to) button <- js_getElementById (toJSString "theme-toggle-button") iconElement <- js_elementQuerySelector button (toJSString ".theme-icon") js_setTextContent iconElement (themeToIcon (csucc theme)) -- Store theme in localStorage js_localStorageSetItem (toJSString "theme") (themeToString theme) -- Get current theme from localStorage or media query getCurrentTheme :: IO Theme getCurrentTheme = do stored <- js_localStorageGetItem (toJSString "theme") case stringToTheme stored of Just theme -> pure theme Nothing -> do prefersDark <- js_windowMatchMediaMatches (toJSString "(prefers-color-scheme: dark)") pure $ if prefersDark then Dark else Light -- Toggle theme to the next value using circular enum toggleTheme :: IO () toggleTheme = do currentTheme <- getCurrentTheme let nextTheme = csucc currentTheme applyTheme nextTheme -- Initialize the theme toggle functionality initializeThemeToggle :: IO () initializeThemeToggle = do -- Get and apply initial theme currentTheme <- getCurrentTheme applyTheme currentTheme -- Get the button element button <- js_getElementById (toJSString "theme-toggle-button") -- Get the classList and manipulate it classList <- js_getClassList button js_classListRemove classList (toJSString "hidden") js_classListAdd classList (toJSString "theme-toggle") -- Create a JavaScript callback for the toggle function callback <- makeCallback toggleTheme -- Add click handler to the button js_elementAddEventListener button (toJSString "click") callback -- Foreign exports for WASM - makes functions callable from JavaScript foreign export ccall "hs_initializeThemeToggle" initializeThemeToggle :: IO ()

There’s clearly 3 parts to this:

  • Special functions that call JavaScript so that we can interact with the web page.
  • Regular old Haskell code.
  • A special export that makes a Haskell function available to the calling JavaScript.

The "wrapper" calling convention is special - it tells GHC to generate JavaScript code that properly marshals between JavaScript function calls and Haskell IO actions. The generated FFI binding looks like:

(...args) => __exports.ghc_wasm_jsffi_callback($1, ...args)

This wrapper handles Haskell runtime context, threading, and ensures the callback can be called from JavaScript as a normal function.

It makes use of the csucc function from the circular-enum package, which satisfies our requirement for an additional package but also makes handling the toggling very simple and everything would continue to work if I added a third theme.

Wait A Second

If it wasn’t clear by now that little icon up in the top right of this very page is what I implemented, with that logic powering the toggling and defaulting of the theme.

Conclusion

Going back to the goals, while it does have a lot of shim JS, it is just that and all the logic is in the Haskell code. Plus it made use of a non-standard library, so that part is proved out as well. Plus it “just works”, once all of those tedious bits are put into place.

The output after all the shrinking is a 791KB WASM file and 3KB of FFI bindings to make this all work, which isn’t that crazy given how much is packed in there but the stock JavaScript approach to this would be probably less than the FFI bindings in size.

One thing that crossed my mind once I had all the workings up and running was that it would be fairly easy to create a rudimentary React-like library and the entire page would be controlled by Haskell that way. I’m not sure if I’d particularly want to, but the important part is that it’s totally possible.

Either way, this was a fun (albeit at times frustrating getting it to work) little experiment and I hope that it becomes even easier in the future.

Read Entire Article