When I started designing the tech stack for Reviewable I knew I wanted something lightweight that would allow a lone developer (me!) to put together a high quality, modern web app quickly. Most of my past experience had been with traditional client-server architectures communicating via a RESTful API, but the overhead of designing and maintaining a URL space, a complex database schema, the obligatory glue code between them on both client and server, sounded unappealing. And this is even before considering fun stuff like security, scalability, and collaboration.
As it happens I’d recently built a prototype for another project on top of Firebase and was surprised with how easy it was to work with the platform and how fast the app came together. While I initially had some qualms about whether it was mature enough to stand up to the rigors of long-term use in production, I had faith in the team and figured that shipping an MVP quickly trumped other concerns. So I decided to give it a try.
The first order of business was to set up some way to serve the app. Firebase is well-suited to a single page app (SPA) architecture, where a static bootstrap page on the client executes JavaScript to grab data from a server and renders HTML templates in the browser. Because the bootstrap page is static there's no need for a seperate application server, so all files can be served from a CDN for low latency and scalability.
I had previously tried a number of CDN solutions but never found a good combination of features and price. I saw that Firebase recently launched their integrated hosting feature with built-in support for SSL, cache control, and instantaneous file invalidation and decided to give it a try. The best part was that Firebase Hosting also supported URL path rewriting—a necessity for a modern app that uses the HTML5 History API to control the URL, while still requiring URLs to load the same static bootstrap page.
As an example, whether you visit Reviewable at https://reviewable.io or its demo code review at https://reviewable.io/reviews/Reviewable/demo/1, the same HTML page is initially sent to the browser before being populated with data from Firebase. This is easy to achieve with Firebase Hosting using a firebase.json configuration file like this:
https://reviewable.io
https://reviewable.io/reviews/Reviewable/demo/1
firebase.json
{ "firebase": "reviewable", "rules": "rules.json", "rewrites": [ {"source": "**", "destination": "/index.html"} ] }
This worked great for staging and production, but what about development? I didn't want to deploy the site to test it every time I made a small tweak, since even a few seconds' delay would disrupt the development flow. And a normal development HTTP server like python -m SimpleHTTPServer or node http-server would only serve files that directly matched the URL structure, unable to deal with path rewrites.
python -m SimpleHTTPServer
node http-server
To solve the problem, I quickly built the firebase-http-server tool that understands the configuration file and mimics how Firebase Hosting works. It's fast and dead simple to use, defaulting to reading the settings from a firebase.json file in the current directory:
npm install -g firebase-http-server firebase-http-server # load your site from http://localhost:8080
Presto, you have a running server that respects all your redirect, rewrite and header rules. The emulation isn’t perfect but it’s close enough for development work, and changes to the configuration or source files take effect instantaneously.
Another critical part of every Firebase application is writing the security rules that determine read/write access to the datastore and validate your data. Having all the rules in a central location and enforced in the datastore itself is a great idea, and much easier to deal with (and audit) compared to manual checks or annotations spread throughout your codebase. However, the native JSON format of the rules is not very human-friendly at scale, so I was looking for a solution somewhere between Firebase’s existing rules and the experimental Blaze Compiler they recently released. This inspired me to build Fireplan, an open source tool that uses a YAML syntax similar to Blaze together with Firebase’s established hierarchical rule structure.
Here's a fragment of Reviewable's actual security rules written in Fireplan syntax:
root: repositories: $ownerName: $repoName: .read: canPullRepo($ownerName, $repoName) core: connection: required oneOf('active', 'draining', 'closed') connector: userKey # null if connection closed current: openPullRequestsCount: required number && next >= 0 pullRequests: $pullRequestId: string functions: - userKey: string && isUser(next) - isUser(userKey): root.users.hasChild(userKey) - canPullRepo(owner, repo): auth.uid != null && root.users[auth.uid] != null && sanitize(root.users[auth.uid].core.public.username.val().toLowerCase()) == owner || root.queues.permissions[makePermissionKey(owner, repo, '*')].verdict.pull == true || auth.uid != null && root.queues.permissions[ makePermissionKey(owner, repo, auth.uid)].verdict.pull == true - makePermissionKey(owner, repo, uid): owner + '|' + repo + '|' + uid - sanitize(key): key.replace('\\', '\\5c').replace('.', '\\2e').replace('$', '\\24') .replace('#', '\\23').replace('[', '\\5b').replace(']', '\\5d') .replace('/', '\\2f')
Now this may look rather imposing—the function definitions in particular are pretty complex—but look at what it expands to in the native JSON format when compiled with Fireplan using fireplan rules.yaml:
fireplan rules.yaml
{ "rules": { "repositories": { "$ownerName": { "$repoName": { "core": { "connection": { ".validate": "newData.val() == 'active' || newData.val() == 'draining' || newData.val() == 'closed'", "$other": {".validate": false} }, "connector": { ".validate": "newData.isString() && root.child('users').hasChild(newData.val())", "$other": {".validate": false} }, ".validate": "newData.hasChildren(['connection'])", "$other": {".validate": false} }, "current": { "openPullRequestsCount": { ".validate": "newData.isNumber() && newData.val() >= 0", "$other": {".validate": false} }, ".validate": "newData.hasChildren(['openPullRequestsCount'])", "$other": {".validate": false} }, "pullRequests": { "$pullRequestId": { ".validate": "newData.isString()", "$other": {".validate": false} } }, ".read": "auth.uid != null && root.child('users').child(auth.uid).val() != null && root.child('users').child(auth.uid).child('core').child('public').child('username').val().toLowerCase().replace('\\\\', '\\\\5c').replace('.', '\\\\2e').replace('$', '\\\\24').replace('#', '\\\\23').replace('[', '\\\\5b').replace(']', '\\\\5d').replace('/', '\\\\2f') == $ownerName || root.child('queues').child('permissions').child($ownerName + '|' + $repoName + '|' + '*').child('verdict').child('pull').val() == true || auth.uid != null && root.child('queues').child('permissions').child($ownerName + '|' + $repoName + '|' + auth.uid).child('verdict').child('pull').val() == true", "$other": {".validate": false} } } } } }
Gotta love those quadruple backslashes! This rule complexity would be difficult to maintain in large apps like Reviewable, but luckily Firebase picked a good set of core rule semantics which made it easy to add syntactic sugar on top.
The Firebase ecosystem is still growing, but thus far I have not had occasion to regret my choice of platform. Sure it may need the occasional extra tool or library, but it’s still easy to achieve a development velocity that you could only dream of with traditional architectures.
If you have any feedback on Reviewable or any of my Firebase open source projects, I’d love to hear your thoughts. You can find me on Twitter at @reviewableio or email me at piotr@reviewable.io.