Serverless Web Architecture with Firebase
Cut out the middle man
For a couple of years, I sensed that I was missing something in web architecture. I was hearing more and more that I may not need that web server that played such an important role in my standard design. You know the server I'm talking about. The one that handles authentication, the one that maintains sessions, the one that serves up static assets like CSS and images. The one that proxies to your backend server. I was hearing more and more people say that you could simply have a backend API server and your Javascript app. But for me, this seemed impossible.
You see, like many others, I learned how to build Javascript web apps with Backbone.js. And Backbone seems to presuppose a middle tier web server. From the Backbone documentation (March, 2016):
So Backbone expects you to use your middle tier server - your Rails, PHP, Node, or whatever server - to assist your client code. This is a really nice architecture. Your Javascript app initializes almost instantaneously. And if your model design isn't overly complicated, it's not hard to implement. In addition, that middle tier server is probably using a framework that makes auth and session management easy.
But you know what else is nice?
- Having one less class of servers to maintain.
- Having one less code base to maintain.
- Having an architecture that mirrors the one needed for a mobile app.
- Having an architecture that scales simply and horizontally.
In short, cutting out the middle tier - the one between the client code and the backend API - could conceivably save lots of time and money.
I realized that this no-middle-tier architecture was becoming much more common early in 2015 when I was experimenting with Ember. It seemed like the Ember ecosystem was steering me toward deploying to a CDN with Ember CLI and using Fastboot to deal with the initial data load. Ember seemed to presuppose just the opposite of Backbone - that there would be no middle tier web server.
Similar buzz about Lamda/Cognito/S3 AWS architecture confirmed my sense that this was at least worth checking out. I decided my reword would be serverless. No more middle tier web server.
But what about sessions and auth?
Re: sessions - that's easy: there typically are no sessions with this architecture. This architecture usually involves using a horizontal scaling backend. In practice, this means that every request to the backend has enough information in the headers to identify the requester and gather up any data that would otherwise be stored in a session.
Re: auth - the backend will need some way of exchanging credentials from the user for a token or cookie that can be sent with every request. The token or cookie is the identifier used to retrieve information about the requester and authorize the request.
But what about the backend and database?
This post is supposed to be about server-less architecture, right? What about that backend API server? And what about the datastore?
Well, it turns out we can get rid of those too.
In October, 2014, I heard an episode of Javascript Jabber that discussed Backend as a Service (BAAS) providers. Unlike Platform as a Services like Heroku or Google App Engine, you don't run your own code on your BAAS provider. BAAS platforms typically give you an API with which you can save and fetch data and authenticate users. For reword, this was all I needed.
Firebase seemed like a great choice. And in addition to providing a datastore and auth service, Firebase also provides free file hosting. So my HTML, JS, and CSS can live there too.
So, no servers. JS, CSS, and HTML hosted on Firebase hosting. And Firebase BAAS for auth and the datastore API.
Working with Firebase
Firebase provides a npm module for interacting with their API. Once you understand Firebase's tree-based data structure, the client library is pretty straightforward.
I load data into my Redux store like this.
import Firebase from 'firebase';
const firebase = new Firebase(`https://${config.firebaseApp}.firebaseio.com`);
...
// ask for all data under "phrases"; the "value" event is fired when the data arrives; we only need to respond to it once
firebase.child('phrases').once('value', (data) => {
// now that we have the data, dispatch an action to the Redux store
store.dispatch({
type: 'PHRASE_ADD_MULTIPLE',
phrases: _map(data.val(), (p, id) => {
return Object.assign({}, p, {id});
})
});
});
And I send data to Firebase like this.
if (phrase.length) {
// push creates a new entity
firebase.child('phrases').push({
words: phrase
}).then((entity) => {
// keys are a crucial part of the Firebase data model
dispatch({
type: 'PHRASE_ADD',
id: entity.key(),
words: phrase
});
});
}
All in all, I had very little problem understanding Firebase's data model and using the client library.
Using Firebase does require you to figure out the relationships between your data entities in advance. This is obviously a good practice for any datastore, but because there is no server code between the client and the data, you can't easily cover over convoluted relationships. Firebase has a good guide on how to do structure your data. I found myself reorganizing the data structure as I figured out how my app would work.
Firebase auth
Firebase provides a few different mechanisms for authenticating users. I opted for OAuth via Github. To log in, a user is redirected to Github to give permission, then back to the app. After logging in, the user's information is available to Firebase client.
The code to direct the user to Github is pretty simple.
firebase.authWithOAuthRedirect("github", (err) => {
if (err) {
firebase.unauth();
}
});
The code used to check for an active session is not too bad either. You can see that I am dispatching an ADD_USER action here.
firebase.onAuth((authData) => {
if (authData) {
const user = {
id: authData.uid,
provider: authData.provider,
name: authData.github.displayName,
image: authData.github.profileImageURL
};
store.dispatch({
type: 'USER_ADD',
user: user
});
}
});
Firebase security
Firebase presents a novel solution for security. Security rules are listed in a declarative way, with special variables that allow for precise definitions. In addition, your security rules can be defined in your app's Firebase dashboard, or they can be stored as part of your codebase. Once I figured out how my rules should work, I opted for the latter.
My security rules. Hopefully the comments help.
{
"rules": {
"words": {
".read": true,
// Only I can create words!
".write": "auth.uid == 'github:686913'"
},
"users": {
"$user_id": {
".read": true,
".write": "$user_id === auth.uid"
}
},
"phrases": {
".read": true, // anyone can request a listing of all phrases
"$phrase_id": {
// !data.exists() - writing new entry
// data.child('user').val() === auth.id - the user making the call owns this
// auth required, anybody can write new entry; deletes and updates require matching user
".write": "auth !== null && (!data.exists() || data.child('user').val() === auth.uid)"
}
}
}
}
Final verdict
Firebase is great. I would definitely use it again. Of course, your project needs to be a good fit. If you need complicated processing around your backend data, you should look elsewhere. But if your data needs are relatively straightforward and you want give serverless architecture a try, look no further than Firebase!