Server side Elm with Phoenix

In my last post @mjackson and I got server side rendering of React setup. After that I was wondering if I could do something similar with Elm. I've played with Elm but not much so for this exercise I just grabbed the Todo app

Before we proceed

I'm going to use a very similar setup to the React server side rendering. One thing to note is that I found a small bug with min-document. At the time of writing there is a bug where it doesn't handle boolean html attributes correctly. I've added a PR to get this sorted, but for now we'll have to overwrite that dependency.

This uses the same method as the React STDIO method. We run a collection of IO servers that take a JSON blob and spit out JSON back to us with the rendered HTML.

You'll need to install Elm. I use brew

brew install elm
See the demo app

If you want to just see the demo application you can find it at hassox/phoenix_elm.

Server code

Generate a new application and follow getting webpack in place at http://matthewlehner.net/using-webpack-with-phoenix-and-elixir/. We're going to use it to generate our app.js bundle, and also our server bundle.

To generate our application bundle we'll use a pretty standard setup for Elm. Here's the webpack.config.js

// setup from http://matthewlehner.net/using-webpack-with-phoenix-and-elixir/

var ExtractTextPlugin = require("extract-text-webpack-plugin");
var CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: ["./web/static/js/app.js", "./web/static/css/app.css"],
  output: {
    path: "./priv/static/",
    filename: "js/app.js"
  },
  module: {
    noParse: /\.elm$/,
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["es2015"]
        }
      },
      {
        test: /\.elm$/,
        exclude: [/elm-stuff/, /node_modules/],
        loader: 'elm-webpack'
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract("css")
      },
      {
        test: /\.(png|woff|woff2|eot|ttf|svg)$/,
        loader: 'url-loader?limit=100000'
      },
    ]
  },
  resolve: {
    alias: {
      phoenix_html:
        __dirname + "/deps/phoenix_html/web/static/js/phoenix_html.js",
      phoenix:
        __dirname + "./deps/phoenix/web/static/js/phoenix.js"
    }
  },
  plugins: [
    new ExtractTextPlugin("css/app.css"),
    new CopyWebpackPlugin([{ from: "./web/static/assets" }])
  ],
  devServer: {
    inline: true,
    stats: 'errors-only'
  }
};

We've included an Elm Loader. Just for reference here's my package.json file:

{
  "repository": {},
  "dependencies": {},
  "devDependencies": {
    "babel-core": "^6.3.26",
    "babel-loader": "^6.2.0",
    "babel-preset-es2015": "^6.3.13",
    "copy-webpack-plugin": "^0.3.3",
    "css-loader": "^0.23.1",
    "elm-stdio": "^1.0.1",
    "elm-webpack-loader": "^1.1.1",
    "extract-text-webpack-plugin": "^0.9.1",
    "file-loader": "^0.8.5",
    "min-document": "hassox/min-document",
    "style-loader": "^0.13.0",
    "url-loader": "^0.5.7",
    "virtual-dom": "^2.1.1",
    "webpack": "^1.12.9"
  },
  "scripts": {
    "compile": "webpack -p"
  }
}

I mostly copied the react-stdio js module and tweaked it for Elm.

Ok. So this should be working. Lets get the Todo Elm code and put it in web/static/elm. Grab it from web/static/elm/Todo.elm. You'll also need to replace the web/static/css/app.css.

Note I had to tweak the css a little to remove the background image. Something isn't quite right in the webpack config.

At this point we need to make sure we have our elm dependencies installed.

elm-package install evancz/elm-html

Ok, so now we have our Elm file. Lets make it work. In you web/static/js/app.js file render out your Elm application.

import "phoenix_html"
import Elm from '../elm/Todo.elm'
Elm.embed(Elm.Todo, document.getElementById('elm-main'), {getStorage: null});

And lets tweak the layout web/templates/layout/app.html.eex

  <body>
    <div id='elm-main'></div>
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>

If you reload your page you should be see the application. Ok real progress. Now lets get our server bundle ready to communicate with the elm-stdio process.

We'll add another webpack config to build our server bundle. It looks like:

module.exports = {
  entry: "./web/static/js/server_render.js",
  output: {
    path: "./priv/server",
    filename: "js/main.js",
    library: "myComponent",
    libraryTarget: "commonjs2"
  },
  module: {
    noParse: /\.elm$/,
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["es2015"]
        }
      },
      {
        test: /\.elm$/,
        exclude: [/elm-stuff/, /node_modules/],
        loader: 'elm-webpack'
      },
    ]
  },
  resolve: {
    alias: {
      phoenix_html:
        __dirname + "/deps/phoenix_html/web/static/js/phoenix_html.js",
      phoenix:
        __dirname + "./deps/phoenix/web/static/js/phoenix.js"
    }
  }
};

Note that we're telling it to output a commonjs2 library component! (That's important).

Add a watcher in your config/dev.exs to pick up changes. According to our webpack server config we're looking for a file at web/static/js/server_render.js so lets make that.

export default require('../elm/Todo.elm');

That's it. That will export Elm for us with the Todo app.

Add a watcher to pick up the changes:

  watchers: [
    {"node", ["node_modules/webpack/bin/webpack.js", "--watch-stdin", "--progress", "--colors"]},
    {"node", ["node_modules/webpack/bin/webpack.js", "--watch-stdin", "--progress", "--colors", "--config", "webpack.server.config.js"]},
  ]

If you restart your server now you should see two bundles built. You should see a file in priv/server/js/main.js

Phew. Almost there.

Start your IO servers

Lets add the IO servers now.

Install std_json_io into your Phoenix application.

mix.exs

def deps do
  [#snip
   {:std_json_io, "~> 0.1.0"},
  ]
end

def applications do
  [applications: [...., :std_json_io]]
end

Create the supervisor lib/phoenix_elm/elm_io.ex

defmodule PhoenixElm.ElmIo do
  use StdJsonIo, otp_app: :phoenix_elm, script: "elm-stdio"
end

And add it to your supervisor tree in your app file lib/phoenix_elm.ex

def start(_type, _args) do
  #snip

  children = [
    #snip
    supervisor(PhoenixElm.ElmIo, []),
  ]
  opts = [strategy: :one_for_one, name: PhoenixElm.Supervisor]
  Supervisor.start_link(children, opts)
end

One more thing, we'll watch the generated server bundle for changes in dev so it reloads. config/dev.exs

config :phoenix_elm, PhoenixElm.ElmIo,
  watch_files: [Path.join(__DIR__, "../priv/server/js/main.js") |> Path.expand]

Ok nearly done. We need to now make our call. We'll just use the PageController. Update the routes to forward to it.

forward "/", PageController, :index

Update the PageView file to add a render method.

defmodule PhoenixElm.PageView do
  use PhoenixElm.Web, :view

  def render("index.html", options) do
    data = %{ getStorage: nil }

    opts = %{
      path: "./priv/server/js/main.js",
      component: "Todo",
      render: "embed",
      id: "elm-main",
      data: data,
    }

    result = PhoenixElm.ElmIo.json_call!(opts)

    render "single_page.html", html: result["html"], data: data
  end
end

Note that we're using the embed method. I haven't got to the bottom of it yet, but the fullscreen method doesn't wire up your listeners properly. The embed seems to work just fine though.

Ok so nearly there. We need to create our single_page.html template, and update our layout and app.js file.

single_page.html

<%= raw @html %>
<script>window.APP_DATA = <%= raw Poison.encode!(@data) %>;</script>

web/templates/layout/app.html.eex

  <body>
    <%= render @view_module, @view_template, assigns %>
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>

And finally our app.js

import "phoenix_html"
import Elm from '../elm/Todo.elm'
Elm.embed(Elm.Todo, document.getElementById('elm-main'), APP_DATA);

In the template we splatted the params as APP_DATA. Ok so now if you restart the server and load the page, you should have server rendered Elm!