Fork me on GitHub

Micromanaging Haste.App dependencies

While the trick described in Haste and cabal files works well when you want to build the same Cabal project using Haste and GHC, it does nothing to alleviate the problem of dependency bleed in Haste.Apps: server side dependencies creeping into the client and vice versa. While this is not necessarily a big deal, investing some time upfront in separating client and server dependencies from each other can yield nice returns in the form of shorter compile times and less wasted disk space.

The trick described in this post uses the C preprocessor Haskell extension to conditionally compile your Haste.Apps in such a way that dependency bleed is avoided, and is quite closely related to the techniques used to implement Haste.App itself.

Haste preprocessor symbols

Just like you can use impl(haste) to distinguish between compilers in your .cabal file, you can use the preprocessor symbol __HASTE__ to check if the file is currently being compiled with Haste or not, and even which version of the compiler is in use:

-- prog.hs
{-# LANGUAGE CPP #-}
#ifdef __HASTE__
main = putStrLn ("Using Haste, version " ++ show __HASTE__)
#else
main = putStrLn "Not using Haste"
#endif
$ runghc prog.hs
"Not using Haste"
$ hastec --onexec prog.hs ; js prog.js
"Using Haste, version 503"

The version number, 503, indicates that the version of Haste used to compile this program was 5.3, similar to how GHC (and Haste, to maintain GHC compatibility) defines the __GLASGOW_HASKELL__ preprocessor symbol.

While we will not be using the version information for this particular endeavour, conditional compilation based on the presence of __HASTE__, or lack thereof, is crucial to separating client and server dependencies.

Different builds, different implementations

Once you start using partially different dependency lists for the client and server parts of your Haste.App, you may want to go a bit further down that particular rabbit hole. While it’s perfectly possible to install all dependencies for your project in Haste’s package library as well as in GHC’s, you might not always want to. Installing server dependencies into Haste’s package library and vice versa both takes up extra disk space and needlessly extends build times. Additionally, unless you’re using sandboxes, each change to the server or client package environment risks pushing it out of sync with its counterpart.

A certain application structure and judicious use of conditional compilation can counteract these drawbacks:

  • break out any code exclusive to the client or the server into separate Client and Server module hierarchies;
  • make sure that any shared code, including all types used in the communication between the client and the server, does not depend on either hierarchy;
  • use impl(haste) in your .cabal file as described above to separate client and server dependencies; and
  • import Client and Server modules conditional on Haste being the compiler or not, respectively.

Due to the clear separation between effectful client and server code, confining each to a separate monad which is only interpreted on the appropriate side of the network, the client part of a Haste.App program couldn’t care less about what is on the server side of a remote call. This means that we’re free to replace any remote Server computation with anything we want, including ⊥, as long as we only do it on the client. We can use this to ensure that server side dependencies don’t bleed over to the client.

To demonstrate this technique, consider a simple mailbox application where clients may either leave a message on the server, or pick one up. The server part of such an application may look like this:

module Server (post, recv) where
import Haste.App
import Data.IORef
import System.IO.Unsafe
import qualified Data.Map as M

{-# NOINLINE ref #-}
ref :: IORef (M.Map String String)
ref = unsafePerformIO $ newIORef M.empty

post :: String -> String -> Server ()
post k v = liftIO $ atomicModifyIORef ref (\m -> (M.insert k v m, ()))

recv :: String -> Server (Maybe String)
recv k = liftIO $ M.lookup k <$> readIORef ref

String messages are kept indefinitely unless overwritten in a Map indexed by String identifiers. This adds a dependency on containers, which we for the sake of this example want to avoid. So far, there is nothing tricky going on, however; just a straightforward implementation of the mailbox application API described earlier.

Now we add a second module, API, to our application. This is where things get interesting.

{-# LANGUAGE CPP #-}
module API where
import Haste.App

#ifndef __HASTE__
import Server
#define REMOTE(x) (remote x)
#else
#define REMOTE(x) (remote undefined)
#endif

data MailboxAPI = MailboxAPI
  { post :: Remote (String -> Server ())
  , recv :: Remote (Server String)
  }

newMailboxAPI :: App MailboxAPI
newMailboxAPI = MailboxAPI <$> REMOTE(Server.post)
                           <*> REMOTE(Server.recv)

This is where the (admittedly quite black and hairy) magic happens. We start by defining a REMOTE macro, which does different things depending on whether the module is compiled using Haste or GHC. If we are being compiled with GHC, REMOTE(Server.post) is equivalent to remote Server.post, bringing the server side post function from the Server module into scope on the client. If, on the other hand, we are being compiled with Haste, the function is imported with an undefined body, allowing us to skip the import of the Server module! Essentially, the Haste.App property that server side code is simply ignored on the client and vice versa gives us a get out of server dependency free card!

Armed with this macro, we can now go ahead and import our server side functions without having to worry about dependencies bleeding over to the client. For convenience, we define a type MailboxAPI to hold all the functions of our API, and a function newMailboxAPI to import the entire API. Doing this makes it easier to pass server side functions around on the client.

Now that we have our API, we can implement the client part of our program:

module Client where
import Haste.App
import Haste.DOM
import Haste.Events
import Shared

clientMain :: MailboxAPI -> Client ()
clientMain mbox = do
  p <- newElem "button" `with` [prop "textContent" =: "Post"]
  r <- newElem "button" `with` [prop "textContent" =: "Receive"]

  p `onEvent` Click $ \_ -> do
    k <- prompt "Key"
    v <- prompt "Value"
    onServer $ post mbox <.> k <.> v
    
  r `onEvent` Click $ \_ -> do
    k <- prompt "Key"
    v <- onServer $ recv mbox <.> k
    alert (show v)

  setChildren documentBody [p, r]

The clientMain function takes a MailboxAPI as its input. This is useful because it allows us to separate the call sites of the API from its import site, something which would be quite cumbersome if we imported each function separately.

To keep the code short, we opt for a very ugly minimalist UI: one button for posting messages and one for receiving them, using JavaScript prompts for text input. When the Post button is clicked, the value provided by the user is written to the mailbox identified by the key similarly provided, and when Receive is clicked the message stored in the specified mailbox is displayed to the user.

Now all that is left is to give the application an entry point:

{-# LANGUAGE CPP #-}
module Main where
import Haste.App.Standalone
import API

#ifdef __HASTE__
import Client
#else
#define clientMain (\_ -> return ())
#endif

main = runStandaloneApp $ newMailboxAPI >>= runClient . clientMain

Again, we see some conditional compilation: we only import the client code if we are actually on the client, otherwise we simply do nothing. This is necessary to prevent the server from picking up client side dependencies, just as we prevented the client from picking up server side ones. Like with the REMOTE macro in Server, this works because the server ignores client side code and vice versa. We use newMailboxAPI from API to import the mailbox API, and pass it to runClient from Client. Note also the use of haste-standalone to avoid having to worry about web servers and application configuration.

Now all that is left is to update our cabal file with the new dependencies:

name:                myapp
version:             0.1.0.0
author:              Jane Doe
maintainer:          janedoe@example.com
build-type:          Simple
cabal-version:       >=1.10

executable myapp
  main-is:           Main.hs
  other-modules:     API, Client, Server
  build-depends:
    base             >= 4.8 && < 4.9,
    haste-standalone
  if impl(haste)
    build-depends:
      haste-lib      >= 0.5 && < 0.6
  else
    build-depends:
      haste-compiler >= 0.5 && < 0.6,
      containers
  default-language:  Haskell2010

When building your project, note that cabal and haste-cabal use the same caches and paths. This means that building your project with first one of them and then the other will require a full reconfiguration, and the binary from the first compilation will be overwritten by the second. In order to see our test app in action, we must thus take care to move the binary from the first compilation round out of harm’s way.

$ cabal configure && cabal build
$ mv dist/build/myapp/myapp ./
$ haste-cabal configure && haste-cabal build
$ ./myapp --embed dist/build/myapp/myapp
$ ./myapp
Application started on http://[your local ip]:8080

In conclusion

Although this preprocessor trickery is not strictly necessary to create Haste.Apps and although it is, admittedly, something of an ugly hack, there are still some nice benefits to be had from using it:

  • less “cabal hell”;
  • less wasted disk space; and
  • shorter build times.

Whether or not this outweighs the inherent ickiness of using preprocessor macros to sprinkle your programs with undefined is entirely up to you.