09/2022

Elm Headless Worker

Elm is a functional programming language that compiles to JavaScript, it is mainly used to build browser and user interface applications. It is purely functional with a very strong type system and error handling, allowing us to build reliable and robust web applications.

In this note we will look into the Platform.worker and we will experiment Elm code in some JavaScript runtimes.

elm-server.jpg

I know what everyone's gonna say. But they're already saying it. We're mad scientists. We're monsters, buddy. We've gotta own it. Make a stand. It's not a loop. It's the end of the line.

-- Tony Stark, Avengers: Age of Ultron

Elm Worker

An Elm worker contains the core functionality of any Elm program:

worker :
{ init : flags -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
}
-> Program flags model msg
  • an initial state init that represents the starting point of our program
  • an update function that reacts to events that happen within our program
  • a list of subscriptions that can react to events that happen outside our program

It basically contains the "brain" of the application and it is very useful when we want Elm to make the calculations and logic without the need of a user interface.

Ports and JavaScript interop

Ports are the way we can communicate back and forward between our Elm code and any other JavaScript code.

In the following example we have 2 ports:

  • input the port that listens (subscribes) to events outside the Elm code. It decodes the input value to a String.
  • output the port that sends events from the Elm code to JavaScript. It encodes the output value to a String.
-- Simple.elm
port module Simple exposing (main)
port input : (Decode.Value -> msg) -> Sub msg
port output : Encode.Value -> Cmd msg
type Msg
= Incoming (Result Error String)
main =
Platform.worker
{ init = \_ -> ( (), Cmd.none )
, update =
\msg model ->
case msg of
Incoming arg ->
case arg of
Ok response ->
( model, "Hello " ++ response |> Encode.string |> output )
Err err ->
( model, Decode.errorToString err |> Encode.string |> output )
, subscriptions =
\_ ->
Decode.decodeValue Decode.string >> Incoming |> input
}

The JavaScript code to interact with the Elm worker looks something like this:

// simple.js
const {Elm} = require('simple')
const app = Elm.Simple.init()
app.ports.input.send('World')
app.ports.output.subscribe((content) => {
console.log(content)
})

Here we communicate with our Elm code by sending data into our input port and subscribing for data to our output port.

When we compile the Elm code everything becomes "just JavaScript" and then we can run it with the Node.js runtime as simple as node simple.js and it will output "Hello World".

And that's about it, we just ran "backend" code with a "frontend" programming language 🤯!

It is just JavaScript

So we can use Elm code as the "logic" for any Node.js application?

What if we would like to build a very simple server in Node.js but we would let Elm handle all the logic of rendering the pages?

Let's have a look at some code.

const http = require('http')
const {Elm} = require('server')
const app = Elm.Server.init()
http.createServer((req, res) => {
const outputCaller = (elmResponse) => {
res.writeHead(elmResponse.statusCode, {'Content-Type': 'text/html'})
res.end(elmResponse.body)
app.ports.output.unsubscribe(outputCaller)
}
app.ports.output.subscribe(outputCaller)
app.ports.input.send(req)
}).listen(8000)

In our JavaScript code we would have to create a server and we can use the Node.js http module. This allows us to create the server in a specific port and handle requests and responses.

It makes it pretty easy to source all the logic of the response into the Elm ports.

In our Elm code there's a bit more to unpack.

We will have to define an Input and Output type with the respectives decoder/encoder so we can handle the incoming and outgoing data.

With the incoming data we can easily build a router to handle every url the user navigates and return the data to be printed out in the page.

As Elm forces us to make sure all corner cases are handled (no runtime exceptions) we can make sure our router "just works".

port module Server exposing (main)
port input : (Decode.Value -> msg) -> Sub msg
port output : Encode.Value -> Cmd msg
type alias Input = { url : String, method : String }
type alias Output = { statusCode : Int, body : String }
type Msg = Incoming (Result Error Input)
result { statusCode, body } =
Encode.object
[ ( "statusCode", Encode.int statusCode )
, ( "body", Encode.string body )
] |> output
router arg =
case arg of
Ok { url, method } ->
case String.split "/" url of
_ :: [ "" ] ->
result
{ statusCode = 200, body = "<h1>Home page</h1>" }
_ :: [ "about" ] ->
result
{ statusCode = 200, body = "<h1>About page</h1>" }
_ ->
result
{ statusCode = 404, body = "<h1>Not found</h1>" }
Err err ->
result
{ statusCode = 503, body = Decode.errorToString err }
main =
Platform.worker
{ init = \_ -> ( (), Cmd.none )
, update =
\msg _ ->
case msg of
Incoming arg ->
( (), router arg )
, subscriptions =
\_ ->
Decode.decodeValue
(Decode.map2 Input
(Decode.field "url" Decode.string)
(Decode.field "method" Decode.string)
)
>> Incoming |> input
}

This was a lot! But hey, after compiling the Elm code we can just run node server.js and there will be a server with a couple of routes and all the routing and rendering is handled in Elm.

What about Deno?

Deno is a JavaScript runtime with TypeScript support out of the box and secure by default.

In order to write a Deno server with Elm we can just create a TypeScript file and make sure we're enabling JavaScript modules in our code.

import {createRequire} from "https://deno.land/std/node/module.ts"
const denoServer = Deno.listen({port: 8000})
const require = createRequire(import.meta.url)
const {Elm} = require('../dist/ServerElm')
const app = Elm.DenoServer.init()
async function serveHttp(conn: Deno.Conn) {
const httpConn = Deno.serveHttp(conn)
for await (const requestEvent of httpConn) {
const outputCaller = (elmResponse: { statusCode: number, body: string }) => {
requestEvent.respondWith(new Response(elmResponse.body, {
status: elmResponse.statusCode,
headers: {'Content-Type': 'text/html'}
}))
app.ports.output.unsubscribe(outputCaller)
}
app.ports.output.subscribe(outputCaller)
app.ports.input.send(requestEvent.request)
}
}
for await (const conn of denoServer) {
void serveHttp(conn)
}

On the Elm side of things, we only need to make a small change in our codebase in order to get the proper path of the URL. For that, we just need to parse the URL given by Deno's request event and extract the path:

path =
(Url.fromString url
|> Maybe.withDefault
{ protocol = Https
, host = ""
, port_ = Nothing
, path = ""
, query = Nothing
, fragment = Nothing
}
).path

After compiling the Elm code, we can just run the server with deno run --allow-read --allow-env=NODE_DEBUG --allow-net denoServer.ts and we will have a Deno server with all the routing and rendering handled in Elm.

What about Bun?

Bun is a new (depending on when you're reading this) JavaScript runtime with a huge potential, especially in terms of speed!

To build a server in Bun is pretty simple as it is built on top of web standards like Request and Response.

const {Elm} = require('../dist/ServerElm.js')
const app = Elm.BunServer.init()
Bun.serve({
fetch(request) {
const response = {body: '', statusCode: 200}
const outputCaller = (elmResponse) => {
response.body = elmResponse.body
response.statusCode = elmResponse.statusCode
app.ports.output.unsubscribe(outputCaller)
}
app.ports.output.subscribe(outputCaller)
app.ports.input.send({url: request.url, method: request.method})
return new Response(response.body, {
status: response.statusCode,
headers: {'Content-Type': 'text/html'}
})
},
port: 8000,
})

In this case we can still use the same Elm code as in the previous example, but in our JavaScript code we'll have to modify the response object when our subscription receives data from the Elm code as we need to return a Response instead of using the callback method.

After compiling the Elm code we can just run the server with bun run bunServer.js and all our pages will be routed and rendered with Elm code.

Now draw the rest of the owl!

So, turns out it is pretty easy to run Elm code as a backend of our application. Basically, any programming language that compiles to JavaScript should be able to run in any JavaScript runtime, (sadly) there's no magic to it.

In our examples the cases were quite simple, and we have some hardcoded HTML as String in order to build a page { statusCode = 200, body = "<h1>Home page</h1>" } a library like elm-html-string would allow us to actually build a proper HTML page and then render it without the need of hardcoded strings, we could probably also expose a /api/content route and output the content as JSON, or maybe even go off the rails and expose some XML.

But let's leave that for another time 😆

You're still here?

Thank you so much for reading this! Go ahead and check the source code used for all this examples on GitHub.

Until then!