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.
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.
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