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
andelm
for the full build to succeed, so we install those globally
- we need the commands
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.
- this moves the built assets out of the
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.