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!