Serverless Middleware

Dynamic CSS and more at the CloudFront Edge

Brett Neese
Frontend Weekly
6 min readJan 31, 2018

--

I recently helped my team transition from running our single-page JavaScript application that had been running in a simple Express Docker container to simply Webpacking up and hosting the static assets on S3. This was a big transition for us — without Docker containers, all of the concerns I had as our ops guy over managing our frontend servers simply went away (yay!)

But there were a few pieces we simply couldn’t do in the browser. One of those pieces was a custom skinning engine we had built that allowed us to white-label our application by injecting custom CSS stylesheets for various users. Before, we ran a bunch of API calls server-side on page render to inject custom CSS rules based on some information we had stored in the database. That’s slow, janky, and it won’t work on S3. We thought about doing that work on the client as well, but it’d always be less than ideal as that’s not how browsers are supposed to deal with style information. Another, similar issue we had is that we wanted to be able to redirect users without an authentication token to our authentication service.

This is where Lambda@Edge becomes very useful. Essentially, Lambda@Edge allows you to write Lambda functions that run in response to CloudFront events, modifying headers, responses, etc — anything you might otherwise do in Express-style middleware. In essence, this means it’s possible to write middleware for statically-hosted SPAs that run entirely on CloudFront edges. The only problem is that Lambda@Edge is fairly hard to test locally. Unit tests can solve some of this problem, but it’s also very useful to run all of the “middleware” locally.

So I came up with a pretty simple solution to this problem: a little npm module I wrote called cloudfront-express.

Basically, this module absracts away most of the logic involved in parsing and sending responses into something that should work in a variety of environments; taking care of most of the implementation details and allowing the developer to focus on business logic. This way, the same "serverless" middleware functions can be invoked locally — by adding them to a local Webpack config.js’s DevServer.setup()— and in production by simply tying it in to the request with Lambda@Edge, and they'll behave the same way in both places -- developers then know exactly how the middleware affects the request/response cycle, because they can run the middleware right on their machine as if it weren't a serverless application.

An Example Implementation: Dynamic CSS

Background

I had already help split out our authentication into it’s own microservice a while ago: basically, the user gets redirected to the authentication service when they try and load the SPA; the authentication service generates a JWT; and the frontend uses that to make backend API calls. I determined that we wanted to use Lambda@Edge to generate a CSS file dynamically on a per-user basis, by simply including a tag that referenced an org.css file to be generated by CloudFront edge, but I really didn't want to have the Lambda@Edge function making its own set of API calls and wasn't even entirely sure how to pass user information along to Lambda in the first place.

Cookies seemed like the obvious answer, but I wasn’t sure a resource requested with the <link> tag actually included cookies in the request. Luckily, a quick experiment proved that link tags will indeed pass along cookie information to the origin server in the request for the CSS file -- and since we can set cookies across domains via JavaScript, that seemed ideal. When the user is redirected from authentication, we just slip in a cookie, and then Lambda@Edge can read it and send down the custom CSS.

While originally I had planned on simply using the existing JWT we set on the page, I instead opted for modifying our authentication service to return a full user object as a JSON object — complete with a skin property containing the appropriate CSS properties we allow our users to customize, and set that as a cookie. Since the authentication API already has and (obviously) needs a database connection to authenticate the users, this would be much faster than talking to the API in our Lambdas in order to generate this code.*

Implementation

There were no existing patterns for Lambda@Edge functions beyond some basic AWS docs, so I created one — and then used the same pattern for both the redirect function and the dynamic CSS function, and will probably do something very similar for any future “middleware” functions we build. You can see both of our examples here; but line-by-line, the patterns looks something like:

First, I export a PathPattern. The reason for this is so that the development middleware and the CloudFront "middleware" are both using the same route patterns: in fact, in our serverless.yml, I literally use this PathPattern to configure the CloudFront CacheBehavior, and it also gets used in the dev server middleware configuration as well:

I should caution that CloudFront’s CacheBehavior PathPattern property and Express’s paths work and act quite a bit differently (in terms of wildcards and the like), but they’re compatible enough for our use case, and I plan to only do things that are supported by both backends.

Then,

all of our Lambda@Edge functions have this populateResponse function. Basically it takes an Express or Express-like (in the case of Lambda@Edge) request object and returns a somewhat generic response object, which then eventually gets fed into either Lambda@Edge's callback, or as an Express response depending on environment.

That happens at the bottom:

This sets up the behavior for the two environments: Lambda calls the “handler” function, while Express calls the “middleware” function. I’ll detail what happens in the cloudfront-express module in a future post, but the tl;dr version is that those functions, respectively, take both Express and Lambda requests and responses and abstract them into a simple generic object. That way, you can, for instance, set response.status or read request.headers and it'll work the same both places.

I thought about encapsulating this even further and simply having one entrypoint, but this was a case where we decided that it was probably better to have less magic.

Conclusion

That’s basically all you need to write a Lambda@Edge-powered serverless “middleware” for a Webpacked SPA. I won’t go into deployment details in this post, but I rely on the Serverless Framework to do much of the heavy lifting.

The essential lesson from this project is for maximum developer productivity when using Lambda@Edge, don’t mix implementation details with business logic (as is done in literally all of AWS’s examples). Use cloudFrontExpress -- or your own library -- to abstract business logic away and allow you to run the same code both locally development environment and in the cloud.

Even though it’s a bit abstracted and confusing at first, Lambda@Edge allows for incredibly powerful “server”-side “middleware”, rather than baking this code into a special server which requires a lot of maintenance. Deploying a SPA like I did on S3 makes deployment and iteration a breeze, it’s incredibly fast, and nearly infinitely scalable without any work on my end as an ops guy — and, with Lambda@Edge, I can still manage to get the advantages of server rendering. Plus, it is totally possible to easily cache the results of your middleware functions on CloudFront itself — right at the edge; the next step for us, for instance, is figuring out how to server-render some of our data components on Lambda@Edge to speed up page load.

For a frontend application, Lambda@Edge gives you the best of both worlds — the simplicity of a static webpage, and the power of middleware — and the pattern I devised and explained above has made it very easy for our team to build and deploy solutions like these.

Brett Neese is a DevOps engineer at HBK Engineering, where he helps cloud GIS solutions for the civil engineering industry, helping make cities smarter and more connected. You can contact him at brett@neese.rocks.

* While this does mean it’d be trivial for someone to man-in-the-middle their own user object and edit their skin on the frontend, this wasn't a problem we are particularly worried about since it's entirely visual and not exposing any sensitive information. It also means a little bit of extra overhead on each request, but ultimately the convenience and speed of this new system trumps any extra roundtrip bits that are needed to complete a page-load.

--

--