HTMX served on the browser with WebAssembly

A Haskell project for generating an HTMX powered website and request routing without a server.

Introduction

Recently I’ve written some posts showcasing projects that serve HTMX endpoints with APIs that functionally generate HTML, i.e., this post for Haskell and this post for Python which more background. However, since the demo projects I made for these posts don’t rely on any backend services such as databases, it made me wonder: “can the API serving the HTMX endpoints live entirely on the browser?” If so, one can use HTMX in static websites.

It’s a strange idea to be sure, but if they way HTMX provides interactivity and dynamic content speaks to you, this may be an effective way of combining the benefits of HTMX with the benefits of statically generating websites (e.g., speed, caching, hosting costs - see this related post of mine).

Luckily, there’s this repo by Ernest Marcinko that allows one to substitute HTMX’s HTTP requests with JavaScript functions that capture query parameters / form data to serve HTML back. So, by compiling my existing API’s endpoints into WebAssembly, I can serve HTMX entirely on the browser!

However, a significant motivation of HTMX is eliminating cumbersome JavaScript normally needed for routing HTTP requests and responses. Thus, I believe that any solution for serving HTMX on the browser must auto-generate the JavaScript routing code. Moreover, if a new HTMX endpoint is added, no more code should be added to the backend (i.e., .wasm generating project) than would be needed in the original backend API server.

In this post, we will go over a solution based on the Haskell project behind this post; all the code is available in this public repo of mine.

The Project

A vomit draft refers to a draft (traditionally of a script or novel) where the author writes continuously, with minimal editing or back-tracking. The project of this post is an online text editor to facilitate vomit draft writing. The basic idea is that the user is given an active text-box, but if too much time has passes without any edits, that text is frozen, and a new text-box is opened (see the original post) for more details.

It could use many more features to prevent backtracking and editing, but the idea is to showcase HTMX beyond the usual example of data-entry forms.

Below, is a non-functioning preview of the idea; a working version is also available here, i.e., the version that serves the HTMX endpoints via a .wasm.

Vomit Draft Editor

Once you start typing, you can only stop typing for 30 seconds before your text is locked in!

Remaining: 30

The Solution

Ernest Marcinko’s htmx-serverless project allows us to add an attribute hx-ext="serverless" to any HTMX powered element so that any time it would fire an HTTP request, a JavaScript function can be run in its place. For example, the following HTML:

<p
    id="timer"
    hx-post="/tick"
    hx-trigger="every 1000ms"
    hx-target="#timer"
    hx-swap="outerHTML"
    hx-ext="serverless"
    hx-vals="js:{timeRemaining: 5}"
>
    Remaining: 5
</p>

Can be handled by the following:

htmxServerless.handlers.set('/tick', function(text, params, xhr){
    ...
});

Which catches any POST /tick call and injects the corresponding FormData content (that HTMX generates) into the params argument. Extra, named form-data can be injected via the hx-vals attribute, e.g., hx-vals="js:{timeRemaining: 5}" adds timeRemaining=5 to the JS FormData object.

Now, to get this handler to call our .wasm for the correct HTML response, and importantly such that we can generate these handles, we serve all hx- requests with a single .wasm function dispatch: String -> String with:

This allows us to generate handlers programmatically like so:

export function genHandler(endpoint) {
  /*Takes an endpoint like POST   /tick
    with form urlencoded:         boxes=a&boxes=b&timeRemaining=3
    and creates a new endpoint:   /tick?boxes=a&boxes=b&timeRemaining=3
    then calls the wasm:          dispatch(/tick?boxes=a&boxes=b&timeRemaining=3)
  */
  return function (text, params, xhr) {
    const qs = new URLSearchParams(params).toString();
    const payload = endpoint + "?" + qs;
    const html = callWithString(inst.exports.dispatch, payload);
    return html;
  };
}

Where our WebAssembly instance inst and callWithString are part of a module wasm-dispatcher.js, and we generate handlers in the HTML with:

<script type="module">
    import { genHandler } from "./static/wasm-dispatcher.js";
    htmxServerless.handlers.set("/tick", genHandler("/tick"));
    htmxServerless.handlers.set("/reset-timer", genHandler("/reset-timer"));
    htmxServerless.handlers.set("/finish", genHandler("/finish"));
    htmxServerless.handlers.set("/close-block", genHandler("/close-block"));
    htmxServerless.handlers.set("/times-up", genHandler("/times-up"));
</script>

Which in turn is generated in our Ui.hs in a straightforward manner by:

handlers ["/tick", "/reset-timer", "/finish", "/close-block", "/times-up"]

handlers :: [Text] -> HTML
handlers routes =
  script_ [type_ "module"] $
    "import { genHandler } from \"./static/wasm-dispatcher.js\";\n"
      <> foldMap registerHandler routes

registerHandler :: Text -> Text
registerHandler route =
  "htmxServerless.handlers.set(\"" <> route <> "\", genHandler(\"" <> route <> "\"));"

Furthermore, the dispatch calls are redirected in our .wasm to our HTML producing functions in Ui.hs by the code block below. Importantly, the .wash API we’ve created involves a similar volume of code to an equivalent Servant API. Albeit without the sophisticated, automated parsing and validation Servant provides.

type QueryMap = Map Text [Text]

handler :: String -> IO String
handler input = do
  putStrLn ("Got input: " ++ input)

  -- split /tick?boxes=a&boxes=b&timeRemaining=3 into (/tick, boxes=a&boxes=b&timeRemaining=3)
  let (path, _) =
        case break (== '?') input of
          (p, '?' : q) -> (p, q)
          (p, _) -> (p, "")

  let qMap = parseQuery $ T.pack input
  let model = buildModel qMap
  let timeRemaining = lookupInt "timeRemaining" qMap

  let html =
        case path of
          "/" ->
            renderHomepage
          "/reset-timer" ->
            renderTimer defaultTimeLimit model
          "/close-block" ->
            renderClosedAndNewBlock model
          "/finish" ->
            renderFinishedContainer model
          "/times-up" ->
            renderTimesUpContainer model
          "/tick" -> do
            case timeRemaining of
              Just n -> renderTimer (n - 1) model
              Nothing -> error "/tick timeRemaining missing!"
          _ -> error $ "Unknown route: " ++ path

  return . LT.unpack . renderText $ html

parseQuery :: T.Text -> QueryMap
--- < 10 line function

Thus, we write < 100 lines of JS utilities once (the .wasm reader and JS function to call our the .wasm’s dispatch function, and the genHandler function), and then every time we need a new hx- endpoint, we implement the handler (i.e., HTML generation) in our Haskell API, and simply add an entry to the corresponding block:

handlers ["/tick", "/reset-timer", "/finish", "/close-block", "/times-up"]

handlers :: [Text] -> HTML
...

Conclusion

So, hopefully this post gives you a good starting point for how to serve HTMX entirely in the browser. One can of course skip the .wasm integration and use htmx-serverless with all-JavaScript handlers. Perhaps there are JavaScript implementations for functionally generating JavaScript that are good enough; I’m just a big fan of Haskell and its implementation Lucid — see this post for more background on why.

If you do go the .wasm API route, I’m sure you could come up with better interfaces between the wasm-dispatcher.js and .wasm API than I did here. But do checkout the code for this project in the repo; a lot of the tough work (for me) ended up centring around:

The solutions to these problems primarily lie in the deploy.sh script, wasm-dispatcher.js and .cabal files. So hopefully my solutions can save you some trouble there too.

Happy coding!

Tags: Software EngineeringWeb DevelopmentHaskell

Comments

Comments are a static snapshot of a GitHub Issue. Please leave a comment and after reviewing it, I'll rebuild the site with it.