I had this goal of making a web app that was very fast, stable and easy to deploy. This is what I was hoping to accomplish: strongly typed server-side and client-side languages (Rust & Elm respectively), push-button deployments to the cloud with free hosting initially, and sub-second response times for API calls and page loads. With this setup, I think I'm well on my way to accomplishing each of these.

Folder structure

My goal for the folder structure was to be super simple and easy to navigate. I wanted only 2 folders in the root dir: api/ and web/. The former will contain the Rust application and the latter, the nodejs/Elm application. Upon the npm run build command in the web folder, I wanted a single html, js, and css file to be produced, then the Rust server would host them.

Rust and Rocket (web framework)

I chose Rocket as my web framework because I like the ease of use and I was kind of familiar with it. One drawback (that always seems to be mentioned when talking about Rocket) is that it requires Rust nightly, but that's totally ok with me with this experiment.

Inside $/api/ I created a new Rust appliation. I added the dependencies for Rocket and soon had a server listening on localhost:8000. This isn't really a primer for getting a Rust/Rocket or Elm app started, so refer to other documentation if you have questions there.

My Rust web server needs the ability to route smartly. Any route starting with /api/ needs to go to the restful api endpoints:

// MOUNT POINT ON ROCKET IN MAIN():
.mount("/api", routes![api])

// HANDLER:
#[get("/")]
fn api() -> &'static str {
    "Hello, from the api!"
}

Anything starting with /assets/ needs to serve files from the assets folder (like css, javascript, icons, images, and any other files):

// MOUNT POINT ON ROCKET IN MAIN():
.mount("/assets", routes![assets])

// HANDLER:
#[get("/<path..>")]
fn assets(path: PathBuf) -> Option<NamedFile> {
    NamedFile::open(Path::new("static/assets").join(path)).ok()
}

And every other path needs to serve up the index.html file as compiled by the web app since Elm will do the frontend routing (when I get there). So url.com/foo/bar will serve up exactly the same html file as url.com/thing:

// MOUNT POINT ON ROCKET IN MAIN():
.mount("/", routes![spa_path, spa_empty])

// HANDLER:
#[get("/")]
fn spa_empty() -> Option<NamedFile> {
    NamedFile::open(Path::new("static/index.html")).ok()
}

#[get("/<_path..>", rank = 2)]
fn spa_path(_path: PathBuf) -> Option<NamedFile> {
    NamedFile::open(Path::new("static/index.html")).ok()
}

A couple notes about that last bit. Maybe it's my lack of knowledge but I couldn't get a catch-all route with Rocket that would match when there was some path and when there was no path, so I needed to make two routes. Secondly, notice the , rank = 2 part above. This is needed because the assets path conflicts with this path, so we need to tell Rocket that this is the second priority for matching.

Elm web app

I used brunch as my nodejs build tool. After I got an Elm application setup and running with it (several tutorials out there for this), I needed to make it work with my Rust web server. This entailed writing out the compiled js, css, and index.html to a place where Rust expects it to be (specfically $/api/static/, with the js living in $/api/static/assets/js/ and similar for css). Here are some notable changes to the brunch-config.js file with my best explanation for each (note, I'm not a brunch expert, some was trial and error).

The files need to be put in the assets/ folder, they cannot be stright up in static/ so I added that here:

  files: {
    javascripts: {
      joinTo: "assets/js/app.js"
    },
    stylesheets: {
      joinTo: "assets/css/app.css"
    }
  },

I need the compiled assets to go to where Rust expects them. Note the public prop:

  paths: {
    // Dependencies and current project directories to watch
    watched: ["static", "scss", "js", "vendor", "elm"],
    // Where to compile files to
    public: "../api/static"
  },

This one was kind of trial and error, but I wasn't getting the javascript to execute correctly so I needed to add the assets folder here so it knew where to find the module:

  modules: {
    autoRequire: {
      "assets/js/app.js": ["js/app"]
    }
  }

The index.html file is one of the simplest you'll see!

<!DOCTYPE>
<html>
    <head>
        <link rel="stylesheet" href="/assets/css/app.css" charset="utf-8">
    </head>
    <body>
        <div id=elm-container></div>
        <script src="/assets/js/app.js"></script>
    </body>
</html>

Lastly, I did need a bit of js to kick the Elm application off. This lives in $/web/js/app.js and gets compiled into the final asset after the Elm app is compiled into its own main module in javascript:

import Elm from './main';
const elmDiv = document.querySelector('#elm-container');

if (elmDiv) {
  Elm.Main.embed(elmDiv);
}

This setup also allows me to use cargo run in one terminal and npm run dev in another terminal (ran from their respective directories) to have live reloading anytime I change a web source file (js, css, elm, or add static stuff like images).

Deployment with Heroku

This part was probably the trickiest, and it really wasn't all that terrible. However, I have to warn you, I'm not totally happy with my solution as there is a bit of a hack, but I was stubborn in keeping the file heirarchy like it is so my hand was forced.

Getting our server running

My first step was to get the Rust buildpack working for heroku (instructions in link). I had to do some playing with the RustConfig file and the Procfile. In the RustConfig file, we have to let the buildpack know what version of Rust we need and where the Rust app sits:

VERSION=nightly-2018-06-20
BUILD_PATH=api/

In the Procfile we need to set up a couple environment variables for Rocket and tell heroku where our web server executable is:

web: ROCKET_PORT=$PORT ROCKET_ENV=prod ./api/target/release/playground

After doing this, pushing to heroku results in the Rocket server running, and hitting /api gives us our api message!

Compiling Elm

This is the first time I've needed multiple buildpacks for heroku. I added the nodejs buildpack as the first in the queue like this heroku buildpacks:add --index 1 heroku/nodejs. Ok hack warning - those of you with the purest eyes, look away. The nodejs buildpack requires a package.json file in the root of the repo. "What?" you might say, "I thought our nodejs app lived in $/web/!" Well you're correct. However we need to sort of spin up a secondary very light nodejs app whose only job is to initiate the build of our main nodejs app. This secondary package.json looks like this:

{
  "name": "playground",
  "version": "1.0.0",
  "scripts": {
    "postinstall": "npm install --prefix web && npm install --global brunch elm && cd web && brunch build --production && mv ../api/static .."
  },
  "cacheDirectories": ["web/node_modules"]
}

The postinstall gets ran after heroku runs npm install. Here is the purpose of this root app. That is one wild command. let's break it down:

  • npm install --prefix web
    • this runs npm install from our web/ folder.
  • npm install --global brunch elm
    • we need the commands brunch and elm for the full build to succeed, so we install those globally
  • cd web && brunch build --production
    • a bit more self explanitory - this just kicks of the build command for brunch, producing our js, css, and index.html.
  • mv ../api/static ..
    • this moves the built assets out of the $/api/ folder and puts them at the root. This is needed because when heroku starts our Rust server, it is started out of $/ not $/api/ like it would be when a dev is working.

Erm, I think that's it, and I hope I didn't forget anything. Here is a link to the specific commit containing the code during the writing of this post.