Why I use the Twain web framework
- 2023-07-01 -I've written articles on this blog about using Spock, Scotty, and Twain, in that order. That order mostly represents my timeline of moving from one web library to the next.
Recently I've been using Twain for all of my newer web apps instead of Scotty and previously Spock, and I wanted to share why.
What they have in common
The three web libraries mentioned above have a lot in common, the most obvious among them is the layer on which they are built - wai. WAI, or Web Application Interface, can be considered an abstraction between web applications and web servers, or a specification for web applications if you will.
A WAI Application, which is defined as:
type Application =
Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
encapsulates the essense of what a web application is - it takes a request description, formulates a response (which might require us to do IO, like hitting a database), and calls a function that can send that response back to the user. That way we can built our web applications without handling low-level details like sockets and connections - we leave that to web servers, such as warp, and instead only deal with requests and responses.
Spock, Scotty, and Twain, are abstractions over WAI - they make it easier for us to handle requests and emit responses in compositional ways, using sometimes similar and sometimes different techniques.
Examples
Here's an example of the same simple web app written in each library:
hello-http.cabal
cabal-version: 3.0
name: hello-http
version: 0.1.0.0
executable hello-spock
main-is: hello-spock.hs
build-depends: base ^>=4.18.0.0, text ^>= 2.0.2, aeson ^>= 2.1.2.1, Spock ^>= 0.14.0.0, warp ^>= 3.3.28
hs-source-dirs: .
default-language: Haskell2010
ghc-options: -O -Wall -threaded -with-rtsopts=-N
executable hello-scotty
main-is: hello-scotty.hs
build-depends: base ^>=4.18.0.0, text ^>= 2.0.2, aeson ^>= 2.1.2.1, scotty ^>= 0.12.1, warp ^>= 3.3.28
hs-source-dirs: .
default-language: Haskell2010
ghc-options: -O -Wall -threaded -with-rtsopts=-N
executable hello-twain
main-is: hello-twain.hs
build-depends: base ^>=4.18.0.0, text ^>= 2.0.2, aeson ^>= 2.1.2.1, twain ^>= 2.1.2.0, warp ^>= 3.3.28
hs-source-dirs: .
default-language: Haskell2010
ghc-options: -O -Wall -threaded -with-rtsopts=-N
cabal.project
packages: hello-http.cabal
source-repository-package
type: git
location: https://github.com/agrafix/Spock.git
tag: 40d028bfea0e94ca7096c719cd024ca47a46e559
subdir: Spock-core
hello-spock.hs
{-# LANGUAGE OverloadedStrings #-}
{-# language ScopedTypeVariables #-}
import Data.Aeson (Value)
import Data.String (fromString)
import Data.Text (Text)
import qualified Web.Spock as Spock
import qualified Web.Spock.Config as Spock
main :: IO ()
main = do
config <- mkConfig
Spock.runSpock 3000 $
Spock.spock config routes
mkConfig :: IO (Spock.SpockCfg () () ())
mkConfig = Spock.defaultSpockCfg () Spock.PCNoDatabase ()
routes :: Spock.SpockM () () () ()
routes = do
Spock.get "/" $
Spock.text "hi"
Spock.get ("/id/" Spock.<//> Spock.var) $ \(id' :: Int) -> do
(name :: Text) <- Spock.param' "name"
Spock.setHeader "x-powered-by" "benchmark"
Spock.text (fromString (show id') <> " " <> name)
Spock.post "/json" $ do
(value :: Value) <- Spock.jsonBody'
Spock.json value
hello-scotty.hs
{-# LANGUAGE OverloadedStrings #-}
{-# language ScopedTypeVariables #-}
import Data.Aeson (Value)
import Data.String (fromString)
import Data.Text.Lazy (Text)
import qualified Web.Scotty as Scotty
main :: IO ()
main =
Scotty.Scotty 3000 routes
routes :: Scotty.ScottyM ()
routes = do
Scotty.get "/" $
Scotty.text "hi"
Scotty.get "/id/:id" $ do
(id' :: Int) <- Scotty.param "id"
(name :: Text) <- Scotty.param "name"
Scotty.setHeader "x-powered-by" "benchmark"
Scotty.text (fromString (show id') <> " " <> name)
Scotty.post "/json" $ do
(value :: Value) <- Scotty.jsonData
Scotty.json value
hello-twain.hs
{-# language OverloadedStrings #-}
{-# language ScopedTypeVariables #-}
module Main where
import Data.Aeson (Value)
import Data.String (fromString)
import Data.Text (Text)
import qualified Web.Twain as Twain
import Network.Wai.Handler.Warp (run, Port)
main :: IO ()
main = runServer 3000
runServer :: Port -> IO ()
runServer port = do
putStrLn $ unwords
[ "Running twain app at"
, "http://localhost:" <> show port
, "(ctrl-c to quit)"
]
run port mkApp
mkApp :: Twain.Application
mkApp =
foldr ($)
(Twain.notFound $ Twain.send $ Twain.text "Error: not found.")
routes
routes :: [Twain.Middleware]
routes =
[ Twain.get "/" $
Twain.send $ Twain.text "hi"
, Twain.get "/id/:id" $ do
(id' :: Int) <- Twain.param "id"
(name :: Text) <- Twain.queryParam "name"
Twain.send
$ Twain.withHeader ("x-powered-by", "benchmark")
$ Twain.text
$ fromString (show id') <> " " <> name
, Twain.post "/json" $ do
(value :: Value) <- Twain.fromBody
Twain.send
$ Twain.json
$ value
]
Deeper dive
Let's look at the three libraries in more detail. In particularily, we will look at three important operations:
- Routing - decide which request is handled using which handler
- Handling requests - such as getting request information to be used to compose a response
- Composing and sending a response
Spock
Routing
Let's look at this routes code from our example.
routes :: Spock.SpockM () () () ()
routes = do
Spock.get "/" $
Spock.text "hi"
Spock.get ("/id/" Spock.<//> Spock.var) $ \(id' :: Int) -> do
(name :: Text) <- Spock.param' "name"
...
As we can see, Spock uses a monadic interface to compose routes.
Each route is built using an
http verb
that takes a Path
and an Action
to run if the route matches. If it does not, the next route is tried.
The paramater we defined in the /id/
route is passes
as an argument to the Action
.
Note that we've never used a value returned from each route in a following route, so maybe the power of monads is unnecessary for composing routes?
The value level ergonomics looks pretty good to me, let's look at some of the types!
type SpockM conn sess st = SpockCtxM () conn sess st
type SpockCtxM ctx conn sess st = SpockCtxT ctx (WebStateM conn sess st)
get :: HasRep xs => RouteSpec xs ps ctx conn sess st
type RouteSpec xs ps ctx conn sess st = Path xs ps -> HVectElim xs (SpockActionCtx ctx conn sess st ()) -> SpockCtxM ctx conn sess st ()
data Path (as :: [Type]) (pathState :: PathState)
var :: (Typeable a, FromHttpApiData a) => Path '[a] 'Open
(<//>) :: forall (as :: [Type]) (bs :: [Type]) (ps :: PathState). Path as 'Open -> Path bs ps -> Path (Append as bs) ps
Spock uses type-level vectors to represent a route, and the types are quite verbose. I'm not going to explain what's going on here, but if you squint real hard it kind of makes sense. It's pretty impressive that although the types are quite complex, that doesn't really get in the way of value level programming.
Handling requests
Once we've matched a request with a handler (SpockAction
),
we start handling the request. Often this will include fetching more
information about the request, such as the body, headers, cookies, files,
and so on. While in the Action
context, we can fetch the entire
wai Request
using request :: MonadIO m => ActionCtxT ctx m Request
,
or with other dedicated functions.
We can also perform IO
actions, like hitting a database, inside an Action.
This part is quite similar between the three libraries.
Composing a response
Composing a response is quite a stateful process in Spock.
Instead of building a response object, we use mutating
functions
inside the context of an Action
to set headers and cookies,
and when we want to send the response we call a function such as
text :: MonadIO m => Text -> ActionCtxT ctx m a
.
This approach kind of flips the continuation-passing style of WAI -
we no longer see the function Response -> IO ResponseReceived
in
type Application =
Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
as it is being taken care of by the framework.
Next, let's look at Scotty's way of doing things.
Scotty
Routing
routes :: Scotty.ScottyM ()
routes = do
Scotty.get "/" $
Scotty.text "hi"
Scotty.get "/id/:id" $ do
(id' :: Int) <- Scotty.param "id"
...
The value level is quite similar to Spock. We also use a monadic interface to compose routes, and use http verbs which take a route pattern and an Action to build a route.
Note than the parameter in the /id/
route is fetched inside the Action
and is not passed as a function argument like in Spock.
Fair enough, let's look at the types!
type ScottyM = ScottyT Text IO
newtype ScottyT e m a = ScottyT { runS :: State (ScottyState e m) a }
get :: RoutePattern -> ActionM () -> ScottyM ()
data RoutePattern
(RoutPattern
is an opaque data type which can be instantiated with the
these functions)
Handling requests
Similarily to Spock, we also have an Action
with monadic interface
that can run IO
action. Getting more information about the request
while in the Action
context can be done with
using request :: ActionM Request
,
or with other dedicated functions.
The one important difference is that while path parameters
are passed as function arguments in Spock, they need to be retrieved
with param :: Parsable a => Text -> ActionM a
in
Scotty. And in both cases we use typeclasses and sometimes
type annotations to declare what kind of data are we looking for
and how to parse it.
Composing a response
With Scotty we still need to mutate the context to form a response, and we use the same pattern to send response.
Versus Spock
All in all, Scotty is somewhat similar but also a lot simpler than Spock. Spock tries to do more work and provide more features than Scotty, like supports sessions, database pooling and csrf protection as part of Spock, which is probably why it includes some of this extra complexity.
Next, we'll look at something a bit different with Twain.
Twain
mkApp :: Twain.Application
mkApp =
foldr ($)
(Twain.notFound $ Twain.send $ Twain.text "Error: not found.")
routes
routes :: [Twain.Middleware]
routes =
[ Twain.get "/" $
Twain.send $ Twain.text "hi"
, Twain.get "/id/:id" $ do
(id' :: Int) <- Twain.param "id"
...
]
Instead of using a monadic interface like Spock and Scotty, Twain uses function composition. Similar to the two previous libraries, each http verb takes a path pattern and a responder, and builds a route. We then compose the routes together by folding over the list and include the final route, the notFound response, as the base case where all unmatched request go.
The parameter in the /id/
route is fetched inside the Responder
like in Scotty, and is not passes as a function argument like in Spock.
It's types time!
type Middleware = Application -> Application
Middleware is a wai type that lets us modify an existing wai application.
get :: PathPattern -> ResponderM a -> Middleware
data PathPattern = MatchPath (Request -> Maybe [Param])
Handling requests
Similarily to Spock and Scotty, Twain also
provides functions
to get request information (or the whole request) inside a Responder
context. And like Scotty it doesn't take path parameters as arguments
to responders and instead uses a param
function.
Composing a response
Unlike Spock and Scotty, in Twain we build a wai
Response
object with functions like text :: Text -> Response
,
and transform it with functions like status :: Status -> Response -> Response
and withHeader :: Header -> Response -> Response
.
Once we've built the response of our dreams, we can send it
back to the client using the send :: Response -> ResponderM a
function. More on responses in
this section.
Versus Spock and Scotty
Personally, I think Twain takes a nicer approach than Spock and Scotty:
- For routing - it required the least amount of concepts (no monadic interface, monad transformers, or type-level vectors), and I've found the types to be nicer than Spock or Scotty.
- For composing responses - I prefer to build a response object explicitly and then send it rather than using mutable state.
We've covered the interface section. But there are other parameters by which we evaluate libraries. Let's have a look at a few.
Other stuff
Popularity
Popularity matters.
If I were to rate the popularity of these libraries it would probably be Scotty, then Spock, then in the far far far back, Twain. I think almost no one uses Twain and that's a shame, and why I'm writing this blog post!
This means that you'll find less examples, tutorials, or help if you try to write a web application using Twain than if you were to write one using Scotty or Spock.
However, I did write a Twain tutorial (Building a bulletin board using Twain and friends) and have made a few simple web apps (such as jot) that can be used as examples.
Maintainance
Are these libraries well maintained? Could you maintain them yourself if you needed to?
Unfortunately I don't think any of these libraries are being actively developed. While for Twain there isn't really a request for new features or bugs to solve, both Scotty and Spock have quite a list of older issues, some which stem from fairly old design decisions which are not so easy to change.
In the Haskell world it is sometimes important to be able to maintain the libraries you use yourself. I think it is easier to do for Twain than Scotty or Spock. Here's why:
Codebase size
First, there's a lot less code to maintain! If I only count source code lines, I get the following results:
| Framework | LoC | Visualized |
| ------------------ | ---- | ----------------------- |
| Spock + Spock-core | 2301 | ####################### |
| Scotty | 906 | ######### |
| Twain | 545 | ##### |
Dependency footprint
Looking through cabal-plan
at the dependency footprint of Twain, Scotty, and Spock, it's clear that
Twain has fewer dependencies than Scotty, though not by much. Spock however
has more dependencies that the other libraries, pulling libraries such as
unliftio
, crypton
, fast-logger
and more.
This means less breaking changes and less maintainance work.
Knowledge requirements / Complexity
We've covered this briefly before, but Twain is the simpler library of the three in terms of implementation complexity. It doesn't use monads or exceptions to handle control flow, and does not include type-level programming or complex state handling. It's really just a thin layer on top of wai. Just look at the code!
It is true that Spock provides some things out of the box like session management, hooks, and database pooling, and that can be important for some use cases, but I think for many use cases these are not required, or can be handled more elegantly outside the web framework, and for these cases it is worth looking at simpler libraries.
Performance
I tried to benchmark and compare Spock, Scotty, and Twain against bun-http-framework-benchmark which uses bombardier.
This benchmark, of course, is very simplistic. But I think this benchmark can still give us some indication regarding which library is faster.
You can find reproduction instructions and try them yourself in this repo.
| Library | Get (/) | Params, query & header | Post JSON |
| ------- | ---------- | ----------------------- | ---------- |
| Spock | 31,321.19 | 25,015.61 | 28,924.38 |
| Scotty | 269,021.44 | 186,814.18 | 194,448.40 |
| Twain | 306,501.25 | 230,075.55 | 227,636.17 |
This is a very surprising result! Since the three libraries all use the same low-level library and server implementation under the wood, I expected them to perform somewhat similarily. What gives?
As it turns out there seems to be a memory leak in Spock.
I haven't tried to have a look why Scotty is slower than Twain though. Maybe you'd like to have a look?
Btw, these numbers are from my computer, rest assured that the top performers here still beat the JS libraries.
| Framework | Get (/) | Params, query & header | Post JSON |
| ---------------------- | ---------- | ---------------------- | --------- |
| vixeny (bun) | 122,374.39 | 102,359.3 | 89,576.88 |
| elysia (bun) | 121,397.09 | 95,221.77 | 92,058.81 |
| bun (bun) | 121,319.35 | 90,183.81 | 94,314.47 |
| nhttp (bun) | 113,231.44 | 86,465.28 | 82,933.61 |
| stricjs (bun) | 93,808.43 | 92,610.55 | 92,361.88 |
| abc (deno) | 93,515.77 | 92,291.91 | 92,614.05 |
| hyper-express (node) | 91,727.23 | 92,990.93 | 92,826.39 |
| uws (node) | 92,418.94 | 92,620.22 | 92,328.87 |
| acorn (deno) | 92,701.53 | 93,086.80 | 91,561.94 |
| cheetah (deno) | 92,664.57 | 92,102.08 | 92,392.08 |
| hono (bun) | 112,057.33 | 81,816.17 | 83,005.40 |
| fast (deno) | 92,933.12 | 91,761.82 | 91,642.67 |
| oak (deno) | 91,960.36 | 91,050.27 | 91,080.37 |
| bun-web-standard (bun) | 107,298.37 | 79,059.06 | 87,252.11 |
| adonis (node) | 91,624.29 | 90,389.19 | 90,897.86 |
| bun-bakery (bun) | 103,169.90 | 76,660.08 | 72,180.55 |
| hyperbun (bun) | 93,970.69 | 74,578.36 | 60,052.84 |
| baojs (bun) | 80,123.36 | 71,482.00 | 70,757.79 |
| nbit (bun) | 78,780.23 | 68,524.39 | 65,443.93 |
| fastify (node) | 47,631.96 | 42,963.56 | 30,199.74 |
| h3 (node) | 46,715.71 | 36,573.97 | 34,191.50 |
| koa (bun) | 39,475.29 | 35,897.86 | 18,362.88 |
| koa (node) | 27,051.64 | 23,878.42 | 16,617.84 |
| express (bun) | 18,977.15 | 17,122.74 | 10,816.04 |
| hapi (node) | 23,912.21 | 8,433.24 | 13,479.71 |
| express (node) | 9,231.05 | 8,755.90 | 7,020.60 |
| nest (node) | 8,615.43 | 7,899.08 | 6,459.58 |
Bugs
I've run into bugs and issues with Scotty and Spock that I was unsure how to solve, and haven't run into similar issues with Twain. But I've worked on more complex applications with Scotty and Spock than with Twain. I'm still confident that even if such issues will arise, I'll be able to fix them myself if needed.
But what about...?
You might ask "what about snap, happstack, IHP, servant, yesod, okapi, ...?", and the truth is that while I have tried some or even most of these libraries, For my usage, I don't need the additional value that they seem to provide, and I don't want to pay for the complexity they bring. But ymmv.
Conclusion
Twain is a tiny, reasonably fast, and easy to use web library. I feel it found the sweet spot between complexity and usefulness for a micro-framework, which is why I currently prefer it over other Haskell web frameworks.