Rip It Up
Starting Over With Serverless Functions
This week I've had the fun and frustration of building my first app in a while. Life as a developer advocate has periods of busy social energy around conferences as well as quiet focus to work on content. Prior roles I've had before had the majority of one or the other, referring specifically to the manic calendar of being a team lead and engineering manager compared to the quiet focus required for developers.
So why fun? This week I’ve had time to build an app and revisit my youth by rebuilding my master’s thesis game Fu-Finder. Getting in the zone and tinkering with a coding project scratches the engineer itch I often get when I’ve not written any code in a while. So the euphoria of getting something working has been great. Kind of like the joy of winning with my favourite character in the Street Fighter games as a kid.
But it’s also been frustrating! I’ve had to make a few big design changes along the way. I ripped out a library I was using for building the search experience after finding the documentation difficult to navigate and that I spent more time getting their components to do what I wanted than just writing my own. I ripped out my server implementation as well when I started to think about hosting. My background in banking means thinking logic resides in a continually running server, which doesn’t really work when I have to consider utilization and cost. I also ripped out my monorepo manager when realising my serverless functions don’t need to reside in a dedicated server app, and that running them locally was just not happening. Error after error left me feeling rather Blanka-esque (cheers for the inspiration Keith!)
Here I walk through using serverless functions, specifically using Netlify, alongside Elasticsearch JavaScript Client and React, to show how I’ve simplified a complex monorepo implementation into a far simpler project.
What the serverl*ss?
Serverless has been a long-standing buzzword that I’ve always found slightly misleading. Traditionally, web developers deploy a running process on server hardware somewhere that will remain alive and respond to calls made from another component, most notably a frontend component as in this case.
Serverless functions do not mean that the box is gone, but that it’s abstracted away by a cloud provider. This can make it easier for new developers to get started, but for more experienced engineers like myself, it requires unlearning the server habit.
A serverless function is an ephemeral process that spins up temporarily to perform our logic before standing down. It’s also stateless, meaning that the data doesn’t persist and we as developers need to ensure our data is stored elsewhere such as a database, Elasticsearch cluster, Kafka broker or queue.
Prerequisites
All of the code discussed in this post is available here on GitHub. Additionally, you will need to have the following elements installed:
- NPM package manager for installation and package management.
- Netlify CLI for local running of serverless functions.
It’s worth noting there are loads of different tutorials online for writing Netlify functions. I took inspiration from two articles from CSS Tricks and Netlify.
Configuration
Serverless providers, including Netlify which I am using here, require configuration to pick up your functions. Here we specify a netlify.toml
file to explain how the site is built, and deployed (if you're not making use of the GitHub connection and configuration via the UI which I did).
There are many options covered in the documentation that you can specify. I found I needed to specify build
options to have my React app running locally alongside the functions.
[build]
command = "npm run build" # React app build command
functions = "src/functions/" # functions location
publish = "build" # static content path
A common option that I didn’t use is redirects
, which allows you to specify redirection logic. This is particularly useful in the Netlify case where the path by default resolves to .netlify/functions/<my-url-path>
. When building an enterprise application, or one for something other than a pet project, you probably don't want to expose the provider. One cool thing you can do is stitch old and new together using the :splat
operator:
[[redirects]]
from = ".netlify/functions/*"
to = "/api/:splat"
status = 200
force = true
The above configuration would mean automatic redirection from a default path such as .netlify/functions/document
to api/document
. It's not something I explicitly needed for a prototype, but I've included it here as I expect to use it for future projects.
Function Writing
Initially, I took the approach of wrapping my Express server in a single function using the serverless-http
module, meaning that my paths would be one level below. So for example, my /document
endpoint to get an individual document from Elasticsearch by ID sat alongside was present in the functions/api.ts
file, resulting in the function api/document
. Conceptually I found this difficult to rationalise in my head as I was making changes.
API endpoint design best practices recommend making URLs easier to read and making use of nouns where you can. Adhering to this means avoiding deep paths and using separate handlers. Below is the function used in my game through POST .netlify/functions/document
.
// Note: Netlify deploys this function at the endpoint
// /.netlify/functions/document
export async function handler(event, context) {
const request = convertRequest(event.body);
if (!request.documentID) {
return generateResponse(500, 'Document ID not specified');
}
try {
const results = await getDocumentByID(request.documentID);
return generateResponse(200, results);
}
catch(e) {
console.log(e);
return generateResponse(500, e);
}
Awaiting Workloads
The stateless nature of these functions means that data must be persisted somewhere and accessed from a store. Here I have made use of a utility using the Elasticsearch JavaScript Client to perform the required searches. The example below makes use of an to return an individual document.
import { Client } from "@elastic/elasticsearch";
const client = new Client({
cloud: { id: cloudID },
auth: { apiKey: apiKey },
});
export function getDocumentByID(documentID: string): Promise<any> {
return client.search({
index: index,
query: {
ids: {
values: documentID,
},
},
});
}
Unlike traditional server implementations where to paraphrase Beyoncé, you shoulda put a spinner on it to communicate a long-running delay we consider necessary, we need to be mindful of the return time from data services. Here I’m making use of the free functions from Netlify, which have memory and usage limits depending on your plan. Limit responses to essential fields and documents to ensure response within limits, as well as to avoid the dreaded Elasticsearch circuit-breaker errors.
Request and Response Handling
The generateResponse
function is a helper used to generate the response in the expected format. It's important to use HTTP error codes to denote success and failure and allow for error detection and handling on the UI, which is why success is denoted by 200: OK compared to error 500: Internal Server Error. The generateResponse
method ensures the response content is stringified for return to the UI, along with the status code in the expected int
format:
export function generateResponse(statusCode: number, data: any) {
return {
statusCode: statusCode,
body: JSON.stringify(data)
};
};
It’s worth noting that the response also needs to be parsed into valid JSON using the JSON.parse
JavaScript method, which I've extracted to another utility method convertRequest
:
export function convertRequest(data: any) {
return JSON.parse(data);
}
Running Locally and Deploying
One of the reasons I’ve avoided serverless functions for so long was the ambiguity of how to run and validate locally. As an advocate running demos locally is a useful backup for unreliable internet, and as a frontend engineer in banking running the app locally was imperative for debugging and E2E test development. Pushing changes and finding issues when deploying to Netlify is not ideal.
Let’s recall our netlify.toml
, and the command
configuration:
[build]
command = "npm run build" # React app build command
functions = "src/functions/" # functions location
publish = "build" # static content path
Kicking off the Netlify CLI using netlify dev
will spin up not just my React frontend as I have the command configured, but also each of my serverless functions. Huzzah!
Deployment is pretty easy given the GitHub integration that Netlify has, especially when our netlify.toml
already has the build command
and publish
options configured. I'd recommend following the guide in their blog.
Conclusions
Despite ripping out my Nx monorepo and replacing the Express app with functions being a frustrating and confusing ride this week, I’m glad I did it. One of the best things about my job as an advocate is that I am already learning new things and building my skills. My experience in banking tech was that building financial software doesn’t always grant you the luxury of playing with shiny new tech toys.
All my fellow software engineers will attest to the satisfaction of getting something working and deployed. But there’s one other fantastic yet underrated feeling: when you get to delete redundant code. With 31,672 additions and 25,091 deletions, I’m satisfied that I have a better-structured project that will be far easier for others to understand.
Happy serverless coding!
Originally published at http://carlyrichmond.com on July 14, 2023.