[es|en]

Request-scoped variables in Express

One common requirement in web applications is keeping track of the currently logged in user throughout the execution of a request. While this is fairly simple to do in web servers where each request has its own execution environment (be it a thread or a process), Node's single-process single-thread model makes it less straightforward - but it can be achieved with the help of continuation-local-storage.

Say we have a secure Express application. At some point, a middleware is going to authenticate the user and save it somewhere for future use (again, I'm going to refer to my earlier post about using JWT with Express since I had to implement this on the same app). In this case, after authentication is done, we'll have the user available on req.user. Good so far, but the request object is only available to middlewares and route handlers, and to use it further down the chain we'd need to pass it all the way down through function parameters. In some cases this might make sense, when conceptually each of those functions is related to the user executing them. But here the user was needed to make a permissions check on the opposite boundary of the application - before making a request to an external system, to make sure they had the right permissions to do it.

So if passing the user as a parameter is not conceptually sound, then how do we get it all the way through to the code making the requests to the external service? Here is where continuation-local-storage helps. Let's see first how to use it and then try to understand exactly how it's doing it. First, we need to create a namespaces in which to store our data. Even though the namespace will be the same for every request, the values will be scoped for each chain of functions.

const { createNamespace } = require('continuation-local-storage')

const session = createNamespace('request')

// Assuming we have our express app in `app`
app.use((req, res, next) => {
  session.run(() => next())
})

// And once we have authenticated the user
app.use((req, res, next) => {
  session.set('currentUser', req.user)
  next()
})

See that I called the namespace request. continuation-local-storage isn't really specific for a web server environment - it works for any chain of functions calling each other - so I gave it a name representing exactly what it was being used for in this case.

Then we have to define a couple of middlewares. For continuation-local-storage to work, it needs to wrap the chain of function calls on the namespace's #run method. That means that that first middleware has to be defined as early as possible in the middleware chain. It's only inside the call stack of that method that values can be set or retrieved from the namespace.

The second middleware, once the user is available somewhere (req.user in this case), sets it in the namespace. And that's all that's needed for the setup.

The usage is way simpler since we'll already be inside a #run chain, so we just have to get a reference to the namespace and fetch the value from it:

const { getNamespace } = require('continuation-local-storage')

function getCurrentUser() {
  return getNamespace('request').get('currentUser')
}

As long as getCurrentUser can be traced all the way back to the #run method, then everything put on the namespace will be available.

How does it work?

Personally I was curious on how they achieved this behavior. How do they keep variables across functions calling functions calling functions, many of those asynchronous - going to the OS and back?

The answer is async-listener. async-listener is a package which allows us to set callbacks for the lifecycle of asynchronous operations. So when an asynchronous operation is queued, when it fails, and right before or after our callbacks are called, we can add custom behavior. continuation-local-storage uses it to keep track of each execution context and make the namespace values available again once we're back from the asynchronous operation.

Then, how does async-listener does it? The answer is simple, although the implementation is fairly complex: wrap every asynchronous function in node to be able to provide those callbacks. You can take a look at the code to see how they did it on each case, with some of the solutions being quite involved (see how they wrap promises!).

Links

powered byDisqus