A bit more than a year ago, I started an exciting open-source project. The goal was to improve my understanding of backend development by the creation of a toy back-end micro-framework. The article is dedicated to the results of my work.
I selected Elm as a platform for my project due to a lack of existing back-end infrastructure and dynamic community, which I expected to attract by my creation. Unfortunately, I was not aware of the underlying change in internal policies, which led to a complete ban on any native code on third-party Elm packages. The fact caused a misunderstanding, which didn’t let me get any collaboration from a core Elm development team. So, I decided to finish it anyway and let’s take a look at the outcome.
router: Request String -> Mode String (Answer String State String)
router =
-- Default router prints requests with specified prefix as default actions
logger "Request"
-- Synchronously handle GET request to "/save/local",
-- check local state for a value based on session cookei
|> getSyncState paths.local getSessionLocal
-- Synchronously handle POST request to "/save/local",
-- save value to local state based on session cookei
|> postSyncState paths.local postSessionLocal
-- Asynchronously handle GET request to "/save/db",
-- check disk state for a value based on session cookei
|> get paths.db getSessionDB
-- Asynchronously handle POST request to "/save/db",
-- save value to disk state based on session cookei
|> post paths.db postSessionDB
-- Asynchronously handle GET request to "/"
-- Reply with "./public/index.html"
|> get (p "/") getIndex
-- statically serve files from "./public/"
|> static any "./public/"
-- Asynchronously redirect "/index" to "/"
|> get (p "/index") (\ _ -> succeed << Redirect <| "/")
-- Fallback, match to any path, take entire unhandled address,
-- Reply with a string value which specifies that the path does not exist
|> getSync str getInvalid
The text below is a shortened version of the original documentation published as a readme file of the project. It has a bit more details regarding the API and architecture of the project. Also, there are seed and demo projects to help you develop your application with the Board.
Motivation
Nowadays, almost every cloud platform offers possibilities for the seamless deployment of Node.js applications. The goal of the project is to combine deployment capabilities of Node.js and the safety of statically typed purely functional languages for the rapid development of a small micro-service application.
The main reasons Elm was chosen over GHCJS or PureScript are a steeper learning curve, excellent documentation, active community and build-in state management system. It also has no way to be used on a back-end, so it was rather cool to be first.
Implementation
Board was partly inspired by Spock, Express and other Sinatra like frameworks. Typesafe URL parsing planned to be one of the main features. The parsing engine was moved to a separate project available for everybody at Elm package manager as Pathfinder.
Router
It is the fundamental part of an application. Basically, it is just a function that defines the process of turning Request
object into Response
one. Request
object describes essential information about the incoming inquiry.
Response
is a representation of the server reply. The object is matched with a client by a request's id. Board can create an initial response for a Request
. Shared.getResponse
function constructs an empty response with an id of the provided request record.
makeStringResponse : Request a -> String -> Response b
makeStringResponse req text =
let
res = Shared.getResponse req
in
{ res
| content = Text "text/plain" text
, id = req.id
, status = ok
}
Routing combinators
A router function is composed out of several custom request handling functions and by the routing combinators. The combinators are represented by a function that takes a path description as a first argument and handler for the specified address as the second one. Pathfinder is utilized to describe the URL, which triggers the path handler. The handling function is responsible for turning the request record and params extracted by the URL parsers into one of the three possible results:
- Redirection to a new path
- Replying with an appropriate response record
- Passing the request further, possible with attached cargo
router =
logger "Request"
|> getSyncState p "/local" getSession
getSession (param, req) state =
let
sessionTag =
getSessionTag req state
sessionValue =
Dict.get sessionTag state
in
( state
, withDefault "No value for your session" sessionValue
|> makeStringResponse req
|> Reply
)
use
routing combinators are capable of handling any types of HTTPS requests while get
, post
, put
and delete
are only working with correspondent HTTP methods.
Stateless and Stateful
State management is not a trivial task for a purely functional application. Board utilizes Elm’s architecture to provide handy tooling for accessing and modifying an application state.
...
|> getSyncState any (\ (param, req) state -> (state, Redirect "/index.html") )
There is a particular type of rout handlers capable of providing access to the state of the application. The access is granted by transactions. Instead of returning the AnswerValue
record itself, a route handler attached by such a routing combinator returns a transaction from a current state to tuple which composes state and AnswerValue
. State-less combinators are not capable to access current state.
...
|> getSync any (\ (param, req) -> Redirect "/index.html")
Sync and Async
On another hand, the route handler can be represented by an atomic as well as an asynchronous operation. Atomic operations are usually related to some processing of request’s data, parsing or local state modifications. Async ones are typically associated with file handling, database manipulations or communication with third-party services. Task handles the asynchronous nature of the actions.
Synchronous processing usually sequentiality handles the request and immediately returns a correspondent response.
getSessionLocal : ( b , Request a) -> State -> ( State, AnswerValue value state error )
getSessionLocal (param, req) state =
( state
, getSession param req state
)
Async processing is usually caused by awaiting an asynchronous action performed based on a handled request.
getSessionDB : ( b , Request a) -> Task x (AnswerValue value state error)
getSessionDB (param, req) =
readDict
|> map (getSession param req)
Initial router
Routing combinators are responsible for combining an existing router with a new path handler. So, therefore, a first router is needed. It is represented by any function which satisfies the following signature Request String -> Mode String (Answer String State String)
. The function is going to be called once for every request. It might execute some parsing or authentication actions. Result
of the actions can be propagated by a Cargo
property of a Request
record.
routerWithLogger =
-- Default router prints requests with specified prefix as default actions
logger "Request"
router =
-- No default actions at an empty router
empty
Node.js server
Board uses calls to native Node.js API to establish the HTTP/HTTPS connection. An incoming Request
object is processed and converted into a Request
record exposed to Elm code. Response
object is placed on a Map. The object is popped up from the Map based on a Response
record created as output of Elm code. From time to time, the Map
is cleaned out of Responses
, which are older than the timeout.
File handling
File handling is implemented via a very simple library based on Node.js fs
. Practically it contains functions to read, write and parse files. Files are represented by a higher-order function which takes a function from Node.js Buffer
to arbitrary Elm type. The data itself is enclosed inside of closure so that it is not directly accessible at the Elm side without proper handling.
getFile : String -> ( b, Request a ) -> Task String (AnswerValue value state error)
getFile path (param, req) =
let
res = getResponse req
makeResponse file =
{ res
| content = Data (File.getContentType path) file
, id = req.id
, status = ok
}
in
path
|> File.read
|> map makeResponse
|> map Reply
There are two standard parsers: string
and dict
. Also, there are functions specialized in the encoding of Elm types to File: fromString
and fromDict
.
saveSessions : ( State, a ) -> Task String a
saveSessions (state, res) =
state
|> File.fromDict
|> File.write "public/db.json"
|> map (\ _ -> res)
The last but not least there is getContentType
function, which returns content-type based on the file name. The function is utilized by static
.
router =
-- No default actions at empty router
empty
-- statically serve files from "./public/"
|> static any "./public/"
Known limitations
It was an experimental project which was mainly done to investigate the possibility of adapting Elm architecture for back-end development at the same time as improving my knowledge of Node.js APIs.
Due to the nature of Elm architecture and Node.js, the system in a current condition is not capable of handling multi-threaded applications. Implementation of such a functionality is way beyond the scope of the project right now.
The project was started right before Elm 0.19 was released. The version of Elm dramatically changed the way native code is handled. Native code is entirely forbidden for third-party libraries since the release. Therefore the project didn’t get any support from the mainstream Elm community, and it will never be available at the package manager. Also, due to dramatic changes in the infrastructure of Elm, the 0.18 and older versions might be eventually discontinued.
Since it is essentially a single person pet project, there is a significant lack of testing, especially the production one. I will personally be happy to see the project based on the library, but you have to be aware of risks.
The micro-framework currently supports only HTTP and HTTPS. Sockets are out of scope.
Some future development is required at following directions: authentication, cookies and file handling.
Future plans
Due to recent changes in a platform, the project ended up to be just a proof of concept. Since it is not possible to update it for the newer version of Elm, it is also not possible to publish it in Elm package manager, because of policy regarding native modules which are an essential part of the system in this case.
PureScript seems to be the best option for migration of the project, but lack of Elm Architecture would require a reconsideration of the state management system. At the same time, it will open an opportunity to utilize and advantages of PureScript type system like Type Classes and Higher-kind types. It might be especially useful for the implementation of the URL parsing eDSL.
Another viable opportunity is GHCJS, but in my point of view, it is overkill since there are many brilliant back-end frameworks for Haskell and there is no need to mess around with such a complication as a translation to JS and utilization of Node.js infrastructure.
Top comments (2)
This looks cool. The combinator-based approach reminds me of F#'s Suave.IO web server library.
Thanks a lot! I don't have much experience with F#, but it feels like now I have to take a look. It was actually inspired by Express and Sinatra because I am most familiar with them.