Running a Serverless Node.js HTTP Server on AWS and Azure
Posted on
The newly introduced cloud.HttpServer in Pulumi makes it easy to serve a standard Node.js HTTP server as a serverless API on any cloud platform. This new API brings together the flexibility and rich ecosystem of Node.js HTTP servers, the cost and operational simplicity of serverless APIs, and the multi-cloud authoring and deployment of Pulumi. In this post, we walk through some of the background on why we introduced this new API and how it fits into the Node.js HTTP ecosystem.
Almost 10 years ago, Node.js was introduced, helping to usher in an era of server-side JavaScript development. From the beginning, Node.js made it simple and easy to stand up an HTTP server and to start listening and responding to requests in an environment that felt natural to people already comfortable working with JavaScript. From the earliest commits this was a core focus, and from early on Node.js exposed the initial JavaScript API for creating an HTTP Server. That API evolved over time to become the well known HTTP module that is the backbone of so many projects.
The approach taken by Node.js was to provide an API, http.createServer, to easily stand up a reliable and performant HTTP server, but it left the processing and control of those actual HTTP conversations up to a simple callback function that a user of the API would provide. This callback function itself was very simple, with the entire API having the form:
http.createServer((req, res) => ...);
With this a developer could now easily plug in a function that would be
called with the information about an incoming request
in the
callback’s first parameter, and which could supply the appropriate
response
through the callback’s second parameter. This approach
allowed Node.js to focus on being simple and easy to get the server
stood up, while being relatively unopinionated giving the developer a
lot of flexibility in terms of how they would actually process requests
and responses.
Thanks to this split, the Node.js ecosystem then came forward to provide more structured design patterns to handle those requests and responses to make things more manageable for developers. These communities provided ‘middleware’ APIs that could bridge between this extremely basic entry-point into one more structured and opinionated, but simpler to understand and work with. Middleware libraries like Express.js allowed one to write simple route-handlers like:
const app = express();
// GET method route
app.get('/', function (req, res) {
res.send('GET request to the homepage')
})
// POST method route
app.post('/', function (req, res) {
res.send('POST request to the homepage')
})
http.createServer(app).listen();
These route-handlers also have extensible middleware points to plug in other libraries (like Body Parser, Passport, and many others). This large effort across the entire ecosystem has made the task of serving websites using JavaScript dramatically simpler, and has helped propel a huge amount of popularity in this space.
Interestingly enough though, the Cloud space has gone a slightly different route here (pun intended). While cloud providers like AWS and Azure provide ways to both create HTTP servers and to use Node.js, they offer a very different shape altogether for processing HTTP messages. For example, in AWS, one needs to first set up an API gateway. That gateway will then have ‘methods’ defined on it which point at ‘Lambda Proxies’ to finally process the request. The connected AWS Lambda then will manage the request and response in a decidedly AWS-specific manner. For example, instead of working with IncomingMessage and ServerResponse like one normally would with Node.js, AWS specific messages like so are the required. Responding to messages with your own data uses a very different form as well. Code has to be written like so:
exports.handler = function(event, context, callback) {
// process AWS 'event' using information from AWS 'context'
// ...
// build response
var response = {
statusCode: responseCode,
headers: {
"x-custom-header" : "my custom header value"
},
body: JSON.stringify(responseBody)
};
callback(null, response);
}
Azure follows its own distinct style as well for processing HTTP messages. These approaches end up working, but often feel decidedly un-Node.js-like. They’re full of cloud-provider-specific values and behaviors, and they end up making it more difficult to work with the existing ecosystem of middleware components out there. (Note that Google Cloud HTTP Functions actually do natively support the Node.js request/response pattern!).
We thought this was a place we could definitely help Pulumi applications
with a simpler approach for these cloud ecosystems. To further this
goal, we’ve created a new API called
cloud.HttpServer.
HttpServer
is a Pulumi
Resource,
but is designed to work well with the existing large middleware
ecosystem out there. And critically, the same HttpServer API can be
implemented consistently on AWS, Azure and GCP - so you can write once
and deploy to any cloud. The core API shape that accomplishes this is:
// Factory function for creating a requestListener function. The returned function is the same
// callback function that would be passed to http.createServer. See:
// https://nodejs.org/api/http.html#http_http_createserver_options_requestlistener for more details.
export type RequestListenerFactory = () => (req: http.IncomingMessage, res: http.ServerResponse) => void;
export interface HttpServerConstructor {
/** * @param createRequestListener Function that, when called, will produce the [[requestListener]] * function that will be called for each http request to the server. The function will be * called once when the module is loaded. As such, it is a suitable place for expensive * computation (like setting up a set of routes). The function returned can then utilize the * results of that computation. */
new (name: string, createRequestListener: RequestListenerFactory, opts?: pulumi.ResourceOptions): HttpServer;
}
The idea here is that a Pulumi app can now simply take the previous Express.js example and rewrite it as:
// The `() => { ... }` callback will actually become the code inside an AWS Lambda!**
const server = new cloud.HttpServer("myserver", () => {
const app = express();
// GET method route
app.get('/', function (req, res) {
res.send('GET request to the homepage')
})
// POST method route
app.post('/', function (req, res) {
res.send('POST request to the homepage')
})
return app;
});
The majority of this code is identical to the original Node.js form. The
only small difference is the creation of the cloud.HttpServer resource
(which is needed for Pulumi to track work and dependencies) and the
returning of the ‘app’ variable instead of directly calling
http.createServer
.
Pulumi will take that code and convert the JavaScript callback^**^ into all the cloud-provider-specific resources necessary to make this work. For example, on AWS, an API Gateway and Lambda. The API Gateway will be properly setup to communicate with the Lambda function, and the Lambda function will properly handle AWS’s specific input/output forms and will map them along so they can properly work with Node.js’ http library expectations.
Effectively, the above callback will get translated into the following code that is uploaded to AWS Lambda.
// index.js
module.exports = WrapAwsEventAndContextAndCallBackAndForwardTo((() => {
const app = express();
// GET method route
app.get('/', function (req, res) {
res.send('GET request to the homepage')
})
// POST method route
app.post('/', function (req, res) {
res.send('POST request to the homepage')
})
return app;
})())
In other words, that factory function will get called, returning the expected requestListener function. Pulumi will then inject the call to the helper that will wrap that function with the right translation code to map from the AWS specific inputs to the expected Node.js form, and from the Node.js outputs to the AWS expected forms.
Right now, this functionality has been enabled for AWS and Azure in our
@pulumi/cloud-aws
and @pulumi/cloud-azure
packages (support for GCP
is on the roadmap!). This means that the above code will run properly on
either platform without consumers having to write platform specific code
or figure out how they would need to translate between the two. And,
because of the translation from AWS/Azure specific APIs to the common
Node.js API, you can now use virtually any middleware components
effectively on either platform. Currently, this only works if you are
writing a Pulumi application. However, we are
tracking the work
to extract the specialized code we’ve written into its own library. Once
that work is done, you’ll then be able to easily do something like
create an Azure
FunctionApp
manually, setup the appropriate
bindings,
and then upload your code which will setup the appropriate Azure
endpoint, but then call into this library to translate to the Node.js
http form that will allow you to then code things up as simply as if
this was just an Express.js app!
^**^
One thing glossed over was how Pulumi knows how to convert a
JavaScript callback into an AWS Lambda or an Azure FunctionApp. Stay
tuned for more on this subject! It’s an incredibly cool part of Pulumi,
and we can’t wait to dive deeper into that functionality and how it can
make it simple and clear to create an entire Cloud App in one single
package, even including the code in-line that will end up executing in
the cloud at runtime! That magic, along with powerful components like
the new HttpServer API can help make cloud applications dramatically
simpler to write and maintain. Happy coding!
You can dig in to serverless coding with Pulumi here, and join us on Wednesday 3rd October at 11am PDT to hear more about serverless programming with Pulumi on our YouTube live stream.