Context

In a Node.js application, it's common to need access to certain shared values throughout your application, for example, the request or response objects in a server application. However, passing these values through the component tree can be cumbersome. In Velocity, we address this problem using Node.js' AsyncLocalStorage API.

What is AsyncLocalStorage?

AsyncLocalStorage is a class introduced in Node.js v13.10.0 as an experimental feature and it's now stable since Node.js v14. It's part of the async_hooks module and it's used to create asynchronous state within callbacks and promise chains. This allows you to keep track of the context of an operation across asynchronous operations, even if you're dealing with nested callbacks or promise chains.

Consider the following code snippet:

import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

asyncLocalStorage.run(new Map(), () => {
  asyncLocalStorage.getStore().set('key', 'value');

  someAsyncOperation(() => {
    // Even though we are in a new async operation, we can still access the store
    console.log(asyncLocalStorage.getStore().get('key')); // logs 'value'
  });
});

In the example above, we create an instance of AsyncLocalStorage and then call the run method with a new Map as the store and a callback function. Inside the callback function, we can add key-value pairs to the store and then access them in nested async operations.

One thing to note is that AsyncLocalStorage can have a slight performance impact, especially when used inside loops. Over time, improvements have been made to the API to enhance its performance, but caution should still be taken to avoid potential performance bottlenecks.

Using AsyncLocalStorage in Velocity

In Velocity, we use AsyncLocalStorage to create a context that is accessible throughout our application. The context is defined in src/utilities/context.ts:

import { AsyncLocalStorage } from 'node:async_hooks';
import { FastifyReply, FastifyRequest } from 'fastify';

export const asyncLocalStorage = new AsyncLocalStorage();

interface ContextRequest<P = unknown, Q = unknown, B = unknown> extends FastifyRequest {
  query: Q;
  params: P;
  body: B;
}

interface Context<Q = unknown, P = unknown, B = unknown> {
  request: ContextRequest<Q, P, B>;
  reply: FastifyReply;
}

type HashMap = Record<string, string | string[] | undefined>;

export const getContext = <P = HashMap, Q = HashMap, B = HashMap>() =>
  asyncLocalStorage.getStore() as Context as Context<P, Q, B>;

export const getRequest = <P = HashMap, Q = HashMap, B = HashMap>() => getContext<P, Q, B>().request;

export const getReply = () => getContext().reply;

We initialize this context with the request and reply objects from Fastify in src/utilities/router/page-handling.ts:

.addHook('onRequest', (request: FastifyRequest<{ Params: Record<any, string> }>, reply: FastifyReply, done) => {
  asyncLocalStorage.run({ request, reply }, done);
})

In this snippet, we use Fastify's addHook method to call a function whenever a request is made. Inside the function, we call asyncLocalStorage.run to create a new context store for each request, initializing it with the current request and reply objects. This ensures that each request has its own isolated context, preventing any possible cross-request data leaks.

Once the context is set, it can be accessed from anywhere in the application using the getRequest and getReply functions defined in context.ts.

This context can be used in JSX components. For example, let's assume we have a component where we need to access the request URL:

import { getRequest } from '~/utilities/context';

const SomeComponent = () => {
  const request = getRequest();

  return <div>Current URL: {request.url}</div>;
};

In this example, we import the getRequest utility from the context and use it to access the request object inside the component. This removes the need to manually pass the request and reply objects down the JSX tree.

Powered by Doctave